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.

Commit
5c3ccff0e5104c8e7fd66b0d30ed933aef82c6de
Author
Marius Peter <dev@marius-peter.com>
Author date
Committer
Marius Peter <dev@marius-peter.com>
Committer date
Changed files
README.org
index 98880573..84258ef1 100644..100644
@@ -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
index c13dc0c2..00000000 100644..000000
@@ -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
index 0c207f72..00000000 100644..000000
@@ -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
index d7bb0f98..00000000 100644..000000
@@ -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
index 3fdd594d..00000000 100644..000000
@@ -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
index 3e35738e..00000000 100755..000000
@@ -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
index 00000000..c13dc0c2 000000..100644
@@ -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
index 00000000..0c207f72 000000..100644
@@ -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
index 00000000..d7bb0f98 000000..100644
@@ -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
index 00000000..3fdd594d 000000..100644
@@ -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
index 00000000..3e35738e 000000..100755
@@ -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