diff options
Diffstat (limited to 'claude-templates/.ai/scripts')
| -rwxr-xr-x | claude-templates/.ai/scripts/inbox-send.py | 21 | ||||
| -rw-r--r-- | claude-templates/.ai/scripts/lint-org.el | 28 | ||||
| -rwxr-xr-x | claude-templates/.ai/scripts/route-batch | 175 | ||||
| -rwxr-xr-x | claude-templates/.ai/scripts/self-inject.sh | 68 | ||||
| -rwxr-xr-x | claude-templates/.ai/scripts/spec-sort | 715 | ||||
| -rw-r--r-- | claude-templates/.ai/scripts/tests/route-batch.bats | 202 | ||||
| -rw-r--r-- | claude-templates/.ai/scripts/tests/self-inject.bats | 78 | ||||
| -rw-r--r-- | claude-templates/.ai/scripts/tests/spec-sort.bats | 453 | ||||
| -rw-r--r-- | claude-templates/.ai/scripts/tests/test-lint-org.el | 31 | ||||
| -rw-r--r-- | claude-templates/.ai/scripts/tests/test-todo-cleanup.el | 171 | ||||
| -rw-r--r-- | claude-templates/.ai/scripts/tests/test_inbox_send.py | 75 | ||||
| -rw-r--r-- | claude-templates/.ai/scripts/todo-cleanup.el | 192 |
12 files changed, 2204 insertions, 5 deletions
diff --git a/claude-templates/.ai/scripts/inbox-send.py b/claude-templates/.ai/scripts/inbox-send.py index 1362a1f..1ebb636 100755 --- a/claude-templates/.ai/scripts/inbox-send.py +++ b/claude-templates/.ai/scripts/inbox-send.py @@ -177,6 +177,23 @@ def build_text_org(message: str, source_name: str, timestamp: str) -> str: ) +def uniquify(dest: Path) -> Path: + """Return dest, or dest with a -2/-3/... stem suffix when it already exists. + + Two sends in the same minute whose text starts with the same phrase + derive identical filenames, and the second silently overwrote the + first (a message was lost this way, 2026-07-02). Never overwrite. + """ + if not dest.exists(): + return dest + n = 2 + while True: + candidate = dest.with_name(f"{dest.stem}-{n}{dest.suffix}") + if not candidate.exists(): + return candidate + n += 1 + + def send_text( target_inbox: Path, message: str, @@ -191,7 +208,7 @@ def send_text( if not slug: raise ValueError(f"could not derive a slug from text: {message!r}") filename = f"{now.strftime(TS_FILENAME_FMT)}-from-{source_name}-{slug}.org" - dest = target_inbox / filename + dest = uniquify(target_inbox / filename) dest.write_text(build_text_org(message, source_name, now.strftime(TS_DOC_FMT))) return dest @@ -211,7 +228,7 @@ def send_file( raise ValueError(f"could not derive a slug from file: {src_path}") ext = src_path.suffix filename = f"{now.strftime(TS_FILENAME_FMT)}-from-{source_name}-{slug}{ext}" - dest = target_inbox / filename + dest = uniquify(target_inbox / filename) shutil.copy2(src_path, dest) return dest diff --git a/claude-templates/.ai/scripts/lint-org.el b/claude-templates/.ai/scripts/lint-org.el index 5447cb3..90b1b1d 100644 --- a/claude-templates/.ai/scripts/lint-org.el +++ b/claude-templates/.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/claude-templates/.ai/scripts/route-batch b/claude-templates/.ai/scripts/route-batch new file mode 100755 index 0000000..8f27d19 --- /dev/null +++ b/claude-templates/.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/claude-templates/.ai/scripts/self-inject.sh b/claude-templates/.ai/scripts/self-inject.sh new file mode 100755 index 0000000..e7340c1 --- /dev/null +++ b/claude-templates/.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/claude-templates/.ai/scripts/spec-sort b/claude-templates/.ai/scripts/spec-sort new file mode 100755 index 0000000..ebfef82 --- /dev/null +++ b/claude-templates/.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/claude-templates/.ai/scripts/tests/route-batch.bats b/claude-templates/.ai/scripts/tests/route-batch.bats new file mode 100644 index 0000000..84ded5f --- /dev/null +++ b/claude-templates/.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/claude-templates/.ai/scripts/tests/self-inject.bats b/claude-templates/.ai/scripts/tests/self-inject.bats new file mode 100644 index 0000000..482f61d --- /dev/null +++ b/claude-templates/.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/claude-templates/.ai/scripts/tests/spec-sort.bats b/claude-templates/.ai/scripts/tests/spec-sort.bats new file mode 100644 index 0000000..583e458 --- /dev/null +++ b/claude-templates/.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/claude-templates/.ai/scripts/tests/test-lint-org.el b/claude-templates/.ai/scripts/tests/test-lint-org.el index 3b8a9bb..d14879f 100644 --- a/claude-templates/.ai/scripts/tests/test-lint-org.el +++ b/claude-templates/.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/claude-templates/.ai/scripts/tests/test-todo-cleanup.el b/claude-templates/.ai/scripts/tests/test-todo-cleanup.el index e569d9a..ffbf2fb 100644 --- a/claude-templates/.ai/scripts/tests/test-todo-cleanup.el +++ b/claude-templates/.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/claude-templates/.ai/scripts/tests/test_inbox_send.py b/claude-templates/.ai/scripts/tests/test_inbox_send.py index cb60e63..f75d7a1 100644 --- a/claude-templates/.ai/scripts/tests/test_inbox_send.py +++ b/claude-templates/.ai/scripts/tests/test_inbox_send.py @@ -401,3 +401,78 @@ class TestInboxSendErrors: assert result.returncode != 0 files = list((tmp_path / "projects" / "target" / "inbox").iterdir()) assert files == [] + + +# ---------------------------------------------------------------------- +# Filename collisions (two sends deriving the same name must not overwrite) +# ---------------------------------------------------------------------- + +def _load_module(): + import importlib.util + spec = importlib.util.spec_from_file_location("inbox_send", SCRIPT) + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + return mod + + +class TestFilenameCollisions: + """Two sends in the same minute with the same leading phrase derived + identical filenames and the second silently overwrote the first + (a message was lost this way, 2026-07-02).""" + + def test_send_text_same_minute_same_phrase_keeps_both(self, tmp_path): + from datetime import datetime + mod = _load_module() + inbox = tmp_path / "inbox" + inbox.mkdir() + now = datetime(2026, 7, 2, 5, 42, 0) + prefix = "identical leading phrase long enough to fill the whole slug budget entirely" + first = mod.send_text(inbox, prefix + " tail one", "archsetup", None, now) + second = mod.send_text(inbox, prefix + " tail two", "archsetup", None, now) + assert first != second + assert first.exists() and second.exists() + assert first.name != second.name + assert "tail one" in first.read_text() + assert "tail two" in second.read_text() + + def test_send_text_collision_suffix_increments(self, tmp_path): + from datetime import datetime + mod = _load_module() + inbox = tmp_path / "inbox" + inbox.mkdir() + now = datetime(2026, 7, 2, 5, 42, 0) + paths = [mod.send_text(inbox, "same lead phrase differs later A", "src", "fixed-slug", now) + for _ in range(3)] + names = [p.name for p in paths] + assert names[0].endswith("fixed-slug.org") + assert names[1].endswith("fixed-slug-2.org") + assert names[2].endswith("fixed-slug-3.org") + + def test_send_file_collision_preserves_extension(self, tmp_path): + from datetime import datetime + mod = _load_module() + inbox = tmp_path / "inbox" + inbox.mkdir() + src = tmp_path / "note.org" + src.write_text("body one") + now = datetime(2026, 7, 2, 5, 42, 0) + first = mod.send_file(inbox, src, "src", None, now) + src.write_text("body two") + second = mod.send_file(inbox, src, "src", None, now) + assert second.name.endswith("note-2.org") + assert first.read_text() == "body one" + assert second.read_text() == "body two" + + def test_cli_two_rapid_sends_lose_nothing(self, project_root, run_script, tmp_path): + project_root("sender") + target = project_root("receiver") + roots = [tmp_path / "projects"] + prefix = "identical leading phrase long enough to fill the whole slug budget entirely" + run_script(["receiver", "--text", prefix + " message one"], + cwd=tmp_path / "projects" / "sender", roots=roots) + run_script(["receiver", "--text", prefix + " message two"], + cwd=tmp_path / "projects" / "sender", roots=roots) + files = list((target / "inbox").iterdir()) + assert len(files) == 2 + bodies = "".join(f.read_text() for f in files) + assert "message one" in bodies and "message two" in bodies diff --git a/claude-templates/.ai/scripts/todo-cleanup.el b/claude-templates/.ai/scripts/todo-cleanup.el index 541d106..bd8166d 100644 --- a/claude-templates/.ai/scripts/todo-cleanup.el +++ b/claude-templates/.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) |
