chore Seed agent-skills with the RAIL skill and STE100 steering

Start a shared repository for reusable Kiro content, versioned in one place and installed into ~/.kiro by symlink. - rail/ — the Rolling Action Item List skill: SKILL.md, the Emacs tool file, its test suite and runner, and the tool reference. 25 tests pass and the skill validator is clean. - steering/ste100/ — the Simplified Technical English steering module, inclusion always. The paid ASD-STE100 PDF is gitignored, not committed; supply a local copy for the deep reference. - README.md — how to install a skill and a steering module by symlink, how updates propagate, and how to pin one project.

Commit
6f719d9c25e2e6a1f0a598715e21f554e2f409ef
Author
Marius Peter <dev@marius-peter.com>
Author date
Committer
Marius Peter <dev@marius-peter.com>
Committer date
Changed files
.gitignore
index 00000000..c446d487 000000..100644
@@ -0,0 +1,10 @@
1 Added: # macOS
2 Added: .DS_Store
3 Added:
4 Added: # Emacs
5 Added: *~
6 Added: \#*\#
7 Added: .\#*
8 Added:
9 Added: # Copyrighted reference material — supply locally, never commit.
10 Added: steering/ste100/references/ASD-STE100_ISSUE9.pdf
README.md
index 00000000..5f86a619 000000..100644
@@ -0,0 +1,93 @@
1 Added: # agent-skills
2 Added:
3 Added: A collection of reusable Kiro skills, versioned in one place and shared
4 Added: across every project.
5 Added:
6 Added: ## Layout
7 Added:
8 Added: The repo holds two kinds of shareable content. Skills load on demand as
9 Added: slash commands. Steering modules shape every response.
10 Added:
11 Added: ```
12 Added: agent-skills/
13 Added: ├── rail/ Skill — Rolling Action Item List
14 Added: │ ├── SKILL.md
15 Added: │ ├── rail-tools.el
16 Added: │ ├── rail-tests.el
17 Added: │ ├── run-tests.sh
18 Added: │ └── references/
19 Added: └── steering/
20 Added: └── ste100/ Steering — Simplified Technical English
21 Added: ├── ste100.md
22 Added: └── references/ ASD-STE100 PDF (supplied locally, not committed)
23 Added: ```
24 Added:
25 Added: ## Install a skill globally
26 Added:
27 Added: Kiro loads skills from `~/.kiro/skills/*/SKILL.md` in every project.
28 Added: Symlink a skill from this repo into that directory. A symlink, not a
29 Added: copy, so `git pull` in this repo updates every project at once.
30 Added:
31 Added: ```sh
32 Added: ln -s ~/git/agent-skills/rail ~/.kiro/skills/rail
33 Added: ```
34 Added:
35 Added: Confirm the link:
36 Added:
37 Added: ```sh
38 Added: ls -l ~/.kiro/skills/rail
39 Added: ```
40 Added:
41 Added: The global skills directory is package-managed by AIM, but AIM touches
42 Added: only the skills listed in its `.aim-core-skills.json` manifest. A skill
43 Added: from this repo is not in that manifest, so AIM leaves it alone.
44 Added:
45 Added: ## Install a steering module globally
46 Added:
47 Added: Kiro loads steering from `~/.kiro/steering/**/*.md` in every project.
48 Added: Symlink a steering module the same way:
49 Added:
50 Added: ```sh
51 Added: ln -s ~/git/agent-skills/steering/ste100 ~/.kiro/steering/ste100
52 Added: ```
53 Added:
54 Added: A skill loads on demand. A steering module with `inclusion: always`
55 Added: shapes every response, with no command.
56 Added:
57 Added: ### The STE100 reference PDF
58 Added:
59 Added: The `ste100.md` doc points at `ASD-STE100_ISSUE9.pdf`, a paid,
60 Added: copyrighted standard. The repo does not commit it. To use the full
61 Added: reference, place your own copy at:
62 Added:
63 Added: ```
64 Added: ~/git/agent-skills/steering/ste100/references/ASD-STE100_ISSUE9.pdf
65 Added: ```
66 Added:
67 Added: The steering rules in `ste100.md` work without the PDF. The PDF only
68 Added: serves the deep reference lookups that the doc mentions.
69 Added:
70 Added: ## Update
71 Added:
72 Added: ```sh
73 Added: cd ~/git/agent-skills && git pull
74 Added: ```
75 Added:
76 Added: Every project that links a skill from this repo picks up the change on
77 Added: the next session, with no copy step.
78 Added:
79 Added: ## Pin one project to an older version
80 Added:
81 Added: The symlink shares one version across every project. To pin a single
82 Added: project, place a copy of the skill under that project's own
83 Added: `.kiro/skills/<name>/` instead of relying on the global link. A
84 Added: workspace skill and a global skill of the same name both load, so remove
85 Added: the global link for that project if you need only the pinned copy.
86 Added:
87 Added: ## Test a skill
88 Added:
89 Added: Each skill carries its own test runner. For RAIL:
90 Added:
91 Added: ```sh
92 Added: ~/git/agent-skills/rail/run-tests.sh
93 Added: ```
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.
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
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
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.
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
steering/ste100/ste100.md
index 00000000..933a3fec 000000..100644
@@ -0,0 +1,65 @@
1 Added: ---
2 Added: inclusion: always
3 Added: ---
4 Added:
5 Added: # Technical Writing — Simplified Technical English (STE)
6 Added:
7 Added: Write clear, plain prose. These rules distill the high-value 20 percent of
8 Added: ASD-STE100 Issue 9 that prevents most writing problems. The full standard and
9 Added: dictionary are at `~/.kiro/steering/ste100/references/ASD-STE100_ISSUE9.pdf`.
10 Added:
11 Added: ## Scope
12 Added:
13 Added: Apply these rules to all prose that you write:
14 Added:
15 Added: - **Written deliverables** — docs, README files, commit messages, CR titles and descriptions, wiki pages, runbooks, API and design documents.
16 Added: - **Code documentation** — comments, docstrings, and inline explanations.
17 Added: - **Your own responses** — every reply, including explanations, reviews, and summaries.
18 Added:
19 Added: STE controls the *form* of your sentences, not the content. When other guidance
20 Added: disagrees about form, follow STE. It still decides what you say and when you act.
21 Added: Keep the substance direct, then express it in STE.
22 Added:
23 Added: ## Always
24 Added:
25 Added: - **Short sentences.** Maximum 20 words in procedures and instructions, 25 words in descriptions.
26 Added: - **One idea per sentence.** Give one instruction or one piece of information.
27 Added: - **Active voice.** "The scheduler starts the job," not "The job is started by the scheduler." Use the passive only when the agent is unknown ("The data is encrypted at rest").
28 Added: - **Imperative for instructions.** "Run the script," not "You should run the script."
29 Added: - **Condition first.** "If the test fails, check the log output," not the reverse.
30 Added: - **Articles before nouns.** "Open the configuration file," not "Open configuration file."
31 Added: - **Keep "that."** "Make sure that the service is running," not "Make sure the service is running."
32 Added: - **Same word for the same thing.** Do not use synonyms for variety. Reuse one term.
33 Added: - **Specific nouns over ambiguous pronouns.** If "it," "they," or "this" could point to more than one thing, name the thing.
34 Added: - **Inclusive, gender-neutral language.** Avoid "he," "she," "man," and "woman" unless necessary.
35 Added: - **Simple, common words.** Prefer short words. Technical nouns and verbs for your field are fine ("endpoint," "deploy," "shard").
36 Added:
37 Added: ## Never
38 Added:
39 Added: - **No contractions.** Write "do not," "cannot," "will not" — even when the user uses them.
40 Added: - **No semicolons in prose.** Write two sentences instead. Semicolons stay correct inside code.
41 Added: - **No Latin abbreviations.** Write "for example," "that is," or list the items. Do not write "e.g.," "i.e.," or "etc."
42 Added: - **No phrasal verbs.** Replace with one precise verb. Write "extinguish," not "put out." Write "release," not "give off."
43 Added: - **No nominalized verbs.** "Apply the patch," not "Do the application of the patch."
44 Added: - **No wordy or Latinate padding.** See Common Fixes.
45 Added:
46 Added: ## Common Fixes
47 Added:
48 Added: | Instead of | Write |
49 Added: |-----------------------------|-----------------------------------------|
50 Added: | "In order to" | "To" |
51 Added: | "It is necessary to" | "You must" |
52 Added: | "Carry out the deployment" | "Deploy" or "Run the deployment" |
53 Added: | "Don't" / "can't" / "won't" | "Do not" / "cannot" / "will not" |
54 Added: | "Utilize" | "Use" |
55 Added: | "Execute" | "Do" (the STE dictionary rejects "execute") |
56 Added: | "Prior to" | "Before" |
57 Added: | "Subsequent to" | "After" |
58 Added: | "Terminate" | "Stop" |
59 Added: | "Initiate" | "Start" |
60 Added: | "i.e." / "e.g." / "etc." | "that is" / "for example" / list items |
61 Added: | Semicolons | Period, then a new sentence |
62 Added:
63 Added: For the complete rule set (part-of-speech constraints, multi-word noun methods,
64 Added: safety-instruction format, word-counting rules, and the approved dictionary),
65 Added: consult the reference PDF above.