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