[Markdown] Tools for working with agents.
refactor Group skills under skills/ and fix reference paths
Move the RAIL skill under skills/rail/ so it sits beside steering/, giving each content type its own top-level directory. Update the README paths and the global-install symlink target to skills/rail. Keep the copyrighted ASD-STE100 PDF under steering/ste100/references/ and gitignored.
Changed files
README.org
@@ -11,12 +11,13 @@
11
11
12
12
#+begin_example
13
13
agent-skills/
14
Removed:
├── rail/ Skill — Rolling Action Item List
15
Removed:
│ ├── SKILL.md
16
Removed:
│ ├── rail-tools.el
17
Removed:
│ ├── rail-tests.el
18
Removed:
│ ├── run-tests.sh
19
Removed:
│ └── references/
14
Added:
├── skills/
15
Added:
│ └── rail/ Skill — Rolling Action Item List
16
Added:
│ ├── SKILL.md
17
Added:
│ ├── rail-tools.el
18
Added:
│ ├── rail-tests.el
19
Added:
│ ├── run-tests.sh
20
Added:
│ └── references/
20
21
└── steering/
21
22
└── ste100/ Steering — Simplified Technical English
22
23
├── ste100.md
@@ -30,7 +31,7 @@
30
31
copy, so =git pull= in this repo updates every project at once.
31
32
32
33
#+begin_src sh
33
Removed:
ln -s ~/git/agent-skills/rail ~/.kiro/skills/rail
34
Added:
ln -s ~/git/agent-skills/skills/rail ~/.kiro/skills/rail
34
35
#+end_src
35
36
36
37
Confirm the link:
@@ -90,5 +91,5 @@
90
91
Each skill carries its own test runner. For RAIL:
91
92
92
93
#+begin_src sh
93
Removed:
~/git/agent-skills/rail/run-tests.sh
94
Added:
~/git/agent-skills/skills/rail/run-tests.sh
94
95
#+end_src
rail/SKILL.md
@@ -1,354 +0,0 @@
1
Removed:
---
2
Removed:
name: rail
3
Removed:
description: Manage a rolling action item list in a RAIL.org stream with dedicated Emacs MCP tools.
4
Removed:
---
5
Removed:
6
Removed:
# RAIL — Rolling Action Item List
7
Removed:
8
Removed:
## Overview
9
Removed:
10
Removed:
RAIL keeps every action item in one Org file, `RAIL.org`, at the root of
11
Removed:
a project. Dedicated Emacs tools write that file. The work follows three
12
Removed:
steps:
13
Removed:
14
Removed:
1. Capture the item at the top of the stream.
15
Removed:
2. Track its status while the work runs.
16
Removed:
3. Close it with commit evidence and test evidence.
17
Removed:
18
Removed:
The command for this skill is `/rail`.
19
Removed:
20
Removed:
## Usage
21
Removed:
22
Removed:
Use this skill when the user does one of these things:
23
Removed:
24
Removed:
- The user runs `/rail`.
25
Removed:
- The user states an action item, a feature request, a defect, or a
26
Removed:
change to make.
27
Removed:
- The user asks you to record or capture an item.
28
Removed:
- The user asks for the status of an item, or asks to change it.
29
Removed:
- The user asks you to close, cancel, or block an item.
30
Removed:
- The user asks what items are open.
31
Removed:
32
Removed:
Treat each prompt as one separate action item. Do not merge two items.
33
Removed:
Do not build, test, commit, or start an agent for a capture.
34
Removed:
35
Removed:
The current prompt is the action item:
36
Removed:
37
Removed:
$ARGUMENTS
38
Removed:
39
Removed:
If the prompt is empty, ask the user for one line that describes the
40
Removed:
item. Then stop. Capture the next prompt as the next item.
41
Removed:
42
Removed:
## Core Concepts
43
Removed:
44
Removed:
### The stream
45
Removed:
46
Removed:
`RAIL.org` is a flat stream. Each action item is a top-level heading.
47
Removed:
The newest item comes first. The file holds no container heading and no
48
Removed:
topical heading. Tags on the heading give the axes of the item. The
49
Removed:
position of a heading gives only its age.
50
Removed:
51
Removed:
### The file owns the vocabulary and the workflow
52
Removed:
53
Removed:
The tools carry no project vocabulary and no project workflow. The
54
Removed:
stream file owns both:
55
Removed:
56
Removed:
- The `#+TODO:` line declares the status keywords.
57
Removed:
- The `#+TAGS:` lines declare the tag vocabulary, grouped into axes.
58
Removed:
59
Removed:
The tools read both from the file. One tool file therefore serves every
60
Removed:
project. To change the states or the tags, edit the preamble of
61
Removed:
`RAIL.org`. Run `rail-inspect` to read the current vocabulary.
62
Removed:
63
Removed:
### The Org ID is the handle
64
Removed:
65
Removed:
Every item carries an Org ID. Each tool that changes an item needs that
66
Removed:
ID. Never select an item by its title text, because two titles can
67
Removed:
match. Run `rail-list` to get the ID of each item.
68
Removed:
69
Removed:
### Evidence, not silence
70
Removed:
71
Removed:
Three transitions demand a written reason or written evidence:
72
Removed:
73
Removed:
- `BLOCKED` needs a reason.
74
Removed:
- `CANCELLED` needs a reason.
75
Removed:
- `DONE` needs a commit hash and a test recap.
76
Removed:
77
Removed:
The tools refuse these transitions without that text. A stalled item or
78
Removed:
a dropped item therefore always states why.
79
Removed:
80
Removed:
### The tag vocabulary is grouped by axis
81
Removed:
82
Removed:
The `#+TAGS:` lines group the tags into named axes. A common shape holds
83
Removed:
a `Kind` axis and a `Scope` axis, plus optional axes. Each axis holds a
84
Removed:
short set of bare tags. Distinct words across axes keep a bare tag
85
Removed:
unambiguous. This is a convention, not a tool rule. The tool accepts any
86
Removed:
tag that a `#+TAGS:` line declares, in any axis the project chooses.
87
Removed:
88
Removed:
Infer the tags from the item text when you capture it. You can re-tag an
89
Removed:
item later with `rail-retag`, as its shape changes.
90
Removed:
91
Removed:
## Set up the stream file
92
Removed:
93
Removed:
A project needs one `RAIL.org` file at its root before the tools run.
94
Removed:
Create it with this preamble, then edit the axes to fit the project:
95
Removed:
96
Removed:
```org
97
Removed:
#+TITLE: Project action items
98
Removed:
#+TODO: TODO IN-PROGRESS TESTING TESTED BLOCKED | CANCELLED DONE
99
Removed:
#+TAGS: [ Kind : feat fix refactor chore docs ]
100
Removed:
#+TAGS: [ Scope : core app web ui ]
101
Removed:
#+TAGS: [ Impact : minor major ]
102
Removed:
103
Removed:
# Local Variables:
104
Removed:
# org-log-done: time
105
Removed:
# End:
106
Removed:
```
107
Removed:
108
Removed:
The `#+TODO:` keywords before the bar are open states. The keywords
109
Removed:
after the bar are closed states. The `org-log-done` local variable makes
110
Removed:
Org write a `CLOSED` timestamp on the transition to `DONE`, which
111
Removed:
`rail-complete` requires. A file with no `#+TAGS:` line accepts any tag.
112
Removed:
113
Removed:
## Install the tools once per session
114
Removed:
115
Removed:
The tools live in `rail-tools.el`, beside this `SKILL.md`. The skill can
116
Removed:
sit in one project, or in a shared global directory that serves every
117
Removed:
project. Load the tool file one time in the running Emacs. Use the
118
Removed:
generic `eval-elisp` tool for the load only.
119
Removed:
120
Removed:
Load the tool file from the directory of this `SKILL.md`, and pass the
121
Removed:
directory of the project you work in as the project root. These are two
122
Removed:
separate places. The tool file has one fixed home. The project root
123
Removed:
changes with each project.
124
Removed:
125
Removed:
```elisp
126
Removed:
(let ((tools "/absolute/path/to/this/skill/rail-tools.el")
127
Removed:
(project "/the/project/directory/you/work/in"))
128
Removed:
(unless (file-readable-p tools)
129
Removed:
(error "No RAIL tools at %s" tools))
130
Removed:
(load tools nil t)
131
Removed:
(setq rail-project-root (file-name-as-directory project)))
132
Removed:
```
133
Removed:
134
Removed:
Set `rail-project-root` to the project you work in. Every tool then uses
135
Removed:
that project by default, wherever the tool file itself lives. Do not
136
Removed:
write an absolute path from a home directory as a fixed constant in a
137
Removed:
committed file, because that path differs on every machine.
138
Removed:
139
Removed:
The tool file also runs an upward search for `RAIL.org` at load time, from
140
Removed:
its own directory and then from `default-directory`. That search finds the
141
Removed:
project only when the tool file sits inside the project. For a shared
142
Removed:
install, or when `default-directory` sits outside the project, set
143
Removed:
`rail-project-root` as shown, or pass `root` to each tool call.
144
Removed:
145
Removed:
Then verify that the tools are present. This expression returns the
146
Removed:
thirteen tool names:
147
Removed:
148
Removed:
```elisp
149
Removed:
(seq-filter (lambda (name) (string-prefix-p "rail-" name))
150
Removed:
(mcp-server-tools-list-names))
151
Removed:
```
152
Removed:
153
Removed:
Confirm that the tools resolve the right project. This expression returns
154
Removed:
the stream file path:
155
Removed:
156
Removed:
```elisp
157
Removed:
(alist-get 'file (json-parse-string
158
Removed:
(alist-get 'text (aref (mcp-server-tools-call
159
Removed:
"rail-inspect" nil) 0))
160
Removed:
:object-type 'alist))
161
Removed:
```
162
Removed:
163
Removed:
If the load fails, stop and report the problem. Do not edit `RAIL.org` as
164
Removed:
raw text instead.
165
Removed:
166
Removed:
### If the tools are not callable
167
Removed:
168
Removed:
The tools register inside Emacs. The MCP client lists its tools when it
169
Removed:
connects, so a mid-session load can leave the tools absent from your own
170
Removed:
tool list. In that case, call each tool through the dispatch path:
171
Removed:
172
Removed:
```elisp
173
Removed:
(mcp-server-tools-call "rail-list" '((state . "TODO")))
174
Removed:
```
175
Removed:
176
Removed:
This path runs the same handler as a direct tool call. To make the tools
177
Removed:
callable directly, load `rail-tools.el` from the Emacs init file.
178
Removed:
179
Removed:
### Why dedicated tools
180
Removed:
181
Removed:
The generic `eval-elisp` tool sends its code through the Emacs security
182
Removed:
form walker. The walker prompts for each file function, such as
183
Removed:
`find-file-noselect` and `save-buffer`. The RAIL tools register as normal
184
Removed:
MCP tools, and tool dispatch does not use the walker. The read-only tools
185
Removed:
also carry a `readOnlyHint` annotation, so the client can approve them
186
Removed:
without a prompt.
187
Removed:
188
Removed:
You **MUST** use these tools for every change to `RAIL.org`.
189
Removed:
190
Removed:
- You **MUST NOT** edit the file as raw text, because raw edits break the
191
Removed:
timestamps, the IDs, and the tag alignment.
192
Removed:
- You **MUST NOT** use a shell tool or a file tool for an item, because
193
Removed:
those tools apply no validation.
194
Removed:
195
Removed:
## The RAIL tools
196
Removed:
197
Removed:
Each tool accepts an optional `root` argument. `root` names the project
198
Removed:
directory that holds `RAIL.org`. When you omit `root`, the tools use
199
Removed:
`rail-project-root`. Set `root` only for a different project.
200
Removed:
201
Removed:
| Tool | Purpose | Read-only |
202
Removed:
|---|---|---|
203
Removed:
| `rail-inspect` | Report the keyword sequence and the tag axes. | Yes |
204
Removed:
| `rail-list` | List items, newest first, with ID, title, state, and tags. Accepts a `state` or `tag` filter. | Yes |
205
Removed:
| `rail-show` | Report one item in full: body, logbook, checklist, and result. | Yes |
206
Removed:
| `rail-verify` | Report the heading, state, `CLOSED` time, and tags of one item. | Yes |
207
Removed:
| `rail-capture` | Create a `TODO` item at the top of the stream. | No |
208
Removed:
| `rail-set-status` | Set the keyword of an item. Refuses `DONE`. | No |
209
Removed:
| `rail-block` | Set `BLOCKED` and record a required reason. | No |
210
Removed:
| `rail-cancel` | Set `CANCELLED` and record a required reason. | No |
211
Removed:
| `rail-check` | Add, toggle, or list the checklist items of one item. | No |
212
Removed:
| `rail-log` | Append a timestamped note to the `:LOGBOOK:` drawer. | No |
213
Removed:
| `rail-retag` | Replace the tags of an item with a validated set. | No |
214
Removed:
| `rail-set-result` | Write the `- result ::` line. | No |
215
Removed:
| `rail-complete` | Set `DONE` after it records the result evidence. | No |
216
Removed:
217
Removed:
Use `rail-show` to read one item, and `rail-retag` to re-tag it. These
218
Removed:
are the correct tools for those two actions, because the generic Emacs
219
Removed:
`org-*` tools cannot operate on this file.
220
Removed:
221
Removed:
For the exact arguments and the result shape of each tool, read
222
Removed:
`references/tools.md`.
223
Removed:
224
Removed:
## Step 1 — Capture the item
225
Removed:
226
Removed:
Run `rail-capture` with these arguments:
227
Removed:
228
Removed:
- `title` — a short imperative title under 60 characters.
229
Removed:
- `body` — the full item text.
230
Removed:
- `tags` — the tags that fit the item, from the file vocabulary.
231
Removed:
232
Removed:
The tool inserts the item at the top of the file. It records the capture
233
Removed:
time as an inactive `SCHEDULED` timestamp. It applies the tags, wraps the
234
Removed:
body to 72 columns, and assigns an Org ID. Report that ID to the user.
235
Removed:
236
Removed:
Keep the item text unchanged. If the text holds separate ideas, write the
237
Removed:
body as a list. Pass the body as plain text, because the tool wraps it.
238
Removed:
239
Removed:
If `rail-capture` rejects a tag, run `rail-inspect` to read the
240
Removed:
vocabulary. Then fix the tag and re-run the capture.
241
Removed:
242
Removed:
Then proceed to Step 2 when work starts on the item.
243
Removed:
244
Removed:
## Step 2 — Track the status
245
Removed:
246
Removed:
First find the item. Run `rail-list` to read the ID, the title, the
247
Removed:
state, and the tags of each item. Pass a `state` filter or a `tag` filter
248
Removed:
to narrow the list. Use the ID for every later call.
249
Removed:
250
Removed:
Read the current state with `rail-verify` before you change a keyword.
251
Removed:
Then pick the tool from the target state:
252
Removed:
253
Removed:
- Target an open keyword, such as `IN-PROGRESS` → `rail-set-status`
254
Removed:
- Target `BLOCKED` → `rail-block`, with a reason
255
Removed:
- Target `CANCELLED` → `rail-cancel`, with a reason
256
Removed:
- Target `DONE` → `rail-complete`, with commit and test evidence
257
Removed:
258
Removed:
Run `rail-verify` again after the change to confirm the keyword.
259
Removed:
260
Removed:
### Block or cancel with a reason
261
Removed:
262
Removed:
Run `rail-block` when work cannot continue. Run `rail-cancel` when you
263
Removed:
stop work on purpose. Each tool needs a `reason`, and writes it to the
264
Removed:
item.
265
Removed:
266
Removed:
You **MUST NOT** use `rail-set-status` here, because it records no reason.
267
Removed:
268
Removed:
### Split a large item with a checklist
269
Removed:
270
Removed:
Run `rail-check` when one item is large enough to track in parts:
271
Removed:
272
Removed:
- `action` `add` — append an unchecked item. Needs `item`.
273
Removed:
- `action` `toggle` — flip one matching item. Needs `item`.
274
Removed:
- `action` `list` — report the items and their state.
275
Removed:
276
Removed:
The tool keeps the items in a `Checklist [/]:` block in the body. The
277
Removed:
`[/]` cookie counts the completed items against the total. The items stay
278
Removed:
inside the one action item. They are not separate stream entries, and
279
Removed:
they carry no keyword of their own. For independent work, capture a new
280
Removed:
action item instead.
281
Removed:
282
Removed:
### Record progress in the logbook
283
Removed:
284
Removed:
Run `rail-log` with a `note` to record progress during a session. The
285
Removed:
tool prepends a timestamped item to the `:LOGBOOK:` drawer. The logbook
286
Removed:
is append-only. The tool never rewrites an earlier note, and never
287
Removed:
rewrites the body.
288
Removed:
289
Removed:
Then proceed to Step 3 when every closing criterion is met.
290
Removed:
291
Removed:
## Step 3 — Close the item
292
Removed:
293
Removed:
Check each criterion before you close an item. Copy this list and mark
294
Removed:
each item:
295
Removed:
296
Removed:
- [ ] The build passes.
297
Removed:
- [ ] The tests pass.
298
Removed:
- [ ] The files have the required format.
299
Removed:
- [ ] A commit exists.
300
Removed:
301
Removed:
If one criterion fails, do not close the item. Set the state with
302
Removed:
`rail-set-status` instead. Then fix the failure and re-run this check.
303
Removed:
304
Removed:
When every item is marked, run `rail-complete` with these arguments:
305
Removed:
306
Removed:
- `id` — the Org ID of the item.
307
Removed:
- `commit` — the commit hash of the completed work.
308
Removed:
- `tests` — a short test recap, for example `215 pass`.
309
Removed:
- `model` — optional. The agent that did the work.
310
Removed:
- `notes` — optional. A short tail, such as a root cause.
311
Removed:
312
Removed:
The tool writes the result line, sets `DONE`, then confirms that Org
313
Removed:
recorded the `CLOSED` timestamp. Org writes that timestamp, because the
314
Removed:
file sets `org-log-done` to `time`.
315
Removed:
316
Removed:
You **MUST NOT** write or edit that timestamp, because Org owns it.
317
Removed:
318
Removed:
The result line takes this form. It stays on one line, because the reader
319
Removed:
reads one line:
320
Removed:
321
Removed:
```org
322
Removed:
- result :: model=example-agent commit=3051af0 tests=243 pass; short root cause
323
Removed:
```
324
Removed:
325
Removed:
The item stays in place in the stream. There is no refile step.
326
Removed:
327
Removed:
Run `rail-verify` last. Verify that the state is `DONE` and that the
328
Removed:
`CLOSED` timestamp is present. To write the result line before you close
329
Removed:
the item, run `rail-set-result` on its own.
330
Removed:
331
Removed:
## Test the tools
332
Removed:
333
Removed:
The tools carry a test suite. Run it after any change to `rail-tools.el`:
334
Removed:
335
Removed:
```sh
336
Removed:
.kiro/skills/rail/run-tests.sh
337
Removed:
```
338
Removed:
339
Removed:
The suite needs no MCP framework and no configuration. Each test uses a
340
Removed:
temporary stream file, so the project stream file stays unchanged.
341
Removed:
342
Removed:
## Common mistakes
343
Removed:
344
Removed:
- **Editing the file as raw text.** The tools keep the timestamps, the
345
Removed:
IDs, and the tag alignment correct. Raw edits do not.
346
Removed:
- **Selecting an item by title.** Two titles can match. Use the Org ID
347
Removed:
from `rail-list`.
348
Removed:
- **Using `rail-set-status` for `DONE`.** That call fails by design. Use
349
Removed:
`rail-complete`, which demands the evidence.
350
Removed:
- **Writing a `CLOSED` timestamp.** Org writes it. The tool only confirms
351
Removed:
it.
352
Removed:
- **Merging two items.** Record each prompt as its own action item.
353
Removed:
354
Removed:
If you cannot complete an item, record the item and record the failure.
rail/rail-tests.el
@@ -1,481 +0,0 @@
1
Removed:
;;; rail-tests.el --- End-to-end tests for the RAIL tools -*- lexical-binding: t; -*-
2
Removed:
3
Removed:
;;; Commentary:
4
Removed:
5
Removed:
;; These tests exercise the RAIL handler functions directly. The MCP
6
Removed:
;; framework calls each handler with one alist of arguments, so a direct call
7
Removed:
;; follows the same path as a tool call. Each test runs against a temporary
8
Removed:
;; stream file, so no test touches the project stream file.
9
Removed:
;;
10
Removed:
;; Run the tests with the runner script in this directory:
11
Removed:
;;
12
Removed:
;; ./run-tests.sh
13
Removed:
14
Removed:
;;; Code:
15
Removed:
16
Removed:
(require 'ert)
17
Removed:
(require 'json)
18
Removed:
19
Removed:
;;; Fixtures
20
Removed:
21
Removed:
(defvar rail-tests--preamble
22
Removed:
(concat "#+TITLE: Test stream\n"
23
Removed:
"#+TODO: TODO IN-PROGRESS TESTING TESTED BLOCKED | CANCELLED DONE\n"
24
Removed:
"#+TAGS: [ Kind : feat fix refactor chore docs ]\n"
25
Removed:
"#+TAGS: [ Scope : core app web ui ]\n"
26
Removed:
"#+TAGS: [ Impact : minor major ]\n"
27
Removed:
"\n"
28
Removed:
"# Local Variables:\n"
29
Removed:
"# org-log-done: time\n"
30
Removed:
"# End:\n")
31
Removed:
"Preamble of the temporary stream file.
32
Removed:
The `#+TODO:' line gives the keyword sequence. The `#+TAGS:' lines give
33
Removed:
the tag vocabulary, grouped into axes. The local variable
34
Removed:
`org-log-done' makes Org write a CLOSED timestamp.")
35
Removed:
36
Removed:
(defun rail-tests--decode (json-string)
37
Removed:
"Return JSON-STRING decoded into Lisp with alists for objects.
38
Removed:
Decode JSON null to nil and JSON false to `:json-false', which are the
39
Removed:
same sentinels that the handlers encode from. A round trip therefore
40
Removed:
gives back the value that the handler started with."
41
Removed:
(json-parse-string json-string
42
Removed:
:object-type 'alist
43
Removed:
:null-object nil
44
Removed:
:false-object :json-false))
45
Removed:
46
Removed:
(defun rail-tests--call (handler args)
47
Removed:
"Call HANDLER with ARGS and return the decoded result.
48
Removed:
Signal an error when the handler reports one, so a failure is visible."
49
Removed:
(let ((result (rail-tests--decode (funcall handler args))))
50
Removed:
(when (alist-get 'error result)
51
Removed:
(error "Handler failed: %s" (alist-get 'error result)))
52
Removed:
result))
53
Removed:
54
Removed:
(defmacro rail-tests--with-stream (root &rest body)
55
Removed:
"Create a temporary project directory, bind ROOT to it, then run BODY.
56
Removed:
Delete the directory and its buffers after BODY."
57
Removed:
(declare (indent 1))
58
Removed:
`(let* ((,root (file-name-as-directory (make-temp-file "rail-test" t)))
59
Removed:
(file (expand-file-name rail-stream-file-name ,root))
60
Removed:
(enable-local-variables :all)
61
Removed:
(org-id-track-globally nil)
62
Removed:
(create-lockfiles nil))
63
Removed:
(unwind-protect
64
Removed:
(progn
65
Removed:
(with-temp-file file (insert rail-tests--preamble))
66
Removed:
,@body)
67
Removed:
(dolist (buf (buffer-list))
68
Removed:
(when (and (buffer-file-name buf)
69
Removed:
(string-prefix-p ,root (buffer-file-name buf)))
70
Removed:
(with-current-buffer buf (set-buffer-modified-p nil))
71
Removed:
(kill-buffer buf)))
72
Removed:
(delete-directory ,root t))))
73
Removed:
74
Removed:
(defun rail-tests--capture (root title tags &optional body)
75
Removed:
"Capture a request in ROOT with TITLE, TAGS, and optional BODY.
76
Removed:
Return the new entry's Org ID."
77
Removed:
(alist-get 'id (rail-tests--call
78
Removed:
#'rail-tools--capture-handler
79
Removed:
(list (cons 'root root)
80
Removed:
(cons 'title title)
81
Removed:
(cons 'tags tags)
82
Removed:
(cons 'body (or body "Request body."))))))
83
Removed:
84
Removed:
(defun rail-tests--file-text (root)
85
Removed:
"Return the text of the stream file in ROOT."
86
Removed:
(with-temp-buffer
87
Removed:
(insert-file-contents (expand-file-name rail-stream-file-name root))
88
Removed:
(buffer-string)))
89
Removed:
90
Removed:
;;; Root discovery
91
Removed:
92
Removed:
(ert-deftest rail-test-locate-root-finds-the-stream-file ()
93
Removed:
"`rail-locate-root' finds the root from a nested directory."
94
Removed:
(rail-tests--with-stream root
95
Removed:
(let ((nested (expand-file-name "a/b/c/" root)))
96
Removed:
(make-directory nested t)
97
Removed:
(should (equal (rail-locate-root nested) root)))))
98
Removed:
99
Removed:
(ert-deftest rail-test-locate-root-returns-nil-without-a-stream-file ()
100
Removed:
"`rail-locate-root' returns nil when no ancestor holds the file."
101
Removed:
(let ((empty (file-name-as-directory (make-temp-file "rail-empty" t))))
102
Removed:
(unwind-protect
103
Removed:
(should (null (rail-locate-root empty)))
104
Removed:
(delete-directory empty t))))
105
Removed:
106
Removed:
(ert-deftest rail-test-root-argument-overrides-the-default ()
107
Removed:
"An explicit `root' argument selects the file, not `rail-project-root'."
108
Removed:
(rail-tests--with-stream root
109
Removed:
(should (equal (rail-tools--file (list (cons 'root root)))
110
Removed:
(expand-file-name rail-stream-file-name root)))))
111
Removed:
112
Removed:
(ert-deftest rail-test-absent-root-uses-the-default ()
113
Removed:
"An absent `root' argument falls back to `rail-project-root'."
114
Removed:
(rail-tests--with-stream root
115
Removed:
(let ((rail-project-root root))
116
Removed:
(should (equal (rail-tools--file nil)
117
Removed:
(expand-file-name rail-stream-file-name root))))))
118
Removed:
119
Removed:
(ert-deftest rail-test-a-missing-directory-signals-an-error ()
120
Removed:
"A `root' that is not a directory signals an error."
121
Removed:
(should-error (rail-tools--file
122
Removed:
(list (cons 'root "/rail/no/such/directory")))))
123
Removed:
124
Removed:
;;; Capture
125
Removed:
126
Removed:
(ert-deftest rail-test-capture-creates-an-addressable-todo ()
127
Removed:
"Capture writes a TODO entry with an ID, a SCHEDULED time, and tags."
128
Removed:
(rail-tests--with-stream root
129
Removed:
(let* ((id (rail-tests--capture root "Add a widget" ["feat" "web"]))
130
Removed:
(entry (rail-tests--call #'rail-tools--show-handler
131
Removed:
(list (cons 'root root) (cons 'id id)))))
132
Removed:
(should (stringp id))
133
Removed:
(should (equal (alist-get 'state entry) "TODO"))
134
Removed:
(should (equal (alist-get 'heading entry) "Add a widget"))
135
Removed:
(should (equal (append (alist-get 'tags entry) nil) '("feat" "web")))
136
Removed:
(should (stringp (alist-get 'scheduled entry)))
137
Removed:
(should (null (alist-get 'closed entry))))))
138
Removed:
139
Removed:
(ert-deftest rail-test-capture-puts-the-newest-request-first ()
140
Removed:
"Capture inserts each new request above the previous request."
141
Removed:
(rail-tests--with-stream root
142
Removed:
(rail-tests--capture root "First request" ["feat" "core"])
143
Removed:
(rail-tests--capture root "Second request" ["fix" "app"])
144
Removed:
(let ((rows (rail-tests--decode
145
Removed:
(rail-tools--list-handler (list (cons 'root root))))))
146
Removed:
(should (equal (length rows) 2))
147
Removed:
(should (equal (alist-get 'title (aref rows 0)) "Second request"))
148
Removed:
(should (equal (alist-get 'title (aref rows 1)) "First request")))))
149
Removed:
150
Removed:
(ert-deftest rail-test-capture-rejects-a-tag-outside-the-vocabulary ()
151
Removed:
"Capture rejects any tag that the closed vocabulary does not hold."
152
Removed:
(rail-tests--with-stream root
153
Removed:
(let ((result (rail-tests--decode
154
Removed:
(rail-tools--capture-handler
155
Removed:
(list (cons 'root root)
156
Removed:
(cons 'title "Bad tags")
157
Removed:
(cons 'tags ["feat" "trivial"]))))))
158
Removed:
(should (string-match-p "Unknown tag" (alist-get 'error result))))))
159
Removed:
160
Removed:
;;; Status
161
Removed:
162
Removed:
(ert-deftest rail-test-set-status-moves-through-the-open-keywords ()
163
Removed:
"Set-status accepts each open keyword from the file's own sequence."
164
Removed:
(rail-tests--with-stream root
165
Removed:
(let ((id (rail-tests--capture root "Track status" ["feat" "core"])))
166
Removed:
(dolist (state '("IN-PROGRESS" "TESTING" "TESTED"))
167
Removed:
(let ((result (rail-tests--call
168
Removed:
#'rail-tools--set-status-handler
169
Removed:
(list (cons 'root root) (cons 'id id)
170
Removed:
(cons 'state state)))))
171
Removed:
(should (equal (alist-get 'state result) state))))
172
Removed:
(should (equal (alist-get 'state (rail-tests--call
173
Removed:
#'rail-tools--verify-handler
174
Removed:
(list (cons 'root root) (cons 'id id))))
175
Removed:
"TESTED")))))
176
Removed:
177
Removed:
(ert-deftest rail-test-set-status-refuses-done ()
178
Removed:
"Set-status refuses DONE, because completion needs result evidence."
179
Removed:
(rail-tests--with-stream root
180
Removed:
(let* ((id (rail-tests--capture root "Refuse done" ["feat" "core"]))
181
Removed:
(result (rail-tests--decode
182
Removed:
(rail-tools--set-status-handler
183
Removed:
(list (cons 'root root) (cons 'id id)
184
Removed:
(cons 'state "DONE"))))))
185
Removed:
(should (string-match-p "rail-complete" (alist-get 'error result))))))
186
Removed:
187
Removed:
(ert-deftest rail-test-block-and-cancel-record-a-reason ()
188
Removed:
"Block and cancel write the reason, so the decision is never silent."
189
Removed:
(rail-tests--with-stream root
190
Removed:
(let ((blocked (rail-tests--capture root "Blocked work" ["feat" "app"]))
191
Removed:
(dropped (rail-tests--capture root "Dropped work" ["feat" "ui"])))
192
Removed:
(rail-tests--call #'rail-tools--block-handler
193
Removed:
(list (cons 'root root) (cons 'id blocked)
194
Removed:
(cons 'reason "The route does not exist")))
195
Removed:
(rail-tests--call #'rail-tools--cancel-handler
196
Removed:
(list (cons 'root root) (cons 'id dropped)
197
Removed:
(cons 'reason "The user withdrew the request")))
198
Removed:
(let ((text (rail-tests--file-text root)))
199
Removed:
(should (string-match-p "- blocked :: The route does not exist" text))
200
Removed:
(should (string-match-p "- cancelled :: The user withdrew" text)))
201
Removed:
(should (equal "BLOCKED"
202
Removed:
(alist-get 'state (rail-tests--call
203
Removed:
#'rail-tools--verify-handler
204
Removed:
(list (cons 'root root)
205
Removed:
(cons 'id blocked))))))
206
Removed:
(should (equal "CANCELLED"
207
Removed:
(alist-get 'state (rail-tests--call
208
Removed:
#'rail-tools--verify-handler
209
Removed:
(list (cons 'root root)
210
Removed:
(cons 'id dropped)))))))))
211
Removed:
212
Removed:
(ert-deftest rail-test-block-requires-a-reason ()
213
Removed:
"Block refuses a blank reason."
214
Removed:
(rail-tests--with-stream root
215
Removed:
(let* ((id (rail-tests--capture root "Needs a reason" ["feat" "app"]))
216
Removed:
(result (rail-tests--decode
217
Removed:
(rail-tools--block-handler
218
Removed:
(list (cons 'root root) (cons 'id id)
219
Removed:
(cons 'reason " "))))))
220
Removed:
(should (string-match-p "reason" (alist-get 'error result))))))
221
Removed:
222
Removed:
;;; Checklist, logbook, and tags
223
Removed:
224
Removed:
(ert-deftest rail-test-checklist-adds-and-toggles-items ()
225
Removed:
"The checklist adds an item, then toggles it, and reports booleans."
226
Removed:
(rail-tests--with-stream root
227
Removed:
(let ((id (rail-tests--capture root "Split the task" ["feat" "core"])))
228
Removed:
(let ((added (rail-tests--call
229
Removed:
#'rail-tools--check-handler
230
Removed:
(list (cons 'root root) (cons 'id id)
231
Removed:
(cons 'action "add") (cons 'item "step one")))))
232
Removed:
(should (equal (alist-get 'done (aref (alist-get 'items added) 0))
233
Removed:
:json-false)))
234
Removed:
(let ((toggled (rail-tests--call
235
Removed:
#'rail-tools--check-handler
236
Removed:
(list (cons 'root root) (cons 'id id)
237
Removed:
(cons 'action "toggle") (cons 'item "step one")))))
238
Removed:
(should (eq (alist-get 'done (aref (alist-get 'items toggled) 0)) t)))
239
Removed:
(should (string-match-p "Checklist \\[1/1\\]" (rail-tests--file-text root))))))
240
Removed:
241
Removed:
(ert-deftest rail-test-log-appends-and-keeps-earlier-notes ()
242
Removed:
"The logbook keeps every note, newest first."
243
Removed:
(rail-tests--with-stream root
244
Removed:
(let ((id (rail-tests--capture root "Log progress" ["feat" "core"])))
245
Removed:
(rail-tests--call #'rail-tools--log-handler
246
Removed:
(list (cons 'root root) (cons 'id id)
247
Removed:
(cons 'note "First note")))
248
Removed:
(rail-tests--call #'rail-tools--log-handler
249
Removed:
(list (cons 'root root) (cons 'id id)
250
Removed:
(cons 'note "Second note")))
251
Removed:
(let* ((entry (rail-tests--call #'rail-tools--show-handler
252
Removed:
(list (cons 'root root) (cons 'id id))))
253
Removed:
(notes (append (alist-get 'logbook entry) nil)))
254
Removed:
(should (equal (length notes) 2))
255
Removed:
(should (string-match-p "Second note" (nth 0 notes)))
256
Removed:
(should (string-match-p "First note" (nth 1 notes)))))))
257
Removed:
258
Removed:
(ert-deftest rail-test-retag-validates-against-the-vocabulary ()
259
Removed:
"Retag replaces the tags, and refuses a tag outside the vocabulary."
260
Removed:
(rail-tests--with-stream root
261
Removed:
(let ((id (rail-tests--capture root "Retag me" ["feat" "core"])))
262
Removed:
(let ((result (rail-tests--call
263
Removed:
#'rail-tools--retag-handler
264
Removed:
(list (cons 'root root) (cons 'id id)
265
Removed:
(cons 'tags ["fix" "web" "minor"])))))
266
Removed:
(should (equal (append (alist-get 'tags result) nil)
267
Removed:
'("fix" "web" "minor"))))
268
Removed:
(let ((result (rail-tests--decode
269
Removed:
(rail-tools--retag-handler
270
Removed:
(list (cons 'root root) (cons 'id id)
271
Removed:
(cons 'tags ["nonsense"]))))))
272
Removed:
(should (string-match-p "Unknown tag" (alist-get 'error result)))))))
273
Removed:
274
Removed:
;;; Result and completion
275
Removed:
276
Removed:
(ert-deftest rail-test-set-result-writes-model-and-notes ()
277
Removed:
"The result line holds the model, the commit, the tests, and the notes."
278
Removed:
(rail-tests--with-stream root
279
Removed:
(let* ((id (rail-tests--capture root "Record a result" ["fix" "core"]))
280
Removed:
(result (rail-tests--call
281
Removed:
#'rail-tools--set-result-handler
282
Removed:
(list (cons 'root root) (cons 'id id)
283
Removed:
(cons 'commit "abc1234") (cons 'tests "246 pass")
284
Removed:
(cons 'model "test-agent")
285
Removed:
(cons 'notes "the sentinel was wrong")))))
286
Removed:
(should (equal (alist-get 'result result)
287
Removed:
"model=test-agent commit=abc1234 tests=246 pass; the sentinel was wrong")))))
288
Removed:
289
Removed:
(ert-deftest rail-test-set-result-omits-absent-optional-fields ()
290
Removed:
"The result line holds only the commit and the tests when nothing else is given."
291
Removed:
(rail-tests--with-stream root
292
Removed:
(let* ((id (rail-tests--capture root "Plain result" ["fix" "core"]))
293
Removed:
(result (rail-tests--call
294
Removed:
#'rail-tools--set-result-handler
295
Removed:
(list (cons 'root root) (cons 'id id)
296
Removed:
(cons 'commit "abc1234") (cons 'tests "246 pass")))))
297
Removed:
(should (equal (alist-get 'result result)
298
Removed:
"commit=abc1234 tests=246 pass")))))
299
Removed:
300
Removed:
(ert-deftest rail-test-set-result-replaces-an-earlier-line ()
301
Removed:
"A second result call replaces the earlier result line."
302
Removed:
(rail-tests--with-stream root
303
Removed:
(let ((id (rail-tests--capture root "Replace result" ["fix" "core"])))
304
Removed:
(dolist (commit '("aaa1111" "bbb2222"))
305
Removed:
(rail-tests--call #'rail-tools--set-result-handler
306
Removed:
(list (cons 'root root) (cons 'id id)
307
Removed:
(cons 'commit commit) (cons 'tests "1 pass"))))
308
Removed:
(let ((text (rail-tests--file-text root)))
309
Removed:
(should-not (string-match-p "aaa1111" text))
310
Removed:
(should (string-match-p "bbb2222" text))))))
311
Removed:
312
Removed:
(ert-deftest rail-test-complete-sets-done-with-result-and-closed ()
313
Removed:
"Completion writes the result, sets DONE, and confirms the CLOSED time."
314
Removed:
(rail-tests--with-stream root
315
Removed:
(let* ((id (rail-tests--capture root "Finish the work" ["feat" "core"]))
316
Removed:
(result (rail-tests--call
317
Removed:
#'rail-tools--complete-handler
318
Removed:
(list (cons 'root root) (cons 'id id)
319
Removed:
(cons 'commit "def5678") (cons 'tests "247 pass")
320
Removed:
(cons 'model "test-agent")))))
321
Removed:
(should (equal (alist-get 'state result) "DONE"))
322
Removed:
(should (string-match-p "commit=def5678" (alist-get 'result result)))
323
Removed:
(should (string-match-p "\\[.*\\]" (alist-get 'closed result)))
324
Removed:
(let ((entry (rail-tests--call #'rail-tools--verify-handler
325
Removed:
(list (cons 'root root) (cons 'id id)))))
326
Removed:
(should (equal (alist-get 'state entry) "DONE"))
327
Removed:
(should (stringp (alist-get 'closed entry)))))))
328
Removed:
329
Removed:
(ert-deftest rail-test-complete-refuses-a-file-without-closed-logging ()
330
Removed:
"Completion refuses to run when the file does not log a CLOSED time.
331
Removed:
Org must write the CLOSED timestamp, so the tool never writes it."
332
Removed:
(let* ((root (file-name-as-directory (make-temp-file "rail-nolog" t)))
333
Removed:
(file (expand-file-name rail-stream-file-name root))
334
Removed:
(enable-local-variables :all)
335
Removed:
(org-id-track-globally nil)
336
Removed:
(create-lockfiles nil)
337
Removed:
(org-log-done nil))
338
Removed:
(unwind-protect
339
Removed:
(progn
340
Removed:
;; This preamble holds no `org-log-done' local variable.
341
Removed:
(with-temp-file file
342
Removed:
(insert "#+TITLE: No logging\n"
343
Removed:
"#+TODO: TODO IN-PROGRESS | CANCELLED DONE\n"))
344
Removed:
(let ((id (rail-tests--capture root "No logging" ["feat" "core"])))
345
Removed:
;; Close the buffer, so the next open reads the local variables
346
Removed:
;; of the file as it now stands.
347
Removed:
(dolist (buf (buffer-list))
348
Removed:
(when (equal (buffer-file-name buf) file)
349
Removed:
(with-current-buffer buf (set-buffer-modified-p nil))
350
Removed:
(kill-buffer buf)))
351
Removed:
(let ((result (rail-tests--decode
352
Removed:
(rail-tools--complete-handler
353
Removed:
(list (cons 'root root) (cons 'id id)
354
Removed:
(cons 'commit "def5678")
355
Removed:
(cons 'tests "1 pass"))))))
356
Removed:
(should (stringp (alist-get 'error result)))
357
Removed:
(should (string-match-p "org-log-done"
358
Removed:
(alist-get 'error result))))))
359
Removed:
(dolist (buf (buffer-list))
360
Removed:
(when (and (buffer-file-name buf)
361
Removed:
(string-prefix-p root (buffer-file-name buf)))
362
Removed:
(with-current-buffer buf (set-buffer-modified-p nil))
363
Removed:
(kill-buffer buf)))
364
Removed:
(delete-directory root t))))
365
Removed:
366
Removed:
;;; Reading
367
Removed:
368
Removed:
(ert-deftest rail-test-list-encodes-an-absent-value-as-json-null ()
369
Removed:
"A heading without an ID reports JSON null, not the text \"null\"."
370
Removed:
(rail-tests--with-stream root
371
Removed:
(let ((file (expand-file-name rail-stream-file-name root)))
372
Removed:
(with-current-buffer (find-file-noselect file)
373
Removed:
(goto-char (point-min))
374
Removed:
(insert "* TODO Entry without an identifier :feat:core:\n")
375
Removed:
(save-buffer))
376
Removed:
(let* ((json (rail-tools--list-handler (list (cons 'root root))))
377
Removed:
(rows (rail-tests--decode json)))
378
Removed:
(should (null (alist-get 'id (aref rows 0))))
379
Removed:
(should-not (string-match-p "\"null\"" json))))))
380
Removed:
381
Removed:
(ert-deftest rail-test-list-filters-by-state-and-tag ()
382
Removed:
"The list filters narrow the result by TODO keyword and by tag."
383
Removed:
(rail-tests--with-stream root
384
Removed:
(let ((first (rail-tests--capture root "Filter one" ["feat" "core"])))
385
Removed:
(rail-tests--capture root "Filter two" ["fix" "web"])
386
Removed:
(rail-tests--call #'rail-tools--set-status-handler
387
Removed:
(list (cons 'root root) (cons 'id first)
388
Removed:
(cons 'state "IN-PROGRESS")))
389
Removed:
(let ((by-state (rail-tests--decode
390
Removed:
(rail-tools--list-handler
391
Removed:
(list (cons 'root root) (cons 'state "IN-PROGRESS")))))
392
Removed:
(by-tag (rail-tests--decode
393
Removed:
(rail-tools--list-handler
394
Removed:
(list (cons 'root root) (cons 'tag "web"))))))
395
Removed:
(should (equal (length by-state) 1))
396
Removed:
(should (equal (alist-get 'title (aref by-state 0)) "Filter one"))
397
Removed:
(should (equal (length by-tag) 1))
398
Removed:
(should (equal (alist-get 'title (aref by-tag 0)) "Filter two"))))))
399
Removed:
400
Removed:
(ert-deftest rail-test-inspect-reports-the-sequence-and-the-axes ()
401
Removed:
"Inspect reports the file's keyword sequence and the file's tag axes."
402
Removed:
(rail-tests--with-stream root
403
Removed:
(let* ((result (rail-tests--call #'rail-tools--inspect-handler
404
Removed:
(list (cons 'root root))))
405
Removed:
(keywords (append (alist-get 'todo_keywords result) nil))
406
Removed:
(axes (alist-get 'tags result)))
407
Removed:
(should (member "IN-PROGRESS" keywords))
408
Removed:
(should (member "DONE" keywords))
409
Removed:
;; The axes come from the file's own #+TAGS: lines, not from the code.
410
Removed:
(should (equal (mapcar #'car axes) '(kind scope impact)))
411
Removed:
(should (equal (append (alist-get 'impact axes) nil) '("minor" "major")))
412
Removed:
(should (equal (append (alist-get 'scope axes) nil) '("core" "app" "web" "ui"))))))
413
Removed:
414
Removed:
(ert-deftest rail-test-a-file-without-tags-accepts-any-tag ()
415
Removed:
"A file that declares no #+TAGS: vocabulary accepts any tag.
416
Removed:
The tool carries no vocabulary of its own, so an undeclared file places
417
Removed:
no restriction on the tags."
418
Removed:
(let* ((root (file-name-as-directory (make-temp-file "rail-notags" t)))
419
Removed:
(file (expand-file-name rail-stream-file-name root))
420
Removed:
(org-id-track-globally nil)
421
Removed:
(create-lockfiles nil))
422
Removed:
(unwind-protect
423
Removed:
(progn
424
Removed:
(with-temp-file file
425
Removed:
(insert "#+TITLE: No vocabulary\n"
426
Removed:
"#+TODO: TODO | DONE\n"))
427
Removed:
(let* ((cap (rail-tests--call
428
Removed:
#'rail-tools--capture-handler
429
Removed:
(list (cons 'root root)
430
Removed:
(cons 'title "Any tag is fine")
431
Removed:
(cons 'tags ["anything" "at" "all"]))))
432
Removed:
(id (alist-get 'id cap)))
433
Removed:
(should (stringp id))
434
Removed:
(should (equal (append (alist-get 'tags cap) nil)
435
Removed:
'("anything" "at" "all")))))
436
Removed:
(dolist (buf (buffer-list))
437
Removed:
(when (and (buffer-file-name buf)
438
Removed:
(string-prefix-p root (buffer-file-name buf)))
439
Removed:
(with-current-buffer buf (set-buffer-modified-p nil))
440
Removed:
(kill-buffer buf)))
441
Removed:
(delete-directory root t))))
442
Removed:
443
Removed:
;;; The full path
444
Removed:
445
Removed:
(ert-deftest rail-test-the-full-request-path-runs-end-to-end ()
446
Removed:
"One request moves from capture to DONE through every step."
447
Removed:
(rail-tests--with-stream root
448
Removed:
(let ((id (rail-tests--capture root "Ship the feature" ["feat" "web"]
449
Removed:
"Add a widget to the page.")))
450
Removed:
(rail-tests--call #'rail-tools--check-handler
451
Removed:
(list (cons 'root root) (cons 'id id)
452
Removed:
(cons 'action "add") (cons 'item "write the code")))
453
Removed:
(rail-tests--call #'rail-tools--set-status-handler
454
Removed:
(list (cons 'root root) (cons 'id id)
455
Removed:
(cons 'state "IN-PROGRESS")))
456
Removed:
(rail-tests--call #'rail-tools--log-handler
457
Removed:
(list (cons 'root root) (cons 'id id)
458
Removed:
(cons 'note "Started the work")))
459
Removed:
(rail-tests--call #'rail-tools--check-handler
460
Removed:
(list (cons 'root root) (cons 'id id)
461
Removed:
(cons 'action "toggle")
462
Removed:
(cons 'item "write the code")))
463
Removed:
(rail-tests--call #'rail-tools--set-status-handler
464
Removed:
(list (cons 'root root) (cons 'id id)
465
Removed:
(cons 'state "TESTED")))
466
Removed:
(rail-tests--call #'rail-tools--complete-handler
467
Removed:
(list (cons 'root root) (cons 'id id)
468
Removed:
(cons 'commit "0badc0de") (cons 'tests "3 pass")
469
Removed:
(cons 'model "test-agent")))
470
Removed:
(let ((entry (rail-tests--call #'rail-tools--show-handler
471
Removed:
(list (cons 'root root) (cons 'id id)))))
472
Removed:
(should (equal (alist-get 'state entry) "DONE"))
473
Removed:
(should (stringp (alist-get 'closed entry)))
474
Removed:
(should (string-match-p "commit=0badc0de" (alist-get 'result entry)))
475
Removed:
(should (equal (length (alist-get 'logbook entry)) 1))
476
Removed:
(should (eq (alist-get 'done (aref (alist-get 'checklist entry) 0)) t))
477
Removed:
(should (string-match-p "Add a widget" (alist-get 'body entry)))))))
478
Removed:
479
Removed:
(provide 'rail-tests)
480
Removed:
481
Removed:
;;; rail-tests.el ends here
rail/rail-tools.el
@@ -1,968 +0,0 @@
1
Removed:
;;; rail-tools.el --- RAIL MCP tools for RAIL.org -*- lexical-binding: t; -*-
2
Removed:
3
Removed:
;; Copyright (C) 2025
4
Removed:
5
Removed:
;; This file is NOT part of GNU Emacs.
6
Removed:
7
Removed:
;;; Commentary:
8
Removed:
9
Removed:
;; RAIL means "Rolling Action Item List".
10
Removed:
;;
11
Removed:
;; This file registers dedicated Emacs MCP tools for the RAIL skill so the
12
Removed:
;; agent does not run raw `eval-elisp' snippets for each capture, status
13
Removed:
;; change, log, result, or verification. Each operation becomes a named tool.
14
Removed:
;;
15
Removed:
;; RAIL.org is a flat stream. Each action item is a top-level heading, newest
16
Removed:
;; first, tagged from a vocabulary that the file itself declares. The file
17
Removed:
;; holds no container heading.
18
Removed:
;;
19
Removed:
;; The tools carry no project vocabulary and no project workflow. The stream
20
Removed:
;; file owns both. The `#+TODO:' line declares the status keywords. The
21
Removed:
;; `#+TAGS:' lines declare the tag vocabulary, grouped into axes. The tools
22
Removed:
;; read both from the file, so one tool file serves every project.
23
Removed:
;;
24
Removed:
;; Why dedicated tools instead of `eval-elisp'?
25
Removed:
;;
26
Removed:
;; The `eval-elisp' tool routes its argument through
27
Removed:
;; `mcp-server-security-safe-eval', whose form walker blocks or prompts for
28
Removed:
;; "dangerous" functions such as `find-file-noselect', `write-file', and
29
Removed:
;; `save-buffer'. With `mcp-server-security-prompt-for-permissions' set to t,
30
Removed:
;; every RAIL snippet triggers a minibuffer prompt.
31
Removed:
;;
32
Removed:
;; A registered MCP tool runs through `mcp-server-tools-call', which calls the
33
Removed:
;; handler function directly and does NOT pass through the form walker. The
34
Removed:
;; handlers below therefore run without the repeated security prompt. Each
35
Removed:
;; tool also carries MCP `annotations' so the MCP client can auto-approve the
36
Removed:
;; read-only tools.
37
Removed:
;;
38
Removed:
;; The tools operate only on the file "RAIL.org". They find that file
39
Removed:
;; under `rail-project-root', or under a caller-supplied project ROOT that
40
Removed:
;; overrides it. They never touch any other file.
41
Removed:
;;
42
Removed:
;; `rail-project-root' comes from an upward search for the stream file. The
43
Removed:
;; search starts at this file's own directory, then at `default-directory'. It
44
Removed:
;; assumes no directory layout, so this file needs no absolute path and it
45
Removed:
;; works on every machine.
46
Removed:
;;
47
Removed:
;; Install the tools once per Emacs session. Load this file, and the tools
48
Removed:
;; register themselves. With `mcp-server-emacs-tools-enabled' set to `all',
49
Removed:
;; which is the default, they appear in the MCP tool list at once.
50
Removed:
;;
51
Removed:
;; The MCP framework is a soft dependency. When the framework is absent, for
52
Removed:
;; example in a batch test run, this file still loads and every handler stays
53
Removed:
;; callable. Run the test suite with the run-tests.sh script beside this file.
54
Removed:
55
Removed:
;;; Code:
56
Removed:
57
Removed:
(require 'cl-lib)
58
Removed:
(require 'org)
59
Removed:
(require 'org-id)
60
Removed:
(require 'json)
61
Removed:
(require 'subr-x)
62
Removed:
63
Removed:
;; Load the MCP tool framework when it is available. When it is absent, for
64
Removed:
;; example in a batch test run, define the two symbols the registrations below
65
Removed:
;; need and discard each registration. The handler functions stay callable, so
66
Removed:
;; the test suite runs on any machine without the framework.
67
Removed:
(defconst rail-mcp-available (require 'mcp-server-tools nil t)
68
Removed:
"Non-nil when the Emacs MCP tool framework is available.")
69
Removed:
70
Removed:
(unless rail-mcp-available
71
Removed:
;; Define plain functions, never a struct. A stub struct would clobber the
72
Removed:
;; real slot layout if the framework loads later in the same session.
73
Removed:
(defun make-mcp-server-tool (&rest _args)
74
Removed:
"Return nil. The MCP framework is absent."
75
Removed:
nil)
76
Removed:
(defun mcp-server-register-tool (_tool)
77
Removed:
"Discard _TOOL. The MCP framework is absent."
78
Removed:
nil))
79
Removed:
80
Removed:
(defvar rail-stream-file-name "RAIL.org"
81
Removed:
"Name of the Org file that RAIL manages.")
82
Removed:
83
Removed:
(defvar rail-tools-path
84
Removed:
(let ((file (or load-file-name buffer-file-name)))
85
Removed:
(and file (expand-file-name file)))
86
Removed:
"Absolute path of this file, or nil when the path is unknown.")
87
Removed:
88
Removed:
(defun rail-locate-root (start)
89
Removed:
"Return the closest directory at or above START that holds the stream file.
90
Removed:
The stream file is `rail-stream-file-name'. Return nil when no
91
Removed:
ancestor directory holds that file."
92
Removed:
(let ((dir (and start (locate-dominating-file
93
Removed:
(file-name-as-directory (expand-file-name start))
94
Removed:
rail-stream-file-name))))
95
Removed:
(and dir (expand-file-name (file-name-as-directory dir)))))
96
Removed:
97
Removed:
(defvar rail-project-root
98
Removed:
(or (rail-locate-root (and rail-tools-path
99
Removed:
(file-name-directory rail-tools-path)))
100
Removed:
(rail-locate-root default-directory)
101
Removed:
(expand-file-name default-directory))
102
Removed:
"Default project directory that holds the RAIL stream file.
103
Removed:
The value comes from an upward search for `rail-stream-file-name',
104
Removed:
first from this file's own directory, then from `default-directory'.
105
Removed:
The search makes no assumption about the depth of this file in the
106
Removed:
project. Set this variable to override the search, or pass a `root'
107
Removed:
argument to any tool.")
108
Removed:
109
Removed:
;;; Helpers
110
Removed:
111
Removed:
(defun rail-tools--file (args)
112
Removed:
"Return the absolute path of the stream file for ARGS.
113
Removed:
ARGS may hold a `root' string that names the project directory. When
114
Removed:
`root' is absent, use `rail-project-root'. Signal an error when the
115
Removed:
selected root is not a directory."
116
Removed:
(let ((root (or (alist-get 'root args) rail-project-root)))
117
Removed:
(unless (and (stringp root) (> (length root) 0))
118
Removed:
(error "No project root: pass `root' or set `rail-project-root'"))
119
Removed:
(let ((dir (expand-file-name root)))
120
Removed:
(unless (file-directory-p dir)
121
Removed:
(error "Not a directory: %s" dir))
122
Removed:
(expand-file-name rail-stream-file-name dir))))
123
Removed:
124
Removed:
(defun rail-tools--buffer (file)
125
Removed:
"Return an org-mode buffer visiting FILE, creating it as needed."
126
Removed:
(let ((buf (find-file-noselect file)))
127
Removed:
(with-current-buffer buf
128
Removed:
(unless (derived-mode-p 'org-mode)
129
Removed:
(org-mode)))
130
Removed:
buf))
131
Removed:
132
Removed:
(defun rail-tools--goto-id (id)
133
Removed:
"Move point to the heading with Org ID in the current buffer.
134
Removed:
Signal an error when ID is not found."
135
Removed:
(let ((marker (org-id-find id 'marker)))
136
Removed:
(unless marker
137
Removed:
(error "Org ID not found: %s" id))
138
Removed:
(goto-char marker)))
139
Removed:
140
Removed:
(defun rail-tools--fill-body ()
141
Removed:
"Wrap the body of the entry at point to 72 columns.
142
Removed:
Fill every paragraph after the metadata (SCHEDULED line, property
143
Removed:
drawer) up to the next heading. Use `org-fill-paragraph' so Org
144
Removed:
list items and other structure fill correctly. Point must be on
145
Removed:
the entry heading."
146
Removed:
(let ((fill-column 72))
147
Removed:
(org-back-to-heading t)
148
Removed:
(let ((end (save-excursion (org-end-of-subtree t t) (point-marker))))
149
Removed:
;; Move past the heading and all metadata (planning line,
150
Removed:
;; property drawer, logbook) to the first line of body text.
151
Removed:
(org-end-of-meta-data t)
152
Removed:
;; Fill each body line. `org-fill-paragraph' fills the whole
153
Removed:
;; element and is idempotent, so stepping one line at a time is
154
Removed:
;; safe and does not overshoot a trailing paragraph.
155
Removed:
(while (< (point) end)
156
Removed:
(unless (looking-at-p "^[ \t]*$")
157
Removed:
(org-fill-paragraph))
158
Removed:
(forward-line 1))
159
Removed:
(set-marker end nil))))
160
Removed:
161
Removed:
(defun rail-tools--nonblank (value)
162
Removed:
"Return VALUE trimmed when it is a non-blank string, else nil."
163
Removed:
(and (stringp value)
164
Removed:
(let ((trimmed (string-trim value)))
165
Removed:
(and (> (length trimmed) 0) trimmed))))
166
Removed:
167
Removed:
(defun rail-tools--set-result (commit tests &optional model notes)
168
Removed:
"Write the structured result line for the entry at point.
169
Removed:
Replace an existing `- result ::' line, or append one at the end of
170
Removed:
the entry body. COMMIT is a commit hash. TESTS is a short recap
171
Removed:
such as \"215 pass\". MODEL names the agent that did the work, and
172
Removed:
NOTES adds a free-text tail after a semicolon. Both are optional.
173
Removed:
Keep the line unwrapped, because the reader captures a single line.
174
Removed:
Point must be on the entry heading."
175
Removed:
(org-back-to-heading t)
176
Removed:
(let* ((model (rail-tools--nonblank model))
177
Removed:
(notes (rail-tools--nonblank notes))
178
Removed:
(subtree-end (save-excursion (org-end-of-subtree t t) (point-marker)))
179
Removed:
(line (concat "- result :: "
180
Removed:
(if model (format "model=%s " model) "")
181
Removed:
(format "commit=%s tests=%s" commit tests)
182
Removed:
(if notes (format "; %s" notes) ""))))
183
Removed:
(org-back-to-heading t)
184
Removed:
(if (re-search-forward "^[ \t]*- result ::.*$" subtree-end t)
185
Removed:
(replace-match line t t)
186
Removed:
(goto-char subtree-end)
187
Removed:
(skip-chars-backward "\n")
188
Removed:
(insert "\n\n" line))
189
Removed:
(set-marker subtree-end nil)))
190
Removed:
191
Removed:
(defun rail-tools--append-log (note)
192
Removed:
"Append NOTE as a timestamped item to the entry's `:LOGBOOK:' drawer.
193
Removed:
Create the drawer directly after the metadata when it is absent.
194
Removed:
Insert the newest item first and wrap it to 72 columns. This is
195
Removed:
append-only. It never edits an existing item or the body. Point
196
Removed:
must be on the entry heading."
197
Removed:
(let ((fill-column 72))
198
Removed:
(org-back-to-heading t)
199
Removed:
(let ((subtree-end (save-excursion (org-end-of-subtree t t) (point-marker)))
200
Removed:
(ts (format-time-string "[%Y-%m-%d %a %H:%M]")))
201
Removed:
(org-back-to-heading t)
202
Removed:
(let ((drawer-start
203
Removed:
(save-excursion
204
Removed:
(when (re-search-forward "^[ \t]*:LOGBOOK:[ \t]*$" subtree-end t)
205
Removed:
(line-beginning-position)))))
206
Removed:
(unless drawer-start
207
Removed:
(org-end-of-meta-data t)
208
Removed:
(insert ":LOGBOOK:\n:END:\n")
209
Removed:
(setq drawer-start
210
Removed:
(save-excursion
211
Removed:
(org-back-to-heading t)
212
Removed:
(re-search-forward "^[ \t]*:LOGBOOK:[ \t]*$" subtree-end t)
213
Removed:
(line-beginning-position))))
214
Removed:
(goto-char drawer-start)
215
Removed:
(forward-line 1)
216
Removed:
(let ((item-start (point)))
217
Removed:
(insert (format "- %s %s\n" ts note))
218
Removed:
(save-excursion
219
Removed:
(goto-char item-start)
220
Removed:
(org-fill-paragraph))))
221
Removed:
(set-marker subtree-end nil))))
222
Removed:
223
Removed:
(defmacro rail-tools--json (&rest body)
224
Removed:
"Evaluate BODY and return its value as a JSON string.
225
Removed:
Catch any error and return a JSON object with an `error' field."
226
Removed:
(declare (indent 0))
227
Removed:
`(condition-case err
228
Removed:
(json-encode (progn ,@body))
229
Removed:
(error (json-encode `((error . ,(error-message-string err)))))))
230
Removed:
231
Removed:
;;; Tag vocabulary
232
Removed:
233
Removed:
;; The vocabulary is not hardcoded. Each stream file declares it with
234
Removed:
;; `#+TAGS:' group-tag lines, for example:
235
Removed:
;;
236
Removed:
;; #+TAGS: [ Kind : feat fix chore ]
237
Removed:
;; #+TAGS: [ Scope : core web ]
238
Removed:
;;
239
Removed:
;; Org parses those lines into `org-current-tag-alist'. The functions below
240
Removed:
;; read that alist, so the vocabulary follows the file, not this code. A file
241
Removed:
;; with no `#+TAGS:' line accepts any tag.
242
Removed:
243
Removed:
(defun rail-tools--tag-axes ()
244
Removed:
"Return the tag vocabulary of the current buffer, grouped by axis.
245
Removed:
Read the group tags that Org parsed from the `#+TAGS:' lines into
246
Removed:
`org-current-tag-alist'. Return an alist that maps each axis symbol to
247
Removed:
its list of tag strings. Return nil when the file declares no axis, and
248
Removed:
then the file accepts any tag."
249
Removed:
(let ((axes '()) (current nil))
250
Removed:
(dolist (entry org-current-tag-alist)
251
Removed:
(pcase entry
252
Removed:
(`(:startgrouptag) (setq current nil))
253
Removed:
(`(:endgrouptag)
254
Removed:
(when current
255
Removed:
(push (cons (intern (downcase (car current)))
256
Removed:
(nreverse (cdr current)))
257
Removed:
axes))
258
Removed:
(setq current nil))
259
Removed:
(`(:grouptags))
260
Removed:
(`(,(and tag (pred stringp)) . ,_)
261
Removed:
(if current
262
Removed:
(setcdr current (cons tag (cdr current)))
263
Removed:
;; The first tag in a group is the axis name.
264
Removed:
(setq current (cons tag '()))))))
265
Removed:
(nreverse axes)))
266
Removed:
267
Removed:
(defun rail-tools--all-tags ()
268
Removed:
"Return every tag the current buffer declares, as one flat list.
269
Removed:
Return nil when the file declares no vocabulary."
270
Removed:
(apply #'append (mapcar #'cdr (rail-tools--tag-axes))))
271
Removed:
272
Removed:
(defun rail-tools--check-tags (tags)
273
Removed:
"Signal an error when TAGS holds a tag outside the file vocabulary.
274
Removed:
TAGS is a list of strings. When the file declares no vocabulary, accept
275
Removed:
any tag. Return TAGS unchanged when valid."
276
Removed:
(let ((allowed (rail-tools--all-tags)))
277
Removed:
(when allowed
278
Removed:
(dolist (tag tags)
279
Removed:
(unless (member tag allowed)
280
Removed:
(error "Unknown tag `%s'; allowed: %s"
281
Removed:
tag (string-join allowed ", "))))))
282
Removed:
tags)
283
Removed:
284
Removed:
(defun rail-tools--goto-stream-top ()
285
Removed:
"Move point to the insertion place for a new item.
286
Removed:
That place is the start of the first top-level heading, after the
287
Removed:
file preamble. When no heading exists, move to the end of the
288
Removed:
preamble."
289
Removed:
(goto-char (point-min))
290
Removed:
(if (re-search-forward "^\\* " nil t)
291
Removed:
(goto-char (line-beginning-position))
292
Removed:
(goto-char (point-max))))
293
Removed:
294
Removed:
;;; inspect (read-only)
295
Removed:
296
Removed:
(defun rail-tools--inspect-handler (args)
297
Removed:
"Report the TODO sequence and the tag vocabulary for RAIL.org.
298
Removed:
Read both from the stream file, so the report mirrors the file."
299
Removed:
(rail-tools--json
300
Removed:
(let ((file (rail-tools--file args)))
301
Removed:
(with-current-buffer (rail-tools--buffer file)
302
Removed:
(org-with-wide-buffer
303
Removed:
`((file . ,file)
304
Removed:
(todo_keywords . ,(vconcat org-todo-keywords-1))
305
Removed:
(tags . ,(mapcar (lambda (axis)
306
Removed:
(cons (car axis) (vconcat (cdr axis))))
307
Removed:
(rail-tools--tag-axes)))))))))
308
Removed:
309
Removed:
(mcp-server-register-tool
310
Removed:
(make-mcp-server-tool
311
Removed:
:name "rail-inspect"
312
Removed:
:title "RAIL Inspect"
313
Removed:
:description "Inspect RAIL.org: return its TODO keyword sequence and the tag vocabulary that the file declares, grouped by axis. Read-only."
314
Removed:
:input-schema '((type . "object")
315
Removed:
(properties . ((root . ((type . "string")
316
Removed:
(description . "Absolute path to the project directory containing RAIL.org")))))
317
Removed:
(required . []))
318
Removed:
:function #'rail-tools--inspect-handler
319
Removed:
:annotations '((readOnlyHint . t)
320
Removed:
(destructiveHint . :false)
321
Removed:
(idempotentHint . t)
322
Removed:
(openWorldHint . :false))))
323
Removed:
324
Removed:
;;; list (read-only)
325
Removed:
326
Removed:
(defun rail-tools--list-handler (args)
327
Removed:
"List the top-level action items in RAIL.org, newest first.
328
Removed:
ARGS keys: `root', `state' (optional), `tag' (optional). When STATE is
329
Removed:
given, return only items with that TODO keyword. When TAG is given,
330
Removed:
return only items that carry that tag. Each row has `id', `title',
331
Removed:
`state', `scheduled', and `tags'."
332
Removed:
(rail-tools--json
333
Removed:
(let ((file (rail-tools--file args))
334
Removed:
(state (alist-get 'state args))
335
Removed:
(tag (alist-get 'tag args)))
336
Removed:
(with-current-buffer (rail-tools--buffer file)
337
Removed:
(org-with-wide-buffer
338
Removed:
(goto-char (point-min))
339
Removed:
(let ((rows '()))
340
Removed:
(while (re-search-forward "^\\* " nil t)
341
Removed:
(let ((todo (org-get-todo-state))
342
Removed:
(tags (org-get-tags nil t)))
343
Removed:
(when (and (or (null state) (equal state todo))
344
Removed:
(or (null tag) (member tag tags)))
345
Removed:
(push `((id . ,(org-id-get))
346
Removed:
(title . ,(org-get-heading t t t t))
347
Removed:
(state . ,todo)
348
Removed:
(scheduled . ,(org-entry-get nil "SCHEDULED"))
349
Removed:
(tags . ,(vconcat tags)))
350
Removed:
rows))))
351
Removed:
;; The file is newest-first, so reverse to keep that order.
352
Removed:
(vconcat (nreverse rows))))))))
353
Removed:
354
Removed:
(mcp-server-register-tool
355
Removed:
(make-mcp-server-tool
356
Removed:
:name "rail-list"
357
Removed:
:title "RAIL List"
358
Removed:
:description "List the top-level action items in RAIL.org, newest first, with each entry's Org ID, title, TODO state, SCHEDULED time, and tags. Pass an optional `state' or `tag' to filter. Read-only."
359
Removed:
:input-schema '((type . "object")
360
Removed:
(properties . ((root . ((type . "string")
361
Removed:
(description . "Absolute path to the project directory")))
362
Removed:
(state . ((type . "string")
363
Removed:
(description . "Optional TODO keyword filter, for example TODO or IN-PROGRESS")))
364
Removed:
(tag . ((type . "string")
365
Removed:
(description . "Optional tag filter, for example web or major")))))
366
Removed:
(required . []))
367
Removed:
:function #'rail-tools--list-handler
368
Removed:
:annotations '((readOnlyHint . t)
369
Removed:
(destructiveHint . :false)
370
Removed:
(idempotentHint . t)
371
Removed:
(openWorldHint . :false))))
372
Removed:
373
Removed:
;;; capture
374
Removed:
375
Removed:
(defun rail-tools--capture-handler (args)
376
Removed:
"Capture a TODO entry at the top of the RAIL.org stream.
377
Removed:
ARGS keys: `root', `title', `body' (optional), `tags' (optional array).
378
Removed:
Insert the entry as a top-level heading directly below the file
379
Removed:
preamble, so the newest item is first. Record the capture time as an
380
Removed:
inactive SCHEDULED timestamp, apply TAGS from the file vocabulary,
381
Removed:
assign an Org ID, and wrap the body to 72 columns."
382
Removed:
(rail-tools--json
383
Removed:
(let* ((file (rail-tools--file args))
384
Removed:
(title (or (alist-get 'title args) (error "Missing `title'")))
385
Removed:
(body (or (alist-get 'body args) ""))
386
Removed:
(raw-tags (append (alist-get 'tags args) nil))
387
Removed:
(captured-at (format-time-string "[%Y-%m-%d %a %H:%M]")))
388
Removed:
(with-current-buffer (rail-tools--buffer file)
389
Removed:
(org-with-wide-buffer
390
Removed:
;; Validate inside the buffer, because the vocabulary lives here.
391
Removed:
(let ((tags (rail-tools--check-tags raw-tags)))
392
Removed:
(rail-tools--goto-stream-top)
393
Removed:
(let ((start (point)))
394
Removed:
(insert (format "* TODO %s\nSCHEDULED: %s\n" title captured-at))
395
Removed:
(unless (string-empty-p body)
396
Removed:
(insert body "\n"))
397
Removed:
(goto-char start)
398
Removed:
(when tags
399
Removed:
(org-set-tags tags))
400
Removed:
(let ((id (org-id-get-create)))
401
Removed:
(rail-tools--fill-body)
402
Removed:
(when (buffer-modified-p) (save-buffer))
403
Removed:
(goto-char (org-id-find id 'marker))
404
Removed:
`((id . ,id)
405
Removed:
(file . ,file)
406
Removed:
(heading . ,(org-get-heading t t t t))
407
Removed:
(tags . ,(vconcat (org-get-tags nil t))))))))))))
408
Removed:
409
Removed:
(mcp-server-register-tool
410
Removed:
(make-mcp-server-tool
411
Removed:
:name "rail-capture"
412
Removed:
:title "RAIL Capture"
413
Removed:
:description "Capture a TODO action item as a top-level heading at the top of RAIL.org, so the newest item comes first. Applies tags that the file vocabulary permits, records the capture time as an inactive SCHEDULED timestamp, wraps the body to 72 columns, and assigns an Org ID."
414
Removed:
:input-schema '((type . "object")
415
Removed:
(properties . ((root . ((type . "string")
416
Removed:
(description . "Absolute path to the project directory")))
417
Removed:
(title . ((type . "string")
418
Removed:
(description . "Imperative title under 60 chars")))
419
Removed:
(body . ((type . "string")
420
Removed:
(description . "Full request text, verbatim")))
421
Removed:
(tags . ((type . "array")
422
Removed:
(items . ((type . "string")))
423
Removed:
(description . "Tags from the vocabulary that the file declares in its #+TAGS: lines. Run rail-inspect to read the axes and their allowed tags.")))))
424
Removed:
(required . ["title"]))
425
Removed:
:function #'rail-tools--capture-handler
426
Removed:
:annotations '((readOnlyHint . :false)
427
Removed:
(destructiveHint . :false)
428
Removed:
(idempotentHint . :false)
429
Removed:
(openWorldHint . :false))))
430
Removed:
431
Removed:
;;; set-status
432
Removed:
433
Removed:
(defun rail-tools--set-status-handler (args)
434
Removed:
"Change the TODO keyword of an entry.
435
Removed:
ARGS keys: `root', `id', `state'. STATE must be one keyword from the
436
Removed:
file's own #+TODO sequence, and must not be DONE (use rail-complete)."
437
Removed:
(rail-tools--json
438
Removed:
(let ((file (rail-tools--file args))
439
Removed:
(id (or (alist-get 'id args) (error "Missing `id'")))
440
Removed:
(state (or (alist-get 'state args) (error "Missing `state'"))))
441
Removed:
(when (string-equal state "DONE")
442
Removed:
(error "Use rail-complete for DONE, not rail-set-status"))
443
Removed:
(with-current-buffer (rail-tools--buffer file)
444
Removed:
(rail-tools--goto-id id)
445
Removed:
(org-todo state)
446
Removed:
(when (buffer-modified-p) (save-buffer))
447
Removed:
`((id . ,id)
448
Removed:
(state . ,(org-get-todo-state)))))))
449
Removed:
450
Removed:
(mcp-server-register-tool
451
Removed:
(make-mcp-server-tool
452
Removed:
:name "rail-set-status"
453
Removed:
:title "RAIL Set Status"
454
Removed:
:description "Set the TODO keyword of a RAIL entry to any open keyword from the file's own #+TODO sequence. Does not accept DONE; use rail-complete for that."
455
Removed:
:input-schema '((type . "object")
456
Removed:
(properties . ((root . ((type . "string")))
457
Removed:
(id . ((type . "string")
458
Removed:
(description . "Org ID of the entry")))
459
Removed:
(state . ((type . "string")
460
Removed:
(description . "TODO keyword from the file's #+TODO sequence")))))
461
Removed:
(required . ["id" "state"]))
462
Removed:
:function #'rail-tools--set-status-handler
463
Removed:
:annotations '((readOnlyHint . :false)
464
Removed:
(destructiveHint . :false)
465
Removed:
(idempotentHint . t)
466
Removed:
(openWorldHint . :false))))
467
Removed:
468
Removed:
;;; log
469
Removed:
470
Removed:
(defun rail-tools--log-handler (args)
471
Removed:
"Append a timestamped note to an entry's `:LOGBOOK:' drawer.
472
Removed:
ARGS keys: `root', `id', `note'. Append-only progress feedback from an
473
Removed:
agentic session. Never edits an existing note or the item body."
474
Removed:
(rail-tools--json
475
Removed:
(let ((file (rail-tools--file args))
476
Removed:
(id (or (alist-get 'id args) (error "Missing `id'")))
477
Removed:
(note (or (alist-get 'note args) (error "Missing `note'"))))
478
Removed:
(with-current-buffer (rail-tools--buffer file)
479
Removed:
(rail-tools--goto-id id)
480
Removed:
(rail-tools--append-log note)
481
Removed:
(when (buffer-modified-p) (save-buffer))
482
Removed:
`((id . ,id)
483
Removed:
(state . ,(org-get-todo-state)))))))
484
Removed:
485
Removed:
(mcp-server-register-tool
486
Removed:
(make-mcp-server-tool
487
Removed:
:name "rail-log"
488
Removed:
:title "RAIL Log"
489
Removed:
:description "Append a timestamped progress note to a RAIL entry's `:LOGBOOK:' drawer. The drawer is append-only. The tool never rewrites an earlier note, and never rewrites the item body. Newest note first, wrapped to 72 columns."
490
Removed:
:input-schema '((type . "object")
491
Removed:
(properties . ((root . ((type . "string")))
492
Removed:
(id . ((type . "string")
493
Removed:
(description . "Org ID of the entry")))
494
Removed:
(note . ((type . "string")
495
Removed:
(description . "Progress note to append")))))
496
Removed:
(required . ["id" "note"]))
497
Removed:
:function #'rail-tools--log-handler
498
Removed:
:annotations '((readOnlyHint . :false)
499
Removed:
(destructiveHint . :false)
500
Removed:
(idempotentHint . :false)
501
Removed:
(openWorldHint . :false))))
502
Removed:
503
Removed:
;;; set-result
504
Removed:
505
Removed:
(defun rail-tools--set-result-handler (args)
506
Removed:
"Write the structured result line for an entry.
507
Removed:
ARGS keys: `root', `id', `commit', `tests', `model' (optional),
508
Removed:
`notes' (optional). COMMIT is a commit hash. TESTS is a short recap
509
Removed:
such as \"215 pass\". Replace an existing result line or append one at
510
Removed:
the end of the body."
511
Removed:
(rail-tools--json
512
Removed:
(let ((file (rail-tools--file args))
513
Removed:
(id (or (alist-get 'id args) (error "Missing `id'")))
514
Removed:
(commit (or (alist-get 'commit args) (error "Missing `commit'")))
515
Removed:
(tests (or (alist-get 'tests args) (error "Missing `tests'")))
516
Removed:
(model (alist-get 'model args))
517
Removed:
(notes (alist-get 'notes args)))
518
Removed:
(with-current-buffer (rail-tools--buffer file)
519
Removed:
(rail-tools--goto-id id)
520
Removed:
(rail-tools--set-result commit tests model notes)
521
Removed:
(when (buffer-modified-p) (save-buffer))
522
Removed:
(rail-tools--goto-id id)
523
Removed:
`((id . ,id)
524
Removed:
(result . ,(rail-tools--result-text)))))))
525
Removed:
526
Removed:
(mcp-server-register-tool
527
Removed:
(make-mcp-server-tool
528
Removed:
:name "rail-set-result"
529
Removed:
:title "RAIL Set Result"
530
Removed:
:description "Write the structured `- result ::' line for a RAIL entry, recording the commit hash, a short test recap, and optionally the model that did the work and a free-text note. Replaces an existing result line or appends one. rail-complete calls this when you pass commit and tests."
531
Removed:
:input-schema '((type . "object")
532
Removed:
(properties . ((root . ((type . "string")))
533
Removed:
(id . ((type . "string")
534
Removed:
(description . "Org ID of the entry")))
535
Removed:
(commit . ((type . "string")
536
Removed:
(description . "Commit hash")))
537
Removed:
(tests . ((type . "string")
538
Removed:
(description . "Short test recap, for example \"215 pass\"")))
539
Removed:
(model . ((type . "string")
540
Removed:
(description . "Optional model or agent that did the work, for example the agent name")))
541
Removed:
(notes . ((type . "string")
542
Removed:
(description . "Optional free-text tail appended after a semicolon, for example a root cause")))))
543
Removed:
(required . ["id" "commit" "tests"]))
544
Removed:
:function #'rail-tools--set-result-handler
545
Removed:
:annotations '((readOnlyHint . :false)
546
Removed:
(destructiveHint . :false)
547
Removed:
(idempotentHint . t)
548
Removed:
(openWorldHint . :false))))
549
Removed:
550
Removed:
;;; check (checklist for sub-tasks)
551
Removed:
552
Removed:
(defconst rail-tools--checklist-header "Checklist [/]:"
553
Removed:
"Header line that introduces an item's checkbox list.
554
Removed:
The `[/]' cookie tracks completed items against the total.")
555
Removed:
556
Removed:
(defun rail-tools--checklist-add (item)
557
Removed:
"Add ITEM as an unchecked checkbox to the entry at point.
558
Removed:
Create the checklist block when it does not exist. Wrap ITEM to 72
559
Removed:
columns and refresh the `[/]' cookie. Point must be on the heading."
560
Removed:
(let ((fill-column 72))
561
Removed:
(org-back-to-heading t)
562
Removed:
(let* ((subtree-end (save-excursion (org-end-of-subtree t t) (point-marker)))
563
Removed:
(line (format "- [ ] %s\n" item))
564
Removed:
insert-at)
565
Removed:
(org-back-to-heading t)
566
Removed:
(if (re-search-forward "^Checklist \\[[0-9]*/[0-9]*\\]:[ \t]*$"
567
Removed:
subtree-end t)
568
Removed:
;; Existing block: step past the trailing checkbox items.
569
Removed:
(progn (forward-line 1)
570
Removed:
(while (looking-at-p "^- \\[.\\] \\|^ ") (forward-line 1))
571
Removed:
(setq insert-at (point))
572
Removed:
(insert line))
573
Removed:
;; No block: append one at the end of the body.
574
Removed:
(goto-char subtree-end)
575
Removed:
(skip-chars-backward "\n")
576
Removed:
(insert "\n\n" rail-tools--checklist-header "\n")
577
Removed:
(setq insert-at (point))
578
Removed:
(insert line))
579
Removed:
(save-excursion (goto-char insert-at) (org-fill-paragraph))
580
Removed:
(org-update-checkbox-count)
581
Removed:
(set-marker subtree-end nil))))
582
Removed:
583
Removed:
(defun rail-tools--checklist-toggle (item)
584
Removed:
"Toggle the checkbox whose text matches ITEM in the entry at point.
585
Removed:
Signal an error when no item matches. Refresh the `[/]' cookie.
586
Removed:
Point must be on the heading."
587
Removed:
(org-back-to-heading t)
588
Removed:
(let ((subtree-end (save-excursion (org-end-of-subtree t t) (point-marker))))
589
Removed:
(org-back-to-heading t)
590
Removed:
(if (re-search-forward (concat "^- \\[.\\] " (regexp-quote item))
591
Removed:
subtree-end t)
592
Removed:
(progn (beginning-of-line) (org-toggle-checkbox)
593
Removed:
(org-update-checkbox-count))
594
Removed:
(set-marker subtree-end nil)
595
Removed:
(error "No checklist item matches: %s" item))
596
Removed:
(set-marker subtree-end nil)))
597
Removed:
598
Removed:
(defun rail-tools--checklist-items ()
599
Removed:
"Return the checklist items of the entry at point.
600
Removed:
Each item is an alist with `done' and `text'. Point must be on the
601
Removed:
heading."
602
Removed:
(org-back-to-heading t)
603
Removed:
(let ((subtree-end (save-excursion (org-end-of-subtree t t) (point)))
604
Removed:
(items '()))
605
Removed:
(org-back-to-heading t)
606
Removed:
(while (re-search-forward "^- \\[\\(.\\)\\] \\(.*\\)$" subtree-end t)
607
Removed:
(push `((done . ,(if (string-equal (match-string 1) " ") :json-false t))
608
Removed:
(text . ,(string-trim (match-string-no-properties 2))))
609
Removed:
items))
610
Removed:
(vconcat (nreverse items))))
611
Removed:
612
Removed:
(defun rail-tools--check-handler (args)
613
Removed:
"Manage the checklist of an item, for splitting a complex task.
614
Removed:
ARGS keys: `root', `id', `action' (add|toggle|list), `item'.
615
Removed:
`add' appends an unchecked item. `toggle' flips a matching item.
616
Removed:
`list' returns the items. A `[/]' cookie tracks progress."
617
Removed:
(rail-tools--json
618
Removed:
(let ((file (rail-tools--file args))
619
Removed:
(id (or (alist-get 'id args) (error "Missing `id'")))
620
Removed:
(action (or (alist-get 'action args) (error "Missing `action'")))
621
Removed:
(item (alist-get 'item args)))
622
Removed:
(with-current-buffer (rail-tools--buffer file)
623
Removed:
(rail-tools--goto-id id)
624
Removed:
(cond
625
Removed:
((string-equal action "add")
626
Removed:
(unless item (error "`add' needs an `item'"))
627
Removed:
(rail-tools--checklist-add item))
628
Removed:
((string-equal action "toggle")
629
Removed:
(unless item (error "`toggle' needs an `item'"))
630
Removed:
(rail-tools--checklist-toggle item))
631
Removed:
((string-equal action "list") nil)
632
Removed:
(t (error "Unknown action `%s'; use add, toggle, or list" action)))
633
Removed:
(when (buffer-modified-p) (save-buffer))
634
Removed:
(rail-tools--goto-id id)
635
Removed:
`((id . ,id)
636
Removed:
(items . ,(rail-tools--checklist-items)))))))
637
Removed:
638
Removed:
(mcp-server-register-tool
639
Removed:
(make-mcp-server-tool
640
Removed:
:name "rail-check"
641
Removed:
:title "RAIL Checklist"
642
Removed:
:description "Manage an item's checkbox list to split a complex task into sub-tasks with their own done state. Actions: add an unchecked item, toggle a matching item, or list items. A [/] cookie on the checklist header tracks progress. The items stay inside the one request. They are not separate stream entries."
643
Removed:
:input-schema '((type . "object")
644
Removed:
(properties . ((root . ((type . "string")))
645
Removed:
(id . ((type . "string")
646
Removed:
(description . "Org ID of the item")))
647
Removed:
(action . ((type . "string")
648
Removed:
(description . "add, toggle, or list")))
649
Removed:
(item . ((type . "string")
650
Removed:
(description . "Item text for add or toggle")))))
651
Removed:
(required . ["id" "action"]))
652
Removed:
:function #'rail-tools--check-handler
653
Removed:
:annotations '((readOnlyHint . :false)
654
Removed:
(destructiveHint . :false)
655
Removed:
(idempotentHint . :false)
656
Removed:
(openWorldHint . :false))))
657
Removed:
658
Removed:
;;; show (read-only)
659
Removed:
660
Removed:
(defun rail-tools--body-text ()
661
Removed:
"Return the plain body text of the entry at point.
662
Removed:
Read from the first line after the metadata up to the first of: a
663
Removed:
`Checklist [' line, a `- result ::' line, or the end of the subtree.
664
Removed:
Return the trimmed string. Point must be on the entry heading."
665
Removed:
(org-back-to-heading t)
666
Removed:
(let ((subtree-end (save-excursion (org-end-of-subtree t t) (point))))
667
Removed:
(org-back-to-heading t)
668
Removed:
(org-end-of-meta-data t)
669
Removed:
(let ((body-start (point))
670
Removed:
(body-end subtree-end))
671
Removed:
(save-excursion
672
Removed:
(goto-char body-start)
673
Removed:
(when (re-search-forward "^\\(Checklist \\[\\|[ \t]*- result ::\\)"
674
Removed:
subtree-end t)
675
Removed:
(setq body-end (line-beginning-position))))
676
Removed:
(string-trim
677
Removed:
(buffer-substring-no-properties body-start body-end)))))
678
Removed:
679
Removed:
(defun rail-tools--logbook-items ()
680
Removed:
"Return the `:LOGBOOK:' drawer item lines of the entry at point.
681
Removed:
Each item is a string, in the order stored (newest first). Return an
682
Removed:
empty vector when there is no drawer. Point must be on the heading."
683
Removed:
(org-back-to-heading t)
684
Removed:
(let ((subtree-end (save-excursion (org-end-of-subtree t t) (point)))
685
Removed:
(items '()))
686
Removed:
(org-back-to-heading t)
687
Removed:
(when (re-search-forward "^[ \t]*:LOGBOOK:[ \t]*$" subtree-end t)
688
Removed:
(forward-line 1)
689
Removed:
(while (and (< (point) subtree-end)
690
Removed:
(not (looking-at-p "^[ \t]*:END:[ \t]*$")))
691
Removed:
(when (looking-at "^[ \t]*- \\(.*\\)$")
692
Removed:
(push (string-trim (match-string-no-properties 1)) items))
693
Removed:
(forward-line 1)))
694
Removed:
(vconcat (nreverse items))))
695
Removed:
696
Removed:
(defun rail-tools--result-text ()
697
Removed:
"Return the text after `- result ::' for the entry at point.
698
Removed:
Return nil when there is no result line, so it encodes as JSON null.
699
Removed:
Point must be on the heading."
700
Removed:
(org-back-to-heading t)
701
Removed:
(let ((subtree-end (save-excursion (org-end-of-subtree t t) (point))))
702
Removed:
(org-back-to-heading t)
703
Removed:
(when (re-search-forward "^[ \t]*- result ::[ \t]*\\(.*\\)$"
704
Removed:
subtree-end t)
705
Removed:
(string-trim (match-string-no-properties 1)))))
706
Removed:
707
Removed:
(defun rail-tools--show-handler (args)
708
Removed:
"Return the full content of an entry.
709
Removed:
ARGS keys: `root', `id'. Report the heading, state, tags, scheduled
710
Removed:
and closed timestamps, body text, logbook notes, checklist items, and
711
Removed:
result line. Read-only."
712
Removed:
(rail-tools--json
713
Removed:
(let ((file (rail-tools--file args))
714
Removed:
(id (or (alist-get 'id args) (error "Missing `id'"))))
715
Removed:
(with-current-buffer (rail-tools--buffer file)
716
Removed:
(org-with-wide-buffer
717
Removed:
(rail-tools--goto-id id)
718
Removed:
`((id . ,id)
719
Removed:
(heading . ,(org-get-heading t t t t))
720
Removed:
(state . ,(org-get-todo-state))
721
Removed:
(tags . ,(vconcat (org-get-tags nil t)))
722
Removed:
(scheduled . ,(org-entry-get nil "SCHEDULED"))
723
Removed:
(closed . ,(org-entry-get nil "CLOSED"))
724
Removed:
(body . ,(rail-tools--body-text))
725
Removed:
(logbook . ,(rail-tools--logbook-items))
726
Removed:
(checklist . ,(rail-tools--checklist-items))
727
Removed:
(result . ,(rail-tools--result-text))))))))
728
Removed:
729
Removed:
(mcp-server-register-tool
730
Removed:
(make-mcp-server-tool
731
Removed:
:name "rail-show"
732
Removed:
:title "RAIL Show"
733
Removed:
:description "Return the full content of a RAIL entry: heading, state, tags, scheduled and closed timestamps, body text, logbook notes, checklist items, and result line. Read-only."
734
Removed:
:input-schema '((type . "object")
735
Removed:
(properties . ((root . ((type . "string")))
736
Removed:
(id . ((type . "string")
737
Removed:
(description . "Org ID of the entry")))))
738
Removed:
(required . ["id"]))
739
Removed:
:function #'rail-tools--show-handler
740
Removed:
:annotations '((readOnlyHint . t)
741
Removed:
(destructiveHint . :false)
742
Removed:
(idempotentHint . t)
743
Removed:
(openWorldHint . :false))))
744
Removed:
745
Removed:
;;; retag
746
Removed:
747
Removed:
(defun rail-tools--retag-handler (args)
748
Removed:
"Replace the tags on an entry with a validated set.
749
Removed:
ARGS keys: `root', `id', `tags' (array). Validate TAGS against the
750
Removed:
file vocabulary, then set them, keeping the file's default tag
751
Removed:
alignment."
752
Removed:
(rail-tools--json
753
Removed:
(let ((file (rail-tools--file args))
754
Removed:
(id (or (alist-get 'id args) (error "Missing `id'")))
755
Removed:
(raw-tags (append (alist-get 'tags args) nil)))
756
Removed:
(with-current-buffer (rail-tools--buffer file)
757
Removed:
(let ((tags (rail-tools--check-tags raw-tags)))
758
Removed:
(rail-tools--goto-id id)
759
Removed:
(org-set-tags tags)
760
Removed:
(when (buffer-modified-p) (save-buffer))
761
Removed:
`((id . ,id)
762
Removed:
(tags . ,(vconcat (org-get-tags nil t)))))))))
763
Removed:
764
Removed:
(mcp-server-register-tool
765
Removed:
(make-mcp-server-tool
766
Removed:
:name "rail-retag"
767
Removed:
:title "RAIL Retag"
768
Removed:
:description "Replace the tags on a RAIL entry with a validated set from the file vocabulary. Use this tool to re-tag an entry as its shape changes. The tool keeps the file's default tag alignment."
769
Removed:
:input-schema '((type . "object")
770
Removed:
(properties . ((root . ((type . "string")))
771
Removed:
(id . ((type . "string")
772
Removed:
(description . "Org ID of the entry")))
773
Removed:
(tags . ((type . "array")
774
Removed:
(items . ((type . "string")))
775
Removed:
(description . "Tags from the file vocabulary")))))
776
Removed:
(required . ["id" "tags"]))
777
Removed:
:function #'rail-tools--retag-handler
778
Removed:
:annotations '((readOnlyHint . :false)
779
Removed:
(destructiveHint . :false)
780
Removed:
(idempotentHint . :false)
781
Removed:
(openWorldHint . :false))))
782
Removed:
783
Removed:
;;; cancel and block
784
Removed:
785
Removed:
(defun rail-tools--transition-with-reason (id keyword marker-label reason)
786
Removed:
"Transition entry ID to KEYWORD and record REASON.
787
Removed:
Signal an error when REASON is missing or blank. Write a wrapped body
788
Removed:
line `- MARKER-LABEL :: REASON', replacing an existing line of that
789
Removed:
form or appending one at the end of the body. Fill the line to 72
790
Removed:
columns, then set the TODO keyword and save."
791
Removed:
(when (or (null reason) (string-empty-p (string-trim reason)))
792
Removed:
(error "A reason is required"))
793
Removed:
(rail-tools--goto-id id)
794
Removed:
(let ((fill-column 72))
795
Removed:
(org-back-to-heading t)
796
Removed:
(let ((subtree-end (save-excursion (org-end-of-subtree t t) (point-marker)))
797
Removed:
(line (format "- %s :: %s" marker-label reason))
798
Removed:
insert-at)
799
Removed:
(org-back-to-heading t)
800
Removed:
(if (re-search-forward
801
Removed:
(format "^[ \t]*- %s ::.*$" (regexp-quote marker-label))
802
Removed:
subtree-end t)
803
Removed:
(progn (replace-match line t t)
804
Removed:
(setq insert-at (line-beginning-position)))
805
Removed:
(goto-char subtree-end)
806
Removed:
(skip-chars-backward "\n")
807
Removed:
(insert "\n\n" line)
808
Removed:
(setq insert-at (line-beginning-position)))
809
Removed:
(save-excursion (goto-char insert-at) (org-fill-paragraph))
810
Removed:
(set-marker subtree-end nil)))
811
Removed:
(rail-tools--goto-id id)
812
Removed:
(org-todo keyword)
813
Removed:
(when (buffer-modified-p) (save-buffer)))
814
Removed:
815
Removed:
(defun rail-tools--cancel-handler (args)
816
Removed:
"Set an entry to CANCELLED with a required reason.
817
Removed:
ARGS keys: `root', `id', `reason'. Record REASON as a `- cancelled ::'
818
Removed:
line so the decision is never silent."
819
Removed:
(rail-tools--json
820
Removed:
(let ((file (rail-tools--file args))
821
Removed:
(id (or (alist-get 'id args) (error "Missing `id'")))
822
Removed:
(reason (alist-get 'reason args)))
823
Removed:
(with-current-buffer (rail-tools--buffer file)
824
Removed:
(rail-tools--transition-with-reason id "CANCELLED" "cancelled" reason)
825
Removed:
(rail-tools--goto-id id)
826
Removed:
`((id . ,id)
827
Removed:
(state . ,(org-get-todo-state)))))))
828
Removed:
829
Removed:
(mcp-server-register-tool
830
Removed:
(make-mcp-server-tool
831
Removed:
:name "rail-cancel"
832
Removed:
:title "RAIL Cancel"
833
Removed:
:description "Set a RAIL entry to CANCELLED and record a required reason as a `- cancelled ::' line, so the decision is never silent."
834
Removed:
:input-schema '((type . "object")
835
Removed:
(properties . ((root . ((type . "string")))
836
Removed:
(id . ((type . "string")
837
Removed:
(description . "Org ID of the entry")))
838
Removed:
(reason . ((type . "string")
839
Removed:
(description . "Reason for cancelling the entry")))))
840
Removed:
(required . ["id" "reason"]))
841
Removed:
:function #'rail-tools--cancel-handler
842
Removed:
:annotations '((readOnlyHint . :false)
843
Removed:
(destructiveHint . :false)
844
Removed:
(idempotentHint . :false)
845
Removed:
(openWorldHint . :false))))
846
Removed:
847
Removed:
(defun rail-tools--block-handler (args)
848
Removed:
"Set an entry to BLOCKED with a required reason.
849
Removed:
ARGS keys: `root', `id', `reason'. Record REASON as a `- blocked ::'
850
Removed:
line so the blocker is never silent."
851
Removed:
(rail-tools--json
852
Removed:
(let ((file (rail-tools--file args))
853
Removed:
(id (or (alist-get 'id args) (error "Missing `id'")))
854
Removed:
(reason (alist-get 'reason args)))
855
Removed:
(with-current-buffer (rail-tools--buffer file)
856
Removed:
(rail-tools--transition-with-reason id "BLOCKED" "blocked" reason)
857
Removed:
(rail-tools--goto-id id)
858
Removed:
`((id . ,id)
859
Removed:
(state . ,(org-get-todo-state)))))))
860
Removed:
861
Removed:
(mcp-server-register-tool
862
Removed:
(make-mcp-server-tool
863
Removed:
:name "rail-block"
864
Removed:
:title "RAIL Block"
865
Removed:
:description "Set a RAIL entry to BLOCKED and record a required reason as a `- blocked ::' line, so the blocker is never silent."
866
Removed:
:input-schema '((type . "object")
867
Removed:
(properties . ((root . ((type . "string")))
868
Removed:
(id . ((type . "string")
869
Removed:
(description . "Org ID of the entry")))
870
Removed:
(reason . ((type . "string")
871
Removed:
(description . "Reason for blocking the entry")))))
872
Removed:
(required . ["id" "reason"]))
873
Removed:
:function #'rail-tools--block-handler
874
Removed:
:annotations '((readOnlyHint . :false)
875
Removed:
(destructiveHint . :false)
876
Removed:
(idempotentHint . :false)
877
Removed:
(openWorldHint . :false))))
878
Removed:
879
Removed:
;;; complete
880
Removed:
881
Removed:
(defun rail-tools--complete-handler (args)
882
Removed:
"Set an entry to DONE with its result evidence and confirm CLOSED.
883
Removed:
ARGS keys: `root', `id', `commit', `tests', `model' (optional),
884
Removed:
`notes' (optional). Write the structured result line, then transition
885
Removed:
to DONE. Requires `org-log-done' to be `time' so the normal Org
886
Removed:
transition inserts CLOSED; this handler never writes CLOSED."
887
Removed:
(rail-tools--json
888
Removed:
(let ((file (rail-tools--file args))
889
Removed:
(id (or (alist-get 'id args) (error "Missing `id'")))
890
Removed:
(commit (or (alist-get 'commit args) (error "Missing `commit'")))
891
Removed:
(tests (or (alist-get 'tests args) (error "Missing `tests'")))
892
Removed:
(model (alist-get 'model args))
893
Removed:
(notes (alist-get 'notes args)))
894
Removed:
(with-current-buffer (rail-tools--buffer file)
895
Removed:
(hack-local-variables)
896
Removed:
(unless (eq org-log-done 'time)
897
Removed:
(error "org-log-done is not set to time"))
898
Removed:
(rail-tools--goto-id id)
899
Removed:
(rail-tools--set-result commit tests model notes)
900
Removed:
(rail-tools--goto-id id)
901
Removed:
(org-todo "DONE")
902
Removed:
(when (buffer-modified-p) (save-buffer))
903
Removed:
(let ((closed (org-entry-get nil "CLOSED")))
904
Removed:
(unless closed
905
Removed:
(error "Org did not record a CLOSED timestamp"))
906
Removed:
`((id . ,id)
907
Removed:
(state . ,(org-get-todo-state))
908
Removed:
(result . ,(rail-tools--result-text))
909
Removed:
(closed . ,closed)))))))
910
Removed:
911
Removed:
(mcp-server-register-tool
912
Removed:
(make-mcp-server-tool
913
Removed:
:name "rail-complete"
914
Removed:
:title "RAIL Complete"
915
Removed:
:description "Set a RAIL entry to DONE. The tool records the result evidence as a `- result ::' line. The evidence holds the commit hash, a short test recap, and optionally the agent that did the work and a free-text note. The tool then confirms that Org inserted a CLOSED timestamp. The file must set org-log-done to time. The tool never writes the timestamp itself. The entry stays in place, and there is no refile step."
916
Removed:
:input-schema '((type . "object")
917
Removed:
(properties . ((root . ((type . "string")))
918
Removed:
(id . ((type . "string")))
919
Removed:
(commit . ((type . "string")
920
Removed:
(description . "Commit hash for the completed work")))
921
Removed:
(tests . ((type . "string")
922
Removed:
(description . "Short test recap, for example \"215 pass\"")))
923
Removed:
(model . ((type . "string")
924
Removed:
(description . "Optional model or agent that did the work, for example the agent name")))
925
Removed:
(notes . ((type . "string")
926
Removed:
(description . "Optional free-text tail appended after a semicolon, for example a root cause")))))
927
Removed:
(required . ["id" "commit" "tests"]))
928
Removed:
:function #'rail-tools--complete-handler
929
Removed:
:annotations '((readOnlyHint . :false)
930
Removed:
(destructiveHint . :false)
931
Removed:
(idempotentHint . t)
932
Removed:
(openWorldHint . :false))))
933
Removed:
934
Removed:
;;; verify (read-only)
935
Removed:
936
Removed:
(defun rail-tools--verify-handler (args)
937
Removed:
"Return the current state of the entry with `id'.
938
Removed:
ARGS keys: `root', `id'. Read-only."
939
Removed:
(rail-tools--json
940
Removed:
(let ((file (rail-tools--file args))
941
Removed:
(id (or (alist-get 'id args) (error "Missing `id'"))))
942
Removed:
(with-current-buffer (rail-tools--buffer file)
943
Removed:
(org-with-wide-buffer
944
Removed:
(rail-tools--goto-id id)
945
Removed:
`((id . ,id)
946
Removed:
(heading . ,(org-get-heading t t t t))
947
Removed:
(state . ,(org-get-todo-state))
948
Removed:
(closed . ,(org-entry-get nil "CLOSED"))
949
Removed:
(tags . ,(vconcat (org-get-tags nil t)))))))))
950
Removed:
951
Removed:
(mcp-server-register-tool
952
Removed:
(make-mcp-server-tool
953
Removed:
:name "rail-verify"
954
Removed:
:title "RAIL Verify"
955
Removed:
:description "Return the heading, TODO state, CLOSED timestamp, and tags of a RAIL entry. Read-only."
956
Removed:
:input-schema '((type . "object")
957
Removed:
(properties . ((root . ((type . "string")))
958
Removed:
(id . ((type . "string")))))
959
Removed:
(required . ["id"]))
960
Removed:
:function #'rail-tools--verify-handler
961
Removed:
:annotations '((readOnlyHint . t)
962
Removed:
(destructiveHint . :false)
963
Removed:
(idempotentHint . t)
964
Removed:
(openWorldHint . :false))))
965
Removed:
966
Removed:
(provide 'rail-tools)
967
Removed:
968
Removed:
;;; rail-tools.el ends here
rail/references/tools.md
@@ -1,168 +0,0 @@
1
Removed:
# RAIL tool reference
2
Removed:
3
Removed:
Read this file when you need the exact arguments of one tool, or the
4
Removed:
shape of its result. The main skill file gives the workflow.
5
Removed:
6
Removed:
Every tool accepts an optional `root` argument. `root` names the project
7
Removed:
directory that holds the stream file. When you omit `root`, the tools use
8
Removed:
`rail-project-root`. That variable comes from an upward search for
9
Removed:
`RAIL.org`, so it needs no configuration.
10
Removed:
11
Removed:
Every tool returns one JSON object. A failed call returns an object with
12
Removed:
one `error` field. An absent value returns JSON `null`.
13
Removed:
14
Removed:
## Read-only tools
15
Removed:
16
Removed:
### rail-inspect
17
Removed:
18
Removed:
Arguments: none beyond `root`.
19
Removed:
20
Removed:
Returns `file`, `todo_keywords`, and `tags`. The `tags` field groups the
21
Removed:
vocabulary by axis: `kind`, `scope`, `impact`, and `misc`.
22
Removed:
23
Removed:
Run this tool when a tag call fails, or when you need the keyword
24
Removed:
sequence of the file.
25
Removed:
26
Removed:
### rail-list
27
Removed:
28
Removed:
Arguments:
29
Removed:
30
Removed:
- `state` — optional. Keep only entries with this TODO keyword.
31
Removed:
- `tag` — optional. Keep only entries that carry this tag.
32
Removed:
33
Removed:
Returns an array of objects, newest first. Each object holds `id`,
34
Removed:
`title`, `state`, `scheduled`, and `tags`. The `id` field is `null` for
35
Removed:
an entry that carries no Org ID.
36
Removed:
37
Removed:
Pass a filter whenever you can, because the stream grows without bound.
38
Removed:
39
Removed:
### rail-show
40
Removed:
41
Removed:
Arguments:
42
Removed:
43
Removed:
- `id` — required. The Org ID of the entry.
44
Removed:
45
Removed:
Returns `id`, `heading`, `state`, `tags`, `scheduled`, `closed`, `body`,
46
Removed:
`logbook`, `checklist`, and `result`. The `checklist` field holds objects
47
Removed:
with a boolean `done` field and a `text` field.
48
Removed:
49
Removed:
### rail-verify
50
Removed:
51
Removed:
Arguments:
52
Removed:
53
Removed:
- `id` — required. The Org ID of the entry.
54
Removed:
55
Removed:
Returns `id`, `heading`, `state`, `closed`, and `tags`. Use this tool
56
Removed:
before a status change and after a status change.
57
Removed:
58
Removed:
## Writing tools
59
Removed:
60
Removed:
### rail-capture
61
Removed:
62
Removed:
Arguments:
63
Removed:
64
Removed:
- `title` — required. A short imperative title under 60 characters.
65
Removed:
- `body` — optional. The full item text, as plain text.
66
Removed:
- `tags` — optional. An array of tags from the file vocabulary (the #+TAGS: lines).
67
Removed:
68
Removed:
Returns `id`, `file`, `heading`, and `tags`.
69
Removed:
70
Removed:
The tool inserts the entry as a top-level heading above every other
71
Removed:
entry. It records the capture time as an inactive `SCHEDULED` timestamp.
72
Removed:
It wraps the body to 72 columns, and it assigns an Org ID.
73
Removed:
74
Removed:
### rail-set-status
75
Removed:
76
Removed:
Arguments:
77
Removed:
78
Removed:
- `id` — required. The Org ID of the entry.
79
Removed:
- `state` — required. One keyword from the file's own `#+TODO:` line.
80
Removed:
81
Removed:
Returns `id` and `state`.
82
Removed:
83
Removed:
The tool refuses `DONE`. Use `rail-complete` for `DONE`.
84
Removed:
85
Removed:
### rail-block and rail-cancel
86
Removed:
87
Removed:
Arguments:
88
Removed:
89
Removed:
- `id` — required. The Org ID of the entry.
90
Removed:
- `reason` — required. The reason for the transition.
91
Removed:
92
Removed:
Returns `id` and `state`.
93
Removed:
94
Removed:
`rail-block` writes a `- blocked ::` line and sets `BLOCKED`.
95
Removed:
`rail-cancel` writes a `- cancelled ::` line and sets `CANCELLED`.
96
Removed:
Each tool refuses a blank reason.
97
Removed:
98
Removed:
### rail-check
99
Removed:
100
Removed:
Arguments:
101
Removed:
102
Removed:
- `id` — required. The Org ID of the entry.
103
Removed:
- `action` — required. One of `add`, `toggle`, or `list`.
104
Removed:
- `item` — required for `add` and for `toggle`. The item text.
105
Removed:
106
Removed:
Returns `id` and `items`.
107
Removed:
108
Removed:
The tool keeps the items in a `Checklist [/]:` block in the body. The
109
Removed:
`[/]` cookie counts the completed items. `toggle` matches the item text
110
Removed:
from the start, and it fails when no item matches.
111
Removed:
112
Removed:
### rail-log
113
Removed:
114
Removed:
Arguments:
115
Removed:
116
Removed:
- `id` — required. The Org ID of the entry.
117
Removed:
- `note` — required. The progress note.
118
Removed:
119
Removed:
Returns `id` and `state`.
120
Removed:
121
Removed:
The tool prepends a timestamped item to the `:LOGBOOK:` drawer, and
122
Removed:
creates the drawer when it is absent. The drawer is append-only.
123
Removed:
124
Removed:
### rail-retag
125
Removed:
126
Removed:
Arguments:
127
Removed:
128
Removed:
- `id` — required. The Org ID of the entry.
129
Removed:
- `tags` — required. An array of tags from the file vocabulary (the #+TAGS: lines).
130
Removed:
131
Removed:
Returns `id` and `tags`.
132
Removed:
133
Removed:
The tool replaces every tag on the entry. It validates each tag first,
134
Removed:
so one invalid tag leaves the entry unchanged.
135
Removed:
136
Removed:
### rail-set-result
137
Removed:
138
Removed:
Arguments:
139
Removed:
140
Removed:
- `id` — required. The Org ID of the entry.
141
Removed:
- `commit` — required. The commit hash.
142
Removed:
- `tests` — required. A short test recap.
143
Removed:
- `model` — optional. The agent that did the work.
144
Removed:
- `notes` — optional. A short tail after a semicolon.
145
Removed:
146
Removed:
Returns `id` and `result`.
147
Removed:
148
Removed:
The tool replaces an existing result line, or appends one at the end of
149
Removed:
the body. The line stays on one line, because the reader reads one line.
150
Removed:
151
Removed:
### rail-complete
152
Removed:
153
Removed:
Arguments: the same arguments as `rail-set-result`.
154
Removed:
155
Removed:
Returns `id`, `state`, `result`, and `closed`.
156
Removed:
157
Removed:
The tool writes the result line, sets `DONE`, then reads back the
158
Removed:
`CLOSED` timestamp. It fails when the file does not set `org-log-done`
159
Removed:
to `time`, because Org must write that timestamp.
160
Removed:
161
Removed:
## Result line format
162
Removed:
163
Removed:
```org
164
Removed:
- result :: model=example-agent commit=3051af0 tests=243 pass; short root cause
165
Removed:
```
166
Removed:
167
Removed:
The `model` field and the tail after the semicolon are optional. The
168
Removed:
`commit` field and the `tests` field are always present.
rail/run-tests.sh
@@ -1,35 +0,0 @@
1
Removed:
#!/bin/sh
2
Removed:
# Run the RAIL test suite in a batch Emacs.
3
Removed:
#
4
Removed:
# Required dependency: emacs, with Org mode. Org mode ships with Emacs.
5
Removed:
# The suite needs no MCP framework and no configuration. It runs on any
6
Removed:
# machine. Every test uses a temporary stream file, so the project stream
7
Removed:
# file stays unchanged.
8
Removed:
#
9
Removed:
# Exit status: 0 when every test passes, 1 otherwise.
10
Removed:
11
Removed:
set -eu
12
Removed:
13
Removed:
dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
14
Removed:
15
Removed:
if ! command -v emacs >/dev/null 2>&1; then
16
Removed:
echo "Error: emacs is not on PATH. Install Emacs, then run this script again." >&2
17
Removed:
exit 1
18
Removed:
fi
19
Removed:
20
Removed:
for file in rail-tools.el rail-tests.el; do
21
Removed:
if [ ! -r "$dir/$file" ]; then
22
Removed:
echo "Error: cannot read $dir/$file. The skill directory is incomplete." >&2
23
Removed:
exit 1
24
Removed:
fi
25
Removed:
done
26
Removed:
27
Removed:
if emacs --batch -Q \
28
Removed:
-l "$dir/rail-tools.el" \
29
Removed:
-l "$dir/rail-tests.el" \
30
Removed:
-f ert-run-tests-batch-and-exit; then
31
Removed:
echo "RAIL tests passed."
32
Removed:
else
33
Removed:
echo "Error: RAIL tests failed. Read the ERT output above for the failing test." >&2
34
Removed:
exit 1
35
Removed:
fi
skills/rail/SKILL.md
@@ -0,0 +1,354 @@
1
Added:
---
2
Added:
name: rail
3
Added:
description: Manage a rolling action item list in a RAIL.org stream with dedicated Emacs MCP tools.
4
Added:
---
5
Added:
6
Added:
# RAIL — Rolling Action Item List
7
Added:
8
Added:
## Overview
9
Added:
10
Added:
RAIL keeps every action item in one Org file, `RAIL.org`, at the root of
11
Added:
a project. Dedicated Emacs tools write that file. The work follows three
12
Added:
steps:
13
Added:
14
Added:
1. Capture the item at the top of the stream.
15
Added:
2. Track its status while the work runs.
16
Added:
3. Close it with commit evidence and test evidence.
17
Added:
18
Added:
The command for this skill is `/rail`.
19
Added:
20
Added:
## Usage
21
Added:
22
Added:
Use this skill when the user does one of these things:
23
Added:
24
Added:
- The user runs `/rail`.
25
Added:
- The user states an action item, a feature request, a defect, or a
26
Added:
change to make.
27
Added:
- The user asks you to record or capture an item.
28
Added:
- The user asks for the status of an item, or asks to change it.
29
Added:
- The user asks you to close, cancel, or block an item.
30
Added:
- The user asks what items are open.
31
Added:
32
Added:
Treat each prompt as one separate action item. Do not merge two items.
33
Added:
Do not build, test, commit, or start an agent for a capture.
34
Added:
35
Added:
The current prompt is the action item:
36
Added:
37
Added:
$ARGUMENTS
38
Added:
39
Added:
If the prompt is empty, ask the user for one line that describes the
40
Added:
item. Then stop. Capture the next prompt as the next item.
41
Added:
42
Added:
## Core Concepts
43
Added:
44
Added:
### The stream
45
Added:
46
Added:
`RAIL.org` is a flat stream. Each action item is a top-level heading.
47
Added:
The newest item comes first. The file holds no container heading and no
48
Added:
topical heading. Tags on the heading give the axes of the item. The
49
Added:
position of a heading gives only its age.
50
Added:
51
Added:
### The file owns the vocabulary and the workflow
52
Added:
53
Added:
The tools carry no project vocabulary and no project workflow. The
54
Added:
stream file owns both:
55
Added:
56
Added:
- The `#+TODO:` line declares the status keywords.
57
Added:
- The `#+TAGS:` lines declare the tag vocabulary, grouped into axes.
58
Added:
59
Added:
The tools read both from the file. One tool file therefore serves every
60
Added:
project. To change the states or the tags, edit the preamble of
61
Added:
`RAIL.org`. Run `rail-inspect` to read the current vocabulary.
62
Added:
63
Added:
### The Org ID is the handle
64
Added:
65
Added:
Every item carries an Org ID. Each tool that changes an item needs that
66
Added:
ID. Never select an item by its title text, because two titles can
67
Added:
match. Run `rail-list` to get the ID of each item.
68
Added:
69
Added:
### Evidence, not silence
70
Added:
71
Added:
Three transitions demand a written reason or written evidence:
72
Added:
73
Added:
- `BLOCKED` needs a reason.
74
Added:
- `CANCELLED` needs a reason.
75
Added:
- `DONE` needs a commit hash and a test recap.
76
Added:
77
Added:
The tools refuse these transitions without that text. A stalled item or
78
Added:
a dropped item therefore always states why.
79
Added:
80
Added:
### The tag vocabulary is grouped by axis
81
Added:
82
Added:
The `#+TAGS:` lines group the tags into named axes. A common shape holds
83
Added:
a `Kind` axis and a `Scope` axis, plus optional axes. Each axis holds a
84
Added:
short set of bare tags. Distinct words across axes keep a bare tag
85
Added:
unambiguous. This is a convention, not a tool rule. The tool accepts any
86
Added:
tag that a `#+TAGS:` line declares, in any axis the project chooses.
87
Added:
88
Added:
Infer the tags from the item text when you capture it. You can re-tag an
89
Added:
item later with `rail-retag`, as its shape changes.
90
Added:
91
Added:
## Set up the stream file
92
Added:
93
Added:
A project needs one `RAIL.org` file at its root before the tools run.
94
Added:
Create it with this preamble, then edit the axes to fit the project:
95
Added:
96
Added:
```org
97
Added:
#+TITLE: Project action items
98
Added:
#+TODO: TODO IN-PROGRESS TESTING TESTED BLOCKED | CANCELLED DONE
99
Added:
#+TAGS: [ Kind : feat fix refactor chore docs ]
100
Added:
#+TAGS: [ Scope : core app web ui ]
101
Added:
#+TAGS: [ Impact : minor major ]
102
Added:
103
Added:
# Local Variables:
104
Added:
# org-log-done: time
105
Added:
# End:
106
Added:
```
107
Added:
108
Added:
The `#+TODO:` keywords before the bar are open states. The keywords
109
Added:
after the bar are closed states. The `org-log-done` local variable makes
110
Added:
Org write a `CLOSED` timestamp on the transition to `DONE`, which
111
Added:
`rail-complete` requires. A file with no `#+TAGS:` line accepts any tag.
112
Added:
113
Added:
## Install the tools once per session
114
Added:
115
Added:
The tools live in `rail-tools.el`, beside this `SKILL.md`. The skill can
116
Added:
sit in one project, or in a shared global directory that serves every
117
Added:
project. Load the tool file one time in the running Emacs. Use the
118
Added:
generic `eval-elisp` tool for the load only.
119
Added:
120
Added:
Load the tool file from the directory of this `SKILL.md`, and pass the
121
Added:
directory of the project you work in as the project root. These are two
122
Added:
separate places. The tool file has one fixed home. The project root
123
Added:
changes with each project.
124
Added:
125
Added:
```elisp
126
Added:
(let ((tools "/absolute/path/to/this/skill/rail-tools.el")
127
Added:
(project "/the/project/directory/you/work/in"))
128
Added:
(unless (file-readable-p tools)
129
Added:
(error "No RAIL tools at %s" tools))
130
Added:
(load tools nil t)
131
Added:
(setq rail-project-root (file-name-as-directory project)))
132
Added:
```
133
Added:
134
Added:
Set `rail-project-root` to the project you work in. Every tool then uses
135
Added:
that project by default, wherever the tool file itself lives. Do not
136
Added:
write an absolute path from a home directory as a fixed constant in a
137
Added:
committed file, because that path differs on every machine.
138
Added:
139
Added:
The tool file also runs an upward search for `RAIL.org` at load time, from
140
Added:
its own directory and then from `default-directory`. That search finds the
141
Added:
project only when the tool file sits inside the project. For a shared
142
Added:
install, or when `default-directory` sits outside the project, set
143
Added:
`rail-project-root` as shown, or pass `root` to each tool call.
144
Added:
145
Added:
Then verify that the tools are present. This expression returns the
146
Added:
thirteen tool names:
147
Added:
148
Added:
```elisp
149
Added:
(seq-filter (lambda (name) (string-prefix-p "rail-" name))
150
Added:
(mcp-server-tools-list-names))
151
Added:
```
152
Added:
153
Added:
Confirm that the tools resolve the right project. This expression returns
154
Added:
the stream file path:
155
Added:
156
Added:
```elisp
157
Added:
(alist-get 'file (json-parse-string
158
Added:
(alist-get 'text (aref (mcp-server-tools-call
159
Added:
"rail-inspect" nil) 0))
160
Added:
:object-type 'alist))
161
Added:
```
162
Added:
163
Added:
If the load fails, stop and report the problem. Do not edit `RAIL.org` as
164
Added:
raw text instead.
165
Added:
166
Added:
### If the tools are not callable
167
Added:
168
Added:
The tools register inside Emacs. The MCP client lists its tools when it
169
Added:
connects, so a mid-session load can leave the tools absent from your own
170
Added:
tool list. In that case, call each tool through the dispatch path:
171
Added:
172
Added:
```elisp
173
Added:
(mcp-server-tools-call "rail-list" '((state . "TODO")))
174
Added:
```
175
Added:
176
Added:
This path runs the same handler as a direct tool call. To make the tools
177
Added:
callable directly, load `rail-tools.el` from the Emacs init file.
178
Added:
179
Added:
### Why dedicated tools
180
Added:
181
Added:
The generic `eval-elisp` tool sends its code through the Emacs security
182
Added:
form walker. The walker prompts for each file function, such as
183
Added:
`find-file-noselect` and `save-buffer`. The RAIL tools register as normal
184
Added:
MCP tools, and tool dispatch does not use the walker. The read-only tools
185
Added:
also carry a `readOnlyHint` annotation, so the client can approve them
186
Added:
without a prompt.
187
Added:
188
Added:
You **MUST** use these tools for every change to `RAIL.org`.
189
Added:
190
Added:
- You **MUST NOT** edit the file as raw text, because raw edits break the
191
Added:
timestamps, the IDs, and the tag alignment.
192
Added:
- You **MUST NOT** use a shell tool or a file tool for an item, because
193
Added:
those tools apply no validation.
194
Added:
195
Added:
## The RAIL tools
196
Added:
197
Added:
Each tool accepts an optional `root` argument. `root` names the project
198
Added:
directory that holds `RAIL.org`. When you omit `root`, the tools use
199
Added:
`rail-project-root`. Set `root` only for a different project.
200
Added:
201
Added:
| Tool | Purpose | Read-only |
202
Added:
|---|---|---|
203
Added:
| `rail-inspect` | Report the keyword sequence and the tag axes. | Yes |
204
Added:
| `rail-list` | List items, newest first, with ID, title, state, and tags. Accepts a `state` or `tag` filter. | Yes |
205
Added:
| `rail-show` | Report one item in full: body, logbook, checklist, and result. | Yes |
206
Added:
| `rail-verify` | Report the heading, state, `CLOSED` time, and tags of one item. | Yes |
207
Added:
| `rail-capture` | Create a `TODO` item at the top of the stream. | No |
208
Added:
| `rail-set-status` | Set the keyword of an item. Refuses `DONE`. | No |
209
Added:
| `rail-block` | Set `BLOCKED` and record a required reason. | No |
210
Added:
| `rail-cancel` | Set `CANCELLED` and record a required reason. | No |
211
Added:
| `rail-check` | Add, toggle, or list the checklist items of one item. | No |
212
Added:
| `rail-log` | Append a timestamped note to the `:LOGBOOK:` drawer. | No |
213
Added:
| `rail-retag` | Replace the tags of an item with a validated set. | No |
214
Added:
| `rail-set-result` | Write the `- result ::` line. | No |
215
Added:
| `rail-complete` | Set `DONE` after it records the result evidence. | No |
216
Added:
217
Added:
Use `rail-show` to read one item, and `rail-retag` to re-tag it. These
218
Added:
are the correct tools for those two actions, because the generic Emacs
219
Added:
`org-*` tools cannot operate on this file.
220
Added:
221
Added:
For the exact arguments and the result shape of each tool, read
222
Added:
`references/tools.md`.
223
Added:
224
Added:
## Step 1 — Capture the item
225
Added:
226
Added:
Run `rail-capture` with these arguments:
227
Added:
228
Added:
- `title` — a short imperative title under 60 characters.
229
Added:
- `body` — the full item text.
230
Added:
- `tags` — the tags that fit the item, from the file vocabulary.
231
Added:
232
Added:
The tool inserts the item at the top of the file. It records the capture
233
Added:
time as an inactive `SCHEDULED` timestamp. It applies the tags, wraps the
234
Added:
body to 72 columns, and assigns an Org ID. Report that ID to the user.
235
Added:
236
Added:
Keep the item text unchanged. If the text holds separate ideas, write the
237
Added:
body as a list. Pass the body as plain text, because the tool wraps it.
238
Added:
239
Added:
If `rail-capture` rejects a tag, run `rail-inspect` to read the
240
Added:
vocabulary. Then fix the tag and re-run the capture.
241
Added:
242
Added:
Then proceed to Step 2 when work starts on the item.
243
Added:
244
Added:
## Step 2 — Track the status
245
Added:
246
Added:
First find the item. Run `rail-list` to read the ID, the title, the
247
Added:
state, and the tags of each item. Pass a `state` filter or a `tag` filter
248
Added:
to narrow the list. Use the ID for every later call.
249
Added:
250
Added:
Read the current state with `rail-verify` before you change a keyword.
251
Added:
Then pick the tool from the target state:
252
Added:
253
Added:
- Target an open keyword, such as `IN-PROGRESS` → `rail-set-status`
254
Added:
- Target `BLOCKED` → `rail-block`, with a reason
255
Added:
- Target `CANCELLED` → `rail-cancel`, with a reason
256
Added:
- Target `DONE` → `rail-complete`, with commit and test evidence
257
Added:
258
Added:
Run `rail-verify` again after the change to confirm the keyword.
259
Added:
260
Added:
### Block or cancel with a reason
261
Added:
262
Added:
Run `rail-block` when work cannot continue. Run `rail-cancel` when you
263
Added:
stop work on purpose. Each tool needs a `reason`, and writes it to the
264
Added:
item.
265
Added:
266
Added:
You **MUST NOT** use `rail-set-status` here, because it records no reason.
267
Added:
268
Added:
### Split a large item with a checklist
269
Added:
270
Added:
Run `rail-check` when one item is large enough to track in parts:
271
Added:
272
Added:
- `action` `add` — append an unchecked item. Needs `item`.
273
Added:
- `action` `toggle` — flip one matching item. Needs `item`.
274
Added:
- `action` `list` — report the items and their state.
275
Added:
276
Added:
The tool keeps the items in a `Checklist [/]:` block in the body. The
277
Added:
`[/]` cookie counts the completed items against the total. The items stay
278
Added:
inside the one action item. They are not separate stream entries, and
279
Added:
they carry no keyword of their own. For independent work, capture a new
280
Added:
action item instead.
281
Added:
282
Added:
### Record progress in the logbook
283
Added:
284
Added:
Run `rail-log` with a `note` to record progress during a session. The
285
Added:
tool prepends a timestamped item to the `:LOGBOOK:` drawer. The logbook
286
Added:
is append-only. The tool never rewrites an earlier note, and never
287
Added:
rewrites the body.
288
Added:
289
Added:
Then proceed to Step 3 when every closing criterion is met.
290
Added:
291
Added:
## Step 3 — Close the item
292
Added:
293
Added:
Check each criterion before you close an item. Copy this list and mark
294
Added:
each item:
295
Added:
296
Added:
- [ ] The build passes.
297
Added:
- [ ] The tests pass.
298
Added:
- [ ] The files have the required format.
299
Added:
- [ ] A commit exists.
300
Added:
301
Added:
If one criterion fails, do not close the item. Set the state with
302
Added:
`rail-set-status` instead. Then fix the failure and re-run this check.
303
Added:
304
Added:
When every item is marked, run `rail-complete` with these arguments:
305
Added:
306
Added:
- `id` — the Org ID of the item.
307
Added:
- `commit` — the commit hash of the completed work.
308
Added:
- `tests` — a short test recap, for example `215 pass`.
309
Added:
- `model` — optional. The agent that did the work.
310
Added:
- `notes` — optional. A short tail, such as a root cause.
311
Added:
312
Added:
The tool writes the result line, sets `DONE`, then confirms that Org
313
Added:
recorded the `CLOSED` timestamp. Org writes that timestamp, because the
314
Added:
file sets `org-log-done` to `time`.
315
Added:
316
Added:
You **MUST NOT** write or edit that timestamp, because Org owns it.
317
Added:
318
Added:
The result line takes this form. It stays on one line, because the reader
319
Added:
reads one line:
320
Added:
321
Added:
```org
322
Added:
- result :: model=example-agent commit=3051af0 tests=243 pass; short root cause
323
Added:
```
324
Added:
325
Added:
The item stays in place in the stream. There is no refile step.
326
Added:
327
Added:
Run `rail-verify` last. Verify that the state is `DONE` and that the
328
Added:
`CLOSED` timestamp is present. To write the result line before you close
329
Added:
the item, run `rail-set-result` on its own.
330
Added:
331
Added:
## Test the tools
332
Added:
333
Added:
The tools carry a test suite. Run it after any change to `rail-tools.el`:
334
Added:
335
Added:
```sh
336
Added:
.kiro/skills/rail/run-tests.sh
337
Added:
```
338
Added:
339
Added:
The suite needs no MCP framework and no configuration. Each test uses a
340
Added:
temporary stream file, so the project stream file stays unchanged.
341
Added:
342
Added:
## Common mistakes
343
Added:
344
Added:
- **Editing the file as raw text.** The tools keep the timestamps, the
345
Added:
IDs, and the tag alignment correct. Raw edits do not.
346
Added:
- **Selecting an item by title.** Two titles can match. Use the Org ID
347
Added:
from `rail-list`.
348
Added:
- **Using `rail-set-status` for `DONE`.** That call fails by design. Use
349
Added:
`rail-complete`, which demands the evidence.
350
Added:
- **Writing a `CLOSED` timestamp.** Org writes it. The tool only confirms
351
Added:
it.
352
Added:
- **Merging two items.** Record each prompt as its own action item.
353
Added:
354
Added:
If you cannot complete an item, record the item and record the failure.
skills/rail/rail-tests.el
@@ -0,0 +1,481 @@
1
Added:
;;; rail-tests.el --- End-to-end tests for the RAIL tools -*- lexical-binding: t; -*-
2
Added:
3
Added:
;;; Commentary:
4
Added:
5
Added:
;; These tests exercise the RAIL handler functions directly. The MCP
6
Added:
;; framework calls each handler with one alist of arguments, so a direct call
7
Added:
;; follows the same path as a tool call. Each test runs against a temporary
8
Added:
;; stream file, so no test touches the project stream file.
9
Added:
;;
10
Added:
;; Run the tests with the runner script in this directory:
11
Added:
;;
12
Added:
;; ./run-tests.sh
13
Added:
14
Added:
;;; Code:
15
Added:
16
Added:
(require 'ert)
17
Added:
(require 'json)
18
Added:
19
Added:
;;; Fixtures
20
Added:
21
Added:
(defvar rail-tests--preamble
22
Added:
(concat "#+TITLE: Test stream\n"
23
Added:
"#+TODO: TODO IN-PROGRESS TESTING TESTED BLOCKED | CANCELLED DONE\n"
24
Added:
"#+TAGS: [ Kind : feat fix refactor chore docs ]\n"
25
Added:
"#+TAGS: [ Scope : core app web ui ]\n"
26
Added:
"#+TAGS: [ Impact : minor major ]\n"
27
Added:
"\n"
28
Added:
"# Local Variables:\n"
29
Added:
"# org-log-done: time\n"
30
Added:
"# End:\n")
31
Added:
"Preamble of the temporary stream file.
32
Added:
The `#+TODO:' line gives the keyword sequence. The `#+TAGS:' lines give
33
Added:
the tag vocabulary, grouped into axes. The local variable
34
Added:
`org-log-done' makes Org write a CLOSED timestamp.")
35
Added:
36
Added:
(defun rail-tests--decode (json-string)
37
Added:
"Return JSON-STRING decoded into Lisp with alists for objects.
38
Added:
Decode JSON null to nil and JSON false to `:json-false', which are the
39
Added:
same sentinels that the handlers encode from. A round trip therefore
40
Added:
gives back the value that the handler started with."
41
Added:
(json-parse-string json-string
42
Added:
:object-type 'alist
43
Added:
:null-object nil
44
Added:
:false-object :json-false))
45
Added:
46
Added:
(defun rail-tests--call (handler args)
47
Added:
"Call HANDLER with ARGS and return the decoded result.
48
Added:
Signal an error when the handler reports one, so a failure is visible."
49
Added:
(let ((result (rail-tests--decode (funcall handler args))))
50
Added:
(when (alist-get 'error result)
51
Added:
(error "Handler failed: %s" (alist-get 'error result)))
52
Added:
result))
53
Added:
54
Added:
(defmacro rail-tests--with-stream (root &rest body)
55
Added:
"Create a temporary project directory, bind ROOT to it, then run BODY.
56
Added:
Delete the directory and its buffers after BODY."
57
Added:
(declare (indent 1))
58
Added:
`(let* ((,root (file-name-as-directory (make-temp-file "rail-test" t)))
59
Added:
(file (expand-file-name rail-stream-file-name ,root))
60
Added:
(enable-local-variables :all)
61
Added:
(org-id-track-globally nil)
62
Added:
(create-lockfiles nil))
63
Added:
(unwind-protect
64
Added:
(progn
65
Added:
(with-temp-file file (insert rail-tests--preamble))
66
Added:
,@body)
67
Added:
(dolist (buf (buffer-list))
68
Added:
(when (and (buffer-file-name buf)
69
Added:
(string-prefix-p ,root (buffer-file-name buf)))
70
Added:
(with-current-buffer buf (set-buffer-modified-p nil))
71
Added:
(kill-buffer buf)))
72
Added:
(delete-directory ,root t))))
73
Added:
74
Added:
(defun rail-tests--capture (root title tags &optional body)
75
Added:
"Capture a request in ROOT with TITLE, TAGS, and optional BODY.
76
Added:
Return the new entry's Org ID."
77
Added:
(alist-get 'id (rail-tests--call
78
Added:
#'rail-tools--capture-handler
79
Added:
(list (cons 'root root)
80
Added:
(cons 'title title)
81
Added:
(cons 'tags tags)
82
Added:
(cons 'body (or body "Request body."))))))
83
Added:
84
Added:
(defun rail-tests--file-text (root)
85
Added:
"Return the text of the stream file in ROOT."
86
Added:
(with-temp-buffer
87
Added:
(insert-file-contents (expand-file-name rail-stream-file-name root))
88
Added:
(buffer-string)))
89
Added:
90
Added:
;;; Root discovery
91
Added:
92
Added:
(ert-deftest rail-test-locate-root-finds-the-stream-file ()
93
Added:
"`rail-locate-root' finds the root from a nested directory."
94
Added:
(rail-tests--with-stream root
95
Added:
(let ((nested (expand-file-name "a/b/c/" root)))
96
Added:
(make-directory nested t)
97
Added:
(should (equal (rail-locate-root nested) root)))))
98
Added:
99
Added:
(ert-deftest rail-test-locate-root-returns-nil-without-a-stream-file ()
100
Added:
"`rail-locate-root' returns nil when no ancestor holds the file."
101
Added:
(let ((empty (file-name-as-directory (make-temp-file "rail-empty" t))))
102
Added:
(unwind-protect
103
Added:
(should (null (rail-locate-root empty)))
104
Added:
(delete-directory empty t))))
105
Added:
106
Added:
(ert-deftest rail-test-root-argument-overrides-the-default ()
107
Added:
"An explicit `root' argument selects the file, not `rail-project-root'."
108
Added:
(rail-tests--with-stream root
109
Added:
(should (equal (rail-tools--file (list (cons 'root root)))
110
Added:
(expand-file-name rail-stream-file-name root)))))
111
Added:
112
Added:
(ert-deftest rail-test-absent-root-uses-the-default ()
113
Added:
"An absent `root' argument falls back to `rail-project-root'."
114
Added:
(rail-tests--with-stream root
115
Added:
(let ((rail-project-root root))
116
Added:
(should (equal (rail-tools--file nil)
117
Added:
(expand-file-name rail-stream-file-name root))))))
118
Added:
119
Added:
(ert-deftest rail-test-a-missing-directory-signals-an-error ()
120
Added:
"A `root' that is not a directory signals an error."
121
Added:
(should-error (rail-tools--file
122
Added:
(list (cons 'root "/rail/no/such/directory")))))
123
Added:
124
Added:
;;; Capture
125
Added:
126
Added:
(ert-deftest rail-test-capture-creates-an-addressable-todo ()
127
Added:
"Capture writes a TODO entry with an ID, a SCHEDULED time, and tags."
128
Added:
(rail-tests--with-stream root
129
Added:
(let* ((id (rail-tests--capture root "Add a widget" ["feat" "web"]))
130
Added:
(entry (rail-tests--call #'rail-tools--show-handler
131
Added:
(list (cons 'root root) (cons 'id id)))))
132
Added:
(should (stringp id))
133
Added:
(should (equal (alist-get 'state entry) "TODO"))
134
Added:
(should (equal (alist-get 'heading entry) "Add a widget"))
135
Added:
(should (equal (append (alist-get 'tags entry) nil) '("feat" "web")))
136
Added:
(should (stringp (alist-get 'scheduled entry)))
137
Added:
(should (null (alist-get 'closed entry))))))
138
Added:
139
Added:
(ert-deftest rail-test-capture-puts-the-newest-request-first ()
140
Added:
"Capture inserts each new request above the previous request."
141
Added:
(rail-tests--with-stream root
142
Added:
(rail-tests--capture root "First request" ["feat" "core"])
143
Added:
(rail-tests--capture root "Second request" ["fix" "app"])
144
Added:
(let ((rows (rail-tests--decode
145
Added:
(rail-tools--list-handler (list (cons 'root root))))))
146
Added:
(should (equal (length rows) 2))
147
Added:
(should (equal (alist-get 'title (aref rows 0)) "Second request"))
148
Added:
(should (equal (alist-get 'title (aref rows 1)) "First request")))))
149
Added:
150
Added:
(ert-deftest rail-test-capture-rejects-a-tag-outside-the-vocabulary ()
151
Added:
"Capture rejects any tag that the closed vocabulary does not hold."
152
Added:
(rail-tests--with-stream root
153
Added:
(let ((result (rail-tests--decode
154
Added:
(rail-tools--capture-handler
155
Added:
(list (cons 'root root)
156
Added:
(cons 'title "Bad tags")
157
Added:
(cons 'tags ["feat" "trivial"]))))))
158
Added:
(should (string-match-p "Unknown tag" (alist-get 'error result))))))
159
Added:
160
Added:
;;; Status
161
Added:
162
Added:
(ert-deftest rail-test-set-status-moves-through-the-open-keywords ()
163
Added:
"Set-status accepts each open keyword from the file's own sequence."
164
Added:
(rail-tests--with-stream root
165
Added:
(let ((id (rail-tests--capture root "Track status" ["feat" "core"])))
166
Added:
(dolist (state '("IN-PROGRESS" "TESTING" "TESTED"))
167
Added:
(let ((result (rail-tests--call
168
Added:
#'rail-tools--set-status-handler
169
Added:
(list (cons 'root root) (cons 'id id)
170
Added:
(cons 'state state)))))
171
Added:
(should (equal (alist-get 'state result) state))))
172
Added:
(should (equal (alist-get 'state (rail-tests--call
173
Added:
#'rail-tools--verify-handler
174
Added:
(list (cons 'root root) (cons 'id id))))
175
Added:
"TESTED")))))
176
Added:
177
Added:
(ert-deftest rail-test-set-status-refuses-done ()
178
Added:
"Set-status refuses DONE, because completion needs result evidence."
179
Added:
(rail-tests--with-stream root
180
Added:
(let* ((id (rail-tests--capture root "Refuse done" ["feat" "core"]))
181
Added:
(result (rail-tests--decode
182
Added:
(rail-tools--set-status-handler
183
Added:
(list (cons 'root root) (cons 'id id)
184
Added:
(cons 'state "DONE"))))))
185
Added:
(should (string-match-p "rail-complete" (alist-get 'error result))))))
186
Added:
187
Added:
(ert-deftest rail-test-block-and-cancel-record-a-reason ()
188
Added:
"Block and cancel write the reason, so the decision is never silent."
189
Added:
(rail-tests--with-stream root
190
Added:
(let ((blocked (rail-tests--capture root "Blocked work" ["feat" "app"]))
191
Added:
(dropped (rail-tests--capture root "Dropped work" ["feat" "ui"])))
192
Added:
(rail-tests--call #'rail-tools--block-handler
193
Added:
(list (cons 'root root) (cons 'id blocked)
194
Added:
(cons 'reason "The route does not exist")))
195
Added:
(rail-tests--call #'rail-tools--cancel-handler
196
Added:
(list (cons 'root root) (cons 'id dropped)
197
Added:
(cons 'reason "The user withdrew the request")))
198
Added:
(let ((text (rail-tests--file-text root)))
199
Added:
(should (string-match-p "- blocked :: The route does not exist" text))
200
Added:
(should (string-match-p "- cancelled :: The user withdrew" text)))
201
Added:
(should (equal "BLOCKED"
202
Added:
(alist-get 'state (rail-tests--call
203
Added:
#'rail-tools--verify-handler
204
Added:
(list (cons 'root root)
205
Added:
(cons 'id blocked))))))
206
Added:
(should (equal "CANCELLED"
207
Added:
(alist-get 'state (rail-tests--call
208
Added:
#'rail-tools--verify-handler
209
Added:
(list (cons 'root root)
210
Added:
(cons 'id dropped)))))))))
211
Added:
212
Added:
(ert-deftest rail-test-block-requires-a-reason ()
213
Added:
"Block refuses a blank reason."
214
Added:
(rail-tests--with-stream root
215
Added:
(let* ((id (rail-tests--capture root "Needs a reason" ["feat" "app"]))
216
Added:
(result (rail-tests--decode
217
Added:
(rail-tools--block-handler
218
Added:
(list (cons 'root root) (cons 'id id)
219
Added:
(cons 'reason " "))))))
220
Added:
(should (string-match-p "reason" (alist-get 'error result))))))
221
Added:
222
Added:
;;; Checklist, logbook, and tags
223
Added:
224
Added:
(ert-deftest rail-test-checklist-adds-and-toggles-items ()
225
Added:
"The checklist adds an item, then toggles it, and reports booleans."
226
Added:
(rail-tests--with-stream root
227
Added:
(let ((id (rail-tests--capture root "Split the task" ["feat" "core"])))
228
Added:
(let ((added (rail-tests--call
229
Added:
#'rail-tools--check-handler
230
Added:
(list (cons 'root root) (cons 'id id)
231
Added:
(cons 'action "add") (cons 'item "step one")))))
232
Added:
(should (equal (alist-get 'done (aref (alist-get 'items added) 0))
233
Added:
:json-false)))
234
Added:
(let ((toggled (rail-tests--call
235
Added:
#'rail-tools--check-handler
236
Added:
(list (cons 'root root) (cons 'id id)
237
Added:
(cons 'action "toggle") (cons 'item "step one")))))
238
Added:
(should (eq (alist-get 'done (aref (alist-get 'items toggled) 0)) t)))
239
Added:
(should (string-match-p "Checklist \\[1/1\\]" (rail-tests--file-text root))))))
240
Added:
241
Added:
(ert-deftest rail-test-log-appends-and-keeps-earlier-notes ()
242
Added:
"The logbook keeps every note, newest first."
243
Added:
(rail-tests--with-stream root
244
Added:
(let ((id (rail-tests--capture root "Log progress" ["feat" "core"])))
245
Added:
(rail-tests--call #'rail-tools--log-handler
246
Added:
(list (cons 'root root) (cons 'id id)
247
Added:
(cons 'note "First note")))
248
Added:
(rail-tests--call #'rail-tools--log-handler
249
Added:
(list (cons 'root root) (cons 'id id)
250
Added:
(cons 'note "Second note")))
251
Added:
(let* ((entry (rail-tests--call #'rail-tools--show-handler
252
Added:
(list (cons 'root root) (cons 'id id))))
253
Added:
(notes (append (alist-get 'logbook entry) nil)))
254
Added:
(should (equal (length notes) 2))
255
Added:
(should (string-match-p "Second note" (nth 0 notes)))
256
Added:
(should (string-match-p "First note" (nth 1 notes)))))))
257
Added:
258
Added:
(ert-deftest rail-test-retag-validates-against-the-vocabulary ()
259
Added:
"Retag replaces the tags, and refuses a tag outside the vocabulary."
260
Added:
(rail-tests--with-stream root
261
Added:
(let ((id (rail-tests--capture root "Retag me" ["feat" "core"])))
262
Added:
(let ((result (rail-tests--call
263
Added:
#'rail-tools--retag-handler
264
Added:
(list (cons 'root root) (cons 'id id)
265
Added:
(cons 'tags ["fix" "web" "minor"])))))
266
Added:
(should (equal (append (alist-get 'tags result) nil)
267
Added:
'("fix" "web" "minor"))))
268
Added:
(let ((result (rail-tests--decode
269
Added:
(rail-tools--retag-handler
270
Added:
(list (cons 'root root) (cons 'id id)
271
Added:
(cons 'tags ["nonsense"]))))))
272
Added:
(should (string-match-p "Unknown tag" (alist-get 'error result)))))))
273
Added:
274
Added:
;;; Result and completion
275
Added:
276
Added:
(ert-deftest rail-test-set-result-writes-model-and-notes ()
277
Added:
"The result line holds the model, the commit, the tests, and the notes."
278
Added:
(rail-tests--with-stream root
279
Added:
(let* ((id (rail-tests--capture root "Record a result" ["fix" "core"]))
280
Added:
(result (rail-tests--call
281
Added:
#'rail-tools--set-result-handler
282
Added:
(list (cons 'root root) (cons 'id id)
283
Added:
(cons 'commit "abc1234") (cons 'tests "246 pass")
284
Added:
(cons 'model "test-agent")
285
Added:
(cons 'notes "the sentinel was wrong")))))
286
Added:
(should (equal (alist-get 'result result)
287
Added:
"model=test-agent commit=abc1234 tests=246 pass; the sentinel was wrong")))))
288
Added:
289
Added:
(ert-deftest rail-test-set-result-omits-absent-optional-fields ()
290
Added:
"The result line holds only the commit and the tests when nothing else is given."
291
Added:
(rail-tests--with-stream root
292
Added:
(let* ((id (rail-tests--capture root "Plain result" ["fix" "core"]))
293
Added:
(result (rail-tests--call
294
Added:
#'rail-tools--set-result-handler
295
Added:
(list (cons 'root root) (cons 'id id)
296
Added:
(cons 'commit "abc1234") (cons 'tests "246 pass")))))
297
Added:
(should (equal (alist-get 'result result)
298
Added:
"commit=abc1234 tests=246 pass")))))
299
Added:
300
Added:
(ert-deftest rail-test-set-result-replaces-an-earlier-line ()
301
Added:
"A second result call replaces the earlier result line."
302
Added:
(rail-tests--with-stream root
303
Added:
(let ((id (rail-tests--capture root "Replace result" ["fix" "core"])))
304
Added:
(dolist (commit '("aaa1111" "bbb2222"))
305
Added:
(rail-tests--call #'rail-tools--set-result-handler
306
Added:
(list (cons 'root root) (cons 'id id)
307
Added:
(cons 'commit commit) (cons 'tests "1 pass"))))
308
Added:
(let ((text (rail-tests--file-text root)))
309
Added:
(should-not (string-match-p "aaa1111" text))
310
Added:
(should (string-match-p "bbb2222" text))))))
311
Added:
312
Added:
(ert-deftest rail-test-complete-sets-done-with-result-and-closed ()
313
Added:
"Completion writes the result, sets DONE, and confirms the CLOSED time."
314
Added:
(rail-tests--with-stream root
315
Added:
(let* ((id (rail-tests--capture root "Finish the work" ["feat" "core"]))
316
Added:
(result (rail-tests--call
317
Added:
#'rail-tools--complete-handler
318
Added:
(list (cons 'root root) (cons 'id id)
319
Added:
(cons 'commit "def5678") (cons 'tests "247 pass")
320
Added:
(cons 'model "test-agent")))))
321
Added:
(should (equal (alist-get 'state result) "DONE"))
322
Added:
(should (string-match-p "commit=def5678" (alist-get 'result result)))
323
Added:
(should (string-match-p "\\[.*\\]" (alist-get 'closed result)))
324
Added:
(let ((entry (rail-tests--call #'rail-tools--verify-handler
325
Added:
(list (cons 'root root) (cons 'id id)))))
326
Added:
(should (equal (alist-get 'state entry) "DONE"))
327
Added:
(should (stringp (alist-get 'closed entry)))))))
328
Added:
329
Added:
(ert-deftest rail-test-complete-refuses-a-file-without-closed-logging ()
330
Added:
"Completion refuses to run when the file does not log a CLOSED time.
331
Added:
Org must write the CLOSED timestamp, so the tool never writes it."
332
Added:
(let* ((root (file-name-as-directory (make-temp-file "rail-nolog" t)))
333
Added:
(file (expand-file-name rail-stream-file-name root))
334
Added:
(enable-local-variables :all)
335
Added:
(org-id-track-globally nil)
336
Added:
(create-lockfiles nil)
337
Added:
(org-log-done nil))
338
Added:
(unwind-protect
339
Added:
(progn
340
Added:
;; This preamble holds no `org-log-done' local variable.
341
Added:
(with-temp-file file
342
Added:
(insert "#+TITLE: No logging\n"
343
Added:
"#+TODO: TODO IN-PROGRESS | CANCELLED DONE\n"))
344
Added:
(let ((id (rail-tests--capture root "No logging" ["feat" "core"])))
345
Added:
;; Close the buffer, so the next open reads the local variables
346
Added:
;; of the file as it now stands.
347
Added:
(dolist (buf (buffer-list))
348
Added:
(when (equal (buffer-file-name buf) file)
349
Added:
(with-current-buffer buf (set-buffer-modified-p nil))
350
Added:
(kill-buffer buf)))
351
Added:
(let ((result (rail-tests--decode
352
Added:
(rail-tools--complete-handler
353
Added:
(list (cons 'root root) (cons 'id id)
354
Added:
(cons 'commit "def5678")
355
Added:
(cons 'tests "1 pass"))))))
356
Added:
(should (stringp (alist-get 'error result)))
357
Added:
(should (string-match-p "org-log-done"
358
Added:
(alist-get 'error result))))))
359
Added:
(dolist (buf (buffer-list))
360
Added:
(when (and (buffer-file-name buf)
361
Added:
(string-prefix-p root (buffer-file-name buf)))
362
Added:
(with-current-buffer buf (set-buffer-modified-p nil))
363
Added:
(kill-buffer buf)))
364
Added:
(delete-directory root t))))
365
Added:
366
Added:
;;; Reading
367
Added:
368
Added:
(ert-deftest rail-test-list-encodes-an-absent-value-as-json-null ()
369
Added:
"A heading without an ID reports JSON null, not the text \"null\"."
370
Added:
(rail-tests--with-stream root
371
Added:
(let ((file (expand-file-name rail-stream-file-name root)))
372
Added:
(with-current-buffer (find-file-noselect file)
373
Added:
(goto-char (point-min))
374
Added:
(insert "* TODO Entry without an identifier :feat:core:\n")
375
Added:
(save-buffer))
376
Added:
(let* ((json (rail-tools--list-handler (list (cons 'root root))))
377
Added:
(rows (rail-tests--decode json)))
378
Added:
(should (null (alist-get 'id (aref rows 0))))
379
Added:
(should-not (string-match-p "\"null\"" json))))))
380
Added:
381
Added:
(ert-deftest rail-test-list-filters-by-state-and-tag ()
382
Added:
"The list filters narrow the result by TODO keyword and by tag."
383
Added:
(rail-tests--with-stream root
384
Added:
(let ((first (rail-tests--capture root "Filter one" ["feat" "core"])))
385
Added:
(rail-tests--capture root "Filter two" ["fix" "web"])
386
Added:
(rail-tests--call #'rail-tools--set-status-handler
387
Added:
(list (cons 'root root) (cons 'id first)
388
Added:
(cons 'state "IN-PROGRESS")))
389
Added:
(let ((by-state (rail-tests--decode
390
Added:
(rail-tools--list-handler
391
Added:
(list (cons 'root root) (cons 'state "IN-PROGRESS")))))
392
Added:
(by-tag (rail-tests--decode
393
Added:
(rail-tools--list-handler
394
Added:
(list (cons 'root root) (cons 'tag "web"))))))
395
Added:
(should (equal (length by-state) 1))
396
Added:
(should (equal (alist-get 'title (aref by-state 0)) "Filter one"))
397
Added:
(should (equal (length by-tag) 1))
398
Added:
(should (equal (alist-get 'title (aref by-tag 0)) "Filter two"))))))
399
Added:
400
Added:
(ert-deftest rail-test-inspect-reports-the-sequence-and-the-axes ()
401
Added:
"Inspect reports the file's keyword sequence and the file's tag axes."
402
Added:
(rail-tests--with-stream root
403
Added:
(let* ((result (rail-tests--call #'rail-tools--inspect-handler
404
Added:
(list (cons 'root root))))
405
Added:
(keywords (append (alist-get 'todo_keywords result) nil))
406
Added:
(axes (alist-get 'tags result)))
407
Added:
(should (member "IN-PROGRESS" keywords))
408
Added:
(should (member "DONE" keywords))
409
Added:
;; The axes come from the file's own #+TAGS: lines, not from the code.
410
Added:
(should (equal (mapcar #'car axes) '(kind scope impact)))
411
Added:
(should (equal (append (alist-get 'impact axes) nil) '("minor" "major")))
412
Added:
(should (equal (append (alist-get 'scope axes) nil) '("core" "app" "web" "ui"))))))
413
Added:
414
Added:
(ert-deftest rail-test-a-file-without-tags-accepts-any-tag ()
415
Added:
"A file that declares no #+TAGS: vocabulary accepts any tag.
416
Added:
The tool carries no vocabulary of its own, so an undeclared file places
417
Added:
no restriction on the tags."
418
Added:
(let* ((root (file-name-as-directory (make-temp-file "rail-notags" t)))
419
Added:
(file (expand-file-name rail-stream-file-name root))
420
Added:
(org-id-track-globally nil)
421
Added:
(create-lockfiles nil))
422
Added:
(unwind-protect
423
Added:
(progn
424
Added:
(with-temp-file file
425
Added:
(insert "#+TITLE: No vocabulary\n"
426
Added:
"#+TODO: TODO | DONE\n"))
427
Added:
(let* ((cap (rail-tests--call
428
Added:
#'rail-tools--capture-handler
429
Added:
(list (cons 'root root)
430
Added:
(cons 'title "Any tag is fine")
431
Added:
(cons 'tags ["anything" "at" "all"]))))
432
Added:
(id (alist-get 'id cap)))
433
Added:
(should (stringp id))
434
Added:
(should (equal (append (alist-get 'tags cap) nil)
435
Added:
'("anything" "at" "all")))))
436
Added:
(dolist (buf (buffer-list))
437
Added:
(when (and (buffer-file-name buf)
438
Added:
(string-prefix-p root (buffer-file-name buf)))
439
Added:
(with-current-buffer buf (set-buffer-modified-p nil))
440
Added:
(kill-buffer buf)))
441
Added:
(delete-directory root t))))
442
Added:
443
Added:
;;; The full path
444
Added:
445
Added:
(ert-deftest rail-test-the-full-request-path-runs-end-to-end ()
446
Added:
"One request moves from capture to DONE through every step."
447
Added:
(rail-tests--with-stream root
448
Added:
(let ((id (rail-tests--capture root "Ship the feature" ["feat" "web"]
449
Added:
"Add a widget to the page.")))
450
Added:
(rail-tests--call #'rail-tools--check-handler
451
Added:
(list (cons 'root root) (cons 'id id)
452
Added:
(cons 'action "add") (cons 'item "write the code")))
453
Added:
(rail-tests--call #'rail-tools--set-status-handler
454
Added:
(list (cons 'root root) (cons 'id id)
455
Added:
(cons 'state "IN-PROGRESS")))
456
Added:
(rail-tests--call #'rail-tools--log-handler
457
Added:
(list (cons 'root root) (cons 'id id)
458
Added:
(cons 'note "Started the work")))
459
Added:
(rail-tests--call #'rail-tools--check-handler
460
Added:
(list (cons 'root root) (cons 'id id)
461
Added:
(cons 'action "toggle")
462
Added:
(cons 'item "write the code")))
463
Added:
(rail-tests--call #'rail-tools--set-status-handler
464
Added:
(list (cons 'root root) (cons 'id id)
465
Added:
(cons 'state "TESTED")))
466
Added:
(rail-tests--call #'rail-tools--complete-handler
467
Added:
(list (cons 'root root) (cons 'id id)
468
Added:
(cons 'commit "0badc0de") (cons 'tests "3 pass")
469
Added:
(cons 'model "test-agent")))
470
Added:
(let ((entry (rail-tests--call #'rail-tools--show-handler
471
Added:
(list (cons 'root root) (cons 'id id)))))
472
Added:
(should (equal (alist-get 'state entry) "DONE"))
473
Added:
(should (stringp (alist-get 'closed entry)))
474
Added:
(should (string-match-p "commit=0badc0de" (alist-get 'result entry)))
475
Added:
(should (equal (length (alist-get 'logbook entry)) 1))
476
Added:
(should (eq (alist-get 'done (aref (alist-get 'checklist entry) 0)) t))
477
Added:
(should (string-match-p "Add a widget" (alist-get 'body entry)))))))
478
Added:
479
Added:
(provide 'rail-tests)
480
Added:
481
Added:
;;; rail-tests.el ends here
skills/rail/rail-tools.el
@@ -0,0 +1,968 @@
1
Added:
;;; rail-tools.el --- RAIL MCP tools for RAIL.org -*- lexical-binding: t; -*-
2
Added:
3
Added:
;; Copyright (C) 2025
4
Added:
5
Added:
;; This file is NOT part of GNU Emacs.
6
Added:
7
Added:
;;; Commentary:
8
Added:
9
Added:
;; RAIL means "Rolling Action Item List".
10
Added:
;;
11
Added:
;; This file registers dedicated Emacs MCP tools for the RAIL skill so the
12
Added:
;; agent does not run raw `eval-elisp' snippets for each capture, status
13
Added:
;; change, log, result, or verification. Each operation becomes a named tool.
14
Added:
;;
15
Added:
;; RAIL.org is a flat stream. Each action item is a top-level heading, newest
16
Added:
;; first, tagged from a vocabulary that the file itself declares. The file
17
Added:
;; holds no container heading.
18
Added:
;;
19
Added:
;; The tools carry no project vocabulary and no project workflow. The stream
20
Added:
;; file owns both. The `#+TODO:' line declares the status keywords. The
21
Added:
;; `#+TAGS:' lines declare the tag vocabulary, grouped into axes. The tools
22
Added:
;; read both from the file, so one tool file serves every project.
23
Added:
;;
24
Added:
;; Why dedicated tools instead of `eval-elisp'?
25
Added:
;;
26
Added:
;; The `eval-elisp' tool routes its argument through
27
Added:
;; `mcp-server-security-safe-eval', whose form walker blocks or prompts for
28
Added:
;; "dangerous" functions such as `find-file-noselect', `write-file', and
29
Added:
;; `save-buffer'. With `mcp-server-security-prompt-for-permissions' set to t,
30
Added:
;; every RAIL snippet triggers a minibuffer prompt.
31
Added:
;;
32
Added:
;; A registered MCP tool runs through `mcp-server-tools-call', which calls the
33
Added:
;; handler function directly and does NOT pass through the form walker. The
34
Added:
;; handlers below therefore run without the repeated security prompt. Each
35
Added:
;; tool also carries MCP `annotations' so the MCP client can auto-approve the
36
Added:
;; read-only tools.
37
Added:
;;
38
Added:
;; The tools operate only on the file "RAIL.org". They find that file
39
Added:
;; under `rail-project-root', or under a caller-supplied project ROOT that
40
Added:
;; overrides it. They never touch any other file.
41
Added:
;;
42
Added:
;; `rail-project-root' comes from an upward search for the stream file. The
43
Added:
;; search starts at this file's own directory, then at `default-directory'. It
44
Added:
;; assumes no directory layout, so this file needs no absolute path and it
45
Added:
;; works on every machine.
46
Added:
;;
47
Added:
;; Install the tools once per Emacs session. Load this file, and the tools
48
Added:
;; register themselves. With `mcp-server-emacs-tools-enabled' set to `all',
49
Added:
;; which is the default, they appear in the MCP tool list at once.
50
Added:
;;
51
Added:
;; The MCP framework is a soft dependency. When the framework is absent, for
52
Added:
;; example in a batch test run, this file still loads and every handler stays
53
Added:
;; callable. Run the test suite with the run-tests.sh script beside this file.
54
Added:
55
Added:
;;; Code:
56
Added:
57
Added:
(require 'cl-lib)
58
Added:
(require 'org)
59
Added:
(require 'org-id)
60
Added:
(require 'json)
61
Added:
(require 'subr-x)
62
Added:
63
Added:
;; Load the MCP tool framework when it is available. When it is absent, for
64
Added:
;; example in a batch test run, define the two symbols the registrations below
65
Added:
;; need and discard each registration. The handler functions stay callable, so
66
Added:
;; the test suite runs on any machine without the framework.
67
Added:
(defconst rail-mcp-available (require 'mcp-server-tools nil t)
68
Added:
"Non-nil when the Emacs MCP tool framework is available.")
69
Added:
70
Added:
(unless rail-mcp-available
71
Added:
;; Define plain functions, never a struct. A stub struct would clobber the
72
Added:
;; real slot layout if the framework loads later in the same session.
73
Added:
(defun make-mcp-server-tool (&rest _args)
74
Added:
"Return nil. The MCP framework is absent."
75
Added:
nil)
76
Added:
(defun mcp-server-register-tool (_tool)
77
Added:
"Discard _TOOL. The MCP framework is absent."
78
Added:
nil))
79
Added:
80
Added:
(defvar rail-stream-file-name "RAIL.org"
81
Added:
"Name of the Org file that RAIL manages.")
82
Added:
83
Added:
(defvar rail-tools-path
84
Added:
(let ((file (or load-file-name buffer-file-name)))
85
Added:
(and file (expand-file-name file)))
86
Added:
"Absolute path of this file, or nil when the path is unknown.")
87
Added:
88
Added:
(defun rail-locate-root (start)
89
Added:
"Return the closest directory at or above START that holds the stream file.
90
Added:
The stream file is `rail-stream-file-name'. Return nil when no
91
Added:
ancestor directory holds that file."
92
Added:
(let ((dir (and start (locate-dominating-file
93
Added:
(file-name-as-directory (expand-file-name start))
94
Added:
rail-stream-file-name))))
95
Added:
(and dir (expand-file-name (file-name-as-directory dir)))))
96
Added:
97
Added:
(defvar rail-project-root
98
Added:
(or (rail-locate-root (and rail-tools-path
99
Added:
(file-name-directory rail-tools-path)))
100
Added:
(rail-locate-root default-directory)
101
Added:
(expand-file-name default-directory))
102
Added:
"Default project directory that holds the RAIL stream file.
103
Added:
The value comes from an upward search for `rail-stream-file-name',
104
Added:
first from this file's own directory, then from `default-directory'.
105
Added:
The search makes no assumption about the depth of this file in the
106
Added:
project. Set this variable to override the search, or pass a `root'
107
Added:
argument to any tool.")
108
Added:
109
Added:
;;; Helpers
110
Added:
111
Added:
(defun rail-tools--file (args)
112
Added:
"Return the absolute path of the stream file for ARGS.
113
Added:
ARGS may hold a `root' string that names the project directory. When
114
Added:
`root' is absent, use `rail-project-root'. Signal an error when the
115
Added:
selected root is not a directory."
116
Added:
(let ((root (or (alist-get 'root args) rail-project-root)))
117
Added:
(unless (and (stringp root) (> (length root) 0))
118
Added:
(error "No project root: pass `root' or set `rail-project-root'"))
119
Added:
(let ((dir (expand-file-name root)))
120
Added:
(unless (file-directory-p dir)
121
Added:
(error "Not a directory: %s" dir))
122
Added:
(expand-file-name rail-stream-file-name dir))))
123
Added:
124
Added:
(defun rail-tools--buffer (file)
125
Added:
"Return an org-mode buffer visiting FILE, creating it as needed."
126
Added:
(let ((buf (find-file-noselect file)))
127
Added:
(with-current-buffer buf
128
Added:
(unless (derived-mode-p 'org-mode)
129
Added:
(org-mode)))
130
Added:
buf))
131
Added:
132
Added:
(defun rail-tools--goto-id (id)
133
Added:
"Move point to the heading with Org ID in the current buffer.
134
Added:
Signal an error when ID is not found."
135
Added:
(let ((marker (org-id-find id 'marker)))
136
Added:
(unless marker
137
Added:
(error "Org ID not found: %s" id))
138
Added:
(goto-char marker)))
139
Added:
140
Added:
(defun rail-tools--fill-body ()
141
Added:
"Wrap the body of the entry at point to 72 columns.
142
Added:
Fill every paragraph after the metadata (SCHEDULED line, property
143
Added:
drawer) up to the next heading. Use `org-fill-paragraph' so Org
144
Added:
list items and other structure fill correctly. Point must be on
145
Added:
the entry heading."
146
Added:
(let ((fill-column 72))
147
Added:
(org-back-to-heading t)
148
Added:
(let ((end (save-excursion (org-end-of-subtree t t) (point-marker))))
149
Added:
;; Move past the heading and all metadata (planning line,
150
Added:
;; property drawer, logbook) to the first line of body text.
151
Added:
(org-end-of-meta-data t)
152
Added:
;; Fill each body line. `org-fill-paragraph' fills the whole
153
Added:
;; element and is idempotent, so stepping one line at a time is
154
Added:
;; safe and does not overshoot a trailing paragraph.
155
Added:
(while (< (point) end)
156
Added:
(unless (looking-at-p "^[ \t]*$")
157
Added:
(org-fill-paragraph))
158
Added:
(forward-line 1))
159
Added:
(set-marker end nil))))
160
Added:
161
Added:
(defun rail-tools--nonblank (value)
162
Added:
"Return VALUE trimmed when it is a non-blank string, else nil."
163
Added:
(and (stringp value)
164
Added:
(let ((trimmed (string-trim value)))
165
Added:
(and (> (length trimmed) 0) trimmed))))
166
Added:
167
Added:
(defun rail-tools--set-result (commit tests &optional model notes)
168
Added:
"Write the structured result line for the entry at point.
169
Added:
Replace an existing `- result ::' line, or append one at the end of
170
Added:
the entry body. COMMIT is a commit hash. TESTS is a short recap
171
Added:
such as \"215 pass\". MODEL names the agent that did the work, and
172
Added:
NOTES adds a free-text tail after a semicolon. Both are optional.
173
Added:
Keep the line unwrapped, because the reader captures a single line.
174
Added:
Point must be on the entry heading."
175
Added:
(org-back-to-heading t)
176
Added:
(let* ((model (rail-tools--nonblank model))
177
Added:
(notes (rail-tools--nonblank notes))
178
Added:
(subtree-end (save-excursion (org-end-of-subtree t t) (point-marker)))
179
Added:
(line (concat "- result :: "
180
Added:
(if model (format "model=%s " model) "")
181
Added:
(format "commit=%s tests=%s" commit tests)
182
Added:
(if notes (format "; %s" notes) ""))))
183
Added:
(org-back-to-heading t)
184
Added:
(if (re-search-forward "^[ \t]*- result ::.*$" subtree-end t)
185
Added:
(replace-match line t t)
186
Added:
(goto-char subtree-end)
187
Added:
(skip-chars-backward "\n")
188
Added:
(insert "\n\n" line))
189
Added:
(set-marker subtree-end nil)))
190
Added:
191
Added:
(defun rail-tools--append-log (note)
192
Added:
"Append NOTE as a timestamped item to the entry's `:LOGBOOK:' drawer.
193
Added:
Create the drawer directly after the metadata when it is absent.
194
Added:
Insert the newest item first and wrap it to 72 columns. This is
195
Added:
append-only. It never edits an existing item or the body. Point
196
Added:
must be on the entry heading."
197
Added:
(let ((fill-column 72))
198
Added:
(org-back-to-heading t)
199
Added:
(let ((subtree-end (save-excursion (org-end-of-subtree t t) (point-marker)))
200
Added:
(ts (format-time-string "[%Y-%m-%d %a %H:%M]")))
201
Added:
(org-back-to-heading t)
202
Added:
(let ((drawer-start
203
Added:
(save-excursion
204
Added:
(when (re-search-forward "^[ \t]*:LOGBOOK:[ \t]*$" subtree-end t)
205
Added:
(line-beginning-position)))))
206
Added:
(unless drawer-start
207
Added:
(org-end-of-meta-data t)
208
Added:
(insert ":LOGBOOK:\n:END:\n")
209
Added:
(setq drawer-start
210
Added:
(save-excursion
211
Added:
(org-back-to-heading t)
212
Added:
(re-search-forward "^[ \t]*:LOGBOOK:[ \t]*$" subtree-end t)
213
Added:
(line-beginning-position))))
214
Added:
(goto-char drawer-start)
215
Added:
(forward-line 1)
216
Added:
(let ((item-start (point)))
217
Added:
(insert (format "- %s %s\n" ts note))
218
Added:
(save-excursion
219
Added:
(goto-char item-start)
220
Added:
(org-fill-paragraph))))
221
Added:
(set-marker subtree-end nil))))
222
Added:
223
Added:
(defmacro rail-tools--json (&rest body)
224
Added:
"Evaluate BODY and return its value as a JSON string.
225
Added:
Catch any error and return a JSON object with an `error' field."
226
Added:
(declare (indent 0))
227
Added:
`(condition-case err
228
Added:
(json-encode (progn ,@body))
229
Added:
(error (json-encode `((error . ,(error-message-string err)))))))
230
Added:
231
Added:
;;; Tag vocabulary
232
Added:
233
Added:
;; The vocabulary is not hardcoded. Each stream file declares it with
234
Added:
;; `#+TAGS:' group-tag lines, for example:
235
Added:
;;
236
Added:
;; #+TAGS: [ Kind : feat fix chore ]
237
Added:
;; #+TAGS: [ Scope : core web ]
238
Added:
;;
239
Added:
;; Org parses those lines into `org-current-tag-alist'. The functions below
240
Added:
;; read that alist, so the vocabulary follows the file, not this code. A file
241
Added:
;; with no `#+TAGS:' line accepts any tag.
242
Added:
243
Added:
(defun rail-tools--tag-axes ()
244
Added:
"Return the tag vocabulary of the current buffer, grouped by axis.
245
Added:
Read the group tags that Org parsed from the `#+TAGS:' lines into
246
Added:
`org-current-tag-alist'. Return an alist that maps each axis symbol to
247
Added:
its list of tag strings. Return nil when the file declares no axis, and
248
Added:
then the file accepts any tag."
249
Added:
(let ((axes '()) (current nil))
250
Added:
(dolist (entry org-current-tag-alist)
251
Added:
(pcase entry
252
Added:
(`(:startgrouptag) (setq current nil))
253
Added:
(`(:endgrouptag)
254
Added:
(when current
255
Added:
(push (cons (intern (downcase (car current)))
256
Added:
(nreverse (cdr current)))
257
Added:
axes))
258
Added:
(setq current nil))
259
Added:
(`(:grouptags))
260
Added:
(`(,(and tag (pred stringp)) . ,_)
261
Added:
(if current
262
Added:
(setcdr current (cons tag (cdr current)))
263
Added:
;; The first tag in a group is the axis name.
264
Added:
(setq current (cons tag '()))))))
265
Added:
(nreverse axes)))
266
Added:
267
Added:
(defun rail-tools--all-tags ()
268
Added:
"Return every tag the current buffer declares, as one flat list.
269
Added:
Return nil when the file declares no vocabulary."
270
Added:
(apply #'append (mapcar #'cdr (rail-tools--tag-axes))))
271
Added:
272
Added:
(defun rail-tools--check-tags (tags)
273
Added:
"Signal an error when TAGS holds a tag outside the file vocabulary.
274
Added:
TAGS is a list of strings. When the file declares no vocabulary, accept
275
Added:
any tag. Return TAGS unchanged when valid."
276
Added:
(let ((allowed (rail-tools--all-tags)))
277
Added:
(when allowed
278
Added:
(dolist (tag tags)
279
Added:
(unless (member tag allowed)
280
Added:
(error "Unknown tag `%s'; allowed: %s"
281
Added:
tag (string-join allowed ", "))))))
282
Added:
tags)
283
Added:
284
Added:
(defun rail-tools--goto-stream-top ()
285
Added:
"Move point to the insertion place for a new item.
286
Added:
That place is the start of the first top-level heading, after the
287
Added:
file preamble. When no heading exists, move to the end of the
288
Added:
preamble."
289
Added:
(goto-char (point-min))
290
Added:
(if (re-search-forward "^\\* " nil t)
291
Added:
(goto-char (line-beginning-position))
292
Added:
(goto-char (point-max))))
293
Added:
294
Added:
;;; inspect (read-only)
295
Added:
296
Added:
(defun rail-tools--inspect-handler (args)
297
Added:
"Report the TODO sequence and the tag vocabulary for RAIL.org.
298
Added:
Read both from the stream file, so the report mirrors the file."
299
Added:
(rail-tools--json
300
Added:
(let ((file (rail-tools--file args)))
301
Added:
(with-current-buffer (rail-tools--buffer file)
302
Added:
(org-with-wide-buffer
303
Added:
`((file . ,file)
304
Added:
(todo_keywords . ,(vconcat org-todo-keywords-1))
305
Added:
(tags . ,(mapcar (lambda (axis)
306
Added:
(cons (car axis) (vconcat (cdr axis))))
307
Added:
(rail-tools--tag-axes)))))))))
308
Added:
309
Added:
(mcp-server-register-tool
310
Added:
(make-mcp-server-tool
311
Added:
:name "rail-inspect"
312
Added:
:title "RAIL Inspect"
313
Added:
:description "Inspect RAIL.org: return its TODO keyword sequence and the tag vocabulary that the file declares, grouped by axis. Read-only."
314
Added:
:input-schema '((type . "object")
315
Added:
(properties . ((root . ((type . "string")
316
Added:
(description . "Absolute path to the project directory containing RAIL.org")))))
317
Added:
(required . []))
318
Added:
:function #'rail-tools--inspect-handler
319
Added:
:annotations '((readOnlyHint . t)
320
Added:
(destructiveHint . :false)
321
Added:
(idempotentHint . t)
322
Added:
(openWorldHint . :false))))
323
Added:
324
Added:
;;; list (read-only)
325
Added:
326
Added:
(defun rail-tools--list-handler (args)
327
Added:
"List the top-level action items in RAIL.org, newest first.
328
Added:
ARGS keys: `root', `state' (optional), `tag' (optional). When STATE is
329
Added:
given, return only items with that TODO keyword. When TAG is given,
330
Added:
return only items that carry that tag. Each row has `id', `title',
331
Added:
`state', `scheduled', and `tags'."
332
Added:
(rail-tools--json
333
Added:
(let ((file (rail-tools--file args))
334
Added:
(state (alist-get 'state args))
335
Added:
(tag (alist-get 'tag args)))
336
Added:
(with-current-buffer (rail-tools--buffer file)
337
Added:
(org-with-wide-buffer
338
Added:
(goto-char (point-min))
339
Added:
(let ((rows '()))
340
Added:
(while (re-search-forward "^\\* " nil t)
341
Added:
(let ((todo (org-get-todo-state))
342
Added:
(tags (org-get-tags nil t)))
343
Added:
(when (and (or (null state) (equal state todo))
344
Added:
(or (null tag) (member tag tags)))
345
Added:
(push `((id . ,(org-id-get))
346
Added:
(title . ,(org-get-heading t t t t))
347
Added:
(state . ,todo)
348
Added:
(scheduled . ,(org-entry-get nil "SCHEDULED"))
349
Added:
(tags . ,(vconcat tags)))
350
Added:
rows))))
351
Added:
;; The file is newest-first, so reverse to keep that order.
352
Added:
(vconcat (nreverse rows))))))))
353
Added:
354
Added:
(mcp-server-register-tool
355
Added:
(make-mcp-server-tool
356
Added:
:name "rail-list"
357
Added:
:title "RAIL List"
358
Added:
:description "List the top-level action items in RAIL.org, newest first, with each entry's Org ID, title, TODO state, SCHEDULED time, and tags. Pass an optional `state' or `tag' to filter. Read-only."
359
Added:
:input-schema '((type . "object")
360
Added:
(properties . ((root . ((type . "string")
361
Added:
(description . "Absolute path to the project directory")))
362
Added:
(state . ((type . "string")
363
Added:
(description . "Optional TODO keyword filter, for example TODO or IN-PROGRESS")))
364
Added:
(tag . ((type . "string")
365
Added:
(description . "Optional tag filter, for example web or major")))))
366
Added:
(required . []))
367
Added:
:function #'rail-tools--list-handler
368
Added:
:annotations '((readOnlyHint . t)
369
Added:
(destructiveHint . :false)
370
Added:
(idempotentHint . t)
371
Added:
(openWorldHint . :false))))
372
Added:
373
Added:
;;; capture
374
Added:
375
Added:
(defun rail-tools--capture-handler (args)
376
Added:
"Capture a TODO entry at the top of the RAIL.org stream.
377
Added:
ARGS keys: `root', `title', `body' (optional), `tags' (optional array).
378
Added:
Insert the entry as a top-level heading directly below the file
379
Added:
preamble, so the newest item is first. Record the capture time as an
380
Added:
inactive SCHEDULED timestamp, apply TAGS from the file vocabulary,
381
Added:
assign an Org ID, and wrap the body to 72 columns."
382
Added:
(rail-tools--json
383
Added:
(let* ((file (rail-tools--file args))
384
Added:
(title (or (alist-get 'title args) (error "Missing `title'")))
385
Added:
(body (or (alist-get 'body args) ""))
386
Added:
(raw-tags (append (alist-get 'tags args) nil))
387
Added:
(captured-at (format-time-string "[%Y-%m-%d %a %H:%M]")))
388
Added:
(with-current-buffer (rail-tools--buffer file)
389
Added:
(org-with-wide-buffer
390
Added:
;; Validate inside the buffer, because the vocabulary lives here.
391
Added:
(let ((tags (rail-tools--check-tags raw-tags)))
392
Added:
(rail-tools--goto-stream-top)
393
Added:
(let ((start (point)))
394
Added:
(insert (format "* TODO %s\nSCHEDULED: %s\n" title captured-at))
395
Added:
(unless (string-empty-p body)
396
Added:
(insert body "\n"))
397
Added:
(goto-char start)
398
Added:
(when tags
399
Added:
(org-set-tags tags))
400
Added:
(let ((id (org-id-get-create)))
401
Added:
(rail-tools--fill-body)
402
Added:
(when (buffer-modified-p) (save-buffer))
403
Added:
(goto-char (org-id-find id 'marker))
404
Added:
`((id . ,id)
405
Added:
(file . ,file)
406
Added:
(heading . ,(org-get-heading t t t t))
407
Added:
(tags . ,(vconcat (org-get-tags nil t))))))))))))
408
Added:
409
Added:
(mcp-server-register-tool
410
Added:
(make-mcp-server-tool
411
Added:
:name "rail-capture"
412
Added:
:title "RAIL Capture"
413
Added:
:description "Capture a TODO action item as a top-level heading at the top of RAIL.org, so the newest item comes first. Applies tags that the file vocabulary permits, records the capture time as an inactive SCHEDULED timestamp, wraps the body to 72 columns, and assigns an Org ID."
414
Added:
:input-schema '((type . "object")
415
Added:
(properties . ((root . ((type . "string")
416
Added:
(description . "Absolute path to the project directory")))
417
Added:
(title . ((type . "string")
418
Added:
(description . "Imperative title under 60 chars")))
419
Added:
(body . ((type . "string")
420
Added:
(description . "Full request text, verbatim")))
421
Added:
(tags . ((type . "array")
422
Added:
(items . ((type . "string")))
423
Added:
(description . "Tags from the vocabulary that the file declares in its #+TAGS: lines. Run rail-inspect to read the axes and their allowed tags.")))))
424
Added:
(required . ["title"]))
425
Added:
:function #'rail-tools--capture-handler
426
Added:
:annotations '((readOnlyHint . :false)
427
Added:
(destructiveHint . :false)
428
Added:
(idempotentHint . :false)
429
Added:
(openWorldHint . :false))))
430
Added:
431
Added:
;;; set-status
432
Added:
433
Added:
(defun rail-tools--set-status-handler (args)
434
Added:
"Change the TODO keyword of an entry.
435
Added:
ARGS keys: `root', `id', `state'. STATE must be one keyword from the
436
Added:
file's own #+TODO sequence, and must not be DONE (use rail-complete)."
437
Added:
(rail-tools--json
438
Added:
(let ((file (rail-tools--file args))
439
Added:
(id (or (alist-get 'id args) (error "Missing `id'")))
440
Added:
(state (or (alist-get 'state args) (error "Missing `state'"))))
441
Added:
(when (string-equal state "DONE")
442
Added:
(error "Use rail-complete for DONE, not rail-set-status"))
443
Added:
(with-current-buffer (rail-tools--buffer file)
444
Added:
(rail-tools--goto-id id)
445
Added:
(org-todo state)
446
Added:
(when (buffer-modified-p) (save-buffer))
447
Added:
`((id . ,id)
448
Added:
(state . ,(org-get-todo-state)))))))
449
Added:
450
Added:
(mcp-server-register-tool
451
Added:
(make-mcp-server-tool
452
Added:
:name "rail-set-status"
453
Added:
:title "RAIL Set Status"
454
Added:
:description "Set the TODO keyword of a RAIL entry to any open keyword from the file's own #+TODO sequence. Does not accept DONE; use rail-complete for that."
455
Added:
:input-schema '((type . "object")
456
Added:
(properties . ((root . ((type . "string")))
457
Added:
(id . ((type . "string")
458
Added:
(description . "Org ID of the entry")))
459
Added:
(state . ((type . "string")
460
Added:
(description . "TODO keyword from the file's #+TODO sequence")))))
461
Added:
(required . ["id" "state"]))
462
Added:
:function #'rail-tools--set-status-handler
463
Added:
:annotations '((readOnlyHint . :false)
464
Added:
(destructiveHint . :false)
465
Added:
(idempotentHint . t)
466
Added:
(openWorldHint . :false))))
467
Added:
468
Added:
;;; log
469
Added:
470
Added:
(defun rail-tools--log-handler (args)
471
Added:
"Append a timestamped note to an entry's `:LOGBOOK:' drawer.
472
Added:
ARGS keys: `root', `id', `note'. Append-only progress feedback from an
473
Added:
agentic session. Never edits an existing note or the item body."
474
Added:
(rail-tools--json
475
Added:
(let ((file (rail-tools--file args))
476
Added:
(id (or (alist-get 'id args) (error "Missing `id'")))
477
Added:
(note (or (alist-get 'note args) (error "Missing `note'"))))
478
Added:
(with-current-buffer (rail-tools--buffer file)
479
Added:
(rail-tools--goto-id id)
480
Added:
(rail-tools--append-log note)
481
Added:
(when (buffer-modified-p) (save-buffer))
482
Added:
`((id . ,id)
483
Added:
(state . ,(org-get-todo-state)))))))
484
Added:
485
Added:
(mcp-server-register-tool
486
Added:
(make-mcp-server-tool
487
Added:
:name "rail-log"
488
Added:
:title "RAIL Log"
489
Added:
:description "Append a timestamped progress note to a RAIL entry's `:LOGBOOK:' drawer. The drawer is append-only. The tool never rewrites an earlier note, and never rewrites the item body. Newest note first, wrapped to 72 columns."
490
Added:
:input-schema '((type . "object")
491
Added:
(properties . ((root . ((type . "string")))
492
Added:
(id . ((type . "string")
493
Added:
(description . "Org ID of the entry")))
494
Added:
(note . ((type . "string")
495
Added:
(description . "Progress note to append")))))
496
Added:
(required . ["id" "note"]))
497
Added:
:function #'rail-tools--log-handler
498
Added:
:annotations '((readOnlyHint . :false)
499
Added:
(destructiveHint . :false)
500
Added:
(idempotentHint . :false)
501
Added:
(openWorldHint . :false))))
502
Added:
503
Added:
;;; set-result
504
Added:
505
Added:
(defun rail-tools--set-result-handler (args)
506
Added:
"Write the structured result line for an entry.
507
Added:
ARGS keys: `root', `id', `commit', `tests', `model' (optional),
508
Added:
`notes' (optional). COMMIT is a commit hash. TESTS is a short recap
509
Added:
such as \"215 pass\". Replace an existing result line or append one at
510
Added:
the end of the body."
511
Added:
(rail-tools--json
512
Added:
(let ((file (rail-tools--file args))
513
Added:
(id (or (alist-get 'id args) (error "Missing `id'")))
514
Added:
(commit (or (alist-get 'commit args) (error "Missing `commit'")))
515
Added:
(tests (or (alist-get 'tests args) (error "Missing `tests'")))
516
Added:
(model (alist-get 'model args))
517
Added:
(notes (alist-get 'notes args)))
518
Added:
(with-current-buffer (rail-tools--buffer file)
519
Added:
(rail-tools--goto-id id)
520
Added:
(rail-tools--set-result commit tests model notes)
521
Added:
(when (buffer-modified-p) (save-buffer))
522
Added:
(rail-tools--goto-id id)
523
Added:
`((id . ,id)
524
Added:
(result . ,(rail-tools--result-text)))))))
525
Added:
526
Added:
(mcp-server-register-tool
527
Added:
(make-mcp-server-tool
528
Added:
:name "rail-set-result"
529
Added:
:title "RAIL Set Result"
530
Added:
:description "Write the structured `- result ::' line for a RAIL entry, recording the commit hash, a short test recap, and optionally the model that did the work and a free-text note. Replaces an existing result line or appends one. rail-complete calls this when you pass commit and tests."
531
Added:
:input-schema '((type . "object")
532
Added:
(properties . ((root . ((type . "string")))
533
Added:
(id . ((type . "string")
534
Added:
(description . "Org ID of the entry")))
535
Added:
(commit . ((type . "string")
536
Added:
(description . "Commit hash")))
537
Added:
(tests . ((type . "string")
538
Added:
(description . "Short test recap, for example \"215 pass\"")))
539
Added:
(model . ((type . "string")
540
Added:
(description . "Optional model or agent that did the work, for example the agent name")))
541
Added:
(notes . ((type . "string")
542
Added:
(description . "Optional free-text tail appended after a semicolon, for example a root cause")))))
543
Added:
(required . ["id" "commit" "tests"]))
544
Added:
:function #'rail-tools--set-result-handler
545
Added:
:annotations '((readOnlyHint . :false)
546
Added:
(destructiveHint . :false)
547
Added:
(idempotentHint . t)
548
Added:
(openWorldHint . :false))))
549
Added:
550
Added:
;;; check (checklist for sub-tasks)
551
Added:
552
Added:
(defconst rail-tools--checklist-header "Checklist [/]:"
553
Added:
"Header line that introduces an item's checkbox list.
554
Added:
The `[/]' cookie tracks completed items against the total.")
555
Added:
556
Added:
(defun rail-tools--checklist-add (item)
557
Added:
"Add ITEM as an unchecked checkbox to the entry at point.
558
Added:
Create the checklist block when it does not exist. Wrap ITEM to 72
559
Added:
columns and refresh the `[/]' cookie. Point must be on the heading."
560
Added:
(let ((fill-column 72))
561
Added:
(org-back-to-heading t)
562
Added:
(let* ((subtree-end (save-excursion (org-end-of-subtree t t) (point-marker)))
563
Added:
(line (format "- [ ] %s\n" item))
564
Added:
insert-at)
565
Added:
(org-back-to-heading t)
566
Added:
(if (re-search-forward "^Checklist \\[[0-9]*/[0-9]*\\]:[ \t]*$"
567
Added:
subtree-end t)
568
Added:
;; Existing block: step past the trailing checkbox items.
569
Added:
(progn (forward-line 1)
570
Added:
(while (looking-at-p "^- \\[.\\] \\|^ ") (forward-line 1))
571
Added:
(setq insert-at (point))
572
Added:
(insert line))
573
Added:
;; No block: append one at the end of the body.
574
Added:
(goto-char subtree-end)
575
Added:
(skip-chars-backward "\n")
576
Added:
(insert "\n\n" rail-tools--checklist-header "\n")
577
Added:
(setq insert-at (point))
578
Added:
(insert line))
579
Added:
(save-excursion (goto-char insert-at) (org-fill-paragraph))
580
Added:
(org-update-checkbox-count)
581
Added:
(set-marker subtree-end nil))))
582
Added:
583
Added:
(defun rail-tools--checklist-toggle (item)
584
Added:
"Toggle the checkbox whose text matches ITEM in the entry at point.
585
Added:
Signal an error when no item matches. Refresh the `[/]' cookie.
586
Added:
Point must be on the heading."
587
Added:
(org-back-to-heading t)
588
Added:
(let ((subtree-end (save-excursion (org-end-of-subtree t t) (point-marker))))
589
Added:
(org-back-to-heading t)
590
Added:
(if (re-search-forward (concat "^- \\[.\\] " (regexp-quote item))
591
Added:
subtree-end t)
592
Added:
(progn (beginning-of-line) (org-toggle-checkbox)
593
Added:
(org-update-checkbox-count))
594
Added:
(set-marker subtree-end nil)
595
Added:
(error "No checklist item matches: %s" item))
596
Added:
(set-marker subtree-end nil)))
597
Added:
598
Added:
(defun rail-tools--checklist-items ()
599
Added:
"Return the checklist items of the entry at point.
600
Added:
Each item is an alist with `done' and `text'. Point must be on the
601
Added:
heading."
602
Added:
(org-back-to-heading t)
603
Added:
(let ((subtree-end (save-excursion (org-end-of-subtree t t) (point)))
604
Added:
(items '()))
605
Added:
(org-back-to-heading t)
606
Added:
(while (re-search-forward "^- \\[\\(.\\)\\] \\(.*\\)$" subtree-end t)
607
Added:
(push `((done . ,(if (string-equal (match-string 1) " ") :json-false t))
608
Added:
(text . ,(string-trim (match-string-no-properties 2))))
609
Added:
items))
610
Added:
(vconcat (nreverse items))))
611
Added:
612
Added:
(defun rail-tools--check-handler (args)
613
Added:
"Manage the checklist of an item, for splitting a complex task.
614
Added:
ARGS keys: `root', `id', `action' (add|toggle|list), `item'.
615
Added:
`add' appends an unchecked item. `toggle' flips a matching item.
616
Added:
`list' returns the items. A `[/]' cookie tracks progress."
617
Added:
(rail-tools--json
618
Added:
(let ((file (rail-tools--file args))
619
Added:
(id (or (alist-get 'id args) (error "Missing `id'")))
620
Added:
(action (or (alist-get 'action args) (error "Missing `action'")))
621
Added:
(item (alist-get 'item args)))
622
Added:
(with-current-buffer (rail-tools--buffer file)
623
Added:
(rail-tools--goto-id id)
624
Added:
(cond
625
Added:
((string-equal action "add")
626
Added:
(unless item (error "`add' needs an `item'"))
627
Added:
(rail-tools--checklist-add item))
628
Added:
((string-equal action "toggle")
629
Added:
(unless item (error "`toggle' needs an `item'"))
630
Added:
(rail-tools--checklist-toggle item))
631
Added:
((string-equal action "list") nil)
632
Added:
(t (error "Unknown action `%s'; use add, toggle, or list" action)))
633
Added:
(when (buffer-modified-p) (save-buffer))
634
Added:
(rail-tools--goto-id id)
635
Added:
`((id . ,id)
636
Added:
(items . ,(rail-tools--checklist-items)))))))
637
Added:
638
Added:
(mcp-server-register-tool
639
Added:
(make-mcp-server-tool
640
Added:
:name "rail-check"
641
Added:
:title "RAIL Checklist"
642
Added:
:description "Manage an item's checkbox list to split a complex task into sub-tasks with their own done state. Actions: add an unchecked item, toggle a matching item, or list items. A [/] cookie on the checklist header tracks progress. The items stay inside the one request. They are not separate stream entries."
643
Added:
:input-schema '((type . "object")
644
Added:
(properties . ((root . ((type . "string")))
645
Added:
(id . ((type . "string")
646
Added:
(description . "Org ID of the item")))
647
Added:
(action . ((type . "string")
648
Added:
(description . "add, toggle, or list")))
649
Added:
(item . ((type . "string")
650
Added:
(description . "Item text for add or toggle")))))
651
Added:
(required . ["id" "action"]))
652
Added:
:function #'rail-tools--check-handler
653
Added:
:annotations '((readOnlyHint . :false)
654
Added:
(destructiveHint . :false)
655
Added:
(idempotentHint . :false)
656
Added:
(openWorldHint . :false))))
657
Added:
658
Added:
;;; show (read-only)
659
Added:
660
Added:
(defun rail-tools--body-text ()
661
Added:
"Return the plain body text of the entry at point.
662
Added:
Read from the first line after the metadata up to the first of: a
663
Added:
`Checklist [' line, a `- result ::' line, or the end of the subtree.
664
Added:
Return the trimmed string. Point must be on the entry heading."
665
Added:
(org-back-to-heading t)
666
Added:
(let ((subtree-end (save-excursion (org-end-of-subtree t t) (point))))
667
Added:
(org-back-to-heading t)
668
Added:
(org-end-of-meta-data t)
669
Added:
(let ((body-start (point))
670
Added:
(body-end subtree-end))
671
Added:
(save-excursion
672
Added:
(goto-char body-start)
673
Added:
(when (re-search-forward "^\\(Checklist \\[\\|[ \t]*- result ::\\)"
674
Added:
subtree-end t)
675
Added:
(setq body-end (line-beginning-position))))
676
Added:
(string-trim
677
Added:
(buffer-substring-no-properties body-start body-end)))))
678
Added:
679
Added:
(defun rail-tools--logbook-items ()
680
Added:
"Return the `:LOGBOOK:' drawer item lines of the entry at point.
681
Added:
Each item is a string, in the order stored (newest first). Return an
682
Added:
empty vector when there is no drawer. Point must be on the heading."
683
Added:
(org-back-to-heading t)
684
Added:
(let ((subtree-end (save-excursion (org-end-of-subtree t t) (point)))
685
Added:
(items '()))
686
Added:
(org-back-to-heading t)
687
Added:
(when (re-search-forward "^[ \t]*:LOGBOOK:[ \t]*$" subtree-end t)
688
Added:
(forward-line 1)
689
Added:
(while (and (< (point) subtree-end)
690
Added:
(not (looking-at-p "^[ \t]*:END:[ \t]*$")))
691
Added:
(when (looking-at "^[ \t]*- \\(.*\\)$")
692
Added:
(push (string-trim (match-string-no-properties 1)) items))
693
Added:
(forward-line 1)))
694
Added:
(vconcat (nreverse items))))
695
Added:
696
Added:
(defun rail-tools--result-text ()
697
Added:
"Return the text after `- result ::' for the entry at point.
698
Added:
Return nil when there is no result line, so it encodes as JSON null.
699
Added:
Point must be on the heading."
700
Added:
(org-back-to-heading t)
701
Added:
(let ((subtree-end (save-excursion (org-end-of-subtree t t) (point))))
702
Added:
(org-back-to-heading t)
703
Added:
(when (re-search-forward "^[ \t]*- result ::[ \t]*\\(.*\\)$"
704
Added:
subtree-end t)
705
Added:
(string-trim (match-string-no-properties 1)))))
706
Added:
707
Added:
(defun rail-tools--show-handler (args)
708
Added:
"Return the full content of an entry.
709
Added:
ARGS keys: `root', `id'. Report the heading, state, tags, scheduled
710
Added:
and closed timestamps, body text, logbook notes, checklist items, and
711
Added:
result line. Read-only."
712
Added:
(rail-tools--json
713
Added:
(let ((file (rail-tools--file args))
714
Added:
(id (or (alist-get 'id args) (error "Missing `id'"))))
715
Added:
(with-current-buffer (rail-tools--buffer file)
716
Added:
(org-with-wide-buffer
717
Added:
(rail-tools--goto-id id)
718
Added:
`((id . ,id)
719
Added:
(heading . ,(org-get-heading t t t t))
720
Added:
(state . ,(org-get-todo-state))
721
Added:
(tags . ,(vconcat (org-get-tags nil t)))
722
Added:
(scheduled . ,(org-entry-get nil "SCHEDULED"))
723
Added:
(closed . ,(org-entry-get nil "CLOSED"))
724
Added:
(body . ,(rail-tools--body-text))
725
Added:
(logbook . ,(rail-tools--logbook-items))
726
Added:
(checklist . ,(rail-tools--checklist-items))
727
Added:
(result . ,(rail-tools--result-text))))))))
728
Added:
729
Added:
(mcp-server-register-tool
730
Added:
(make-mcp-server-tool
731
Added:
:name "rail-show"
732
Added:
:title "RAIL Show"
733
Added:
:description "Return the full content of a RAIL entry: heading, state, tags, scheduled and closed timestamps, body text, logbook notes, checklist items, and result line. Read-only."
734
Added:
:input-schema '((type . "object")
735
Added:
(properties . ((root . ((type . "string")))
736
Added:
(id . ((type . "string")
737
Added:
(description . "Org ID of the entry")))))
738
Added:
(required . ["id"]))
739
Added:
:function #'rail-tools--show-handler
740
Added:
:annotations '((readOnlyHint . t)
741
Added:
(destructiveHint . :false)
742
Added:
(idempotentHint . t)
743
Added:
(openWorldHint . :false))))
744
Added:
745
Added:
;;; retag
746
Added:
747
Added:
(defun rail-tools--retag-handler (args)
748
Added:
"Replace the tags on an entry with a validated set.
749
Added:
ARGS keys: `root', `id', `tags' (array). Validate TAGS against the
750
Added:
file vocabulary, then set them, keeping the file's default tag
751
Added:
alignment."
752
Added:
(rail-tools--json
753
Added:
(let ((file (rail-tools--file args))
754
Added:
(id (or (alist-get 'id args) (error "Missing `id'")))
755
Added:
(raw-tags (append (alist-get 'tags args) nil)))
756
Added:
(with-current-buffer (rail-tools--buffer file)
757
Added:
(let ((tags (rail-tools--check-tags raw-tags)))
758
Added:
(rail-tools--goto-id id)
759
Added:
(org-set-tags tags)
760
Added:
(when (buffer-modified-p) (save-buffer))
761
Added:
`((id . ,id)
762
Added:
(tags . ,(vconcat (org-get-tags nil t)))))))))
763
Added:
764
Added:
(mcp-server-register-tool
765
Added:
(make-mcp-server-tool
766
Added:
:name "rail-retag"
767
Added:
:title "RAIL Retag"
768
Added:
:description "Replace the tags on a RAIL entry with a validated set from the file vocabulary. Use this tool to re-tag an entry as its shape changes. The tool keeps the file's default tag alignment."
769
Added:
:input-schema '((type . "object")
770
Added:
(properties . ((root . ((type . "string")))
771
Added:
(id . ((type . "string")
772
Added:
(description . "Org ID of the entry")))
773
Added:
(tags . ((type . "array")
774
Added:
(items . ((type . "string")))
775
Added:
(description . "Tags from the file vocabulary")))))
776
Added:
(required . ["id" "tags"]))
777
Added:
:function #'rail-tools--retag-handler
778
Added:
:annotations '((readOnlyHint . :false)
779
Added:
(destructiveHint . :false)
780
Added:
(idempotentHint . :false)
781
Added:
(openWorldHint . :false))))
782
Added:
783
Added:
;;; cancel and block
784
Added:
785
Added:
(defun rail-tools--transition-with-reason (id keyword marker-label reason)
786
Added:
"Transition entry ID to KEYWORD and record REASON.
787
Added:
Signal an error when REASON is missing or blank. Write a wrapped body
788
Added:
line `- MARKER-LABEL :: REASON', replacing an existing line of that
789
Added:
form or appending one at the end of the body. Fill the line to 72
790
Added:
columns, then set the TODO keyword and save."
791
Added:
(when (or (null reason) (string-empty-p (string-trim reason)))
792
Added:
(error "A reason is required"))
793
Added:
(rail-tools--goto-id id)
794
Added:
(let ((fill-column 72))
795
Added:
(org-back-to-heading t)
796
Added:
(let ((subtree-end (save-excursion (org-end-of-subtree t t) (point-marker)))
797
Added:
(line (format "- %s :: %s" marker-label reason))
798
Added:
insert-at)
799
Added:
(org-back-to-heading t)
800
Added:
(if (re-search-forward
801
Added:
(format "^[ \t]*- %s ::.*$" (regexp-quote marker-label))
802
Added:
subtree-end t)
803
Added:
(progn (replace-match line t t)
804
Added:
(setq insert-at (line-beginning-position)))
805
Added:
(goto-char subtree-end)
806
Added:
(skip-chars-backward "\n")
807
Added:
(insert "\n\n" line)
808
Added:
(setq insert-at (line-beginning-position)))
809
Added:
(save-excursion (goto-char insert-at) (org-fill-paragraph))
810
Added:
(set-marker subtree-end nil)))
811
Added:
(rail-tools--goto-id id)
812
Added:
(org-todo keyword)
813
Added:
(when (buffer-modified-p) (save-buffer)))
814
Added:
815
Added:
(defun rail-tools--cancel-handler (args)
816
Added:
"Set an entry to CANCELLED with a required reason.
817
Added:
ARGS keys: `root', `id', `reason'. Record REASON as a `- cancelled ::'
818
Added:
line so the decision is never silent."
819
Added:
(rail-tools--json
820
Added:
(let ((file (rail-tools--file args))
821
Added:
(id (or (alist-get 'id args) (error "Missing `id'")))
822
Added:
(reason (alist-get 'reason args)))
823
Added:
(with-current-buffer (rail-tools--buffer file)
824
Added:
(rail-tools--transition-with-reason id "CANCELLED" "cancelled" reason)
825
Added:
(rail-tools--goto-id id)
826
Added:
`((id . ,id)
827
Added:
(state . ,(org-get-todo-state)))))))
828
Added:
829
Added:
(mcp-server-register-tool
830
Added:
(make-mcp-server-tool
831
Added:
:name "rail-cancel"
832
Added:
:title "RAIL Cancel"
833
Added:
:description "Set a RAIL entry to CANCELLED and record a required reason as a `- cancelled ::' line, so the decision is never silent."
834
Added:
:input-schema '((type . "object")
835
Added:
(properties . ((root . ((type . "string")))
836
Added:
(id . ((type . "string")
837
Added:
(description . "Org ID of the entry")))
838
Added:
(reason . ((type . "string")
839
Added:
(description . "Reason for cancelling the entry")))))
840
Added:
(required . ["id" "reason"]))
841
Added:
:function #'rail-tools--cancel-handler
842
Added:
:annotations '((readOnlyHint . :false)
843
Added:
(destructiveHint . :false)
844
Added:
(idempotentHint . :false)
845
Added:
(openWorldHint . :false))))
846
Added:
847
Added:
(defun rail-tools--block-handler (args)
848
Added:
"Set an entry to BLOCKED with a required reason.
849
Added:
ARGS keys: `root', `id', `reason'. Record REASON as a `- blocked ::'
850
Added:
line so the blocker is never silent."
851
Added:
(rail-tools--json
852
Added:
(let ((file (rail-tools--file args))
853
Added:
(id (or (alist-get 'id args) (error "Missing `id'")))
854
Added:
(reason (alist-get 'reason args)))
855
Added:
(with-current-buffer (rail-tools--buffer file)
856
Added:
(rail-tools--transition-with-reason id "BLOCKED" "blocked" reason)
857
Added:
(rail-tools--goto-id id)
858
Added:
`((id . ,id)
859
Added:
(state . ,(org-get-todo-state)))))))
860
Added:
861
Added:
(mcp-server-register-tool
862
Added:
(make-mcp-server-tool
863
Added:
:name "rail-block"
864
Added:
:title "RAIL Block"
865
Added:
:description "Set a RAIL entry to BLOCKED and record a required reason as a `- blocked ::' line, so the blocker is never silent."
866
Added:
:input-schema '((type . "object")
867
Added:
(properties . ((root . ((type . "string")))
868
Added:
(id . ((type . "string")
869
Added:
(description . "Org ID of the entry")))
870
Added:
(reason . ((type . "string")
871
Added:
(description . "Reason for blocking the entry")))))
872
Added:
(required . ["id" "reason"]))
873
Added:
:function #'rail-tools--block-handler
874
Added:
:annotations '((readOnlyHint . :false)
875
Added:
(destructiveHint . :false)
876
Added:
(idempotentHint . :false)
877
Added:
(openWorldHint . :false))))
878
Added:
879
Added:
;;; complete
880
Added:
881
Added:
(defun rail-tools--complete-handler (args)
882
Added:
"Set an entry to DONE with its result evidence and confirm CLOSED.
883
Added:
ARGS keys: `root', `id', `commit', `tests', `model' (optional),
884
Added:
`notes' (optional). Write the structured result line, then transition
885
Added:
to DONE. Requires `org-log-done' to be `time' so the normal Org
886
Added:
transition inserts CLOSED; this handler never writes CLOSED."
887
Added:
(rail-tools--json
888
Added:
(let ((file (rail-tools--file args))
889
Added:
(id (or (alist-get 'id args) (error "Missing `id'")))
890
Added:
(commit (or (alist-get 'commit args) (error "Missing `commit'")))
891
Added:
(tests (or (alist-get 'tests args) (error "Missing `tests'")))
892
Added:
(model (alist-get 'model args))
893
Added:
(notes (alist-get 'notes args)))
894
Added:
(with-current-buffer (rail-tools--buffer file)
895
Added:
(hack-local-variables)
896
Added:
(unless (eq org-log-done 'time)
897
Added:
(error "org-log-done is not set to time"))
898
Added:
(rail-tools--goto-id id)
899
Added:
(rail-tools--set-result commit tests model notes)
900
Added:
(rail-tools--goto-id id)
901
Added:
(org-todo "DONE")
902
Added:
(when (buffer-modified-p) (save-buffer))
903
Added:
(let ((closed (org-entry-get nil "CLOSED")))
904
Added:
(unless closed
905
Added:
(error "Org did not record a CLOSED timestamp"))
906
Added:
`((id . ,id)
907
Added:
(state . ,(org-get-todo-state))
908
Added:
(result . ,(rail-tools--result-text))
909
Added:
(closed . ,closed)))))))
910
Added:
911
Added:
(mcp-server-register-tool
912
Added:
(make-mcp-server-tool
913
Added:
:name "rail-complete"
914
Added:
:title "RAIL Complete"
915
Added:
:description "Set a RAIL entry to DONE. The tool records the result evidence as a `- result ::' line. The evidence holds the commit hash, a short test recap, and optionally the agent that did the work and a free-text note. The tool then confirms that Org inserted a CLOSED timestamp. The file must set org-log-done to time. The tool never writes the timestamp itself. The entry stays in place, and there is no refile step."
916
Added:
:input-schema '((type . "object")
917
Added:
(properties . ((root . ((type . "string")))
918
Added:
(id . ((type . "string")))
919
Added:
(commit . ((type . "string")
920
Added:
(description . "Commit hash for the completed work")))
921
Added:
(tests . ((type . "string")
922
Added:
(description . "Short test recap, for example \"215 pass\"")))
923
Added:
(model . ((type . "string")
924
Added:
(description . "Optional model or agent that did the work, for example the agent name")))
925
Added:
(notes . ((type . "string")
926
Added:
(description . "Optional free-text tail appended after a semicolon, for example a root cause")))))
927
Added:
(required . ["id" "commit" "tests"]))
928
Added:
:function #'rail-tools--complete-handler
929
Added:
:annotations '((readOnlyHint . :false)
930
Added:
(destructiveHint . :false)
931
Added:
(idempotentHint . t)
932
Added:
(openWorldHint . :false))))
933
Added:
934
Added:
;;; verify (read-only)
935
Added:
936
Added:
(defun rail-tools--verify-handler (args)
937
Added:
"Return the current state of the entry with `id'.
938
Added:
ARGS keys: `root', `id'. Read-only."
939
Added:
(rail-tools--json
940
Added:
(let ((file (rail-tools--file args))
941
Added:
(id (or (alist-get 'id args) (error "Missing `id'"))))
942
Added:
(with-current-buffer (rail-tools--buffer file)
943
Added:
(org-with-wide-buffer
944
Added:
(rail-tools--goto-id id)
945
Added:
`((id . ,id)
946
Added:
(heading . ,(org-get-heading t t t t))
947
Added:
(state . ,(org-get-todo-state))
948
Added:
(closed . ,(org-entry-get nil "CLOSED"))
949
Added:
(tags . ,(vconcat (org-get-tags nil t)))))))))
950
Added:
951
Added:
(mcp-server-register-tool
952
Added:
(make-mcp-server-tool
953
Added:
:name "rail-verify"
954
Added:
:title "RAIL Verify"
955
Added:
:description "Return the heading, TODO state, CLOSED timestamp, and tags of a RAIL entry. Read-only."
956
Added:
:input-schema '((type . "object")
957
Added:
(properties . ((root . ((type . "string")))
958
Added:
(id . ((type . "string")))))
959
Added:
(required . ["id"]))
960
Added:
:function #'rail-tools--verify-handler
961
Added:
:annotations '((readOnlyHint . t)
962
Added:
(destructiveHint . :false)
963
Added:
(idempotentHint . t)
964
Added:
(openWorldHint . :false))))
965
Added:
966
Added:
(provide 'rail-tools)
967
Added:
968
Added:
;;; rail-tools.el ends here
skills/rail/references/tools.md
@@ -0,0 +1,168 @@
1
Added:
# RAIL tool reference
2
Added:
3
Added:
Read this file when you need the exact arguments of one tool, or the
4
Added:
shape of its result. The main skill file gives the workflow.
5
Added:
6
Added:
Every tool accepts an optional `root` argument. `root` names the project
7
Added:
directory that holds the stream file. When you omit `root`, the tools use
8
Added:
`rail-project-root`. That variable comes from an upward search for
9
Added:
`RAIL.org`, so it needs no configuration.
10
Added:
11
Added:
Every tool returns one JSON object. A failed call returns an object with
12
Added:
one `error` field. An absent value returns JSON `null`.
13
Added:
14
Added:
## Read-only tools
15
Added:
16
Added:
### rail-inspect
17
Added:
18
Added:
Arguments: none beyond `root`.
19
Added:
20
Added:
Returns `file`, `todo_keywords`, and `tags`. The `tags` field groups the
21
Added:
vocabulary by axis: `kind`, `scope`, `impact`, and `misc`.
22
Added:
23
Added:
Run this tool when a tag call fails, or when you need the keyword
24
Added:
sequence of the file.
25
Added:
26
Added:
### rail-list
27
Added:
28
Added:
Arguments:
29
Added:
30
Added:
- `state` — optional. Keep only entries with this TODO keyword.
31
Added:
- `tag` — optional. Keep only entries that carry this tag.
32
Added:
33
Added:
Returns an array of objects, newest first. Each object holds `id`,
34
Added:
`title`, `state`, `scheduled`, and `tags`. The `id` field is `null` for
35
Added:
an entry that carries no Org ID.
36
Added:
37
Added:
Pass a filter whenever you can, because the stream grows without bound.
38
Added:
39
Added:
### rail-show
40
Added:
41
Added:
Arguments:
42
Added:
43
Added:
- `id` — required. The Org ID of the entry.
44
Added:
45
Added:
Returns `id`, `heading`, `state`, `tags`, `scheduled`, `closed`, `body`,
46
Added:
`logbook`, `checklist`, and `result`. The `checklist` field holds objects
47
Added:
with a boolean `done` field and a `text` field.
48
Added:
49
Added:
### rail-verify
50
Added:
51
Added:
Arguments:
52
Added:
53
Added:
- `id` — required. The Org ID of the entry.
54
Added:
55
Added:
Returns `id`, `heading`, `state`, `closed`, and `tags`. Use this tool
56
Added:
before a status change and after a status change.
57
Added:
58
Added:
## Writing tools
59
Added:
60
Added:
### rail-capture
61
Added:
62
Added:
Arguments:
63
Added:
64
Added:
- `title` — required. A short imperative title under 60 characters.
65
Added:
- `body` — optional. The full item text, as plain text.
66
Added:
- `tags` — optional. An array of tags from the file vocabulary (the #+TAGS: lines).
67
Added:
68
Added:
Returns `id`, `file`, `heading`, and `tags`.
69
Added:
70
Added:
The tool inserts the entry as a top-level heading above every other
71
Added:
entry. It records the capture time as an inactive `SCHEDULED` timestamp.
72
Added:
It wraps the body to 72 columns, and it assigns an Org ID.
73
Added:
74
Added:
### rail-set-status
75
Added:
76
Added:
Arguments:
77
Added:
78
Added:
- `id` — required. The Org ID of the entry.
79
Added:
- `state` — required. One keyword from the file's own `#+TODO:` line.
80
Added:
81
Added:
Returns `id` and `state`.
82
Added:
83
Added:
The tool refuses `DONE`. Use `rail-complete` for `DONE`.
84
Added:
85
Added:
### rail-block and rail-cancel
86
Added:
87
Added:
Arguments:
88
Added:
89
Added:
- `id` — required. The Org ID of the entry.
90
Added:
- `reason` — required. The reason for the transition.
91
Added:
92
Added:
Returns `id` and `state`.
93
Added:
94
Added:
`rail-block` writes a `- blocked ::` line and sets `BLOCKED`.
95
Added:
`rail-cancel` writes a `- cancelled ::` line and sets `CANCELLED`.
96
Added:
Each tool refuses a blank reason.
97
Added:
98
Added:
### rail-check
99
Added:
100
Added:
Arguments:
101
Added:
102
Added:
- `id` — required. The Org ID of the entry.
103
Added:
- `action` — required. One of `add`, `toggle`, or `list`.
104
Added:
- `item` — required for `add` and for `toggle`. The item text.
105
Added:
106
Added:
Returns `id` and `items`.
107
Added:
108
Added:
The tool keeps the items in a `Checklist [/]:` block in the body. The
109
Added:
`[/]` cookie counts the completed items. `toggle` matches the item text
110
Added:
from the start, and it fails when no item matches.
111
Added:
112
Added:
### rail-log
113
Added:
114
Added:
Arguments:
115
Added:
116
Added:
- `id` — required. The Org ID of the entry.
117
Added:
- `note` — required. The progress note.
118
Added:
119
Added:
Returns `id` and `state`.
120
Added:
121
Added:
The tool prepends a timestamped item to the `:LOGBOOK:` drawer, and
122
Added:
creates the drawer when it is absent. The drawer is append-only.
123
Added:
124
Added:
### rail-retag
125
Added:
126
Added:
Arguments:
127
Added:
128
Added:
- `id` — required. The Org ID of the entry.
129
Added:
- `tags` — required. An array of tags from the file vocabulary (the #+TAGS: lines).
130
Added:
131
Added:
Returns `id` and `tags`.
132
Added:
133
Added:
The tool replaces every tag on the entry. It validates each tag first,
134
Added:
so one invalid tag leaves the entry unchanged.
135
Added:
136
Added:
### rail-set-result
137
Added:
138
Added:
Arguments:
139
Added:
140
Added:
- `id` — required. The Org ID of the entry.
141
Added:
- `commit` — required. The commit hash.
142
Added:
- `tests` — required. A short test recap.
143
Added:
- `model` — optional. The agent that did the work.
144
Added:
- `notes` — optional. A short tail after a semicolon.
145
Added:
146
Added:
Returns `id` and `result`.
147
Added:
148
Added:
The tool replaces an existing result line, or appends one at the end of
149
Added:
the body. The line stays on one line, because the reader reads one line.
150
Added:
151
Added:
### rail-complete
152
Added:
153
Added:
Arguments: the same arguments as `rail-set-result`.
154
Added:
155
Added:
Returns `id`, `state`, `result`, and `closed`.
156
Added:
157
Added:
The tool writes the result line, sets `DONE`, then reads back the
158
Added:
`CLOSED` timestamp. It fails when the file does not set `org-log-done`
159
Added:
to `time`, because Org must write that timestamp.
160
Added:
161
Added:
## Result line format
162
Added:
163
Added:
```org
164
Added:
- result :: model=example-agent commit=3051af0 tests=243 pass; short root cause
165
Added:
```
166
Added:
167
Added:
The `model` field and the tail after the semicolon are optional. The
168
Added:
`commit` field and the `tests` field are always present.
skills/rail/run-tests.sh
@@ -0,0 +1,35 @@
1
Added:
#!/bin/sh
2
Added:
# Run the RAIL test suite in a batch Emacs.
3
Added:
#
4
Added:
# Required dependency: emacs, with Org mode. Org mode ships with Emacs.
5
Added:
# The suite needs no MCP framework and no configuration. It runs on any
6
Added:
# machine. Every test uses a temporary stream file, so the project stream
7
Added:
# file stays unchanged.
8
Added:
#
9
Added:
# Exit status: 0 when every test passes, 1 otherwise.
10
Added:
11
Added:
set -eu
12
Added:
13
Added:
dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
14
Added:
15
Added:
if ! command -v emacs >/dev/null 2>&1; then
16
Added:
echo "Error: emacs is not on PATH. Install Emacs, then run this script again." >&2
17
Added:
exit 1
18
Added:
fi
19
Added:
20
Added:
for file in rail-tools.el rail-tests.el; do
21
Added:
if [ ! -r "$dir/$file" ]; then
22
Added:
echo "Error: cannot read $dir/$file. The skill directory is incomplete." >&2
23
Added:
exit 1
24
Added:
fi
25
Added:
done
26
Added:
27
Added:
if emacs --batch -Q \
28
Added:
-l "$dir/rail-tools.el" \
29
Added:
-l "$dir/rail-tests.el" \
30
Added:
-f ert-run-tests-batch-and-exit; then
31
Added:
echo "RAIL tests passed."
32
Added:
else
33
Added:
echo "Error: RAIL tests failed. Read the ERT output above for the failing test." >&2
34
Added:
exit 1
35
Added:
fi