aboutsummaryrefslogtreecommitdiff
path: root/.ai/scripts
diff options
context:
space:
mode:
Diffstat (limited to '.ai/scripts')
-rw-r--r--.ai/scripts/lint-org.el28
-rwxr-xr-x.ai/scripts/route-batch175
-rwxr-xr-x.ai/scripts/self-inject.sh68
-rwxr-xr-x.ai/scripts/spec-sort715
-rw-r--r--.ai/scripts/tests/route-batch.bats202
-rw-r--r--.ai/scripts/tests/self-inject.bats78
-rw-r--r--.ai/scripts/tests/spec-sort.bats453
-rw-r--r--.ai/scripts/tests/test-lint-org.el31
-rw-r--r--.ai/scripts/tests/test-todo-cleanup.el171
-rw-r--r--.ai/scripts/todo-cleanup.el192
10 files changed, 2110 insertions, 3 deletions
diff --git a/.ai/scripts/lint-org.el b/.ai/scripts/lint-org.el
index 5447cb3..90b1b1d 100644
--- a/.ai/scripts/lint-org.el
+++ b/.ai/scripts/lint-org.el
@@ -35,6 +35,7 @@
;; empty-heading bare stars with no title
;; malformed-priority-cookie [#x]-shaped token org rejected
;; level2-done-without-closed completed level-2 task with no CLOSED
+;; subtask-done-not-dated level-3+ done sub-task still a DONE keyword
;; (anything else) surfaced as judgment with checker name
;;
;; Output format on stdout:
@@ -503,6 +504,32 @@ the live file on the next `task-sorted'."
"level-2 DONE/CANCELLED has no CLOSED date — add CLOSED: [YYYY-MM-DD Day]; task-sorted's aging step archives an undated completed task immediately"))))))))
;;; ---------------------------------------------------------------------------
+;;; level-3+ dated-header check (claude-rules/todo-format.md)
+;;
+;; The inverse of the level-2 check above. A completed sub-task — a heading at
+;; level 3 or deeper, under a parent task — becomes a dated event-log entry, not
+;; a DONE keyword, so the parent's subtree grows a chronological history instead
+;; of a long tail of nested DONE lines. An interactive org close
+;; (`org-log-done' → DONE + CLOSED) leaves the keyword in place, and
+;; `--archive-done' only touches level 2, so these accumulate. Flag them for
+;; conversion. Judgment-only and regex-based (independent of which TODO keywords
+;; the batch Emacs recognizes); todo-cleanup.el --convert-subtasks does the fix.
+
+(defun lo--check-subtask-done-not-dated ()
+ "Flag level-3+ headings carrying a done keyword (DONE/CANCELLED/FAILED).
+Emits one judgment item per offending heading (checker
+`subtask-done-not-dated')."
+ (save-excursion
+ (goto-char (point-min))
+ ;; Case-sensitive: the keywords are uppercase, not the words in a title.
+ (let ((case-fold-search nil))
+ (while (re-search-forward
+ "^\\*\\{3,\\} \\(DONE\\|CANCELLED\\|FAILED\\) " nil t)
+ (lo--emit-judgment
+ 'subtask-done-not-dated (line-number-at-pos)
+ "level-3+ done sub-task should be a dated event-log entry (todo-format.md): run todo-cleanup.el --convert-subtasks to rewrite it")))))
+
+;;; ---------------------------------------------------------------------------
;;; File processing
(defun lo--backup (file)
@@ -543,6 +570,7 @@ left unmodified and mechanical entries are recorded with :preview t."
(lo--check-empty-headings)
(lo--check-malformed-priority-cookies)
(lo--check-level2-done-without-closed)
+ (lo--check-subtask-done-not-dated)
(when (and (not lo-check-only) (buffer-modified-p))
(save-buffer)))
(with-current-buffer buf (set-buffer-modified-p nil))
diff --git a/.ai/scripts/route-batch b/.ai/scripts/route-batch
new file mode 100755
index 0000000..8f27d19
--- /dev/null
+++ b/.ai/scripts/route-batch
@@ -0,0 +1,175 @@
+#!/usr/bin/env python3
+"""route-batch — the wrap-up router's mechanical go path.
+
+The wrap-up cross-project router (wrap-it-up.org Step 3; wrapup-routing spec
+D7/D8/D9) surfaces the local tasks that inbox process mode stamped with
+:ROUTE_CANDIDATE: <destination> at file time, and on "go" delivers each to its
+destination project's inbox. This script does the mechanical half so the
+subtree surgery is deterministic:
+
+ route-batch --list [--todo todo.org]
+ One "<destination>\t<heading>" line per :ROUTE_CANDIDATE:-tagged task.
+ Silent with exit 0 when there are no candidates (the workflow's
+ empty-set-equals-zero-interaction rule). Read-only.
+
+ route-batch --go [--todo todo.org]
+ For each candidate, bottom-up: extract the task's whole subtree
+ (children ride along), drop the :ROUTE_CANDIDATE: line (and the
+ property drawer if that leaves it empty), promote the subtree so its
+ top heading is level 1, write it to a temp file, and deliver it via
+ the sibling inbox-send.py to the destination's inbox/ (one file per
+ task, from-<source> provenance stamped by inbox-send). Only after a
+ successful send is the subtree removed from the local todo.org — a
+ failed send leaves that task in place, is reported, and the run exits
+ non-zero after attempting the rest.
+
+The candidate set is exactly the tagged tasks — never the standing backlog.
+Discovery, roots, and the source-project name all come from inbox-send.py
+(INBOX_SEND_ROOTS sandboxes it in tests). The reject-from-another-project
+flow in inbox process mode is the mis-route recovery; that path is why
+removing the local source after a successful send is safe.
+"""
+
+import argparse
+import os
+import re
+import subprocess
+import sys
+import tempfile
+from pathlib import Path
+
+HEADING_RE = re.compile(r"^(\*+)\s+(.*)$")
+MARKER_RE = re.compile(r"^\s*:ROUTE_CANDIDATE:\s+(\S+)\s*$")
+
+
+def find_candidates(lines):
+ """[(heading_idx, end_idx, marker_idx, destination, heading_text)] —
+ end_idx is one past the subtree's last line."""
+ candidates = []
+ for i, line in enumerate(lines):
+ m = MARKER_RE.match(line)
+ if not m:
+ continue
+ head_idx = None
+ for j in range(i, -1, -1):
+ hm = HEADING_RE.match(lines[j])
+ if hm:
+ head_idx = j
+ level = len(hm.group(1))
+ heading = hm.group(2)
+ break
+ if head_idx is None:
+ continue
+ end = len(lines)
+ for k in range(head_idx + 1, len(lines)):
+ km = HEADING_RE.match(lines[k])
+ if km and len(km.group(1)) <= level:
+ end = k
+ break
+ candidates.append((head_idx, end, i, m.group(1), heading))
+ return candidates
+
+
+def extract_handoff(lines, head_idx, end):
+ """The subtree as handoff text: every :ROUTE_CANDIDATE: line dropped
+ (a marker is meaningless at the destination), empty drawers pruned,
+ headings promoted so the task is level 1."""
+ sub = [l for l in lines[head_idx:end] if not MARKER_RE.match(l)]
+
+ pruned = []
+ i = 0
+ while i < len(sub):
+ if sub[i].strip() == ":PROPERTIES:" and i + 1 < len(sub) and sub[i + 1].strip() == ":END:":
+ i += 2
+ continue
+ pruned.append(sub[i])
+ i += 1
+
+ shift = len(HEADING_RE.match(pruned[0]).group(1)) - 1
+ if shift > 0:
+ pruned = [l[shift:] if HEADING_RE.match(l) else l for l in pruned]
+ return "\n".join(pruned).rstrip() + "\n"
+
+
+def send(destination, handoff_text, slug):
+ inbox_send = Path(__file__).with_name("inbox-send.py")
+ with tempfile.NamedTemporaryFile(
+ "w", suffix=".org", prefix=f"route-{slug}-", delete=False, encoding="utf-8"
+ ) as tf:
+ tf.write(handoff_text)
+ tmp = tf.name
+ try:
+ result = subprocess.run(
+ [sys.executable, str(inbox_send), destination, "--file", tmp],
+ capture_output=True, text=True,
+ )
+ return result.returncode == 0, (result.stderr or result.stdout).strip()
+ finally:
+ os.unlink(tmp)
+
+
+def main():
+ ap = argparse.ArgumentParser(prog="route-batch")
+ mode = ap.add_mutually_exclusive_group(required=True)
+ mode.add_argument("--list", action="store_true", dest="list_mode")
+ mode.add_argument("--go", action="store_true")
+ ap.add_argument("--todo", default="todo.org")
+ args = ap.parse_args()
+
+ todo_path = Path(args.todo)
+ if not todo_path.is_file():
+ return 0 # no todo file, no candidates
+ lines = todo_path.read_text(encoding="utf-8").splitlines()
+ candidates = find_candidates(lines)
+
+ # Two markers in one task's drawer are one candidate, not two: same span +
+ # same destination dedupes. Everything else that overlaps — a tagged child
+ # inside a tagged parent, one task tagged for two destinations — is a
+ # conflict: routing either span would silently take the other (or, with a
+ # stale end index, a bystander task) along. Conflicts are left in place
+ # and reported; the human untangles which project the pieces belong to.
+ deduped = []
+ for cand in candidates:
+ if not any(c[0] == cand[0] and c[1] == cand[1] and c[3] == cand[3] for c in deduped):
+ deduped.append(cand)
+ conflicted = set()
+ for a in deduped:
+ for b in deduped:
+ if a is not b and a[0] <= b[0] and b[1] <= a[1]:
+ conflicted.add(a)
+ conflicted.add(b)
+ routable = [c for c in deduped if c not in conflicted]
+
+ if not deduped:
+ return 0
+
+ if args.list_mode:
+ for _h, _e, _m, dest, heading in deduped:
+ flag = "\tCONFLICT (overlapping candidates — resolve by hand)" if (_h, _e, _m, dest, heading) in conflicted else ""
+ print(f"{dest}\t{heading}{flag}")
+ return 0
+
+ failures = 0
+ for _h, _e, _m, dest, heading in sorted(conflicted):
+ failures += 1
+ print(f"CONFLICT: {dest}\t{heading}\t(overlapping candidate subtrees — left in place, resolve by hand)")
+
+ # Bottom-up so earlier indices stay valid as subtrees are removed; the
+ # file is rewritten after every successful send so a crash mid-run never
+ # leaves an already-sent task still present locally.
+ for head_idx, end, _marker_idx, dest, heading in sorted(routable, reverse=True):
+ handoff = extract_handoff(lines, head_idx, end)
+ slug = re.sub(r"[^a-z0-9]+", "-", heading.lower()).strip("-")[:40] or "task"
+ ok, detail = send(dest, handoff, slug)
+ if ok:
+ del lines[head_idx:end]
+ todo_path.write_text("\n".join(lines).rstrip("\n") + "\n", encoding="utf-8")
+ print(f"routed: {dest}\t{heading}")
+ else:
+ failures += 1
+ print(f"FAILED: {dest}\t{heading}\t({detail})")
+ return 1 if failures else 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/.ai/scripts/self-inject.sh b/.ai/scripts/self-inject.sh
new file mode 100755
index 0000000..e7340c1
--- /dev/null
+++ b/.ai/scripts/self-inject.sh
@@ -0,0 +1,68 @@
+#!/bin/sh
+# self-inject.sh — type text into the tmux pane running this agent session.
+#
+# The building block for AUTO-FLUSH: an agent checkpoints its session-context,
+# then has tmux type "/clear" and a resume prompt at its own idle prompt, so a
+# session flushes with no human at the keyboard.
+#
+# Usage:
+# self-inject.sh -t %PANE <delay> <text> [<delay2> <text2> ...]
+# self-inject.sh <delay> <text> [...] # derive pane from ancestry
+# self-inject.sh [-t %PANE] # no pairs: report the pane
+#
+# Each pair: sleep <delay> seconds, then type <text> literally and press Enter.
+#
+# TWO HARD-WON GOTCHAS (2026-07-02, archsetup session):
+# 1. A detached child (setsid/nohup/&) of an agent tool call DIES when the
+# tool call ends — the harness cleans up the process group. The arm step
+# must run under the tmux SERVER instead:
+# tmux run-shell -b "self-inject.sh -t %1 25 '/clear' 15 'go — resume...'"
+# 2. Under tmux run-shell the process is a child of the tmux server, so
+# ancestry-based pane detection CANNOT work there. Derive the pane FIRST,
+# synchronously from the agent's own shell (no -t), then pass it
+# explicitly with -t when arming.
+#
+# Collision hazard: if the user happens to be typing when the send fires, the
+# injected text merges into their input line (a real /clear became "/clearto"
+# mid-word). Auto-flush is for sessions running unattended; warn the user to
+# keep hands off for the armed window if they're present.
+
+PANE=""
+if [ "$1" = "-t" ]; then
+ PANE=$2; shift 2
+fi
+
+ppid_of() {
+ # /proc/<pid>/stat: pid (comm) state ppid ... — comm may contain spaces,
+ # so take the 2nd field after the LAST ')'.
+ stat=$(cat "/proc/$1/stat" 2>/dev/null) || return 1
+ # shellcheck disable=SC2086 # word-splitting the stat tail is the point
+ set -- ${stat##*) }
+ echo "$2"
+}
+
+find_pane() {
+ anc=" "
+ pid=$$
+ while [ -n "$pid" ] && [ "$pid" -gt 1 ] 2>/dev/null; do
+ anc="$anc$pid "
+ pid=$(ppid_of "$pid") || break
+ done
+ tmux list-panes -a -F "#{pane_pid} #{pane_id}" 2>/dev/null | \
+ while read -r ppid pane; do
+ case "$anc" in *" $ppid "*) echo "$pane"; break;; esac
+ done
+}
+
+[ -n "$PANE" ] || PANE=$(find_pane)
+[ -n "$PANE" ] || { echo "self-inject: no owning pane found (pass -t %PANE)" >&2; exit 1; }
+
+# With no delay/text pairs, just report the pane (the derive-first step).
+[ $# -ge 2 ] || { echo "$PANE"; exit 0; }
+
+while [ $# -ge 2 ]; do
+ sleep "$1"
+ tmux send-keys -t "$PANE" -l "$2"
+ tmux send-keys -t "$PANE" Enter
+ shift 2
+done
diff --git a/.ai/scripts/spec-sort b/.ai/scripts/spec-sort
new file mode 100755
index 0000000..ebfef82
--- /dev/null
+++ b/.ai/scripts/spec-sort
@@ -0,0 +1,715 @@
+#!/usr/bin/env python3
+"""spec-sort — one-time docs-pile retrofit for the docs-lifecycle convention.
+
+Classifies every docs/**/*.org outside docs/specs/ by one predicate: a doc
+carrying BOTH a "Decisions" heading AND an "Implementation phases" heading is
+a spec candidate; everything else is a note. For each candidate it shows an
+evidence panel (Status field, decision/finding cookies, the linking todo.org
+task, recent dated history, cheap existence checks on phase-named artifacts)
+and proposes a lifecycle keyword the evidence supports — conservative
+non-terminal (DRAFT) when inconclusive. The helper proposes; a human confirms
+every move.
+
+Dry-run report is the default. --apply executes under the fail-safe contract:
+
+ - Clean-worktree preflight: refuses on a dirty git tree (exit 2) unless
+ --allow-dirty, which prints exactly what recovery loses.
+ - Every candidate must be addressed with --confirm REL=KEYWORD or
+ --skip REL; terminal keywords (IMPLEMENTED SUPERSEDED CANCELLED) also
+ need --reason REL=TEXT, recorded in the status-history line.
+ - The full move + relink plan is computed and validated first (every
+ destination free, every link resolvable), written to a plan file, and
+ only then executed from that recorded plan.
+ - Bare-path mentions of a moving doc inside the rewritten roots are
+ reported, never rewritten; they block --apply until --acknowledge-bare
+ explicitly waives them.
+ - Mid-apply failure stops the run, names what was and wasn't applied, and
+ prints the git-restore recovery recipe (plus deletion of newly created
+ destination copies, which git restore can't remove).
+ - After a successful apply, a residue scan across the rewritten roots must
+ find no link still resolving to an old path, or spec-sort exits non-zero
+ naming the residue.
+
+Per move: rename to carry the -spec.org suffix, prepend the status heading
+(:ID: UUID + dated history line), rewrite the keyword header to the
+two-sequence form, mirror the keyword into the Metadata Status field, and
+recompute every affected file: link (inbound links to the moved doc AND the
+moved doc's own outbound relative links). Rewritten roots: todo.org,
+.ai/notes.org, docs/**, .ai/project-workflows/, .ai/project-scripts/.
+Reported-never-rewritten: .ai/sessions/ (frozen history) and synced template
+paths (.ai/workflows/, .ai/scripts/, .ai/protocols.org — the report names
+the canonical claude-templates file instead).
+
+Finally stamps :LAST_SPEC_SORT: YYYY-MM-DD in .ai/notes.org's
+* Workflow State section (created idempotently), which permanently clears
+the startup nudge. A run with zero candidates still stamps.
+
+Exit codes: 0 done (or clean report), 1 blocked (confirm gate, validation,
+bare mentions, residue, mid-apply failure), 2 usage / preflight refusal.
+
+Test hook: SPEC_SORT_INJECT_FAIL_AFTER=N aborts the apply after N write
+operations, exercising the recovery path in the bats suite.
+"""
+
+import argparse
+import json
+import os
+import re
+import subprocess
+import sys
+import tempfile
+import uuid
+from datetime import datetime
+
+LIFECYCLE = ("DRAFT", "READY", "DOING", "IMPLEMENTED", "SUPERSEDED", "CANCELLED")
+TERMINAL = {"IMPLEMENTED", "SUPERSEDED", "CANCELLED"}
+TODO_HEADER = [
+ "#+TODO: TODO | DONE",
+ "#+TODO: DRAFT READY DOING | IMPLEMENTED SUPERSEDED CANCELLED",
+]
+
+# Project-owned surfaces whose file: links get rewritten.
+REWRITE_ROOTS = ("todo.org", ".ai/notes.org", "docs", ".ai/project-workflows", ".ai/project-scripts")
+# Frozen or synced surfaces: occurrences are reported, never rewritten.
+REPORT_ROOTS = (".ai/sessions", ".ai/workflows", ".ai/scripts", ".ai/protocols.org")
+# Synced template paths map to their canonical rulesets file for the report.
+SYNCED_PREFIX = (".ai/workflows", ".ai/scripts", ".ai/protocols.org")
+
+LINK_RE = re.compile(r"\[\[file:([^\]\[]+)\](?:\[([^\]\[]*)\])?\]")
+HEADING_RE = re.compile(r"^(\*+)\s+(.*)$")
+COOKIE_RE = re.compile(r"\[\d+/\d+\]")
+DATED_RE = re.compile(r"\b\d{4}-\d{2}-\d{2}\b")
+
+
+def read_text(path):
+ try:
+ with open(path, encoding="utf-8") as f:
+ return f.read()
+ except (UnicodeDecodeError, OSError):
+ return None
+
+
+def heading_text(line):
+ """Heading text with the org keyword and priority cookie stripped."""
+ m = HEADING_RE.match(line)
+ if not m:
+ return None
+ text = re.sub(r"^[A-Z]+\s+", "", m.group(2))
+ text = re.sub(r"^\[#[A-Z]\]\s+", "", text)
+ return text.strip()
+
+
+def has_spine(content):
+ """The classification predicate: Decisions AND Implementation phases."""
+ dec = imp = False
+ for line in content.splitlines():
+ t = heading_text(line)
+ if t is None:
+ continue
+ tl = t.lower()
+ if tl.startswith("decisions"):
+ dec = True
+ elif tl.startswith("implementation phases"):
+ imp = True
+ return dec and imp
+
+
+def walk_files(root, rel_base):
+ """Yield project-relative paths of files under rel_base (file or dir)."""
+ abs_base = os.path.join(root, rel_base)
+ if os.path.isfile(abs_base):
+ yield rel_base
+ return
+ for dirpath, dirs, files in os.walk(abs_base):
+ dirs.sort()
+ for name in sorted(files):
+ yield os.path.relpath(os.path.join(dirpath, name), root)
+
+
+def classify(root):
+ """Split docs/**/*.org outside docs/specs/ into candidates / anomalies / notes."""
+ candidates, anomalies, notes = [], [], []
+ docs = os.path.join(root, "docs")
+ if not os.path.isdir(docs):
+ return candidates, anomalies, notes
+ for rel in walk_files(root, "docs"):
+ if not rel.endswith(".org"):
+ continue
+ parts = rel.split(os.sep)
+ if len(parts) > 1 and parts[1] == "specs":
+ continue
+ content = read_text(os.path.join(root, rel))
+ if content is None:
+ continue
+ if has_spine(content):
+ candidates.append(rel)
+ elif os.path.basename(rel).endswith("-spec.org"):
+ anomalies.append(rel)
+ else:
+ notes.append(rel)
+ return candidates, anomalies, notes
+
+
+def dest_for(rel):
+ base = os.path.basename(rel)
+ if not base.endswith("-spec.org"):
+ base = base[: -len(".org")] + "-spec.org"
+ return os.path.join("docs", "specs", base)
+
+
+# ---- Evidence panel ---------------------------------------------------
+
+
+def todo_task_for(root, rel):
+ """Heading of the first todo.org task whose subtree mentions the doc."""
+ content = read_text(os.path.join(root, "todo.org"))
+ if content is None:
+ return None
+ lines = content.splitlines()
+ basename = os.path.basename(rel)
+ for i, line in enumerate(lines):
+ if basename in line or rel in line:
+ for j in range(i, -1, -1):
+ if HEADING_RE.match(lines[j]):
+ return lines[j].lstrip("* ").strip()
+ return None
+ return None
+
+
+def gather_evidence(root, rel, content):
+ ev = {}
+ m = re.search(r"^\|\s*Status\s*\|\s*([^|]*)\|", content, re.MULTILINE | re.IGNORECASE)
+ ev["status"] = m.group(1).strip() if m else None
+
+ cookies = []
+ for line in content.splitlines():
+ t = heading_text(line)
+ if t and COOKIE_RE.search(t) and (
+ t.lower().startswith("decisions") or t.lower().startswith("review findings")
+ ):
+ cookies.append(t)
+ ev["cookies"] = cookies
+
+ ev["todo"] = todo_task_for(root, rel)
+ kw = None
+ if ev["todo"]:
+ m = re.match(r"([A-Z]+)\s", ev["todo"])
+ kw = m.group(1) if m else None
+ ev["todo_keyword"] = kw
+
+ dated = [ln.strip() for ln in content.splitlines() if DATED_RE.search(ln)]
+ ev["history"] = dated[-1][:100] if dated else None
+
+ # Cheap artifact check: =path= tokens inside the Implementation phases section.
+ artifacts, exists = [], 0
+ section = re.split(r"^\*+\s+.*implementation phases.*$", content, maxsplit=1, flags=re.MULTILINE | re.IGNORECASE)
+ if len(section) > 1:
+ for tok in re.findall(r"=([^=\s]+)=", section[1]):
+ if "/" in tok:
+ artifacts.append(tok)
+ if os.path.exists(os.path.join(root, tok)):
+ exists += 1
+ ev["artifacts"] = (exists, artifacts)
+ return ev
+
+
+def propose_keyword(ev):
+ s = (ev["status"] or "").lower()
+ words = set(re.findall(r"[a-z]+", s))
+ if words & {"implemented", "shipped", "complete", "completed", "done"}:
+ return "IMPLEMENTED"
+ if words & {"superseded"}:
+ return "SUPERSEDED"
+ if words & {"cancelled", "canceled", "dead", "abandoned"}:
+ return "CANCELLED"
+ if words & {"doing", "implementing"} or "in progress" in s or "in-progress" in s:
+ return "DOING"
+ if ev["todo_keyword"] == "DOING":
+ return "DOING"
+ if words & {"ready", "approved", "accepted"}:
+ return "READY"
+ return "DRAFT" # conservative non-terminal default
+
+
+# ---- Link scanning ----------------------------------------------------
+
+
+def rewrite_files(root):
+ """Project-relative *.org files under the rewritten roots."""
+ seen = []
+ for base in REWRITE_ROOTS:
+ if not os.path.exists(os.path.join(root, base)):
+ continue
+ for rel in walk_files(root, base):
+ if rel.endswith(".org") and rel not in seen:
+ seen.append(rel)
+ return seen
+
+
+def resolve_target(root, linker_rel, raw_target, moved):
+ """Resolve a file: link target to a project-relative path (org semantics
+ first — relative to the linking file's directory — then project-root
+ anchoring as a fallback for root-anchored links)."""
+ if raw_target.startswith(("/", "~", "http:", "https:")):
+ return None
+ rel_a = os.path.normpath(os.path.join(os.path.dirname(linker_rel), raw_target))
+ if rel_a in moved or os.path.exists(os.path.join(root, rel_a)):
+ return rel_a
+ rel_b = os.path.normpath(raw_target)
+ if rel_b in moved or os.path.exists(os.path.join(root, rel_b)):
+ return rel_b
+ return rel_a
+
+
+def plan_link_edits(root, moved):
+ """Compute every link rewrite: inbound links to moved docs and moved
+ docs' own outbound relative links. Returns ({linker_rel: [(old, new)]},
+ [ambiguity descriptions]) — a link whose file-relative and root-anchored
+ readings are both live and disagree about a moving doc blocks validation
+ rather than being rewritten against a guess."""
+ edits = {}
+ ambiguous = []
+ for linker in rewrite_files(root):
+ content = read_text(os.path.join(root, linker))
+ if content is None:
+ continue
+ linker_post = moved.get(linker, linker)
+ for m in LINK_RE.finditer(content):
+ raw = m.group(1)
+ desc = m.group(2)
+ target_path, sep, anchor = raw.partition("::")
+ target = resolve_target(root, linker, target_path, moved)
+ if target is None:
+ continue
+ rel_a = os.path.normpath(os.path.join(os.path.dirname(linker), target_path))
+ rel_b = os.path.normpath(target_path)
+ if rel_a != rel_b:
+ live_a = rel_a in moved or os.path.exists(os.path.join(root, rel_a))
+ live_b = rel_b in moved or os.path.exists(os.path.join(root, rel_b))
+ if live_a and live_b and (rel_a in moved or rel_b in moved):
+ ambiguous.append(
+ "%s: [[file:%s]] reads as %s (file-relative) or %s (root-anchored) "
+ "and a moving doc is involved — resolve the link by hand" % (linker, raw, rel_a, rel_b))
+ continue
+ if target not in moved and linker not in moved:
+ continue
+ if target not in moved and not os.path.exists(os.path.join(root, target)):
+ continue # already broken before this run; not ours to guess
+ target_post = moved.get(target, target)
+ new_path = os.path.relpath(target_post, os.path.dirname(linker_post) or ".")
+ new_raw = new_path + (sep + anchor if sep else "")
+ if new_raw == raw:
+ continue
+ new_link = "[[file:%s]%s]" % (new_raw, "[%s]" % desc if desc is not None else "")
+ if m.group(0) != new_link:
+ edits.setdefault(linker, []).append((m.group(0), new_link))
+ return edits, ambiguous
+
+
+def scan_bare_mentions(root, moved):
+ """Bare-path mentions of moving docs in the rewritten roots — text
+ occurrences outside any [[...]] link. Reported, never rewritten."""
+ found = []
+ for base in REWRITE_ROOTS:
+ if not os.path.exists(os.path.join(root, base)):
+ continue
+ for rel in walk_files(root, base):
+ content = read_text(os.path.join(root, rel))
+ if content is None:
+ continue
+ for i, line in enumerate(content.splitlines(), 1):
+ stripped = re.sub(r"\[\[[^\]]*\](?:\[[^\]]*\])?\]", "", line)
+ for src in moved:
+ if src in stripped:
+ found.append((rel, i, src))
+ return found
+
+
+def scan_report_only(root, moved):
+ """Occurrences of moving docs in frozen/synced surfaces."""
+ reports = []
+ for base in REPORT_ROOTS:
+ if not os.path.exists(os.path.join(root, base)):
+ continue
+ for rel in walk_files(root, base):
+ content = read_text(os.path.join(root, rel))
+ if content is None:
+ continue
+ for src in moved:
+ if src in content:
+ if rel.startswith(SYNCED_PREFIX):
+ note = ("synced template, not rewritten — a local edit is reverted by the "
+ "next sync; edit the canonical claude-templates/%s instead" % rel)
+ else:
+ note = "frozen history; not rewritten"
+ reports.append((rel, src, note))
+ return reports
+
+
+# ---- Content transforms -----------------------------------------------
+
+
+def transform_spec(content, keyword, reason, title, doc_id, link_edits):
+ """Apply the retrofit rewrite to a moving spec's content: two-sequence
+ keyword header, prepended status heading, Status-field mirror, and the
+ doc's own link edits."""
+ for old, new in link_edits:
+ content = content.replace(old, new)
+ lines = content.splitlines()
+
+ todo_idx = None
+ kept = []
+ for line in lines:
+ if line.startswith("#+TODO:"):
+ if todo_idx is None:
+ todo_idx = len(kept)
+ continue
+ kept.append(line)
+ lines = kept
+ if todo_idx is None:
+ todo_idx = 0
+ while todo_idx < len(lines) and lines[todo_idx].startswith("#+"):
+ todo_idx += 1
+ lines[todo_idx:todo_idx] = TODO_HEADER
+
+ head_end = 0
+ while head_end < len(lines) and (lines[head_end].startswith("#+") or not lines[head_end].strip()):
+ head_end += 1
+ ts = datetime.now().astimezone().strftime("%Y-%m-%d %a @ %H:%M:%S %z")
+ provenance = "reason: %s" % reason if reason else "evidence-based, human-confirmed"
+ block = [
+ "* %s %s" % (keyword, title),
+ ":PROPERTIES:",
+ ":ID: %s" % doc_id,
+ ":END:",
+ "- %s — retrofitted by spec-sort; status set to %s (%s)" % (ts, keyword, provenance),
+ "",
+ ]
+ lines[head_end:head_end] = block
+
+ out = []
+ mirrored = False
+ for line in lines:
+ m = re.match(r"^(\|\s*Status\s*\|)([^|]*)(\|.*)$", line, re.IGNORECASE)
+ if m and not mirrored:
+ value = " %s" % keyword.lower()
+ width = len(m.group(2))
+ line = m.group(1) + (value.ljust(width) if len(value) <= width else value + " ") + m.group(3)
+ mirrored = True
+ out.append(line)
+ return "\n".join(out) + "\n"
+
+
+def title_for(content, rel):
+ m = re.search(r"^#\+TITLE:\s*(.+)$", content, re.MULTILINE | re.IGNORECASE)
+ if m:
+ return m.group(1).strip()
+ base = os.path.basename(rel)[: -len(".org")]
+ return base[: -len("-spec")] if base.endswith("-spec") else base
+
+
+# ---- Marker ------------------------------------------------------------
+
+
+def stamp_marker(root, date):
+ path = os.path.join(root, ".ai", "notes.org")
+ os.makedirs(os.path.dirname(path), exist_ok=True)
+ content = read_text(path) or ""
+ line = ":LAST_SPEC_SORT: %s" % date
+ if ":LAST_SPEC_SORT:" in content:
+ content = re.sub(r":LAST_SPEC_SORT:.*", line, content, count=1)
+ elif re.search(r"^\* Workflow State\s*$", content, re.MULTILINE):
+ content = re.sub(r"(^\* Workflow State\s*$)", r"\1\n" + line, content, count=1, flags=re.MULTILINE)
+ else:
+ if content and not content.endswith("\n"):
+ content += "\n"
+ content += "\n* Workflow State\n\n%s\n" % line
+ with open(path, "w", encoding="utf-8") as f:
+ f.write(content)
+
+
+# ---- Apply -------------------------------------------------------------
+
+
+class ApplyFailure(Exception):
+ """Mid-apply failure: args are (applied_labels, remaining_ops, cause)."""
+
+
+def apply_plan(root, plan, fail_after):
+ """Execute the recorded plan. Returns the applied-op labels; raises
+ ApplyFailure mid-way on a write error or when the test hook fires."""
+ ops = []
+ for mv in plan["moves"]:
+ ops.append(("move", mv))
+ for linker, edits in plan["link_edits"].items():
+ if linker in {mv["src"] for mv in plan["moves"]}:
+ continue # a moving doc's own edits ride along in its transform
+ ops.append(("relink", (linker, edits)))
+
+ applied = []
+ specs_dir = os.path.join(root, "docs", "specs")
+ if plan["moves"] and not os.path.isdir(specs_dir):
+ os.makedirs(specs_dir)
+ plan["created_dirs"].append(os.path.join("docs", "specs"))
+
+ for n, (kind, payload) in enumerate(ops, 1):
+ if fail_after and n > fail_after:
+ raise ApplyFailure(applied, ops[n - 1:], "injected test failure")
+ try:
+ if kind == "move":
+ mv = payload
+ content = read_text(os.path.join(root, mv["src"]))
+ new = transform_spec(content, mv["keyword"], mv["reason"], mv["title"], mv["id"],
+ plan["link_edits"].get(mv["src"], []))
+ with open(os.path.join(root, mv["dest"]), "w", encoding="utf-8") as f:
+ f.write(new)
+ os.remove(os.path.join(root, mv["src"]))
+ applied.append("move %s -> %s" % (mv["src"], mv["dest"]))
+ else:
+ linker, edits = payload
+ path = os.path.join(root, linker)
+ content = read_text(path)
+ for old, new in edits:
+ content = content.replace(old, new)
+ with open(path, "w", encoding="utf-8") as f:
+ f.write(content)
+ applied.append("relink %s (%d link%s)" % (linker, len(edits), "s" if len(edits) != 1 else ""))
+ except OSError as exc:
+ raise ApplyFailure(applied, ops[n - 1:], str(exc))
+ return applied
+
+
+def residue_check(root, plan):
+ """Post-apply: no link in the rewritten roots may still resolve to an
+ old path; bare mentions beyond the acknowledged set fail too."""
+ moved = {mv["src"]: mv["dest"] for mv in plan["moves"]}
+ residue = []
+ for linker in rewrite_files(root):
+ content = read_text(os.path.join(root, linker))
+ if content is None:
+ continue
+ for m in LINK_RE.finditer(content):
+ target_path = m.group(1).partition("::")[0]
+ target = resolve_target(root, linker, target_path, {})
+ if target in moved:
+ residue.append("%s: link still resolves to %s" % (linker, target))
+ # Acknowledged mentions were recorded pre-apply; a mention inside a moved
+ # doc now lives at the doc's destination, so map the file side through the
+ # moves before comparing.
+ acknowledged = {(moved.get(f, f), src) for f, _ln, src in plan["bare"]}
+ for f, ln, src in scan_bare_mentions(root, moved):
+ if (f, src) not in acknowledged:
+ residue.append("%s:%d: bare mention of %s" % (f, ln, src))
+ return residue
+
+
+def print_recovery(plan, applied, not_applied):
+ print("FAILURE — the apply did not complete.")
+ print(" applied:")
+ for a in applied or ["(nothing)"]:
+ print(" %s" % a)
+ print(" not applied:")
+ for kind, payload in not_applied:
+ if kind == "move":
+ print(" move %s -> %s" % (payload["src"], payload["dest"]))
+ else:
+ print(" relink %s" % payload[0])
+ print("RECOVERY — restore the pre-run state (safe: preflight required a clean tree):")
+ touched = [mv["src"] for mv in plan["moves"]] + [l for l in plan["link_edits"] if l not in {mv["src"] for mv in plan["moves"]}]
+ print(" git restore -- %s" % " ".join(touched))
+ created = [mv["dest"] for mv in plan["moves"]]
+ print(" rm -f -- %s # git restore can't remove the created copies" % " ".join(created))
+ for d in plan.get("created_dirs", []):
+ print(" rmdir --ignore-fail-on-non-empty -- %s" % d)
+
+
+# ---- Main ---------------------------------------------------------------
+
+
+def parse_kv(pairs, label):
+ out = {}
+ for item in pairs or []:
+ if "=" not in item:
+ sys.exit("spec-sort: %s expects REL=VALUE, got %r" % (label, item))
+ k, v = item.split("=", 1)
+ out[os.path.normpath(k)] = v
+ return out
+
+
+def main():
+ ap = argparse.ArgumentParser(prog="spec-sort", add_help=True)
+ ap.add_argument("--project-root", default=".")
+ ap.add_argument("--apply", action="store_true")
+ ap.add_argument("--allow-dirty", action="store_true")
+ ap.add_argument("--acknowledge-bare", action="store_true")
+ ap.add_argument("--confirm", action="append", metavar="REL=KEYWORD")
+ ap.add_argument("--reason", action="append", metavar="REL=TEXT")
+ ap.add_argument("--skip", action="append", metavar="REL")
+ ap.add_argument("--plan-file")
+ args = ap.parse_args()
+
+ root = os.path.abspath(args.project_root)
+ confirms = parse_kv(args.confirm, "--confirm")
+ reasons = parse_kv(args.reason, "--reason")
+ skips = {os.path.normpath(s) for s in (args.skip or [])}
+
+ candidates, anomalies, notes = classify(root)
+ if not candidates and not anomalies and not notes and not os.path.isdir(os.path.join(root, "docs")):
+ return 0 # no docs pile at all — silent no-op
+
+ for named in list(confirms) + list(skips) + list(reasons):
+ if named not in candidates:
+ print("spec-sort: %s is not a spec candidate" % named)
+ return 1
+ for rel, kw in confirms.items():
+ if kw not in LIFECYCLE:
+ print("spec-sort: %r is not a lifecycle keyword (%s)" % (kw, " ".join(LIFECYCLE)))
+ return 1
+
+ # ---- Build the plan (shared by report and apply) ----
+ moves = []
+ for rel in candidates:
+ if rel in skips:
+ continue
+ if args.apply and rel not in confirms:
+ continue # gate failure reported below
+ content = read_text(os.path.join(root, rel))
+ moves.append({
+ "src": rel,
+ "dest": dest_for(rel),
+ "keyword": confirms.get(rel, None),
+ "reason": reasons.get(rel),
+ "title": title_for(content, rel),
+ "id": str(uuid.uuid4()),
+ })
+ moved_map = {mv["src"]: mv["dest"] for mv in moves}
+ link_edits, ambiguous = plan_link_edits(root, moved_map)
+ bare = scan_bare_mentions(root, moved_map)
+ reports = scan_report_only(root, moved_map)
+
+ # ---- Report ----
+ for rel in candidates:
+ content = read_text(os.path.join(root, rel))
+ ev = gather_evidence(root, rel, content)
+ proposed = propose_keyword(ev)
+ print("CANDIDATE %s -> %s" % (rel, dest_for(rel)))
+ suffix = " (terminal — requires --reason to apply)" if proposed in TERMINAL else ""
+ print(" proposed keyword: %s%s" % (proposed, suffix))
+ print(" evidence:")
+ print(" status field: %s" % (ev["status"] or "(none)"))
+ print(" cookies: %s" % ("; ".join(ev["cookies"]) or "(none)"))
+ print(" todo.org: %s" % (ev["todo"] or "(no linking task)"))
+ print(" history: %s" % (ev["history"] or "(none)"))
+ n_exist, artifacts = ev["artifacts"]
+ if artifacts:
+ print(" artifacts: %d/%d named paths exist (%s)" % (n_exist, len(artifacts), ", ".join(artifacts)))
+ else:
+ print(" artifacts: (none named)")
+ for rel in anomalies:
+ print("ANOMALY %s: named -spec.org but lacks the spec spine (Decisions + Implementation phases); surfaced, not moved" % rel)
+ for rel in notes:
+ print("NOTE %s" % rel)
+ for linker, edits in sorted(link_edits.items()):
+ for old, new in edits:
+ print("RELINK %s: %s -> %s" % (linker, old, new))
+ for a in ambiguous:
+ print("AMBIGUOUS %s" % a)
+ for f, ln, src in bare:
+ print("BARE-PATH %s:%d: %s (reported for manual handling, never rewritten)" % (f, ln, src))
+ for rel, src, note in reports:
+ print("REPORT %s: reference to %s (%s)" % (rel, src, note))
+
+ if not args.apply:
+ if candidates or anomalies or notes:
+ print("DRY RUN — no changes written. Pass --apply with per-candidate --confirm/--skip to execute.")
+ return 0
+
+ # ---- Apply: preflight ----
+ try:
+ porcelain = subprocess.run(
+ ["git", "status", "--porcelain"], cwd=root,
+ capture_output=True, text=True, check=True,
+ ).stdout
+ except (subprocess.CalledProcessError, FileNotFoundError):
+ print("spec-sort: --apply needs a git worktree (recovery depends on git restore)")
+ return 2
+ if porcelain.strip():
+ dirty = [ln[3:] for ln in porcelain.splitlines()]
+ if not args.allow_dirty:
+ print("spec-sort: refusing --apply on a dirty worktree (%d path%s). Commit or stash first, or pass --allow-dirty."
+ % (len(dirty), "s" if len(dirty) != 1 else ""))
+ return 2
+ print("WARNING --allow-dirty: recovery via git restore would also revert your pre-existing uncommitted changes:")
+ for p in dirty:
+ print(" %s" % p)
+
+ # ---- Apply: confirm gate ----
+ unaddressed = [rel for rel in candidates if rel not in confirms and rel not in skips]
+ if unaddressed:
+ print("spec-sort: unconfirmed candidate(s) — pass --confirm REL=KEYWORD or --skip REL for each:")
+ for rel in unaddressed:
+ print(" %s" % rel)
+ return 1
+ for mv in moves:
+ if mv["keyword"] in TERMINAL and not mv["reason"]:
+ print("spec-sort: %s -> %s is a terminal state and requires an explicit --reason %s=TEXT"
+ % (mv["src"], mv["keyword"], mv["src"]))
+ return 1
+
+ # ---- Apply: validation ----
+ problems = []
+ dests = {}
+ for mv in moves:
+ if os.path.exists(os.path.join(root, mv["dest"])):
+ problems.append("%s: destination exists (%s)" % (mv["src"], mv["dest"]))
+ if mv["dest"] in dests:
+ problems.append("%s and %s: destination exists twice (%s)" % (mv["src"], dests[mv["dest"]], mv["dest"]))
+ dests[mv["dest"]] = mv["src"]
+ for a in ambiguous:
+ problems.append("ambiguous link: %s" % a)
+ if bare and not args.acknowledge_bare:
+ problems.append("bare-path mention(s) listed above need manual handling — re-run with --acknowledge-bare to proceed without rewriting them")
+ if problems:
+ print("spec-sort: validation blocked — nothing written:")
+ for p in problems:
+ print(" %s" % p)
+ return 1
+
+ # ---- Apply: record the plan, then execute from it ----
+ today = datetime.now().astimezone().strftime("%Y-%m-%d")
+ plan = {
+ "root": root, "date": today, "moves": moves,
+ "link_edits": link_edits, "bare": bare,
+ "reports": [list(r) for r in reports], "created_dirs": [],
+ }
+ plan_path = args.plan_file or os.path.join(
+ tempfile.gettempdir(), "spec-sort-plan-%s.json" % os.path.basename(root))
+ with open(plan_path, "w", encoding="utf-8") as f:
+ json.dump(plan, f, indent=2)
+ print("plan written: %s" % plan_path)
+
+ fail_after = int(os.environ.get("SPEC_SORT_INJECT_FAIL_AFTER", "0") or 0)
+ try:
+ applied = apply_plan(root, plan, fail_after)
+ except ApplyFailure as exc:
+ print("write failed: %s" % exc.args[2])
+ print_recovery(plan, exc.args[0], exc.args[1])
+ return 1
+
+ residue = residue_check(root, plan)
+ if residue:
+ print("spec-sort: residue after apply — old paths still referenced:")
+ for r in residue:
+ print(" %s" % r)
+ print_recovery(plan, applied, [])
+ return 1
+
+ stamp_marker(root, today)
+ for a in applied:
+ print("applied: %s" % a)
+ print("spec-sort: done — %d spec(s) sorted, :LAST_SPEC_SORT: %s stamped" % (len(moves), today))
+ return 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/.ai/scripts/tests/route-batch.bats b/.ai/scripts/tests/route-batch.bats
new file mode 100644
index 0000000..84ded5f
--- /dev/null
+++ b/.ai/scripts/tests/route-batch.bats
@@ -0,0 +1,202 @@
+#!/usr/bin/env bats
+#
+# Tests for claude-templates/.ai/scripts/route-batch — the wrap-up router's
+# mechanical go path (wrapup-routing spec, Phase 4 / D7 / D9).
+#
+# Contract under test:
+# route-batch --list one "<destination>\t<heading>" line per task
+# carrying :ROUTE_CANDIDATE:; silent when none;
+# never modifies anything
+# route-batch --go per candidate: write the subtree (minus the
+# :ROUTE_CANDIDATE: line) as a one-task handoff,
+# deliver via inbox-send to the destination's
+# inbox/, then remove the subtree from the local
+# todo.org. Send failure leaves the task in
+# place and exits non-zero. Empty set: no-op.
+#
+# Strategy: fixture roots under $TEST_DIR hold a source project and two
+# destination projects; INBOX_SEND_ROOTS sandboxes inbox-send's discovery to
+# them (the same hook inbox-send's own tests use).
+
+SCRIPT="$(cd "$(dirname "$BATS_TEST_FILENAME")/.." && pwd)/route-batch"
+
+setup() {
+ TEST_DIR="$(mktemp -d -t route-batch-bats.XXXXXX)"
+ ROOTS="$TEST_DIR/roots"
+ SRC="$ROOTS/srcproj"
+ mkdir -p "$SRC/.ai" "$SRC/inbox" \
+ "$ROOTS/alpha/.ai" "$ROOTS/alpha/inbox" \
+ "$ROOTS/beta/.ai" "$ROOTS/beta/inbox"
+ touch "$ROOTS/alpha/todo.org" # alpha has a todo.org; beta deliberately not
+
+ cat > "$SRC/todo.org" <<'EOF'
+* Srcproj Open Work
+** TODO [#B] Alpha-bound task :feature:
+:PROPERTIES:
+:ROUTE_CANDIDATE: alpha
+:END:
+Body line about the alpha work.
+*** TODO Sub-task that rides along
+** TODO [#C] Purely local task
+Local body stays put.
+** TODO [#C] Beta-bound task :quick:
+:PROPERTIES:
+:CREATED: [2026-07-01 Tue]
+:ROUTE_CANDIDATE: beta
+:END:
+Beta body.
+EOF
+
+ export INBOX_SEND_ROOTS="$ROOTS"
+ cd "$SRC"
+}
+
+teardown() {
+ rm -rf "$TEST_DIR"
+}
+
+# ---- --list ------------------------------------------------------------
+
+@test "route-batch --list: one destination+heading line per candidate, backlog excluded" {
+ run "$SCRIPT" --list
+ [ "$status" -eq 0 ]
+ [[ "$output" == *"alpha"*"Alpha-bound task"* ]]
+ [[ "$output" == *"beta"*"Beta-bound task"* ]]
+ [[ "$output" != *"Purely local task"* ]]
+}
+
+@test "route-batch --list: empty candidate set is silent (exit 0)" {
+ sed -i '/:ROUTE_CANDIDATE:/d' todo.org
+ run "$SCRIPT" --list
+ [ "$status" -eq 0 ]
+ [ -z "$output" ]
+}
+
+@test "route-batch --list: modifies nothing (skip leaves all in place)" {
+ before="$(cat todo.org)"
+ run "$SCRIPT" --list
+ [ "$status" -eq 0 ]
+ [ "$(cat todo.org)" = "$before" ]
+ [ -z "$(ls "$ROOTS/alpha/inbox" "$ROOTS/beta/inbox" 2>/dev/null | grep -v ':')" ]
+}
+
+# ---- --go --------------------------------------------------------------
+
+@test "route-batch --go: delivers each candidate to its destination inbox with provenance" {
+ run "$SCRIPT" --go
+ [ "$status" -eq 0 ]
+ alpha_file=$(find "$ROOTS/alpha/inbox" -name '*from-srcproj*' -type f)
+ beta_file=$(find "$ROOTS/beta/inbox" -name '*from-srcproj*' -type f)
+ [ -n "$alpha_file" ]
+ [ -n "$beta_file" ]
+ grep -q 'Alpha-bound task' "$alpha_file"
+ grep -q 'Sub-task that rides along' "$alpha_file" # children ride along
+ grep -q 'Beta-bound task' "$beta_file"
+ ! grep -q ':ROUTE_CANDIDATE:' "$alpha_file"
+ ! grep -q ':ROUTE_CANDIDATE:' "$beta_file"
+}
+
+@test "route-batch --go: removes routed subtrees from todo.org, leaves local tasks" {
+ run "$SCRIPT" --go
+ [ "$status" -eq 0 ]
+ ! grep -q 'Alpha-bound task' todo.org
+ ! grep -q 'Sub-task that rides along' todo.org
+ ! grep -q 'Beta-bound task' todo.org
+ grep -q 'Purely local task' todo.org
+ grep -q 'Local body stays put' todo.org
+}
+
+@test "route-batch --go: a kept property drawer survives minus the marker" {
+ run "$SCRIPT" --go
+ [ "$status" -eq 0 ]
+ beta_file=$(find "$ROOTS/beta/inbox" -name '*from-srcproj*' -type f)
+ grep -q ':CREATED: \[2026-07-01 Tue\]' "$beta_file"
+}
+
+@test "route-batch --go: destination with inbox/ but no todo.org still delivers" {
+ run "$SCRIPT" --go
+ [ "$status" -eq 0 ]
+ [ ! -f "$ROOTS/beta/todo.org" ]
+ [ -n "$(find "$ROOTS/beta/inbox" -name '*from-srcproj*' -type f)" ]
+}
+
+@test "route-batch --go: empty candidate set is a silent no-op (exit 0)" {
+ sed -i '/:ROUTE_CANDIDATE:/d' todo.org
+ before="$(cat todo.org)"
+ run "$SCRIPT" --go
+ [ "$status" -eq 0 ]
+ [ -z "$output" ]
+ [ "$(cat todo.org)" = "$before" ]
+}
+
+@test "route-batch --go: a failed send leaves that task in place, marker intact, and exits non-zero" {
+ sed -i 's/:ROUTE_CANDIDATE: beta/:ROUTE_CANDIDATE: ghost/' todo.org
+ run "$SCRIPT" --go
+ [ "$status" -ne 0 ]
+ grep -q 'Beta-bound task' todo.org # failed route stays local
+ grep -q ':ROUTE_CANDIDATE: ghost' todo.org # marker survives so it resurfaces next wrap
+ ! grep -q 'Alpha-bound task' todo.org # the good route still landed
+ [ -n "$(find "$ROOTS/alpha/inbox" -name '*from-srcproj*' -type f)" ]
+}
+
+@test "route-batch --go: handoff headings are promoted to top level" {
+ run "$SCRIPT" --go
+ [ "$status" -eq 0 ]
+ alpha_file=$(find "$ROOTS/alpha/inbox" -name '*from-srcproj*' -type f)
+ grep -q '^\* TODO \[#B\] Alpha-bound task' "$alpha_file"
+ grep -q '^\*\* TODO Sub-task that rides along' "$alpha_file"
+}
+
+@test "route-batch --go: a drawer emptied by the marker strip is pruned from the handoff" {
+ run "$SCRIPT" --go
+ [ "$status" -eq 0 ]
+ alpha_file=$(find "$ROOTS/alpha/inbox" -name '*from-srcproj*' -type f)
+ ! grep -q ':PROPERTIES:' "$alpha_file"
+}
+
+# ---- Overlapping candidates (nested marker data-loss regression) --------
+
+@test "route-batch --go: nested candidates conflict — both stay, bystander survives, exit non-zero" {
+ cat > todo.org <<'EOF'
+* Srcproj Open Work
+** TODO [#B] Parent bound for alpha
+:PROPERTIES:
+:ROUTE_CANDIDATE: alpha
+:END:
+Parent body.
+*** TODO Child bound for beta
+:PROPERTIES:
+:ROUTE_CANDIDATE: beta
+:END:
+Child body.
+** TODO [#C] Innocent bystander task
+Bystander body.
+EOF
+ run "$SCRIPT" --go
+ [ "$status" -ne 0 ]
+ [[ "$output" == *"CONFLICT"* ]]
+ grep -q 'Parent bound for alpha' todo.org
+ grep -q 'Child bound for beta' todo.org
+ grep -q 'Innocent bystander task' todo.org
+ grep -q 'Bystander body' todo.org
+ [ -z "$(find "$ROOTS/alpha/inbox" "$ROOTS/beta/inbox" -name '*from-srcproj*' -type f)" ]
+}
+
+@test "route-batch: duplicate identical markers in one drawer dedupe to a single route" {
+ cat > todo.org <<'EOF'
+* Srcproj Open Work
+** TODO [#B] Double-tagged for alpha
+:PROPERTIES:
+:ROUTE_CANDIDATE: alpha
+:ROUTE_CANDIDATE: alpha
+:END:
+Body.
+EOF
+ run "$SCRIPT" --list
+ [ "$status" -eq 0 ]
+ [ "$(echo "$output" | grep -c 'Double-tagged')" -eq 1 ]
+ [[ "$output" != *"CONFLICT"* ]]
+ run "$SCRIPT" --go
+ [ "$status" -eq 0 ]
+ [ "$(find "$ROOTS/alpha/inbox" -name '*from-srcproj*' -type f | wc -l)" -eq 1 ]
+}
diff --git a/.ai/scripts/tests/self-inject.bats b/.ai/scripts/tests/self-inject.bats
new file mode 100644
index 0000000..482f61d
--- /dev/null
+++ b/.ai/scripts/tests/self-inject.bats
@@ -0,0 +1,78 @@
+#!/usr/bin/env bats
+# Tests for self-inject.sh — tmux is the external boundary, stubbed with a
+# recording fake so no real server is needed.
+
+setup() {
+ SCRIPT="$BATS_TEST_DIRNAME/../self-inject.sh"
+ STUB_DIR="$BATS_TEST_TMPDIR/bin"
+ LOG="$BATS_TEST_TMPDIR/tmux.log"
+ mkdir -p "$STUB_DIR"
+}
+
+# A tmux stub that records every invocation and answers list-panes from
+# $STUB_PANES (empty by default, so pane derivation fails unless a test
+# provides ancestry-matching output).
+make_stub() {
+ cat > "$STUB_DIR/tmux" <<'EOF'
+#!/bin/sh
+echo "$@" >> "$LOG"
+case "$1" in
+ list-panes) printf '%s\n' "$STUB_PANES" ;;
+esac
+EOF
+ chmod +x "$STUB_DIR/tmux"
+}
+
+@test "self-inject: -t pane with no pairs echoes the pane and exits 0" {
+ make_stub
+ run env PATH="$STUB_DIR:$PATH" LOG="$LOG" STUB_PANES="" sh "$SCRIPT" -t %42
+ [ "$status" -eq 0 ]
+ [ "$output" = "%42" ]
+ # Pane was supplied, nothing sent: tmux must not have been called.
+ [ ! -e "$LOG" ]
+}
+
+@test "self-inject: no pane derivable and no -t exits 1 with an error" {
+ make_stub
+ run env PATH="$STUB_DIR:$PATH" LOG="$LOG" STUB_PANES="" sh "$SCRIPT" 0 "hello"
+ [ "$status" -eq 1 ]
+ case "$output" in *"no owning pane"*) : ;; *) false ;; esac
+}
+
+@test "self-inject: derives the pane from process ancestry via list-panes" {
+ make_stub
+ # The stub reports the bats test process itself as a pane's pane_pid;
+ # the script runs as our child, so that pid is in its ancestry.
+ run env PATH="$STUB_DIR:$PATH" LOG="$LOG" STUB_PANES="$$ %7" sh "$SCRIPT"
+ [ "$status" -eq 0 ]
+ [ "$output" = "%7" ]
+}
+
+@test "self-inject: one delay/text pair sends literal text then Enter" {
+ make_stub
+ run env PATH="$STUB_DIR:$PATH" LOG="$LOG" STUB_PANES="" sh "$SCRIPT" -t %3 0 "/clear"
+ [ "$status" -eq 0 ]
+ run cat "$LOG"
+ [ "${lines[0]}" = "send-keys -t %3 -l /clear" ]
+ [ "${lines[1]}" = "send-keys -t %3 Enter" ]
+}
+
+@test "self-inject: multiple pairs send in order" {
+ make_stub
+ run env PATH="$STUB_DIR:$PATH" LOG="$LOG" STUB_PANES="" \
+ sh "$SCRIPT" -t %3 0 "/clear" 0 "go — resume"
+ [ "$status" -eq 0 ]
+ run cat "$LOG"
+ [ "${lines[0]}" = "send-keys -t %3 -l /clear" ]
+ [ "${lines[1]}" = "send-keys -t %3 Enter" ]
+ [ "${lines[2]}" = "send-keys -t %3 -l go — resume" ]
+ [ "${lines[3]}" = "send-keys -t %3 Enter" ]
+}
+
+@test "self-inject: dangling odd argument after pairs is ignored" {
+ make_stub
+ run env PATH="$STUB_DIR:$PATH" LOG="$LOG" STUB_PANES="" sh "$SCRIPT" -t %3 0 "one" 99
+ [ "$status" -eq 0 ]
+ run cat "$LOG"
+ [ "${#lines[@]}" -eq 2 ]
+}
diff --git a/.ai/scripts/tests/spec-sort.bats b/.ai/scripts/tests/spec-sort.bats
new file mode 100644
index 0000000..583e458
--- /dev/null
+++ b/.ai/scripts/tests/spec-sort.bats
@@ -0,0 +1,453 @@
+#!/usr/bin/env bats
+#
+# Tests for claude-templates/.ai/scripts/spec-sort — the one-time docs-pile
+# retrofit from the docs-lifecycle spec: classify docs/**/*.org outside
+# docs/specs/ (spec candidate iff it carries BOTH a Decisions heading AND an
+# Implementation phases heading), show an evidence panel, and on --apply
+# move + rename confirmed candidates to docs/specs/*-spec.org, prepend the
+# status heading (:ID:, dated history line), rewrite the keyword header to
+# the two-sequence form, relink file: links across the rewritten roots,
+# stamp :LAST_SPEC_SORT: in .ai/notes.org.
+#
+# Contract under test (docs/specs/2026-07-01-docs-lifecycle-spec.org,
+# "The retrofit"):
+# - dry-run report is the default; --apply writes
+# - --apply refuses on a dirty worktree (exit 2) unless --allow-dirty
+# - every candidate needs --confirm REL=KEYWORD or --skip REL (exit 1
+# otherwise); terminal keywords need --reason REL=TEXT
+# - plan validated before the first write; destination collisions block
+# - bare-path mentions in rewritten roots block --apply until
+# --acknowledge-bare waives them (reported, never rewritten)
+# - mid-apply failure names applied/not-applied + git restore recovery
+# - idempotent: a sorted project yields no candidates, no changes
+#
+# Strategy: each test builds a throwaway git project fixture and runs the
+# real script against it. Mid-apply failure is forced via the test-only
+# SPEC_SORT_INJECT_FAIL_AFTER env hook.
+
+SCRIPT="$(cd "$(dirname "$BATS_TEST_FILENAME")/.." && pwd)/spec-sort"
+
+setup() {
+ TEST_DIR="$(mktemp -d -t spec-sort-bats.XXXXXX)"
+ PROJ="$TEST_DIR/proj"
+ mkdir -p "$PROJ"
+}
+
+teardown() {
+ rm -rf "$TEST_DIR"
+}
+
+# Standard fixture: one spec candidate, one note, a stray root spec with a
+# spine, an anomaly (-spec.org name, no spine), inbound links from todo.org,
+# a sibling note, a session archive (report-only surface), and .ai/notes.org
+# with a Workflow State section.
+make_project() {
+ cd "$PROJ"
+ git init -q
+ git config user.email test@test
+ git config user.name test
+ mkdir -p docs/design .ai/sessions
+
+ cat > docs/design/widget.org <<'EOF'
+#+TITLE: Widget Feature
+#+DATE: 2026-05-01
+#+TODO: DRAFT REVIEW | SHIPPED
+
+* Metadata
+| Status | draft |
+| Owner | Craig |
+
+* Summary
+The widget feature. See [[file:scratch-note.org][the note]].
+
+* Decisions [1/2]
+** DONE Pick the widget shape
+** TODO Pick the color
+
+* Implementation phases
+** Phase 1 — build =src/widget.py=
+EOF
+
+ cat > docs/design/scratch-note.org <<'EOF'
+#+TITLE: Scratch Note
+
+* Metadata
+| Status | n/a |
+
+* Thoughts
+See [[file:widget.org][the widget spec]].
+EOF
+
+ cat > docs/rooty-spec.org <<'EOF'
+#+TITLE: Rooty
+
+* Decisions
+** DONE Only decision
+
+* Implementation phases
+** Phase 1 — nothing
+EOF
+
+ cat > docs/lonely-spec.org <<'EOF'
+#+TITLE: Lonely
+Just prose, no spine.
+EOF
+
+ cat > todo.org <<'EOF'
+* Open Work
+** DOING [#B] Widget feature
+Spec: [[file:docs/design/widget.org][widget spec]].
+Summary anchor: [[file:docs/design/widget.org::*Summary][the summary]].
+EOF
+
+ cat > .ai/notes.org <<'EOF'
+* Active Reminders
+
+* Workflow State
+:LAST_AUDIT: 2026-06-28
+EOF
+
+ cat > .ai/sessions/2026-06-01-old.org <<'EOF'
+Old log: [[file:../../docs/design/widget.org][widget]]
+EOF
+
+ git add -A
+ git commit -qm init
+}
+
+# Confirm flags that satisfy the gate for the standard fixture's candidates.
+CONFIRM_ALL=(--confirm docs/design/widget.org=DRAFT --confirm docs/rooty-spec.org=DRAFT)
+
+# ---- Classification (dry-run) ----------------------------------------
+
+@test "spec-sort: dry-run classifies the spine-carrying doc as a candidate" {
+ make_project
+ run "$SCRIPT"
+ [ "$status" -eq 0 ]
+ [[ "$output" == *"CANDIDATE docs/design/widget.org -> docs/specs/widget-spec.org"* ]]
+}
+
+@test "spec-sort: a Metadata table alone does not qualify — note stays a note" {
+ make_project
+ run "$SCRIPT"
+ [ "$status" -eq 0 ]
+ [[ "$output" == *"NOTE docs/design/scratch-note.org"* ]]
+ [[ "$output" != *"CANDIDATE docs/design/scratch-note.org"* ]]
+}
+
+@test "spec-sort: stray root spec with a spine is a candidate, suffix not doubled" {
+ make_project
+ run "$SCRIPT"
+ [ "$status" -eq 0 ]
+ [[ "$output" == *"CANDIDATE docs/rooty-spec.org -> docs/specs/rooty-spec.org"* ]]
+ [[ "$output" != *"rooty-spec-spec.org"* ]]
+}
+
+@test "spec-sort: -spec.org name without a spine is an anomaly, never auto-moved" {
+ make_project
+ run "$SCRIPT"
+ [ "$status" -eq 0 ]
+ [[ "$output" == *"ANOMALY docs/lonely-spec.org"* ]]
+ [[ "$output" != *"CANDIDATE docs/lonely-spec.org"* ]]
+}
+
+@test "spec-sort: docs/specs/ contents are excluded from classification" {
+ make_project
+ mkdir -p docs/specs
+ cp docs/design/widget.org docs/specs/sorted-spec.org
+ git add -A && git commit -qm more
+ run "$SCRIPT"
+ [ "$status" -eq 0 ]
+ [[ "$output" != *"CANDIDATE docs/specs/sorted-spec.org"* ]]
+}
+
+@test "spec-sort: no docs/ directory is a silent no-op" {
+ cd "$PROJ"
+ git init -q
+ git config user.email test@test
+ git config user.name test
+ echo x > README.md
+ git add -A && git commit -qm init
+ run "$SCRIPT"
+ [ "$status" -eq 0 ]
+ [ -z "$output" ]
+}
+
+# ---- Evidence panel ---------------------------------------------------
+
+@test "spec-sort: evidence panel shows status field, cookies, and todo.org task" {
+ make_project
+ run "$SCRIPT"
+ [ "$status" -eq 0 ]
+ [[ "$output" == *"status field: draft"* ]]
+ [[ "$output" == *"Decisions [1/2]"* ]]
+ [[ "$output" == *"todo.org:"*"DOING"*"Widget feature"* ]]
+}
+
+@test "spec-sort: keyword proposal follows the evidence — DOING from the linked DOING task" {
+ make_project
+ run "$SCRIPT"
+ [ "$status" -eq 0 ]
+ # status field says draft, but the linking todo.org task is DOING — the
+ # panel proposes the state the strongest evidence supports
+ [[ "$output" == *"proposed keyword: DOING"* ]]
+}
+
+@test "spec-sort: an 'incomplete' status field never proposes the terminal IMPLEMENTED" {
+ make_project
+ sed -i 's/| Status | draft |/| Status | incomplete |/' docs/design/widget.org
+ git add -A && git commit -qm status
+ run "$SCRIPT"
+ [ "$status" -eq 0 ]
+ [[ "$output" != *"proposed keyword: IMPLEMENTED"* ]]
+}
+
+# ---- Confirm gate -----------------------------------------------------
+
+@test "spec-sort --apply: refuses when a candidate is neither confirmed nor skipped" {
+ make_project
+ run "$SCRIPT" --apply --confirm docs/design/widget.org=DRAFT
+ [ "$status" -eq 1 ]
+ [[ "$output" == *"unconfirmed"* ]]
+ [[ "$output" == *"docs/rooty-spec.org"* ]]
+ [ -f docs/design/widget.org ] # nothing moved
+}
+
+@test "spec-sort --apply: a terminal keyword without --reason refuses" {
+ make_project
+ run "$SCRIPT" --apply --confirm docs/design/widget.org=IMPLEMENTED --skip docs/rooty-spec.org
+ [ "$status" -eq 1 ]
+ [[ "$output" == *"--reason"* ]]
+ [ -f docs/design/widget.org ]
+}
+
+@test "spec-sort --apply: a terminal keyword with --reason records it in the history line" {
+ make_project
+ run "$SCRIPT" --apply --confirm docs/design/widget.org=IMPLEMENTED \
+ --reason "docs/design/widget.org=shipped in v2, confirmed against src" \
+ --skip docs/rooty-spec.org
+ [ "$status" -eq 0 ]
+ grep -q '^\* IMPLEMENTED Widget Feature' docs/specs/widget-spec.org
+ grep -q 'shipped in v2, confirmed against src' docs/specs/widget-spec.org
+}
+
+@test "spec-sort --apply: --skip leaves the candidate in place and still stamps the marker" {
+ make_project
+ run "$SCRIPT" --apply --skip docs/design/widget.org --skip docs/rooty-spec.org
+ [ "$status" -eq 0 ]
+ [ -f docs/design/widget.org ]
+ grep -q ':LAST_SPEC_SORT:' .ai/notes.org
+}
+
+# ---- Preflight --------------------------------------------------------
+
+@test "spec-sort --apply: refuses on a dirty worktree (exit 2)" {
+ make_project
+ echo "drift" >> todo.org
+ run "$SCRIPT" --apply "${CONFIRM_ALL[@]}"
+ [ "$status" -eq 2 ]
+ [[ "$output" == *"dirty"* ]]
+ [ -f docs/design/widget.org ]
+}
+
+@test "spec-sort --apply --allow-dirty: proceeds and names what recovery loses" {
+ make_project
+ echo "drift" >> todo.org
+ git add todo.org && git commit -qm drift # keep the link intact; dirty a different file
+ echo "scratch" > untracked-note.txt
+ echo "local edit" >> .ai/notes.org
+ run "$SCRIPT" --apply --allow-dirty "${CONFIRM_ALL[@]}"
+ [ "$status" -eq 0 ]
+ [[ "$output" == *"pre-existing"* ]]
+ [[ "$output" == *".ai/notes.org"* ]]
+ [ -f docs/specs/widget-spec.org ]
+}
+
+# ---- Move + rename + rewrite ------------------------------------------
+
+@test "spec-sort --apply: moves, renames to -spec.org, prepends status heading with :ID: and history" {
+ make_project
+ run "$SCRIPT" --apply "${CONFIRM_ALL[@]}"
+ [ "$status" -eq 0 ]
+ [ -f docs/specs/widget-spec.org ]
+ [ ! -f docs/design/widget.org ]
+ grep -q '^\* DRAFT Widget Feature' docs/specs/widget-spec.org
+ grep -q ':ID:' docs/specs/widget-spec.org
+ grep -q 'retrofitted by spec-sort' docs/specs/widget-spec.org
+}
+
+@test "spec-sort --apply: keyword header rewritten to the two-sequence form" {
+ make_project
+ run "$SCRIPT" --apply "${CONFIRM_ALL[@]}"
+ [ "$status" -eq 0 ]
+ grep -q '^#+TODO: TODO | DONE$' docs/specs/widget-spec.org
+ grep -q '^#+TODO: DRAFT READY DOING | IMPLEMENTED SUPERSEDED CANCELLED$' docs/specs/widget-spec.org
+ ! grep -q 'DRAFT REVIEW | SHIPPED' docs/specs/widget-spec.org
+}
+
+@test "spec-sort --apply: Metadata Status field mirrors the confirmed keyword in lowercase" {
+ make_project
+ run "$SCRIPT" --apply --confirm docs/design/widget.org=READY --skip docs/rooty-spec.org
+ [ "$status" -eq 0 ]
+ grep -q '^\* READY Widget Feature' docs/specs/widget-spec.org
+ grep -Eq '^\| Status[[:space:]]*\|[[:space:]]*ready' docs/specs/widget-spec.org
+}
+
+# ---- Relink -----------------------------------------------------------
+
+@test "spec-sort --apply: rewrites the todo.org link, preserving the description" {
+ make_project
+ run "$SCRIPT" --apply "${CONFIRM_ALL[@]}"
+ [ "$status" -eq 0 ]
+ grep -q '\[\[file:docs/specs/widget-spec.org\]\[widget spec\]\]' todo.org
+ ! grep -q 'docs/design/widget.org' todo.org
+}
+
+@test "spec-sort --apply: preserves a ::anchor suffix through the rewrite" {
+ make_project
+ run "$SCRIPT" --apply "${CONFIRM_ALL[@]}"
+ [ "$status" -eq 0 ]
+ grep -q '\[\[file:docs/specs/widget-spec.org::\*Summary\]\[the summary\]\]' todo.org
+}
+
+@test "spec-sort --apply: recomputes a sibling note's relative link to the moved spec" {
+ make_project
+ run "$SCRIPT" --apply "${CONFIRM_ALL[@]}"
+ [ "$status" -eq 0 ]
+ grep -q '\[\[file:../specs/widget-spec.org\]\[the widget spec\]\]' docs/design/scratch-note.org
+}
+
+@test "spec-sort --apply: recomputes the moved spec's own outbound link to an unmoved note" {
+ make_project
+ run "$SCRIPT" --apply "${CONFIRM_ALL[@]}"
+ [ "$status" -eq 0 ]
+ grep -q '\[\[file:../design/scratch-note.org\]\[the note\]\]' docs/specs/widget-spec.org
+}
+
+@test "spec-sort: session archives are reported, never rewritten" {
+ make_project
+ run "$SCRIPT"
+ [ "$status" -eq 0 ]
+ [[ "$output" == *"REPORT .ai/sessions/2026-06-01-old.org"* ]]
+ run "$SCRIPT" --apply "${CONFIRM_ALL[@]}"
+ [ "$status" -eq 0 ]
+ grep -q 'docs/design/widget.org' .ai/sessions/2026-06-01-old.org
+}
+
+@test "spec-sort: a synced template path report names the canonical rulesets file" {
+ make_project
+ mkdir -p .ai/workflows
+ echo 'See [[file:../../docs/design/widget.org][widget]]' > .ai/workflows/startup.org
+ git add -A && git commit -qm wf
+ run "$SCRIPT"
+ [ "$status" -eq 0 ]
+ [[ "$output" == *"REPORT .ai/workflows/startup.org"* ]]
+ [[ "$output" == *"claude-templates/.ai/workflows/startup.org"* ]]
+}
+
+# ---- Bare-path mentions -----------------------------------------------
+
+@test "spec-sort --apply: a bare-path mention in a rewritten root blocks until acknowledged" {
+ make_project
+ echo "raw mention: docs/design/widget.org needs review" >> todo.org
+ git add -A && git commit -qm bare
+ run "$SCRIPT" --apply "${CONFIRM_ALL[@]}"
+ [ "$status" -eq 1 ]
+ [[ "$output" == *"BARE"* ]]
+ [ -f docs/design/widget.org ] # nothing moved
+ run "$SCRIPT" --apply --acknowledge-bare "${CONFIRM_ALL[@]}"
+ [ "$status" -eq 0 ]
+ grep -q 'raw mention: docs/design/widget.org' todo.org # reported, never rewritten
+}
+
+@test "spec-sort --apply: a moving doc's bare mention of its own old path is acknowledgeable, not post-apply residue" {
+ make_project
+ echo "History: docs/design/widget.org was drafted in May." >> docs/design/widget.org
+ git add -A && git commit -qm selfmention
+ run "$SCRIPT" --apply "${CONFIRM_ALL[@]}"
+ [ "$status" -eq 1 ]
+ [[ "$output" == *"BARE"* ]]
+ run "$SCRIPT" --apply --acknowledge-bare "${CONFIRM_ALL[@]}"
+ [ "$status" -eq 0 ] # the acknowledged mention rides along to docs/specs/; not residue
+ grep -q ':LAST_SPEC_SORT:' .ai/notes.org
+}
+
+# ---- Plan validation ---------------------------------------------------
+
+@test "spec-sort --apply: a destination collision blocks validation, nothing moved" {
+ make_project
+ mkdir -p docs/specs
+ echo "occupied" > docs/specs/widget-spec.org
+ git add -A && git commit -qm occupy
+ run "$SCRIPT" --apply "${CONFIRM_ALL[@]}"
+ [ "$status" -eq 1 ]
+ [[ "$output" == *"destination exists"* ]]
+ [ -f docs/design/widget.org ]
+ [ "$(cat docs/specs/widget-spec.org)" = "occupied" ]
+}
+
+@test "spec-sort --apply: writes the plan file before executing" {
+ make_project
+ run "$SCRIPT" --apply --plan-file "$TEST_DIR/plan.json" "${CONFIRM_ALL[@]}"
+ [ "$status" -eq 0 ]
+ [ -f "$TEST_DIR/plan.json" ]
+ grep -q 'widget-spec.org' "$TEST_DIR/plan.json"
+}
+
+# ---- Mid-apply failure recovery ----------------------------------------
+
+@test "spec-sort --apply: forced mid-apply failure yields named recovery, not a half-migrated shrug" {
+ make_project
+ run env SPEC_SORT_INJECT_FAIL_AFTER=1 "$SCRIPT" --apply "${CONFIRM_ALL[@]}"
+ [ "$status" -eq 1 ]
+ [[ "$output" == *"RECOVERY"* ]]
+ [[ "$output" == *"git restore"* ]]
+ [[ "$output" == *"applied"* ]]
+ [[ "$output" == *"not applied"* ]]
+ ! grep -q ':LAST_SPEC_SORT:' .ai/notes.org # no stamp on a failed apply
+}
+
+# ---- Idempotence + marker ----------------------------------------------
+
+@test "spec-sort --apply: stamps :LAST_SPEC_SORT: in the Workflow State section" {
+ make_project
+ run "$SCRIPT" --apply "${CONFIRM_ALL[@]}"
+ [ "$status" -eq 0 ]
+ grep -q ':LAST_SPEC_SORT: ' .ai/notes.org
+ # lands inside the Workflow State section, alongside the existing marker
+ awk '/^\* Workflow State/{ws=1} ws && /:LAST_SPEC_SORT:/{found=1} END{exit !found}' .ai/notes.org
+}
+
+@test "spec-sort --apply: creates the Workflow State section when notes.org lacks it" {
+ make_project
+ printf '* Active Reminders\n' > .ai/notes.org
+ git add -A && git commit -qm notes
+ run "$SCRIPT" --apply "${CONFIRM_ALL[@]}"
+ [ "$status" -eq 0 ]
+ grep -q '^\* Workflow State' .ai/notes.org
+ grep -q ':LAST_SPEC_SORT: ' .ai/notes.org
+}
+
+@test "spec-sort --apply: zero candidates still stamps the marker (clears the nudge)" {
+ make_project
+ rm docs/design/widget.org docs/rooty-spec.org docs/lonely-spec.org
+ git add -A && git commit -qm notes-only
+ run "$SCRIPT" --apply
+ [ "$status" -eq 0 ]
+ grep -q ':LAST_SPEC_SORT:' .ai/notes.org
+}
+
+@test "spec-sort: a second run after a successful apply finds nothing to do" {
+ make_project
+ run "$SCRIPT" --apply "${CONFIRM_ALL[@]}"
+ [ "$status" -eq 0 ]
+ git add -A && git commit -qm sorted
+ run "$SCRIPT"
+ [ "$status" -eq 0 ]
+ [[ "$output" != *"CANDIDATE"* ]]
+ run "$SCRIPT" --apply
+ [ "$status" -eq 0 ]
+ run git status --porcelain
+ # only the re-stamped marker (same date) may differ — tree stays clean
+ [ -z "$(git status --porcelain -- docs todo.org)" ]
+}
diff --git a/.ai/scripts/tests/test-lint-org.el b/.ai/scripts/tests/test-lint-org.el
index 3b8a9bb..d14879f 100644
--- a/.ai/scripts/tests/test-lint-org.el
+++ b/.ai/scripts/tests/test-lint-org.el
@@ -685,6 +685,37 @@ missing-rules violation."
(judgments (lo-test--judgments (plist-get out :issues))))
(should-not (member 'level-2-dated-header (lo-test--checkers judgments)))))
+;;; subtask-done-not-dated check (the inverse: level-3+ done keyword)
+
+(ert-deftest lo-subtask-done-not-dated-flags-level3 ()
+ "A level-3 DONE sub-task still carrying the keyword is flagged for conversion."
+ (let* ((out (lo-test--run
+ "* Open Work\n\n** TODO [#B] Parent\n*** DONE [#C] Sub-task done\nCLOSED: [2026-06-20 Sat 10:00]\nBody.\n"))
+ (judgments (lo-test--judgments (plist-get out :issues))))
+ (should (= 0 (plist-get out :fixes))) ; judgment-only, never auto-fixed
+ (should (member 'subtask-done-not-dated (lo-test--checkers judgments)))))
+
+(ert-deftest lo-subtask-done-not-dated-flags-level4-cancelled ()
+ "A level-4 CANCELLED sub-task is flagged too."
+ (let* ((out (lo-test--run
+ "* Open Work\n\n** PROJECT [#B] Parent\n*** TODO Mid\n**** CANCELLED Deep abandoned\nCLOSED: [2026-06-20 Sat]\n"))
+ (judgments (lo-test--judgments (plist-get out :issues))))
+ (should (member 'subtask-done-not-dated (lo-test--checkers judgments)))))
+
+(ert-deftest lo-subtask-done-not-dated-ignores-level2 ()
+ "A level-2 DONE task is a top-level task, not a sub-task — this checker skips it."
+ (let* ((out (lo-test--run
+ "* Open Work\n\n** DONE [#B] Top-level\nCLOSED: [2026-06-20 Sat]\nBody.\n"))
+ (judgments (lo-test--judgments (plist-get out :issues))))
+ (should-not (member 'subtask-done-not-dated (lo-test--checkers judgments)))))
+
+(ert-deftest lo-subtask-done-not-dated-ignores-dated-and-lowercase ()
+ "An already-dated level-3 entry, and the word done in a title, are not flagged."
+ (let* ((out (lo-test--run
+ "* Open Work\n\n** TODO [#B] Parent\n*** 2026-06-20 Sat @ 10:00:00 -0400 landed\n*** TODO wrap the done cleanup\n"))
+ (judgments (lo-test--judgments (plist-get out :issues))))
+ (should-not (member 'subtask-done-not-dated (lo-test--checkers judgments)))))
+
;;; ---------------------------------------------------------------------------
;;; structural heading checks (org-lint gaps)
diff --git a/.ai/scripts/tests/test-todo-cleanup.el b/.ai/scripts/tests/test-todo-cleanup.el
index e569d9a..ffbf2fb 100644
--- a/.ai/scripts/tests/test-todo-cleanup.el
+++ b/.ai/scripts/tests/test-todo-cleanup.el
@@ -768,5 +768,176 @@ in ISSUES, in document order."
(should (= 2 (plist-get once :bumped)))
(should (= 2 (plist-get twice :bumped)))))
+;;; ---------------------------------------------------------------------------
+;;; --convert-subtasks harness + tests
+
+(defun tc-test--reset-convert (&optional check)
+ (setq tc-fixes 0 tc-archived 0 tc-bumped 0 tc-converted 0 tc-archived-to-file 0
+ tc-issues nil
+ tc-check-only (and check t)
+ tc-archive-done nil tc-sync-child-priority nil tc-convert-subtasks t
+ tc-current-file nil
+ tc-archive-retain-days nil tc-archive-reference-date nil tc-archive-file nil))
+
+(defun tc-test--convert (content &optional runs check)
+ "Write CONTENT to a temp .org file, run `--convert-subtasks' RUNS times (default 1).
+Return a plist: :result final file contents, :converted count from the last run,
+:issues from the last run. CHECK non-nil ⇒ --check (preview, no writes)."
+ (let ((file (make-temp-file "tc-test-" nil ".org"))
+ last-converted last-issues)
+ (unwind-protect
+ (progn
+ (with-temp-file file (insert content))
+ (dotimes (_ (or runs 1))
+ (tc-test--reset-convert check)
+ (tc-process-file file)
+ (setq last-converted tc-converted last-issues tc-issues)
+ (tc-test--drop-buffer file))
+ (list :result (with-temp-buffer (insert-file-contents file)
+ (buffer-string))
+ :converted last-converted
+ :issues last-issues))
+ (tc-test--drop-buffer file)
+ (delete-file file))))
+
+;; The UTC offset in a converted header is the test machine's local offset for
+;; that date, so assertions match it as `[-+]NNNN' rather than a fixed value —
+;; the mode's job is to emit a well-formed offset, not to run in one timezone.
+
+(defconst tc-test--convert-timed
+ "* Project Open Work
+** TODO [#B] Parent task
+*** DONE [#C] F12 opens the terminal :feature:quick:
+CLOSED: [2026-06-27 Sat 12:50]
+Verified live: docks, toggles, colors clean.
+")
+
+(ert-deftest tc-convert-timed-subtask-normal ()
+ "Normal: a timed CLOSED close becomes a dated header, keyword/priority/tags/CLOSED gone."
+ (let* ((out (tc-test--convert tc-test--convert-timed))
+ (res (plist-get out :result)))
+ (should (= 1 (plist-get out :converted)))
+ (should (string-match-p
+ "^\\*\\*\\* 2026-06-27 Sat @ 12:50:00 [-+][0-9]\\{4\\} F12 opens the terminal$"
+ res))
+ (should-not (string-match-p "CLOSED:" res))
+ (should-not (string-match-p "DONE" res))
+ (should (string-match-p "Verified live: docks, toggles, colors clean\\." res))
+ (should (string-match-p "^\\*\\* TODO \\[#B\\] Parent task$" res))))
+
+(defconst tc-test--convert-dateonly
+ "* Project Open Work
+** PROJECT [#B] Parent
+**** DONE [#B] Write full spec :refactor:
+CLOSED: [2026-05-04 Mon]
+Body.
+")
+
+(ert-deftest tc-convert-dateonly-boundary-midnight ()
+ "Boundary: a date-only CLOSED (no time) yields 00:00:00, at level 4."
+ (let ((res (plist-get (tc-test--convert tc-test--convert-dateonly) :result)))
+ (should (string-match-p
+ "^\\*\\*\\*\\* 2026-05-04 Mon @ 00:00:00 [-+][0-9]\\{4\\} Write full spec$"
+ res))
+ (should-not (string-match-p "CLOSED:" res))))
+
+(defconst tc-test--convert-level2
+ "* Project Open Work
+** DONE [#B] Top-level task
+CLOSED: [2026-06-01 Mon 09:00]
+Body.
+")
+
+(ert-deftest tc-convert-leaves-level-2-alone-boundary ()
+ "Boundary: a level-2 DONE task is a top-level task, not a sub-task — untouched."
+ (let ((out (tc-test--convert tc-test--convert-level2)))
+ (should (= 0 (plist-get out :converted)))
+ (should (equal tc-test--convert-level2 (plist-get out :result)))))
+
+(ert-deftest tc-convert-idempotent-boundary ()
+ "Boundary: a second run over an already-dated entry converts nothing new."
+ (let ((once (tc-test--convert tc-test--convert-timed 1))
+ (twice (tc-test--convert tc-test--convert-timed 2)))
+ (should (equal (plist-get once :result) (plist-get twice :result)))
+ (should (= 0 (plist-get twice :converted)))))
+
+(defconst tc-test--convert-nested
+ "* Project Open Work
+** TODO [#B] Parent
+*** DONE Outer sub :feature:
+CLOSED: [2026-06-10 Wed 08:15]
+**** DONE Inner sub
+CLOSED: [2026-06-09 Tue 07:00]
+Inner body.
+")
+
+(ert-deftest tc-convert-nested-done-subtasks-boundary ()
+ "Boundary: a done sub-task nested under a done sub-task — both convert."
+ (let* ((out (tc-test--convert tc-test--convert-nested))
+ (res (plist-get out :result)))
+ (should (= 2 (plist-get out :converted)))
+ (should (string-match-p
+ "^\\*\\*\\* 2026-06-10 Wed @ 08:15:00 [-+][0-9]\\{4\\} Outer sub$" res))
+ (should (string-match-p
+ "^\\*\\*\\*\\* 2026-06-09 Tue @ 07:00:00 [-+][0-9]\\{4\\} Inner sub$" res))
+ (should-not (string-match-p "CLOSED:" res))))
+
+(defconst tc-test--convert-cancelled
+ "* Project Open Work
+** TODO [#B] Parent
+*** CANCELLED [#C] Abandoned idea :feature:
+CLOSED: [2026-06-15 Mon 10:00]
+")
+
+(ert-deftest tc-convert-cancelled-subtask-boundary ()
+ "Boundary: a CANCELLED sub-task converts too (terminal state)."
+ (let ((res (plist-get (tc-test--convert tc-test--convert-cancelled) :result)))
+ (should (string-match-p
+ "^\\*\\*\\* 2026-06-15 Mon @ 10:00:00 [-+][0-9]\\{4\\} Abandoned idea$" res))
+ (should-not (string-match-p "CANCELLED" res))))
+
+(defconst tc-test--convert-noclosed
+ "* Project Open Work
+** TODO [#B] Parent
+*** DONE Orphan with no closed date
+Body only.
+")
+
+(ert-deftest tc-convert-skips-subtask-without-closed-error ()
+ "Error: a done sub-task with no parseable CLOSED is flagged and left unchanged."
+ (let ((out (tc-test--convert tc-test--convert-noclosed)))
+ (should (= 0 (plist-get out :converted)))
+ (should (equal tc-test--convert-noclosed (plist-get out :result)))
+ (should (cl-some (lambda (i) (eq (plist-get i :kind) 'convert-skip))
+ (plist-get out :issues)))))
+
+(ert-deftest tc-convert-check-mode-previews-without-writing ()
+ "Check mode reports the conversion but writes nothing."
+ (let ((out (tc-test--convert tc-test--convert-timed 1 t)))
+ (should (= 1 (plist-get out :converted)))
+ (should (equal tc-test--convert-timed (plist-get out :result)))
+ (should (cl-some (lambda (i) (eq (plist-get i :kind) 'convert-would))
+ (plist-get out :issues)))))
+
+(defconst tc-test--convert-closed-with-deadline
+ "* Project Open Work
+** TODO [#B] Parent task
+*** DONE [#C] Ship the panel :feature:
+CLOSED: [2026-06-27 Sat 12:50] DEADLINE: <2026-06-30 Tue>
+Body line.
+")
+
+(ert-deftest tc-convert-preserves-deadline-on-shared-planning-line-boundary ()
+ "Boundary: removing the CLOSED cookie keeps a DEADLINE sharing its planning line."
+ (let* ((out (tc-test--convert tc-test--convert-closed-with-deadline))
+ (res (plist-get out :result)))
+ (should (= 1 (plist-get out :converted)))
+ (should (string-match-p
+ "^\\*\\*\\* 2026-06-27 Sat @ 12:50:00 [-+][0-9]\\{4\\} Ship the panel$"
+ res))
+ (should-not (string-match-p "CLOSED:" res))
+ (should (string-match-p "^DEADLINE: <2026-06-30 Tue>$" res))
+ (should (string-match-p "^Body line\\.$" res))))
+
(provide 'test-todo-cleanup)
;;; test-todo-cleanup.el ends here
diff --git a/.ai/scripts/todo-cleanup.el b/.ai/scripts/todo-cleanup.el
index 541d106..bd8166d 100644
--- a/.ai/scripts/todo-cleanup.el
+++ b/.ai/scripts/todo-cleanup.el
@@ -5,10 +5,12 @@
;; emacs --batch -q -l todo-cleanup.el --check todo.org # hygiene report only
;; emacs --batch -q -l todo-cleanup.el --archive-done todo.org # archive completed subtrees
;; emacs --batch -q -l todo-cleanup.el --archive-done --check todo.org # preview the archive
+;; emacs --batch -q -l todo-cleanup.el --convert-subtasks todo.org # dated-rewrite done level-3+ sub-tasks
+;; emacs --batch -q -l todo-cleanup.el --convert-subtasks --check todo.org # preview the conversion
;; emacs --batch -q -l todo-cleanup.el --sync-child-priority todo.org # bump children whose priority drifted below the parent's
;; emacs --batch -q -l todo-cleanup.el --check-child-priority todo.org # preview the sync (same as --sync-child-priority --check)
;;
-;; Three independent modes:
+;; Four independent modes:
;;
;; * Default (hygiene). Designed for the wrap-it-up workflow: cheap, idempotent,
;; safe to run every session.
@@ -52,6 +54,20 @@
;; Archiving is consequential, so it's never run by default; it does *not*
;; also run the hygiene passes.
;;
+;; * --convert-subtasks (opt-in). Rewrites every level-3-and-deeper heading whose
+;; TODO state is DONE/CANCELLED/FAILED into a dated event-log entry
+;; (`<stars> YYYY-MM-DD Day @ HH:MM:SS -ZZZZ <text>'), dropping the keyword,
+;; priority cookie, and tags, and removing the now-redundant CLOSED line. The
+;; date and time come from that entry's own CLOSED cookie; a date-only close
+;; yields 00:00:00, and the UTC offset is computed DST-aware for that date.
+;; This enforces the todo-format depth rule that interactive closes
+;; (`org-log-done' → DONE + CLOSED) and `--archive-done' (level-2 only) leave
+;; unapplied. The heading text is preserved verbatim — a batch tool can't
+;; past-tense an imperative title reliably. Idempotent (an already-dated
+;; heading has no done keyword); a done sub-task with no parseable CLOSED date
+;; is flagged and left alone, never stamped with a fabricated date. Like
+;; --archive-done it does not also run the hygiene passes.
+;;
;; * --sync-child-priority (opt-in). Walks every heading with a priority cookie
;; ([#A]-[#D]) and, for each of its direct child headings whose own priority
;; is lower (later in the alphabet — D is lower than A), bumps the child's
@@ -73,11 +89,16 @@
(require 'calendar)
(setq org-todo-keywords
- '((sequence "TODO" "DOING" "WAITING" "NEXT" "|" "DONE" "CANCELLED")))
+ '((sequence "TODO" "DOING" "WAITING" "NEXT" "|" "DONE" "CANCELLED" "FAILED")))
(defconst tc-done-states '("DONE" "CANCELLED")
"TODO keywords that mark an entry as completed for `--archive-done'.")
+(defconst tc--convert-done-states '("DONE" "CANCELLED" "FAILED")
+ "TODO keywords whose level-3-and-deeper entries `--convert-subtasks' rewrites
+to dated event-log entries. Broader than `tc-done-states' because a FAILED
+sub-task is terminal too and belongs in the parent's dated history.")
+
(defconst tc--priority-cookie-regexp "\\[#\\([A-Z]\\)\\]"
"Regexp matching an org priority cookie. Match group 1 is the letter.")
@@ -89,10 +110,12 @@ every heading below it.")
(defvar tc-fixes 0)
(defvar tc-archived 0)
(defvar tc-bumped 0)
+(defvar tc-converted 0)
(defvar tc-issues nil)
(defvar tc-check-only nil)
(defvar tc-archive-done nil)
(defvar tc-sync-child-priority nil)
+(defvar tc-convert-subtasks nil)
(defvar tc-current-file nil)
(defvar tc-current-dir nil)
(defvar tc-archived-to-file 0)
@@ -578,6 +601,138 @@ before their descendants — a [#A] → [#B] → [#D] chain collapses in one pas
(org-map-entries #'tc-sync-child-priority-at-heading nil 'file))
;;; ---------------------------------------------------------------------------
+;;; --convert-subtasks mode
+;;
+;; A sub-task (a heading at level 3 or deeper, i.e. under a parent task) that is
+;; marked DONE/CANCELLED/FAILED should become a dated event-log entry per the
+;; todo-format depth rule: drop the keyword, priority cookie, and tags, and
+;; rewrite the heading to `<stars> YYYY-MM-DD Day @ HH:MM:SS -ZZZZ <text>' so the
+;; parent's subtree grows a chronological history instead of a long tail of
+;; nested DONE lines. Nothing enforced this before: `org-log-done' just flips an
+;; interactive close to DONE + CLOSED, and `--archive-done' only touches level 2.
+;; So level-3+ closes piled up as DONE keywords. This mode converts them
+;; mechanically, pulling the timestamp from each entry's own CLOSED cookie. The
+;; heading text is kept verbatim (a batch tool can't reliably past-tense an
+;; imperative title, and guessing prose in the task file is worse than leaving it
+;; as written). Idempotent: an already-dated heading has no done keyword, so it
+;; is skipped. A done sub-task with no parseable CLOSED cookie can't be dated, so
+;; it is flagged and left alone rather than stamped with a fabricated date.
+
+(defun tc--closed-parts-in-entry ()
+ "Return a plist (:year :month :day :dow :hour :minute) from the CLOSED cookie
+of the entry at point, or nil when the entry has no parseable CLOSED line.
+:hour and :minute are nil when the cookie carries only a date. The CLOSED line
+sits in canonical position directly under the heading, so the first match within
+the entry is the task's own close."
+ (save-excursion
+ (org-back-to-heading t)
+ (let ((end (save-excursion
+ (or (outline-next-heading) (goto-char (point-max)))
+ (point))))
+ (when (re-search-forward
+ (concat "CLOSED:[ \t]*\\[\\([0-9]\\{4\\}\\)-\\([0-9]\\{2\\}\\)-\\([0-9]\\{2\\}\\)"
+ "[ \t]+\\([A-Za-z]+\\)"
+ "\\(?:[ \t]+\\([0-9]\\{2\\}\\):\\([0-9]\\{2\\}\\)\\)?\\]")
+ end t)
+ (list :year (match-string 1) :month (match-string 2) :day (match-string 3)
+ :dow (match-string 4)
+ :hour (match-string 5) :minute (match-string 6))))))
+
+(defun tc--tz-offset-string (year month day hour minute)
+ "Return the local UTC offset (e.g. \"-0500\") for the given wall-clock instant.
+DST-aware: `encode-time' with an unknown-DST field lets the system pick the
+correct offset for that date, so a summer close reads -0400 and a winter one
+-0500 without hardcoding either."
+ (format-time-string
+ "%z" (encode-time (list 0 minute hour day month year nil -1 nil))))
+
+(defun tc--dated-header-line (level parts title)
+ "Build the dated event-log heading string from LEVEL, CLOSED PARTS, and TITLE.
+Missing time in PARTS defaults to 00:00:00 (the close logged only a date)."
+ (let* ((year (plist-get parts :year))
+ (month (plist-get parts :month))
+ (day (plist-get parts :day))
+ (dow (plist-get parts :dow))
+ (hh (or (plist-get parts :hour) "00"))
+ (mm (or (plist-get parts :minute) "00"))
+ (tz (tc--tz-offset-string (string-to-number year)
+ (string-to-number month)
+ (string-to-number day)
+ (string-to-number hh)
+ (string-to-number mm))))
+ (format "%s %s-%s-%s %s @ %s:%s:00 %s %s"
+ (make-string level ?*) year month day dow hh mm tz title)))
+
+(defun tc--convert-collect-targets ()
+ "Markers at every heading at level >= 3 whose TODO state is a done state.
+Collected up front so the rewrite loop can edit the buffer without disturbing an
+in-progress `org-map-entries' walk; markers track their headings across edits."
+ (let (targets)
+ (org-map-entries
+ (lambda ()
+ (when (and (>= (org-current-level) 3)
+ (member (org-get-todo-state) tc--convert-done-states))
+ (push (copy-marker (point)) targets)))
+ nil 'file)
+ (nreverse targets)))
+
+(defun tc--convert-one-subtask (marker)
+ "Convert the done sub-task heading at MARKER to a dated event-log entry.
+Under `tc-check-only' the conversion is reported but not performed."
+ (goto-char marker)
+ (org-back-to-heading t)
+ (let* ((level (org-current-level))
+ (title (org-get-heading t t t t))
+ (line (line-number-at-pos))
+ (parts (tc--closed-parts-in-entry)))
+ (cond
+ ((null parts)
+ (push (list :kind 'convert-skip :file tc-current-file
+ :line line :heading title
+ :detail "no CLOSED date to derive the timestamp")
+ tc-issues))
+ (t
+ (let ((new (tc--dated-header-line level parts title)))
+ (cl-incf tc-converted)
+ (if tc-check-only
+ (push (list :kind 'convert-would :file tc-current-file
+ :line line :heading title :new new)
+ tc-issues)
+ ;; Replace the heading line, then drop the now-redundant CLOSED
+ ;; cookie from the entry (its date now lives in the header). Only
+ ;; the cookie goes: a planning line can also carry DEADLINE: or
+ ;; SCHEDULED: beside it, and those survive on their line. A line
+ ;; left blank by the removal is deleted whole.
+ (delete-region (line-beginning-position) (line-end-position))
+ (insert new)
+ (let ((end (save-excursion
+ (or (outline-next-heading) (goto-char (point-max)))
+ (point))))
+ (save-excursion
+ (when (re-search-forward "CLOSED:[ \t]*\\[[^]]*\\][ \t]*" end t)
+ (replace-match "")
+ (let ((bol (line-beginning-position))
+ (eol (line-end-position)))
+ (if (string-match-p "\\`[ \t]*\\'"
+ (buffer-substring bol eol))
+ (delete-region bol (min (1+ eol) (point-max)))
+ (goto-char bol)
+ (when (looking-at "[ \t]+")
+ (replace-match "")))))))
+ (push (list :kind 'convert-done :file tc-current-file
+ :line line :heading title :new new)
+ tc-issues)))))))
+
+(defun tc-convert-subtasks-in-file ()
+ "Rewrite every level-3-and-deeper DONE/CANCELLED/FAILED heading to a dated
+event-log entry, pulling the timestamp from its CLOSED cookie. Honors
+`tc-check-only'."
+ (let ((targets (tc--convert-collect-targets)))
+ (dolist (m targets)
+ (tc--convert-one-subtask m)
+ (set-marker m nil))))
+
+;;; ---------------------------------------------------------------------------
;;; Driver + reporting
(defun tc-process-file (file)
@@ -590,6 +745,8 @@ before their descendants — a [#A] → [#B] → [#D] chain collapses in one pas
(tc-archive-done-in-file))
(tc-sync-child-priority
(tc-sync-child-priority-in-file))
+ (tc-convert-subtasks
+ (tc-convert-subtasks-in-file))
(t
;; Pass 1: auto-fix bogus state logs (or report under --check).
(org-map-entries #'tc-fix-bogus-state-log-in-entry nil 'file)
@@ -684,9 +841,34 @@ before their descendants — a [#A] → [#B] → [#D] chain collapses in one pas
(plist-get i :child-heading)
(plist-get i :parent-heading)))))))
+(defun tc--emit-convert-report ()
+ ;; Silent on a real-mode no-op (nothing to convert and nothing skipped), for
+ ;; the same reason as the archive report: the wrap runs cleanup passes more
+ ;; than once, and a vocal \"0 converted\" reads as noise. Check mode always
+ ;; reports (the preview is what the caller asked for), and a skip always
+ ;; reports (a done sub-task with no CLOSED date is a real condition to see).
+ (let ((has-skip (cl-some (lambda (i) (eq (plist-get i :kind) 'convert-skip))
+ tc-issues)))
+ (when (or tc-check-only (> tc-converted 0) has-skip)
+ (princ (format "todo-cleanup --convert-subtasks: %d sub-task(s) %s%s\n"
+ tc-converted
+ (if tc-check-only "would convert" "converted")
+ (if tc-check-only " — CHECK MODE (no writes)" "")))
+ (dolist (i (reverse tc-issues))
+ (pcase (plist-get i :kind)
+ ((or 'convert-done 'convert-would)
+ (princ (format " %s:%d: %s\n → %s\n"
+ (plist-get i :file) (plist-get i :line)
+ (plist-get i :heading) (plist-get i :new))))
+ ('convert-skip
+ (princ (format " skipped %s:%d: %s — %s\n"
+ (plist-get i :file) (plist-get i :line)
+ (plist-get i :heading) (plist-get i :detail)))))))))
+
(defun tc-emit-report ()
(cond (tc-archive-done (tc--emit-archive-report))
(tc-sync-child-priority (tc--emit-sync-report))
+ (tc-convert-subtasks (tc--emit-convert-report))
(t (tc--emit-hygiene-report))))
(defun tc-main ()
@@ -701,6 +883,9 @@ before their descendants — a [#A] → [#B] → [#D] chain collapses in one pas
(when (member "--sync-child-priority" command-line-args-left)
(setq tc-sync-child-priority t)
(setq command-line-args-left (delete "--sync-child-priority" command-line-args-left)))
+ (when (member "--convert-subtasks" command-line-args-left)
+ (setq tc-convert-subtasks t)
+ (setq command-line-args-left (delete "--convert-subtasks" command-line-args-left)))
;; --check-child-priority is the report-only alias for
;; `--sync-child-priority --check'.
(when (member "--check-child-priority" command-line-args-left)
@@ -708,7 +893,7 @@ before their descendants — a [#A] → [#B] → [#D] chain collapses in one pas
(setq command-line-args-left (delete "--check-child-priority" command-line-args-left)))
(if (null command-line-args-left)
(progn
- (princ "Usage: emacs --batch -q -l todo-cleanup.el [--check] [--archive-done | --sync-child-priority | --check-child-priority] FILE...\n")
+ (princ "Usage: emacs --batch -q -l todo-cleanup.el [--check] [--archive-done | --convert-subtasks | --sync-child-priority | --check-child-priority] FILE...\n")
(kill-emacs 1))
(let ((files command-line-args-left))
(setq command-line-args-left nil)
@@ -727,6 +912,7 @@ ert-run-tests-batch-and-exit'."
(cl-every (lambda (a)
(cond ((member a '("--check"
"--archive-done"
+ "--convert-subtasks"
"--sync-child-priority"
"--check-child-priority"))
t)