[Markdown] Tools for working with agents.
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.
Changed files
.gitignore
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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.