refactor Replace closing body lines with Org closing notes

The result, blocked, and cancelled evidence moved from body lines (- result ::, - blocked ::, - cancelled ::) into the :LOGBOOK: drawer, written as CLOSING NOTE, BLOCKED NOTE, and CANCELLED NOTE items next to the state timestamp. The closing note is the natural conclusion of an item and mirrors Org's own closing note. rail-complete sets DONE then writes the CLOSING NOTE; rail-block and rail-cancel write their reason as a note; rail-set-result pre-stages the DONE note. A shared rail-tools--drawer-insert handles LOGBOOK insertion, with a nofill option so closing notes stay on one line. Notes of the same purpose replace in place, so a re-run does not stack a duplicate. Updated the test suite, SKILL.md, and references/tools.md. All 25 tests pass.

Commit
35c9866511cc7fc39890e0f4fa0b8c1aa0dc0a0f
Author
Marius Peter <dev@marius-peter.com>
Author date
Committer
Marius Peter <dev@marius-peter.com>
Committer date
Changed files
skills/rail/SKILL.md
index c13dc0c2..bc606724 100644..100644
@@ -1,6 +1,6 @@
1 1 ---
2 2 name: rail
3 Removed: description: Manage a rolling action item list in a RAIL.org stream with dedicated Emacs MCP tools.
3 Added: description: Manage a rolling action item list in an Org mode file with dedicated Emacs MCP tools.
4 4 ---
5 5
6 6 # RAIL — Rolling Action Item List
@@ -77,6 +77,12 @@
77 77 The tools refuse these transitions without that text. A stalled item or
78 78 a dropped item therefore always states why.
79 79
80 Added: Each closing transition writes the text as a closing note in the
81 Added: `:LOGBOOK:` drawer, next to the state timestamp. The note reads
82 Added: `CLOSING NOTE` for `DONE`, `BLOCKED NOTE` for `BLOCKED`, and
83 Added: `CANCELLED NOTE` for `CANCELLED`. This mirrors Org's own closing note,
84 Added: and it replaces the older body-line evidence.
85 Added:
80 86 ### The tag vocabulary is grouped by axis
81 87
82 88 The `#+TAGS:` lines group the tags into named axes. A common shape holds
@@ -211,7 +217,7 @@
211 217 | `rail-check` | Add, toggle, or list the checklist items of one item. | No |
212 218 | `rail-log` | Append a timestamped note to the `:LOGBOOK:` drawer. | No |
213 219 | `rail-retag` | Replace the tags of an item with a validated set. | No |
214 Removed: | `rail-set-result` | Write the `- result ::` line. | No |
220 Added: | `rail-set-result` | Pre-stage the DONE closing note in the `:LOGBOOK:` drawer. | No |
215 221 | `rail-complete` | Set `DONE` after it records the result evidence. | No |
216 222
217 223 Use `rail-show` to read one item, and `rail-retag` to re-tag it. These
@@ -260,8 +266,8 @@
260 266 ### Block or cancel with a reason
261 267
262 268 Run `rail-block` when work cannot continue. Run `rail-cancel` when you
263 Removed: stop work on purpose. Each tool needs a `reason`, and writes it to the
264 Removed: item.
269 Added: stop work on purpose. Each tool needs a `reason`, and writes it as a
270 Added: closing note in the `:LOGBOOK:` drawer of the item.
265 271
266 272 You **MUST NOT** use `rail-set-status` here, because it records no reason.
267 273
@@ -309,24 +315,27 @@
309 315 - `model` — optional. The agent that did the work.
310 316 - `notes` — optional. A short tail, such as a root cause.
311 317
312 Removed: The tool writes the result line, sets `DONE`, then confirms that Org
313 Removed: recorded the `CLOSED` timestamp. Org writes that timestamp, because the
314 Removed: file sets `org-log-done` to `time`.
318 Added: The tool sets `DONE`, writes the closing note into the `:LOGBOOK:`
319 Added: drawer, then confirms that Org recorded the `CLOSED` timestamp. Org
320 Added: writes that timestamp, because the file sets `org-log-done` to `time`.
315 321
316 322 You **MUST NOT** write or edit that timestamp, because Org owns it.
317 323
318 Removed: The result line takes this form. It stays on one line, because the reader
319 Removed: reads one line:
324 Added: The closing note lands in the `:LOGBOOK:` drawer, next to the `CLOSED`
325 Added: timestamp. It replaces the old body-line evidence. It stays on one
326 Added: line, because the reader reads one line:
320 327
321 328 ```org
322 Removed: - result :: model=example-agent commit=3051af0 tests=243 pass; short root cause
329 Added: :LOGBOOK:
330 Added: - CLOSING NOTE [2026-09-07 Mon 19:31] :: model=example-agent commit=3051af0 tests=243 pass; short root cause
331 Added: :END:
323 332 ```
324 333
325 334 The item stays in place in the stream. There is no refile step.
326 335
327 336 Run `rail-verify` last. Verify that the state is `DONE` and that the
328 Removed: `CLOSED` timestamp is present. To write the result line before you close
329 Removed: the item, run `rail-set-result` on its own.
337 Added: `CLOSED` timestamp is present. To pre-stage the closing note before you
338 Added: close the item, run `rail-set-result` on its own.
330 339
331 340 ## Test the tools
332 341
skills/rail/rail-tests.el
index 0c207f72..6766255d 100644..100644
@@ -196,8 +196,13 @@
196 196 (list (cons 'root root) (cons 'id dropped)
197 197 (cons 'reason "The user withdrew the request")))
198 198 (let ((text (rail-tests--file-text root)))
199 Removed: (should (string-match-p "- blocked :: The route does not exist" text))
200 Removed: (should (string-match-p "- cancelled :: The user withdrew" text)))
199 Added: (should (string-match-p
200 Added: "- BLOCKED NOTE \\[.*\\] :: The route does not exist" text))
201 Added: (should (string-match-p
202 Added: "- CANCELLED NOTE \\[.*\\] :: The user withdrew" text))
203 Added: ;; The note lives in the LOGBOOK drawer, not as a body line.
204 Added: (should-not (string-match-p "- blocked ::" text))
205 Added: (should-not (string-match-p "- cancelled ::" text)))
201 206 (should (equal "BLOCKED"
202 207 (alist-get 'state (rail-tests--call
203 208 #'rail-tools--verify-handler
@@ -321,6 +326,14 @@
321 326 (should (equal (alist-get 'state result) "DONE"))
322 327 (should (string-match-p "commit=def5678" (alist-get 'result result)))
323 328 (should (string-match-p "\\[.*\\]" (alist-get 'closed result)))
329 Added: ;; The closing note is a CLOSING NOTE item in the LOGBOOK drawer,
330 Added: ;; not a `- result ::' body line.
331 Added: (let ((text (rail-tests--file-text root)))
332 Added: (should (string-match-p ":LOGBOOK:" text))
333 Added: (should (string-match-p
334 Added: "- CLOSING NOTE \\[.*\\] :: model=test-agent commit=def5678"
335 Added: text))
336 Added: (should-not (string-match-p "- result ::" text)))
324 337 (let ((entry (rail-tests--call #'rail-tools--verify-handler
325 338 (list (cons 'root root) (cons 'id id)))))
326 339 (should (equal (alist-get 'state entry) "DONE"))
@@ -472,7 +485,10 @@
472 485 (should (equal (alist-get 'state entry) "DONE"))
473 486 (should (stringp (alist-get 'closed entry)))
474 487 (should (string-match-p "commit=0badc0de" (alist-get 'result entry)))
475 Removed: (should (equal (length (alist-get 'logbook entry)) 1))
488 Added: ;; The logbook now holds the progress note plus the closing note.
489 Added: (should (equal (length (alist-get 'logbook entry)) 2))
490 Added: (should (seq-some (lambda (n) (string-match-p "CLOSING NOTE" n))
491 Added: (alist-get 'logbook entry)))
476 492 (should (eq (alist-get 'done (aref (alist-get 'checklist entry) 0)) t))
477 493 (should (string-match-p "Add a widget" (alist-get 'body entry)))))))
478 494
skills/rail/rail-tools.el
index d7bb0f98..6921d45e 100644..100644
@@ -164,40 +164,24 @@
164 164 (let ((trimmed (string-trim value)))
165 165 (and (> (length trimmed) 0) trimmed))))
166 166
167 Removed: (defun rail-tools--set-result (commit tests &optional model notes)
168 Removed: "Write the structured result line for the entry at point.
169 Removed: Replace an existing `- result ::' line, or append one at the end of
170 Removed: the entry body. COMMIT is a commit hash. TESTS is a short recap
171 Removed: such as \"215 pass\". MODEL names the agent that did the work, and
172 Removed: NOTES adds a free-text tail after a semicolon. Both are optional.
173 Removed: Keep the line unwrapped, because the reader captures a single line.
174 Removed: Point must be on the entry heading."
175 Removed: (org-back-to-heading t)
176 Removed: (let* ((model (rail-tools--nonblank model))
177 Removed: (notes (rail-tools--nonblank notes))
178 Removed: (subtree-end (save-excursion (org-end-of-subtree t t) (point-marker)))
179 Removed: (line (concat "- result :: "
180 Removed: (if model (format "model=%s " model) "")
181 Removed: (format "commit=%s tests=%s" commit tests)
182 Removed: (if notes (format "; %s" notes) ""))))
183 Removed: (org-back-to-heading t)
184 Removed: (if (re-search-forward "^[ \t]*- result ::.*$" subtree-end t)
185 Removed: (replace-match line t t)
186 Removed: (goto-char subtree-end)
187 Removed: (skip-chars-backward "\n")
188 Removed: (insert "\n\n" line))
189 Removed: (set-marker subtree-end nil)))
167 Added: (defconst rail-tools--closing-labels
168 Added: '((done . "CLOSING NOTE")
169 Added: (blocked . "BLOCKED NOTE")
170 Added: (cancelled . "CANCELLED NOTE"))
171 Added: "Map a closing purpose to its `:LOGBOOK:' note label.
172 Added: The label mirrors Org's own `CLOSING NOTE' heading for the `done'
173 Added: purpose, so a RAIL closing note reads like a native Org log note.")
190 174
191 Removed: (defun rail-tools--append-log (note)
192 Removed: "Append NOTE as a timestamped item to the entry's `:LOGBOOK:' drawer.
193 Removed: Create the drawer directly after the metadata when it is absent.
194 Removed: Insert the newest item first and wrap it to 72 columns. This is
195 Removed: append-only. It never edits an existing item or the body. Point
196 Removed: must be on the entry heading."
175 Added: (defun rail-tools--drawer-insert (line &optional nofill)
176 Added: "Insert LINE as the newest item in the entry's `:LOGBOOK:' drawer.
177 Added: Create the drawer directly after the metadata when it is absent. LINE
178 Added: is one already-formatted list item without its leading dash. Wrap the
179 Added: inserted item to 72 columns, unless NOFILL is non-nil, in which case
180 Added: keep it on one line. This never edits an existing item or the body.
181 Added: Point must be on the entry heading."
197 182 (let ((fill-column 72))
198 183 (org-back-to-heading t)
199 Removed: (let ((subtree-end (save-excursion (org-end-of-subtree t t) (point-marker)))
200 Removed: (ts (format-time-string "[%Y-%m-%d %a %H:%M]")))
184 Added: (let ((subtree-end (save-excursion (org-end-of-subtree t t) (point-marker))))
201 185 (org-back-to-heading t)
202 186 (let ((drawer-start
203 187 (save-excursion
@@ -214,12 +198,72 @@
214 198 (goto-char drawer-start)
215 199 (forward-line 1)
216 200 (let ((item-start (point)))
217 Removed: (insert (format "- %s %s\n" ts note))
218 Removed: (save-excursion
219 Removed: (goto-char item-start)
220 Removed: (org-fill-paragraph))))
201 Added: (insert (format "- %s\n" line))
202 Added: (unless nofill
203 Added: (save-excursion
204 Added: (goto-char item-start)
205 Added: (org-fill-paragraph)))))
221 206 (set-marker subtree-end nil))))
222 207
208 Added: (defun rail-tools--append-log (note)
209 Added: "Append NOTE as a timestamped item to the entry's `:LOGBOOK:' drawer.
210 Added: Insert the newest item first and wrap it to 72 columns. This is
211 Added: append-only. It never edits an existing item or the body. Point must
212 Added: be on the entry heading."
213 Added: (let ((ts (format-time-string "[%Y-%m-%d %a %H:%M]")))
214 Added: (rail-tools--drawer-insert (format "%s %s" ts note))))
215 Added:
216 Added: (defun rail-tools--closing-note-text (commit tests &optional model notes)
217 Added: "Compose the closing-note text for a completed item.
218 Added: COMMIT is a commit hash. TESTS is a short recap such as \"215 pass\".
219 Added: MODEL names the agent that did the work, and NOTES adds a free-text
220 Added: tail after a semicolon. Both are optional. Return one line, because
221 Added: the reader reads one line."
222 Added: (let ((model (rail-tools--nonblank model))
223 Added: (notes (rail-tools--nonblank notes)))
224 Added: (concat (if model (format "model=%s " model) "")
225 Added: (format "commit=%s tests=%s" commit tests)
226 Added: (if notes (format "; %s" notes) ""))))
227 Added:
228 Added: (defun rail-tools--append-closing-note (purpose text)
229 Added: "Write a closing note into the entry's `:LOGBOOK:' drawer.
230 Added: PURPOSE is `done', `blocked', or `cancelled'. TEXT is the note body.
231 Added: The note reads `LABEL [timestamp] :: TEXT', with LABEL from
232 Added: `rail-tools--closing-labels'. It lands in the drawer next to the
233 Added: state timestamp, and it replaces the old body-line evidence. When the
234 Added: entry already carries a note of the same PURPOSE, replace that note in
235 Added: place, so a re-run does not stack a second note. Point must be on the
236 Added: entry heading."
237 Added: (let ((label (or (cdr (assq purpose rail-tools--closing-labels))
238 Added: (error "Unknown closing purpose: %s" purpose)))
239 Added: (ts (format-time-string "[%Y-%m-%d %a %H:%M]")))
240 Added: (org-back-to-heading t)
241 Added: (let ((subtree-end (save-excursion (org-end-of-subtree t t) (point-marker)))
242 Added: (line (format "%s %s :: %s" label ts text)))
243 Added: (org-back-to-heading t)
244 Added: (if (re-search-forward
245 Added: (format "^[ \t]*- %s \\[[^]]*\\] ::.*$" (regexp-quote label))
246 Added: subtree-end t)
247 Added: ;; Replace an existing note of this purpose in place. Keep the
248 Added: ;; note on one line, because the reader reads one line.
249 Added: (replace-match (format "- %s" line) t t)
250 Added: (rail-tools--drawer-insert line 'nofill))
251 Added: (set-marker subtree-end nil))))
252 Added:
253 Added: (defun rail-tools--closing-note-of (purpose)
254 Added: "Return the text of the newest closing note of PURPOSE at point.
255 Added: PURPOSE is `done', `blocked', or `cancelled'. Return nil when the
256 Added: entry carries no such note. Point must be on the entry heading."
257 Added: (let ((label (cdr (assq purpose rail-tools--closing-labels))))
258 Added: (org-back-to-heading t)
259 Added: (let ((subtree-end (save-excursion (org-end-of-subtree t t) (point))))
260 Added: (org-back-to-heading t)
261 Added: (when (re-search-forward
262 Added: (format "^[ \t]*- %s \\[[^]]*\\] :: \\(.*\\)$"
263 Added: (regexp-quote label))
264 Added: subtree-end t)
265 Added: (string-trim (match-string-no-properties 1))))))
266 Added:
223 267 (defmacro rail-tools--json (&rest body)
224 268 "Evaluate BODY and return its value as a JSON string.
225 269 Catch any error and return a JSON object with an `error' field."
@@ -503,11 +547,12 @@
503 547 ;;; set-result
504 548
505 549 (defun rail-tools--set-result-handler (args)
506 Removed: "Write the structured result line for an entry.
550 Added: "Write the DONE closing note for an entry without changing its state.
507 551 ARGS keys: `root', `id', `commit', `tests', `model' (optional),
508 552 `notes' (optional). COMMIT is a commit hash. TESTS is a short recap
509 Removed: such as \"215 pass\". Replace an existing result line or append one at
510 Removed: the end of the body."
553 Added: such as \"215 pass\". Compose the note text and write it as a
554 Added: `CLOSING NOTE' item in the `:LOGBOOK:' drawer, replacing an earlier
555 Added: one. Use this to pre-stage the closing evidence before rail-complete."
511 556 (rail-tools--json
512 557 (let ((file (rail-tools--file args))
513 558 (id (or (alist-get 'id args) (error "Missing `id'")))
@@ -517,7 +562,8 @@
517 562 (notes (alist-get 'notes args)))
518 563 (with-current-buffer (rail-tools--buffer file)
519 564 (rail-tools--goto-id id)
520 Removed: (rail-tools--set-result commit tests model notes)
565 Added: (rail-tools--append-closing-note
566 Added: 'done (rail-tools--closing-note-text commit tests model notes))
521 567 (when (buffer-modified-p) (save-buffer))
522 568 (rail-tools--goto-id id)
523 569 `((id . ,id)
@@ -527,7 +573,7 @@
527 573 (make-mcp-server-tool
528 574 :name "rail-set-result"
529 575 :title "RAIL Set Result"
530 Removed: :description "Write the structured `- result ::' line for a RAIL entry, recording the commit hash, a short test recap, and optionally the model that did the work and a free-text note. Replaces an existing result line or appends one. rail-complete calls this when you pass commit and tests."
576 Added: :description "Write the DONE closing note for a RAIL entry into the :LOGBOOK: drawer, recording the commit hash, a short test recap, and optionally the model that did the work and a free-text note. Replaces an earlier closing note. Use it to pre-stage the closing evidence; rail-complete writes the same note when it closes the item."
531 577 :input-schema '((type . "object")
532 578 (properties . ((root . ((type . "string")))
533 579 (id . ((type . "string")
@@ -660,7 +706,8 @@
660 706 (defun rail-tools--body-text ()
661 707 "Return the plain body text of the entry at point.
662 708 Read from the first line after the metadata up to the first of: a
663 Removed: `Checklist [' line, a `- result ::' line, or the end of the subtree.
709 Added: `Checklist [' line or the end of the subtree. The closing note lives
710 Added: in the `:LOGBOOK:' drawer, not the body, so it never appears here.
664 711 Return the trimmed string. Point must be on the entry heading."
665 712 (org-back-to-heading t)
666 713 (let ((subtree-end (save-excursion (org-end-of-subtree t t) (point))))
@@ -670,8 +717,7 @@
670 717 (body-end subtree-end))
671 718 (save-excursion
672 719 (goto-char body-start)
673 Removed: (when (re-search-forward "^\\(Checklist \\[\\|[ \t]*- result ::\\)"
674 Removed: subtree-end t)
720 Added: (when (re-search-forward "^Checklist \\[" subtree-end t)
675 721 (setq body-end (line-beginning-position))))
676 722 (string-trim
677 723 (buffer-substring-no-properties body-start body-end)))))
@@ -694,21 +740,17 @@
694 740 (vconcat (nreverse items))))
695 741
696 742 (defun rail-tools--result-text ()
697 Removed: "Return the text after `- result ::' for the entry at point.
698 Removed: Return nil when there is no result line, so it encodes as JSON null.
743 Added: "Return the text of the entry's DONE closing note.
744 Added: Read the newest `CLOSING NOTE' item from the `:LOGBOOK:' drawer.
745 Added: Return nil when there is no such note, so it encodes as JSON null.
699 746 Point must be on the heading."
700 Removed: (org-back-to-heading t)
701 Removed: (let ((subtree-end (save-excursion (org-end-of-subtree t t) (point))))
702 Removed: (org-back-to-heading t)
703 Removed: (when (re-search-forward "^[ \t]*- result ::[ \t]*\\(.*\\)$"
704 Removed: subtree-end t)
705 Removed: (string-trim (match-string-no-properties 1)))))
747 Added: (rail-tools--closing-note-of 'done))
706 748
707 749 (defun rail-tools--show-handler (args)
708 750 "Return the full content of an entry.
709 751 ARGS keys: `root', `id'. Report the heading, state, tags, scheduled
710 752 and closed timestamps, body text, logbook notes, checklist items, and
711 Removed: result line. Read-only."
753 Added: the DONE closing note. Read-only."
712 754 (rail-tools--json
713 755 (let ((file (rail-tools--file args))
714 756 (id (or (alist-get 'id args) (error "Missing `id'"))))
@@ -730,7 +772,7 @@
730 772 (make-mcp-server-tool
731 773 :name "rail-show"
732 774 :title "RAIL Show"
733 Removed: :description "Return the full content of a RAIL entry: heading, state, tags, scheduled and closed timestamps, body text, logbook notes, checklist items, and result line. Read-only."
775 Added: :description "Return the full content of a RAIL entry: heading, state, tags, scheduled and closed timestamps, body text, logbook notes, checklist items, and the DONE closing note. Read-only."
734 776 :input-schema '((type . "object")
735 777 (properties . ((root . ((type . "string")))
736 778 (id . ((type . "string")
@@ -782,46 +824,32 @@
782 824
783 825 ;;; cancel and block
784 826
785 Removed: (defun rail-tools--transition-with-reason (id keyword marker-label reason)
786 Removed: "Transition entry ID to KEYWORD and record REASON.
787 Removed: Signal an error when REASON is missing or blank. Write a wrapped body
788 Removed: line `- MARKER-LABEL :: REASON', replacing an existing line of that
789 Removed: form or appending one at the end of the body. Fill the line to 72
790 Removed: columns, then set the TODO keyword and save."
827 Added: (defun rail-tools--transition-with-reason (id keyword purpose reason)
828 Added: "Transition entry ID to KEYWORD and record REASON as a closing note.
829 Added: Signal an error when REASON is missing or blank. Set the TODO keyword
830 Added: first, so the state timestamp lands, then write REASON as a PURPOSE
831 Added: closing note in the `:LOGBOOK:' drawer. PURPOSE is `blocked' or
832 Added: `cancelled'. The closing note replaces the old `- PURPOSE ::' body
833 Added: line, so the decision is never silent."
791 834 (when (or (null reason) (string-empty-p (string-trim reason)))
792 835 (error "A reason is required"))
793 836 (rail-tools--goto-id id)
794 Removed: (let ((fill-column 72))
795 Removed: (org-back-to-heading t)
796 Removed: (let ((subtree-end (save-excursion (org-end-of-subtree t t) (point-marker)))
797 Removed: (line (format "- %s :: %s" marker-label reason))
798 Removed: insert-at)
799 Removed: (org-back-to-heading t)
800 Removed: (if (re-search-forward
801 Removed: (format "^[ \t]*- %s ::.*$" (regexp-quote marker-label))
802 Removed: subtree-end t)
803 Removed: (progn (replace-match line t t)
804 Removed: (setq insert-at (line-beginning-position)))
805 Removed: (goto-char subtree-end)
806 Removed: (skip-chars-backward "\n")
807 Removed: (insert "\n\n" line)
808 Removed: (setq insert-at (line-beginning-position)))
809 Removed: (save-excursion (goto-char insert-at) (org-fill-paragraph))
810 Removed: (set-marker subtree-end nil)))
811 Removed: (rail-tools--goto-id id)
812 837 (org-todo keyword)
838 Added: (rail-tools--goto-id id)
839 Added: (rail-tools--append-closing-note purpose (string-trim reason))
813 840 (when (buffer-modified-p) (save-buffer)))
814 841
815 842 (defun rail-tools--cancel-handler (args)
816 843 "Set an entry to CANCELLED with a required reason.
817 Removed: ARGS keys: `root', `id', `reason'. Record REASON as a `- cancelled ::'
818 Removed: line so the decision is never silent."
844 Added: ARGS keys: `root', `id', `reason'. Record REASON as a `CANCELLED NOTE'
845 Added: closing note in the `:LOGBOOK:' drawer, so the decision is never
846 Added: silent."
819 847 (rail-tools--json
820 848 (let ((file (rail-tools--file args))
821 849 (id (or (alist-get 'id args) (error "Missing `id'")))
822 850 (reason (alist-get 'reason args)))
823 851 (with-current-buffer (rail-tools--buffer file)
824 Removed: (rail-tools--transition-with-reason id "CANCELLED" "cancelled" reason)
852 Added: (rail-tools--transition-with-reason id "CANCELLED" 'cancelled reason)
825 853 (rail-tools--goto-id id)
826 854 `((id . ,id)
827 855 (state . ,(org-get-todo-state)))))))
@@ -830,7 +858,7 @@
830 858 (make-mcp-server-tool
831 859 :name "rail-cancel"
832 860 :title "RAIL Cancel"
833 Removed: :description "Set a RAIL entry to CANCELLED and record a required reason as a `- cancelled ::' line, so the decision is never silent."
861 Added: :description "Set a RAIL entry to CANCELLED and record a required reason as a CANCELLED NOTE closing note in the :LOGBOOK: drawer, so the decision is never silent."
834 862 :input-schema '((type . "object")
835 863 (properties . ((root . ((type . "string")))
836 864 (id . ((type . "string")
@@ -846,14 +874,15 @@
846 874
847 875 (defun rail-tools--block-handler (args)
848 876 "Set an entry to BLOCKED with a required reason.
849 Removed: ARGS keys: `root', `id', `reason'. Record REASON as a `- blocked ::'
850 Removed: line so the blocker is never silent."
877 Added: ARGS keys: `root', `id', `reason'. Record REASON as a `BLOCKED NOTE'
878 Added: closing note in the `:LOGBOOK:' drawer, so the blocker is never
879 Added: silent."
851 880 (rail-tools--json
852 881 (let ((file (rail-tools--file args))
853 882 (id (or (alist-get 'id args) (error "Missing `id'")))
854 883 (reason (alist-get 'reason args)))
855 884 (with-current-buffer (rail-tools--buffer file)
856 Removed: (rail-tools--transition-with-reason id "BLOCKED" "blocked" reason)
885 Added: (rail-tools--transition-with-reason id "BLOCKED" 'blocked reason)
857 886 (rail-tools--goto-id id)
858 887 `((id . ,id)
859 888 (state . ,(org-get-todo-state)))))))
@@ -862,7 +891,7 @@
862 891 (make-mcp-server-tool
863 892 :name "rail-block"
864 893 :title "RAIL Block"
865 Removed: :description "Set a RAIL entry to BLOCKED and record a required reason as a `- blocked ::' line, so the blocker is never silent."
894 Added: :description "Set a RAIL entry to BLOCKED and record a required reason as a BLOCKED NOTE closing note in the :LOGBOOK: drawer, so the blocker is never silent."
866 895 :input-schema '((type . "object")
867 896 (properties . ((root . ((type . "string")))
868 897 (id . ((type . "string")
@@ -879,11 +908,12 @@
879 908 ;;; complete
880 909
881 910 (defun rail-tools--complete-handler (args)
882 Removed: "Set an entry to DONE with its result evidence and confirm CLOSED.
911 Added: "Set an entry to DONE with its closing note and confirm CLOSED.
883 912 ARGS keys: `root', `id', `commit', `tests', `model' (optional),
884 Removed: `notes' (optional). Write the structured result line, then transition
885 Removed: to DONE. Requires `org-log-done' to be `time' so the normal Org
886 Removed: transition inserts CLOSED; this handler never writes CLOSED."
913 Added: `notes' (optional). Transition to DONE, then write the closing note
914 Added: into the `:LOGBOOK:' drawer next to the CLOSED timestamp. Requires
915 Added: `org-log-done' to be `time' so the normal Org transition inserts
916 Added: CLOSED; this handler never writes CLOSED."
887 917 (rail-tools--json
888 918 (let ((file (rail-tools--file args))
889 919 (id (or (alist-get 'id args) (error "Missing `id'")))
@@ -896,9 +926,10 @@
896 926 (unless (eq org-log-done 'time)
897 927 (error "org-log-done is not set to time"))
898 928 (rail-tools--goto-id id)
899 Removed: (rail-tools--set-result commit tests model notes)
900 Removed: (rail-tools--goto-id id)
901 929 (org-todo "DONE")
930 Added: (rail-tools--goto-id id)
931 Added: (rail-tools--append-closing-note
932 Added: 'done (rail-tools--closing-note-text commit tests model notes))
902 933 (when (buffer-modified-p) (save-buffer))
903 934 (let ((closed (org-entry-get nil "CLOSED")))
904 935 (unless closed
@@ -912,7 +943,7 @@
912 943 (make-mcp-server-tool
913 944 :name "rail-complete"
914 945 :title "RAIL Complete"
915 Removed: :description "Set a RAIL entry to DONE. The tool records the result evidence as a `- result ::' line. The evidence holds the commit hash, a short test recap, and optionally the agent that did the work and a free-text note. The tool then confirms that Org inserted a CLOSED timestamp. The file must set org-log-done to time. The tool never writes the timestamp itself. The entry stays in place, and there is no refile step."
946 Added: :description "Set a RAIL entry to DONE. The tool writes the closing note as a CLOSING NOTE item in the :LOGBOOK: drawer. The note 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 947 :input-schema '((type . "object")
917 948 (properties . ((root . ((type . "string")))
918 949 (id . ((type . "string")))
skills/rail/references/tools.md
index 3fdd594d..6a98dcee 100644..100644
@@ -44,7 +44,10 @@
44 44
45 45 Returns `id`, `heading`, `state`, `tags`, `scheduled`, `closed`, `body`,
46 46 `logbook`, `checklist`, and `result`. The `checklist` field holds objects
47 Removed: with a boolean `done` field and a `text` field.
47 Added: with a boolean `done` field and a `text` field. The `result` field holds
48 Added: the text of the `CLOSING NOTE` from the `:LOGBOOK:` drawer, or `null`
49 Added: when the entry has no closing note. The closing note also appears in the
50 Added: `logbook` array.
48 51
49 52 ### rail-verify
50 53
@@ -91,9 +94,9 @@
91 94
92 95 Returns `id` and `state`.
93 96
94 Removed: `rail-block` writes a `- blocked ::` line and sets `BLOCKED`.
95 Removed: `rail-cancel` writes a `- cancelled ::` line and sets `CANCELLED`.
96 Removed: Each tool refuses a blank reason.
97 Added: `rail-block` writes a `BLOCKED NOTE` closing note into the `:LOGBOOK:`
98 Added: drawer and sets `BLOCKED`. `rail-cancel` writes a `CANCELLED NOTE`
99 Added: closing note and sets `CANCELLED`. Each tool refuses a blank reason.
97 100
98 101 ### rail-check
99 102
@@ -145,8 +148,11 @@
145 148
146 149 Returns `id` and `result`.
147 150
148 Removed: The tool replaces an existing result line, or appends one at the end of
149 Removed: the body. The line stays on one line, because the reader reads one line.
151 Added: The tool writes the DONE closing note (`CLOSING NOTE`) into the
152 Added: `:LOGBOOK:` drawer without changing the state, and replaces an earlier
153 Added: closing note. Use it to pre-stage the closing evidence before
154 Added: `rail-complete`. The note stays on one line, because the reader reads
155 Added: one line.
150 156
151 157 ### rail-complete
152 158
@@ -154,15 +160,25 @@
154 160
155 161 Returns `id`, `state`, `result`, and `closed`.
156 162
157 Removed: The tool writes the result line, sets `DONE`, then reads back the
158 Removed: `CLOSED` timestamp. It fails when the file does not set `org-log-done`
159 Removed: to `time`, because Org must write that timestamp.
163 Added: The tool sets `DONE`, then writes the `CLOSING NOTE` into the
164 Added: `:LOGBOOK:` drawer next to the `CLOSED` timestamp, and reads that
165 Added: timestamp back. It fails when the file does not set `org-log-done` to
166 Added: `time`, because Org must write that timestamp.
160 167
161 Removed: ## Result line format
168 Added: ## Closing note format
162 169
170 Added: Each closing transition writes one note into the `:LOGBOOK:` drawer,
171 Added: next to the state timestamp. The note replaces the old body-line
172 Added: evidence. It reads:
173 Added:
163 174 ```org
164 Removed: - result :: model=example-agent commit=3051af0 tests=243 pass; short root cause
175 Added: :LOGBOOK:
176 Added: - CLOSING NOTE [2026-09-07 Mon 19:31] :: model=example-agent commit=3051af0 tests=243 pass; short root cause
177 Added: :END:
165 178 ```
166 179
167 Removed: The `model` field and the tail after the semicolon are optional. The
168 Removed: `commit` field and the `tests` field are always present.
180 Added: The label is `CLOSING NOTE` for `DONE`, `BLOCKED NOTE` for `BLOCKED`,
181 Added: and `CANCELLED NOTE` for `CANCELLED`. For `DONE`, the `model` field and
182 Added: the tail after the semicolon are optional. The `commit` field and the
183 Added: `tests` field are always present. For `BLOCKED` and `CANCELLED`, the
184 Added: note holds the reason. The note stays on one line.