aboutsummaryrefslogtreecommitdiff
path: root/claude-templates/.ai
diff options
context:
space:
mode:
Diffstat (limited to 'claude-templates/.ai')
-rw-r--r--claude-templates/.ai/notes.org14
-rw-r--r--claude-templates/.ai/protocols.org76
-rw-r--r--claude-templates/.ai/references/calendar-reference.org2
-rwxr-xr-xclaude-templates/.ai/scripts/agent-lock248
-rwxr-xr-xclaude-templates/.ai/scripts/apkg-to-orgdrill.py251
-rwxr-xr-xclaude-templates/.ai/scripts/cj-remove-block.py81
-rwxr-xr-xclaude-templates/.ai/scripts/flashcard-stats.py12
-rwxr-xr-xclaude-templates/.ai/scripts/flashcard-to-anki.py105
-rwxr-xr-xclaude-templates/.ai/scripts/inbox-send.py82
-rwxr-xr-xclaude-templates/.ai/scripts/inbox-status1
-rw-r--r--claude-templates/.ai/scripts/lint-org.el254
-rwxr-xr-xclaude-templates/.ai/scripts/route-batch175
-rw-r--r--claude-templates/.ai/scripts/route_recommend.py9
-rwxr-xr-xclaude-templates/.ai/scripts/self-inject.sh68
-rwxr-xr-xclaude-templates/.ai/scripts/spec-sort715
-rwxr-xr-xclaude-templates/.ai/scripts/task-review-staleness.sh40
-rw-r--r--claude-templates/.ai/scripts/tests/agent-lock.bats214
-rw-r--r--claude-templates/.ai/scripts/tests/flashcard-sync.bats25
-rw-r--r--claude-templates/.ai/scripts/tests/inbox-status.bats12
-rw-r--r--claude-templates/.ai/scripts/tests/lint-org-cli.bats18
-rw-r--r--claude-templates/.ai/scripts/tests/route-batch.bats202
-rw-r--r--claude-templates/.ai/scripts/tests/self-inject.bats78
-rw-r--r--claude-templates/.ai/scripts/tests/spec-sort.bats453
-rw-r--r--claude-templates/.ai/scripts/tests/task-review-staleness.bats53
-rw-r--r--claude-templates/.ai/scripts/tests/test-lint-org.el335
-rw-r--r--claude-templates/.ai/scripts/tests/test-todo-cleanup.el406
-rw-r--r--claude-templates/.ai/scripts/tests/test-wrap-org-table.el42
-rw-r--r--claude-templates/.ai/scripts/tests/test_apkg_to_orgdrill.py301
-rw-r--r--claude-templates/.ai/scripts/tests/test_cj_remove_block.py167
-rw-r--r--claude-templates/.ai/scripts/tests/test_flashcard_stats.py25
-rw-r--r--claude-templates/.ai/scripts/tests/test_flashcard_to_anki.py61
-rw-r--r--claude-templates/.ai/scripts/tests/test_inbox_send.py189
-rw-r--r--claude-templates/.ai/scripts/tests/test_route_recommend.py28
-rw-r--r--claude-templates/.ai/scripts/tests/test_upcoming_birthdays.py168
-rw-r--r--claude-templates/.ai/scripts/todo-cleanup.el373
-rwxr-xr-xclaude-templates/.ai/scripts/upcoming_birthdays.py182
-rw-r--r--claude-templates/.ai/scripts/wrap-org-table.el58
-rw-r--r--claude-templates/.ai/workflows/INDEX.org15
-rw-r--r--claude-templates/.ai/workflows/add-calendar-event.org2
-rw-r--r--claude-templates/.ai/workflows/broadcast.org2
-rw-r--r--claude-templates/.ai/workflows/clean-todo.org21
-rw-r--r--claude-templates/.ai/workflows/code-quality.org9
-rw-r--r--claude-templates/.ai/workflows/daily-prep.org7
-rw-r--r--claude-templates/.ai/workflows/delete-calendar-event.org2
-rw-r--r--claude-templates/.ai/workflows/edit-calendar-event.org2
-rw-r--r--claude-templates/.ai/workflows/email-assembly.org2
-rw-r--r--claude-templates/.ai/workflows/extract-email.org2
-rw-r--r--claude-templates/.ai/workflows/find-email.org2
-rw-r--r--claude-templates/.ai/workflows/first-session.org2
-rw-r--r--claude-templates/.ai/workflows/flashcard-review.org2
-rw-r--r--claude-templates/.ai/workflows/helper-mode.org2
-rw-r--r--claude-templates/.ai/workflows/inbox.org41
-rw-r--r--claude-templates/.ai/workflows/journal-entry.org2
-rw-r--r--claude-templates/.ai/workflows/meeting-prep.org2
-rw-r--r--claude-templates/.ai/workflows/meeting-prep.pre-wire.org2
-rw-r--r--claude-templates/.ai/workflows/no-approvals.org8
-rw-r--r--claude-templates/.ai/workflows/open-tasks.org9
-rw-r--r--claude-templates/.ai/workflows/page-me.org53
-rw-r--r--claude-templates/.ai/workflows/process-meeting-transcript.org26
-rw-r--r--claude-templates/.ai/workflows/read-calendar-events.org2
-rw-r--r--claude-templates/.ai/workflows/readability-audit.org2
-rw-r--r--claude-templates/.ai/workflows/rename-artifact.org2
-rw-r--r--claude-templates/.ai/workflows/send-email.org2
-rw-r--r--claude-templates/.ai/workflows/sentry.org227
-rw-r--r--claude-templates/.ai/workflows/session-harvest.org2
-rw-r--r--claude-templates/.ai/workflows/spec-create.org18
-rw-r--r--claude-templates/.ai/workflows/spec-response.org6
-rw-r--r--claude-templates/.ai/workflows/spec-review.org9
-rw-r--r--claude-templates/.ai/workflows/startup.org82
-rw-r--r--claude-templates/.ai/workflows/status-check.org2
-rw-r--r--claude-templates/.ai/workflows/summarize-emails.org2
-rw-r--r--claude-templates/.ai/workflows/suspend.org43
-rw-r--r--claude-templates/.ai/workflows/sync-email.org2
-rw-r--r--claude-templates/.ai/workflows/task-audit.org25
-rw-r--r--claude-templates/.ai/workflows/task-review.org12
-rw-r--r--claude-templates/.ai/workflows/triage-intake.cmail.org2
-rw-r--r--claude-templates/.ai/workflows/triage-intake.github-prs.org2
-rw-r--r--claude-templates/.ai/workflows/triage-intake.org174
-rw-r--r--claude-templates/.ai/workflows/triage-intake.personal-calendar.org2
-rw-r--r--claude-templates/.ai/workflows/triage-intake.personal-gmail.org23
-rw-r--r--claude-templates/.ai/workflows/triage-intake.telegram.org2
-rw-r--r--claude-templates/.ai/workflows/work-the-backlog.org265
-rw-r--r--claude-templates/.ai/workflows/wrap-it-up.org145
83 files changed, 6467 insertions, 324 deletions
diff --git a/claude-templates/.ai/notes.org b/claude-templates/.ai/notes.org
index 42ea8df..311a86f 100644
--- a/claude-templates/.ai/notes.org
+++ b/claude-templates/.ai/notes.org
@@ -1,24 +1,24 @@
#+TITLE: Claude Code Notes - [Project Name]
-#+AUTHOR: Craig Jennings & Claude
+#+AUTHOR: Craig Jennings
#+DATE: [Date]
* About This File
This file contains project-specific information for this project.
-**When to read this:**
+- When to read this:
- At the start of EVERY session (after reading protocols.org)
- When needing project context or history
- When checking reminders or pending decisions
-**What's in this file:**
+- What's in this file:
- Project-specific context and goals
- Pending decisions
- Active reminders
-**Session history is NOT in this file.** Each session's record lives in =.ai/sessions/YYYY-MM-DD-HH-MM-description.org= — one file per session. Catch-up reads the Summary sections of the most recent 5.
+- Session history is NOT in this file. Each session's record lives in =.ai/sessions/YYYY-MM-DD-HH-MM-description.org= — one file per session. Catch-up reads the Summary sections of the most recent 5.
-**For protocols and conventions, see:** [[file:protocols.org][protocols.org]]
+- For protocols and conventions, see [[file:protocols.org][protocols.org]].
* Project-Specific Context
@@ -48,9 +48,9 @@ This section tracks decisions that need Craig's input before work can proceed.
- Include: What needs to be decided, options available, why it matters
- Remove decisions once resolved (the resolution is captured in the Session Log of the session where it was resolved)
-**Example format:**
+- Example format:
#+begin_example
-** Feature Name or Topic
+,** Feature Name or Topic
Craig needs to decide on [specific question].
diff --git a/claude-templates/.ai/protocols.org b/claude-templates/.ai/protocols.org
index ed07c0e..f4eefed 100644
--- a/claude-templates/.ai/protocols.org
+++ b/claude-templates/.ai/protocols.org
@@ -1,5 +1,5 @@
#+TITLE: Claude Code Protocols
-#+AUTHOR: Craig Jennings & Claude
+#+AUTHOR: Craig Jennings
#+DATE: 2025-11-05
* About This File
@@ -84,7 +84,7 @@ Do NOT estimate, guess, or rely on memory. Just run the command. It takes one se
Every session pulls rulesets first, then the local project repo. Rulesets carries the canonical behavioral rules and =.ai/= templates (the old =claude-templates= repo is folded in as a subtree at =rulesets/claude-templates/=); the project pull lands commits pushed from other machines or teammates since the last session.
-Resolve any dirty-tree or merge issue at each step before moving on. Both pulls run as =git pull --ff-only= (or =git merge --ff-only= against the fetched upstream), so anything non-trivial — non-fast-forward history, dirty working tree, diverged branches — aborts. Surface the state and stop. Never auto-stash, auto-merge, or auto-rebase; the user resolves the conflict before further work.
+Resolve any sync-blocking tree or merge issue at each step before moving on. The shared =git-worktree-gate sync-safe= policy permits untracked deliveries beneath =inbox/= so receiving a handoff never prevents another project from refreshing rulesets; every staged or tracked change, dirty submodule, Git operation in progress, or untracked path outside =inbox/= blocks. Both pulls run as =git pull --ff-only= (or =git merge --ff-only= against the fetched upstream), so non-fast-forward history and diverged branches also abort. Surface the state and stop. Never auto-stash, auto-merge, or auto-rebase; the user resolves the conflict before further work.
Mechanics live in =startup.org= Phase A.0. The rule lives here because it governs the very first action of every session: load the freshest behavioral rules and templates before anything else runs.
@@ -187,6 +187,8 @@ Canonical rule: =~/code/rulesets/claude-rules/cross-project.md=.
Every in-progress task that produces files (drafts, source documents, diagrams, scripts, sub-deliverables) gets a dedicated subdirectory under =<project-root>/working/=, named after the task. All artifacts for that task live in that subdirectory until the task is marked done.
+=working/= is version-controlled from creation — it's the tracked home of in-progress work, never gitignored. Filing on completion *reorganizes* durable artifacts into permanent homes; it doesn't mark when they became durable (they were durable, and tracked, from the start). Genuinely disposable artifacts go in a gitignored =temp/= (or =/tmp=), never =working/=; the install tooling ignores =temp/= in both track and gitignore modes.
+
When the task ships, files are **renamed individually** (standard form: =YYYY-MM-DD-<task-slug>-<descriptor>.<ext>=) and **moved flat** into the appropriate permanent home (typically =assets/= or an area-specific =<area>/assets/=). The working subdirectory is then empty and gets deleted.
***Never rename the directory itself as a substitute for filing.*** The point is to keep =assets/= flat-searchable — a nested =assets/old-tech-deck-2026/slide.png= is harder to find than =assets/2026-05-18-tech-deck-vol2-slide-04-diagram.png=.
@@ -205,6 +207,8 @@ Check =inbox/= at every task boundary (after finishing a unit of work, before re
Exit 1 means handoffs are pending — process them per =inbox.org= process mode. For each accepted handoff, the act-vs-file rule: *act now* when it's clear, bounded, low-risk, in-scope, and cheaper than deferring — just do it, no asking; *file* otherwise — ask first, with filing as option 1 and "do it now" as option 2; *ask* if unsure. Exception: a proposal to change a shared asset (template workflow, rule, skill, synced script) or a substantive convention never silently acts now — it goes through the inbox engine's skeptical review and its approval (or park) step. Always reply to a handoff's sender (confirm on accept, the why on reject). Full process, the reply discipline, and the opt-in background-monitor =/loop= recipe live in =inbox.org= monitor mode.
+A machine-global =Stop= hook (=inbox-boundary-check.sh=) backs this rule so it isn't prose-only. When handoffs are pending it blocks the turn once and injects the count, so a task boundary can't pass with items unseen. It soft-nudges rather than hard-blocks: on the harness re-entry it steps aside, so a mid-task pause to ask "what's next" is never hijacked into inbox processing. The rule above still governs what to do with the items; the hook only makes sure you look.
+
** Recursive Reads — Honor =.aiignore=
Before a naive recursive read or glob of a project tree (file inventories, "what's in this repo", broad greps), skip the noise: dependency trees (=node_modules/=, =.venv/=), build output (=dist/=, =build/=, =coverage/=), language caches (=__pycache__/=, =.pytest_cache/=, =*.pyc=), editor/OS cruft, and generated token/OAuth artifacts. These waste tokens and skew project summaries even when gitignored — a recursive read sees the disk, not git.
@@ -246,6 +250,20 @@ Execute the wrap-up workflow (details in Session Protocols section below):
Execute the suspend workflow ([[file:workflows/suspend.org][suspend.org]]): a capture-only mid-session pause for an abrupt departure. It appends a resume-weighted =SUSPENDED= entry to the Session Log, notes uncommitted work, and LEAVES =.ai/session-context.org= in place so the next startup resumes from it — no archive, no teardown, no valediction. The capture-only counterpart to "wrap it up" (which ends + archives + tears down) and to =/flush= (which prompts =/clear= and resumes the same session). "I need to go" is broad — if it reads as a conversational aside, confirm before suspending.
+* Colloquialisms and Expansions
+
+Shorthand phrases Craig uses that expand to a defined action the agent applies without asking. The set is extensible: a project may add its own entries, and new shared shorthands land here.
+
+** "the list": the Before-Close Queue
+
+"Put X on the list" or "add X to the list" appends X to the Before-Close Queue, a FIFO queue of tasks and actions to finish before the session closes. Work it oldest-first at wrap-up, before teardown (=wrap-it-up.org= Step 1 works it before finalizing the Summary), and surface anything unfinished in the valediction rather than dropping it.
+
+The queue lives in the session anchor (=.ai/session-context.org=) under a =* Before-Close Queue= heading. Create the heading on the first "put it on the list" if it's absent, then append one line per item. It's session-scoped: it resets when the anchor is archived at wrap. Anything that must outlive the session is a =todo.org= task instead, not a list item.
+
+** "tell <project> <message>": cross-project handoff
+
+"Tell <project> <message>" drops the message in that project's =inbox/= via =inbox-send= (=python3 .ai/scripts/inbox-send.py <project> --text "<message>"=), the sanctioned cross-project handoff. Never write another project's =todo.org= or =inbox/= directly. Resolve =<project>= the way =inbox-send= does (basename match, dots stripped); if it's ambiguous, ask which project rather than guessing.
+
* User Information
** Calendar Management
@@ -356,9 +374,19 @@ Craig runs a pure Wayland setup (Hyprland) and avoids XWayland/Xorg apps.
- Clipboard: Use =wl-copy= and =wl-paste= (NOT =xclip= or =xsel=)
- Window management: Use Hyprland commands (NOT =xkill=, =xdotool=, etc.)
- Prefer Wayland-native tools over X11 equivalents
-- Open URLs in browser: Use =google-chrome-stable "URL" &>/dev/null &=
- - The =&>/dev/null &= is required to detach the process and suppress output
- - Without it, the command may appear to hang or produce no result
+- Open URLs in browser: invoke Chrome directly — never =xdg-open=, which returned success in a home session on 2026-07-26 while no tab appeared.
+
+ Chrome is normally already running, and in that case it hands the URL to the live session and exits immediately (rc 0), printing =Opening in existing browser session.= on *stdout*. So run it in the foreground and read that line as the confirmation the tab actually opened:
+
+ #+begin_src bash
+ google-chrome-stable --new-tab "URL"
+ #+end_src
+
+ Don't redirect stdout away while checking for that line — verified 2026-07-27 on ratio: with =2>/dev/null= the message still appears (it isn't stderr), and with =>/dev/null= it vanishes.
+
+ Several URLs in one invocation open as separate tabs (=google-chrome-stable --new-tab "URL1" "URL2"=). Pass them as separate words or an array — the Bash tool runs zsh, which does not word-split an unquoted =$urls= variable, so a space-joined string arrives as one malformed argument (see the zsh note below).
+
+ *Cold start.* If Chrome is *not* already running, the command becomes the browser process and blocks. Detach that case with =&>/dev/null &=, accepting that the confirmation line is discarded — there is no session to confirm into. Don't apply the detach form unconditionally: it suppresses the very output the warm path is verified by.
*** Shell aliases (=ls= → =exa=)
Craig's shell aliases =ls= to =exa=, which prints nothing to non-TTY pipes (e.g. when capturing =ls= output in a Bash tool call). The result looks like the directory is empty when it isn't.
@@ -367,6 +395,12 @@ Craig's shell aliases =ls= to =exa=, which prints nothing to non-TTY pipes (e.g.
- Applies to =ls -la=, =ls -t=, glob expansions piped through =ls=, and any =ls= invocation whose output gets read programmatically.
- Symptom if forgotten: the Bash tool returns empty output and you mistakenly conclude the directory is empty.
+*** zsh does not word-split unquoted variables
+The Bash tool runs zsh, which (unlike bash) does not split an unquoted =$var= on whitespace. =chrome $urls= passes all the space-joined URLs as one malformed argument.
+
+- Loop over the values, use an array, or force the split with =${=var}=.
+- Symptom if forgotten: a command that "works in bash" gets one garbled argument and fails, often silently (from the takuzu session, 2026-07-11).
+
** Miscellaneous Information
- Craig currently lives in New Orleans, LA
- Craig's phone number: 510-316-9357
@@ -406,14 +440,29 @@ Full usage: =notify --help= or see =~/.local/bin/notify=
- =atq= - list all scheduled alarms
- =atrm [number]= - remove an alarm by its queue number
-** Paging Craig — desktop vs. away from the machine
+** Reaching Craig — the notification vocabulary
+
+Two channels, two trigger words. "page me" is the desktop, "text me" is the phone, "text and page me" is both. Pick by where Craig is, and default to both when a run can't tell. Both work from any agent runtime (nothing here is Claude-specific). The words are what Craig says; a run deciding on its own maps the same way (away run texts, at-desk run pages, unsure does both).
+
+- *"page me" — at his laptop/desktop.* A desktop =notify ... --persist= that reaches him on the machine and stays up until dismissed.
-"Page me" has two channels; pick by where Craig is.
+ #+begin_src bash
+ notify info "Title" "Message" --persist
+ #+end_src
-- *At his laptop/desktop* — desktop =notify ... --persist= (above). It reaches him on the machine and stays up until dismissed.
-- *Away from his laptop/desktop* — page his phone over Signal via the *signal-mcp* tool =send_message_to_user=, addressed to Craig's account UUID =b1b5601e-6126-47f8-afaa-0a59f5188fde= (his primary number reads as unregistered in Signal's directory — never page a phone number). The message goes out from the dedicated pager account (+15045173983) and fires a normal mobile push. This is the live cross-device path, verified working 2026-06-30.
+- *"text me" — away from his machine.* A Signal push to his phone via =agent-text=:
-Do *not* use the old =page-signal= shell script — it was removed from the rulesets canonical 2026-06-12 and its =~/.local/bin/page-signal= symlink no longer exists. The signal-mcp tool is the only supported Signal path; =notify --persist= is the only supported desktop path.
+ #+begin_src bash
+ agent-text "Message for Craig's phone"
+ #+end_src
+
+ =agent-text= (in =~/.local/bin= via the rulesets install) sends from the dedicated Signal identity (+15045173983) to Craig's Signal account UUID, firing a normal mobile push. The account is registered on velox (primary) and ratio (linked device), so either sends directly; a machine without it ssh-relays to velox. Verified end to end 2026-07-13 (velox) and 2026-07-20 (ratio). Never target Craig's phone *number* (it reads as unregistered in Signal's directory); the script targets the UUID.
+
+ Caveats: a relay from a non-linked machine needs velox up on the tailnet, and each device holding the account wants a periodic =receive= (the signal-receive timer handles that). The full runbook lives in rulesets =docs/design/=.
+
+- *"text and page me" — both.* Fire =agent-text= and =notify= together. The phone reaches him now, the desktop note waits for his return. This is the default when a run can't tell whether he's away.
+
+On velox, Claude sessions may also have the *signal-mcp* tool (=send_message_to_user=, same identity), fine to use there, but it exists only in velox's local MCP config, so =agent-text= is the portable habit. The tool was named =agent-page= before 2026-07-20; a deprecated =agent-page= shim still delegates to =agent-text=. Do *not* use the old =page-signal= shell script (removed 2026-06-12).
* Session Protocols
@@ -528,12 +577,13 @@ When monitoring a long-running process (rsync, large downloads, builds, VM tests
** "Wrap it up" / "That's a wrap" / "Let's call it a wrap"
-When Craig says any of these phrases (or variations), execute the wrap-up workflow: [[file:workflows/wrap-it-up.org][wrap-it-up.org]]. Four steps:
+When Craig says any of these phrases (or variations), execute the wrap-up workflow: [[file:workflows/wrap-it-up.org][wrap-it-up.org]]. Five load-bearing steps:
1. *Finalize the Summary* in =.ai/session-context.org= (populate the 5 subsections from the Session Log)
2. *Rename* =.ai/session-context.org= → =.ai/sessions/YYYY-MM-DD-HH-MM-description.org=
3. *Git commit + push* to all remotes (see Git Commit Requirements)
-4. *Valediction* — brief, warm, specific closing
+4. *Certify the clean tree* with =git-worktree-gate certify=. Any remaining staged, unstaged, untracked, submodule, or in-progress-operation state blocks wrap entirely; report each path and the exact decision needed. There is no dirty-file deferral.
+5. *Valediction* — brief, warm, specific closing, reachable only after certification
The absence of =.ai/session-context.org= after wrap-up is the signal that the session ended cleanly. If the file is still there at the next session start, the previous session was interrupted.
@@ -552,6 +602,8 @@ Claude needs to add information to =.ai/notes.org=. For large amounts of informa
**The gitignore set follows that same decision.** A project that gitignores =.ai/= (the code-project case) gitignores the whole personal-tooling set: =.ai/=, =.claude/=, =CLAUDE.md=, =AGENTS.md=. =.claude/= is rulesets-owned — copies of =claude-rules/*.md= plus the language bundle's rules, hooks, and settings — and re-synced from rulesets on every startup, so git isn't how it travels between machines; ignoring it also keeps those private rule copies out of the repo, which ignoring =CLAUDE.md= alone would miss. A track-mode project (personal/doc repos, or a team repo that shares config with teammates who don't run rulesets) tracks the set instead. =install-ai.sh= writes the full set at bootstrap in gitignore mode; =scripts/sweep-gitignore-tooling.sh= backfills it idempotently across existing gitignore-mode projects when the set grows.
+**Public reachability decides harder than project type.** Any repo whose remotes include a non-cjennings.net host gitignores the tooling set, whatever kind of project it is — the only exception is a team repo that deliberately shares the config, decided explicitly, never by default. And a private remote is not proof of privacy: a server-side =post-receive --mirror= hook republishes invisibly from the client (the 2026-06-30 =.emacs.d= exposure rode exactly that — a cjennings.net remote mirroring to public GitHub). The sweep recognizes both the anchored (=/.ai/=) and unanchored (=.ai/=) ignore styles — an anchored-style project used to be misread as track-mode and silently skipped — and warns when tracked tooling can reach a non-cjennings.net remote.
+
**Credential-leak concern: gate it on project type, not on the credential itself.** A tracked secret, token, or credentials doc is only a public-leak risk where the repo can reach a public remote — that is, *code projects pushed to public GitHub*, which is exactly why those gitignore =.ai/= and =.claude/=. For *personal / documentation projects* (the =~/projects/= set: elibrary, home, finances, health, philosophy, etc.), the git remote is a private single-user repo on =cjennings.net=, so tracked credentials inside =.ai/= files are fine — that's the design, the project history IS the project. Do NOT raise a leak warning or suggest gitignoring a secret for these. When the question "is this a leak / should we gitignore this secret?" comes up, decide it on *which kind of project and remote* this is, never on the mere presence of a credential in a tracked file.
**When to break out documents:**
diff --git a/claude-templates/.ai/references/calendar-reference.org b/claude-templates/.ai/references/calendar-reference.org
index b44c0f1..5791b08 100644
--- a/claude-templates/.ai/references/calendar-reference.org
+++ b/claude-templates/.ai/references/calendar-reference.org
@@ -1,5 +1,5 @@
#+TITLE: Calendar Reference
-#+AUTHOR: Craig Jennings & Claude
+#+AUTHOR: Craig Jennings
Tool recipes, authentication, and credentials for Craig's calendar
setup. Three access methods, in order of preference.
diff --git a/claude-templates/.ai/scripts/agent-lock b/claude-templates/.ai/scripts/agent-lock
new file mode 100755
index 0000000..634412c
--- /dev/null
+++ b/claude-templates/.ai/scripts/agent-lock
@@ -0,0 +1,248 @@
+#!/usr/bin/env bash
+# agent-lock — a mkdir-atomic advisory lock for agent workflows.
+#
+# Why not flock: every Bash call an agent makes is its own short-lived shell,
+# so an flock taken in one /loop turn is gone by the next. This helper persists
+# the lock on disk between calls (an atomic mkdir is the acquire), and a crashed
+# holder's lock self-clears via age-based staleness reclaim instead of wedging
+# every later acquire.
+#
+# Serves both of sentry's locks (the single-runner lock and the roam-write
+# lock); callers pass a name, never a path — the helper owns the path scheme.
+#
+# Usage:
+# agent-lock acquire <name> [--ttl=SECONDS] [--wait[=SECONDS]]
+# Atomic acquire. exit 0 on win (fresh, or reclaimed from a stale holder);
+# exit 1 when a live lock already holds <name> (deferred — a note names the
+# holder on stderr). --wait polls up to SECONDS (default 30) before
+# deferring; without it, acquire is single-shot win-or-lose. --ttl records
+# the staleness horizon in the lock's metadata (default below).
+# agent-lock refresh <name>
+# Heartbeat: re-touch a held lock's mtime so it stays young. A runner
+# refreshes its own lock between passes, so a live run's lock is never older
+# than one pass and the TTL sizes to the longest single pass. exit 1 if the
+# lock is absent (nothing to refresh).
+# agent-lock release <name>
+# Remove the lock. Idempotent: exit 0 even if already free.
+# agent-lock status <name>
+# Print "free" | "held ..." | "stale ..." plus metadata. exit 0 (a query
+# never fails on lock state).
+# agent-lock path <name>
+# Print the resolved lock-directory path without creating it.
+#
+# Lock home (the helper owns this; callers pass names only):
+# $AGENT_LOCK_DIR/<name>/ when AGENT_LOCK_DIR is set (tests / advanced)
+# $XDG_RUNTIME_DIR/agent-locks/<name>/ the tmpfs runtime dir /run/user/<uid>
+# (host-local, out of every repo,
+# cleared on reboot). XDG_RUNTIME_DIR is
+# the standard handle for it and is set
+# in sentry's interactive launch.
+# ${XDG_CACHE_HOME:-~/.cache}/agent-locks/<name>/ fallback where no runtime
+# dir exists (XDG_RUNTIME_DIR unset or
+# unwritable — a headless/container box)
+#
+# tmpfs residence is deliberate: a lock under ~/org/roam would ride roam-sync's
+# `git add -A` to the other machine as a phantom hold. Host-locality is by
+# construction, and reboot clears any lock a crash left behind for free.
+#
+# Staleness is age-based on the metadata file's mtime versus the lock's own
+# recorded TTL. Heartbeat re-touches the mtime; a reclaim is always surfaced,
+# never silent.
+
+set -euo pipefail
+
+DEFAULT_TTL=600 # 10 min: sized to the longest single sentry pass, since a
+ # live runner heartbeats between passes and stays young.
+DEFAULT_WAIT=30 # bounded-wait budget for --wait (capture-guard's shape).
+WAIT_INTERVAL=3 # poll cadence while waiting on a busy lock.
+
+usage() {
+ echo "usage: agent-lock {acquire|refresh|release|status|path} <name> [--ttl=N] [--wait[=N]]" >&2
+ exit 2
+}
+
+# Resolve the base directory that holds all lock dirs, per the home scheme above.
+lock_base() {
+ if [ -n "${AGENT_LOCK_DIR:-}" ]; then
+ printf '%s\n' "$AGENT_LOCK_DIR"
+ elif [ -n "${XDG_RUNTIME_DIR:-}" ] && [ -d "$XDG_RUNTIME_DIR" ] && [ -w "$XDG_RUNTIME_DIR" ]; then
+ printf '%s/agent-locks\n' "$XDG_RUNTIME_DIR"
+ else
+ printf '%s/agent-locks\n' "${XDG_CACHE_HOME:-$HOME/.cache}"
+ fi
+}
+
+# Validate a lock name: non-empty, no path separators (so a name can never
+# escape the base dir).
+valid_name() {
+ case "$1" in
+ ''|*/*|.|..) return 1 ;;
+ *) return 0 ;;
+ esac
+}
+
+lock_dir() { printf '%s/%s\n' "$(lock_base)" "$1"; }
+meta_path() { printf '%s/meta\n' "$(lock_dir "$1")"; }
+
+# Read a key from a lock's metadata file; empty if absent.
+meta_get() {
+ local key="$1" file="$2"
+ [ -f "$file" ] || return 0
+ sed -n "s/^${key}=//p" "$file" | head -n1
+}
+
+# Age of a lock in whole seconds, from the metadata mtime.
+lock_age() {
+ local file="$1" mtime now
+ mtime=$(stat -c %Y "$file" 2>/dev/null) || return 1
+ now=$(date +%s)
+ printf '%s\n' "$((now - mtime))"
+}
+
+# True when a lock dir exists but its age exceeds its recorded TTL.
+is_stale() {
+ local name="$1" file age ttl
+ file="$(meta_path "$name")"
+ [ -f "$file" ] || return 1
+ age="$(lock_age "$file")" || return 1
+ ttl="$(meta_get ttl "$file")"
+ [ -n "$ttl" ] || ttl="$DEFAULT_TTL"
+ [ "$age" -gt "$ttl" ]
+}
+
+# Write the metadata file for a freshly-taken lock.
+write_meta() {
+ local name="$1" ttl="$2" file
+ file="$(meta_path "$name")"
+ {
+ printf 'pid=%s\n' "$$"
+ printf 'host=%s\n' "$(uname -n)"
+ printf 'acquired=%s\n' "$(date +%Y-%m-%dT%H:%M:%S%z)"
+ printf 'ttl=%s\n' "$ttl"
+ } > "$file"
+}
+
+# One-line holder description for surfaced notes.
+holder_desc() {
+ local file="$1"
+ printf "pid=%s host=%s age=%ss ttl=%ss" \
+ "$(meta_get pid "$file")" "$(meta_get host "$file")" \
+ "$(lock_age "$file" 2>/dev/null || echo '?')" "$(meta_get ttl "$file")"
+}
+
+# Attempt a single atomic acquire. exit 0 win, 1 busy (live holder).
+try_acquire() {
+ local name="$1" ttl="$2" dir file
+ dir="$(lock_dir "$name")"
+ file="$(meta_path "$name")"
+ mkdir -p "$(lock_base)"
+
+ if mkdir "$dir" 2>/dev/null; then
+ write_meta "$name" "$ttl"
+ return 0
+ fi
+
+ # Directory exists. Reclaim it if the holder is stale; otherwise it's busy.
+ if is_stale "$name"; then
+ # Claim the stale dir atomically before removing it. `mv` of a directory is
+ # atomic, so when two acquirers both see the lock stale, only one's rename
+ # of $dir succeeds — the other's fails because $dir is already gone, and it
+ # falls through to busy. Never `rm -rf $dir` directly: a plain remove lets
+ # the loser delete the winner's freshly-created lock and double-acquire.
+ local claimed="$dir.stale.$$"
+ if mv "$dir" "$claimed" 2>/dev/null; then
+ echo "agent-lock: reclaimed stale lock '$name' ($(holder_desc "$claimed/meta"))" >&2
+ rm -rf "$claimed"
+ # mkdir stays the sole grant: a concurrent fresh acquirer may win here,
+ # in which case our mkdir fails and we correctly defer to it.
+ if mkdir "$dir" 2>/dev/null; then
+ write_meta "$name" "$ttl"
+ return 0
+ fi
+ fi
+ fi
+ return 1
+}
+
+cmd_acquire() {
+ local name="$1"; shift
+ local ttl="$DEFAULT_TTL" wait_total=0
+ while [ $# -gt 0 ]; do
+ case "$1" in
+ --ttl=*) ttl="${1#--ttl=}" ;;
+ --ttl) shift; ttl="${1:-}" ;;
+ --wait) wait_total="$DEFAULT_WAIT" ;;
+ --wait=*) wait_total="${1#--wait=}" ;;
+ *) usage ;;
+ esac
+ shift
+ done
+ case "$ttl" in ''|*[!0-9]*) usage ;; esac
+ case "$wait_total" in *[!0-9]*) usage ;; esac
+
+ local elapsed=0
+ while :; do
+ if try_acquire "$name" "$ttl"; then
+ exit 0
+ fi
+ if [ "$elapsed" -ge "$wait_total" ]; then
+ echo "agent-lock: '$name' busy ($(holder_desc "$(meta_path "$name")")); deferring" >&2
+ exit 1
+ fi
+ local remaining=$((wait_total - elapsed)) step
+ step=$(( remaining < WAIT_INTERVAL ? remaining : WAIT_INTERVAL ))
+ sleep "$step"
+ elapsed=$((elapsed + step))
+ done
+}
+
+cmd_refresh() {
+ local name="$1" file
+ file="$(meta_path "$name")"
+ [ -f "$file" ] || exit 1
+ # Re-stamp acquired and bump mtime so the age clock restarts.
+ local ttl; ttl="$(meta_get ttl "$file")"; [ -n "$ttl" ] || ttl="$DEFAULT_TTL"
+ write_meta "$name" "$ttl"
+ exit 0
+}
+
+cmd_release() {
+ local name="$1" dir
+ dir="$(lock_dir "$name")"
+ rm -rf "$dir"
+ exit 0
+}
+
+cmd_status() {
+ local name="$1" dir file
+ dir="$(lock_dir "$name")"
+ file="$(meta_path "$name")"
+ if [ ! -d "$dir" ]; then
+ echo "free $name"
+ exit 0
+ fi
+ local state="held"
+ is_stale "$name" && state="stale"
+ echo "$state $name pid=$(meta_get pid "$file") host=$(meta_get host "$file") acquired=$(meta_get acquired "$file") ttl=$(meta_get ttl "$file") age=$(lock_age "$file" 2>/dev/null || echo '?')s"
+ exit 0
+}
+
+cmd_path() {
+ lock_dir "$1"
+ exit 0
+}
+
+[ $# -ge 1 ] || usage
+subcmd="$1"; shift
+[ $# -ge 1 ] || usage
+name="$1"; shift
+valid_name "$name" || usage
+
+case "$subcmd" in
+ acquire) cmd_acquire "$name" "$@" ;;
+ refresh) cmd_refresh "$name" ;;
+ release) cmd_release "$name" ;;
+ status) cmd_status "$name" ;;
+ path) cmd_path "$name" ;;
+ *) usage ;;
+esac
diff --git a/claude-templates/.ai/scripts/apkg-to-orgdrill.py b/claude-templates/.ai/scripts/apkg-to-orgdrill.py
new file mode 100755
index 0000000..79e24a4
--- /dev/null
+++ b/claude-templates/.ai/scripts/apkg-to-orgdrill.py
@@ -0,0 +1,251 @@
+#!/usr/bin/env -S uv run --script
+# /// script
+# requires-python = ">=3.11"
+# dependencies = []
+# ///
+"""Convert an Anki .apkg deck into an org-drill file (inverse of flashcard-to-anki.py).
+
+The flashcard pipeline is otherwise one-directional (org-drill -> apkg).
+Decks curated on the phone, and orphaned apkgs whose .org source was never
+saved, can't get back into the org source of truth. This recovers them.
+
+Reading needs no third-party library: an apkg is a zip holding
+collection.anki2 / .anki21 (an Anki sqlite db) plus a media blob, so stdlib
+zipfile + sqlite3 suffice. genanki is only needed to write apkgs, not read
+them.
+
+Mapping (mirrors flashcard-to-anki.py's parse/build, inverted):
+ - Deck name (from the apkg) -> #+TITLE:
+ - Note Front -> ** <Front> :drill:
+ - Note Back (HTML) -> entry body (<br> -> newlines,
+ &amp;/&lt;/&gt; unescaped,
+ <hr id="answer"> stripped)
+ - Note tag -> * <tag> section grouping
+ (best-effort: the tag is a slug,
+ so it won't round-trip to the exact
+ original section title — a human
+ retitles)
+ - A fresh :ID: UUID per card -> so the output is org-drill-valid
+
+GUIDs in flashcard-to-anki.py are derived from the Front text, not the
+:ID:, so a deck regenerated from recovered org still matches existing phone
+cards by Front. Only Front/Back (Basic) note types convert; other models
+(cloze, etc.) are skipped with a warning rather than silently dropped.
+
+Usage:
+ apkg-to-orgdrill.py <input.apkg> # one <deck-slug>.org per deck in cwd
+ apkg-to-orgdrill.py <input.apkg> --output-dir DIR
+ apkg-to-orgdrill.py <input.apkg> --deck "Name" --output deck.org
+"""
+from __future__ import annotations
+
+import argparse
+import json
+import re
+import sqlite3
+import sys
+import tempfile
+import uuid
+import zipfile
+from collections import OrderedDict
+from dataclasses import dataclass
+from pathlib import Path
+
+# Collection member names Anki uses, newest schema first.
+COLLECTION_NAMES = ("collection.anki21", "collection.anki2")
+
+_BR_RE = re.compile(r"<br\s*/?>", re.IGNORECASE)
+_ANSWER_HR_RE = re.compile(r'<hr id="answer">', re.IGNORECASE)
+_MEDIA_RE = re.compile(r"<img\b|\[sound:|<audio\b|<video\b", re.IGNORECASE)
+
+
+@dataclass
+class Note:
+ deck: str
+ front: str
+ back_html: str
+ tag: str
+
+
+def html_to_org_body(back_html: str) -> list[str]:
+ """Invert flashcard-to-anki.py's back-of-card HTML into org body lines.
+
+ <br> (all spellings) and a stray answer <hr> become line breaks; the
+ entity unescape undoes escape_html, which escaped ``&`` first — so ``&``
+ is unescaped last here, or a literally-escaped ``&lt;`` in the source
+ would wrongly collapse to ``<``.
+ """
+ if not back_html:
+ return []
+ s = _ANSWER_HR_RE.sub("\n", back_html)
+ s = _BR_RE.sub("\n", s)
+ s = s.replace("&lt;", "<").replace("&gt;", ">").replace("&amp;", "&")
+ return s.split("\n")
+
+
+def _slug(title: str) -> str:
+ return re.sub(r"[^a-z0-9]+", "-", title.lower()).strip("-")
+
+
+def _read_collection(db_path: Path) -> list[Note]:
+ con = sqlite3.connect(db_path)
+ try:
+ row = con.execute("SELECT decks, models FROM col LIMIT 1").fetchone()
+ if row is None:
+ raise ValueError("collection has no col row")
+ decks_json, models_json = row
+ decks = {int(k): v["name"] for k, v in json.loads(decks_json).items()}
+ models = {
+ int(k): [f["name"] for f in v["flds"]]
+ for k, v in json.loads(models_json).items()
+ }
+
+ # A note's deck comes from its card; the Default deck (id 1) carries
+ # no cards from this pipeline, so it never shows up here.
+ nid_to_did: dict[int, int] = {}
+ for nid, did in con.execute("SELECT nid, did FROM cards"):
+ nid_to_did.setdefault(nid, did)
+
+ notes: list[Note] = []
+ for nid, mid, flds, tags in con.execute(
+ "SELECT id, mid, flds, tags FROM notes"
+ ):
+ field_names = models.get(mid)
+ if not field_names or "Front" not in field_names or "Back" not in field_names:
+ print(
+ f"apkg-to-orgdrill: skip note {nid} — model is not a Front/Back "
+ f"type (fields={field_names})",
+ file=sys.stderr,
+ )
+ continue
+ fields = flds.split("\x1f")
+ fi, bi = field_names.index("Front"), field_names.index("Back")
+ front = fields[fi] if fi < len(fields) else ""
+ back_html = fields[bi] if bi < len(fields) else ""
+
+ did = nid_to_did.get(nid)
+ if did is None:
+ continue # note with no card — orphan
+ deck = decks.get(did)
+ if deck is None:
+ continue
+
+ tag_list = tags.split()
+ tag = tag_list[0] if tag_list else "drill"
+
+ if _MEDIA_RE.search(back_html):
+ print(
+ f"apkg-to-orgdrill: note {nid} references media; org has no "
+ f"media path (left inline for a human to resolve)",
+ file=sys.stderr,
+ )
+ notes.append(Note(deck=deck, front=front, back_html=back_html, tag=tag))
+ return notes
+ finally:
+ con.close()
+
+
+def read_apkg(path: Path) -> list[Note]:
+ """Read an .apkg and return its Front/Back notes. Raises on a malformed file."""
+ with zipfile.ZipFile(path) as z: # BadZipFile if it isn't a zip
+ names = set(z.namelist())
+ col_name = next((n for n in COLLECTION_NAMES if n in names), None)
+ if col_name is None:
+ raise ValueError(f"{path}: no collection.anki2/.anki21 inside the apkg")
+ with tempfile.TemporaryDirectory() as td:
+ db_path = Path(td) / col_name
+ db_path.write_bytes(z.read(col_name))
+ return _read_collection(db_path)
+
+
+def notes_to_org(notes: list[Note], deck_name: str, *, new_id=None) -> str:
+ """Render one deck's notes as an org-drill file in the house shape."""
+ if new_id is None:
+ new_id = lambda: str(uuid.uuid4()) # noqa: E731
+ groups: "OrderedDict[str, list[Note]]" = OrderedDict()
+ for n in notes:
+ groups.setdefault(n.tag, []).append(n)
+
+ lines: list[str] = [f"#+TITLE: {deck_name}", ""]
+ for tag, group in groups.items():
+ lines.append(f"* {tag}")
+ for n in group:
+ lines.append(f"** {n.front} :drill:")
+ lines.append(":PROPERTIES:")
+ lines.append(f":ID: {new_id()}")
+ lines.append(":END:")
+ lines.extend(html_to_org_body(n.back_html))
+ lines.append("")
+ return "\n".join(lines).rstrip("\n") + "\n"
+
+
+def convert(apkg_path: Path, *, new_id=None) -> "OrderedDict[str, str]":
+ """apkg -> {deck_name: org_text}, one entry per deck that has Front/Back cards."""
+ by_deck: "OrderedDict[str, list[Note]]" = OrderedDict()
+ for n in read_apkg(apkg_path):
+ by_deck.setdefault(n.deck, []).append(n)
+ out: "OrderedDict[str, str]" = OrderedDict()
+ for deck, deck_notes in by_deck.items():
+ out[deck] = notes_to_org(deck_notes, deck, new_id=new_id)
+ return out
+
+
+def main() -> int:
+ parser = argparse.ArgumentParser(
+ description="Convert an Anki .apkg deck into an org-drill file.",
+ )
+ parser.add_argument("input", type=Path, help="Path to the .apkg file.")
+ parser.add_argument("--deck", help="Only convert the deck with this exact name.")
+ parser.add_argument(
+ "--output",
+ type=Path,
+ help="Output .org path. Requires a single deck (use --deck to pick one).",
+ )
+ parser.add_argument(
+ "--output-dir",
+ type=Path,
+ help="Directory for per-deck .org files (default: current directory).",
+ )
+ args = parser.parse_args()
+
+ input_path = args.input.expanduser().resolve()
+ if not input_path.is_file():
+ print(f"error: {input_path} not found", file=sys.stderr)
+ return 1
+
+ by_deck = convert(input_path)
+ if args.deck:
+ by_deck = OrderedDict((k, v) for k, v in by_deck.items() if k == args.deck)
+ if not by_deck:
+ print(f"error: no deck named {args.deck!r} in {input_path}", file=sys.stderr)
+ return 1
+ if not by_deck:
+ print(f"error: no Front/Back cards found in {input_path}", file=sys.stderr)
+ return 1
+
+ if args.output:
+ if len(by_deck) != 1:
+ print(
+ f"error: --output needs a single deck; {input_path} has "
+ f"{len(by_deck)} ({', '.join(by_deck)}). Use --deck or --output-dir.",
+ file=sys.stderr,
+ )
+ return 1
+ out = args.output.expanduser().resolve()
+ out.parent.mkdir(parents=True, exist_ok=True)
+ deck, org = next(iter(by_deck.items()))
+ out.write_text(org, encoding="utf-8")
+ print(f"wrote {out} ({org.count(':drill:')} cards, deck {deck!r})")
+ return 0
+
+ out_dir = (args.output_dir or Path.cwd()).expanduser().resolve()
+ out_dir.mkdir(parents=True, exist_ok=True)
+ for deck, org in by_deck.items():
+ out = out_dir / f"{_slug(deck) or 'deck'}.org"
+ out.write_text(org, encoding="utf-8")
+ print(f"wrote {out} ({org.count(':drill:')} cards, deck {deck!r})")
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/claude-templates/.ai/scripts/cj-remove-block.py b/claude-templates/.ai/scripts/cj-remove-block.py
index 71c7b3d..d5137a3 100755
--- a/claude-templates/.ai/scripts/cj-remove-block.py
+++ b/claude-templates/.ai/scripts/cj-remove-block.py
@@ -16,8 +16,12 @@ Companion to the /respond-to-cj-comments skill and to cj-scan.py.
from __future__ import annotations
import argparse
+import os
import re
+import shutil
import sys
+import tempfile
+from datetime import datetime
from pathlib import Path
SRC_OPEN_RE = re.compile(r"^\s*#\+begin_src\s+cj:", re.IGNORECASE)
@@ -57,12 +61,83 @@ def looks_like_cj_range(lines: list[str], start: int, end: int) -> tuple[bool, s
f"Line {end} does not look like a #+end_src closing fence "
f"(got: {last[:60]!r})"
)
+
+ # The range must hold exactly ONE block. Checking only the first and last
+ # lines let a drifted range run from one block's opener to a *later* block's
+ # closer: validation passed and the removal silently deleted everything
+ # between, prose and headings included. Drift is the case this check exists
+ # for, so it has to look inside the range, not just at its ends.
+ for offset, line in enumerate(lines[start:end - 1], start=start + 1):
+ if SRC_CLOSE_RE.match(line):
+ return False, (
+ f"Range {start}..{end} covers more than one cj block — "
+ f"a #+end_src appears at line {offset}, before the range ends. "
+ f"Re-scan for current line numbers; removing this range would "
+ f"delete everything between the two blocks."
+ )
+ if SRC_OPEN_RE.match(line):
+ return False, (
+ f"Range {start}..{end} covers more than one cj block — "
+ f"a second #+begin_src cj: appears at line {offset}. "
+ f"Re-scan for current line numbers."
+ )
return True, ""
+def _backup(path: Path) -> Path:
+ """Copy path to /tmp before mutating it, mirroring lint-org.el's convention.
+
+ These are Craig's org files. lint-org.el, the other tool that rewrites them,
+ leaves a /tmp copy before touching anything; this matches it so a bad edit is
+ always recoverable without reaching for git (which only reaches the last
+ commit, losing intra-session work).
+ """
+ stamp = datetime.now().strftime("%Y%m%d-%H%M%S")
+ base = Path(tempfile.gettempdir()) / f"{path.name}.before-cj-remove.{stamp}"
+ # Never overwrite an earlier backup. The skill removes several annotations
+ # in quick succession, so a second-resolution stamp collides and the later
+ # copy would replace the earlier one with already-mutated content — losing
+ # the pre-session original the backup exists to preserve.
+ dest = base
+ n = 2
+ while dest.exists():
+ dest = base.with_name(f"{base.name}-{n}")
+ n += 1
+ shutil.copy2(path, dest)
+ return dest
+
+
+def _atomic_write(path: Path, text: str) -> None:
+ """Write text to path via a temp sibling and os.replace.
+
+ A bare write_text truncates the target on open, so a mid-write failure left
+ the org file truncated with no complete copy on disk. Writing a temp sibling
+ and renaming means the file is either its old content or its new content,
+ never a partial.
+ """
+ # Follow a symlink to the file it names. os.replace would otherwise swap the
+ # symlink itself for a regular file, leaving the real target holding the old
+ # content — the edit silently goes nowhere. Resolving also puts the temp
+ # sibling on the same filesystem as the real file, which os.replace needs.
+ path = path.resolve()
+ fd, tmp = tempfile.mkstemp(dir=path.parent, prefix=f".{path.name}.", suffix=".tmp")
+ os.close(fd)
+ tmp_path = Path(tmp)
+ # Carry the original's permissions across. mkstemp creates 0600, and
+ # defaulting to the umask instead widened a deliberately-restricted file
+ # (a 0600 org file came back 0644).
+ shutil.copymode(path, tmp_path)
+ try:
+ tmp_path.write_text(text, encoding="utf-8")
+ os.replace(tmp_path, path)
+ except BaseException:
+ tmp_path.unlink(missing_ok=True)
+ raise
+
+
def remove_range(path: Path, start: int, end: int) -> None:
"""Read path, validate range looks like cj content, remove the range, write back."""
- text = path.read_text()
+ text = path.read_text(encoding="utf-8")
had_trailing_newline = text.endswith("\n")
lines = text.splitlines(keepends=False)
@@ -77,7 +152,9 @@ def remove_range(path: Path, start: int, end: int) -> None:
new_text += "\n"
elif not new_lines and had_trailing_newline:
new_text = ""
- path.write_text(new_text)
+
+ _backup(path)
+ _atomic_write(path, new_text)
def main() -> int:
diff --git a/claude-templates/.ai/scripts/flashcard-stats.py b/claude-templates/.ai/scripts/flashcard-stats.py
index 1fa5afb..cb580ac 100755
--- a/claude-templates/.ai/scripts/flashcard-stats.py
+++ b/claude-templates/.ai/scripts/flashcard-stats.py
@@ -35,7 +35,12 @@ import re
import sys
from pathlib import Path
-CARD_RE = re.compile(r"^\*\*\s+(.+?)\s+:drill:\s*$")
+# A card is a level-2 heading whose trailing org tag block includes `drill`.
+# Group 1 is the front, group 2 the tag block — so a curated card multi-tagged
+# :fundamental:drill: still counts (it would silently drop under a :drill:$
+# anchor, undercounting the deck). HEADING_RE bounds a card's body.
+CARD_RE = re.compile(r"^\*\*\s+(.+?)\s+(:[A-Za-z0-9_@#%:]+:)\s*$")
+HEADING_RE = re.compile(r"^\*{1,2}\s")
ANSWER_RE = re.compile(r"^\*\*\*\s+Answer\b")
PROP_START_RE = re.compile(r"^\s*:PROPERTIES:\s*$")
PROP_END_RE = re.compile(r"^\s*:END:\s*$")
@@ -177,7 +182,8 @@ def parse_cards(lines: list[str]) -> tuple[list[dict], int]:
n = len(lines)
while i < n:
m = CARD_RE.match(lines[i])
- if not m:
+ tags = [t for t in m.group(2).split(":") if t] if m else []
+ if not (m and "drill" in tags):
i += 1
continue
heading = m.group(1).strip()
@@ -188,7 +194,7 @@ def parse_cards(lines: list[str]) -> tuple[list[dict], int]:
body_lines: list[str] = []
while i < n:
line = lines[i]
- if line.startswith("* ") or CARD_RE.match(line):
+ if HEADING_RE.match(line):
break
if PROP_START_RE.match(line):
prop_count += 1
diff --git a/claude-templates/.ai/scripts/flashcard-to-anki.py b/claude-templates/.ai/scripts/flashcard-to-anki.py
index ca4c70b..e369fd8 100755
--- a/claude-templates/.ai/scripts/flashcard-to-anki.py
+++ b/claude-templates/.ai/scripts/flashcard-to-anki.py
@@ -10,8 +10,15 @@
Parses org-drill structure:
- Top-level "* Section" headings become tags on every card under them.
- Each "** Card name :drill:" entry becomes a card. Front = heading
- text (sans :drill: tag). Back = entry body with newlines converted
+ text (sans the tag block). Back = entry body with newlines converted
to <br>.
+ - A card may carry a second org tag ("** Card :fundamental:drill:").
+ Any heading whose tag block includes `drill` is a card; the other
+ tags ride along as Anki tags next to the section tag, so a curated
+ subset stays grep-able in the source. --tag-filter <tag> emits only
+ the cards carrying that tag, and a subset deck built that way should
+ pass --guid-salt so its notes get their own GUID space (Anki dedupes
+ on GUID, so without it the subset imports empty against the full deck).
Deck name defaults to the org #+TITLE: (so the phone deck reads as the
curated title), falling back to the input basename when the source has
@@ -27,6 +34,8 @@ Usage:
flashcard-to-anki.py <input.org>
flashcard-to-anki.py <input.org> --deck "My Deck Name"
flashcard-to-anki.py <input.org> --output /path/to/deck.apkg
+ flashcard-to-anki.py <input.org> --tag-filter fundamental \
+ --deck "DeepSat Fundamentals" --guid-salt fundamentals
Requires genanki, which uv resolves automatically via the PEP 723
script metadata above. No venv or system install needed.
@@ -47,6 +56,15 @@ import genanki
ID_BASE = 1_500_000_000
ID_RANGE = 500_000_000
+# A card is any level-2 heading whose trailing org tag block includes `drill`.
+# Group 1 is the front text, group 2 the colon-delimited tag block (e.g.
+# ":fundamental:drill:") — so a curated subset can carry a second org tag
+# (:fundamental:) and stay grep-able in the source without dropping from the
+# full deck. HEADING_RE bounds a card's body at the next L1/L2 heading.
+CARD_RE = re.compile(r"^\*\*\s+(.+?)\s+(:[A-Za-z0-9_@#%:]+:)\s*$")
+HEADING_RE = re.compile(r"^\*{1,2}\s")
+SECTION_RE = re.compile(r"^\*\s+(.+?)\s*$")
+
def stable_id(name: str, salt: str) -> int:
"""Derive a deterministic 32-bit id from `name` and a `salt`.
@@ -120,33 +138,40 @@ def strip_org_metadata(body_lines: list[str]) -> list[str]:
return cleaned
-def parse(org_text: str) -> list[tuple[str, str, str]]:
- """Return [(front, back_html, tag), ...] for every :drill: card."""
- cards: list[tuple[str, str, str]] = []
- current_section: str | None = None
+def parse(
+ org_text: str, tag_filter: str | None = None
+) -> list[tuple[str, str, list[str]]]:
+ """Return [(front, back_html, anki_tags), ...] for every :drill: card.
- section_re = re.compile(r"^\*\s+(.+?)\s*$")
- card_re = re.compile(r"^\*\*\s+(.+?)\s+:drill:\s*$")
+ A card is any level-2 heading whose trailing org tag block includes
+ `drill`. Non-drill org tags on the heading (e.g. :fundamental:) ride
+ along as Anki tags next to the section tag, so a curated subset stays
+ grep-able in the source. When `tag_filter` is set, only cards carrying
+ that org tag are returned (the subset-deck path).
+ """
+ cards: list[tuple[str, str, list[str]]] = []
+ current_section: str | None = None
lines = org_text.splitlines()
i = 0
while i < len(lines):
line = lines[i]
- sec = section_re.match(line)
+ sec = SECTION_RE.match(line)
if sec:
current_section = sec.group(1).strip()
i += 1
continue
- card = card_re.match(line)
- if card:
- front = card.group(1).strip()
+ m = CARD_RE.match(line)
+ tags = [t for t in m.group(2).split(":") if t] if m else []
+ if m and "drill" in tags:
+ front = m.group(1).strip()
body_lines: list[str] = []
i += 1
while i < len(lines):
nxt = lines[i]
- if nxt.startswith("* ") or card_re.match(nxt):
+ if HEADING_RE.match(nxt):
break
body_lines.append(nxt)
i += 1
@@ -156,8 +181,17 @@ def parse(org_text: str) -> list[tuple[str, str, str]]:
while body_lines and not body_lines[-1].strip():
body_lines.pop()
back_html = "<br>".join(escape_html(ln) for ln in body_lines)
- tag = section_to_tag(current_section) if current_section else "drill"
- cards.append((front, back_html, tag))
+
+ org_tags = [t for t in tags if t != "drill"]
+ if tag_filter and tag_filter not in org_tags:
+ continue
+ anki_tags: list[str] = []
+ if current_section:
+ anki_tags.append(section_to_tag(current_section))
+ anki_tags.extend(org_tags)
+ if not anki_tags:
+ anki_tags = ["drill"]
+ cards.append((front, back_html, anki_tags))
continue
i += 1
@@ -165,15 +199,28 @@ def parse(org_text: str) -> list[tuple[str, str, str]]:
return cards
-def build(cards: list[tuple[str, str, str]], deck_name: str) -> genanki.Deck:
+def card_guid(front: str, guid_salt: str | None) -> str:
+ """GUID for a card's front. A salt gives a derived subset deck its own
+ GUID space so its notes don't collide with the full deck's (Anki dedupes
+ on GUID, which would otherwise import the subset empty). No salt is the
+ original behavior, so an unsalted deck's GUIDs and SRS state are untouched.
+ """
+ return genanki.guid_for(guid_salt, front) if guid_salt else genanki.guid_for(front)
+
+
+def build(
+ cards: list[tuple[str, str, list[str]]],
+ deck_name: str,
+ guid_salt: str | None = None,
+) -> genanki.Deck:
deck = genanki.Deck(stable_id(deck_name, "deck"), deck_name)
model = make_model(deck_name)
- for front, back, tag in cards:
+ for front, back, tags in cards:
note = genanki.Note(
model=model,
fields=[front, back],
- tags=[tag],
- guid=genanki.guid_for(front),
+ tags=tags,
+ guid=card_guid(front, guid_salt),
)
deck.add_note(note)
return deck
@@ -219,6 +266,16 @@ def main() -> int:
help="Output .apkg path. Defaults to "
"~/sync/phone/anki/<input-basename>.apkg.",
)
+ parser.add_argument(
+ "--tag-filter",
+ help="Emit only cards carrying this org tag (e.g. --tag-filter "
+ "fundamental for a curated subset deck).",
+ )
+ parser.add_argument(
+ "--guid-salt",
+ help="Salt note GUIDs so a subset deck gets its own GUID space and "
+ "imports non-empty without disturbing the full deck's SRS state.",
+ )
args = parser.parse_args()
input_path: Path = args.input.expanduser().resolve()
@@ -231,12 +288,18 @@ def main() -> int:
output_path: Path = (args.output or default_output_path(input_path)).expanduser().resolve()
output_path.parent.mkdir(parents=True, exist_ok=True)
- cards = parse(org_text)
+ cards = parse(org_text, tag_filter=args.tag_filter)
if not cards:
- print(f"error: no :drill: cards found in {input_path}", file=sys.stderr)
+ if args.tag_filter:
+ print(
+ f"error: no :drill: cards tagged :{args.tag_filter}: in {input_path}",
+ file=sys.stderr,
+ )
+ else:
+ print(f"error: no :drill: cards found in {input_path}", file=sys.stderr)
return 1
- deck = build(cards, deck_name)
+ deck = build(cards, deck_name, guid_salt=args.guid_salt)
genanki.Package(deck).write_to_file(str(output_path))
print(f"wrote {output_path} ({len(cards)} cards, deck '{deck_name}')")
return 0
diff --git a/claude-templates/.ai/scripts/inbox-send.py b/claude-templates/.ai/scripts/inbox-send.py
index 1362a1f..663efcb 100755
--- a/claude-templates/.ai/scripts/inbox-send.py
+++ b/claude-templates/.ai/scripts/inbox-send.py
@@ -31,6 +31,7 @@ import os
import re
import shutil
import sys
+import tempfile
from datetime import datetime
from pathlib import Path
@@ -48,7 +49,7 @@ def resolve_roots() -> list[Path]:
config = Path.home() / ".claude" / "inbox-roots.txt"
if config.is_file():
paths: list[Path] = []
- for line in config.read_text().splitlines():
+ for line in config.read_text(encoding="utf-8").splitlines():
line = line.strip()
if line and not line.startswith("#"):
paths.append(Path(line).expanduser())
@@ -69,17 +70,28 @@ def discover_projects(roots: list[Path]) -> list[Path]:
a specific project root (included directly if it qualifies).
"""
projects: list[Path] = []
+ seen: set[Path] = set()
+
+ def _add(p: Path) -> None:
+ # Dedupe on the resolved path: a roots config naming both a parent and
+ # one of its children would otherwise list the child project twice, at
+ # two different indices.
+ key = p.resolve()
+ if key not in seen:
+ seen.add(key)
+ projects.append(p)
+
for root in roots:
if not root.is_dir():
continue
if _is_project(root):
- projects.append(root)
+ _add(root)
continue
for child in sorted(root.iterdir()):
if not child.is_dir():
continue
if _is_project(child):
- projects.append(child)
+ _add(child)
return projects
@@ -177,6 +189,56 @@ 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 _atomic_write(dest: Path, writer) -> None:
+ """Write to a temp file in dest's directory, then rename it into place.
+
+ dest is another project's inbox/, and a direct write truncates the target
+ on open, so any mid-write failure (a full disk, an encoding error, an
+ interrupted process) leaves a zero-byte .org there. inbox-status counts
+ that phantom as a pending handoff and blocks a turn in the receiving
+ project over a file with no content and no sender (2026-07-23). Writing to
+ a temp sibling and os.replace-ing means the inbox only ever sees a complete
+ file. os.replace is atomic within one filesystem, and the temp sits in the
+ same directory as dest, so it is.
+
+ `writer` receives the open temp path and fills it. On any failure the temp
+ is removed and the error re-raised, so a caught error never leaves debris.
+ """
+ fd, tmp = tempfile.mkstemp(
+ dir=dest.parent, prefix=".inbox-send-", suffix=dest.suffix
+ )
+ os.close(fd)
+ tmp_path = Path(tmp)
+ # mkstemp creates the temp 0600; give the delivered file the umask-default
+ # mode the old direct write produced, so inbox files stay readable as before.
+ umask = os.umask(0)
+ os.umask(umask)
+ os.chmod(tmp_path, 0o666 & ~umask)
+ try:
+ writer(tmp_path)
+ os.replace(tmp_path, dest)
+ except BaseException:
+ tmp_path.unlink(missing_ok=True)
+ raise
+
+
def send_text(
target_inbox: Path,
message: str,
@@ -191,8 +253,9 @@ 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.write_text(build_text_org(message, source_name, now.strftime(TS_DOC_FMT)))
+ dest = uniquify(target_inbox / filename)
+ body = build_text_org(message, source_name, now.strftime(TS_DOC_FMT))
+ _atomic_write(dest, lambda p: p.write_text(body, encoding="utf-8"))
return dest
@@ -211,8 +274,8 @@ 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
- shutil.copy2(src_path, dest)
+ dest = uniquify(target_inbox / filename)
+ _atomic_write(dest, lambda p: shutil.copyfile(src_path, p))
return dest
@@ -293,7 +356,10 @@ def main() -> int:
else:
assert args.file is not None
dest = send_file(target_inbox, args.file, source_name, args.name, now)
- except (ValueError, FileNotFoundError) as exc:
+ except (ValueError, OSError) as exc:
+ # OSError covers FileNotFoundError (missing source), PermissionError
+ # (unreadable source), and any atomic-write failure — all should
+ # surface as the clean "inbox-send: <message>" error, never a traceback.
print(f"inbox-send: {exc}", file=sys.stderr)
return 1
diff --git a/claude-templates/.ai/scripts/inbox-status b/claude-templates/.ai/scripts/inbox-status
index b917144..17031af 100755
--- a/claude-templates/.ai/scripts/inbox-status
+++ b/claude-templates/.ai/scripts/inbox-status
@@ -35,6 +35,7 @@ mapfile -t pending < <(find inbox -maxdepth 1 -type f \
! -name '.gitkeep' \
! -name 'lint-followups.org' \
! -name 'PROCESSED-*' \
+ ! -name '.inbox-send-*' \
-printf '%f\n' 2>/dev/null | sort)
n=${#pending[@]}
diff --git a/claude-templates/.ai/scripts/lint-org.el b/claude-templates/.ai/scripts/lint-org.el
index 5447cb3..33dc52f 100644
--- a/claude-templates/.ai/scripts/lint-org.el
+++ b/claude-templates/.ai/scripts/lint-org.el
@@ -2,16 +2,19 @@
;;
;; Usage:
;; emacs --batch -q -l lint-org.el FILE.org [FILE.org ...]
+;; report only (the default) — categorize without modifying the file.
+;; A linter reports, it doesn't write; mutation requires --fix.
+;; --check is accepted as an explicit alias of this default.
+;;
+;; emacs --batch -q -l lint-org.el --fix FILE.org [FILE.org ...]
;; apply mechanical fixes in place, emit judgment items on stdout for the
;; command layer to walk
;;
-;; emacs --batch -q -l lint-org.el --check FILE.org [FILE.org ...]
-;; report only — categorize without modifying the file
-;;
-;; emacs --batch -q -l lint-org.el --followups-file=PATH FILE.org
+;; emacs --batch -q -l lint-org.el --fix --followups-file=PATH FILE.org
;; apply mechanical fixes; if any judgment items remain, append them to
;; PATH as an org section dated today. Used by wrap-it-up to defer the
;; judgment walk to the next morning's review without blocking the wrap.
+;; (--followups-file only writes in --fix mode.)
;;
;; Mechanical categories (auto-fixed):
;; item-number add [@N] directive to drifted bullets
@@ -35,6 +38,9 @@
;; 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
+;; task-missing-last-reviewed open level-2 task with no :LAST_REVIEWED:
+;; subtask-done-not-dated level-3+ done sub-task still a DONE keyword
+;; dated-log-heading-active-timestamp dated-log heading with a live SCHEDULED/DEADLINE
;; (anything else) surfaced as judgment with checker name
;;
;; Output format on stdout:
@@ -65,9 +71,23 @@
Each plist has :kind (mechanical-fixed | judgment), :line, :checker, :msg.
Mechanical entries from --check mode also carry :preview t.")
(defvar lo-check-only nil
- "Non-nil means run in report-only mode — no buffer writes.")
+ "Non-nil means run in report-only mode — no buffer writes.
+The CLI defaults this to t (a linter reports, it doesn't write);
+`--fix' is what enables writes on a command-line run.")
(defvar lo-current-file nil
"Path of the file currently being processed.")
+
+(defun lo--spec-file-p ()
+ "Non-nil when the current file lives under a docs/specs/ directory.
+The four todo-format-family checkers encode todo.org completion conventions
+and misfire on a spec: a spec's Decisions section legitimately carries a
+level-2 DONE with no CLOSED cookie, and its review-history section carries
+level-2 dated headings. docs/specs/ is the canonical spec home per the
+docs-lifecycle rule, so a path segment match is the scope test. Link,
+table, and structural checks still run on specs — only the todo-format
+family is scoped out."
+ (and lo-current-file
+ (string-match-p "/docs/specs/" (expand-file-name lo-current-file))))
(defvar lo-followups-file nil
"When non-nil, after a non-check run any judgment items are appended to this
path as an org section dated today. The file is created if missing.")
@@ -286,6 +306,52 @@ Craig-specific annotation marker rather than Babel src-block syntax."
(lo--goto-line line)
(looking-at-p "^[ \t]*#\\+begin_src[ \t]+cj:")))
+(defvar-local lo--matched-blocks-cache nil
+ "Cons of (TICK . REGIONS) memoizing `lo--matched-block-regions'.
+TICK is the `buffer-chars-modified-tick' the regions were computed at, so a
+fix applied mid-pass invalidates them.")
+
+(defun lo--matched-block-regions ()
+ "Return ((BEGIN-LINE . END-LINE) ...) for every correctly paired block.
+Scans lines directly rather than asking org, because org's own parser is what
+mis-reads these blocks: a heading-shaped line inside a verbatim body reads as a
+structural break and loses the open block. The scan applies org's real rule —
+once a block is open, only its own `#+end_TYPE' closes it, so a nested
+`#+begin_' or a foreign `#+end_' in the body is just text."
+ (let ((tick (buffer-chars-modified-tick)))
+ (if (eql (car lo--matched-blocks-cache) tick)
+ (cdr lo--matched-blocks-cache)
+ (let ((case-fold-search t)
+ (regions nil) (open-type nil) (open-line nil) (line 0))
+ (save-excursion
+ (goto-char (point-min))
+ (while (not (eobp))
+ (setq line (1+ line))
+ (let ((text (buffer-substring-no-properties
+ (line-beginning-position) (line-end-position))))
+ (cond
+ (open-type
+ (when (string-match
+ (format "\\`[ \t]*#\\+end_%s[ \t]*\\'"
+ (regexp-quote open-type))
+ text)
+ (push (cons open-line line) regions)
+ (setq open-type nil open-line nil)))
+ ((string-match "\\`[ \t]*#\\+begin_\\([^ \t\n]+\\)" text)
+ (setq open-type (match-string 1 text)
+ open-line line))))
+ (forward-line 1)))
+ (setq lo--matched-blocks-cache (cons tick (nreverse regions)))
+ (cdr lo--matched-blocks-cache)))))
+
+(defun lo--in-matched-block-p (line)
+ "Non-nil when LINE sits within a correctly paired block, delimiters included.
+org-lint reports `invalid-block' at the delimiter lines themselves, so the
+range has to be inclusive for the suppression to reach them."
+ (cl-some (lambda (region)
+ (and (>= line (car region)) (<= line (cdr region))))
+ (lo--matched-block-regions)))
+
(defun lo--handle-item (item)
(let ((name (lo--checker-name item))
(line (lo--line item))
@@ -298,6 +364,13 @@ Craig-specific annotation marker rather than Babel src-block syntax."
wrong-header-argument))
(lo--cj-comment-block-opener-p line))
nil)
+ ;; `invalid-block' on a block that is in fact correctly paired — the
+ ;; checker is org-lint's own, so this filters its output rather than
+ ;; fixing a local checker. A genuinely unterminated block isn't in any
+ ;; matched region, so it still reports.
+ ((and (eq name 'invalid-block)
+ (lo--in-matched-block-p line))
+ nil)
((eq name 'item-number)
(lo--apply-or-preview name line msg #'lo-fix-item-number))
((eq name 'missing-language-in-src-block)
@@ -354,24 +427,40 @@ logical row, matching wrap-org-table.el's grouping."
(defun lo--check-tables ()
"Scan the current buffer for org tables violating the table standard.
-Emits one judgment item per violating table."
+Emits one judgment item per violating table. Pipe-led lines inside
+#+begin_/#+end_ blocks are content (ASCII art, shell pipes), not tables,
+and are skipped — the same block rule `wot-process-file' applies."
(save-excursion
(goto-char (point-min))
- (while (re-search-forward "^[ \t]*|" nil t)
- (let ((start-line (line-number-at-pos))
- (lines nil))
- (beginning-of-line)
- (while (and (not (eobp)) (looking-at "[ \t]*|"))
- (push (buffer-substring-no-properties (line-beginning-position)
- (line-end-position))
- lines)
+ (let ((in-block nil)) ; the open block's type, e.g. "example" — nil outside
+ (while (not (eobp))
+ (cond
+ ;; Type-matched close only: literal #+end_src quoted inside an
+ ;; example block must not clear the flag (see wot-process-file).
+ ((and (not in-block)
+ (looking-at "^[ \t]*#\\+begin_\\([^ \t\n]+\\)"))
+ (setq in-block (downcase (match-string 1)))
+ (forward-line 1))
+ ((and in-block
+ (looking-at-p (format "^[ \t]*#\\+end_%s\\([ \t]\\|$\\)"
+ (regexp-quote in-block))))
+ (setq in-block nil)
(forward-line 1))
- (let ((violations (lo--table-violations (nreverse lines))))
- (when violations
- (lo--emit-judgment
- 'org-table-standard start-line
- (format "table violates the org-table standard: %s — wrap-org-table.el reflows it"
- (string-join violations "; ")))))))))
+ ((and (not in-block) (looking-at-p "^[ \t]*|"))
+ (let ((start-line (line-number-at-pos))
+ (lines nil))
+ (while (and (not (eobp)) (looking-at "[ \t]*|"))
+ (push (buffer-substring-no-properties (line-beginning-position)
+ (line-end-position))
+ lines)
+ (forward-line 1))
+ (let ((violations (lo--table-violations (nreverse lines))))
+ (when violations
+ (lo--emit-judgment
+ 'org-table-standard start-line
+ (format "table violates the org-table standard: %s — wrap-org-table.el reflows it"
+ (string-join violations "; ")))))))
+ (t (forward-line 1)))))))
;;; ---------------------------------------------------------------------------
;;; level-2 dated-header check (claude-rules/todo-format.md)
@@ -503,6 +592,103 @@ 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"))))))))
;;; ---------------------------------------------------------------------------
+;;; task-missing-last-reviewed check (claude-rules/todo-format.md)
+;;
+;; A task is stamped `:LAST_REVIEWED:' when it is *created*, not a review cycle
+;; later. An agent filing a task has just written its body and graded its
+;; priority, which is a review by any honest reading — so a fresh task that
+;; carries no stamp reads as "never reviewed" and lands at the top of the next
+;; staleness batch, where re-reviewing it is pure ceremony. Every task filed
+;; during the 2026-07-23 sweep hit exactly that, which is what prompted the rule.
+;;
+;; Judgment-only, deliberately. The stamp's whole value is that its date is
+;; true, and nothing here can know when an unstamped task was actually last
+;; looked at. Auto-stamping today's date would convert a "nobody has reviewed
+;; this" signal into a false "reviewed today" one — worse than the gap it
+;; closes. Flag it; a human or the filing workflow supplies the honest date.
+;;
+;; Scope matches `task-review-staleness.sh' exactly (level-2, open keyword,
+;; priority cookie), so the checker and the staleness count never disagree
+;; about which headings are in the review pool.
+
+(defun lo--check-task-missing-last-reviewed ()
+ "Flag an open level-2 task with a priority cookie and no `:LAST_REVIEWED:'."
+ (save-excursion
+ (goto-char (point-min))
+ (let ((case-fold-search nil))
+ (while (re-search-forward "^\\*\\* \\(TODO\\|DOING\\|VERIFY\\) \\[#[A-D]\\]" nil t)
+ (let ((hline (line-number-at-pos))
+ (entry-end (save-excursion (outline-next-heading) (point))))
+ (save-excursion
+ (forward-line 1)
+ (unless (re-search-forward "^[ \t]*:LAST_REVIEWED:[ \t]*[[0-9]"
+ entry-end t)
+ (lo--emit-judgment
+ 'task-missing-last-reviewed hline
+ "task has no :LAST_REVIEWED: — stamp it at creation with today's date (a task you just wrote and graded is reviewed); otherwise it enters the next staleness batch as never-reviewed"))))))))
+
+;;; ---------------------------------------------------------------------------
+;;; 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")))))
+
+;;; ---------------------------------------------------------------------------
+;;; dated-log heading with a stale active planning timestamp (todo-format.md)
+;;
+;; The mechanical backstop for the planning-line-strip rule. A dated event-log
+;; heading (`<stars> YYYY-MM-DD Day @ ...', no TODO keyword) records completed
+;; work — its date lives in the heading. An active `<...>' SCHEDULED or DEADLINE
+;; left on it pins the entry to the agenda forever: org renders any headline with
+;; an active planning timestamp, keyword or not, so a stale SCHEDULED shows as
+;; weeks-overdue long after the work is done. Invisible to a keyword scan (no
+;; TODO) and it survives --archive-done, so nothing else catches it. The
+;; completion rewrite and todo-cleanup --convert-subtasks now strip the planning
+;; line; this flags any that slipped through before that landed, the same way
+;; subtask-done-not-dated backstops the depth rule. Judgment-only.
+
+(defun lo--check-dated-log-active-timestamp ()
+ "Flag a dated event-log heading that still carries an active SCHEDULED/DEADLINE.
+The heading matches `<stars> YYYY-MM-DD Day @ ...' with no TODO keyword; an
+active `<...>' planning timestamp in its entry is the defect. An inactive
+`[...]' timestamp is ignored (org doesn't render it on the agenda). Emits one
+judgment item per offending heading (checker `dated-log-heading-active-timestamp')."
+ (save-excursion
+ (goto-char (point-min))
+ (let ((case-fold-search nil))
+ (while (re-search-forward
+ "^\\*+ [0-9]\\{4\\}-[0-9]\\{2\\}-[0-9]\\{2\\} [A-Za-z]+ @ " nil t)
+ (let ((hline (line-number-at-pos))
+ (entry-end (save-excursion (outline-next-heading) (point))))
+ (save-excursion
+ (forward-line 1)
+ (when (re-search-forward
+ "^[ \t]*\\(?:SCHEDULED\\|DEADLINE\\):[ \t]*<" entry-end t)
+ (lo--emit-judgment
+ 'dated-log-heading-active-timestamp hline
+ "dated-log heading carries an active SCHEDULED/DEADLINE — org renders any active planning timestamp (keyword or not), so it stays on the agenda as weeks-overdue; delete the planning line (todo-format.md)"))))))))
+
+;;; ---------------------------------------------------------------------------
;;; File processing
(defun lo--backup (file)
@@ -536,13 +722,22 @@ left unmodified and mechanical entries are recorded with :preview t."
;; After org-lint items: the custom table-standard scan. Runs on the
;; post-fix buffer; judgment-only, so order doesn't perturb fixes.
(lo--check-tables)
- ;; Same shape: flag level-2 dated headers (completion defects).
- (lo--check-level2-dated-headers)
- ;; Structural heading defects org-lint doesn't cover.
+ ;; Structural heading defects org-lint doesn't cover. These run on
+ ;; every org file, specs included.
(lo--check-indented-headings)
(lo--check-empty-headings)
(lo--check-malformed-priority-cookies)
- (lo--check-level2-done-without-closed)
+ ;; The todo-format family encodes todo.org completion conventions and
+ ;; misfires on a spec (a Decisions section's undated DONE, a
+ ;; review-history dated heading, a phases task with no LAST_REVIEWED).
+ ;; Scope them out of docs/specs/; link, table, and structural checks
+ ;; above still run there.
+ (unless (lo--spec-file-p)
+ (lo--check-level2-dated-headers)
+ (lo--check-level2-done-without-closed)
+ (lo--check-task-missing-last-reviewed)
+ (lo--check-subtask-done-not-dated)
+ (lo--check-dated-log-active-timestamp))
(when (and (not lo-check-only) (buffer-modified-p))
(save-buffer)))
(with-current-buffer buf (set-buffer-modified-p nil))
@@ -649,6 +844,13 @@ After printing, also append judgments to `lo-followups-file' when set."
;;; CLI
(defun lo-main ()
+ ;; Report-only is the CLI default; --fix is the only way a command-line run
+ ;; writes to disk. The old mutate-by-default reformatted five files in one
+ ;; pass before anyone confirmed anything (work project, 2026-07-09).
+ (setq lo-check-only t)
+ (when (member "--fix" command-line-args-left)
+ (setq lo-check-only nil)
+ (setq command-line-args-left (delete "--fix" command-line-args-left)))
(when (member "--check" command-line-args-left)
(setq lo-check-only t)
(setq command-line-args-left (delete "--check" command-line-args-left)))
@@ -660,7 +862,7 @@ After printing, also append judgments to `lo-followups-file' when set."
(setq command-line-args-left (delete followups command-line-args-left))))
(if (null command-line-args-left)
(progn
- (princ "Usage: emacs --batch -q -l lint-org.el [--check] [--followups-file=PATH] FILE.org ...\n")
+ (princ "Usage: emacs --batch -q -l lint-org.el [--fix] [--check] [--followups-file=PATH] FILE.org ...\n")
(kill-emacs 1))
(let ((files command-line-args-left))
(setq command-line-args-left nil)
@@ -679,7 +881,7 @@ this file without firing the CLI dispatch — under `ert-run-tests-batch-and-exi
the trailing args are things like `-f ert-run-tests-batch-and-exit'."
(and command-line-args-left
(cl-every (lambda (a)
- (cond ((member a '("--check")) t)
+ (cond ((member a '("--check" "--fix")) t)
((string-prefix-p "--followups-file=" a) t)
((string-prefix-p "-" a) nil)
(t (file-readable-p a))))
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/route_recommend.py b/claude-templates/.ai/scripts/route_recommend.py
index 7b36405..12ab132 100644
--- a/claude-templates/.ai/scripts/route_recommend.py
+++ b/claude-templates/.ai/scripts/route_recommend.py
@@ -71,6 +71,15 @@ def recommend(item: str, projects: list[str]) -> tuple[str | None, str]:
if not projects:
return (None, "none")
+ # Collapse identical names first. Projects are addressed by bare basename, so
+ # two projects sharing one across roots (~/code/notes, ~/projects/notes) arrive
+ # twice; both literal-match, and the tie test below then read that as ambiguity
+ # and downgraded a correct strong match to weak. Deduping here rather than in
+ # discover_destination_names protects every caller of the pure core, not just
+ # the CLI path. Order-preserving, and it collapses only identical names — two
+ # *different* projects matching is real ambiguity and still downgrades.
+ projects = list(dict.fromkeys(projects))
+
item_lower = item.lower()
item_tokens = _tokens(item)
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/task-review-staleness.sh b/claude-templates/.ai/scripts/task-review-staleness.sh
index ed43712..50e0257 100755
--- a/claude-templates/.ai/scripts/task-review-staleness.sh
+++ b/claude-templates/.ai/scripts/task-review-staleness.sh
@@ -19,9 +19,14 @@
# deeper headings, and cookie-less headings are not review units.
#
# A task is stale (count mode), and sorts oldest (list mode), when its
-# :LAST_REVIEWED: property is missing or unparseable (NIL sorts first), or
-# when its age strictly exceeds the threshold (age > N days; age == N is
-# still fresh).
+# :LAST_REVIEWED: property is missing (NIL sorts first) or when its age
+# strictly exceeds the threshold (age > N days; age == N is still fresh).
+#
+# :LAST_REVIEWED: accepts a bare date (2026-07-09) or an org-native
+# timestamp ([2026-07-09 Thu] or <2026-07-09 Thu ...>); both normalize to
+# the ISO date. A value that is present but parses to neither is a data
+# error: the script warns loudly to stderr (file:line:value) and leaves it
+# out of the stale count rather than silently treating it as never-reviewed.
set -euo pipefail
@@ -46,7 +51,7 @@ num="$2"
# only the property drawer between a qualifying heading and the next heading
# is scanned.
extract_tasks() {
- awk '
+ awk -v fname="$todo_file" '
function flush() {
if (in_task) printf "%s\t%s\t%s\n", hline, (have_lr ? lr : "NONE"), heading
}
@@ -65,7 +70,21 @@ extract_tasks() {
v = $0
sub(/^[ \t]*:LAST_REVIEWED:[ \t]*/, "", v)
sub(/[ \t]*$/, "", v)
- lr = v; have_lr = 1
+ raw = v
+ # Accept org-native stamps: strip a leading [ or < (inactive/active
+ # timestamp bracket) and take the leading ISO date, so [2026-07-09 Thu]
+ # and 2026-07-09 both normalize to 2026-07-09.
+ sub(/^[[<]/, "", v)
+ if (match(v, /^[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]/)) {
+ lr = substr(v, RSTART, RLENGTH); have_lr = 1
+ } else {
+ # Present but unparseable — a data error, not "never reviewed". Warn
+ # loudly (file:line:value) instead of silently folding it into the
+ # stale count, where a re-review in the same bad format would never
+ # drop the number and nothing would explain why.
+ printf "task-review-staleness: %s:%d: unparseable :LAST_REVIEWED: %s (expected YYYY-MM-DD or [YYYY-MM-DD Day])\n", fname, NR, raw > "/dev/stderr"
+ lr = "INVALID"; have_lr = 1
+ }
next
}
END { flush() }
@@ -98,9 +117,16 @@ while IFS=$'\t' read -r hline value heading; do
continue
fi
- # Unparseable date → treat as NIL (stale).
+ # Malformed stamp: extract_tasks already warned. A data error is not
+ # "never reviewed", so it stays out of the stale count.
+ if [ "$value" = "INVALID" ]; then
+ continue
+ fi
+
+ # A shape-valid but impossible date (e.g. 2026-13-45) slips past the awk
+ # regex; warn and skip rather than silently counting it.
if ! rev_epoch=$(date -d "$value" +%s 2>/dev/null); then
- count=$((count + 1))
+ echo "task-review-staleness: $todo_file: unparseable :LAST_REVIEWED: $value" >&2
continue
fi
diff --git a/claude-templates/.ai/scripts/tests/agent-lock.bats b/claude-templates/.ai/scripts/tests/agent-lock.bats
new file mode 100644
index 0000000..dbcffe1
--- /dev/null
+++ b/claude-templates/.ai/scripts/tests/agent-lock.bats
@@ -0,0 +1,214 @@
+#!/usr/bin/env bats
+#
+# Tests for claude-templates/.ai/scripts/agent-lock — a mkdir-atomic advisory
+# lock helper for agent workflows (sentry's single-runner and roam-write
+# locks). flock can't span an agent's tool calls: every Bash call is its own
+# short-lived shell, so a flock dies with the call that took it. This helper
+# persists the lock on disk between calls and self-clears after a crash via
+# age-based staleness reclaim.
+#
+# Contract under test:
+# agent-lock acquire <name> [--ttl=SECONDS] [--wait[=SECONDS]]
+# exit 0 → acquired (fresh, or reclaimed from a stale prior holder).
+# exit 1 → busy: a live lock holds <name>; deferred (note on stderr).
+# exit 2 → usage error (bad/absent name, unknown subcommand).
+# agent-lock refresh <name> → re-touch a held lock (heartbeat); exit 1 if absent.
+# agent-lock release <name> → remove the lock; idempotent (exit 0 if already free).
+# agent-lock status <name> → print free|held|stale + metadata; exit 0 (query).
+# agent-lock path <name> → print the resolved lock dir path; does not create it.
+#
+# Staleness is age-based on the metadata file's mtime versus the lock's own
+# recorded TTL, so a crashed holder's lock expires instead of wedging every
+# later acquire. Heartbeat (refresh) re-touches the mtime, keeping a live
+# holder's lock young. Every reclaim surfaces a note (never silent).
+#
+# Lock home: /run/user/<uid>/agent-locks/<name>/ (tmpfs: host-local, out of
+# every repo, cleared on reboot), with ~/.cache/agent-locks/ as the fallback
+# where no runtime dir exists. AGENT_LOCK_DIR overrides the base for tests and
+# advanced callers; the helper otherwise owns the path scheme and callers pass
+# only names.
+#
+# Strategy: AGENT_LOCK_DIR points every lock at a temp base, so tests never
+# touch a real runtime dir. Staleness is exercised by aging the metadata
+# file's mtime with `touch` rather than sleeping.
+
+SCRIPT="$(cd "$(dirname "$BATS_TEST_FILENAME")/.." && pwd)/agent-lock"
+BASH_BIN="$(command -v bash)"
+
+setup() {
+ TEST_DIR="$(mktemp -d -t agent-lock-bats.XXXXXX)"
+ LOCK_BASE="$TEST_DIR/locks"
+}
+
+teardown() {
+ rm -rf "$TEST_DIR"
+}
+
+lock() {
+ run env AGENT_LOCK_DIR="$LOCK_BASE" "$BASH_BIN" "$SCRIPT" "$@"
+}
+
+# meta-file path for a lock name, for direct inspection / aging.
+meta_of() {
+ printf '%s/%s/meta\n' "$LOCK_BASE" "$1"
+}
+
+# ---- acquire: fresh win + metadata --------------------------------------
+
+@test "acquire: fresh name wins (exit 0) and writes pid/host/timestamp/ttl" {
+ lock acquire job
+ [ "$status" -eq 0 ]
+ local meta; meta="$(meta_of job)"
+ [ -f "$meta" ]
+ grep -q "^pid=$$\|^pid=[0-9][0-9]*$" "$meta"
+ grep -q "^host=$(uname -n)$" "$meta"
+ grep -qE "^acquired=[0-9]{4}-[0-9]{2}-[0-9]{2}T" "$meta"
+ grep -qE "^ttl=[0-9]+$" "$meta"
+}
+
+@test "acquire: honors an explicit --ttl in the metadata" {
+ lock acquire job --ttl=45
+ [ "$status" -eq 0 ]
+ grep -q "^ttl=45$" "$(meta_of job)"
+}
+
+# ---- acquire: contention (one winner) -----------------------------------
+
+@test "acquire: a second acquire of a live lock defers (exit 1, note)" {
+ lock acquire job
+ [ "$status" -eq 0 ]
+ lock acquire job
+ [ "$status" -eq 1 ]
+ [[ "$output" == *job* ]]
+}
+
+@test "acquire: two racing acquires yield exactly one winner" {
+ # Fire both without releasing; exactly one mkdir wins.
+ env AGENT_LOCK_DIR="$LOCK_BASE" "$BASH_BIN" "$SCRIPT" acquire race & p1=$!
+ env AGENT_LOCK_DIR="$LOCK_BASE" "$BASH_BIN" "$SCRIPT" acquire race & p2=$!
+ local r1=0 r2=0
+ wait $p1 || r1=$?
+ wait $p2 || r2=$?
+ # One exits 0 (won), one exits 1 (deferred).
+ [ "$((r1 + r2))" -eq 1 ]
+}
+
+# ---- release: frees the lock --------------------------------------------
+
+@test "release: frees a held lock so the next acquire wins" {
+ lock acquire job
+ [ "$status" -eq 0 ]
+ lock release job
+ [ "$status" -eq 0 ]
+ [ ! -d "$LOCK_BASE/job" ]
+ lock acquire job
+ [ "$status" -eq 0 ]
+}
+
+@test "release: is idempotent on an already-free lock (exit 0)" {
+ lock release never-held
+ [ "$status" -eq 0 ]
+}
+
+# ---- staleness reclaim (surfaced, never silent) -------------------------
+
+@test "acquire: reclaims a stale lock and surfaces the reclaim note" {
+ lock acquire job --ttl=1
+ [ "$status" -eq 0 ]
+ # Age the metadata mtime well past the 1s TTL.
+ touch -d '1 hour ago' "$(meta_of job)"
+ lock acquire job --ttl=1
+ [ "$status" -eq 0 ]
+ [[ "$output" == *reclaim* ]]
+ [[ "$output" == *job* ]]
+ # The reclaim installed fresh metadata (young again), not the aged holder's.
+ lock status job
+ [[ "$output" == *held* ]]
+ [[ "$output" != *stale* ]]
+}
+
+@test "acquire: a lock inside its TTL is not stale (stays deferred)" {
+ lock acquire job --ttl=3600
+ [ "$status" -eq 0 ]
+ lock acquire job --ttl=3600
+ [ "$status" -eq 1 ]
+}
+
+# ---- heartbeat (refresh keeps a live lock young) ------------------------
+
+@test "refresh: re-touches a held lock so it is no longer stale" {
+ lock acquire job --ttl=1
+ [ "$status" -eq 0 ]
+ touch -d '1 hour ago' "$(meta_of job)"
+ lock status job
+ [[ "$output" == *stale* ]]
+ lock refresh job
+ [ "$status" -eq 0 ]
+ lock status job
+ [[ "$output" == *held* ]]
+ [[ "$output" != *stale* ]]
+}
+
+@test "refresh: an absent lock cannot be refreshed (exit 1)" {
+ lock refresh nothing
+ [ "$status" -eq 1 ]
+}
+
+# ---- status query -------------------------------------------------------
+
+@test "status: reports free for an unheld lock (exit 0)" {
+ lock status job
+ [ "$status" -eq 0 ]
+ [[ "$output" == *free* ]]
+}
+
+@test "status: reports held with metadata for a live lock" {
+ lock acquire job --ttl=3600
+ lock status job
+ [ "$status" -eq 0 ]
+ [[ "$output" == *held* ]]
+ [[ "$output" == *"host=$(uname -n)"* ]]
+}
+
+# ---- path resolution: runtime dir home with cache fallback --------------
+
+@test "path: resolves under AGENT_LOCK_DIR when set" {
+ lock path job
+ [ "$status" -eq 0 ]
+ [ "$output" = "$LOCK_BASE/job" ]
+ [ ! -d "$LOCK_BASE/job" ] # path does not create the lock
+}
+
+@test "path: prefers the runtime dir home when no override is set" {
+ local rt="$TEST_DIR/run"
+ mkdir -p "$rt"
+ run env -u AGENT_LOCK_DIR XDG_RUNTIME_DIR="$rt" "$BASH_BIN" "$SCRIPT" path job
+ [ "$status" -eq 0 ]
+ [ "$output" = "$rt/agent-locks/job" ]
+}
+
+@test "path: falls back to the cache home when no runtime dir exists" {
+ local home="$TEST_DIR/home"
+ mkdir -p "$home"
+ run env -u AGENT_LOCK_DIR -u XDG_RUNTIME_DIR -u XDG_CACHE_HOME \
+ HOME="$home" "$BASH_BIN" "$SCRIPT" path job
+ [ "$status" -eq 0 ]
+ [ "$output" = "$home/.cache/agent-locks/job" ]
+}
+
+# ---- usage errors -------------------------------------------------------
+
+@test "usage: a missing name is a usage error (exit 2)" {
+ lock acquire
+ [ "$status" -eq 2 ]
+}
+
+@test "usage: a name with a slash is rejected (exit 2)" {
+ lock acquire bad/name
+ [ "$status" -eq 2 ]
+}
+
+@test "usage: an unknown subcommand is a usage error (exit 2)" {
+ lock frobnicate job
+ [ "$status" -eq 2 ]
+}
diff --git a/claude-templates/.ai/scripts/tests/flashcard-sync.bats b/claude-templates/.ai/scripts/tests/flashcard-sync.bats
index 608a280..e6ffc21 100644
--- a/claude-templates/.ai/scripts/tests/flashcard-sync.bats
+++ b/claude-templates/.ai/scripts/tests/flashcard-sync.bats
@@ -6,6 +6,7 @@
setup() {
SCRIPT_DIR="$(cd "$(dirname "$BATS_TEST_FILENAME")/.." && pwd)"
SYNC="$SCRIPT_DIR/flashcard-sync"
+ STATS="$SCRIPT_DIR/flashcard-stats.py"
TMP="$(mktemp -d)"
}
@@ -36,3 +37,27 @@ EOF
[ "$status" -eq 1 ]
[ ! -f "$HOME/sync/phone/anki/dirty.apkg" ]
}
+
+@test "flashcard-stats: a multi-tagged :fundamental:drill: card still counts" {
+ # Regression guard: a curated card carrying a second org tag must not drop
+ # from the count. A :drill:$ anchor would have counted only one card here.
+ cat > "$TMP/multitag.org" <<'EOF'
+#+TITLE: Multitag Test
+
+* Orbital Regimes
+** What is LEO? :fundamental:drill:
+:PROPERTIES:
+:ID: c1
+:END:
+Low Earth Orbit is the region below about 2000 kilometers.
+** What is GEO? :drill:
+:PROPERTIES:
+:ID: c2
+:END:
+Geostationary orbit sits at roughly 35786 kilometers of altitude.
+EOF
+ run python3 "$STATS" "$TMP/multitag.org"
+ [ "$status" -eq 0 ]
+ [[ "$output" == *"Cards: 2"* ]]
+ [[ "$output" == *clean* ]]
+}
diff --git a/claude-templates/.ai/scripts/tests/inbox-status.bats b/claude-templates/.ai/scripts/tests/inbox-status.bats
index bc8a734..27a497e 100644
--- a/claude-templates/.ai/scripts/tests/inbox-status.bats
+++ b/claude-templates/.ai/scripts/tests/inbox-status.bats
@@ -45,6 +45,18 @@ teardown() {
[[ "$output" == *"0 pending"* ]]
}
+@test "inbox-status: ignores an in-flight .inbox-send-* temp file" {
+ mkdir "$TMP/inbox"
+ # inbox-send writes to a .inbox-send-* temp then renames it into place;
+ # during that window the temp must not read as a pending handoff, or a
+ # concurrent boundary check blocks on a file that's about to become real.
+ touch "$TMP/inbox/.inbox-send-abc123.org"
+ cd "$TMP"
+ run "$SCRIPT"
+ [ "$status" -eq 0 ]
+ [[ "$output" == *"0 pending"* ]]
+}
+
@test "inbox-status: -q suppresses the per-item lines" {
mkdir "$TMP/inbox"
echo body > "$TMP/inbox/handoff.org"
diff --git a/claude-templates/.ai/scripts/tests/lint-org-cli.bats b/claude-templates/.ai/scripts/tests/lint-org-cli.bats
index d457696..b9faef6 100644
--- a/claude-templates/.ai/scripts/tests/lint-org-cli.bats
+++ b/claude-templates/.ai/scripts/tests/lint-org-cli.bats
@@ -20,6 +20,24 @@ teardown() {
[[ "$output" == *"lint-org: file="* ]]
}
+@test "lint-org.el default invocation is report-only — file untouched" {
+ # bare #+begin_src is a mechanical fix (→ #+begin_example) that the old
+ # default applied on disk; a linter reports, it doesn't write
+ printf '* H\n\n#+begin_src\nx\n#+end_src\n' > "$TMPFILE"
+ before="$(cat "$TMPFILE")"
+ run emacs --batch -q -l "$SCRIPTS_DIR/lint-org.el" "$TMPFILE"
+ [ "$status" -eq 0 ]
+ [[ "$output" == *"would-fix"* ]]
+ [ "$(cat "$TMPFILE")" = "$before" ]
+}
+
+@test "lint-org.el --fix applies mechanical fixes on disk" {
+ printf '* H\n\n#+begin_src\nx\n#+end_src\n' > "$TMPFILE"
+ run emacs --batch -q -l "$SCRIPTS_DIR/lint-org.el" --fix "$TMPFILE"
+ [ "$status" -eq 0 ]
+ grep -q '#+begin_example' "$TMPFILE"
+}
+
@test "wrap-org-table.el loads and runs without -L on the load path" {
run emacs --batch -q -l "$SCRIPTS_DIR/wrap-org-table.el" --width=120 "$TMPFILE"
[ "$status" -eq 0 ]
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/task-review-staleness.bats b/claude-templates/.ai/scripts/tests/task-review-staleness.bats
index 488b023..79aad79 100644
--- a/claude-templates/.ai/scripts/tests/task-review-staleness.bats
+++ b/claude-templates/.ai/scripts/tests/task-review-staleness.bats
@@ -49,6 +49,16 @@ task_unreviewed() {
printf '** %s [#%s] %s\nBody.\n\n' "$keyword" "$prio" "$title" >> "$TODO"
}
+# Emit a qualifying task whose LAST_REVIEWED is an org-native inactive
+# timestamp — [YYYY-MM-DD Day] — matching the CREATED:/CLOSED: cookies that
+# sit in the same drawer. The date is derived from an ISO date via `date`.
+task_reviewed_org() {
+ local keyword="$1" prio="$2" title="$3" isodate="$4"
+ local org="[$(date -d "$isodate" '+%F %a')]"
+ printf '** %s [#%s] %s\n:PROPERTIES:\n:LAST_REVIEWED: %s\n:END:\nBody.\n\n' \
+ "$keyword" "$prio" "$title" "$org" >> "$TODO"
+}
+
# ---- Normal cases ----------------------------------------------------
@test "staleness: empty file reports zero" {
@@ -85,6 +95,20 @@ task_unreviewed() {
[ "$output" = "2" ]
}
+@test "staleness: org-native bracketed LAST_REVIEWED parses — recent is fresh" {
+ task_reviewed_org TODO A "Reviewed five days ago, org stamp" "$D5"
+ run bash "$SCRIPT" "$TODO" 30
+ [ "$status" -eq 0 ]
+ [ "$output" = "0" ]
+}
+
+@test "staleness: org-native bracketed LAST_REVIEWED parses — old is stale" {
+ task_reviewed_org TODO A "Reviewed forty days ago, org stamp" "$D40"
+ run bash "$SCRIPT" "$TODO" 30
+ [ "$status" -eq 0 ]
+ [ "$output" = "1" ]
+}
+
# ---- Boundary cases --------------------------------------------------
@test "staleness: age exactly equal to threshold is fresh" {
@@ -136,9 +160,23 @@ task_unreviewed() {
[ "$output" = "0" ]
}
-@test "staleness: malformed LAST_REVIEWED is treated as stale" {
+@test "staleness: malformed LAST_REVIEWED warns to stderr and is not counted" {
task_reviewed TODO A "Bad date" "not-a-date"
- run bash "$SCRIPT" "$TODO" 30
+ # stdout carries only the count — the malformed stamp is not folded in.
+ run bash -c "bash '$SCRIPT' '$TODO' 30 2>/dev/null"
+ [ "$status" -eq 0 ]
+ [ "$output" = "0" ]
+ # stderr carries the loud warning naming the offending value.
+ run bash -c "bash '$SCRIPT' '$TODO' 30 2>&1 1>/dev/null"
+ [ "$status" -eq 0 ]
+ [[ "$output" == *"not-a-date"* ]]
+ [[ "$output" == *"LAST_REVIEWED"* ]]
+}
+
+@test "staleness: malformed stamp is excluded while real stale tasks still count" {
+ task_reviewed TODO A "Real stale" "$D40"
+ task_reviewed TODO B "Broken stamp" "garbage"
+ run bash -c "bash '$SCRIPT' '$TODO' 30 2>/dev/null"
[ "$status" -eq 0 ]
[ "$output" = "1" ]
}
@@ -161,6 +199,17 @@ task_unreviewed() {
[[ "${lines[2]}" == *"Reviewed recently"* ]]
}
+@test "staleness --list: org-native bracketed stamp sorts by its real date" {
+ task_reviewed TODO A "Bare recent" "$D5"
+ task_reviewed_org TODO B "Org-stamped old" "$D40"
+ run bash "$SCRIPT" --list "$TODO" 10
+ [ "$status" -eq 0 ]
+ # The org-bracketed old stamp must sort ahead of the bare recent one —
+ # proof it parsed to a real date rather than falling to 0000-00-00.
+ [[ "${lines[0]}" == *"Org-stamped old"* ]]
+ [[ "${lines[1]}" == *"Bare recent"* ]]
+}
+
@test "staleness --list: takes only the requested count" {
task_unreviewed TODO A "First"
task_reviewed TODO B "Second" "$D40"
diff --git a/claude-templates/.ai/scripts/tests/test-lint-org.el b/claude-templates/.ai/scripts/tests/test-lint-org.el
index 3b8a9bb..ceee209 100644
--- a/claude-templates/.ai/scripts/tests/test-lint-org.el
+++ b/claude-templates/.ai/scripts/tests/test-lint-org.el
@@ -193,6 +193,65 @@ real suspicious-language warning here
#+end_src
")
+;; invalid-block, false-positive case — a correctly paired example block whose
+;; body holds a heading-shaped line. org's parser reads the `** ' inside the
+;; verbatim body as a structural break, loses the open block, and flags BOTH
+;; delimiters as "Possible incomplete block".
+(defconst lo-test--verbatim-heading-block "\
+* Heading
+
+#+begin_example
+** Feature Name or Topic
+Body line.
+#+end_example
+
+Trailing prose.
+")
+
+;; invalid-block, literal-delimiter case — a paired src block whose body holds
+;; a literal `#+end_example' plus a heading-shaped line. Only `#+end_src'
+;; closes a src block, so all three findings here are false.
+(defconst lo-test--literal-end-in-src "\
+* Heading
+
+#+begin_src text
+#+end_example
+** heading shaped
+#+end_src
+")
+
+;; invalid-block, uppercase-delimiter case — org accepts #+BEGIN_/#+END_ in
+;; either case, and the pre-fix script flagged both delimiters here too.
+(defconst lo-test--uppercase-verbatim-block "\
+* Heading
+
+#+BEGIN_EXAMPLE
+** heading shaped
+#+END_EXAMPLE
+")
+
+;; invalid-block, genuine case — a block that really is never closed. The
+;; suppression must not reach this one.
+(defconst lo-test--unterminated-block "\
+* Heading
+
+#+begin_example
+truly unterminated block body
+")
+
+;; A genuinely unterminated block *after* a correctly paired one — verifies the
+;; suppression is scoped per block rather than per file.
+(defconst lo-test--paired-then-unterminated "\
+* Heading
+
+#+begin_example
+** heading shaped
+#+end_example
+
+#+begin_example
+never closed
+")
+
;; Mixed fixture — each category once.
(defconst lo-test--mixed "\
* Mixed
@@ -392,6 +451,55 @@ suspicious-language judgment."
(should (= 1 suspicious))))
;;; ---------------------------------------------------------------------------
+;;; invalid-block — false positives on correctly paired verbatim blocks
+
+(ert-deftest lo-verbatim-heading-block-emits-no-invalid-block ()
+ "Normal: a paired example block containing a heading-shaped body line emits
+no invalid-block judgment. Both delimiters are flagged by org-lint because the
+parser treats the `** ' inside the verbatim body as a structural break."
+ (let* ((out (lo-test--run lo-test--verbatim-heading-block))
+ (res (plist-get out :result))
+ (judgments (lo-test--judgments (plist-get out :issues))))
+ ;; File untouched, no fixes applied — suppression only, never a rewrite.
+ (should (equal lo-test--verbatim-heading-block res))
+ (should (= 0 (plist-get out :fixes)))
+ (should-not (member 'invalid-block (lo-test--checkers judgments)))))
+
+(ert-deftest lo-literal-end-delimiter-in-src-emits-no-invalid-block ()
+ "Boundary: a paired src block whose body holds a literal `#+end_example' and
+a heading-shaped line emits no invalid-block judgment. Only `#+end_src' closes
+a src block, so the interior delimiter is body text."
+ (let* ((out (lo-test--run lo-test--literal-end-in-src))
+ (judgments (lo-test--judgments (plist-get out :issues))))
+ (should-not (member 'invalid-block (lo-test--checkers judgments)))))
+
+(ert-deftest lo-uppercase-verbatim-block-emits-no-invalid-block ()
+ "Boundary: block delimiters are case-insensitive in org, so an uppercase
+`#+BEGIN_EXAMPLE' pair is suppressed the same as a lowercase one."
+ (let* ((out (lo-test--run lo-test--uppercase-verbatim-block))
+ (judgments (lo-test--judgments (plist-get out :issues))))
+ (should-not (member 'invalid-block (lo-test--checkers judgments)))))
+
+(ert-deftest lo-unterminated-block-still-emits-invalid-block ()
+ "Error: a block that is never closed still emits its invalid-block judgment.
+This is the finding the checker exists for — the suppression must not mask it."
+ (let* ((out (lo-test--run lo-test--unterminated-block))
+ (judgments (lo-test--judgments (plist-get out :issues))))
+ (should (member 'invalid-block (lo-test--checkers judgments)))))
+
+(ert-deftest lo-invalid-block-suppression-is-scoped-per-block ()
+ "Boundary: a paired block and an unterminated block in the same file — the
+paired one is suppressed and the unterminated one still reports. Exactly one
+invalid-block judgment, and it points at the unterminated opener (line 7)."
+ (let* ((out (lo-test--run lo-test--paired-then-unterminated))
+ (judgments (lo-test--judgments (plist-get out :issues)))
+ (invalid (cl-remove-if-not
+ (lambda (i) (eq (plist-get i :checker) 'invalid-block))
+ judgments)))
+ (should (= 1 (length invalid)))
+ (should (= 7 (plist-get (car invalid) :line)))))
+
+;;; ---------------------------------------------------------------------------
;;; --check mode
(ert-deftest lo-check-mode-does-not-modify-file ()
@@ -620,6 +728,29 @@ followups file on the next run."
;;; ---------------------------------------------------------------------------
;;; org-table-standard check (width budget + rules between rows)
+(ert-deftest lo-table-inside-example-block-not-flagged ()
+ "Pipe-led ASCII art inside an example block is not a table; no judgment."
+ (let* ((run (lo-test--run
+ "* H\n\n#+begin_example\n| client |----->| server |\n| box | | box |\n#+end_example\n"
+ 1 t))
+ (judgments (lo-test--judgments (plist-get run :issues))))
+ (should-not (memq 'org-table-standard (lo-test--checkers judgments)))))
+
+(ert-deftest lo-table-inside-src-block-not-flagged ()
+ "Shell pipes inside a src block are not a table; no judgment."
+ (let* ((run (lo-test--run
+ "* H\n\n#+begin_src sh\n| sort\n| uniq -c\n#+end_src\n" 1 t))
+ (judgments (lo-test--judgments (plist-get run :issues))))
+ (should-not (memq 'org-table-standard (lo-test--checkers judgments)))))
+
+(ert-deftest lo-real-table-after-block-still-flagged ()
+ "Block safety must not mask a genuine violation later in the file."
+ (let* ((run (lo-test--run
+ "* H\n\n#+begin_example\n| art |\n#+end_example\n\n| a | b |\n| 1 | 2 |\n"
+ 1 t))
+ (judgments (lo-test--judgments (plist-get run :issues))))
+ (should (memq 'org-table-standard (lo-test--checkers judgments)))))
+
(ert-deftest lo-table-over-budget-emits-judgment ()
"A table line rendering wider than 120 surfaces as an org-table-standard judgment."
(let* ((wide (make-string 130 ?x))
@@ -685,6 +816,79 @@ 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)))))
+
+;;; dated-log-heading-active-timestamp check (stale SCHEDULED/DEADLINE on a
+;;; completed dated-log entry — the home 2026-07-17 agenda-pollution bug)
+
+(ert-deftest lo-dated-log-active-scheduled-is-flagged ()
+ "A dated-log entry still carrying an active SCHEDULED is flagged: org renders
+it on the agenda forever despite the missing keyword."
+ (let* ((out (lo-test--run
+ "* Open Work\n\n** TODO [#B] Parent\n*** 2026-06-20 Sat @ 10:00:00 -0500 trip booked\nSCHEDULED: <2026-06-18 Thu>\nBody.\n"))
+ (judgments (lo-test--judgments (plist-get out :issues))))
+ (should (= 0 (plist-get out :fixes))) ; judgment-only, never auto-fixed
+ (should (member 'dated-log-heading-active-timestamp (lo-test--checkers judgments)))))
+
+(ert-deftest lo-dated-log-active-deadline-is-flagged ()
+ "An active DEADLINE on a dated-log entry is flagged too."
+ (let* ((out (lo-test--run
+ "* Open Work\n\n** TODO [#B] Parent\n*** 2026-06-20 Sat @ 10:00:00 -0500 shipped\nDEADLINE: <2026-06-25 Thu>\n"))
+ (judgments (lo-test--judgments (plist-get out :issues))))
+ (should (member 'dated-log-heading-active-timestamp (lo-test--checkers judgments)))))
+
+(ert-deftest lo-dated-log-clean-entry-not-flagged ()
+ "A dated-log entry with no active planning timestamp is correct — not flagged."
+ (let* ((out (lo-test--run
+ "* Open Work\n\n** TODO [#B] Parent\n*** 2026-06-20 Sat @ 10:00:00 -0500 done cleanly\nBody only.\n"))
+ (judgments (lo-test--judgments (plist-get out :issues))))
+ (should-not (member 'dated-log-heading-active-timestamp (lo-test--checkers judgments)))))
+
+(ert-deftest lo-dated-log-inactive-timestamp-not-flagged ()
+ "An inactive [..] timestamp doesn't render on the agenda, so it isn't flagged —
+only active <..> planning timestamps are the defect."
+ (let* ((out (lo-test--run
+ "* Open Work\n\n** TODO [#B] Parent\n*** 2026-06-20 Sat @ 10:00:00 -0500 recorded\nSCHEDULED: [2026-06-18 Thu]\n"))
+ (judgments (lo-test--judgments (plist-get out :issues))))
+ (should-not (member 'dated-log-heading-active-timestamp (lo-test--checkers judgments)))))
+
+(ert-deftest lo-dated-log-active-scheduled-on-live-todo-not-flagged ()
+ "A live TODO (keyword present) that legitimately carries an active SCHEDULED is
+not a dated-log heading, so this checker leaves it alone."
+ (let* ((out (lo-test--run
+ "* Open Work\n\n** TODO [#B] Parent\n*** TODO [#C] real upcoming task\nSCHEDULED: <2026-06-18 Thu>\n"))
+ (judgments (lo-test--judgments (plist-get out :issues))))
+ (should-not (member 'dated-log-heading-active-timestamp (lo-test--checkers judgments)))))
+
;;; ---------------------------------------------------------------------------
;;; structural heading checks (org-lint gaps)
@@ -763,3 +967,134 @@ heading, so it is not flagged — only two-or-more indented stars are."
(provide 'test-lint-org)
;;; test-lint-org.el ends here
+
+;;; ---------------------------------------------------------------------------
+;;; task-missing-last-reviewed (claude-rules/todo-format.md)
+
+(ert-deftest lo-task-without-last-reviewed-is-judgment ()
+ "An open level-2 task with no :LAST_REVIEWED: is flagged."
+ (let* ((out (lo-test--run "* Open Work\n** TODO [#B] A task :feature:\nBody.\n"))
+ (js (lo-test--judgments (plist-get out :issues))))
+ (should (memq 'task-missing-last-reviewed (lo-test--checkers js)))))
+
+(ert-deftest lo-task-with-last-reviewed-is-clean ()
+ "A task carrying the property is not flagged."
+ (let* ((out (lo-test--run (concat "* Open Work\n** TODO [#B] A task :feature:\n"
+ ":PROPERTIES:\n:LAST_REVIEWED: 2026-07-23\n:END:\n"
+ "Body.\n")))
+ (js (lo-test--judgments (plist-get out :issues))))
+ (should-not (memq 'task-missing-last-reviewed (lo-test--checkers js)))))
+
+(ert-deftest lo-task-last-reviewed-accepts-org-timestamp ()
+ "The org-native [YYYY-MM-DD Day] form counts, matching the staleness script."
+ (let* ((out (lo-test--run (concat "* Open Work\n** TODO [#B] A task :feature:\n"
+ ":PROPERTIES:\n:LAST_REVIEWED: [2026-07-23 Thu]\n:END:\n")))
+ (js (lo-test--judgments (plist-get out :issues))))
+ (should-not (memq 'task-missing-last-reviewed (lo-test--checkers js)))))
+
+(ert-deftest lo-done-task-without-last-reviewed-is-clean ()
+ "Completed tasks leave the review pool, so they are never flagged."
+ (let* ((out (lo-test--run (concat "* Open Work\n** DONE [#B] A task :feature:\n"
+ "CLOSED: [2026-07-23 Thu]\n")))
+ (js (lo-test--judgments (plist-get out :issues))))
+ (should-not (memq 'task-missing-last-reviewed (lo-test--checkers js)))))
+
+(ert-deftest lo-subtask-without-last-reviewed-is-clean ()
+ "Only level-2 tasks are in the review pool; deeper headings are not."
+ (let* ((out (lo-test--run (concat "* Open Work\n** TODO [#B] Parent :feature:\n"
+ ":PROPERTIES:\n:LAST_REVIEWED: 2026-07-23\n:END:\n"
+ "*** TODO A sub-task\n")))
+ (js (lo-test--judgments (plist-get out :issues))))
+ (should-not (memq 'task-missing-last-reviewed (lo-test--checkers js)))))
+
+(ert-deftest lo-cookieless-task-without-last-reviewed-is-clean ()
+ "The staleness script selects on a priority cookie, so match that scope."
+ (let* ((out (lo-test--run "* Open Work\n** TODO Manual testing and validation\n"))
+ (js (lo-test--judgments (plist-get out :issues))))
+ (should-not (memq 'task-missing-last-reviewed (lo-test--checkers js)))))
+
+(ert-deftest lo-verify-task-without-last-reviewed-is-judgment ()
+ "VERIFY is in the review pool too."
+ (let* ((out (lo-test--run "* Open Work\n** VERIFY [#B] Waiting on Craig\n"))
+ (js (lo-test--judgments (plist-get out :issues))))
+ (should (memq 'task-missing-last-reviewed (lo-test--checkers js)))))
+
+;;; ---------------------------------------------------------------------------
+;;; todo-format checkers skip docs/specs/ files (claude-rules/todo-format.md)
+;;
+;; The four todo-format-family checkers encode todo.org completion conventions.
+;; A spec legitimately uses ** DONE <decision> with no CLOSED cookie and
+;; ** <dated> — <who> review-history headings, so those checkers misfire on
+;; every spec. They must skip any file under a docs/specs/ path segment.
+
+(defun lo-test--run-at (relpath content)
+ "Write CONTENT to <tmpdir>/RELPATH, run lint on it, return :issues.
+RELPATH is a relative path (may contain slashes) so a docs/specs/ segment
+can be exercised — the checkers key on the file's path, not just its name."
+ (let* ((root (make-temp-file "lo-test-root-" t))
+ (file (expand-file-name relpath root)))
+ (make-directory (file-name-directory file) t)
+ (unwind-protect
+ (progn
+ (with-temp-file file (insert content))
+ (lo-test--reset)
+ (lo-process-file file)
+ (prog1 (list :issues lo-issues)
+ (lo-test--drop-buffer file)))
+ (delete-directory root t))))
+
+(defconst lo-test--spec-decisions
+ "* Decisions [1/1]\n** DONE Some decision\n- Context: x\n"
+ "A spec Decisions section: a level-2 DONE with no CLOSED cookie.")
+
+(defconst lo-test--spec-history
+ "* Review history\n** 2026-07-14 Tue @ 02:03:28 -0500 — Claude — responder\n- What: x\n"
+ "A spec review-history section: a level-2 dated header.")
+
+(ert-deftest lo-todo-checkers-fire-on-a-normal-org-file ()
+ "Baseline: the checkers DO fire on a non-spec path (the bug is scope, not silence)."
+ (let* ((out (lo-test--run-at "todo.org" lo-test--spec-decisions))
+ (cs (lo-test--checkers (lo-test--judgments (plist-get out :issues)))))
+ (should (memq 'level2-done-without-closed cs))))
+
+(ert-deftest lo-level2-done-without-closed-skips-specs ()
+ (let* ((out (lo-test--run-at "docs/specs/2026-07-14-x-spec.org" lo-test--spec-decisions))
+ (cs (lo-test--checkers (lo-test--judgments (plist-get out :issues)))))
+ (should-not (memq 'level2-done-without-closed cs))))
+
+(ert-deftest lo-level2-dated-header-skips-specs ()
+ (let* ((out (lo-test--run-at "docs/specs/2026-07-14-x-spec.org" lo-test--spec-history))
+ (cs (lo-test--checkers (lo-test--judgments (plist-get out :issues)))))
+ (should-not (memq 'level-2-dated-header cs))))
+
+(ert-deftest lo-dated-log-active-timestamp-skips-specs ()
+ (let* ((c "* History\n** 2026-07-14 Tue @ 02:03:28 -0500 — did a thing\nSCHEDULED: <2026-07-20 Mon>\n")
+ (out (lo-test--run-at "docs/specs/2026-07-14-x-spec.org" c))
+ (cs (lo-test--checkers (lo-test--judgments (plist-get out :issues)))))
+ (should-not (memq 'dated-log-heading-active-timestamp cs))))
+
+(ert-deftest lo-subtask-done-not-dated-skips-specs ()
+ (let* ((c "* Work\n** TODO Parent\n*** DONE A sub-decision\n")
+ (out (lo-test--run-at "docs/specs/2026-07-14-x-spec.org" c))
+ (cs (lo-test--checkers (lo-test--judgments (plist-get out :issues)))))
+ (should-not (memq 'subtask-done-not-dated cs))))
+
+(ert-deftest lo-link-checks-still-fire-on-specs ()
+ "Only the todo-format family is scoped out; a broken link in a spec still flags."
+ (let* ((c "* X\n[[file:does-not-exist-xyz.org][link]]\n")
+ (out (lo-test--run-at "docs/specs/2026-07-14-x-spec.org" c))
+ (cs (lo-test--checkers (lo-test--judgments (plist-get out :issues)))))
+ (should (memq 'link-to-local-file cs))))
+
+(ert-deftest lo-task-missing-last-reviewed-skips-specs ()
+ "The fifth todo-format checker (added 2026-07-23) skips specs too — a spec's
+phases section may carry ** TODO [#x] items that aren't backlog tasks."
+ (let* ((c "* Implementation phases\n** TODO [#B] Phase one\nBody.\n")
+ (out (lo-test--run-at "docs/specs/2026-07-14-x-spec.org" c))
+ (cs (lo-test--checkers (lo-test--judgments (plist-get out :issues)))))
+ (should-not (memq 'task-missing-last-reviewed cs)))
+ ;; And still fires on a normal file.
+ (let* ((c "* Work\n** TODO [#B] Real backlog task\nBody.\n")
+ (out (lo-test--run-at "todo.org" c))
+ (cs (lo-test--checkers (lo-test--judgments (plist-get out :issues)))))
+ (should (memq 'task-missing-last-reviewed cs))))
diff --git a/claude-templates/.ai/scripts/tests/test-todo-cleanup.el b/claude-templates/.ai/scripts/tests/test-todo-cleanup.el
index e569d9a..a92d238 100644
--- a/claude-templates/.ai/scripts/tests/test-todo-cleanup.el
+++ b/claude-templates/.ai/scripts/tests/test-todo-cleanup.el
@@ -31,6 +31,7 @@
(defun tc-test--reset (&optional check)
(setq tc-fixes 0 tc-archived 0 tc-bumped 0 tc-archived-to-file 0 tc-issues nil
+ tc-sealed 0 tc-seal nil tc-convert-subtasks nil
tc-check-only (and check t)
tc-archive-done t tc-sync-child-priority nil
tc-current-file nil
@@ -40,6 +41,7 @@
(defun tc-test--reset-sync (&optional check)
(setq tc-fixes 0 tc-archived 0 tc-bumped 0 tc-archived-to-file 0 tc-issues nil
+ tc-sealed 0 tc-seal nil
tc-check-only (and check t)
tc-archive-done nil tc-sync-child-priority t
tc-current-file nil
@@ -514,6 +516,12 @@ gitignore todo.org, then run `--archive-done' aging with the DEFAULT archive pat
.gitignore contents or nil), :archive-ignored (whether git ignores the archive),
:archive-exists."
(let* ((root (make-temp-file "tc-git-" t))
+ ;; Private backup dir: this helper writes a file literally named
+ ;; todo.org and runs a real (non-check) pass, so without this its
+ ;; backup lands in the shared temp dir under the exact production
+ ;; name and is indistinguishable from a real one.
+ (temporary-file-directory
+ (file-name-as-directory (make-temp-file "tc-git-bk-" t)))
(todo (expand-file-name "todo.org" root))
(archive (expand-file-name "archive/task-archive.org" root))
(gi (expand-file-name ".gitignore" root)))
@@ -534,7 +542,8 @@ gitignore todo.org, then run `--archive-done' aging with the DEFAULT archive pat
:archive-ignored
(eq 0 (call-process "git" nil nil nil "check-ignore" "-q" archive))
:archive-exists (file-readable-p archive)))
- (delete-directory root t))))
+ (delete-directory root t)
+ (delete-directory temporary-file-directory t))))
(ert-deftest tc-age-self-protect-gitignores-archive-when-todo-ignored ()
"When the todo file is gitignored, the aged-out archive is added to .gitignore
@@ -578,6 +587,95 @@ entry is added for it."
(should (> (plist-get out :archived) 0)))))
;;; ---------------------------------------------------------------------------
+;;; --archive-done retention default
+
+(ert-deftest tc-archive-retain-default-is-one-month ()
+ "The shipped retention default is one month (31 days), not the legacy 7.
+The defvar initializes from this defconst; the live var itself is mutated by
+other tests, so the immutable defconst is the stable contract to pin."
+ (should (= 31 tc-archive-retain-days-default)))
+
+;;; ---------------------------------------------------------------------------
+;;; --seal: rename the working archive to resolved-YYYY-MM-DD.org
+
+(defun tc-test--seal (&optional opts)
+ "Run `--seal' against a temp todo file with a temp archive dir.
+OPTS is a plist: :archive-content (seed task-archive.org with this; nil = no
+working archive), :ref (YEAR MONTH DAY seal date; default (2026 7 18)),
+:check, :presealed (also create resolved-<ref>.org first, to test collision).
+Returns a plist: :sealed count, :issues, :working-exists, :sealed-exists,
+:sealed-name, :report."
+ (let* ((ref (or (plist-get opts :ref) '(2026 7 18)))
+ (check (plist-get opts :check))
+ (archive-content (plist-get opts :archive-content))
+ (todo (make-temp-file "tc-seal-todo-" nil ".org"))
+ (adir (make-temp-file "tc-seal-arch-" t))
+ (afile (expand-file-name "task-archive.org" adir))
+ (sealed-name (format "resolved-%04d-%02d-%02d.org"
+ (nth 0 ref) (nth 1 ref) (nth 2 ref)))
+ (sealed (expand-file-name sealed-name adir)))
+ (unwind-protect
+ (progn
+ (with-temp-file todo (insert "* Open Work\n** TODO [#A] live\n"))
+ (when archive-content (with-temp-file afile (insert archive-content)))
+ (when (plist-get opts :presealed)
+ (with-temp-file sealed (insert "pre-existing seal\n")))
+ (tc-test--reset check)
+ ;; Set every mode flag explicitly: tc-test--reset leaves
+ ;; tc-convert-subtasks untouched, so a convert test running earlier in
+ ;; the suite would otherwise still own the dispatch and run convert.
+ (setq tc-archive-done nil tc-sync-child-priority nil
+ tc-convert-subtasks nil tc-seal t tc-sealed 0
+ tc-archive-reference-date ref
+ tc-archive-file afile)
+ (let ((report (with-output-to-string (tc-process-file todo) (tc-emit-report))))
+ (tc-test--drop-buffer todo)
+ (list :sealed tc-sealed
+ :issues tc-issues
+ :working-exists (file-readable-p afile)
+ :sealed-exists (file-readable-p sealed)
+ :sealed-name sealed-name
+ :report report)))
+ (tc-test--drop-buffer todo)
+ (delete-file todo)
+ (delete-directory adir t))))
+
+(ert-deftest tc-seal-renames-working-archive-to-dated-file ()
+ "Normal: --seal renames task-archive.org to resolved-<seal-date>.org."
+ (let ((out (tc-test--seal '(:archive-content "* Resolved (archived)\n** DONE old\n"
+ :ref (2026 7 18)))))
+ (should (= 1 (plist-get out :sealed)))
+ (should-not (plist-get out :working-exists))
+ (should (plist-get out :sealed-exists))
+ (should (equal "resolved-2026-07-18.org" (plist-get out :sealed-name)))
+ (should (tc-test--has (plist-get out :report) "sealed task-archive.org → resolved-2026-07-18.org"))))
+
+(ert-deftest tc-seal-nothing-to-seal-is-a-reported-noop ()
+ "Boundary: no working archive present — reported no-op, nothing created."
+ (let ((out (tc-test--seal '(:ref (2026 7 18)))))
+ (should (= 0 (plist-get out :sealed)))
+ (should-not (plist-get out :sealed-exists))
+ (should (tc-test--has (plist-get out :report) "no working archive to seal"))))
+
+(ert-deftest tc-seal-check-mode-previews-without-renaming ()
+ "Boundary: --check reports the seal but leaves the working archive in place."
+ (let ((out (tc-test--seal '(:archive-content "* Resolved (archived)\n"
+ :ref (2026 7 18) :check t))))
+ (should (= 1 (plist-get out :sealed)))
+ (should (plist-get out :working-exists))
+ (should-not (plist-get out :sealed-exists))
+ (should (tc-test--has (plist-get out :report) "would seal"))))
+
+(ert-deftest tc-seal-refuses-to-clobber-existing-sealed-file ()
+ "Error: resolved-<today>.org already exists — refuse, leave both files intact."
+ (let ((out (tc-test--seal '(:archive-content "* Resolved (archived)\n"
+ :ref (2026 7 18) :presealed t))))
+ (should (= 0 (plist-get out :sealed)))
+ (should (plist-get out :working-exists))
+ (should (plist-get out :sealed-exists))
+ (should (tc-test--has (plist-get out :report) "already exists"))))
+
+;;; ---------------------------------------------------------------------------
;;; Sync-child-priority harness + fixtures
(defun tc-test--sync (content &optional runs check)
@@ -768,5 +866,311 @@ 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-sealed 0 tc-seal 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-strips-deadline-sharing-the-planning-line-boundary ()
+ "Boundary: a DEADLINE sharing the CLOSED planning line goes too — a dated-log
+entry carries no active planning timestamp (todo-format.md). Body survives."
+ (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-not (string-match-p "DEADLINE:" res))
+ (should (string-match-p "^Body line\\.$" res))))
+
+(defconst tc-test--convert-closed-and-scheduled-separate-lines
+ "* Project Open Work
+** TODO [#B] Parent task
+*** DONE [#C] Book the venue :feature:
+CLOSED: [2026-06-27 Sat 12:50]
+SCHEDULED: <2026-06-20 Sat>
+Body line.
+")
+
+(ert-deftest tc-convert-strips-scheduled-on-its-own-line ()
+ "Normal (the home bug): a SCHEDULED planning line on its own — the completion
+rewrite dropped keyword/priority/tags but left the SCHEDULED, pinning the dated
+entry to the agenda as weeks-overdue. Both planning lines go; body survives."
+ (let* ((out (tc-test--convert tc-test--convert-closed-and-scheduled-separate-lines))
+ (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\\} Book the venue$"
+ res))
+ (should-not (string-match-p "CLOSED:" res))
+ (should-not (string-match-p "SCHEDULED:" res))
+ (should (string-match-p "^Body line\\.$" res))))
+
+(defconst tc-test--convert-scheduled-in-body-prose
+ "* Project Open Work
+** TODO [#B] Parent task
+*** DONE [#C] Note the mechanism :feature:
+CLOSED: [2026-06-27 Sat 12:50]
+An active SCHEDULED: <2026-06-20 Sat> in prose must survive.
+")
+
+(ert-deftest tc-convert-leaves-planning-shaped-body-prose-alone ()
+ "Boundary: a planning-shaped token inside body prose (not a canonical planning
+line) is left untouched — the strip stops at the first non-planning line."
+ (let* ((out (tc-test--convert tc-test--convert-scheduled-in-body-prose))
+ (res (plist-get out :result)))
+ (should (= 1 (plist-get out :converted)))
+ (should-not (string-match-p "CLOSED:" res))
+ (should (string-match-p "An active SCHEDULED: <2026-06-20 Sat> in prose must survive\\." res))))
+
(provide 'test-todo-cleanup)
;;; test-todo-cleanup.el ends here
+
+;;; ---------------------------------------------------------------------------
+;;; Backup before mutating (parity with lint-org.el / wrap-org-table.el)
+;;
+;; todo-cleanup rewrites todo.org in place and left no copy behind, while both
+;; sibling org-mutators back up to /tmp first. It is also the one that runs most
+;; often (every wrap, every sentry fire). Emacs's own backup does not fire under
+;; --batch -q, so there was genuinely no undo short of git.
+
+(ert-deftest tc-backup-written-before-a-real-mutation ()
+ "A real (non-check) run leaves a copy holding the pre-edit content.
+
+`temporary-file-directory' is rebound to a private dir for the duration: the
+backup name derives from the *file's* basename, and the real todo.org shares
+that basename, so a live sentry run writing /tmp/todo.org.before-todo-cleanup.*
+would otherwise be indistinguishable from this test's own artifact. The first
+version of this test globbed the shared /tmp and passed only until a real run
+created one (2026-07-24)."
+ (let* ((dir (make-temp-file "tc-backup-" t))
+ (bdir (file-name-as-directory (make-temp-file "tc-bk-" t)))
+ (file (expand-file-name "todo.org" dir))
+ (before "* P Open Work\n** TODO [#B] parent\n*** DONE a subtask\nCLOSED: [2026-07-01 Tue]\n"))
+ (unwind-protect
+ (progn
+ (with-temp-file file (insert before))
+ (let ((tc-check-only nil)
+ (tc-convert-subtasks t)
+ (temporary-file-directory bdir))
+ (tc-process-file file))
+ (let ((backups (file-expand-wildcards
+ (concat bdir "todo.org.before-todo-cleanup.*"))))
+ (should backups)
+ (should (string-match-p
+ "a subtask"
+ (with-temp-buffer (insert-file-contents (car backups))
+ (buffer-string))))))
+ (delete-directory dir t)
+ (delete-directory bdir t))))
+
+(ert-deftest tc-no-backup-in-check-mode ()
+ "--check writes nothing, so it must not leave a backup either.
+Uses a private `temporary-file-directory' for the same isolation reason."
+ (let* ((dir (make-temp-file "tc-backup-" t))
+ (bdir (file-name-as-directory (make-temp-file "tc-bk-" t)))
+ (file (expand-file-name "todo.org" dir)))
+ (unwind-protect
+ (progn
+ (with-temp-file file
+ (insert "* P Open Work\n** TODO [#B] parent\n*** DONE sub\nCLOSED: [2026-07-01 Tue]\n"))
+ (let ((tc-check-only t)
+ (tc-convert-subtasks t)
+ (temporary-file-directory bdir))
+ (tc-process-file file))
+ (should-not (file-expand-wildcards
+ (concat bdir "todo.org.before-todo-cleanup.*"))))
+ (delete-directory dir t)
+ (delete-directory bdir t))))
+
+(ert-deftest tc-backup-never-overwrites-an-earlier-one ()
+ "Two invocations in the same second must not collapse to one backup.
+
+open-tasks.org runs --convert-subtasks then --archive-done back to back, each
+a sub-second batch run. With a second-resolution stamp and copy-file's
+OK-IF-ALREADY-EXISTS, the second invocation overwrote the first's backup with
+already-mutated content, so the true pre-session original was unrecoverable —
+the exact state the backup exists to preserve (found 2026-07-24 in review)."
+ (let* ((dir (make-temp-file "tc-collide-" t))
+ (bdir (file-name-as-directory (make-temp-file "tc-cbk-" t)))
+ (file (expand-file-name "todo.org" dir))
+ (original (concat "* P Open Work\n** TODO [#B] parent\n*** DONE sub\n"
+ "CLOSED: [2026-07-01 Tue]\n"
+ "* P Resolved\n** DONE [#C] old\nCLOSED: [2025-01-01 Wed]\n")))
+ (unwind-protect
+ (progn
+ (with-temp-file file (insert original))
+ ;; Two back-to-back invocations, as the shipped workflow does.
+ (let ((temporary-file-directory bdir))
+ (let ((tc-check-only nil) (tc-convert-subtasks t))
+ (tc-process-file file))
+ (let ((tc-check-only nil) (tc-convert-subtasks nil) (tc-archive-done t)
+ (tc-archive-retain-days nil))
+ (tc-process-file file)))
+ (let ((backups (file-expand-wildcards
+ (concat bdir "todo.org.before-todo-cleanup.*"))))
+ ;; Both invocations kept their own backup.
+ (should (= (length backups) 2))
+ ;; And one of them still holds the true original.
+ (should (cl-some (lambda (b)
+ (string= original
+ (with-temp-buffer (insert-file-contents b)
+ (buffer-string))))
+ backups))))
+ (delete-directory dir t)
+ (delete-directory bdir t))))
diff --git a/claude-templates/.ai/scripts/tests/test-wrap-org-table.el b/claude-templates/.ai/scripts/tests/test-wrap-org-table.el
index 8d1ecb6..0b3b375 100644
--- a/claude-templates/.ai/scripts/tests/test-wrap-org-table.el
+++ b/claude-templates/.ai/scripts/tests/test-wrap-org-table.el
@@ -186,3 +186,45 @@
(should (string-match-p "Prose before\\." content))
(should (string-match-p "Prose after\\." content))))
(delete-file file))))
+
+;;; ---------------------------------------------------------------------------
+;;; block safety — pipe lines inside #+begin_/#+end_ blocks are never tables
+
+(defconst wot-test--block-content
+ "#+begin_example
+| client |----->| server |
+| box | | box |
+#+end_example
+"
+ "An example block whose ASCII-art lines start with pipes.")
+
+(defun wot-test--process-content (content budget)
+ "Write CONTENT to a temp file, run `wot-process-file' at BUDGET, return result."
+ (let ((file (make-temp-file "wot-test" nil ".org")))
+ (unwind-protect
+ (progn
+ (with-temp-file file (insert content))
+ (wot-process-file file budget)
+ (with-temp-buffer (insert-file-contents file) (buffer-string)))
+ (delete-file file))))
+
+(ert-deftest wot-process-file-leaves-example-block-byte-identical ()
+ (let ((content (concat "* Diagram\n\n" wot-test--block-content)))
+ (should (equal (wot-test--process-content content 120) content))))
+
+(ert-deftest wot-process-file-reformats-table-but-not-block ()
+ (let* ((content (concat "* Doc\n\n" wot-test--block-content "\n"
+ wot-test--wide-input))
+ (result (wot-test--process-content content 40)))
+ (should (string-match-p (regexp-quote wot-test--block-content) result))
+ (should (string-match-p (regexp-quote wot-test--wide-expected) result))))
+
+(ert-deftest wot-process-file-skips-pipes-in-src-block ()
+ (let ((content "* Pipeline\n\n#+begin_src sh\n| sort\n| uniq -c\n#+end_src\n"))
+ (should (equal (wot-test--process-content content 120) content))))
+
+(ert-deftest wot-process-file-literal-inner-end-marker-stays-in-block ()
+ "A literal #+end_src quoted inside an example block must not close it."
+ (let ((content (concat "* Doc\n\n#+begin_example\n#+begin_src sh\nx\n"
+ "#+end_src\n| art |----| art |\n#+end_example\n")))
+ (should (equal (wot-test--process-content content 120) content))))
diff --git a/claude-templates/.ai/scripts/tests/test_apkg_to_orgdrill.py b/claude-templates/.ai/scripts/tests/test_apkg_to_orgdrill.py
new file mode 100644
index 0000000..6a95ea4
--- /dev/null
+++ b/claude-templates/.ai/scripts/tests/test_apkg_to_orgdrill.py
@@ -0,0 +1,301 @@
+"""Tests for apkg-to-orgdrill.py — the inverse of flashcard-to-anki.py.
+
+The converter reads an Anki .apkg (a zip holding collection.anki2 / .anki21
+sqlite) and emits an org-drill .org in the house canonical shape. It is
+stdlib-only (zipfile + sqlite3), so it imports directly — no genanki stub.
+
+The apkg schema these tests build by hand mirrors what genanki actually
+writes, confirmed against a real apkg generated from flashcard-to-anki.py:
+ - col.decks : JSON {did: {"name": ...}}, always including id-1 "Default"
+ - col.models : JSON {mid: {"name": ..., "flds": [{"name": "Front"}, ...]}}
+ - notes.flds : fields joined by \x1f; tags space-padded (" tag ")
+ - cards : nid -> did (the Default deck carries no cards)
+
+The round-trip test closes the loop through flashcard-to-anki.py's own
+parse(): original org -> forward parse tuples -> apkg fixture -> converter
+-> recovered org -> forward parse -> assert the (front, back, tag) tuples
+match. Only the apkg materialization is hand-built (the genanki boundary);
+everything else is the real code on both sides.
+"""
+from __future__ import annotations
+
+import importlib.util
+import json
+import sqlite3
+import sys
+import types
+import zipfile
+from pathlib import Path
+
+import pytest
+
+SCRIPTS = Path(__file__).resolve().parents[1]
+CONVERTER = SCRIPTS / "apkg-to-orgdrill.py"
+FORWARD = SCRIPTS / "flashcard-to-anki.py"
+
+
+def _load(path: Path, name: str, stub_genanki: bool = False):
+ if stub_genanki:
+ sys.modules.setdefault("genanki", types.ModuleType("genanki"))
+ spec = importlib.util.spec_from_file_location(name, path)
+ assert spec and spec.loader
+ module = importlib.util.module_from_spec(spec)
+ # Register before exec: @dataclass resolves cls.__module__ via sys.modules
+ # (Python 3.14), which is None for an unregistered importlib module.
+ sys.modules[name] = module
+ spec.loader.exec_module(module)
+ return module
+
+
+@pytest.fixture(scope="module")
+def conv():
+ return _load(CONVERTER, "apkg_to_orgdrill")
+
+
+@pytest.fixture(scope="module")
+def forward():
+ return _load(FORWARD, "flashcard_to_anki", stub_genanki=True)
+
+
+# --- fixture builder: write a genanki-shaped apkg by hand ------------------
+
+def _make_apkg(
+ path: Path,
+ decks: dict[int, str],
+ models: dict[int, list[str]],
+ notes: list[tuple[int, int, list[str], str]], # (nid, mid, fields, tag)
+ cards: list[tuple[int, int]], # (nid, did)
+ *,
+ media: str = "{}",
+) -> None:
+ """Materialize a minimal apkg matching genanki's collection.anki2 shape."""
+ col_dir = path.parent / f"{path.stem}-build"
+ col_dir.mkdir(parents=True, exist_ok=True)
+ db = col_dir / "collection.anki2"
+ if db.exists():
+ db.unlink()
+ con = sqlite3.connect(db)
+ con.execute("CREATE TABLE col (id INTEGER, decks TEXT, models TEXT)")
+ decks_json = {"1": {"name": "Default"}}
+ decks_json.update({str(did): {"name": name} for did, name in decks.items()})
+ models_json = {
+ str(mid): {"name": f"{decks.get(list(decks)[0], 'M')} model",
+ "flds": [{"name": n, "ord": i} for i, n in enumerate(flds)]}
+ for mid, flds in models.items()
+ }
+ con.execute("INSERT INTO col (id, decks, models) VALUES (1, ?, ?)",
+ (json.dumps(decks_json), json.dumps(models_json)))
+ con.execute("CREATE TABLE notes (id INTEGER, mid INTEGER, flds TEXT, tags TEXT)")
+ for nid, mid, fields, tag in notes:
+ con.execute("INSERT INTO notes (id, mid, flds, tags) VALUES (?, ?, ?, ?)",
+ (nid, mid, "\x1f".join(fields), f" {tag} " if tag else " "))
+ con.execute("CREATE TABLE cards (id INTEGER, nid INTEGER, did INTEGER)")
+ for i, (nid, did) in enumerate(cards):
+ con.execute("INSERT INTO cards (id, nid, did) VALUES (?, ?, ?)", (1000 + i, nid, did))
+ con.commit()
+ con.close()
+ with zipfile.ZipFile(path, "w") as z:
+ z.write(db, "collection.anki2")
+ z.writestr("media", media)
+
+
+# --- html_to_org_body ------------------------------------------------------
+
+def test_html_to_org_splits_br_into_lines(conv):
+ assert conv.html_to_org_body("one<br>two<br>three") == ["one", "two", "three"]
+
+
+def test_html_to_org_handles_br_variants(conv):
+ assert conv.html_to_org_body("a<br/>b<br />c<BR>d") == ["a", "b", "c", "d"]
+
+
+def test_html_to_org_unescapes_entities_amp_last(conv):
+ # Inverts escape_html (which escapes & first): &lt; &gt; &amp; -> < > &.
+ assert conv.html_to_org_body("x &lt;tag&gt; &amp; y") == ["x <tag> & y"]
+
+
+def test_html_to_org_preserves_a_literal_escaped_entity(conv):
+ # Forward-escaping the literal "&lt;" yields "&amp;lt;"; the inverse must
+ # recover "&lt;", not "<".
+ assert conv.html_to_org_body("&amp;lt;") == ["&lt;"]
+
+
+def test_html_to_org_strips_answer_hr(conv):
+ assert conv.html_to_org_body('front<hr id="answer">back') == ["front", "back"]
+
+
+def test_html_to_org_empty_back_is_empty(conv):
+ assert conv.html_to_org_body("") == []
+
+
+# --- read_apkg -------------------------------------------------------------
+
+def test_read_apkg_single_deck_recovers_front_back_tag_deck(conv, tmp_path):
+ apkg = tmp_path / "d.apkg"
+ _make_apkg(
+ apkg,
+ decks={20: "My Deck"},
+ models={9: ["Front", "Back"]},
+ notes=[(100, 9, ["Q1?", "A1.<br>line2"], "sec-one")],
+ cards=[(100, 20)],
+ )
+ recovered = conv.read_apkg(apkg)
+ assert len(recovered) == 1
+ note = recovered[0]
+ assert note.deck == "My Deck"
+ assert note.front == "Q1?"
+ assert note.back_html == "A1.<br>line2"
+ assert note.tag == "sec-one"
+
+
+def test_read_apkg_multiple_decks_grouped(conv, tmp_path):
+ apkg = tmp_path / "multi.apkg"
+ _make_apkg(
+ apkg,
+ decks={20: "Deck A", 21: "Deck B"},
+ models={9: ["Front", "Back"]},
+ notes=[(100, 9, ["QA?", "AA"], "ta"), (101, 9, ["QB?", "AB"], "tb")],
+ cards=[(100, 20), (101, 21)],
+ )
+ decks = {n.deck for n in conv.read_apkg(apkg)}
+ assert decks == {"Deck A", "Deck B"}
+
+
+def test_read_apkg_skips_default_deck_without_cards(conv, tmp_path):
+ apkg = tmp_path / "def.apkg"
+ _make_apkg(
+ apkg,
+ decks={20: "Real Deck"},
+ models={9: ["Front", "Back"]},
+ notes=[(100, 9, ["Q?", "A"], "t")],
+ cards=[(100, 20)],
+ )
+ assert {n.deck for n in conv.read_apkg(apkg)} == {"Real Deck"}
+
+
+def test_read_apkg_warns_and_skips_non_basic_model(conv, tmp_path, capsys):
+ apkg = tmp_path / "cloze.apkg"
+ _make_apkg(
+ apkg,
+ decks={20: "Cloze Deck"},
+ models={9: ["Text", "Extra"]}, # not Front/Back
+ notes=[(100, 9, ["some {{c1::text}}", "extra"], "t")],
+ cards=[(100, 20)],
+ )
+ recovered = conv.read_apkg(apkg)
+ assert recovered == []
+ assert "skip" in capsys.readouterr().err.lower()
+
+
+def test_read_apkg_reads_anki21_collection_name(conv, tmp_path):
+ # A .anki21 collection filename must be read the same as .anki2.
+ apkg = tmp_path / "new.apkg"
+ _make_apkg(
+ apkg,
+ decks={20: "Deck"},
+ models={9: ["Front", "Back"]},
+ notes=[(100, 9, ["Q?", "A"], "t")],
+ cards=[(100, 20)],
+ )
+ # Rewrite the zip renaming the collection member to .anki21.
+ with zipfile.ZipFile(apkg) as z:
+ data = z.read("collection.anki2")
+ media = z.read("media")
+ with zipfile.ZipFile(apkg, "w") as z:
+ z.writestr("collection.anki21", data)
+ z.writestr("media", media)
+ assert conv.read_apkg(apkg)[0].front == "Q?"
+
+
+def test_read_apkg_flags_media_reference(conv, tmp_path, capsys):
+ apkg = tmp_path / "media.apkg"
+ _make_apkg(
+ apkg,
+ decks={20: "Deck"},
+ models={9: ["Front", "Back"]},
+ notes=[(100, 9, ["Q?", 'see <img src="x.png">'], "t")],
+ cards=[(100, 20)],
+ )
+ conv.read_apkg(apkg)
+ assert "media" in capsys.readouterr().err.lower()
+
+
+# --- notes_to_org ----------------------------------------------------------
+
+def test_notes_to_org_emits_canonical_shape(conv):
+ Note = conv.Note
+ notes = [
+ Note(deck="My Deck", front="Q1?", back_html="A1.", tag="alpha"),
+ Note(deck="My Deck", front="Q2?", back_html="A2.", tag="alpha"),
+ ]
+ ids = iter(["id-1", "id-2"])
+ org = conv.notes_to_org(notes, "My Deck", new_id=lambda: next(ids))
+ assert "#+TITLE: My Deck" in org
+ assert "* alpha" in org
+ assert "** Q1? :drill:" in org
+ assert ":ID: id-1" in org
+ assert ":ID: id-2" in org
+ assert org.count("* alpha") == 1 # both cards share one section
+
+
+def test_notes_to_org_distinct_tags_get_distinct_sections(conv):
+ Note = conv.Note
+ notes = [
+ Note(deck="D", front="Qa?", back_html="a", tag="alpha"),
+ Note(deck="D", front="Qb?", back_html="b", tag="beta"),
+ ]
+ org = conv.notes_to_org(notes, "D", new_id=lambda: "x")
+ assert "* alpha" in org and "* beta" in org
+
+
+# --- round-trip through the real forward parse() ---------------------------
+
+def test_round_trip_matches_forward_parse_tuples(conv, forward, tmp_path):
+ original = (
+ "#+TITLE: RT Deck\n"
+ "\n"
+ "* First Section\n"
+ "** What is 2+2? :drill:\n"
+ ":PROPERTIES:\n:ID: aaaa\n:END:\n"
+ "Four.\n"
+ "Second line with <angle> & amp.\n"
+ "\n"
+ "* Second Section\n"
+ "** Capital of France? :drill:\n"
+ "Paris.\n"
+ )
+ tuples = forward.parse(original) # [(front, back_html, anki_tags), ...]
+ assert len(tuples) == 2
+
+ apkg = tmp_path / "rt.apkg"
+ _make_apkg(
+ apkg,
+ decks={20: "RT Deck"},
+ models={9: ["Front", "Back"]},
+ # anki_tags is a list; the apkg tags field is space-joined.
+ notes=[(100 + i, 9, [f, b], " ".join(tags))
+ for i, (f, b, tags) in enumerate(tuples)],
+ cards=[(100 + i, 20) for i in range(len(tuples))],
+ )
+
+ by_deck = conv.convert(apkg)
+ assert set(by_deck) == {"RT Deck"}
+ recovered_tuples = forward.parse(by_deck["RT Deck"])
+ assert recovered_tuples == tuples
+
+
+# --- errors ----------------------------------------------------------------
+
+def test_read_apkg_missing_collection_errors(conv, tmp_path):
+ bad = tmp_path / "bad.apkg"
+ with zipfile.ZipFile(bad, "w") as z:
+ z.writestr("media", "{}")
+ with pytest.raises(Exception):
+ conv.read_apkg(bad)
+
+
+def test_read_apkg_not_a_zip_errors(conv, tmp_path):
+ notzip = tmp_path / "plain.apkg"
+ notzip.write_text("not a zip")
+ with pytest.raises(Exception):
+ conv.read_apkg(notzip)
diff --git a/claude-templates/.ai/scripts/tests/test_cj_remove_block.py b/claude-templates/.ai/scripts/tests/test_cj_remove_block.py
index 2c8dade..3cdee46 100644
--- a/claude-templates/.ai/scripts/tests/test_cj_remove_block.py
+++ b/claude-templates/.ai/scripts/tests/test_cj_remove_block.py
@@ -14,6 +14,34 @@ import pytest
SCRIPT = Path(__file__).parent.parent / "cj-remove-block.py"
+@pytest.fixture(autouse=True)
+def isolated_tmpdir(tmp_path, monkeypatch):
+ """Give every test in this module a private TMPDIR.
+
+ The script backs up to the system temp dir under a name derived from the
+ edited file's BASENAME. The real todo.org shares that basename, so any test
+ operating on a fixture named todo.org writes something indistinguishable
+ from a production backup — and an earlier version of this file globbed the
+ shared /tmp and unlinked every match, so a routine `make test` destroyed
+ Craig's real backups (found in review, 2026-07-24).
+
+ Isolating at module scope rather than per-test is deliberate: the same bug
+ was fixed once in the elisp sibling and left here, so relying on each new
+ test to remember is exactly how it recurred. Autouse makes it structural.
+ """
+ d = tmp_path / "_tmpdir"
+ d.mkdir()
+ # TMPDIR covers subprocess invocations of the script.
+ monkeypatch.setenv("TMPDIR", str(d))
+ # tempfile.gettempdir() caches its answer on first call, so a test that
+ # loads the module in-process would keep writing to the real /tmp no matter
+ # what TMPDIR says. Override the cache too — this is the gap that made the
+ # env-var-only version still leak one backup per suite run.
+ import tempfile as _tempfile
+ monkeypatch.setattr(_tempfile, "tempdir", str(d))
+ return d
+
+
@pytest.fixture
def run_remove(tmp_path):
"""Write content to a temp org file, run cj-remove-block, return new contents."""
@@ -155,3 +183,142 @@ class TestCjRemoveBlockSafety:
err, post_content = run_remove_expecting_failure(original, start=4, end=2)
assert err.returncode != 0
assert post_content == original
+
+
+class TestMultiBlockRangeRefused:
+ """The validation exists to catch a drifted range, but it only checked the
+ first and last lines of that range. A span from one block's opening fence to
+ a LATER block's closing fence passed, and the removal silently deleted every
+ line between — real prose, headings, whole tasks — with a zero exit. Drift is
+ the skill's normal operating mode (respond-to-cj-comments edits the file as it
+ processes, and a file under cj review usually holds several blocks), so this
+ is the exact scenario the check was written for. Reproduced 2026-07-24."""
+
+ TWO_BLOCKS = (
+ "* Alpha\n"
+ "#+begin_src cj:\n"
+ "note A\n"
+ "#+end_src\n"
+ "KEEP THIS LINE\n"
+ "* Beta\n"
+ "#+begin_src cj:\n"
+ "note B\n"
+ "#+end_src\n"
+ )
+
+ def test_range_spanning_two_blocks_is_refused(self, run_remove_expecting_failure):
+ # Lines 2..9: block one's opener through block two's closer.
+ err, content = run_remove_expecting_failure(self.TWO_BLOCKS, 2, 9)
+ assert err.returncode == 1
+ assert "KEEP THIS LINE" in content, "content between the blocks was destroyed"
+ assert "* Beta" in content, "a heading between the blocks was destroyed"
+
+ def test_refusal_names_the_reason(self, run_remove_expecting_failure):
+ err, _ = run_remove_expecting_failure(self.TWO_BLOCKS, 2, 9)
+ assert "more than one" in err.stderr.decode().lower()
+
+ def test_a_correct_single_block_range_still_removes(self, run_remove):
+ # The fix must not over-tighten: the legitimate range still works.
+ out = run_remove(self.TWO_BLOCKS, 2, 4)
+ assert "note A" not in out
+ assert "KEEP THIS LINE" in out
+ assert "note B" in out, "the second block must be untouched"
+
+ def test_a_nested_end_src_inside_the_range_is_refused(self, run_remove_expecting_failure):
+ # Any #+end_src before the final line means the range covers >1 block.
+ content = (
+ "#+begin_src cj:\n"
+ "a\n"
+ "#+end_src\n"
+ "middle\n"
+ "#+begin_src cj:\n"
+ "b\n"
+ "#+end_src\n"
+ )
+ err, after = run_remove_expecting_failure(content, 1, 7)
+ assert err.returncode == 1
+ assert "middle" in after
+
+
+class TestSafeMutation:
+ """The script rewrites Craig's org files (todo.org, notes.org). It wrote with
+ a bare write_text, which truncates the target on open, and took no backup —
+ so a mid-write failure left the file truncated with no copy to recover from.
+ lint-org.el, the other tool that mutates these files, backs up to a temp dir
+ first. Match that, and make the write atomic.
+
+ Every test here redirects TMPDIR to a private directory. The backup name
+ derives from the file's basename, and the real todo.org shares it, so a test
+ globbing the shared temp dir cannot tell its own artifact from a genuine
+ backup — and an earlier version of this class globbed /tmp and unlinked every
+ match, so a routine `make test` destroyed real backups (found in review,
+ 2026-07-24). Never glob or delete across the shared temp dir."""
+
+ ONE_BLOCK = "* T\n#+begin_src cj:\nnote\n#+end_src\nkeep\n"
+
+ def test_a_backup_is_written_before_mutating(self, tmp_path):
+ import subprocess, glob, os
+ bdir = tmp_path / "bk"
+ bdir.mkdir()
+ f = tmp_path / "todo.org"
+ f.write_text(self.ONE_BLOCK)
+ subprocess.run(
+ ["python3", str(SCRIPT), "--file", str(f), "--start", "2", "--end", "4"],
+ check=True, capture_output=True,
+ env={**os.environ, "TMPDIR": str(bdir)},
+ )
+ backups = glob.glob(str(bdir / "todo.org.before-cj-remove.*"))
+ assert backups, "no backup was written before mutating the org file"
+ assert "note" in Path(max(backups)).read_text()
+
+ def test_no_partial_file_when_the_write_fails(self, tmp_path, monkeypatch):
+ import importlib.util
+ spec = importlib.util.spec_from_file_location("crb", SCRIPT)
+ mod = importlib.util.module_from_spec(spec)
+ spec.loader.exec_module(mod)
+ bdir = tmp_path / "bk"
+ bdir.mkdir()
+ monkeypatch.setenv("TMPDIR", str(bdir))
+ f = tmp_path / "todo.org"
+ f.write_text(self.ONE_BLOCK)
+ def boom(*a, **k):
+ raise OSError("disk full")
+ monkeypatch.setattr(mod.os, "replace", boom)
+ with pytest.raises(OSError):
+ mod.remove_range(f, 2, 4)
+ # The original survives intact — no truncation, no partial.
+ assert f.read_text() == self.ONE_BLOCK
+
+
+class TestBackupNeverOverwrites:
+ """Same defect class as todo-cleanup's, and more reachable here: the
+ respond-to-cj-comments skill removes several annotations in quick
+ succession, so a second-resolution stamp collides and the later backup
+ overwrote the earlier one with already-mutated content."""
+
+ TWO_BLOCKS = (
+ "* A\n#+begin_src cj:\nfirst\n#+end_src\n"
+ "* B\n#+begin_src cj:\nsecond\n#+end_src\n"
+ )
+
+ def test_consecutive_removals_each_keep_a_backup(self, tmp_path, monkeypatch):
+ import subprocess, glob
+ bdir = tmp_path / "bk"
+ bdir.mkdir()
+ monkeypatch.setenv("TMPDIR", str(bdir))
+ f = tmp_path / "todo.org"
+ f.write_text(self.TWO_BLOCKS)
+ original = f.read_text()
+ # Remove the second block, then the first — back to back, same second.
+ subprocess.run(["python3", str(SCRIPT), "--file", str(f),
+ "--start", "6", "--end", "8"],
+ check=True, capture_output=True,
+ env={**__import__("os").environ, "TMPDIR": str(bdir)})
+ subprocess.run(["python3", str(SCRIPT), "--file", str(f),
+ "--start", "2", "--end", "4"],
+ check=True, capture_output=True,
+ env={**__import__("os").environ, "TMPDIR": str(bdir)})
+ backups = glob.glob(str(bdir / "todo.org.before-cj-remove.*"))
+ assert len(backups) == 2, f"expected 2 backups, got {len(backups)}"
+ contents = [Path(b).read_text() for b in backups]
+ assert original in contents, "no backup holds the true original"
diff --git a/claude-templates/.ai/scripts/tests/test_flashcard_stats.py b/claude-templates/.ai/scripts/tests/test_flashcard_stats.py
index 606f7c1..46deccc 100644
--- a/claude-templates/.ai/scripts/tests/test_flashcard_stats.py
+++ b/claude-templates/.ai/scripts/tests/test_flashcard_stats.py
@@ -217,6 +217,31 @@ def test_parse_cards_captures_body_without_drawer_planning_or_answer_header(stat
assert c["body"] == "the real answer"
+def test_parse_cards_counts_a_multitag_heading_as_a_card(stats):
+ """A card multi-tagged :fundamental:drill: still counts; the front is clean."""
+ text = "* Sec\n** Q multi? :fundamental:drill:\nthe answer\n"
+ cards, _ = stats.parse_cards(text.splitlines())
+ assert len(cards) == 1
+ assert cards[0]["heading"] == "Q multi?"
+ assert cards[0]["body"] == "the answer"
+
+
+def test_parse_cards_ignores_a_tagged_heading_without_drill(stats):
+ """A tagged heading missing :drill: is not a drill card."""
+ text = "* Sec\n** Just a note :note:\nbody\n"
+ cards, _ = stats.parse_cards(text.splitlines())
+ assert cards == []
+
+
+def test_parse_cards_body_stops_at_next_multitag_card(stats):
+ """The body scan ends at the next L2 card even when it is multi-tagged."""
+ text = "** Q1? :a:drill:\nbody1\n** Q2? :drill:\nbody2\n"
+ cards, _ = stats.parse_cards(text.splitlines())
+ assert len(cards) == 2
+ assert cards[0]["body"] == "body1"
+ assert cards[1]["body"] == "body2"
+
+
def test_find_duplicate_fronts_matches_normalized_headings(stats):
cards = [
{"heading": "What is LEO?"},
diff --git a/claude-templates/.ai/scripts/tests/test_flashcard_to_anki.py b/claude-templates/.ai/scripts/tests/test_flashcard_to_anki.py
index 87008a8..fa38b64 100644
--- a/claude-templates/.ai/scripts/tests/test_flashcard_to_anki.py
+++ b/claude-templates/.ai/scripts/tests/test_flashcard_to_anki.py
@@ -158,17 +158,18 @@ Geostationary Earth Orbit.
def test_parse_returns_front_back_tag_per_card(drill):
cards = drill.parse(SECTIONED)
assert len(cards) == 2
- assert cards[0] == ("What is LEO?", "Low Earth Orbit.", "orbital-regimes")
+ # The section becomes the sole Anki tag (as a one-element list).
+ assert cards[0] == ("What is LEO?", "Low Earth Orbit.", ["orbital-regimes"])
assert cards[1][0] == "What is GEO?"
def test_parse_card_without_a_section_gets_the_drill_tag(drill):
- assert drill.parse("** Lone card? :drill:\nbody\n") == [("Lone card?", "body", "drill")]
+ assert drill.parse("** Lone card? :drill:\nbody\n") == [("Lone card?", "body", ["drill"])]
def test_parse_strips_properties_drawer_from_back(drill):
text = "** Q? :drill:\n:PROPERTIES:\n:ID: abc\n:END:\nThe answer.\n"
- assert drill.parse(text) == [("Q?", "The answer.", "drill")]
+ assert drill.parse(text) == [("Q?", "The answer.", ["drill"])]
def test_parse_trims_leading_and_trailing_blank_body_lines(drill):
@@ -178,7 +179,59 @@ def test_parse_trims_leading_and_trailing_blank_body_lines(drill):
def test_parse_card_with_only_a_drawer_has_empty_back(drill):
text = "** Q? :drill:\n:PROPERTIES:\n:ID: x\n:END:\n"
- assert drill.parse(text) == [("Q?", "", "drill")]
+ assert drill.parse(text) == [("Q?", "", ["drill"])]
+
+
+# --- multi-tag headings, --tag-filter, --guid-salt -------------------------
+
+MULTITAG = """* Fundamentals
+** What is LEO? :fundamental:drill:
+Low Earth Orbit.
+** What is GEO? :drill:
+Geostationary Earth Orbit.
+"""
+
+
+def test_parse_multitag_heading_is_a_card_when_drill_is_present(drill):
+ """A heading with a second org tag still parses when drill is among them."""
+ cards = drill.parse(MULTITAG)
+ assert len(cards) == 2
+ assert cards[0][0] == "What is LEO?"
+
+
+def test_parse_multitag_tags_ride_along_next_to_the_section_tag(drill):
+ """Non-drill org tags become Anki tags alongside the section tag."""
+ cards = drill.parse(MULTITAG)
+ assert cards[0][2] == ["fundamentals", "fundamental"] # section slug + org tag
+ assert cards[1][2] == ["fundamentals"] # drill-only -> section only
+
+
+def test_parse_heading_without_drill_tag_is_not_a_card(drill):
+ """A tagged heading missing :drill: is not a card (e.g. :note:)."""
+ assert drill.parse("* S\n** Just a note :note:\nbody\n") == []
+
+
+def test_parse_tag_filter_returns_only_cards_with_that_org_tag(drill):
+ """--tag-filter narrows to cards carrying the given org tag."""
+ cards = drill.parse(MULTITAG, tag_filter="fundamental")
+ assert len(cards) == 1
+ assert cards[0][0] == "What is LEO?"
+
+
+def test_parse_body_bounded_by_any_l1_or_l2_heading(drill):
+ """A card body stops at the next L1/L2 heading, multi-tagged or not."""
+ text = "** Q1? :a:drill:\nbody1\n** Q2? :drill:\nbody2\n"
+ cards = drill.parse(text)
+ assert cards[0][1] == "body1"
+ assert cards[1][1] == "body2"
+
+
+def test_card_guid_salt_changes_the_guid(drill, monkeypatch):
+ """--guid-salt gives a subset deck its own GUID space; no salt is unchanged."""
+ monkeypatch.setattr(drill.genanki, "guid_for", lambda *a: ":".join(a), raising=False)
+ assert drill.card_guid("front", None) == "front"
+ assert drill.card_guid("front", "fundamentals") == "fundamentals:front"
+ assert drill.card_guid("front", None) != drill.card_guid("front", "fundamentals")
def test_parse_joins_multiline_body_with_br(drill):
diff --git a/claude-templates/.ai/scripts/tests/test_inbox_send.py b/claude-templates/.ai/scripts/tests/test_inbox_send.py
index cb60e63..9b0a8c6 100644
--- a/claude-templates/.ai/scripts/tests/test_inbox_send.py
+++ b/claude-templates/.ai/scripts/tests/test_inbox_send.py
@@ -401,3 +401,192 @@ 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
+
+
+class TestAtomicWrite:
+ """A send wrote straight to the destination path in another project's
+ inbox/, and write_text truncates on open, so any mid-write failure left a
+ zero-byte .org there. inbox-status counts that phantom as a pending
+ handoff, blocking a turn in the receiving project over a file with no
+ content (2026-07-23). The write must be atomic: the inbox sees a complete
+ file or nothing."""
+
+ def test_send_text_writes_utf8(self, tmp_path):
+ from datetime import datetime
+ mod = _load_module()
+ inbox = tmp_path / "inbox"
+ inbox.mkdir()
+ now = datetime(2026, 7, 23, 4, 36, 0)
+ # An em dash and an accented char — both non-ASCII.
+ dest = mod.send_text(inbox, "accent café and dash — here", "src", None, now)
+ # Reading as utf-8 must round-trip; a locale-encoded write would raise
+ # under a C locale, and reading back proves the bytes are utf-8.
+ assert "—" in dest.read_text(encoding="utf-8")
+
+ def test_send_text_no_partial_on_write_failure(self, tmp_path, monkeypatch):
+ from datetime import datetime
+ mod = _load_module()
+ inbox = tmp_path / "inbox"
+ inbox.mkdir()
+ now = datetime(2026, 7, 23, 4, 36, 0)
+ # Force the atomic finalize to fail after the temp file is written.
+ def boom(*a, **k):
+ raise OSError("disk full")
+ monkeypatch.setattr(mod.os, "replace", boom)
+ with pytest.raises(OSError):
+ mod.send_text(inbox, "a message that should never half-land", "src", None, now)
+ # No phantom, no leftover temp: the inbox is empty.
+ assert list(inbox.iterdir()) == []
+
+ def test_send_text_leaves_no_temp_on_success(self, tmp_path):
+ from datetime import datetime
+ mod = _load_module()
+ inbox = tmp_path / "inbox"
+ inbox.mkdir()
+ now = datetime(2026, 7, 23, 4, 36, 0)
+ dest = mod.send_text(inbox, "clean send", "src", None, now)
+ assert list(inbox.iterdir()) == [dest]
+
+ def test_send_file_no_partial_on_write_failure(self, tmp_path, monkeypatch):
+ from datetime import datetime
+ mod = _load_module()
+ inbox = tmp_path / "inbox"
+ inbox.mkdir()
+ src = tmp_path / "note.org"
+ src.write_text("body")
+ now = datetime(2026, 7, 23, 4, 36, 0)
+ def boom(*a, **k):
+ raise OSError("disk full")
+ monkeypatch.setattr(mod.os, "replace", boom)
+ with pytest.raises(OSError):
+ mod.send_file(inbox, src, "src", None, now)
+ assert list(inbox.iterdir()) == []
+
+ def test_send_file_leaves_no_temp_on_success(self, tmp_path):
+ from datetime import datetime
+ mod = _load_module()
+ inbox = tmp_path / "inbox"
+ inbox.mkdir()
+ src = tmp_path / "note.org"
+ src.write_text("payload")
+ now = datetime(2026, 7, 23, 4, 36, 0)
+ dest = mod.send_file(inbox, src, "src", None, now)
+ assert list(inbox.iterdir()) == [dest]
+ assert dest.read_text() == "payload"
+
+
+class TestSmallerDefects:
+ """Two low-severity defects found reading inbox-send during the 2026-07-23
+ sweep: an unreadable source raised an uncaught traceback instead of the
+ clean error every other failure path produces, and a roots config naming
+ both a parent and one of its children listed the same project twice."""
+
+ def test_unreadable_source_gives_clean_error_not_traceback(
+ self, project_root, run_script, tmp_path
+ ):
+ project_root("sender")
+ project_root("receiver")
+ roots = [tmp_path / "projects"]
+ src = tmp_path / "secret.bin"
+ src.write_text("x")
+ src.chmod(0o000)
+ try:
+ result = run_script(
+ ["receiver", "--file", str(src)],
+ cwd=tmp_path / "projects" / "sender",
+ roots=roots,
+ expect_failure=True,
+ )
+ finally:
+ src.chmod(0o644)
+ assert result.returncode == 1
+ # The clean "inbox-send: <message>" shape, not a Python traceback.
+ assert result.stderr.startswith("inbox-send:")
+ assert "Traceback" not in result.stderr
+
+ def test_discover_projects_dedupes_parent_and_child_root(self, tmp_path):
+ mod = _load_module()
+ # A project directory, reachable both as a child of its parent root and
+ # as a root in its own right.
+ parent = tmp_path / "projects"
+ proj = parent / "app"
+ (proj / ".ai").mkdir(parents=True)
+ (proj / "inbox").mkdir()
+ found = mod.discover_projects([parent, proj])
+ resolved = [p.resolve() for p in found]
+ assert resolved.count(proj.resolve()) == 1
diff --git a/claude-templates/.ai/scripts/tests/test_route_recommend.py b/claude-templates/.ai/scripts/tests/test_route_recommend.py
index acc4755..2ec900a 100644
--- a/claude-templates/.ai/scripts/tests/test_route_recommend.py
+++ b/claude-templates/.ai/scripts/tests/test_route_recommend.py
@@ -122,3 +122,31 @@ def test_cli_exclude_drops_current_project(tmp_path):
r = _run(["--exclude", "foo"], roots=[tmp_path / "projects"], item="fix the foo widget")
assert r.returncode == 0
assert r.stdout.strip() == "none"
+
+
+# ----------------------------------------------------------------------
+# Duplicate candidate names
+#
+# Projects are collapsed to bare basenames, so two projects sharing a basename
+# across roots (~/code/notes and ~/projects/notes) appear twice in the candidate
+# list. Both literal-match, recommend read len(strong) > 1 as an ambiguous tie,
+# and a correct strong match was downgraded to weak. Latent when discovered
+# 2026-07-24 (27 projects, 27 distinct basenames) but real.
+# ----------------------------------------------------------------------
+
+def test_duplicate_candidate_name_keeps_strong_confidence():
+ assert rr.recommend("fix the notes thing", ["notes", "other"]) == ("notes", "strong")
+ # The same name twice must not read as a tie.
+ assert rr.recommend("fix the notes thing", ["notes", "notes", "other"]) == ("notes", "strong")
+
+
+def test_genuine_ambiguity_still_downgrades():
+ # Two DIFFERENT projects both matching is a real tie and stays weak — the
+ # dedupe must collapse identical names only, never real ambiguity.
+ dest, conf = rr.recommend("notes and other both", ["notes", "other"])
+ assert conf == "weak"
+
+
+def test_duplicates_do_not_change_the_chosen_destination():
+ dest, _ = rr.recommend("fix the notes thing", ["notes", "notes"])
+ assert dest == "notes"
diff --git a/claude-templates/.ai/scripts/tests/test_upcoming_birthdays.py b/claude-templates/.ai/scripts/tests/test_upcoming_birthdays.py
new file mode 100644
index 0000000..1e15183
--- /dev/null
+++ b/claude-templates/.ai/scripts/tests/test_upcoming_birthdays.py
@@ -0,0 +1,168 @@
+"""Tests for upcoming_birthdays.py — the daily-prep upcoming-birthdays block.
+
+Pure core:
+ parse_birthdays(text) -> [Birthday(name, month, day, year|None), ...]
+ upcoming(birthdays, today, window=30) -> [Upcoming(name, date, days_away, age|None), ...]
+ format_block(items, window, callout_days=7) -> str
+
+Birth year 1900 is the placeholder org-contacts uses when the real year is
+unknown; those entries carry year=None and render date-only (no age).
+"""
+
+import datetime as dt
+import subprocess
+import sys
+from pathlib import Path
+
+SCRIPTS = Path(__file__).parent.parent
+SCRIPT = SCRIPTS / "upcoming_birthdays.py"
+sys.path.insert(0, str(SCRIPTS))
+
+import upcoming_birthdays as ub # noqa: E402
+
+
+# --- parse_birthdays --------------------------------------------------------
+
+def test_parse_reads_name_month_day_year():
+ text = "** Jane Doe\n:PROPERTIES:\n:BIRTHDAY: 1970-08-05\n:END:\n"
+ bdays = ub.parse_birthdays(text)
+ assert bdays == [ub.Birthday("Jane Doe", 8, 5, 1970)]
+
+
+def test_parse_placeholder_year_1900_becomes_none():
+ text = "** John Smith\n:PROPERTIES:\n:BIRTHDAY: 1900-07-14\n:END:\n"
+ bdays = ub.parse_birthdays(text)
+ assert bdays == [ub.Birthday("John Smith", 7, 14, None)]
+
+
+def test_parse_skips_contacts_without_birthday():
+ text = (
+ "** No Birthday\n:PROPERTIES:\n:PHONE: 555\n:END:\n"
+ "** Has Birthday\n:PROPERTIES:\n:BIRTHDAY: 1990-03-02\n:END:\n"
+ )
+ bdays = ub.parse_birthdays(text)
+ assert [b.name for b in bdays] == ["Has Birthday"]
+
+
+def test_parse_strips_heading_stars_and_tags():
+ text = "*** Bob Jones :friend:\n:PROPERTIES:\n:BIRTHDAY: 1980-01-01\n:END:\n"
+ bdays = ub.parse_birthdays(text)
+ assert bdays[0].name == "Bob Jones"
+
+
+def test_parse_ignores_malformed_birthday_lines():
+ text = "** Bad Date\n:PROPERTIES:\n:BIRTHDAY: not-a-date\n:END:\n"
+ assert ub.parse_birthdays(text) == []
+
+
+# --- upcoming ---------------------------------------------------------------
+
+TODAY = dt.date(2026, 7, 18)
+
+
+def test_upcoming_birthday_today_is_zero_days():
+ bdays = [ub.Birthday("Today Person", 7, 18, 1990)]
+ got = ub.upcoming(bdays, TODAY)
+ assert got[0].days_away == 0
+ assert got[0].date == dt.date(2026, 7, 18)
+
+
+def test_upcoming_includes_within_window():
+ bdays = [ub.Birthday("Soon", 7, 23, 1990)]
+ got = ub.upcoming(bdays, TODAY, window=30)
+ assert got[0].days_away == 5
+
+
+def test_upcoming_excludes_beyond_window():
+ bdays = [ub.Birthday("Far", 9, 1, 1990)] # 45 days out
+ assert ub.upcoming(bdays, TODAY, window=30) == []
+
+
+def test_upcoming_boundary_day_30_included_day_31_excluded():
+ on = [ub.Birthday("On", 8, 17, 1990)] # exactly 30 days
+ off = [ub.Birthday("Off", 8, 18, 1990)] # 31 days
+ assert ub.upcoming(on, TODAY, window=30)[0].days_away == 30
+ assert ub.upcoming(off, TODAY, window=30) == []
+
+
+def test_upcoming_uses_next_year_when_this_years_passed():
+ # today is 2026-07-18; a Jan 5 birthday recurs on 2027-01-05
+ today = dt.date(2026, 12, 27)
+ bdays = [ub.Birthday("New Year", 1, 5, 1990)]
+ got = ub.upcoming(bdays, today, window=30)
+ assert got[0].date == dt.date(2027, 1, 5)
+ assert got[0].days_away == 9
+
+
+def test_upcoming_age_is_occurrence_year_minus_birth_year():
+ bdays = [ub.Birthday("Ager", 7, 23, 1970)]
+ got = ub.upcoming(bdays, TODAY)
+ assert got[0].age == 56 # 2026 - 1970
+
+
+def test_upcoming_age_none_for_placeholder():
+ bdays = [ub.Birthday("Placeholder", 7, 23, None)]
+ got = ub.upcoming(bdays, TODAY)
+ assert got[0].age is None
+
+
+def test_upcoming_sorted_by_days_away():
+ bdays = [
+ ub.Birthday("Later", 8, 10, 1990),
+ ub.Birthday("Sooner", 7, 20, 1990),
+ ]
+ got = ub.upcoming(bdays, TODAY)
+ assert [u.name for u in got] == ["Sooner", "Later"]
+
+
+def test_upcoming_leap_day_maps_to_feb_28_in_non_leap_year():
+ today = dt.date(2027, 2, 1) # 2027 is not a leap year
+ bdays = [ub.Birthday("Leapling", 2, 29, 2000)]
+ got = ub.upcoming(bdays, today, window=30)
+ assert got[0].date == dt.date(2027, 2, 28)
+
+
+# --- format_block -----------------------------------------------------------
+
+def test_format_block_empty_reports_none():
+ out = ub.format_block([], window=30)
+ assert "No birthdays" in out
+
+
+def test_format_block_callout_marks_within_seven_days():
+ items = [ub.Upcoming("Soon", dt.date(2026, 7, 22), 4, 40)]
+ out = ub.format_block(items, window=30, callout_days=7)
+ assert "⚠" in out
+ assert "Soon" in out
+ assert "40" in out # age shown
+
+
+def test_format_block_beyond_callout_is_not_flagged():
+ items = [ub.Upcoming("Later", dt.date(2026, 8, 10), 23, 30)]
+ out = ub.format_block(items, window=30, callout_days=7)
+ assert "⚠" not in out
+
+
+def test_format_block_placeholder_shows_date_only_no_age():
+ items = [ub.Upcoming("NoYear", dt.date(2026, 7, 25), 7, None)]
+ out = ub.format_block(items, window=30)
+ assert "NoYear" in out
+ assert "turns" not in out
+
+
+# --- CLI --------------------------------------------------------------------
+
+def test_cli_runs_against_a_fixture_file(tmp_path):
+ contacts = tmp_path / "contacts.org"
+ contacts.write_text(
+ "** Alice\n:PROPERTIES:\n:BIRTHDAY: 1990-07-20\n:END:\n"
+ "** Bob\n:PROPERTIES:\n:BIRTHDAY: 1900-12-01\n:END:\n"
+ )
+ res = subprocess.run(
+ [sys.executable, str(SCRIPT), "--file", str(contacts),
+ "--today", "2026-07-18", "--window", "30"],
+ capture_output=True, text=True,
+ )
+ assert res.returncode == 0
+ assert "Alice" in res.stdout
+ assert "Bob" not in res.stdout # Dec 1 is outside the 30-day window
diff --git a/claude-templates/.ai/scripts/todo-cleanup.el b/claude-templates/.ai/scripts/todo-cleanup.el
index 541d106..cb333e2 100644
--- a/claude-templates/.ai/scripts/todo-cleanup.el
+++ b/claude-templates/.ai/scripts/todo-cleanup.el
@@ -5,10 +5,14 @@
;; 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 --seal todo.org # seal the working archive to resolved-YYYY-MM-DD.org
+;; emacs --batch -q -l todo-cleanup.el --seal --check todo.org # preview the seal
+;; 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.
@@ -35,23 +39,51 @@
;; a message. Only direct level-2 children move — a DONE entry nested under
;; an open parent stays put.
;;
-;; 2. Ages the "Resolved" section: a level-2 DONE/CANCELLED subtree whose
-;; CLOSED date is older than `tc-archive-retain-days' (default 7) is moved
+;; 2. Ages the "Resolved" section: a level-2 DONE/CANCELLED subtree is moved
;; out to `tc-archive-file' (default `archive/task-archive.org' beside the
-;; todo file), keeping only the last week of closed tasks in the file
-;; itself. Only subtrees closed within the window stay; older ones, and
-;; those with no parseable CLOSED date, are moved out. Set
-;; `tc-archive-retain-days' to nil to disable this step (legacy in-file-only
-;; behavior). The aging date is `tc-archive-reference-date' when set
-;; (tests), otherwise the real current date. The archive inherits the todo
-;; file's gitignore status: when the todo file is gitignored, the archive
-;; path is added to .gitignore before the first write, so private task
-;; history never lands in a tracked path (see
+;; todo file) when its CLOSED date is older than `tc-archive-retain-days'
+;; (default 31 — one month) OR its CLOSED date can't be parsed. The last
+;; month of closed tasks stays browsable in the file itself; older ones age
+;; out. The unparseable-CLOSED case archives too, deliberately: a
+;; keyword-complete task with no readable close date is cruft, not live
+;; work. Set `tc-archive-retain-days' to nil to disable this step (legacy
+;; in-file-only behavior). The aging date is `tc-archive-reference-date'
+;; when set (tests), otherwise the real current date. The archive inherits
+;; the todo file's gitignore status: when the todo file is gitignored, the
+;; archive path is added to .gitignore before the first write, so private
+;; task history never lands in a tracked path (see
;; `tc--ensure-archive-gitignored').
;;
;; Archiving is consequential, so it's never run by default; it does *not*
;; also run the hygiene passes.
;;
+;; * --seal (opt-in). Renames the working archive file (`tc-archive-file',
+;; default `archive/task-archive.org') to `resolved-YYYY-MM-DD.org' beside it,
+;; dated by the seal run, and leaves the next `--archive-done' to recreate a
+;; fresh working file. The dated file means "everything sealed as of that
+;; date" — not a calendar quarter — so a task closed late in a quarter and
+;; archived after the boundary is never mislabeled; cadence (e.g. quarterly)
+;; becomes independent of correctness and any slip is harmless. The sealed
+;; file inherits the todo file's gitignore status the same way the working
+;; archive does. A no-op (reported) when there's no working archive to seal;
+;; refuses to clobber an existing `resolved-<today>.org'. Honors `--check'.
+;; The seal date is `tc-archive-reference-date' when set (tests), otherwise the
+;; real current date.
+;;
+;; * --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
@@ -68,19 +100,41 @@
;; --check-child-priority is the report-only alias for --sync-child-priority
;; --check.
+;; Before any modification a backup is copied to
+;; /tmp/<basename>.before-todo-cleanup.<YYYYMMDD-HHMMSS>
+;; matching lint-org.el and wrap-org-table.el. Skipped under --check, which
+;; writes nothing.
+;;
+
(require 'org)
(require 'cl-lib)
(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.")
+(defconst tc--planning-cookie-regexp
+ "\\(?:CLOSED\\|DEADLINE\\|SCHEDULED\\):[ \t]*[[<][^]>\n]*[]>]"
+ "One org planning cookie: a CLOSED/DEADLINE/SCHEDULED keyword followed by a
+bracketed (inactive) or angled (active) timestamp.")
+
+(defconst tc--planning-line-regexp
+ (concat "\\`[ \t]*\\(?:" tc--planning-cookie-regexp "[ \t]*\\)+\\'")
+ "A whole org planning line: nothing but planning cookies and whitespace.
+Anchored to a single line's contents so a line mixing a cookie with real body
+text is never matched.")
+
(defconst tc-no-sync-tag "no-sync"
"Org tag that opts a heading and all its descendants out of
`--sync-child-priority'. Inherits down: a tag on an ancestor counts for
@@ -89,20 +143,32 @@ 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-sealed 0)
(defvar tc-check-only nil)
(defvar tc-archive-done nil)
(defvar tc-sync-child-priority nil)
+(defvar tc-convert-subtasks nil)
+(defvar tc-seal nil)
(defvar tc-current-file nil)
(defvar tc-current-dir nil)
(defvar tc-archived-to-file 0)
-(defvar tc-archive-retain-days 7
+(defconst tc-archive-retain-days-default 31
+ "Default retention window (days) for the `--archive-done' file-aging step —
+one month. A closed Resolved subtree stays in-file for this long before it ages
+out to `tc-archive-file'; the last month of resolved work stays browsable in the
+todo file itself. Named so the \"one month\" contract is explicit and testable.")
+
+(defvar tc-archive-retain-days tc-archive-retain-days-default
"Retention window for the `--archive-done' file-aging step. A closed Resolved
subtree whose CLOSED date is within this many days of the reference date stays
in the in-file Resolved section; an older one is moved out to `tc-archive-file'.
-A subtree with no parseable CLOSED date stays. nil disables the aging step
-entirely, leaving the legacy in-file-only behavior.")
+A subtree with no parseable CLOSED date is aged out too (a keyword-complete task
+with no readable close date is cruft, not live work). nil disables the aging
+step entirely, leaving the legacy in-file-only behavior. Defaults to
+`tc-archive-retain-days-default' (one month).")
(defvar tc-archive-reference-date nil
"(YEAR MONTH DAY) treated as \"today\" when aging Resolved subtrees out to a
@@ -456,6 +522,51 @@ step. Honors `tc-check-only' (report only)."
tc-issues))))))))))
;;; ---------------------------------------------------------------------------
+;;; --seal mode: rename the working archive to a dated resolved-YYYY-MM-DD.org
+
+(defun tc--seal-date-string ()
+ "YYYY-MM-DD for the seal — `tc-archive-reference-date' when set (tests),
+otherwise the real current date."
+ (if tc-archive-reference-date
+ (pcase-let ((`(,y ,m ,d) tc-archive-reference-date))
+ (format "%04d-%02d-%02d" y m d))
+ (format-time-string "%Y-%m-%d")))
+
+(defun tc-seal-archive-file ()
+ "Rename the working archive file to `resolved-YYYY-MM-DD.org' beside it.
+The next `--archive-done' run recreates a fresh working file. No-op (reported)
+when there is no working archive to seal; refuses to clobber an existing
+`resolved-<today>.org'. Ensures the sealed file inherits the todo file's
+gitignore status. Honors `tc-check-only'."
+ (let ((path (tc--archive-file-path)))
+ (cond
+ ((or (null path) (not (file-readable-p path)))
+ (push (list :kind 'seal-nothing :file tc-current-file) tc-issues))
+ (t
+ (let* ((dir (file-name-directory path))
+ (sealed (expand-file-name
+ (format "resolved-%s.org" (tc--seal-date-string)) dir)))
+ (cond
+ ((file-exists-p sealed)
+ (push (list :kind 'seal-collision :file tc-current-file
+ :detail (file-name-nondirectory sealed))
+ tc-issues))
+ (tc-check-only
+ (cl-incf tc-sealed)
+ (push (list :kind 'seal-would :file tc-current-file
+ :detail (file-name-nondirectory sealed))
+ tc-issues))
+ (t
+ ;; Ignore the sealed name before the rename so its history stays as
+ ;; private as the working archive it derives from.
+ (tc--ensure-archive-gitignored sealed)
+ (rename-file path sealed)
+ (cl-incf tc-sealed)
+ (push (list :kind 'seal-done :file tc-current-file
+ :detail (file-name-nondirectory sealed))
+ tc-issues))))))))
+
+;;; ---------------------------------------------------------------------------
;;; --sync-child-priority mode
(defun tc--heading-priority-letter ()
@@ -578,11 +689,186 @@ 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.
+;;
+;; The planning line goes entirely. A dated-log entry carries its date in the
+;; heading, so CLOSED is redundant and an active DEADLINE/SCHEDULED is wrong: org
+;; renders any headline with an active planning timestamp — keyword or not — so a
+;; SCHEDULED left on a dated-log heading pins it to the agenda as weeks-overdue
+;; long after the work is done. The conversion deletes the whole planning line,
+;; not just the CLOSED cookie (todo-format.md; lint checker
+;; `dated-log-heading-active-timestamp' backstops any that slip through).
+
+(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--strip-planning-lines-in-entry ()
+ "Delete the canonical planning line(s) directly under the heading at point.
+A planning line is one composed solely of CLOSED/DEADLINE/SCHEDULED cookies and
+whitespace. Walks the lines immediately after the heading and stops at the first
+non-planning line, so a planning-shaped line deeper in the body (e.g. in a code
+block) is never touched. Returns the count of lines removed."
+ (save-excursion
+ (org-back-to-heading t)
+ (forward-line 1)
+ (let ((removed 0) (continue t))
+ (while (and continue (not (eobp)))
+ (let ((line (buffer-substring-no-properties
+ (line-beginning-position) (line-end-position))))
+ (if (string-match-p tc--planning-line-regexp line)
+ (progn
+ (delete-region (line-beginning-position)
+ (min (1+ (line-end-position)) (point-max)))
+ (cl-incf removed))
+ (setq continue nil))))
+ removed)))
+
+(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 whole planning line. The
+ ;; date now lives in the header, so CLOSED is redundant and an active
+ ;; DEADLINE/SCHEDULED would wrongly pin this completed entry to the
+ ;; agenda (todo-format.md). Both go, not just the CLOSED cookie.
+ (delete-region (line-beginning-position) (line-end-position))
+ (insert new)
+ (tc--strip-planning-lines-in-entry)
+ (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--backup (file)
+ "Copy FILE to /tmp before any modification. Skipped in --check mode.
+
+Matches `lint-org.el' and `wrap-org-table.el', the other tools that rewrite
+these org files. todo-cleanup runs the most often of the three (every wrap,
+every sentry fire), and Emacs's own backup does not fire under --batch -q, so
+without this a mechanical rewrite has no undo short of git — which recovers
+only to the last commit and loses intra-session work."
+ (let* ((base (format "%s%s.before-todo-cleanup.%s"
+ temporary-file-directory
+ (file-name-nondirectory file)
+ (format-time-string "%Y%m%d-%H%M%S")))
+ (backup base)
+ (n 2))
+ ;; Never overwrite an earlier backup. A second-resolution stamp collides
+ ;; when two invocations run back to back, which the shipped workflow does
+ ;; (open-tasks.org runs --convert-subtasks then --archive-done, each a
+ ;; sub-second batch run). Overwriting there replaces the true pre-session
+ ;; original with already-mutated content — losing exactly what the backup
+ ;; exists to preserve. Suffix instead, so every invocation keeps its own.
+ (while (file-exists-p backup)
+ (setq backup (format "%s-%d" base n))
+ (setq n (1+ n)))
+ (copy-file file backup nil)
+ backup))
+
(defun tc-process-file (file)
(setq tc-current-file (file-name-nondirectory file))
(setq tc-current-dir (file-name-directory (expand-file-name file)))
+ (unless tc-check-only
+ (tc--backup file))
(with-current-buffer (find-file-noselect file)
(org-mode)
(cond
@@ -590,6 +876,10 @@ 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))
+ (tc-seal
+ (tc-seal-archive-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 +974,50 @@ 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-seal-report ()
+ (dolist (i (reverse tc-issues))
+ (pcase (plist-get i :kind)
+ ('seal-done
+ (princ (format "todo-cleanup --seal: sealed task-archive.org → %s\n"
+ (plist-get i :detail))))
+ ('seal-would
+ (princ (format "todo-cleanup --seal: would seal task-archive.org → %s — CHECK MODE (no writes)\n"
+ (plist-get i :detail))))
+ ('seal-collision
+ (princ (format "todo-cleanup --seal: %s already exists — not sealing (already sealed today?)\n"
+ (plist-get i :detail))))
+ ('seal-nothing
+ (princ "todo-cleanup --seal: no working archive to seal\n")))))
+
(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))
+ (tc-seal (tc--emit-seal-report))
(t (tc--emit-hygiene-report))))
(defun tc-main ()
@@ -701,6 +1032,12 @@ 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)))
+ (when (member "--seal" command-line-args-left)
+ (setq tc-seal t)
+ (setq command-line-args-left (delete "--seal" 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 +1045,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 | --seal | --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 +1064,8 @@ ert-run-tests-batch-and-exit'."
(cl-every (lambda (a)
(cond ((member a '("--check"
"--archive-done"
+ "--seal"
+ "--convert-subtasks"
"--sync-child-priority"
"--check-child-priority"))
t)
diff --git a/claude-templates/.ai/scripts/upcoming_birthdays.py b/claude-templates/.ai/scripts/upcoming_birthdays.py
new file mode 100755
index 0000000..d3f30c0
--- /dev/null
+++ b/claude-templates/.ai/scripts/upcoming_birthdays.py
@@ -0,0 +1,182 @@
+#!/usr/bin/env python3
+"""Upcoming-birthdays block for daily prep.
+
+Reads an org-contacts file for ``:BIRTHDAY: YYYY-MM-DD`` properties, finds the
+ones whose next occurrence falls within a window (default 30 days) from today,
+and prints a daily-prep block: name, date, days-away, and — when the birth year
+is real — the age the person is turning. Many contacts use ``1900`` as a
+placeholder year when the real one is unknown; those render date-only, no age.
+Anything within the callout window (default 7 days) is flagged so a gift or
+plan gets prompted.
+
+Pure core:
+ parse_birthdays(text) -> [Birthday(name, month, day, year|None), ...]
+ upcoming(birthdays, today, window=30) -> [Upcoming(name, date, days_away, age|None), ...]
+ format_block(items, window, callout_days=7) -> str
+
+CLI:
+ upcoming_birthdays.py [--file PATH] [--today YYYY-MM-DD] [--window N] [--callout N]
+prints the block on stdout. Default --file is ~/sync/org/contacts.org.
+"""
+
+import argparse
+import datetime as dt
+import re
+import sys
+from dataclasses import dataclass
+from pathlib import Path
+
+PLACEHOLDER_YEAR = 1900
+DEFAULT_CONTACTS = Path.home() / "sync" / "org" / "contacts.org"
+
+_HEADING_RE = re.compile(r"^\*+\s+(.*?)\s*$")
+_TAGS_RE = re.compile(r"\s+:[A-Za-z0-9_@#%:]+:$")
+_BIRTHDAY_RE = re.compile(r"^\s*:BIRTHDAY:\s*(\d{4})-(\d{2})-(\d{2})\s*$")
+
+
+@dataclass(frozen=True)
+class Birthday:
+ name: str
+ month: int
+ day: int
+ year: int | None # None when the source used the 1900 placeholder
+
+
+@dataclass(frozen=True)
+class Upcoming:
+ name: str
+ date: dt.date
+ days_away: int
+ age: int | None # None when the birth year is unknown
+
+
+def _clean_name(heading_text: str) -> str:
+ """Strip a trailing org tag cluster from a heading's text."""
+ return _TAGS_RE.sub("", heading_text).strip()
+
+
+def parse_birthdays(text: str) -> list[Birthday]:
+ """Extract (name, month, day, year|None) for every contact with a BIRTHDAY.
+
+ The name is the nearest preceding org heading. A birth year of 1900 is the
+ placeholder org-contacts uses for an unknown year and is returned as None.
+ Malformed birthday lines are ignored.
+ """
+ birthdays: list[Birthday] = []
+ current_name: str | None = None
+ for line in text.splitlines():
+ heading = _HEADING_RE.match(line)
+ if heading:
+ current_name = _clean_name(heading.group(1))
+ continue
+ bday = _BIRTHDAY_RE.match(line)
+ if bday and current_name:
+ year, month, day = (int(g) for g in bday.groups())
+ # Guard against a nonsense month/day that regex width still admits.
+ try:
+ dt.date(2000, month, day)
+ except ValueError:
+ continue
+ birthdays.append(
+ Birthday(
+ current_name,
+ month,
+ day,
+ None if year == PLACEHOLDER_YEAR else year,
+ )
+ )
+ return birthdays
+
+
+def _next_occurrence(month: int, day: int, today: dt.date) -> dt.date:
+ """First date on/after ``today`` landing on this month/day.
+
+ A Feb 29 birthday maps to Feb 28 in a non-leap year.
+ """
+ def on(year: int) -> dt.date:
+ try:
+ return dt.date(year, month, day)
+ except ValueError:
+ # Only Feb 29 can fail here; fall back to Feb 28.
+ return dt.date(year, 2, 28)
+
+ candidate = on(today.year)
+ if candidate < today:
+ candidate = on(today.year + 1)
+ return candidate
+
+
+def upcoming(
+ birthdays: list[Birthday], today: dt.date, window: int = 30
+) -> list[Upcoming]:
+ """Birthdays whose next occurrence is within ``window`` days, soonest first."""
+ items: list[Upcoming] = []
+ for b in birthdays:
+ occ = _next_occurrence(b.month, b.day, today)
+ days = (occ - today).days
+ if 0 <= days <= window:
+ age = None if b.year is None else occ.year - b.year
+ items.append(Upcoming(b.name, occ, days, age))
+ items.sort(key=lambda u: (u.days_away, u.name))
+ return items
+
+
+def _days_phrase(days: int) -> str:
+ if days == 0:
+ return "today"
+ if days == 1:
+ return "tomorrow"
+ return f"in {days} days"
+
+
+def format_block(items: list[Upcoming], window: int, callout_days: int = 7) -> str:
+ """Render the daily-prep block. Callout entries (within ``callout_days``) are
+ flagged with a marker and a plan-a-gift nudge."""
+ if not items:
+ return f"No birthdays in the next {window} days."
+
+ lines = [f"Upcoming birthdays (next {window} days):"]
+ for u in items:
+ callout = u.days_away <= callout_days
+ marker = "⚠" if callout else "·"
+ date_str = u.date.strftime("%a %b %d")
+ piece = f" {marker} {date_str} — {u.name}"
+ if u.age is not None:
+ piece += f" turns {u.age}"
+ piece += f" ({_days_phrase(u.days_away)})"
+ if callout:
+ piece += " — plan a gift/card"
+ lines.append(piece)
+ return "\n".join(lines)
+
+
+def _parse_today(value: str | None) -> dt.date:
+ if value is None:
+ return dt.date.today()
+ return dt.date.fromisoformat(value)
+
+
+def main(argv: list[str] | None = None) -> int:
+ parser = argparse.ArgumentParser(description="Print the upcoming-birthdays daily-prep block.")
+ parser.add_argument("--file", type=Path, default=DEFAULT_CONTACTS,
+ help="org-contacts file (default: ~/sync/org/contacts.org)")
+ parser.add_argument("--today", default=None,
+ help="override today's date (YYYY-MM-DD), for testing")
+ parser.add_argument("--window", type=int, default=30,
+ help="look-ahead window in days (default: 30)")
+ parser.add_argument("--callout", type=int, default=7,
+ help="flag birthdays within this many days (default: 7)")
+ args = parser.parse_args(argv)
+
+ if not args.file.exists():
+ print(f"contacts file not found: {args.file}", file=sys.stderr)
+ return 1
+
+ text = args.file.read_text(encoding="utf-8")
+ items = upcoming(parse_birthdays(text), _parse_today(args.today), args.window)
+ print(format_block(items, args.window, args.callout))
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/claude-templates/.ai/scripts/wrap-org-table.el b/claude-templates/.ai/scripts/wrap-org-table.el
index ddbea65..173e44d 100644
--- a/claude-templates/.ai/scripts/wrap-org-table.el
+++ b/claude-templates/.ai/scripts/wrap-org-table.el
@@ -228,22 +228,40 @@ continuation lines merge back into their logical row before re-wrapping."
;;; file layer
(defun wot-process-file (file &optional budget)
- "Reformat every org table in FILE in place to BUDGET width."
+ "Reformat every org table in FILE in place to BUDGET width.
+Pipe-led lines inside #+begin_/#+end_ blocks (example, src, quote, …) are
+content, not tables — ASCII art in an example block once got mangled into a
+bordered table — so block regions are skipped verbatim."
(with-temp-buffer
(insert-file-contents file)
(goto-char (point-min))
- (while (re-search-forward "^[ \t]*|" nil t)
- (let ((start (line-beginning-position)))
- (while (and (not (eobp))
- (save-excursion (beginning-of-line)
- (looking-at "[ \t]*|")))
+ (let ((in-block nil)) ; the open block's type, e.g. "example" — nil outside
+ (while (not (eobp))
+ (cond
+ ;; Only the matching #+end_<type> closes a block: an example block
+ ;; often quotes literal #+begin_src/#+end_src lines, and a boolean
+ ;; flag would let that inner literal end-marker re-expose the rest
+ ;; of the block to reformatting.
+ ((and (not in-block)
+ (looking-at "^[ \t]*#\\+begin_\\([^ \t\n]+\\)"))
+ (setq in-block (downcase (match-string 1)))
(forward-line 1))
- (let* ((end (point))
- (table (buffer-substring-no-properties start end))
- (reformatted (wot-reformat-table-string table budget)))
- (delete-region start end)
- (goto-char start)
- (insert reformatted))))
+ ((and in-block
+ (looking-at-p (format "^[ \t]*#\\+end_%s\\([ \t]\\|$\\)"
+ (regexp-quote in-block))))
+ (setq in-block nil)
+ (forward-line 1))
+ ((and (not in-block) (looking-at-p "^[ \t]*|"))
+ (let ((start (point)))
+ (while (and (not (eobp)) (looking-at-p "^[ \t]*|"))
+ (forward-line 1))
+ (let* ((end (point))
+ (table (buffer-substring-no-properties start end))
+ (reformatted (wot-reformat-table-string table budget)))
+ (delete-region start end)
+ (goto-char start)
+ (insert reformatted))))
+ (t (forward-line 1)))))
(write-region (point-min) (point-max) file)))
;;; ---------------------------------------------------------------------------
@@ -289,7 +307,21 @@ so the ERT suite can `require' this file without firing the CLI dispatch."
(t (file-readable-p a))))
command-line-args-left)))
-(when (and noninteractive (wot--cli-invocation-p))
+(defun wot--entry-script-p ()
+ "Non-nil when wrap-org-table.el itself was named on the command line.
+lint-org.el `require's this file, and a load-triggered dispatch would run
+the table reformatter over lint-org's file arguments — that's how a lint
+invocation once reformatted the files it was only supposed to report on.
+Only dispatch when a -l/--load argument names this very file."
+ (and load-file-name
+ (cl-loop for (flag arg) on command-line-args
+ thereis (and (member flag '("-l" "--load"))
+ (stringp arg)
+ (file-exists-p arg)
+ (string= (file-truename (expand-file-name arg))
+ (file-truename load-file-name))))))
+
+(when (and noninteractive (wot--entry-script-p) (wot--cli-invocation-p))
(wot-main))
(provide 'wrap-org-table)
diff --git a/claude-templates/.ai/workflows/INDEX.org b/claude-templates/.ai/workflows/INDEX.org
index a474b29..f18d953 100644
--- a/claude-templates/.ai/workflows/INDEX.org
+++ b/claude-templates/.ai/workflows/INDEX.org
@@ -1,5 +1,5 @@
#+TITLE: Workflow Index
-#+AUTHOR: Craig Jennings & Claude
+#+AUTHOR: Craig Jennings
#+DATE: 2026-04-25
* Purpose
@@ -54,6 +54,14 @@ This index must list every =.org= file in =.ai/workflows/= except this one and e
- Roam-mode triggers: "inbox zero", "empty the inbox", "process the roam inbox", "triage my roam inbox"
- Auto-mode trigger: "auto inbox zero" (match before "inbox zero")
+- =work-the-backlog.org= — the autonomous task-execution loop, the single home for working a batch of marked tasks unattended: takes an ordered task set (explicit list or tag query) + session mode (=file-only= default / =autonomous-commit= + paging) + a hard run cap; each candidate passes the mechanical eligibility gate (status =TODO= + =:solo:= per the project's scheme header) and the four-item defer checklist, then is implemented to the full quality bar (TDD, =/review-code=, =/voice=) as its own logical commits. Fed by the inbox auto-loop's chain step (yes-gated, file-only, cap 1) and the no-approvals speedrun preset (pre-flight Q&A → autonomous-commit + always-push + end-of-set page over an explicit ordered list).
+ - Speedrun triggers: "speedrun", "no approvals speedrun", "speedrun these: <task set>" — any phrase containing "speedrun" routes here (the preset), never to =no-approvals.org=
+ - Manual triggers: "work the backlog", "work the backlog with <task set>" (file-only defaults)
+ - Synthesis trigger: "synthesize backlog metrics" — read the per-project metrics logs, compute trends + the corrections signal, write one =:agent:metrics:= KB node (personal projects only)
+- =sentry.org= — the overnight hygiene supervisor: an interval loop (default hourly) that walks a fixed pass list (roam pull, inbox zero, triage, todo cleanup, task audit, working-files hygiene, spec board, link integrity, git health, prep freshness), commits each writing pass to a throwaway =sentry/<date>-<host>= branch (never pushed), and parks every judgment call and destructive action in a morning-approval queue. Gated on =:COMMIT_AUTONOMY: yes= plus interactive entry gates (clean tree, green suite) with Craig present. Locks via =agent-lock=; morning teardown (review, squash-merge, delete) is Craig's, never automated.
+ - Triggers: "start sentry", "run sentry", "arm sentry", "sentry mode", "start sentry every <interval>"
+ - Stop trigger: "stop sentry", "stand down sentry", "sentry off"
+
** Calendar
- =add-calendar-event.org= — create a calendar event.
@@ -105,8 +113,8 @@ This index must list every =.org= file in =.ai/workflows/= except this one and e
- Situational triggers: "broadcast the <event> to all projects", "broadcast that <situation>", "let every project know I'll be away ..."
- =flashcard-review.org= — review an org-drill flashcard file, restructure cards to question-form headings (no answer hints), audit content accuracy against project source-of-truth via subagent, rewrite source preserving SRS state, regenerate the Anki =.apkg= to =~/sync/phone/anki/=. Person cards use "Who is X? Tell me about their Y."; talking-points cards stay as-is. Script behavior: =flashcard-to-anki.py= strips =:PROPERTIES:= drawers + =SCHEDULED:= / =DEADLINE:= planning lines from Anki output.
- Triggers: "review the flashcards", "update the flashcards", "review the drill deck", "update the drill deck", "refresh the Anki cards", "let's run the flashcard-review workflow"
-- =page-me.org= — set a timed notification.
- - Triggers: anything containing the word "page" used as a verb ("page me", "page me in 10 minutes", "page me at 3pm")
+- =page-me.org= — set a timed notification. "page me" desktop =notify=, "text me" phone via =agent-text=, "text and page me" both.
+ - Triggers: anything containing the word "page" used as a verb ("page me", "page me in 10 minutes", "page me at 3pm", "page my phone")
- =status-check.org= — proactive long-running-job updates.
- Triggers: "keep me posted on this", "provide status checks on this job", "let me know when it's done", "monitor this for me". Auto: any job estimated 10+ min.
- =create-workflow.org= — define a new workflow.
@@ -117,6 +125,7 @@ This index must list every =.org= file in =.ai/workflows/= except this one and e
- Triggers: "session harvest", "harvest the sessions", "let's run the session-harvest workflow", "monthly harvest", "mine the sessions"
- =no-approvals.org= — drop the interaction-level approval gates for a pre-agreed batch while keeping engineering-discipline gates (=/review-code=, =/voice personal=, tests, session-log updates, subagent reviews, destructive-action consent). Mode stays on until Craig turns it off, a real question arises, the queue empties, or the conversation switches topics.
- Triggers: "no-approvals mode", "no approvals", "no-approval", "no need for approval gates", "stop asking, just keep going", "I'll check back in when you're done or stuck", "do all =<selector>= with no-approval"
+ - Exception: any phrase containing "speedrun" routes to =work-the-backlog.org='s no-approvals speedrun preset instead
* Living Document
diff --git a/claude-templates/.ai/workflows/add-calendar-event.org b/claude-templates/.ai/workflows/add-calendar-event.org
index 2650fb7..5dd6c42 100644
--- a/claude-templates/.ai/workflows/add-calendar-event.org
+++ b/claude-templates/.ai/workflows/add-calendar-event.org
@@ -1,5 +1,5 @@
#+TITLE: Add Calendar Event Workflow
-#+AUTHOR: Craig Jennings & Claude
+#+AUTHOR: Craig Jennings
#+DATE: 2026-02-01
* Overview
diff --git a/claude-templates/.ai/workflows/broadcast.org b/claude-templates/.ai/workflows/broadcast.org
index 60e9ed1..cc14f00 100644
--- a/claude-templates/.ai/workflows/broadcast.org
+++ b/claude-templates/.ai/workflows/broadcast.org
@@ -1,5 +1,5 @@
#+TITLE: Broadcast Workflow
-#+AUTHOR: Craig Jennings & Claude
+#+AUTHOR: Craig Jennings
#+DATE: 2026-05-29
* Overview
diff --git a/claude-templates/.ai/workflows/clean-todo.org b/claude-templates/.ai/workflows/clean-todo.org
index dd33056..48d3084 100644
--- a/claude-templates/.ai/workflows/clean-todo.org
+++ b/claude-templates/.ai/workflows/clean-todo.org
@@ -1,5 +1,5 @@
#+TITLE: Clean-Todo Workflow
-#+AUTHOR: Craig Jennings & Claude
+#+AUTHOR: Craig Jennings
#+DATE: 2026-05-11
* Overview
@@ -27,7 +27,17 @@ Deletes bogus =- State "X" from "X" [date]= log lines (state didn't actually cha
To preview without writing, run =--check= first: =emacs --batch -q -l .ai/scripts/todo-cleanup.el --check todo.org=.
-** Step 2: Archive completed work
+** Step 2: Convert done sub-tasks to dated entries
+
+#+begin_src bash
+emacs --batch -q -l .ai/scripts/todo-cleanup.el --convert-subtasks todo.org
+#+end_src
+
+Rewrites every heading at level 3 or deeper 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 =CLOSED:= line. Enforces the depth rule that a completed sub-task becomes dated history — a shape interactive org closes and =--archive-done= (level-2 only) leave unapplied. Timestamp comes from each entry's =CLOSED= cookie; heading text kept verbatim; idempotent; a done sub-task with no parseable =CLOSED= is flagged and left alone. Run before archiving so a parent's sub-tasks are already dated when it moves. Capture the output.
+
+To preview without writing: =emacs --batch -q -l .ai/scripts/todo-cleanup.el --convert-subtasks --check todo.org=.
+
+** Step 3: Archive completed work
#+begin_src bash
emacs --batch -q -l .ai/scripts/todo-cleanup.el --archive-done todo.org
@@ -37,10 +47,11 @@ Moves every level-2 subtree whose TODO state is DONE or CANCELLED out of the "Op
To preview the moves without writing: =emacs --batch -q -l .ai/scripts/todo-cleanup.el --archive-done --check todo.org=.
-** Step 3: Summarize
+** Step 4: Summarize
-Report to Craig from the two captured outputs:
+Report to Craig from the three captured outputs:
- Hygiene: how many bogus state-log lines were deleted; any orphan-planning warnings (file:line + heading), or "none".
+- Convert: how many done sub-tasks were rewritten to dated entries (heading + line), any flagged for no =CLOSED= date, or "nothing to convert".
- Archive: how many subtrees moved and which (heading + line), or "nothing to move" / the skip reason if a section was missing or ambiguous.
- If the file changed, note that =todo.org= now has an uncommitted edit — review =git diff -- todo.org= and commit it (in this repo's commit style) if it looks right. If nothing changed, say so and stop.
@@ -49,7 +60,7 @@ Don't auto-commit. The summary is the review point; Craig decides whether the di
* Principles
- *Both passes apply, not just preview.* The workflow is invoked because cleanup is wanted. Use the =--check= variants only when Craig asks for a dry run.
-- *Two passes, two invocations.* =--archive-done= is its own mode and does not run the hygiene pass; run both.
+- *Separate modes, separate invocations.* =--convert-subtasks=, =--archive-done=, and the hygiene pass are each their own mode and don't run the others; run all three.
- *Never auto-commit todo.org.* Surface the diff and let Craig commit it. The cleanup is a working-tree change, fully reversible until committed.
- *Trust the script.* It's fast and idempotent; if there's nothing to do, it reports zero and exits clean. No pre-checks.
diff --git a/claude-templates/.ai/workflows/code-quality.org b/claude-templates/.ai/workflows/code-quality.org
index 2406f4c..3c4ed8f 100644
--- a/claude-templates/.ai/workflows/code-quality.org
+++ b/claude-templates/.ai/workflows/code-quality.org
@@ -1,5 +1,5 @@
#+TITLE: Code-Quality Sweep Workflow
-#+AUTHOR: Craig Jennings & Claude
+#+AUTHOR: Craig Jennings
#+DATE: 2026-06-28
* Overview
@@ -9,6 +9,13 @@ One trigger that runs every behavior-preserving quality pass over a scope of
orchestrator — each pass keeps its own discipline and its own confirm gate; this
workflow only sequences them and collects the residue.
+*Behavior-preserving rests on a test net.* The passes below claim to preserve
+behavior, but a refactor on untested code is a guess, not a preservation. Where
+the scope has no tests, bring it under a characterization net first
+(Normal/Boundary/Error per unit, per the =testing-standards= skill's "Adding Tests to Existing
+Untested Code") — that net is what turns "behavior-preserving" from an assertion
+into something the green suite actually verifies across each pass.
+
The passes it chains:
1. =/refactor= — structural and logic cleanup on measurable metrics (complexity,
diff --git a/claude-templates/.ai/workflows/daily-prep.org b/claude-templates/.ai/workflows/daily-prep.org
index b6989e7..3f21214 100644
--- a/claude-templates/.ai/workflows/daily-prep.org
+++ b/claude-templates/.ai/workflows/daily-prep.org
@@ -1,5 +1,5 @@
#+TITLE: Daily Prep Workflow
-#+AUTHOR: Craig Jennings & Claude
+#+AUTHOR: Craig Jennings
#+DATE: 2026-06-11
* Overview
@@ -74,7 +74,7 @@ The separate =* Standup Briefs= and =* Upcoming Deadlines= sections are *retired
Items that frame the day. Four standing items are *always* present, plus the look-ahead:
1. *Meeting-density framing.* One line on the day's shape, e.g. "Meeting-dense morning: 09:00 team discussion, 10:00 standup, 11:00 general standup. Real focus time only opens at noon."
-2. *Calendar events from BOTH calendars* (work + personal): birthdays, holidays, anniversaries, vacations, trips, big events. "Your trip begins Friday." "It's <person>'s birthday today."
+2. *Calendar events from BOTH calendars* (work + personal): birthdays, holidays, anniversaries, vacations, trips, big events. "Your trip begins Friday." "It's <person>'s birthday today." Fold in the =upcoming_birthdays.py= block from Phase A (source 8) for contact birthdays the calendar doesn't carry; keep its callout entries (within 7 days) so a gift or plan gets prompted.
3. *Reminders due, imminently due, or past trigger* — from notes.org Active Reminders plus scheduled/deadline tasks. A reminder tied to a rescheduled meeting reports against the new date.
4. *Requested metrics* — a slot for any metric Craig has asked to track in the daily prep. Render the slot only when a metric is active; none are active by default. (Metric design is a separate discussion — don't invent metrics.)
5. *5-Day Look-Ahead* — one day per line, format =Fri 12:= / =Mon 15:=, including clear days marked =clear=. Built by Phase 1's forward scan with the invite quick-read and decline gate applied.
@@ -192,6 +192,7 @@ Pull every source in a *single batch of parallel tool calls*:
5. Pull the project tracker's view of Craig's plate (assigned issues, items in review, blocked items) where the project has one.
6. List + read the *previous* prep doc. Glob =daily-prep/*-daily-prep.org=, sort by date, take the file *before* the one the root =daily-prep.org= symlink resolves to. If the symlink doesn't resolve yet, take the most recent file. The standup lookback anchors on this file's date.
7. Read the most recent =.ai/sessions/= summary (for the standup brief's lookback).
+8. Run =.ai/scripts/upcoming_birthdays.py= (reads =~/sync/org/contacts.org=). Its block feeds the Heads-Up birthday line — contacts carry birthdays the calendar doesn't, and anything inside 7 days comes back flagged so a gift/plan gets prompted.
This fetch *is* the live calendar read for build time. In Update mode, re-run the calendar fetches — never reuse the build-time snapshot.
@@ -239,7 +240,7 @@ Assemble priorities from both sources; no mid-flow confirmation (the gate handle
*** Sub-step 3b: Triage external sources (delegate to triage-intake.org)
-Don't scan email / Slack / tracker / PRs inline. Run the =triage-intake.org= engine (if the mode's freshness check already ran one this hour, use its synthesis). It classifies everything new against the four-bucket model, writes every Action item into =todo.org= as its own =:quick:reactive:= task, and executes the routine actions on confirmation. Source coverage comes from its Phase 0 plugin load — both =.ai/workflows/triage-intake.*.org= (general) and =.ai/project-workflows/triage-intake.*.org= (project-specific). A missing source is a missing plugin, not a daily-prep regression.
+Don't scan email / Slack / tracker / PRs inline. Run the =triage-intake.org= engine (if the mode's freshness check already ran one this hour, use its synthesis). It surfaces the three-section digest (==TASKS== / ==FYI== / ==MISC==) and then closes by default per its Phase D — filing every TASKS item into =todo.org= as its own =:quick:reactive:= task and running the routine mail hygiene without itemized confirmation. Source coverage comes from its Phase 0 plugin load — both =.ai/workflows/triage-intake.*.org= (general) and =.ai/project-workflows/triage-intake.*.org= (project-specific). A missing source is a missing plugin, not a daily-prep regression.
*** Sub-step 3c: Build the entries
diff --git a/claude-templates/.ai/workflows/delete-calendar-event.org b/claude-templates/.ai/workflows/delete-calendar-event.org
index 5bb92a1..7de0086 100644
--- a/claude-templates/.ai/workflows/delete-calendar-event.org
+++ b/claude-templates/.ai/workflows/delete-calendar-event.org
@@ -1,5 +1,5 @@
#+TITLE: Delete Calendar Event Workflow
-#+AUTHOR: Craig Jennings & Claude
+#+AUTHOR: Craig Jennings
#+DATE: 2026-02-01
* Overview
diff --git a/claude-templates/.ai/workflows/edit-calendar-event.org b/claude-templates/.ai/workflows/edit-calendar-event.org
index 662f0b4..27a9dd3 100644
--- a/claude-templates/.ai/workflows/edit-calendar-event.org
+++ b/claude-templates/.ai/workflows/edit-calendar-event.org
@@ -1,5 +1,5 @@
#+TITLE: Edit Calendar Event Workflow
-#+AUTHOR: Craig Jennings & Claude
+#+AUTHOR: Craig Jennings
#+DATE: 2026-02-01
* Overview
diff --git a/claude-templates/.ai/workflows/email-assembly.org b/claude-templates/.ai/workflows/email-assembly.org
index 003459c..699dbc0 100644
--- a/claude-templates/.ai/workflows/email-assembly.org
+++ b/claude-templates/.ai/workflows/email-assembly.org
@@ -1,5 +1,5 @@
#+TITLE: Email Assembly Workflow
-#+AUTHOR: Craig Jennings & Claude
+#+AUTHOR: Craig Jennings
#+DATE: 2026-01-29
* Overview
diff --git a/claude-templates/.ai/workflows/extract-email.org b/claude-templates/.ai/workflows/extract-email.org
index 3a70bea..c68bafe 100644
--- a/claude-templates/.ai/workflows/extract-email.org
+++ b/claude-templates/.ai/workflows/extract-email.org
@@ -1,5 +1,5 @@
#+TITLE: Extract Email Workflow
-#+AUTHOR: Craig Jennings & Claude
+#+AUTHOR: Craig Jennings
#+DATE: 2026-02-06
* Overview
diff --git a/claude-templates/.ai/workflows/find-email.org b/claude-templates/.ai/workflows/find-email.org
index 0ef9615..d71ed3e 100644
--- a/claude-templates/.ai/workflows/find-email.org
+++ b/claude-templates/.ai/workflows/find-email.org
@@ -1,5 +1,5 @@
#+TITLE: Find Email Workflow
-#+AUTHOR: Craig Jennings & Claude
+#+AUTHOR: Craig Jennings
#+DATE: 2026-02-01
* Overview
diff --git a/claude-templates/.ai/workflows/first-session.org b/claude-templates/.ai/workflows/first-session.org
index 60118a2..147026f 100644
--- a/claude-templates/.ai/workflows/first-session.org
+++ b/claude-templates/.ai/workflows/first-session.org
@@ -1,5 +1,5 @@
#+TITLE: First Session Workflow
-#+AUTHOR: Craig Jennings & Claude
+#+AUTHOR: Craig Jennings
Run this workflow on the first Claude Code session for a new
project. It establishes the git/.ai policy, orients Claude to the
diff --git a/claude-templates/.ai/workflows/flashcard-review.org b/claude-templates/.ai/workflows/flashcard-review.org
index 31027b3..09af348 100644
--- a/claude-templates/.ai/workflows/flashcard-review.org
+++ b/claude-templates/.ai/workflows/flashcard-review.org
@@ -1,5 +1,5 @@
#+TITLE: Drill Deck Review Workflow
-#+AUTHOR: Craig Jennings & Claude
+#+AUTHOR: Craig Jennings
#+DATE: 2026-05-30
* Overview
diff --git a/claude-templates/.ai/workflows/helper-mode.org b/claude-templates/.ai/workflows/helper-mode.org
index cdec200..a6acfa7 100644
--- a/claude-templates/.ai/workflows/helper-mode.org
+++ b/claude-templates/.ai/workflows/helper-mode.org
@@ -1,5 +1,5 @@
#+TITLE: Helper Mode Workflow
-#+AUTHOR: Craig Jennings & Claude
+#+AUTHOR: Craig Jennings
#+DATE: 2026-06-15
* Overview
diff --git a/claude-templates/.ai/workflows/inbox.org b/claude-templates/.ai/workflows/inbox.org
index 5fc855f..6faa20f 100644
--- a/claude-templates/.ai/workflows/inbox.org
+++ b/claude-templates/.ai/workflows/inbox.org
@@ -1,5 +1,5 @@
#+TITLE: Inbox Workflow (Engine)
-#+AUTHOR: Craig Jennings & Claude
+#+AUTHOR: Craig Jennings
#+DATE: 2026-06-23
* Overview
@@ -114,6 +114,14 @@ The item extends a task already filed. Update the parent TODO's body with a date
** File as TODO
Substantive but waits, or needs design/triage before implementation. Add the TODO under =* <Project> Open Work= with priority + tags per the priority-scheme check (core §6). Body summarizes the proposal and links the inbox content if it's been moved to =docs/design/=. Delete the inbox file (or move it to =docs/design/= first if the content survives).
+*Route-candidate marking (feeds the wrap-up router).* After filing, check whether the keeper's inferred home is a different project:
+
+#+begin_src bash
+python3 .ai/scripts/route_recommend.py --item "<the keeper's heading + body text>" --exclude "$(basename "$PWD")"
+#+end_src
+
+On a =<destination>\tstrong= or =<destination>\tweak= result, stamp the new TODO's property drawer with =:ROUTE_CANDIDATE: <destination>= (create the drawer if the task has none). A =none= result stamps nothing, and a local keeper stays unstamped. The marker is the wrap-up router's entire candidate set — =wrap-it-up.org= Step 3 surfaces exactly the =:ROUTE_CANDIDATE:=-tagged tasks and offers to deliver each to its destination's inbox, never scanning the standing backlog. Stamping is cheap and reversible (the router's skip leaves the task in place; a wrong marker is one property line to delete), so prefer stamping on any plausible match — the human reviews the batch at wrap time.
+
*Blocking-dependency handoff.* A special shape: another project sends a note that *this* project's work is blocking one of theirs ("your task X is blocked on us — we need Y"). File or link the owning task, tag it =:blocker:=, and name the requesting project in the body (see the cross-project dependency convention in =todo-format.md=). The =:blocker:= tag makes =open-tasks.org= surface that task *first*, since clearing it unblocks the other project. Dedup against an existing task rather than filing a duplicate. When the work later lands, drop =:blocker:= and notify the waiting project (=inbox-send <their-project> --text "Delivered: <what> — you're unblocked."=) so it can lift its own =:blocked:=.
** Defer
@@ -155,6 +163,20 @@ An org capture is usually only a few seconds of mid-finalize state, so =--wait=
- *Auto inbox zero (=/loop=) cycle* → don't surface or wait further; defer the roam reconcile to the next cycle, which is itself the retry at loop cadence. The items were already filed in Phase C, so the next cycle's Phase C status-check drops the duplicates and its Phase D removes them. Note one line: "roam reconcile deferred — a capture is still open; next cycle catches it."
- *Wrap-up sub-step* → don't block the wrap. Skip the roam reconcile for this run and surface one line: "Skipped roam-inbox reconcile — a live org-capture is open against it; claimed items stay and get caught next run." The items were already filed into =todo.org= in roam mode Phase C, so the next roam run's Phase C status-check drops the duplicates and its Phase D removes them — the skip self-heals.
+*The roam-write lock (around the Phase D edit).* Capture-guard protects against a live *human* capture; the roam-write lock protects against a concurrent *agent* writer (a sentry inbox pass, a KB promotion) editing =~/org/roam= at the same time. Acquire it after the capture-guard clears and release it after the edit-and-trigger, so the two guards nest — capture-guard underneath, the agent lock around the write:
+
+#+begin_src bash
+if [ -x .ai/scripts/agent-lock ]; then
+ .ai/scripts/agent-lock acquire roam-write --wait || { echo "roam-write busy; deferring roam reconcile" >&2; exit 1; }
+fi
+# capture-guard (above), then the Phase D read-modify-write of ~/org/roam/inbox.org,
+# then trigger the sync — roam-sync stays the only committer:
+systemctl --user start roam-sync.service
+[ -x .ai/scripts/agent-lock ] && .ai/scripts/agent-lock release roam-write
+#+end_src
+
+Degrade gracefully when =agent-lock= isn't installed (an older checkout mid-sync): the write proceeds unlocked, today's behavior. A *present* helper reporting the lock busy after its bounded wait defers the roam reconcile (the auto-loop and wrap-up paths already defer-and-retry per the fallback list above); an *absent* helper never blocks it.
+
* Core §6 — Priority-scheme check
This gates filing whenever there are accept-and-file items. Check whether =todo.org= has a top-of-file priority scheme (an explicit legend defining =[#A]= through =[#D]= semantics and mandatory/optional tag conventions — a =* <Project> Priority Scheme= section or similar).
@@ -253,7 +275,7 @@ The gap it closes: handoffs that arrive mid-session used to sit unseen until the
Never begin monitoring on a dirty worktree or a failing test suite. A dirty tree means the auto-commit at the end of an executed item sweeps up unrelated changes; a red suite means you can't tell whether the monitor broke something. At the start:
-1. =git status --porcelain= is empty (clean worktree).
+1. =git status --porcelain --untracked-files=no= is empty (no tracked modifications). Untracked and gitignored files never block — an inbox drop is exactly what this mode processes, and a scratch file is none of its business (the template-freshness policy in =startup.org= Phase A.0). The tracked-only gate is safe because the per-item commit stages its files explicitly (=commits.md=: only intended changes staged) — never =git add -A=, which would sweep untracked files and is the failure this gate guards against.
2. A full test run is all green (=make test= here, or the project's full-suite command).
If *dirty*: offer to commit the pending changes in discrete, logical batches before starting. If *red*: offer to investigate the failures first. Surface the blocker with inline numbered options per =interaction.md= and wait — monitoring does not start until the tree is clean and the suite is green.
@@ -324,7 +346,7 @@ When Craig has put the session in no-approvals mode, an accepted item may be imp
2. *Quick* — the whole implementation, including verification, is under ~15 minutes.
3. *Solo* — you can carry it end to end without a decision from Craig. Manual verification you perform yourself is fine; needing Craig to choose an option, approve a design, or resolve an ambiguity is not.
-All three → implement it, verify, then commit and push at the end of that item (the Step 0 reconcile and pre-push check from =commits.md= still run). Miss any one and it doesn't self-apply: a shared-asset or convention change needs Craig's decision, so it fails *solo* and routes to the defer-and-stage park (core §2 / core §3); an oversized item fails *quick* and gets filed.
+All three → implement it, verify, then commit and push at the end of that item (the Step 0 reconcile and pre-push check from the =publish= skill still run). Miss any one and it doesn't self-apply: a shared-asset or convention change needs Craig's decision, so it fails *solo* and routes to the defer-and-stage park (core §2 / core §3); an oversized item fails *quick* and gets filed.
** Replying to handoffs
@@ -338,7 +360,7 @@ Close the loop per the reply-to-sender discipline (core §4): confirm what lande
End the way it started: clean worktree, green suite. Before stopping the loop or reporting the pass done:
-1. Commit or revert everything left in the worktree — nothing uncommitted remains.
+1. Commit or revert every tracked modification left in the worktree — no tracked change remains uncommitted. Untracked files (unprocessed inbox drops, scratch) are not the monitor's to sweep.
2. Run the full test suite once more and confirm all green.
If either can't be satisfied — a half-done item, a failure introduced during the pass — surface it rather than leaving it. The next monitor run assumes a clean, green starting state (the Preconditions gate).
@@ -347,6 +369,8 @@ If either can't be satisfied — a half-done item, a failure introduced during t
Reads the *global roam inbox* (=~/org/roam/inbox.org=), Craig's cross-project GTD capture: one shared file every project can see. This mode routes each roam item to the project that owns it. The current session claims only the items belonging to THIS project, files them into the project's =todo.org=, and removes them from the shared inbox. Everything it doesn't own stays.
+*Allowed from any project, work included.* Tidying the shared roam inbox is housekeeping on a shared resource, not a cross-project boundary crossing and not a durable KB-node write, so the =knowledge-base.md= work-denylist doesn't gate it (a sentry inbox-zero pass mis-parked the whole inbox as a boundary crossing from the work project on 2026-07-19 — the error this note closes). Reading roam and tidying its inbox are fine from work; only promoting a durable =agents/= node stays work-denylisted.
+
The aspiration is inbox zero: after this mode runs, the current project's local handoff inbox has been processed (Phase A delegates to process mode) and the shared roam inbox no longer contains items explicitly owned by this project.
This is distinct from the wrap-up inbox/transcript routing feature (which moves session-filed keepers between projects). This routes the shared roam capture file by ownership prefix.
@@ -453,18 +477,19 @@ Take these up when the single-destination version is in use and the multi-projec
* Mode: auto inbox zero
-A recurring, *interactive* roam check. Trigger phrase: "auto inbox zero" (match before "inbox zero" — the longer phrase wins). On invocation, *ask Craig for the interval* (e.g. 30 min, 2 hours), then drive the loop with =/loop <interval>= running roam mode. It is in-session and interactive by design — each cycle reports, and a find waits for Craig's go before any work happens.
+A recurring, *interactive* roam check. Trigger phrase: "auto inbox zero" (match before "inbox zero" — the longer phrase wins). On invocation, *ask Craig for the interval* (e.g. 30 min, 2 hours), then drive the loop with =/loop <interval>= running roam mode. It is in-session and interactive by design — each cycle reports what it found and filed.
** Per cycle
1. Run roam mode's scan (Phase A local check + Phase B roam scan), read-only — no =git pull=. The capture-guard still gates any write: use =capture-guard --wait= (core §5) so a transient capture clears itself; if it's still open after the wait, *defer this cycle's roam reconcile to the next cycle* rather than surfacing — the loop cadence is the retry, and the filed items get swept next time. The rare write hands its git to =roam-sync= (roam Phase D).
-2. *Nothing found* → no inbox summary. One acknowledgement line: =ran at HH:MM, nothing found=. Nothing else. The acknowledge-only-on-empty rule keeps a quiet inbox quiet.
+2. *Nothing found* → no inbox summary. One heartbeat line: =inbox zero at HH:MM: nothing= (HH:MM local, from =date=) — the silent-until-signal policy, see =docs/specs/2026-07-20-silent-until-signal-monitors-spec.org=. Nothing else. Keeping a quiet inbox quiet is the whole point.
3. *Items found* → summarize the found items, file them as tasks (roam Phase C), and *append them to a displayed queue* — the harness task list, via =TaskCreate= — so the queue accumulates across cycles. Then ask: "run this batch next?"
- - *Yes* → launch into implementing the found items, each through the normal disposition ladder (core §3) + verify flow.
+ - *Yes* → chain into =work-the-backlog.org= as an explicit second step after routing completes: pass it the eligibility query over the queued items (status =TODO= + =:solo:= per the scheme header, priority-ordered), =file-only= mode, paging off, cap 1. The highest-priority eligible candidate runs; the rest wait for the next tick or a later yes.
- *No* → they stay queued for a later go.
+ This mode never implements anything itself — routing ends here, and the execution loop lives in =work-the-backlog.org=, its one home.
4. *Cross-cycle dedup.* Subsequent cycles add only *newly-found* items to the same displayed queue, never re-surfacing what's already there. Dedup against the queue (the =TaskCreate= list), not against what's already been implemented — a find that was queued-but-not-yet-run must not reappear, and one already filed into =todo.org= is dropped by roam Phase C's status check.
-A find is always surfaced and gated on Craig's yes; a quiet inbox produces only the timestamped acknowledgement. =auto inbox zero= is inherently in-session because its execute step waits for a yes.
+A find is always surfaced and filed; execution happens only through the =work-the-backlog.org= chain and waits for Craig's yes. A quiet inbox produces only the =inbox zero at HH:MM: nothing= heartbeat. =auto inbox zero= is inherently in-session because its chain step waits for that yes.
** Fully-unattended pass (=/schedule=) — vNext, not v1
diff --git a/claude-templates/.ai/workflows/journal-entry.org b/claude-templates/.ai/workflows/journal-entry.org
index 3f476a7..c70dfe8 100644
--- a/claude-templates/.ai/workflows/journal-entry.org
+++ b/claude-templates/.ai/workflows/journal-entry.org
@@ -1,5 +1,5 @@
#+TITLE: Journal Entry Workflow
-#+AUTHOR: Craig Jennings & Claude
+#+AUTHOR: Craig Jennings
#+DATE: 2025-11-07
* Overview
diff --git a/claude-templates/.ai/workflows/meeting-prep.org b/claude-templates/.ai/workflows/meeting-prep.org
index 162ae30..563328b 100644
--- a/claude-templates/.ai/workflows/meeting-prep.org
+++ b/claude-templates/.ai/workflows/meeting-prep.org
@@ -1,5 +1,5 @@
#+TITLE: Meeting-Prep Workflow
-#+AUTHOR: Craig Jennings & Claude
+#+AUTHOR: Craig Jennings
#+DATE: 2026-06-10
* Overview
diff --git a/claude-templates/.ai/workflows/meeting-prep.pre-wire.org b/claude-templates/.ai/workflows/meeting-prep.pre-wire.org
index 6a156c0..3e27c2a 100644
--- a/claude-templates/.ai/workflows/meeting-prep.pre-wire.org
+++ b/claude-templates/.ai/workflows/meeting-prep.pre-wire.org
@@ -1,5 +1,5 @@
#+TITLE: Meeting-Prep — Pre-Wire Method (supporting doc)
-#+AUTHOR: Craig Jennings & Claude
+#+AUTHOR: Craig Jennings
#+DATE: 2026-06-10
Supporting document for the [[file:meeting-prep.org][meeting-prep workflow]]'s Phase 3.5. The workflow carries the condensed, in-flow version of pre-wiring; this file is the full Manager Tools method, kept beside the workflow (same name + =.pre-wire= suffix) so it travels with the workflow. Source casts: "How to Prewire a Meeting" (2007) and "Peer Prewire" (2015).
diff --git a/claude-templates/.ai/workflows/no-approvals.org b/claude-templates/.ai/workflows/no-approvals.org
index 1efce82..b4c7fcf 100644
--- a/claude-templates/.ai/workflows/no-approvals.org
+++ b/claude-templates/.ai/workflows/no-approvals.org
@@ -1,5 +1,5 @@
#+TITLE: No-Approvals Mode
-#+AUTHOR: Craig Jennings & Claude
+#+AUTHOR: Craig Jennings
#+DATE: 2026-05-28
* Overview
@@ -22,6 +22,8 @@ Craig activates the mode with any of:
- Queuing several tasks in =todo.org= followed by any phrase above
- Any equivalent phrasing that signals he doesn't want to be re-asked between items
+*Not this mode:* any phrase containing "speedrun" ("speedrun", "no approvals speedrun") routes to =work-the-backlog.org='s no-approvals speedrun preset — an autonomous batch over an explicit ordered task set, with a pre-flight Q&A, autonomous commits, always-push, and an end-of-set page. This mode is the general interaction-gate suspension for whatever work is already underway; the speedrun is the dedicated backlog-batch workflow.
+
Mode resets when:
- Craig says approvals are back on
@@ -33,7 +35,7 @@ Mode resets when:
The interaction gates that step the workflow back to Craig for an "OK to proceed?" check:
-- The commit-message gate in =commits.md= Step 2. Print the final commit message inline, then commit immediately. No "approve / request changes / open in editor" prompt.
+- The commit-message gate in the =publish= skill, Step 2. Print the final commit message inline, then commit immediately. No "approve / request changes / open in editor" prompt.
- The PR-description gate. Print the final body, then create the PR.
- The PR-review-reply gate. Print the final reply, then post.
- "Ready to start?" / "Plan looks like X, proceed?" gates before implementation work begins.
@@ -47,7 +49,7 @@ The engineering-discipline gates protect quality, not Craig's interaction time.
- =/review-code= against the staged diff before every commit. Critical and Important findings still block. Minor findings still surface. No "proceed anyway" override unless Craig has given it explicitly for this batch.
- =/voice personal= on every publish artifact (commit messages, PR titles + bodies, PR review comments). The full pattern walk happens. The printed result just doesn't wait for approval.
- The full test suite + lint + compile before commit (per =verification.md=).
-- Fetch-and-reconcile in =commits.md= Step 0.
+- Fetch-and-reconcile in the =publish= skill, Step 0.
- Session Log updates per =protocols.org=. Every state-mutating turn writes to =.ai/session-context.org= before the closing message. The log is the crash-recovery anchor while Craig is away. Missing entries lose work.
- Subagent review-gate cadence (=subagents.md=). Review each subagent's output before the next dispatch.
- Destructive or irreversible operations per =CLAUDE.md='s "Executing actions with care": force-push, =rm -rf=, dropping a column, dropping a branch, package removal. These need explicit consent regardless of mode. No-approvals is for *interaction* gates, not destructive-action consent.
diff --git a/claude-templates/.ai/workflows/open-tasks.org b/claude-templates/.ai/workflows/open-tasks.org
index 4ba29dd..205d95c 100644
--- a/claude-templates/.ai/workflows/open-tasks.org
+++ b/claude-templates/.ai/workflows/open-tasks.org
@@ -1,5 +1,5 @@
#+TITLE: Open Tasks Workflow
-#+AUTHOR: Craig Jennings & Claude
+#+AUTHOR: Craig Jennings
#+DATE: 2026-04-25
* Overview
@@ -23,15 +23,16 @@ Don't route "task review" / "review tasks" here — those trigger the hygiene ha
* Phase A: Data Gathering (both modes)
-** Phase A pre-step — archive any freshly-DONE tasks
+** Phase A pre-step — normalize freshly-closed tasks
-Before reading =todo.org=, run the cleanup script's archive-done sweep so completed level-2 subtrees move from =* $Project Open Work= to =* $Project Resolved=:
+Before reading =todo.org=, run two cleanup sweeps so the read reflects current state. First convert any done sub-tasks to dated entries, then archive completed level-2 subtrees from =* $Project Open Work= to =* $Project Resolved=:
#+begin_src bash
+emacs --batch -q -l .ai/scripts/todo-cleanup.el --convert-subtasks todo.org
emacs --batch -q -l .ai/scripts/todo-cleanup.el --archive-done todo.org
#+end_src
-Costs a few hundred milliseconds. Without it, a task that completed earlier in the session sits as =** DONE= under Open Work until the next =clean-todo= or wrap-up pass, and Next Mode would surface it as a "what's next" candidate. The sweep makes Phase A's read of =todo.org= reflect current state.
+Costs a few hundred milliseconds. Without the archive sweep, a task that completed earlier in the session sits as =** DONE= under Open Work until the next =clean-todo= or wrap-up pass, and Next Mode would surface it as a "what's next" candidate. The convert sweep runs first so a completed parent's sub-tasks are already dated when it archives; it also keeps interactive level-3 closes from lingering as DONE keywords. Together they make Phase A's read of =todo.org= reflect current state.
Skip the sweep if the workflow is invoked in an explicit read-only or dry-run context. Default is to run it.
diff --git a/claude-templates/.ai/workflows/page-me.org b/claude-templates/.ai/workflows/page-me.org
index 607ed51..7a3b792 100644
--- a/claude-templates/.ai/workflows/page-me.org
+++ b/claude-templates/.ai/workflows/page-me.org
@@ -1,21 +1,29 @@
#+TITLE: Page Me Workflow
-#+AUTHOR: Craig Jennings & Claude
+#+AUTHOR: Craig Jennings
#+DATE: 2026-01-31
#+UPDATED: 2026-02-27
* Overview
-This workflow enables Claude to set timers and alarms that reliably notify Craig, even if the terminal session ends or is accidentally closed. Notifications are distinctive (audible + visual with alarm icon) and persist until manually dismissed.
+This workflow enables Claude to set timers and alarms that reliably notify Craig, even if the terminal session ends or is accidentally closed. Notifications are distinctive (audible + visual with the blue info icon) and persist until manually dismissed.
-Uses the =notify= command (alarm type) for consistent notifications across all AI workflows.
+Uses the =notify= command (info type) for consistent notifications across all AI workflows. Info-level on purpose: the earlier alarm styling read as all-red urgency, and Craig's verdict was that a page "should be a persistent info notification" — noticeable, never crash-scary (2026-07-02).
* Trigger Phrase
Craig says *"page me"* (or variations like "page me in 10 minutes", "page me at 3pm").
-The word "page" is the trigger for this workflow. It means: set a timed notification.
+The word "page" is the trigger for this workflow. It means: set a timed notification on the *desktop* channel (=notify=).
-Previously called "set-alarm" -- renamed to "page-me" for a distinctive, short trigger phrase that won't collide with common words like "remind" or "alert."
+Two sibling triggers pick a different channel; the timed =at= machinery below is identical for all three, only the fired command changes:
+
+- *"page me"* — desktop =notify= (this workflow's default).
+- *"text me"* — a Signal push to Craig's phone via =agent-text= (the away channel).
+- *"text and page me"* — both, for when he might be either place.
+
+Scope the triggers to the reflexive "me": "page me" and "text me", not a bare "page" or "text" in prose. The full channel vocabulary lives in protocols.org "Reaching Craig".
+
+"page" was chosen (renamed from the old "set-alarm") for a distinctive, short trigger that won't collide with common words like "remind" or "alert".
* Problem We're Solving
@@ -63,8 +71,8 @@ Craig tells Claude when and why:
Claude schedules the alarm using the =at= daemon with =notify=:
#+begin_src bash
-echo "notify alarm 'Page' 'Time to call the dentist' --persist" | at 3:30pm
-echo "notify alarm 'Page' 'Meeting starts' --persist" | at now + 45 minutes
+echo "notify info 'Page' 'Time to call the dentist' --persist" | at 3:30pm
+echo "notify info 'Page' 'Meeting starts' --persist" | at now + 45 minutes
#+end_src
The =at= daemon:
@@ -89,30 +97,43 @@ Craig dismisses the notification and acts on it.
** Setting Alarms
-Use the =at= daemon to schedule a =notify alarm= command:
+Use the =at= daemon to schedule a =notify info= command:
#+begin_src bash
# Schedule for specific time
-echo "notify alarm 'Page' 'Meeting starts' --persist" | at 3:30pm
+echo "notify info 'Page' 'Meeting starts' --persist" | at 3:30pm
# Schedule for relative time
-echo "notify alarm 'Page' 'Check the build' --persist" | at now + 30 minutes
+echo "notify info 'Page' 'Check the build' --persist" | at now + 30 minutes
# Schedule for tomorrow
-echo "notify alarm 'Page' 'Call the dentist' --persist" | at 3:30pm tomorrow
+echo "notify info 'Page' 'Call the dentist' --persist" | at 3:30pm tomorrow
#+end_src
** Notification System
-Uses the =notify= command with the =alarm= type. The =notify= command provides 8 notification types with matching icons and sounds.
+Uses the =notify= command with the =info= type. The =notify= command provides 8 notification types with matching icons and sounds.
#+begin_src bash
-# Immediate alarm notification (for testing)
-notify alarm "Page" "Your message here" --persist
+# Immediate page notification (for testing)
+notify info "Page" "Your message here" --persist
#+end_src
The =--persist= flag keeps the notification on screen until manually dismissed. All page-me notifications should use =--persist= by default.
+** Texting Craig's phone (the "text me" channel)
+
+The timed =notify= alarm above is the desktop channel. When Craig says "text me" (or a run expects him away from the machine), use =agent-text= instead, a Signal push to his phone from any machine or agent runtime:
+
+#+begin_src bash
+agent-text "Build finished, ready for your eyes"
+
+# Timed phone message: same at-daemon pattern, different channel
+echo "agent-text 'Meeting starts in 5'" | at 3:25pm
+#+end_src
+
+Channel selection and the mechanics live in protocols.org "Reaching Craig". On "text and page me", fire both: the desktop notification persists for whenever he returns, the phone push reaches him now.
+
** Managing Alarms
#+begin_src bash
@@ -139,10 +160,10 @@ The alarm must fire. Use the =at= daemon which is designed for exactly this purp
Simple invocation - Claude runs one command. No complex setup required per alarm.
** Fail Audibly
-If the alarm fails to schedule, report the error clearly. Don't fail silently.
+If the page fails to schedule, report the error clearly. Don't fail silently.
** Testable
-The =notify alarm= command can be called directly to verify notifications work without waiting for a timer.
+The =notify info= command can be called directly to verify notifications work without waiting for a timer.
** Non-Alarming
Use normal urgency, not critical. The notification should be noticeable but not imply something has gone horribly wrong.
diff --git a/claude-templates/.ai/workflows/process-meeting-transcript.org b/claude-templates/.ai/workflows/process-meeting-transcript.org
index 4dd340f..d0806ad 100644
--- a/claude-templates/.ai/workflows/process-meeting-transcript.org
+++ b/claude-templates/.ai/workflows/process-meeting-transcript.org
@@ -1,5 +1,5 @@
#+TITLE: Process Meeting Transcript Workflow
-#+AUTHOR: Craig Jennings & Claude
+#+AUTHOR: Craig Jennings
#+DATE: 2026-02-03
* Overview
@@ -10,16 +10,16 @@ This workflow defines the process for processing meeting recordings from start t
Trigger this workflow when:
- Craig says "process the transcript" or "process the recording" or similar
-- New recording files (.mkv) appear in ~/sync/recordings/ after meetings
+- New recording files (.mkv, .m4a, or .flac) appear in ~/sync/recordings/ after meetings
- Craig wants to process meeting recordings into labeled transcripts
* Prerequisites
-- Recording file(s) exist in ~/sync/recordings/ (*.mkv)
+- Recording file(s) exist in ~/sync/recordings/ (*.mkv, *.m4a, or *.flac)
- Calendar files available at ~/.emacs.d/data/*cal.org for meeting titles
- AssemblyAI transcription script at ~/.emacs.d/scripts/assemblyai-transcribe
- AssemblyAI API key stored in ~/.authinfo.gpg (machine api.assemblyai.com)
-- ffmpeg available for audio extraction
+- ffmpeg available for audio extraction (video .mkv only; .m4a and .flac skip extraction)
* The Workflow
@@ -43,13 +43,13 @@ Classification is per recording, not per session — a single run can carry this
Find and match recording files with calendar events. *Run sub-steps 1 and 3 (recording list + calendar dump) as a single parallel batch* — they're independent. Sub-step 2 (parse timestamps) and sub-step 4 (matching) work from those two outputs in-memory, so they're sequential after the batch.
-1. **List recordings:** Find all recording files in ~/sync/recordings/ (video .mkv or audio-only .m4a)
+1. **List recordings:** Find all recording files in ~/sync/recordings/ (video .mkv or audio-only .m4a / .flac)
#+begin_src bash
- ls -la ~/sync/recordings/*.mkv ~/sync/recordings/*.m4a 2>/dev/null
+ ls -la ~/sync/recordings/*.mkv ~/sync/recordings/*.m4a ~/sync/recordings/*.flac 2>/dev/null
#+end_src
- Audio-only recordings (.m4a) are used when no screen content is expected. These skip Step 3 (audio extraction) since they're already in a transcribable format.
+ Audio-only recordings (.m4a or lossless .flac) are used when no screen content is expected. These skip Step 3 (audio extraction) since they're already in a transcribable format. FLAC is the recorder's current audio format — its frames are self-contained, so an interrupted recording still decodes.
-2. **Extract timestamps:** Parse date/time from each filename (format: YYYY-MM-DD-HH-MM-SS.mkv or .m4a)
+2. **Extract timestamps:** Parse date/time from each filename (format: YYYY-MM-DD-HH-MM-SS.mkv, .m4a, or .flac)
3. **Match with calendar:** Check ~/.emacs.d/data/*cal.org for meetings at those times
#+begin_src bash
@@ -74,7 +74,7 @@ Per =cross-project.md=: transcribe and label here, then deliver the *labeled tra
** Step 3: Extract Audio (video recordings only)
-*Skip this step for .m4a files* — they are already audio and can go directly to transcription.
+*Skip this step for .m4a and .flac files* — they are already audio and can go directly to transcription.
For .mkv video recordings, extract audio for transcription:
@@ -96,11 +96,11 @@ Output: /tmp/FILENAME.m4a (temporary, deleted after transcription)
#+begin_src bash
# For .mkv files (audio was extracted to /tmp/):
~/.emacs.d/scripts/assemblyai-transcribe /tmp/FILENAME.m4a > ~/sync/recordings/FILENAME.txt
- # For .m4a files (transcribe directly):
+ # For .m4a or .flac files (transcribe directly — AssemblyAI accepts both natively):
~/.emacs.d/scripts/assemblyai-transcribe ~/sync/recordings/FILENAME.m4a > ~/sync/recordings/FILENAME.txt
#+end_src
-2. **Clean up:** Delete intermediate .m4a file after successful transcription (only for .mkv extractions — do NOT delete original .m4a recordings)
+2. **Clean up:** Delete intermediate .m4a file after successful transcription (only for .mkv extractions — do NOT delete original .m4a or .flac recordings)
#+begin_src bash
rm /tmp/FILENAME.m4a
#+end_src
@@ -181,13 +181,13 @@ Present the speaker identification table to Craig for confirmation:
** Step 9: Copy Recording to Meetings Folder
-1. Ensure engagement meetings folder exists and patterns are in .gitignore (~*/meetings/*.mkv~ and ~*/meetings/*.m4a~)
+1. Ensure engagement meetings folder exists and patterns are in .gitignore (~*/meetings/*.mkv~, ~*/meetings/*.m4a~, and ~*/meetings/*.flac~)
2. Copy the recording file with descriptive name:
#+begin_src bash
# Video recordings:
cp ~/sync/recordings/YYYY-MM-DD-HH-MM-SS.mkv {engagement}/meetings/YYYY-MM-DD_HH-MM-meeting-name.mkv
- # Audio-only recordings:
+ # Audio-only recordings (.m4a or .flac — copy with the original extension):
cp ~/sync/recordings/YYYY-MM-DD-HH-MM-SS.m4a {engagement}/meetings/YYYY-MM-DD_HH-MM-meeting-name.m4a
#+end_src
Example: ~deepsat/meetings/2026-02-03_11-02-standup-ipm-grooming.mkv~
diff --git a/claude-templates/.ai/workflows/read-calendar-events.org b/claude-templates/.ai/workflows/read-calendar-events.org
index be66bf4..5eac529 100644
--- a/claude-templates/.ai/workflows/read-calendar-events.org
+++ b/claude-templates/.ai/workflows/read-calendar-events.org
@@ -1,5 +1,5 @@
#+TITLE: Read Calendar Events Workflow
-#+AUTHOR: Craig Jennings & Claude
+#+AUTHOR: Craig Jennings
#+DATE: 2026-02-01
* Overview
diff --git a/claude-templates/.ai/workflows/readability-audit.org b/claude-templates/.ai/workflows/readability-audit.org
index 8223a03..90ad366 100644
--- a/claude-templates/.ai/workflows/readability-audit.org
+++ b/claude-templates/.ai/workflows/readability-audit.org
@@ -1,5 +1,5 @@
#+TITLE: Readability Audit Workflow
-#+AUTHOR: Craig Jennings & Claude
+#+AUTHOR: Craig Jennings
#+DATE: 2026-06-28
* Overview
diff --git a/claude-templates/.ai/workflows/rename-artifact.org b/claude-templates/.ai/workflows/rename-artifact.org
index 7b9f15b..a8d1246 100644
--- a/claude-templates/.ai/workflows/rename-artifact.org
+++ b/claude-templates/.ai/workflows/rename-artifact.org
@@ -1,5 +1,5 @@
#+TITLE: Rename an .ai Artifact
-#+AUTHOR: Craig Jennings & Claude
+#+AUTHOR: Craig Jennings
#+DATE: 2026-05-31
* Summary
diff --git a/claude-templates/.ai/workflows/send-email.org b/claude-templates/.ai/workflows/send-email.org
index 065f925..82d2286 100644
--- a/claude-templates/.ai/workflows/send-email.org
+++ b/claude-templates/.ai/workflows/send-email.org
@@ -1,5 +1,5 @@
#+TITLE: Email Workflow
-#+AUTHOR: Craig Jennings & Claude
+#+AUTHOR: Craig Jennings
#+DATE: 2026-01-26
* Overview
diff --git a/claude-templates/.ai/workflows/sentry.org b/claude-templates/.ai/workflows/sentry.org
new file mode 100644
index 0000000..e25ca39
--- /dev/null
+++ b/claude-templates/.ai/workflows/sentry.org
@@ -0,0 +1,227 @@
+#+TITLE: Sentry — Overnight Hygiene Supervisor
+#+AUTHOR: Craig Jennings
+#+DATE: 2026-07-19
+
+* Overview
+
+Sentry is an interval loop that keeps a project's hygiene current while Craig is away. Each fire walks a fixed list of passes — roam pull, inbox zero, triage (no mail or messengers), todo cleanup, task audit, working-files hygiene, spec board, link integrity, git health, prep freshness, bug and refactor finding, and (opt-in) solo-task implementation — and commits each pass's writing to a throwaway daily branch. Nothing pushes. In the morning Craig reviews the branch, squash-merges what he wants, and deletes it.
+
+The design goal is a project that greets the morning already tidy, with every judgment call and every destructive action parked in an approval queue rather than executed unattended. Sentry does the mechanical sweeping; Craig does the deciding.
+
+This file is the engine. It owns the entry gates, the branch mechanics, the lock model, the per-fire pass runner, the digest and approval queue, the skip semantics, and the stop-sentry shutdown. The =agent-lock= helper (=.ai/scripts/agent-lock=) provides the locks. The passes reuse existing workflows (=inbox.org=, =triage-intake.org=, =clean-todo.org=, =task-audit.org=) under sentry's unattended contract.
+
+* When to Use This Workflow
+
+Craig arms sentry at the end of a session, with the machine left running, to have overnight hygiene done by morning.
+
+Triggers:
+
+- "start sentry", "run sentry", "arm sentry", "sentry mode"
+- "let sentry watch this overnight", "keep this tidy overnight"
+- "start sentry hourly", "start sentry every <interval>" (sets the loop interval)
+
+Stop trigger (see Stop Sentry below):
+
+- "stop sentry", "stand down sentry", "sentry off"
+
+Sentry is deliberately *not* auto-armed. Running it in a project is a per-project grant (the =:COMMIT_AUTONOMY:= marker) plus a deliberate launch with Craig at the terminal for the entry gates.
+
+* Prerequisite — the autonomy ticket
+
+Sentry commits unattended. =commits.md= gates commits on Craig's approval, so sentry needs standing, per-project authorization to run at all. Before anything else, read the project's =.ai/notes.org= Workflow State block for:
+
+: :COMMIT_AUTONOMY: yes
+
+If the marker is absent or not =yes=, decline to start and name the marker:
+
+: Sentry needs ":COMMIT_AUTONOMY: yes" in .ai/notes.org Workflow State to run — it commits unattended. Add it to grant, or run the hygiene passes by hand.
+
+No half-running mode: a project without the grant doesn't run sentry's read-only passes either. The grant is one line away, so this is a deliberate opt-in, not a barrier.
+
+A second, *independent* marker gates the solo-task implementation pass (pass 12):
+
+: :SENTRY_MAY_IMPLEMENT: yes
+
+=:COMMIT_AUTONOMY:= lets sentry commit its hygiene sweeps to the branch; =:SENTRY_MAY_IMPLEMENT:= additionally lets it implement solo, decision-free backlog tasks on the branch. The split exists because the two carry different morning costs: hygiene is a two-minute merge, implemented code is a review session. A project can run hygiene-only sentry without the implement pass, and most should until sentry has quiet weeks behind it. Absent =:SENTRY_MAY_IMPLEMENT:=, pass 12 skips; sentry still runs every other pass. Requires =:COMMIT_AUTONOMY:= alongside it — implementing implies committing.
+
+* Entry — interactive, with Craig present
+
+Craig types the sentry trigger, so the first moves run with him at the terminal. Do them in order; each gate that fails stops entry until Craig answers.
+
+1. *Autonomy ticket* — the prerequisite above. Absent → decline and stop.
+
+2. *Dirty-tree gate.* =git diff --quiet HEAD= (tracked modifications only; untracked and gitignored files never block — an inbox drop or scratch file is not in-progress work). If the tracked tree is dirty, describe what's dirty and offer, inline-numbered per =interaction.md=:
+
+ 1. Finish the job — commit the in-progress work first (recommended if it's a coherent unit)
+ 2. Stash it — =git stash= and start sentry on a clean tree
+ 3. Roll back named changes — discard specific files (names them)
+
+ Wait for an answer. Sentry can't start unattended from a dirty state; that's the point.
+
+3. *Green-suite gate.* Run the project's full suite (=make test=, or the project's equivalent — detect it). Read the output. If anything is red, describe the failures and offer to investigate before arming. The loop starts only on a green baseline, because every unattended fire measures itself against "did I break this?" and a pre-existing red poisons that check.
+
+4. *Prior sentry branch.* =git branch --list 'sentry/*'=. An unmerged =sentry/*= branch from a previous night means the morning review didn't happen. Surface it and offer to squash-merge or delete it now (Craig is present); don't stack a second sentry branch on the first.
+
+5. *Reconcile the project branch.* Fetch and fast-forward-only against upstream — the same reconcile =startup= runs:
+
+ : git fetch --all --prune
+ : git rev-list --left-right --count @{u}...HEAD
+
+ Zero-behind → continue. Behind-only and clean → =git merge --ff-only @{u}=. Diverged → surface to Craig (he's present); don't auto-resolve.
+
+6. *Create the daily branch.* From HEAD:
+
+ : git switch -c "sentry/$(date +%F)-$(uname -n)"
+
+ The host suffix (=uname -n=) stops a same-date collision between the two daily drivers. The working tree now sits on this branch overnight — the launch hands the repo to sentry until the morning merge. Reclaiming it mid-night means stopping sentry first (see Stop Sentry). Note the Emacs buffer-revert caveat to Craig if he has the repo open: files change on disk under him overnight, so buffers want reverting after the morning merge (see =emacs.md=).
+
+7. *Arm the loop.* Start =/loop= at the interval (default hourly; Craig's "every <interval>" phrase overrides) with the per-fire body being one sentry fire (the Pass Runner below). Confirm the arming in one line: interval, branch name, project.
+
+* The lock model
+
+Two locks, both served by =.ai/scripts/agent-lock= (names only; the helper owns the paths, which live on tmpfs under =$XDG_RUNTIME_DIR/agent-locks/=, host-local and cleared on reboot).
+
+*Single-runner lock* (=sentry-<project>=, where =<project>= is the repo-root basename: =basename "$(git rev-parse --show-toplevel)"= — the same derivation =wrap-it-up.org='s guard uses, so the two agree on the lock name). Each fire acquires it at fire start and releases it at fire end, and refreshes it between passes (the heartbeat, so a live fire's lock never ages past one pass). If =/loop= fires again while a previous fire still holds it, the new fire's acquire fails and the fire skips with one digest line — no two fires run at once. The bounded wait is short (a few seconds); a live fire means defer, not queue.
+
+*Roam-write lock* (=roam-write=). A pass that edits a file under =~/org/roam= acquires it, runs =capture-guard --wait= (the human-capture layer stays underneath), edits the working tree, triggers =systemctl --user start roam-sync.service=, and releases. The lock spans only edit-plus-trigger. Sentry never runs =git= against =~/org/roam= — roam-sync stays the repo's only committer (the 2026-06-24 one-git-owner rule). Pass 1's =pull --ff-only= is the sole, read-only exception.
+
+Every reclaim of a stale lock surfaces in the digest — the helper prints the reclaim note, and the fire records it. A reclaim during a genuinely slow pass is possible, so it's never silent.
+
+* The Pass Runner — one contract per pass
+
+Each fire, after acquiring the single-runner lock and verifying branch state (below), walks the pass list in order. Every pass follows the same four-step contract:
+
+1. *Probe* — a cheap existence check for the pass's target (named per pass below). Absent → the pass is one skip line in the digest and nothing more. This is what makes the pass list portable: passes self-activate where their target exists and stay silent elsewhere, with zero per-project configuration.
+
+2. *Work* — run the pass under the unattended contract. Quick, solo, already-agreed mechanical actions execute. Anything destructive or requiring judgment does *not* execute — it appends to the morning-approval queue (what, why, the exact command or edit that fires on approval). A pass runs fully or not at all; there is no reduced-form pass.
+
+3. *Session-context entry* — a pass that does or queues work appends its digest line to the =session-context.org= Session Log (path resolved via =.ai/scripts/session-context-path=) before its commit, so a crash between them still leaves the trail. Per-pass lines for an all-quiet fire (every pass probe-skipped or no-op) are not written one by one — the fire collapses to a single heartbeat at fire-end (below), so an idle fire doesn't spray one skip line per pass.
+
+4. *Commit* — if the pass wrote to disk, commit it: =chore(sentry): <pass> — <what changed>=. One commit per writing pass. A probe-skip or a no-op pass writes nothing and commits nothing.
+
+Between passes, refresh the single-runner lock (=agent-lock refresh sentry-<project>=) — the heartbeat.
+
+** Branch-state verification (fire start, before the passes)
+
+After acquiring the lock, confirm the fire is safe to run:
+
+- *On the right branch* — HEAD is =sentry/<today>-<host>=. If the loop was armed on a prior day and crossed midnight, the branch keeps the arming date; that's fine, morning teardown handles it. If HEAD is somehow *not* a sentry branch (an interrupted stop, a manual checkout), skip the whole fire with a digest line rather than committing onto main.
+- *Clean of foreign changes* — =git diff --quiet HEAD= excluding the spine set (=session-context.org= / =session-context.d/=, resolved via =session-context-path=). Sentry's own spine writes must not trip this; a genuinely unexpected dirty tree (something outside the spine changed and wasn't committed by a prior pass) poisons the fire — skip it with a digest line, the next fire retries.
+
+* Unattended safety — skip, never degrade
+
+With no one at the terminal, any unsafe state makes the affected scope skip with one digest line, and the next fire retries. Unsafe states and their scope:
+
+- *Unexpected dirty tree* (non-spine) → skip the whole fire.
+- *Lost or un-acquirable single-runner lock* → skip the fire (another fire holds it, or the helper is missing).
+- *A pass's own precondition unmet* (its probe fails, or a dependency is dirty) → skip that pass only.
+- *Red suite at fire-end* (see below) → the commits stay on the branch, flagged in the digest for morning review; the fire doesn't roll back.
+
+Skips are never silent and never partial. Inside a *working* fire, a pass line means the pass fully ran and a skip line names why it didn't. An *all-quiet* fire is not a silent skip either: its single =sentry at HH:MM: nothing= heartbeat is the explicit record that every pass found nothing, standing in for a wall of identical skip lines. The anti-silence rule targets a pass that hides work it should have surfaced; a quiet fire has surfaced that there was none.
+
+** Multi-day stall notification
+
+An unmerged prior =sentry/*= branch at fire start (the morning review never happened) skips the fire. After the *second consecutive* fire skipped for this reason, send one persistent desktop notification naming the project and branch:
+
+: sentry stalled: <branch> unmerged — merge or delete to resume
+
+Then repeat at most daily. Persistent notify matches the paging convention — it stays on screen until dismissed. A multi-day stall never stays silent.
+
+* The pass list (v1)
+
+In order. Each names its detection probe. A pass whose probe fails is one skip line.
+
+1. *Roam pull* — =git -C ~/org/roam pull --ff-only=. Probe: =~/org/roam= is a git clone. Skipped when the roam tree is dirty (roam-sync owns that case) or the clone is absent. Read-only and ff-only — the one narrow exception to "don't touch roam git," so later passes read a fresh tree.
+
+2. *Inbox zero* — run =inbox.org= roam mode under the no-approvals contract: quick+solo+agreed items execute, shared-asset and convention proposals park (prepared diff, =VERIFY= task, sender reply) in the approval queue. Edits to =~/org/roam/inbox.org= take the roam-write lock + =capture-guard=. Probe: the roam clone or a project =inbox/= exists. Tidying the shared roam inbox is allowed from *any* project session, work included — it's housekeeping on a shared resource, not a durable KB-node write, so the work-denylist doesn't gate it (=knowledge-base.md=). Never park it as a cross-project boundary crossing.
+
+3. *Triage intake — mail and messenger sources excluded.* Run =triage-intake.org=, loading only its non-mail, non-messenger source plugins (calendar, PR/ticketing). The mail and messenger plugins — cmail, any Gmail variant, Telegram, Signal, chat DMs — are never loaded by a sentry fire: Craig ruled 2026-07-21 that sentry doesn't check email or messengers. A manual "triage intake" still scans everything. Probe: the project has at least one *active* triage source that survives that exclusion — a project-specific plugin (=.ai/project-workflows/triage-intake.*.org=), or a non-empty =:TRIAGE_SOURCES:= declaration naming general plugins that exist. Mere presence of the template-synced general plugins does *not* activate the pass; a project that declares no sources, or whose only declared sources are mail or messengers, probe-skips (see =docs/specs/2026-07-20-triage-source-activation-spec.org=). Destructive actions (deleting, archiving, sending) queue; they never fire unattended.
+
+4. *Todo cleanup* — the =clean-todo.org= mechanics (hygiene pass + =--archive-done= + =--convert-subtasks=). Probe: a root =todo.org=. Note that =--archive-done= is not purely an org-file pass on its first run in a project: it creates =archive/task-archive.org= and appends a =.gitignore= entry, so it produces a real tracked-file commit and correctly trips the fire-end conditional suite. (archangel, first live run 2026-07-21.)
+
+5. *Task audit* — the *mechanical subset* of =task-audit.org= hourly (staleness counts, structural checks, cookie recomputation); the judgment half (priority regrades, consolidations, merge candidates) runs *once per night* and queues its findings rather than repeating them every fire. Probe: a root =todo.org=. A full audit every hour is too heavy and re-surfaces the same judgment calls all night. (takuzu, first live run 2026-07-21.) Factual staleness fixes that are unambiguous still execute.
+
+6. *Working-files hygiene* — flag =working/<slug>/= directories whose backing task is closed (a filing candidate per =working-files.md=). Probe: a =working/= directory exists. The filing itself queues (it's a judgment move).
+
+7. *Spec status board* — the =docs-lifecycle= grep for spec keywords, surfacing any =DOING= spec whose bound build parent is closed. Probe: =docs/specs/= exists.
+
+8. *Link integrity* — broken =file:= links in the project's org files, via =lint-org.el=. Probe: =lint-org.el= present. Report-only into the digest; no unattended rewrites.
+
+9. *Git health* — uncommitted drift, unpushed commits on other branches, stale branches, main-behind-origin. Probe: =.git=. Report into the digest.
+
+10. *Prep + symlink freshness* — stale daily-prep docs, broken symlinks. Probe: the prep dir / symlinks exist (work and home only, in practice).
+
+11. *Bug and refactor finding* — hunt for real bugs and worthwhile refactoring opportunities in the project's codebase: static analysis (=shellcheck= for shell, the project's own linters for its languages), config sanity checks, plus one targeted code-reading area per fire. Rotate the area across fires and name it in the digest, so coverage accumulates over a night instead of re-reading the same corner. Randomized property sweeps (generate-and-verify against an engine's own invariants) are good quiet-fire work here, reaching past a frozen test corpus. Expect the pass to go honestly quiet after the first few fires find the standing defects; a quiet hunt is a result, not a failure. (takuzu, first live run 2026-07-21: three real fixes in the first four fires, then quiet.) This pass does *not* run the test suite — the entry baseline already ran it, and re-running it hourly is anti-pattern 5; read the entry result instead. Probe: the project carries a codebase — source under version control beyond its org and tooling files. File each verified bug as a graded task in =todo.org= per the severity × frequency matrix (=todo-format.md=), and each refactoring opportunity as a =:refactor:= task, deduped against existing tasks; an unverifiable suspicion is a digest line, not a task. *Find, never fix in this pass* — the finding files a task and stops. A fix happens only in the opt-in implementation pass below, and only after the finding is a filed task that pass then re-verifies from scratch (see the premise rule there). A freshly-found "bug" can be a misread — one was filed and retracted two fires apart on 2026-07-23 — so the file-then-verify-then-fix pipeline is deliberate: the task is the checkpoint, not a same-breath fix. (Added at Craig's order 2026-07-21, first dogfooded in dotfiles; refactor-finding added 2026-07-24.)
+
+12. *Solo-task implementation (opt-in — =:SENTRY_MAY_IMPLEMENT:=)* — work the backlog's solo, decision-free tasks on the branch. Probe: =.ai/notes.org= Workflow State carries =:SENTRY_MAY_IMPLEMENT: yes= *and* the project holds =:COMMIT_AUTONOMY:= (the implement pass commits). Absent the marker, skip — this pass is off by default, because it turns the morning from a two-minute merge into a code review, and that's the project owner's call. When on: invoke =work-the-backlog.org= under its unattended-loop contract (no pre-flight Q&A — there's no Craig overnight), eligibility =TODO= + =:solo:=, with the defer checklist deciding act-vs-file. The overnight-only tightening: only the *ready* bucket implements (clears every checklist item with zero open decisions); a task needing even one quick decision defers to a =VERIFY= rather than guessing, exactly as the loop caller already does. Commit each logical change to the sentry branch; *never push* — the morning review and merge is the gate, same as every other pass. The full quality bar holds (TDD, suite green before each commit, =/review-code=, =/voice=), and =/review-code= here runs the *premise check first*: reproduce the bug or confirm the problem is real before judging the diff. The review is the fact-checker that a filed claim never got, and it is what makes fixing-on-a-branch safe (Craig, 2026-07-24). A task that fails its premise check is not implemented — the finding was wrong, and that outcome is a digest line, not a commit. (Added at Craig's direction 2026-07-24: overnight implement-on-branch, gated and never-pushed.)
+
+(KB lesson promotion — the pass the original proposal listed eleventh — is deferred to vNext. An unattended judgment pass writing to the shared knowledge base waits until sentry has quiet weeks behind it and a designed detection heuristic. See the filed lesson-detection-heuristic task.)
+
+* Fire-end — conditional suite, then the digest commit
+
+After the passes:
+
+1. *Conditional suite run.* If any pass this fire modified files *outside* the org/spine set (a code-touching pass, rare but possible via fixtures), run the full suite once. A green run confirms the fire's commits are safe; a red run flags the digest for morning review — the commits stay on the branch (nothing is pushed, so the morning gate catches it). No per-pass suite runs: the entry run is the green baseline, and hourly per-commit runs would turn a seconds-long fire into minutes all night. Fires that only touched org/spine files skip this.
+
+2. *Heartbeat or digest, then commit.* Decide quiet vs working. A *quiet* fire — every pass probe-skipped or no-op, nothing added to the approval queue — writes a single heartbeat line to the Session Log, =sentry at HH:MM: nothing= (HH:MM local, from =date=), and no per-pass digest block. A *working* fire — any pass ran, wrote, or queued — writes its full per-pass digest block. Then commit any accumulated spine writes in one sweep: =chore(sentry): digest — <date> <time> fire= for a working fire, =chore(sentry): heartbeat — <date> <time>= for a quiet one, so even a quiet fire leaves a clean tree for the next branch-state check (where the spine is untracked, the mirror-only case, there is nothing to commit and the heartbeat line stays in the working-tree anchor). This is the silent-until-signal policy (see =docs/specs/2026-07-20-silent-until-signal-monitors-spec.org=): an all-quiet night collapses from a wall of no-op digests to a list of one-line heartbeats, while a fire that actually did or queued something still writes the full record.
+
+3. *Release the single-runner lock.*
+
+* The digest and the approval queue
+
+*Digest.* A *working* fire appends its block to the =session-context.org= Session Log (the spine the fire already writes), so it survives a crash, rides the session archive, and is on screen in the running session. One block per working fire: the timestamp, then one line per pass (ran + what, or skipped + why), plus any lock reclaim notes. A *quiet* fire (nothing done or queued) writes no block — just the one heartbeat line =sentry at HH:MM: nothing= (the silent-until-signal policy). The per-pass block is a working-fire artifact; it still carries one line per pass so a real skip inside a working fire is never hidden.
+
+*Approval queue.* Destructive and judgment actions accumulate under one heading in the same file — =* Sentry approval queue (<date>)= — newest last. Each item carries three things: *what* (the action), *why* (what triggered it), and the *exact command or edit* that fires on approval. The morning review is Craig reading this heading top to bottom and running or discarding each item.
+
+* Morning teardown — Craig's, documented not automated
+
+Sentry never merges its own branch. In the morning Craig:
+
+1. Reviews the digest and the approval queue in =session-context.org=.
+2. Runs or discards each approval-queue item.
+3. Reviews the branch: =git log main..sentry/<date>-<host>= and the diff.
+4. Squash-merges what he wants (=git switch main && git merge --squash sentry/<date>-<host>=, then one clean commit) or cherry-picks selectively.
+5. Deletes the branch: =git branch -D sentry/<date>-<host>=.
+6. Reverts any Emacs buffers still showing the pre-merge on-disk state (=emacs.md= buffer-revert caveat).
+
+A bad night is discarded by deleting one branch — nothing reached main, nothing was pushed.
+
+In a project that gitignores =.ai/=, the whole spine is untracked, so quiet fires produce no commits at all and =git log main..sentry/<date>-<host>= understates the night's activity. There the anchor's heartbeat list is the only record of what fired. Read the anchor, not just the log. (archangel, first live run 2026-07-21.)
+
+* Stop Sentry
+
+Trigger: "stop sentry" (and synonyms above). Sentry owns its own shutdown:
+
+1. *Cancel the loop* — stop the =/loop= (=ScheduleWakeup= stop / the loop's stop path). No further fires.
+2. *Release the single-runner lock* if this context holds it.
+3. *Branch disposition* — offer, inline-numbered:
+ 1. Squash-merge the day's branch into main now (walk the morning teardown steps 3-5 interactively)
+ 2. Leave it named for later review (=sentry/<date>-<host>= stays; review at leisure)
+4. *Approval queue* — offer to walk the queued items now, or carry them (they stay under the heading for whenever Craig reviews).
+
+Stopping sentry is the only way to reclaim the working tree mid-night. The entry gate fronts the handoff; stop-sentry ends it.
+
+* Wrap-up interaction
+
+=wrap-it-up.org= refuses while sentry is live: it detects the single-runner lock (=agent-lock status sentry-<project>= → held) and stops with "sentry is active — say 'stop sentry' first." The shutdown logic lives here, not in wrap-up; wrap-up carries only the one guard.
+
+* Common Mistakes
+
+1. *Running without the =:COMMIT_AUTONOMY:= grant* — sentry commits unattended; the marker is the entry ticket, and its absence is a hard stop, not a degrade.
+2. *Starting from a dirty or red tree* — the entry gates exist because an unattended fire can't tell Craig's in-progress work from a regression. Answer the gate; don't bypass it.
+3. *Committing onto main* — every writing pass commits to the daily =sentry/*= branch. A fire that finds HEAD off the sentry branch skips rather than commits.
+4. *Running a =git= write against =~/org/roam=* — roam-sync is the only committer. Sentry edits the tree under the roam-write lock and triggers the sync; it never commits or pushes roam.
+5. *A per-pass suite run* — the suite runs at entry (baseline) and conditionally at fire-end (only when a pass touched non-org files). Hourly per-commit runs all night is the anti-pattern the suite policy exists to prevent.
+6. *Executing a judgment or destructive action unattended* — those queue for the morning with their exact command. The pass did its detection; Craig makes the call. The one sanctioned exception is pass 12's solo-task implementation, and only because it inherits work-the-backlog's full defer checklist (data-loss and irreversible actions defer, never execute) plus a premise-verifying review, and it commits to the branch rather than acting on anything live.
+7. *A silent skip* — inside a working fire, every skip writes a digest line naming why; a missing pass with no line reads as "ran clean" when it didn't. The one exception is not a violation: an all-quiet fire collapses to a single =sentry at HH:MM: nothing= heartbeat instead of one skip line per pass — the heartbeat is the explicit "nothing to do" record, per the silent-until-signal policy.
+8. *Degrading a pass to a reduced form* — a pass runs fully or skips. No half-passes.
+9. *Letting an unmerged branch stall silently* — after two consecutive unmerged-branch skips, the persistent desktop notify fires. Don't suppress it.
+10. *Merging sentry's branch automatically* — the morning teardown is Craig's. Sentry creates and commits; it never merges or deletes its own branch.
+
+* Living Document
+
+Sentry ships with eleven finding/hygiene passes, one opt-in implementation pass, and a deferred KB pass. The pass list, the interval default, the =:SENTRY_MAY_IMPLEMENT:= default, and the queue-vs-execute line for each pass are the knobs most likely to move with dogfooding. The implement pass especially is new (2026-07-24) and unproven at scale — watch the corrections signal (work-the-backlog's metric for autonomous commits later reverted or hand-fixed) before widening it past the projects that opt in. Fold in what the live trial surfaces — a pass that queues too eagerly, a probe that misfires, a digest line that wants more detail. Refine as the signal arrives.
+
+* History
+
+Built 2026-07-19 from the sentry spec (=docs/specs/2026-07-14-sentry-workflow-spec.org=, ID f6c51f27-d7a2-4b63-9ff9-5ba005a66dfb) — 10 decisions and 12 review findings resolved before the build. Phase 1 shipped the =agent-lock= helper (commit =a8b6cf4=); this file is Phase 2, the engine. Phase 3 reconciles the roam writers (=inbox.org=, =knowledge-base.md=) to acquire the roam-write lock and adds the =wrap-it-up.org= guard.
diff --git a/claude-templates/.ai/workflows/session-harvest.org b/claude-templates/.ai/workflows/session-harvest.org
index c48d689..54a7c09 100644
--- a/claude-templates/.ai/workflows/session-harvest.org
+++ b/claude-templates/.ai/workflows/session-harvest.org
@@ -1,5 +1,5 @@
#+TITLE: Session-Harvest Workflow
-#+AUTHOR: Craig Jennings & Claude
+#+AUTHOR: Craig Jennings
#+DATE: 2026-06-11
* Overview
diff --git a/claude-templates/.ai/workflows/spec-create.org b/claude-templates/.ai/workflows/spec-create.org
index 508b969..39758a0 100644
--- a/claude-templates/.ai/workflows/spec-create.org
+++ b/claude-templates/.ai/workflows/spec-create.org
@@ -1,5 +1,5 @@
#+TITLE: Spec-Create Workflow
-#+AUTHOR: Craig Jennings & Claude
+#+AUTHOR: Craig Jennings
#+DATE: 2026-06-09
* Overview
@@ -47,6 +47,8 @@ Capture, in this order:
** Phase 2 — Design, alternatives, decisions
1. *Design* — overview first, then detail. Write the reasoning as *prose, not bullet dumps* — prose exposes weak logic that bullets let you hide. Use bullets only for genuinely enumerable lists. When the thing has an interface, use the *two-altitude* split (Rust RFC): explain it once for a user/caller, once for an implementer.
+
+ *Non-trivial UI.* When the deliverable is a real UI (a panel, a multi-control surface, an interacting visual layout — not a single dialog, a CLI flag, or a one-off prompt), the design isn't settled on the page. Run the research → ~5 distinct working-prototype directions → iterate-one-to-final process in =claude-rules/ui-prototyping.md= before treating the UI design as done, and add a =Prototype iterations= subsection under the spec's status heading linking every iteration (final linked in the design section). A UI design decision moves to =DONE= only once it's been seen working in a prototype.
2. *Alternatives considered* — the load-bearing section authors skip and reviewers need most. For each option, force a why-not with the MADR grammar: "Good, because… / Bad, because… / Neutral, because…". Even one rejected option, with the reason, beats presenting one path as inevitable.
3. *Decisions* — capture each real choice as an org =TODO= task carrying an inline mini-ADR (Nygard's spine):
- The heading is =** TODO <Decision name>=. It flips to =DONE= when the decision-maker agrees with the call; until then it stays =TODO=.
@@ -82,8 +84,9 @@ This is where the spec earns a "Ready" from review: an engineer must be able to
** Phase 5 — Wire it up (conventions)
-- *Filename + location:* =docs/<problem-slug>-spec.org=. Org-mode. The slug names the *problem/feature*, not a date. Must end in =-spec.org=.
-- *Metadata header:* a small table at the top — Status, Owner, Reviewer(s), Date, Related (link to the task/ticket).
+- *Filename + location:* =docs/specs/YYYY-MM-DD-<problem-slug>-spec.org= — formal specs live in =docs/specs/=, never =docs/design/= (that's for notes, brainstorms, inventories; see =claude-rules/docs-lifecycle.md=). Org-mode. The slug names the *problem/feature*; no status suffixes ever — status lives in the file. Must end in =-spec.org=.
+- *Status heading (first element after the file header):* a top-level heading carrying the lifecycle keyword, stamped =DRAFT= at authoring — spec-create owns this flip. It holds an =:ID:= UUID (generate with =uuidgen=) and dated history lines, newest first. The keyword is authoritative; the Metadata =Status= field mirrors it in lowercase. Transitions are three lines in one file (keyword + history line + mirror): spec-review flips =READY=, spec-response flips =DOING= at decomposition, the final build task flips =IMPLEMENTED=. Terminal states always record a reason.
+- *Metadata header:* a small table at the top — Status (the lowercase mirror), Owner, Reviewer(s), Date, Related (link to the task/ticket).
- *Review-and-iteration-history stub:* add a =Review and iteration history= section at the bottom and seed it with the author's first entry. =spec-review= and =spec-response= append provenance entries here, so the heading shape is a contract: =YYYY-MM-DD Day @ HH:MM:SS -ZZZZ — Contributor — Role=, body fields What / Why / Artifacts.
- *Cross-link both ways:* the spec links its task; the task links the spec (replace the task's inline plan with a terse description + a =file:= link to the spec).
@@ -103,7 +106,14 @@ Then it's ready for =spec-review.org=. Snapshot-vs-living rule: keep the spec li
,#+TITLE: <Feature> — Spec
,#+AUTHOR: <author>
,#+DATE: <YYYY-MM-DD>
-,#+TODO: TODO | DONE SUPERSEDED CANCELLED
+,#+TODO: TODO | DONE
+,#+TODO: DRAFT READY DOING | IMPLEMENTED SUPERSEDED CANCELLED
+
+,* DRAFT <spec short name>
+:PROPERTIES:
+:ID: <uuid — generate with uuidgen>
+:END:
+- <YYYY-MM-DD Day @ HH:MM:SS -ZZZZ> — drafted.
,* Metadata
| Status | draft |
diff --git a/claude-templates/.ai/workflows/spec-response.org b/claude-templates/.ai/workflows/spec-response.org
index de5b1c8..7628e49 100644
--- a/claude-templates/.ai/workflows/spec-response.org
+++ b/claude-templates/.ai/workflows/spec-response.org
@@ -130,9 +130,11 @@ When related specs were reviewed together, two reviews can recommend opposite th
This is the *last* step of the workflow, and it runs *only after the author confirms the spec is Ready* — never during review iterations. A Ready spec nobody can act on is unfinished; this phase turns it into tracked work. It applies to every project type (library, application, service, docs set).
-1. *Decide where the tasks live.* If the work is spinning off into its own project/repo, move the parent task into that project's =todo.org= (and relocate the spec with it); otherwise use the current project's =todo.org=. One parent task owns the effort; the phase tasks hang under it.
+*This phase owns the =READY= → =DOING= lifecycle flip* (docs-lifecycle convention): when the decomposition below lands, update the spec's top-level status heading keyword to =DOING=, add a dated history line, and set the Metadata =Status= mirror to =doing= — three lines, one file.
-2. *Create one task per implementation phase* from the spec's =Implementation phases=, in dependency order, so the task set as a whole describes the *full* milestone (e.g. v1) with no gaps. Each task body names the deliverable, its tests, and how it is verified. Carry over deferred/vNext work and any publish/release steps as their own tasks.
+1. *Decide where the tasks live.* If the work is spinning off into its own project/repo, move the parent task into that project's =todo.org= (and relocate the spec with it); otherwise use the current project's =todo.org=. One parent task owns the effort; the phase tasks hang under it. *Stamp the binding:* the parent task's =:PROPERTIES:= drawer gets a =:SPEC_ID:= line holding the spec's status-heading UUID. That property is the durable join task-audit uses to police =DOING= specs (a =DOING= spec whose bound parent is closed, archived, or missing gets flagged).
+
+2. *Create one task per implementation phase* from the spec's =Implementation phases=, in dependency order, so the task set as a whole describes the *full* milestone (e.g. v1) with no gaps. Each task body names the deliverable, its tests, and how it is verified. Carry over deferred/vNext work and any publish/release steps as their own tasks. *Always end the set with the flip task:* a final "flip the spec to IMPLEMENTED (+ dated history line + mirror)" task under the same parent — the tracked obligation that closes the lifecycle loop when the build finishes. Never skip it; "a human remembers" is the failure mode this exists to prevent.
3. *Turn a critical eye on completeness.* Re-read the spec — every phase, every acceptance criterion, every named deliverable, every data-safety/principle rule — and confirm each has a home in a task. The work is not done when the tasks merely exist; it is done when nothing in the spec is left untracked. This completeness pass is mandatory regardless of project type.
diff --git a/claude-templates/.ai/workflows/spec-review.org b/claude-templates/.ai/workflows/spec-review.org
index 833dfc9..0da8e65 100644
--- a/claude-templates/.ai/workflows/spec-review.org
+++ b/claude-templates/.ai/workflows/spec-review.org
@@ -50,6 +50,11 @@ Run it *early* — design review exists to catch viability problems and costly m
Before Phase 1, verify the file under review ends with =-spec.org=. Every design, decision, or planning document under a project's =docs/= directory carries that suffix as its identifier. The =.org= extension alone is not enough because =docs/= holds non-spec org files too (tutorials, frozen inventories, reference material).
+*Location expectation (docs-lifecycle convention).* Formal specs live in =docs/specs/=. Whether that's enforced depends on whether the project has run its one-time =spec-sort= retrofit:
+
+- =:LAST_SPEC_SORT:= present in =.ai/notes.org= Workflow State → the project has sorted; a =-spec.org= file outside =docs/specs/= fails this precondition. Surface it: "this spec sits outside docs/specs/ — move it (and update inbound links) before review."
+- Marker absent → legacy locations (=docs/= root, =docs/design/=) stay reviewable; add one nudge line to the review output ("this project's docs pile has never been spec-sorted — say 'run spec-sort' to sort it") and proceed. No legacy spec is ever unreviewable during the transition.
+
If the file does not end with =-spec.org=, stop immediately and surface the mismatch:
#+begin_example
@@ -128,6 +133,8 @@ Work the spec against these. Each is a source of concrete findings, not a box to
- *Performance & scale.* Expected counts (issues/comments/labels/teams/projects/views)? Server-side filtering where possible? Bounded, visible pagination? Cached name→ID lookups? Sync calls in the command path acceptable? Could a save hook or whole-file scan make N network calls? Rendering linear? Full-file rewrites avoided? Long-running operations async/cancellable/observable? Is concurrency/queueing/backpressure defined? Are high-output process filters throttled and cheap? Is progress/ETA exposed only when defensible, and are hung/stalled operations detectable and killable? Identify UI freezes, repeated network calls, unbounded pagination — without premature optimization.
- *Security & privacy.* API keys safe? Debug logs leaking secrets or private issue text? Confirmations before mutating shared workspace objects? Personal vs shared distinguished? Local files holding sensitive descriptions/comments? Anything to redact from messages/logs? Any work-tracker integration may handle private company data.
- *UX & accessibility.* Discoverable commands? Recoverable mistakes? Prompts ordered to the task? Safe, useful defaults? Informative-not-noisy status messages? Does the UI avoid implying unsupported actions are supported? Match the upstream product's permissions/concepts? Are customizations named in user language, with clear defaults and docstrings? For Emacs packages, command names, completion candidates, buffer layout, defcustom names, and message wording *are* the UX.
+- *Operational-panel UI traps.* Applies when the spec covers a user-facing panel, dialog, or control surface; skip otherwise. Lists that mix saved, current, and generated items must name each item's source. Refresh or scan actions must not gate data that could be shown immediately. Add-forms must not ask the user to retype values the system already discovered. Destructive confirmations read in future tense before the action and verified-result tense after it. Diagnostics, performance, logging, and repair affordances are reviewed as one coherent flow before extra pages or buttons are added. A popup launched from a bar, tray, or tool surface should visually belong to that launcher. (Promoted from archsetup's Waybar network-panel review, 2026-06-30.)
+- *Prototype process for non-trivial UI.* Applies when the deliverable is a real UI (a panel, a multi-control surface, an interacting visual layout — not a single dialog or CLI flag); skip otherwise. Verify the =claude-rules/ui-prototyping.md= process ran: category research is cited in Goals/Design, the final prototype is linked in the design section, a =Prototype iterations= subsection under the status heading lists every pass, and each UI design decision is backed by a prototype it was seen working in rather than asserted on the page. A non-trivial-UI spec with decisions but no prototype evidence is a =:blocking:= finding.
- *Test strategy and coverage.* Characterization tests before behavior changes? Pure functions to unit-test? API responses needing fixtures? Command flows needing stubs? Regression tests for prior bugs? Boundary/error cases? What's covered elsewhere and shouldn't be re-tested? Which existing tests must change? How is coverage generated, summarized, and used to find untested/refactor-worthy code? Prefer tests that lock contracts: representation shape, query compilation, sync no-op, conflict refusal, pagination, dirty-buffer protection, log redaction, and long-running/slow-operation behavior via fakes rather than flaky live dependencies.
- *Observability & operations.* How does a user see what the package is doing? Progress messages for long ops? Useful, safe debug logging? Are logs structured enough to isolate issues from a bug report? Are commands provided to inspect/clear caches, test connectivity, diagnose backends/tools, copy redacted debug info, or reproduce command invocations? How are terminal states discovered: completion, failure, partial success, stalled/hung, cancelled, cleanup-unverified, and "needs user action"? Does the product notify only when useful, avoid noisy success spam, and keep non-success states visible until acknowledged? For generated org files, headers should often carry source, filter/view name, refresh time, count, truncation state.
- *Comparable-product sentiment.* When there are obvious adjacent products, research what users love and hate about them from official docs plus current community reports. Do not cargo-cult their feature set; translate findings into the spec's scope. For each loved behavior, say whether the spec provides it, intentionally omits it, or defers it. For each hated behavior, say whether the spec avoids, resolves, inherits, or accepts it.
@@ -166,6 +173,8 @@ Assign one label consistently:
The most useful reviews move a spec from =Not ready= to =Ready with caveats= or =Ready= once decisions are captured.
+*The =Ready= verdict flips the spec's lifecycle status.* spec-review owns the =DRAFT= → =READY= transition (docs-lifecycle convention): on assigning =Ready= (or =Ready with caveats= the author accepts), update the spec's top-level status heading keyword to =READY=, add a dated history line under it naming the review that passed, and set the Metadata =Status= mirror to =ready= — three lines, one file. Any other rubric label leaves the keyword where it stands (a re-review that finds new blockers on a =READY= spec demotes it back to =DRAFT= the same three-line way, with the reason in the history line).
+
Finding severity maps to blocking power: *high-priority findings block =Ready=* — they hold the rubric at =Not ready= (or =Ready with caveats= if the author accepts and tracks them) until dispositioned; *medium-priority findings are the author's discretion* and don't block. State the blocking status on each finding so the author running spec-response knows which ones gate the rubric.
Then update the spec's review history. Specs should carry a bottom section named =Review and iteration history= (or the nearest existing equivalent) that tracks each material author/reviewer pass. Add a concise entry for this review even when the spec is ready and no findings are recorded.
diff --git a/claude-templates/.ai/workflows/startup.org b/claude-templates/.ai/workflows/startup.org
index 5e8f61e..2262eea 100644
--- a/claude-templates/.ai/workflows/startup.org
+++ b/claude-templates/.ai/workflows/startup.org
@@ -1,5 +1,5 @@
#+TITLE: Startup Workflow
-#+AUTHOR: Craig Jennings & Claude
+#+AUTHOR: Craig Jennings
#+DATE: 2026-04-25
* Summary
@@ -29,10 +29,16 @@ Inside a rulesets session, the project-repo refresh below covers this — the ru
#+begin_src bash
rs="$HOME/code/rulesets"
if [ -d "$rs/.git" ]; then
- if (cd "$rs" && git diff --quiet --ignore-submodules HEAD -- 2>/dev/null); then
+ gate="$rs/claude-templates/bin/git-worktree-gate"
+ if [ -x "$gate" ] && "$gate" sync-safe "$rs"; then
+ (cd "$rs" && git pull --ff-only origin main 2>&1) | tail -3
+ elif [ ! -x "$gate" ] \
+ && (cd "$rs" && git diff --quiet --ignore-submodules HEAD -- 2>/dev/null); then
+ # Bootstrap fallback for a checkout old enough not to have the shared
+ # gate yet. The pull that follows installs it for subsequent starts.
(cd "$rs" && git pull --ff-only origin main 2>&1) | tail -3
else
- echo "rulesets: dirty working tree — using as-is, skipping pull"
+ echo "rulesets: changes beyond untracked inbox deliveries — using as-is, skipping pull"
fi
else
echo "rulesets: not a git checkout — skipping"
@@ -40,10 +46,12 @@ fi
#+end_src
Behavior:
-- *Clean working tree* → fast-forward pull. =git pull --ff-only= refuses any merge or rebase, so the operation is either a no-op (already current) or a clean advance.
-- *Dirty working tree* → skip the pull. Don't auto-stash and don't auto-merge — those would either lose work or invite conflicts at the worst possible moment (session start).
+- *Clean working tree, or untracked deliveries only beneath =inbox/=* → fast-forward pull. =git pull --ff-only= refuses any merge or rebase, so the operation is either a no-op (already current) or a clean advance. Inbox files are queue input, not source-tree work, and do not block other projects from receiving rulesets updates.
+- *Any staged or tracked change, dirty submodule, Git operation in progress, or untracked file outside =inbox/=* → skip the pull. Don't auto-stash and don't auto-merge — those would either lose work or invite conflicts at the worst possible moment (session start).
- *Non-fast-forward history* → =--ff-only= aborts with an error. Surface that to the user; the rsync still proceeds against the working tree as-is.
+*Template-freshness policy (applies to every dirty-check in the synced workflows).* The shared =git-worktree-gate sync-safe= policy is the source of truth: untracked files beneath =inbox/= and gitignored files do not block a pull, fast-forward, or monitoring gate; every other staged, tracked, untracked, submodule, or in-progress-operation state does. Projects must not fall behind merely because somebody sent them a task, but an arbitrary scratch file is not silently treated as safe. One deliberate exception remains: the rsync WIP-guard below is narrower than the repository gate and counts untracked files within rulesets' own synced source paths, because an untracked half-written template is exactly the WIP it exists to hold back.
+
*** Install rulesets symlinks into ~/.claude (idempotent)
A skill, rule, or bin script added to rulesets and pushed reaches each machine's *files* on the next pull, but not its =~/.claude= *symlink* — =make install= only links what isn't already linked, and =git pull= doesn't run it. So a newly-added skill stays silently uninstalled until someone re-runs =make install= by hand. The flush skill sat in that gap from 2026-06-02 until a manual install on 2026-06-05. Running =make install= here, right after the rulesets pull, closes it: "add a skill, commit, push" becomes enough for it to reach every machine on the next session.
@@ -72,8 +80,11 @@ if [ -d .git ]; then
current=$(git symbolic-ref --short HEAD 2>/dev/null)
dirty=0
- if ! git diff --quiet --ignore-submodules HEAD -- 2>/dev/null \
- || [ -n "$(git status --porcelain --untracked-files=no)" ]; then
+ gate="$HOME/code/rulesets/claude-templates/bin/git-worktree-gate"
+ if [ -x "$gate" ]; then
+ "$gate" sync-safe "$PWD" >/dev/null 2>&1 || dirty=1
+ elif ! git diff --quiet --ignore-submodules HEAD -- 2>/dev/null \
+ || [ -n "$(git status --porcelain --untracked-files=no)" ]; then
dirty=1
fi
@@ -105,8 +116,8 @@ fi
#+end_src
Behavior, per branch:
-- *Behind only, current branch, clean tree* → =git merge --ff-only= advances HEAD.
-- *Behind only, current branch, dirty tree* → fetched but not advanced. Surface so Craig can ff manually after dealing with the dirty state.
+- *Behind only, current branch, sync-safe tree* → =git merge --ff-only= advances HEAD. An untracked =inbox/= delivery is sync-safe.
+- *Behind only, current branch, sync-blocking tree* → fetched but not advanced. Surface so Craig can ff manually after dealing with the reported state.
- *Behind only, non-checkout branch* → =git fetch . upstream:branch= advances the ref without touching the working tree.
- *Diverged* (ahead and behind) → leave alone. Surface for Craig to resolve. Don't auto-rebase or auto-merge.
- *Ahead only* or *up to date* → silent no-op.
@@ -124,7 +135,7 @@ These calls have no dependencies on each other. Issue them all together in one m
sc=$(.ai/scripts/session-context-path 2>/dev/null || echo .ai/session-context.org)
[ -e "$sc" ] && echo "present: $sc" || echo "absent: $sc"
#+end_src
-3. *Sync =.ai/= from templates — but only when the synced source paths in rulesets are clean.* Guard the three rsyncs behind a check that =claude-templates/.ai/{protocols.org,workflows/,scripts/}= have no uncommitted changes. Otherwise Phase A copies in-flight rulesets WIP (tracked edits or new untracked files) into this project's =.ai/workflows/= and =.ai/scripts/=, where it shows up as drift the user didn't author. Skipping once is cheap — the next session with rulesets clean catches up. The check is scoped to the synced paths, so unrelated rulesets dirt (a stray =session-context.org=, scratch files) doesn't needlessly block the sync.
+3. *Sync =.ai/= from templates — but only when the synced source paths in rulesets are clean.* Guard the three rsyncs behind a check that =claude-templates/.ai/{protocols.org,workflows/,scripts/}= have no uncommitted changes. Otherwise Phase A copies in-flight rulesets WIP (tracked edits or new untracked files) into this project's =.ai/workflows/= and =.ai/scripts/=, where it shows up as drift the user didn't author. Skipping once is cheap — the next session with rulesets clean catches up. The check is scoped to the synced paths, so unrelated rulesets dirt (a stray =session-context.org=, scratch files) doesn't needlessly block the sync. A second guard skips the same rsyncs when the *project* branch is behind its upstream (=git rev-list --left-right --count @{u}...HEAD= with =behind > 0=): syncing templates onto a stale committed =.ai/= baseline measures the diff against old content, so it comes out huge and conflicts when the branch later reconciles to upstream, whose history already carries the newer templates. It composes with the rulesets-clean guard — a stable rulesets source and a current project branch are both required before the sync runs.
#+begin_src bash
rs="$HOME/code/rulesets"
@@ -132,15 +143,30 @@ These calls have no dependencies on each other. Issue them all together in one m
claude-templates/.ai/protocols.org \
claude-templates/.ai/workflows/ \
claude-templates/.ai/scripts/ 2>/dev/null)
- if [ -z "$synced_dirty" ]; then
+ # Skip the sync when the project branch hasn't reached its upstream. Syncing
+ # templates onto a behind baseline measures the diff against stale committed
+ # .ai/, producing confusing drift that conflicts when the branch reconciles —
+ # the newer .ai/ is already in upstream. behind==0 (up-to-date or ahead-only)
+ # means HEAD contains all of upstream, so the baseline is current. No upstream
+ # (new/unpushed branch) → rev-list fails → proj_behind stays 0, sync runs.
+ proj_behind=0
+ if [ -d .git ]; then
+ counts=$(git rev-list --left-right --count '@{u}...HEAD' 2>/dev/null) \
+ && [ "$(printf '%s' "$counts" | cut -f1)" -gt 0 ] 2>/dev/null \
+ && proj_behind=1
+ fi
+
+ if [ -n "$synced_dirty" ]; then
+ echo "rulesets has uncommitted changes under the synced template paths — skipping .ai/ sync this session (catches up when rulesets is clean):"
+ echo "$synced_dirty" | sed 's/^/ /'
+ elif [ "$proj_behind" -eq 1 ]; then
+ echo "project branch is behind upstream — skipping .ai/ sync this session (templates never land on a stale baseline; the sync runs once the branch is current)"
+ else
rsync -a "$rs/claude-templates/.ai/protocols.org" .ai/protocols.org
rsync -a --delete "$rs/claude-templates/.ai/workflows/" .ai/workflows/
rsync -a --delete --exclude='__pycache__' --exclude='.pytest_cache' --exclude='*.pyc' \
"$rs/claude-templates/.ai/scripts/" .ai/scripts/
echo ".ai/ synced from templates"
- else
- echo "rulesets has uncommitted changes under the synced template paths — skipping .ai/ sync this session (catches up when rulesets is clean):"
- echo "$synced_dirty" | sed 's/^/ /'
fi
#+end_src
@@ -151,14 +177,16 @@ These calls have no dependencies on each other. Issue them all together in one m
8. =[ -f todo.org ] && .ai/scripts/task-review-staleness.sh todo.org 7 || true= — count top-level tasks overdue for review (the daily task-review habit's startup nudge). The =[ -f todo.org ]= guard skips projects without a root todo.org; =|| true= keeps Phase A from failing if the script isn't synced yet. Threshold 7 days is one review cycle of slack — softer than the wrap-up health check's 30-day alarm.
9. =bash ~/code/rulesets/scripts/sync-language-bundle.sh "$PWD" 2>/dev/null || true= — language-bundle freshness for the current project. Fingerprint-detects which bundle (if any) the project has, auto-fixes drifted rulesets-owned files (=.claude/rules/*.md=, =.claude/hooks/*=, =githooks/*=), and surfaces drift in =settings.json= without writing it (a project may have customized it). =CLAUDE.md= is deliberately left untracked — it's seed-only in =install-lang= and project-owned afterward, mirroring how =diff-lang= skips it. Quiet when there's no bundle or everything's clean. Hardcodes the rulesets path because =languages/= is the canonical source and lives only there — the same absolute-path dependency the rsyncs already carry. =|| true= keeps Phase A from failing on older checkouts where the script isn't present yet. The =.ai/= rsyncs and this call write to disjoint paths (=.ai/= vs =.claude/=/=githooks/=), so the batch stays parallel-safe.
10. =[ -f "$HOME/org/roam/inbox.org" ] && grep -cE '^\*\* ' "$HOME/org/roam/inbox.org" || true= — count items in the roam global inbox (=~/org/roam/inbox.org=), the roam-mode startup nudge. Silent if the roam clone isn't on this machine. Phase C reads the file when the count is non-zero, splits total vs items related to this project, and surfaces the offer (see =inbox.org= roam mode). Read-only; never files at startup.
-11. KB surface prep (the read + contribute startup nudges; see =docs/design/2026-06-16-encourage-kb-contribution-spec.org=). Gated on the agent KB clone. Counts =:agent:= nodes, lists up to 5 whose content matches the current project basename (titles only; a few most-recent nodes as a fallback when nothing matches), and resolves the best-practices node path. Read-only; silent when the clone is absent. Phase C surfaces the relevant titles (consult) and the best-practices link (contribute).
+11. KB surface prep (the read + contribute startup nudges; see =docs/specs/2026-06-16-encourage-kb-contribution-spec.org=). Gated on the agent KB clone. Counts =:agent:= nodes, lists up to 5 whose content matches the current project basename (titles only; a few most-recent nodes as a fallback when nothing matches), and resolves the best-practices node path. Read-only; silent when the clone is absent. Phase C surfaces the relevant titles (consult) and the best-practices link (contribute).
+
+ The best-practices lookup matches the node's *filename*, not its content. A roam node's slug lives only in its filename, so the earlier content-grep (=rg -l 'agent-kb-best-practices'=) matched nothing and the contribute nudge silently pointed at an empty path in every project, every session, for as long as it shipped. =find= rather than a glob keeps the probe identical under bash and zsh (zsh aborts on an unmatched glob) — the same reason the spec-sort probe below uses =find=.
#+begin_src bash
ra="$HOME/org/roam/agents"
if [ -d "$ra" ]; then
proj=$(basename "$PWD")
echo "kb-total: $(rg -l '#\+filetags:.*:agent:' "$ra" 2>/dev/null | wc -l)"
- echo "kb-bestpractices: $(rg -l 'agent-kb-best-practices' "$ra" 2>/dev/null | head -1)"
+ echo "kb-bestpractices: $(find "$ra" -maxdepth 1 -name '*agent-kb-best-practices*.org' -print -quit 2>/dev/null)"
matches=$(rg -il "$proj" "$ra" 2>/dev/null | head -5)
[ -z "$matches" ] && matches=$(\ls -t "$ra"/*.org 2>/dev/null | head -3)
echo "kb-relevant-titles:"
@@ -166,12 +194,32 @@ These calls have no dependencies on each other. Issue them all together in one m
fi
#+end_src
+12. Spec-sort probe (the docs-lifecycle retrofit nudge; see the docs-lifecycle spec in =docs/specs/=). Read-only; prints one line when the project has an unsorted docs pile — a =docs/design/= directory or stray =docs/*-spec.org= root files — and no =:LAST_SPEC_SORT:= marker in =.ai/notes.org=. Silent for projects with nothing to sort or an already-stamped marker (the marker permanently clears it).
+
+ #+begin_src bash
+ { [ -d docs/design ] || [ -n "$(find docs -maxdepth 1 -name '*-spec.org' -print -quit 2>/dev/null)" ]; } \
+ && ! grep -qs ':LAST_SPEC_SORT:' .ai/notes.org \
+ && echo "spec-sort: unsorted docs present" || true
+ #+end_src
+
+ The stray-root check uses =find= rather than a glob so the probe behaves identically under bash and zsh (=compgen= is bash-only, and zsh aborts on an unmatched glob).
+
+13. Host-identity probe (see the host-identity rule in =claude-rules/=). Read-only; flags fixed machine-identity claims in the project's tracked/synced docs — the "This machine is ratio" trap, false on every machine but the one that wrote it. Silent when nothing matches.
+
+ #+begin_src bash
+ grep -inE '\b(this|the current) (machine|host|box|laptop|workstation) is ' \
+ CLAUDE.md .ai/notes.org 2>/dev/null | head -3 || true
+ #+end_src
+
+ Fleet descriptions ("the fleet is ratio and velox") and runtime derivations ("run =uname -n= to find the hostname") don't match — only current-identity assertions do. Fixture-verified under bash and zsh.
+
Notes on the rsync commands:
- Trailing slashes on both source and destination matter — they tell rsync to sync /contents/ rather than nest a directory inside.
- =--delete= on the directory syncs lets retired template files actually disappear from each project on next startup.
- protocols.org is a single file, no =--delete= needed.
- The =scripts/= sync excludes Python build artifacts (=__pycache__/=, =.pytest_cache/=, =*.pyc=). Running rulesets' own pytest leaves these in =claude-templates/.ai/scripts/tests/=, and =rsync -a= copies by disk presence regardless of =.gitignore=, so without the excludes every consuming project's tree gets polluted with machine-specific cache files. The excludes also protect existing dest copies from =--delete= cleanup, so a project that already received the cache must remove it once by hand.
- The sync is guarded to skip when rulesets has uncommitted changes under the synced source paths. =rsync -a --delete= copies the working tree by disk presence, so without the guard a downstream session started while rulesets had in-flight WIP would pull that WIP into its =.ai/workflows/= and =.ai/scripts/=, surfacing as drift the user never authored (and tempting a fake "chore: sync .ai tooling" commit). The guard is scoped to the synced paths, not the whole repo, so unrelated rulesets dirt doesn't block the sync. From the jr-estate handoff 2026-05-29.
+- The sync is also guarded to skip when the *project* branch is behind its upstream (=proj_behind=). Phase A.0 correctly declines to fast-forward a diverged or behind-and-dirty branch, but the rsync would then land templates on the stale committed =.ai/= baseline — a huge diff measured against old content that conflicts once the branch reconciles to upstream's newer templates. Skipping is safe: the sync runs next session once the branch is current. Not an auto-discard — startup never =git checkout=s drift away, because a legitimate local stopgap in a synced file is indistinguishable from accidental drift by content alone (home reverted an intentional =flashcard-to-anki.py= fix this way on 2026-06-22). Prevention is safe; blind cleanup-after is not. Phase C's template-sync-churn safety net still surfaces any pre-existing dirt for a human decision. From the home handoff 2026-07-04.
- The sync touches only =protocols.org=, =workflows/=, and =scripts/=. The project-owned dirs =project-workflows/= and =project-scripts/= are deliberately *outside* the synced set, so a project's own workflows and scripts survive startup. This is why a project script that a workflow imports must live in =.ai/project-scripts/=, never =.ai/scripts/= — the latter is wiped to match the template by =--delete= on every startup. Naming: a script imported as a Python module needs an importable name (underscores, e.g. =zlibrary_api.py=); a CLI-invoked script can stay kebab-case like the template tooling (=cmail-action.py=).
Rationale: Every call in Phase A is read-only or writes to a distinct path. Running them sequentially wastes round-trips; running them in parallel gives Claude the complete starting picture in one round-trip.
@@ -199,6 +247,8 @@ This phase touches the user and runs sequentially:
- *Roam inbox nudge.* If the Phase A roam-inbox count is greater than zero, read =~/org/roam/inbox.org=, split total vs items related to this project (claimed by the =<project>:= prefix, plus any unprefixed item whose topic plainly concerns this project), and surface one line: "Roam inbox: =<N>= total, =<M>= appear related to this project — say 'inbox zero' to file them." Offer it as a priority option; never auto-file. If the count is zero or the file is absent, say nothing. See =inbox.org= roam mode.
- *KB consult nudge (read side).* If the Phase A KB-surface prep returned any =kb-relevant-titles=, surface one line listing them (capped 5): "KB lessons that may be relevant: =<title>=; =<title>=… — open the node before related work." The titles are declarative, so the list alone tells you whether to open one. Gated on the roam clone; silent when the clone is absent or nothing relevant surfaced. See the best-practices node and =knowledge-base.md=.
- *KB contribute nudge (write side).* Once per session, surface one line pointing at the best-practices node (the =kb-bestpractices= path from Phase A): "Learned something durable? See =<path>= for how to write a KB node — contributing cross-project facts is welcome (personal projects only; work/unknown projects never write per =knowledge-base.md=)." Light encouragement, never a gate. Gated on the roam clone; silent when absent.
+ - *Spec-sort nudge.* If the Phase A spec-sort probe printed =spec-sort: unsorted docs present=, surface one line: "this project's docs pile has never been spec-sorted — say 'run spec-sort' to sort it." If the probe was silent, say nothing. A project with nothing to sort never sees the line; a stamped =:LAST_SPEC_SORT:= marker permanently clears it. See the docs-lifecycle rule and the spec in =docs/specs/=.
+ - *Host-identity flag.* If the Phase A host-identity probe printed any match, surface it with the file:line and the fix: "this doc asserts a fixed machine identity — false on every other machine; replace with a runtime derivation (run =uname -n=), per the host-identity rule." The probe flags for judgment, never blocks. Silent when the probe is silent.
- *Language-bundle sync.* If the Phase A step-12 call (=sync-language-bundle.sh=) printed anything, surface it. =fixed= lines are informational — the drift was already repaired (note that =.claude/= is now dirty if the project commits it). A =drift= line on =settings.json= is surface-only and needs the printed =make install-<lang> PROJECT=.= to reconcile; flag it so the user can decide. If the call was silent, say nothing.
- *Newly-installed symlinks.* If the Phase A.0 =make install= step printed any =link= / =relink= / =WARN= line, surface it. A =link= line means a skill, rule, hook, or script added to rulesets is now linked into =~/.claude= for the first time on this machine. For a newly-linked *skill*, check the agent's available-skills list: if the harness already registered it mid-session, note it's available and move on; if it's absent, stop and tell Craig to restart the agent so it loads (whether a mid-session reload works is harness-version-dependent). For a newly-linked *hook*, note that the harness reads hooks at session start — it fires from the next session (or after Craig opens =/hooks= once); its settings.json wiring travels with the tracked file, so the link is usually the only missing piece. A =WARN ... not a symlink= line is a real collision at the target path — surface it; it needs a human. If the step printed only "nothing new to link", say nothing.
- *Template-sync churn (safety net).* Check whether Phase A's rsync left uncommitted churn in the synced =.ai/= paths — accumulated from a prior session that crashed before wrap-up, or freshly added this session when rulesets advanced. Without surfacing, it builds up silently until it blocks Phase A.0's auto-ff (git won't ff a dirty tree). Skip in the rulesets repo itself (there =.ai/= is a committed mirror, kept honest by the pre-commit hook). The check is sequential here, after the rsync has finished — not a Phase A step, to keep that batch race-free.
diff --git a/claude-templates/.ai/workflows/status-check.org b/claude-templates/.ai/workflows/status-check.org
index efff16d..4a9972c 100644
--- a/claude-templates/.ai/workflows/status-check.org
+++ b/claude-templates/.ai/workflows/status-check.org
@@ -1,5 +1,5 @@
#+TITLE: Status Check Workflow
-#+AUTHOR: Craig Jennings & Claude
+#+AUTHOR: Craig Jennings
#+DATE: 2026-02-02
* Overview
diff --git a/claude-templates/.ai/workflows/summarize-emails.org b/claude-templates/.ai/workflows/summarize-emails.org
index 6ac5e6f..c9c7001 100644
--- a/claude-templates/.ai/workflows/summarize-emails.org
+++ b/claude-templates/.ai/workflows/summarize-emails.org
@@ -1,5 +1,5 @@
#+TITLE: Summarize Emails Workflow
-#+AUTHOR: Craig Jennings & Claude
+#+AUTHOR: Craig Jennings
#+DATE: 2026-02-14
* Overview
diff --git a/claude-templates/.ai/workflows/suspend.org b/claude-templates/.ai/workflows/suspend.org
index 1c16bb9..166f9c9 100644
--- a/claude-templates/.ai/workflows/suspend.org
+++ b/claude-templates/.ai/workflows/suspend.org
@@ -1,5 +1,5 @@
#+TITLE: Session Suspend Workflow
-#+AUTHOR: Craig Jennings & Claude
+#+AUTHOR: Craig Jennings
#+DATE: 2026-06-28
* Overview
@@ -23,8 +23,10 @@ straight:
Refreshes the anchor in place, prompts Craig to type =/clear=, and a hook
resumes the *same* logical session in a fresh context. Craig is still here.
- *suspend* (this workflow) — *leave.* Captures richly into the anchor, leaves
- the file in place, and Craig walks away. The next session is a cold startup
- that detects the present anchor and resumes from it.
+ the file in place, detaches the tmux client so the session parks in the
+ re-attachable set, and Craig walks away. The next session is a cold startup
+ that detects the present anchor and resumes from it — or Craig re-attaches the
+ still-live session directly.
- =wrap-it-up= ([[file:wrap-it-up.org][wrap-it-up.org]]) — *end.* Writes the
Summary, archives the anchor into =.ai/sessions/=, commits + pushes, and runs
the phrase-dependent teardown.
@@ -91,7 +93,32 @@ when the Summary body is from an earlier thread.
that set — but the default shared behavior is to leave the tree alone.)
4. *Leave =.ai/session-context.org= in place.* Do not archive it.
5. *Brief handoff* — one or two lines: what was captured, where the resume
- pointer is, the most-active thread. End and let Craig go.
+ pointer is, the most-active thread. This is the last thing Craig sees before
+ the view detaches (Step 6), so deliver it complete.
+6. *Detach the tmux client.* As the final action, detach the client viewing the
+ =aiv-<project>= session so it drops out of Craig's active view while staying
+ alive in the background. This is a DETACH, not a teardown: the session and the
+ agent process keep running, nothing is killed, no context is lost.
+
+ #+begin_src bash
+ sess=$(tmux display-message -p '#S' 2>/dev/null)
+ [ -n "$sess" ] && tmux detach-client -s "$sess"
+ #+end_src
+
+ Run it as the very last tool call, after the handoff text has rendered — tmux
+ preserves the pane, so Craig sees the full handoff when he re-attaches. Unlike
+ wrap-up's teardown (which must defer to a =Stop= hook because it kills the
+ session the agent runs in, which would cut off the valediction), detach runs
+ inline: it disconnects the view but leaves the agent's session alive, so
+ nothing is cut off. Degrade gracefully — if not inside tmux (=$TMUX= unset, no
+ session), skip silently and the session simply stays attached.
+
+ Why detach on every suspend: Craig cycles his live agent sessions in Emacs
+ with alt-space, and rotates through everything — including re-attaching
+ detached ai-term sessions — with shift+alt+space. A suspended session left
+ attached clutters the active rotation; detaching parks it in the
+ re-attachable set, which is what makes suspend-and-walk-away work. Re-attach
+ is one keystroke (shift+alt+space) or =tmux attach -t aiv-<project>=.
* What suspend does NOT do
@@ -103,8 +130,12 @@ does beyond capture:
- No KB / memory promotion sweep.
- No Linear / board reconciliation.
- No session-record archive (the file stays live).
-- No teardown (the ai-term buffer + tmux session stay up). It drops no
- =Stop=-hook teardown sentinel, so the wrap-teardown hook stays dormant.
+- No teardown. Suspend DETACHES the tmux client (Step 6) but never kills the
+ session: the =aiv-<project>= session and the agent process stay alive in the
+ background, only the view disconnects. It drops no =Stop=-hook teardown
+ sentinel, so the wrap-teardown hook stays dormant. Teardown — killing the
+ session — is wrap-it-up's job, not suspend's; detach is the lighter move that
+ parks a still-live session.
- No blind commit of working files (step 3).
- No valediction. A suspend is a pause, not a goodbye.
diff --git a/claude-templates/.ai/workflows/sync-email.org b/claude-templates/.ai/workflows/sync-email.org
index 52a7caf..863b400 100644
--- a/claude-templates/.ai/workflows/sync-email.org
+++ b/claude-templates/.ai/workflows/sync-email.org
@@ -1,5 +1,5 @@
#+TITLE: Sync Email Workflow
-#+AUTHOR: Craig Jennings & Claude
+#+AUTHOR: Craig Jennings
#+DATE: 2026-02-01
* Overview
diff --git a/claude-templates/.ai/workflows/task-audit.org b/claude-templates/.ai/workflows/task-audit.org
index 94b99da..aa50176 100644
--- a/claude-templates/.ai/workflows/task-audit.org
+++ b/claude-templates/.ai/workflows/task-audit.org
@@ -1,5 +1,5 @@
#+TITLE: Task Audit Workflow
-#+AUTHOR: Craig Jennings & Claude
+#+AUTHOR: Craig Jennings
#+DATE: 2026-05-22
* Overview
@@ -61,6 +61,8 @@ For each open task, read its body and cross-check its claims against the actual
- *Calendar* — did a scheduled event happen; is a SCHEDULED/DEADLINE date now past.
- *Meeting recordings* — if a task hinges on "did this conversation happen / what was said," check the recording queue (e.g. =~/sync/recordings/=) and transcribe via =process-meeting-transcript.org= if the answer lives in an un-transcribed recording. (This is exactly how a "did the interview happen?" task gets resolved instead of guessed.)
+*Spec lifecycle reconcile (docs-lifecycle convention).* If the project has a =docs/specs/=, run the =:SPEC_ID:= query as part of this phase: for each spec whose top-level status heading reads =DOING=, find the =todo.org= task whose =:SPEC_ID:= property matches the spec's =:ID:=. Flag the spec NEEDS-USER when that bound parent is =DONE=/=CANCELLED=, archived, or missing — the build finished (or evaporated) without the =IMPLEMENTED= flip, exactly the drift this check exists to catch. Check the parent's own keyword, not its children (completed children become dated entries and the final flip task is a child, so child-counting misleads).
+
Assign each task a bucket (CURRENT / STALE / NEEDS-USER) and, for STALE, the specific factual update.
*Scale tactic.* For a large open-task set, dispatch read-only investigation sub-agents over batches of tasks (parallel-safe per =subagents.md= — independent read-only domains). Each returns a per-task bucket + suggested update. *Never* let sub-agents write to =todo.org= concurrently — apply all edits serially in the main thread (concurrent writes to one file race and lose work).
@@ -79,7 +81,7 @@ For every STALE task, edit it in the main thread:
- *Ensure priority is set per the project scheme.* The top of the project's =todo.org= should carry the priority legend (=[#A]= through =[#D]=). Every task should carry an explicit priority cookie. If a cookie is missing, or no longer matches the reconciled facts, assign the right level per the legend. If the level is unambiguous from the body, do it autonomously; if it's a judgment call (especially the [#A] / [#B] line for important-but-not-urgent work), flag NEEDS-USER. Also enforce the [#A]-discipline rule from the legend — an [#A] task without a =SCHEDULED:= or =DEADLINE:= line is mis-graded and is either down-graded to [#B] (when reconciled facts say "important but not urgent") or surfaced as NEEDS-USER for the user to date.
- *Ensure a type tag is set.* Every task carries one type tag from the project's tag legend (typically =:feature:= / =:chore:= / =:spec:= / =:bug:=). If missing or wrong, assign or correct it from the body when the type is unambiguous. If two tags fit (a refactor that also fixes a bug; a spec that's also a chore), flag NEEDS-USER rather than picking one silently.
- *Enforce the project's declared tag vocabulary.* If the project's tag legend declares an *exhaustive* set of allowed tags, strip from each task any tag outside that set — the heading and parent section already carry topic/scope context, so ad-hoc tags only fragment the vocabulary and defeat tag-based filtering. Normalize near-duplicate spellings to the canonical tag (a plural to its singular, say). Where the legend does not declare the set closed, leave existing tags alone; this step applies only where the allowed set is exhaustive by design.
-- *Re-assess the =:quick:= and =:solo:= tags* — reconciliation can change a task's effort or autonomy: a resolved dependency may make a stuck task =:solo:=, a scope cut may make it =:quick:=, and new complexity surfaced by the sources can invalidate either. Add or remove the tags per the definitions in the project's tag legend (and [[file:task-review.org][task-review.org]]) when the reconciled facts make the call clear. When they don't — an effort estimate you can't pin down, a =:solo:= gate you can't confirm — it's a NEEDS-USER flag, not a guess.
+- *Re-assess the =:quick:= and =:solo:= tags (mandatory — an audit that skips this is incomplete).* Reconciliation can change a task's effort or autonomy: a resolved dependency may make a stuck task =:solo:=, a scope cut may make it =:quick:=, and new complexity surfaced by the sources can invalidate either. Add or remove the tags per the hard definitions in [[file:../../claude-rules/todo-format.md][todo-format.md]] ("Hard definitions: :solo: and :quick:"; task-review carries the same three-gate walk). Autonomous execution reads =:solo:= as its eligibility gate and trusts the tag, so a stale one is a run-time hazard, not cosmetic drift. When the call isn't clear — an effort estimate you can't pin down, a =:solo:= gate you can't confirm — it's a NEEDS-USER flag, not a guess.
- Bump =:LAST_REVIEWED:= on each edited task.
Follow =todo-format.md= for completion mechanics (depth-based DONE vs dated-rewrite) and the working-files / link-hygiene rules when moving artifacts.
@@ -99,6 +101,21 @@ Never merge or re-parent autonomously — which tasks belong together, and wheth
When no clear cluster exists, say so in one line and move on — most audits won't find one, and forcing a merge fragments worse than it consolidates.
+** Phase C.6 — Retire completed parents and promote stragglers (interactive)
+
+Phase C.5 consolidates related *open* tasks. This step retires parent tasks whose work is *finished*, so completed containers don't linger in Open Work as scaffolding.
+
+Run =todo-cleanup.el --convert-subtasks= first (it's part of the =clean-todo= / wrap-up cleanup, and =open-tasks.org= runs it too) so every completed sub-task is a dated event-log entry rather than a lingering =DONE= keyword. The closure logic below reads "open child" as a child heading still carrying a task keyword (=TODO=/=DOING=/=WAITING=/=VERIFY=/=NEXT=/=PROJECT=/=STALLED=/=DELEGATED=); a dated entry is correctly not open.
+
+Two shapes, both proposed to Craig (inline numbered options per =interaction.md=, no popup) before applying:
+
+- *Zero open children → close the parent.* A parent whose child *tasks* are all resolved (now dated) and that carries no open child task is finished: close it per =todo-format.md= (=**= parent → =DONE=/=CANCELLED= + =CLOSED:=), and it moves to Resolved on the next =--archive-done=. If the work resurfaces later, a fresh task is created then; a completed container shouldn't sit open as a placeholder.
+- *One or two open children → promote, then close.* When a parent has only one or two open children, pull them out and rewrite them as standalone =**= level-2 tasks — give each a priority per the project scheme, and make the heading stand alone without the parent's context — then close the now-childless parent and let it move to Resolved. The former children become first-class Open Work tasks; the retired parent stops being scaffolding for one or two stragglers.
+
+*The leaf-with-notes carve-out (important).* "Zero open children" is not the same as "done." A =**= leaf task whose only descendants are dated *notes* — a captured "Ideas", "Goals", or "Current State" entry, not a real completed sub-task — is unstarted work with a note attached, not a finished container. Do not close it. Tell the two apart by intent: a container reads as a grouping (a =PROJECT= keyword, an explicit "parent grouping ..." line, or several dated entries that were genuinely separate sub-tasks that shipped); a leaf-with-notes is a single feature/bug task whose title names unstarted work and whose lone dated child is a design note. When the call is ambiguous, flag it NEEDS-USER rather than closing.
+
+Never close or promote autonomously past the ambiguous line — surface the candidates with a recommendation and let Craig ratify, the same interactive stance as Phase C.5. Clear container completions (a =PROJECT= whose every child is dated) can be proposed as a batch; leaf-with-notes ambiguities are flagged individually. Verify open-vs-done counts against the actual headings (a real scan of the subtree), not a fragile regex that a shell's =\b= support can silently break — a miscount here closes live work.
+
** Phase D — Flag the judgment calls (interactive)
Present the NEEDS-USER bucket as a short, scannable list — one line per task, naming the decision or the fact required. Adjudicate with the user one item at a time (inline numbered options per =interaction.md=, no popup). Apply the user's calls as they come (which may itself produce more autonomous updates, or new tasks).
@@ -147,3 +164,7 @@ Two Phase C behaviors added, both surfaced by an Emacs-config =todo.org= audit:
- *Tag-vocabulary enforcement.* That project declares a closed tag set (=bug=, =feature=, =refactor=, =test=, =quick=, =solo=); the audit had to strip ~44 ad-hoc tags that had accumulated across the file. The prior workflow only checked that a type tag was *present* — it had no concept of an exhaustive allowed set. The new bullet enforces a declared closed vocabulary and leaves open-vocabulary projects untouched.
- *Code-complete-but-unverified closing.* Many tasks had shipped (tests green, live in the daemon) but stayed open awaiting a manual or visual verification, so they accumulated as half-open. Leaving them open is noise; auto-closing them would violate "never claim a fix verified before the user confirms." The fix routes the pending human check into the project's =Manual testing and validation= parent (dedup-checked) per =verification.md='s manual-verification hand-off, then closes the implementation task. The work is done and the check is tracked; a failed check promotes to a bug.
+
+** 2026-07-01 — Retire completed parents (Phase C.6)
+
+Added Phase C.6: retire a parent task once its child *tasks* are all done. Zero open children → close the parent; one or two open children → promote them to standalone level-2 tasks, then close. Surfaced by an Emacs-config =todo.org= audit where several PROJECT containers had all children complete. Depends on =todo-cleanup.el --convert-subtasks= running first so completed sub-tasks are dated (not lingering =DONE= keywords) and the open-child count is accurate. Carries a leaf-with-notes carve-out: a =**= leaf task whose only descendant is a dated design note ("Ideas"/"Goals") is unstarted work, not a finished container, and must not be closed — the ambiguous case is flagged NEEDS-USER. The step also warns against counting open-vs-done with a fragile regex (a =\b= that a given shell/awk silently drops miscounts and closes live work).
diff --git a/claude-templates/.ai/workflows/task-review.org b/claude-templates/.ai/workflows/task-review.org
index 69e172d..7ea2e8e 100644
--- a/claude-templates/.ai/workflows/task-review.org
+++ b/claude-templates/.ai/workflows/task-review.org
@@ -1,5 +1,5 @@
#+TITLE: Task Review Workflow
-#+AUTHOR: Craig Jennings & Claude
+#+AUTHOR: Craig Jennings
#+DATE: 2026-05-20
* Overview
@@ -57,7 +57,9 @@ Keep is the common case — most tasks are still right and just need re-stamping
*** Tagging =:quick:= — small tasks
-While reviewing each task, estimate its effort. If you judge it *30 minutes or less* and it doesn't already carry =:quick:=, add the tag to the heading line. If the heading and body don't tell you how long it'll take, *ask Craig* — don't guess. A wrong =:quick:= is worse than none: the tag exists so Craig can grab a genuinely small task in a spare moment, and a mislabeled one wastes that moment.
+The =:quick:= and =:solo:= assessments (this section and the next) are *mandatory* for every reviewed task except a Kill — a review that skips them is incomplete. The hard definitions live in [[file:../../claude-rules/todo-format.md][todo-format.md]] ("Hard definitions: :solo: and :quick:"); autonomous execution (work-the-backlog / the no-approvals speedrun) reads =:solo:= as its eligibility gate and trusts the author's tag, so the run-time gate is only as trustworthy as this pass.
+
+While reviewing each task, estimate its effort. If you judge it *30 minutes or less* and it doesn't already carry =:quick:=, add the tag to the heading line. If the heading and body don't tell you how long it'll take, *ask Craig* — don't guess. A wrong =:quick:= is worse than none: the tag exists so Craig can grab a genuinely small task in a spare moment, and a mislabeled one wastes that moment. =:quick:= is an effort hint only, never an eligibility gate — size does not decide what runs autonomously.
This is orthogonal to the action chosen — a task can be kept (or re-graded, or marked DOING) *and* tagged =:quick:= in the same pass. Skip the assessment on a Kill, since it's leaving the pool. Tags go on the heading line per [[file:../../claude-rules/todo-format.md][todo-format.md]], sharing one =:tag1:tag2:= cluster.
@@ -67,7 +69,7 @@ While reviewing each task, judge whether Claude could build *and* verify it with
1. *Buildable* — Claude has the capability and access to do the work.
2. *Verifiable by Claude* — an objective or local check exists that Claude can run itself. Craig's routine spot-checking does not count against this, and neither does handing off a residual human-in-the-loop confirmation as a structured manual-testing reminder (the =verification.md= "Handing Off Manual Verification" pattern). The disqualifier is having no verification path of Claude's own at all — when the success criterion is only judgeable by Craig's eyes or subjective taste.
-3. *No upfront decision* — no design or preference call Craig must make before Claude can begin.
+3. *No deliberation* — no open design question and no "weigh these approaches" with real tradeoffs. At most one or two *quick, upfront-answerable* factual decisions are allowed — the speedrun preset batches those into its pre-flight Q&A, so they don't break the hands-off run. A genuine design or preference call disqualifies.
If any gate is shaky, leave the tag off. Like =:quick:=, a wrong =:solo:= is worse than none — it tells Craig he can hand the task off and walk away, so a mislabeled one wastes that trust. When the heading and body don't make all three gates clear, ask Craig instead of guessing.
@@ -90,12 +92,14 @@ Set =:LAST_REVIEWED:= to today's date (from above) in the task's =:PROPERTIES:=
Body...
#+end_example
-The exact date string matters: =task-review-staleness.sh= and the wrap-up health check both parse =:LAST_REVIEWED: YYYY-MM-DD=.
+Format: =:LAST_REVIEWED:= takes a bare ISO date (=2026-05-20=) or an org-native inactive timestamp (=[2026-05-20 Tue]=, matching the =CREATED:=/=CLOSED:= cookies beside it); =task-review-staleness.sh= and the wrap-up health check normalize both to the date. A value that is neither is a data error — the staleness script warns loudly to stderr (naming the file, line, and value) and leaves the task out of the stale count rather than silently reporting a freshly-reviewed task as never-reviewed. Stamp a clean date and the warning never fires.
*** Killing a task
Follow the completion rules in [[file:../../claude-rules/todo-format.md][todo-format.md]]. A killed top-level =**= task stays task-shaped: change the keyword to =CANCELLED=, add a =CLOSED: [YYYY-MM-DD Day]= line under the heading (generate with =date "+%Y-%m-%d %a"=), and leave the priority and tags intact. It's then a candidate for =--archive-done= at the next cleanup. Don't stamp =:LAST_REVIEWED:= on a kill — it's leaving the review pool anyway.
+A killed *sub-task* (=***= or deeper, under a parent task) instead becomes a dated event-log entry per the depth rule — but you don't have to hand-format it here. =todo-cleanup.el --convert-subtasks= (run in the =clean-todo= and wrap-up cleanup passes) rewrites any level-3+ DONE/CANCELLED/FAILED heading into its dated form mechanically from the =CLOSED= cookie, so a keyword-plus-=CLOSED= close at depth gets normalized on the next cleanup rather than lingering. =lint-org.el= flags any that slip through (checker =subtask-done-not-dated=).
+
* Phase D: Close out
When the batch is done (or Craig calls it early):
diff --git a/claude-templates/.ai/workflows/triage-intake.cmail.org b/claude-templates/.ai/workflows/triage-intake.cmail.org
index d818c72..8d8abfb 100644
--- a/claude-templates/.ai/workflows/triage-intake.cmail.org
+++ b/claude-templates/.ai/workflows/triage-intake.cmail.org
@@ -1,5 +1,5 @@
#+TITLE: Triage Intake — cmail (Proton) Source
-#+AUTHOR: Craig Jennings & Claude
+#+AUTHOR: Craig Jennings
#+DATE: 2026-05-26
# Source plugin for the triage-intake engine. See triage-intake.org for the
diff --git a/claude-templates/.ai/workflows/triage-intake.github-prs.org b/claude-templates/.ai/workflows/triage-intake.github-prs.org
index c1bc796..644421c 100644
--- a/claude-templates/.ai/workflows/triage-intake.github-prs.org
+++ b/claude-templates/.ai/workflows/triage-intake.github-prs.org
@@ -1,5 +1,5 @@
#+TITLE: Triage Intake — Personal GitHub PRs Source
-#+AUTHOR: Craig Jennings & Claude
+#+AUTHOR: Craig Jennings
#+DATE: 2026-05-26
# Source plugin for the triage-intake engine. See triage-intake.org for the
diff --git a/claude-templates/.ai/workflows/triage-intake.org b/claude-templates/.ai/workflows/triage-intake.org
index a5a3bda..55cc939 100644
--- a/claude-templates/.ai/workflows/triage-intake.org
+++ b/claude-templates/.ai/workflows/triage-intake.org
@@ -1,5 +1,5 @@
#+TITLE: Triage Intake Workflow (Engine)
-#+AUTHOR: Craig Jennings & Claude
+#+AUTHOR: Craig Jennings
#+DATE: 2026-05-01
* Summary
@@ -11,12 +11,14 @@ Think of it as the ER intake queue: every new message, invite, and PR notificati
*This file is the engine.* It carries no sources of its own. Every source it scans comes from a *source plugin* — a =triage-intake.<source>.org= file the engine loads at Phase 0. The engine is source-agnostic and project-agnostic; the project- and account-specific knowledge lives entirely in the plugins. To add a source, drop a plugin file. To change one, edit its plugin. Never wire a source into this file.
+*Which sources a project pulls is a per-project choice.* A *project-specific* plugin (=.ai/project-workflows/triage-intake.*.org=, never synced) is active by presence — dropping it is the declaration. A *general* plugin (=.ai/workflows/triage-intake.*.org=, template-synced into every project — personal Gmail, cmail, calendar, Telegram, GitHub PRs) is active only when the project names its basename in a =:TRIAGE_SOURCES:= line in =.ai/notes.org= Workflow State (space-separated basenames, e.g. =:TRIAGE_SOURCES: personal-gmail cmail=). A project that declares nothing and owns no project plugin pulls nothing. This is the Phase 0 activation gate — presence is capability, the declaration is activation (see =docs/specs/2026-07-20-triage-source-activation-spec.org=).
+
Distinct from =daily-prep.org=:
- *daily-prep* — heavier, once daily, builds the day's plan + standup brief + meeting prep + time blocks.
- *triage-intake* — fast, repeatable, just answers "what's new since last check?"
-Quick contract — what it does: fans out across source plugins, classifies every item into Action / FYI / Noise-keep / Noise-trash, synthesizes one deduped summary, writes each Action item to =todo.org= as a =:quick:reactive:= task, and executes star/mark-read/trash on confirmation.
+Quick contract — what it does: fans out across source plugins, classifies every item into Action / FYI / Noise-keep / Noise-trash, surfaces one three-section digest (==TASKS== / ==FYI== / ==MISC==) with a two-option close offer, then *closes by default*: files each TASKS item to =todo.org= as a =:quick:reactive:= task, runs the star/mark-read/trash hygiene on every scanned account, advances the sentinel, and tears down anything it started. The close runs unless Craig explicitly holds it.
** When to Use This Workflow
@@ -37,6 +39,8 @@ Typical timing:
Do *not* use when running daily-prep — daily-prep already does this as Phase 3.
+Also runs unattended as sentry's triage pass (=sentry.org=, pass 3): sentry invokes this engine under its no-approvals contract, where destructive actions (deleting, archiving, sending) queue for the morning-approval review instead of firing. The trigger phrases above are unchanged — a manual "triage intake" always routes here directly.
+
* Execution
@@ -56,16 +60,18 @@ ls .ai/workflows/triage-intake.*.org .ai/project-workflows/triage-intake.*.org 2
The glob exclude is automatic: =triage-intake.*.org= matches the plugins but not this engine file (=triage-intake.org= has no second dot-segment), so the engine never loads itself.
After globbing, for each plugin file:
-1. Read it.
-2. Evaluate its =ENABLED= precondition. If false, *announce the skip with its reason* ("skipping linear — mcp__linear not present") and move on.
-3. The surviving set is the source list for Phases A-D.
+1. *Activation gate.* A *general* plugin (from =.ai/workflows/=, template-synced into every project) is active only if its basename appears in the project's =:TRIAGE_SOURCES:= declaration (=.ai/notes.org= Workflow State — a space-separated list of source basenames). If it isn't declared, it is *inactive*: announce it ("inactive: personal-gmail — not in :TRIAGE_SOURCES:") and skip it. A *project-specific* plugin (from =.ai/project-workflows/=, never synced) is always active — dropping it there is itself the per-project declaration. This is what stops the synced general plugins from self-activating in projects that aren't triage targets: presence is capability, the declaration is activation (see =docs/specs/2026-07-20-triage-source-activation-spec.org=). An absent or empty =:TRIAGE_SOURCES:= means no general sources are active; a project with no declaration and no project plugin has no active sources, so triage no-ops there.
+2. Read it.
+3. Evaluate its =ENABLED= precondition. If false, *announce the skip with its reason* ("skipping linear — mcp__linear not present") and move on.
+4. The surviving set — active and enabled — is the source list for Phases A-D.
-*Announce the loaded set before scanning* so the omission can't hide:
+*Announce the loaded set before scanning* so the omission can't hide — inactive (undeclared) plugins are named too, so a general plugin left out of =:TRIAGE_SOURCES:= is a visible choice, not a silent drop:
#+begin_example
-Loaded 5 source plugins:
- general: personal-gmail, personal-calendar, cmail, github-prs
+Loaded 2 source plugins (:TRIAGE_SOURCES: personal-gmail cmail):
+ general: personal-gmail, cmail
project: deepsat-gmail
+ inactive (undeclared): personal-calendar, github-prs, telegram
skipped: linear (mcp__linear not present)
#+end_example
@@ -91,23 +97,27 @@ Every item lands in one bucket. Plugins refine these with source-specific bias a
Per-source bias (a work email account leans keep for audit value; a personal account leans trash on high noise volume) lives in each plugin's =Classify= section. Read it from there; don't re-derive it here.
-*** Phase C: Synthesize a single summary
+*** Phase C: Synthesize — the three-section digest
+
+One digest surfaced inline to Craig — notable items only, written as prose bullets a reader can absorb without knowing the source taxonomy. It is *not* a per-source roll-call; the plugins' =Render= shapes feed the classification, they are no longer displayed as blocks. (Format ratified by Craig 2026-07-18 after a sweep where the follow-up "summarize the notable items" digest was the report he actually wanted first.)
-One markdown summary surfaced inline to Craig. Order:
+Order:
-0. *Scan failures — first, loud, always.* Any loaded source whose scan failed, hung, was killed, or was skipped for an operational reason renders at the very top of the summary, before Top signals:
+0. *Scan failures — first, loud, always.* Any loaded source whose scan failed, hung, was killed, or was skipped for an operational reason renders at the very top of the summary, before everything else:
#+begin_example
⚠ SCAN FAILED: <source> — <reason, one line> — <what's now unknown>
#+end_example
- A failed scan is never folded into "quiet." Quiet means the scan ran and found nothing; a failure means the sweep is blind on that channel, and the reader must know which. The same applies to a precondition skip the user hasn't standing-approved (e.g. a messaging client that needs a temporary server spin-up): run the lifecycle or report the failure — don't silently narrow the sweep.
+ A failed scan is never folded into "quiet." Quiet means the scan ran and found nothing; a failure means the sweep is blind on that channel, and the reader must know which. The same applies to a precondition skip the user hasn't standing-approved (e.g. a messaging client that needs a temporary server spin-up): run the lifecycle or report the failure — don't silently narrow the sweep. Backlog banners (a plugin's pre-anchor-residue probe firing) render here too — loud, above the sections.
-1. *Top signals to act on* — bullet list of 3-7 items, ordered by urgency, *Action only*. Each bullet links to the source (permalink, thread URL, PR number).
-2. *Per-source breakdown* — one short section per loaded source *that has changes*, in =ORDER=, using that plugin's =Render= shape: Action items detailed, FYI items as a short list, Noise as a tally only ("Noise: 12 trash candidates, 4 keep, 0 starred").
-3. *Suggested actions* — explicit list of state changes Craig could take this run (trash these N messages, mark-read these M, star this Action item, respond to this invite, merge PRs #X and #Y, etc.). This line stays whenever there are queued actions, regardless of how quiet the sweep was.
+1. *==TASKS==* — every work item that needs Craig or his sign-off. Sorted in two groups: *solo-executable first* (see the solo test in Phase D), then the rest; within each group, priority order (blocking someone / deadline inside 48h first). Each item is one short prose bullet naming who, what, and why-now, with the source link or locator.
+2. *==FYI==* — substantive work context worth seeing, no action owed. Priority order.
+3. *==MISC==* — everything outside the project: personal mail, family, household, personal calendar. Priority order. An outside-project item that needs Craig still lands here (with its action-ness stated inline), not in TASKS — TASKS is the work queue. MISC items are *not* filed into this project's =todo.org=; they surface in the digest and are gone after the close unless Craig reroutes them to their owning project (the "and reroute" modifier, below).
-*Deltas only.* The summary reports what *changed* since the anchor: a new invite, a new/moved/cancelled calendar event, a new message needing attention. A source with no changes gets no block — no "Calendar — quiet", no "PRs — nothing new" roll-call. A sweep where nothing changed anywhere renders as a single line:
+A section with nothing in it is omitted. After the sections, the offer (see Phase D). The sweep's final line is always the run timestamp (=date "+%A %Y-%m-%d %H:%M %Z"=).
+
+*Deltas only.* The digest reports what *changed* since the anchor: a new invite, a new/moved/cancelled calendar event, a new message needing attention. A source with no changes contributes nothing — no "Calendar — quiet", no "PRs — nothing new" roll-call. A sweep where nothing changed anywhere renders as a single line plus the timestamp:
#+begin_example
17:39 sweep: no changes
@@ -117,11 +127,40 @@ One markdown summary surfaced inline to Craig. Order:
Scan failures are the standing exception: a failed or skipped scan always renders loudly per point 0 above and is never folded into the no-change line — "no changes" is a claim about channels the sweep could actually see.
-Format target: scannable in 30 seconds, full read in 2 minutes. Don't pad.
+Format target: scannable in 30 seconds, full read in 2 minutes. Don't pad. The old long-form report (anchor line, per-source breakdown, itemized suggested-actions list) is available *on request* — it is no longer the default surface.
+
+**** The offer — exactly this, right after the sections
+
+#+begin_example
+1. Close the triage — file todo.org tasks for the TASKS items, run the standard close
+2. Close the triage and execute the solo TASKS now, after filing the rest
+Append "and reroute" to either option to send the outside-project items to their owning projects.
+Or tell me what you'd like handled differently.
+#+end_example
+
+Option 2 renders *only when solo-executable TASKS exist*. The reroute line renders only when MISC is non-empty. No other options, no itemized action menu — the close (Phase D) owns the routine hygiene.
+
+*The reroute modifier.* "1 and reroute" / "2 and reroute" (or a bare "reroute" right after a close) means: in addition to the close, deliver every outside-project item the sweep surfaced to the project that owns it. Routing goes through =inbox-send= per the cross-project rule — a handoff into the owner's =inbox/=, never a direct write to a foreign =todo.org= — and the owner's own inbox processing files it by its conventions. This is the persistence path for MISC: without a reroute, MISC items are surfaced-only.
-**** Sub-step: write each Action item into =todo.org= as its own =:quick:= task
+*** Phase D: Close — the default, not an option
-After surfacing the summary inline, append every Action item — regardless of source — to =todo.org= as its own top-level =** TODO= heading carrying the =:quick:= tag plus =:reactive:= and any relevant person/entity tag.
+*Closing the triage is the next action after the digest, no exceptions* — unless Craig explicitly says not to ("hold the triage", "don't close yet"). If his reply picks option 1 or 2, close per the option. If his reply is anything else — a question, a redirect, a new task — *close the triage first* (as option 1), then handle what he asked. An unclosed triage strands the noise unprocessed and the sentinel stale; Craig ruled 2026-07-18 that the close, including the mail hygiene on every scanned account, is unconditional.
+
+The close, in order:
+
+1. *File every TASKS item into =todo.org=* as its own =:quick:reactive:= task (format below). Dedupe against existing tasks first — fold into an existing task's body when one already covers the topic.
+2. *Mail and message hygiene on every scanned account*: trash the Noise-trash set, mark-read the Noise-keep set, star what was flagged for keeping. This runs *without itemized confirmation* — the digest's tallies are the notice, and the actions dispatch through each plugin's =Actions= verbs. Trash is recoverable (Gmail 30-day trash; cmail =\Deleted= flag), which is what makes the no-confirmation batch safe.
+3. *Clear unacked items* that the sweep found resolved.
+4. *If option 2: execute the solo TASKS.* The solo test — mechanical, standing-approved, and producing *no prose under Craig's name*: calendar RSVPs, ticket-state moves the publishing overlay already authorizes, mark-read/star/trash. Anything that sends words as Craig (a Slack reply, an email, a PR comment, a Linear comment) is *never* solo — it stays a filed task and goes through the normal draft gate when worked. Destructive or hard-to-reverse actions beyond mail hygiene (branch deletes, PR merges) also stay confirm-gated per their plugins.
+5. *If "and reroute": route the outside-project items.* For each MISC item (and any surfaced item this project doesn't own), send a handoff to the owning project's inbox: =inbox-send <project> --text "..."= carrying what it is, the source locator (message id, thread, event id), and why it routed. Resolve the owner against =inbox-send --list=; when ownership is ambiguous, ask before sending — a wrong-project handoff costs more than one question. Never write another project's =todo.org= directly (cross-project rule). Note in the close's status line which items went where.
+6. *Advance the sentinel* — write the held Phase A capture into the sentinel's *content* (see "Capture the Phase A timestamp"): =echo "$PHASE_A_TS $(date -d "@$PHASE_A_TS" '+%Y-%m-%d %H:%M:%S %z')" > .ai/last-triage-intake=. Do not use plain =touch= (writes mtime to /now/ and strands items posted between Phase A and end of run) and do not use =touch -d "@$PHASE_A_TS"= (correct timestamp but mtime is per-machine — won't survive a fresh clone or cross-machine sync).
+7. *Tear down anything the sweep started* (e.g. the telegram lifecycle's leave-no-trace shutdown).
+
+After the close, report one status line — what shipped, the sentinel time — and stop. The close *is* the exit; there is no separate confirmation loop.
+
+**** Task-filing format (=todo.org=)
+
+Append every TASKS item — regardless of source — as its own top-level =** TODO= heading carrying the =:quick:= tag plus =:reactive:= and any relevant person/entity tag.
Each Action item is one task. Don't group items by source under =** Email Response=, =** PR Review=, etc. sub-headings. Each response is its own filterable task so Craig can re-prioritize, =SCHEDULE:= / =DEADLINE:=, or tag individually.
@@ -142,30 +181,11 @@ Rules:
- *Record the source locator in the task body* so a reply can be routed back to where the request came from — the channel + thread id for chat, the repo + PR number, the message id for mail. The general rule: a reply goes back to the *origin* of the request, not a fixed notification channel. (Project plugins may add stricter routing rules in their own files.)
- Placement: append at end of =* Work Open Work= (just before =* Work Incubate=) unless the project's =todo.org= has a designated triage section near the top (=* Triage= or =* Inbox=).
-This sub-step makes triage-intake's findings *persist* in =todo.org= instead of evaporating after the inline summary.
-
-*** Phase D: Execute actions on confirmation
-
-Wait for Craig's go-ahead before running any state changes. Default to single-confirmation for the whole batch ("yes" → run everything proposed). Craig may also pick a subset ("trash personal but hold the work account") or hand back a different plan ("trash all but star the expense thread and queue PR merges for after lunch").
-
-Each action dispatches to the owning source plugin's =Actions= verb (trash, mark-read, star, respond, merge, comment, attachment-fetch). The engine doesn't hardcode action commands — it reads them from the loaded plugins. Read each plugin's =Actions= section for the exact command.
-
-After actions complete, write the Phase A capture into the sentinel's *content* (see "Capture the Phase A timestamp"): =echo "$PHASE_A_TS $(date -d "@$PHASE_A_TS" '+%Y-%m-%d %H:%M:%S %z')" > .ai/last-triage-intake=. Do not use plain =touch= (writes mtime to /now/ and strands items posted between Phase A and end of run) and do not use =touch -d "@$PHASE_A_TS"= (correct timestamp but mtime is per-machine — won't survive a fresh clone or cross-machine sync).
-
-*Do not close the workflow yet.* See Exit Criteria below.
+The filing makes triage-intake's findings *persist* in =todo.org= instead of evaporating after the inline digest. Every close action dispatches to the owning source plugin's =Actions= verb (trash, mark-read, star, respond, merge, comment, attachment-fetch) — the engine doesn't hardcode action commands; it reads them from the loaded plugins.
*** Exit Criteria
-The workflow stays open until Craig has *explicitly* either:
-
-1. *Confirmed* that the executed actions are sufficient and nothing more is needed this round, or
-2. *Handed back a different plan* (e.g., "actually hold the PR merges, address #131 first").
-
-A successful Phase D run is *not* an exit signal. After the action batch returns, surface what shipped and wait. Don't volunteer "done" or "all set" — those are exit-claim phrases that pre-empt Craig's call. Use a status report ("17 actions succeeded, sentinel written at 12:19") and stop.
-
-If Craig has been silent for a while after Phase D and the surface looks closed-out, *ask*: "Anything else on this triage, or are we good to close out?" Don't auto-terminate.
-
-This rule prevents the failure mode where the workflow self-declares done and the next exchange has to relitigate what state things are in.
+The close is the exit. Once the close completes (tasks filed, hygiene run, sentinel advanced, teardown done), report one status line — what shipped, the sentinel time — and stop. The old stay-open-until-confirmed loop is retired (Craig, 2026-07-18): the digest plus the two-option offer is the whole interaction, and the close runs by default. The only way the workflow stays open is Craig explicitly saying not to close.
*** KB capture (only if the sweep surfaced something durable)
@@ -189,6 +209,22 @@ Auto mode runs as a =/loop= in the *live session*, not a detached cron job:
Running in the live session means MCP auth (Slack, Gmail, Linear) is inherited from the session — the headless-auth wall that blocks a detached cron run does not apply. A durable cross-session schedule is out of scope here; that belongs to the morning-ops orchestrator, which can later invoke auto mode's accumulate behavior as its triage limb. The close/stop commands below require a live session by design.
+*** Phone delivery — push each signal sweep via =agent-text=
+
+Auto mode exists for when Craig is away from the desk, so a sweep that surfaces something worth seeing is delivered to his phone, not just printed into a session he isn't watching. After a sweep that renders the full three sections — one with real deltas or an unacked-list change (see "End-of-sweep output" below) — send that same output to his phone over Signal with =agent-text=:
+
+#+begin_src bash
+agent-text "$SWEEP_SUMMARY"
+#+end_src
+
+The pushed text is the *fuller* three-section shape, not a terse one-liner: the per-source deltas, the responses-awaiting-acknowledgment list, and the timestamp, led by a ⚠ SCAN FAILED banner if any source failed.
+
+*Signal-only — never on a quiet sweep.* An empty sweep (the =triage intake at HH:MM: nothing= heartbeat) does *not* push to the phone. Silent-until-signal (see =docs/specs/2026-07-20-silent-until-signal-monitors-spec.org=) governs the phone channel too, so the phone stays silent until a sweep has real signal. The in-session heartbeat still prints as proof the loop ran; the phone is reserved for something that actually needs Craig. (Craig's ruling, 2026-07-20: the higher-cost channel doesn't buzz with "nothing.")
+
+If =agent-text= isn't on =PATH=, fall back to inline delivery and say so once.
+
+*Reply polling is deferred.* The send half ships here; polling the phone for Craig's replies (the =phone-recv= half of the retired ntfy design) waits on the reply-correlation follow-up. With the Signal account linked on more than one device, a reply fans out to every device and neither knows which page it answers — that has to be resolved before auto mode reads replies back. Until then auto mode pushes but does not poll, and Craig acts on a pushed summary from wherever he picks it up.
+
** Preconditions and Close-out
Auto mode borrows the inbox monitor-mode gates (=inbox.org= monitor mode): do not start on a dirty worktree or a red test suite — a close's batch commit would otherwise sweep up unrelated changes — and leave the tree clean and green when the loop stops. Surface a blocker with inline numbered options per =interaction.md= and wait.
@@ -204,11 +240,13 @@ Each sweep runs Phase 0 (load *both* plugin dirs — the loud requirement still
- DOES update an active daily-prep in Update mode and re-open it on change (per =daily-prep.org=).
- DOES report, deltas-only, with loud scan-failure banners (Phase C rules unchanged).
-** End-of-sweep output — three sections
+** End-of-sweep output — three sections, or one heartbeat
+
+*Silent-until-signal (see =docs/specs/2026-07-20-silent-until-signal-monitors-spec.org=).* An *empty sweep* — no deltas since the previous sweep and no change to the awaiting-acknowledgment list — collapses to a single heartbeat line and nothing else: =triage intake at HH:MM: nothing= (HH:MM local, from =date=). Detection still runs in full (Phase 0 plus the A-D scan, against the session's inherited MCP auth); only the output collapses, so a long unattended run stops filling the session with identical "no changes" blocks. A sweep with real deltas or an unacked-list change prints the full three sections below, and — when away — pushes them to Craig's phone via =agent-text= (see "Phone delivery" above). The empty-sweep heartbeat is never pushed.
-1. *Deltas* — what changed since the *previous sweep* (the standard Phase C summary scoped to the inter-sweep delta; one line if nothing: "HH:MM sweep: no changes").
+1. *Deltas* — what changed since the *previous sweep* (the standard Phase C summary scoped to the inter-sweep delta).
2. *Responses awaiting your acknowledgment* — every Slack reply, email, or message directed at Craig that he hasn't acknowledged or had the agent answer. A *running list carried forward across sweeps* until Craig acks each item or closes the triage. An away user's first need is "who's waiting to hear back from me," which a delta-only sweep loses the moment it scrolls past.
-3. *Timestamp* — the current date, time, and timezone on the sweep's own final line, so an away reader sees how fresh the summary is without computing it. Print it on *every* sweep, including a quiet "no changes" one — on a quiet sweep the stamp is the proof the loop ran. Generate it with:
+3. *Timestamp* — the current date, time, and timezone on the sweep's own final line, so an away reader sees how fresh the summary is without computing it. Print it on every sweep that prints these three sections. On an *empty* sweep there is no separate timestamp line — the heartbeat (=triage intake at HH:MM: nothing=) is itself the freshness stamp and the proof the loop ran. Generate it with:
#+begin_src bash
date "+%A %Y-%m-%d %H:%M:%S %Z (%z)"
@@ -242,7 +280,7 @@ She's waiting on a yes/no to the move.
The mutations are gated behind two commands:
-- *"close the triage"* — run the full close: take the accumulated mail actions, add the accumulated Action items to =todo.org= as =:quick:reactive:= tasks (asking Craig the questions a normal Phase C/D would), empty the unacked list, then *advance the sentinel* — capture the close run's Phase A timestamp, do the mutations, write that timestamp to =.ai/last-triage-intake= exactly as a normal run does (per "Capture the Phase A timestamp") — and commit + push the batch. Then *keep looping* (next sweep on the normal interval). This is the "flush the batch and carry on" checkpoint.
+- *"close the triage"* — run the full close per Phase D: take the accumulated mail hygiene, file the accumulated TASKS items to =todo.org=, reroute if asked ("close the triage and reroute"), empty the unacked list, then *advance the sentinel* — capture the close run's Phase A timestamp, do the mutations, write that timestamp to =.ai/last-triage-intake= exactly as a normal run does (per "Capture the Phase A timestamp") — and commit + push the batch. Then *keep looping* (next sweep on the normal interval). This is the "flush the batch and carry on" checkpoint.
- *"stop the triage"* — the same close processing, then *stop the loop* and revert to manual (on-demand) triage.
A close is the only point auto mode advances the sentinel or commits. Between closes the engine state is untouched — that is what makes a 20-minute sweep cheap and non-destructive, and it preserves the engine invariant: the sentinel still means "everything before this timestamp has been scanned," it just advances once per close instead of once per run.
@@ -269,7 +307,7 @@ A plugin file declares exactly one source through a fixed shape:
*Body sections:*
- =** Scan= — the command(s) that fetch new/unread items since =<anchor>=, emitting raw items.
- =** Classify= — the source's per-bucket bias and noise patterns. *Deltas* from the engine's shared four-bucket model below, not a re-derivation.
-- =** Render= — the source's block in the Phase C summary. "Omit if empty."
+- =** Render= — the source's classification shape. Since the 2026-07-18 digest format, Render blocks are *inputs to the Phase C synthesis*, not displayed sections — the digest is source-agnostic (TASKS / FYI / MISC). Keep the shape: it defines what the source considers reportable, and the long-form breakdown (on request) still uses it.
- =** Actions= — the executable state-changes, one verb per line: =verb :: command template (parameterized by item id)=.
Template:
@@ -347,33 +385,37 @@ If both fail, fall through to the resolution order above (prep doc → session f
** Output Template
-The summary follows this shape (deltas only: a source with no changes gets no block; when *nothing* changed anywhere, the whole summary collapses to the one-line form below — plus any scan-failure banners and the suggested-actions line if actions are queued):
+The digest follows this shape (deltas only: a source with no changes contributes nothing; when *nothing* changed anywhere, the whole digest collapses to the one-line form below plus the timestamp — scan-failure banners always render regardless):
#+begin_example
17:39 sweep: no changes
#+end_example
-When there are changes, render one block per changed source in =ORDER=, using each plugin's =Render= shape:
+When there are changes:
#+begin_example
-**Anchor:** <previous run timestamp> → now (<elapsed> elapsed)
-**Loaded:** <general plugins> + <project plugins> (skipped: <disabled, with reason>)
+<⚠ SCAN FAILED / backlog banners, if any>
-**Top signals to act on:**
-1. <terse Action description with link>
-2. ...
+==TASKS==
+- <solo-executable items first, then the rest; priority order within each;
+ one prose bullet each: who, what, why-now, source link>
-<one block per loaded source, in ORDER — see each plugin's Render>
+==FYI==
+- <substantive work context, no action owed; priority order>
-**Suggested actions:**
-- Trash N noise items
-- Mark-read M keep items
-- Respond to <invite>
-- Merge PRs #X and #Y
-- ...
+==MISC==
+- <everything outside the project — personal mail, family, household;
+ priority order; action-ness stated inline>
+
+1. Close the triage — file todo.org tasks for the TASKS items, run the standard close
+2. Close the triage and execute the solo TASKS now, after filing the rest
+Append "and reroute" to either option to send the outside-project items to their owning projects.
+Or tell me what you'd like handled differently.
+
+<run timestamp — always the final line>
#+end_example
-Order matters: top-signals first because that's what Craig reads in 30 seconds between meetings. Per-source detail second. Suggested actions last because they require a decision.
+Option 2 appears only when solo-executable TASKS exist; the reroute line only when MISC is non-empty. Sections with nothing in them are omitted. No per-source blocks, no itemized suggested-actions list — the close owns the routine hygiene. The long-form per-source breakdown is available on request only.
** Common Mistakes
@@ -381,11 +423,11 @@ Order matters: top-signals first because that's what Craig reads in 30 seconds b
1. *Globbing only =.ai/workflows/= and missing the project plugins.* The single most damaging failure mode — the sweep runs with half its sources and the omission is invisible (a missing source looks identical to a quiet one). Phase 0 globs *both* =.ai/workflows/triage-intake.*.org= and =.ai/project-workflows/triage-intake.*.org=, every run, and announces the loaded set.
2. *Running Phase A sequentially.* Send every enabled source's scan in one message — the whole point is parallelism.
3. *Wiring a source into the engine.* Sources live in plugin files, never here. If you find yourself editing this file to add an account, repo, or channel, stop — write or edit a =triage-intake.<source>.org= plugin instead.
-4. *Executing actions without explicit confirmation.* Phase D runs only after Craig says "yes" or picks a subset.
+4. *Leaving the triage open, or re-asking about the routine hygiene.* The close is the default next action after the digest (Craig's 2026-07-18 ruling — no exceptions unless he says hold). Mail hygiene (trash/mark-read/star) runs at close without itemized confirmation. What still needs explicit confirmation: anything sending prose under Craig's name, and destructive actions beyond mail hygiene (branch deletes, PR merges).
5. *Forgetting to set the sentinel at the end.* Without it, the next run re-scans the same window.
6. *Using mtime instead of content for the sentinel.* Plain =touch= writes /now/ to mtime, stranding items posted between Phase A and end of run. =touch -d "@$PHASE_A_TS"= fixes the time but mtime is per-machine — git tracks content, not metadata, so the anchor doesn't survive a clone or cross-machine sync. Always write the epoch into the file's *content*.
7. *Running this alongside daily-prep.* Daily-prep already does this as Phase 3 — don't duplicate.
-8. *Mixing Action and FYI in the top-signals list.* Top signals = Action only. FYI lives in the per-source detail.
+8. *Mixing sections.* TASKS = work items needing Craig only; work FYIs never appear there. Outside-project items always land in MISC, even when they carry an action. Solo items lead TASKS; priority order inside every section.
9. *Reporting a failed or skipped scan as a quiet source.* A hung receive, a dead daemon, or a skipped spin-up looks identical to "no new messages" in the output unless it's flagged. The 2026-06-10 sweep shipped with Signal silently missing because the scan hung on an account lock. Failures lead the summary, in their own banner line.
10. *Rendering a per-source quiet roll-call.* "Calendar — quiet" / "PRs — nothing new" lines on every silent source bury the one change that matters and pad a no-change sweep into a report. Deltas only: changed sources get blocks, unchanged sources get nothing, and an all-quiet sweep is one line (Craig's 2026-06-11 ruling in Phase C).
@@ -398,6 +440,12 @@ Update the engine as the orchestration pattern evolves; update a plugin as its s
*** Updates and Learnings
+**** 2026-07-20: Phone delivery for signal sweeps (=agent-text=, send half)
+Auto mode now pushes a full-three-section sweep to Craig's phone over Signal via =agent-text=, the away-from-desk delivery the retired ntfy design carried before ntfy was torn down (2026-07-04). Transport is =agent-text= (the renamed Signal pager), not ntfy. Signal-only by Craig's 2026-07-20 ruling: a quiet sweep's =nothing= heartbeat never reaches the phone — silent-until-signal governs the phone channel too, so the higher-cost channel only fires when a sweep has real signal, while the in-session heartbeat stays as proof the loop ran. Falls back to inline when =agent-text= is absent. Only the send half ships; reply polling (the old =phone-recv=) waits on the reply-correlation follow-up, because a Signal reply fans out to every linked device and neither knows which page it answers.
+
+**** 2026-07-18: Three-section digest + close-by-default (Phase C/D rewrite)
+Craig's ruling after a 42h-gap sweep where the long-form report (top signals + per-source breakdown + 7-option action menu) was followed by "summarize the notable items" — and the digest that answered it was the report he wanted first. Phase C now renders ==TASKS== (work items needing Craig, solo-executable first, priority order) / ==FYI== (work context, no action owed) / ==MISC== (everything outside the project, actions stated inline), then exactly two options (close-and-file / close-and-execute-solo, the latter only when solo items exist), timestamp last. Per-source blocks and the itemized action menu are gone from the default surface (long form on request). Phase D became the close: it runs as the next action no matter what Craig replies (unless he explicitly holds), includes the mail hygiene on every scanned account without itemized confirmation, files the TASKS, clears resolved unacked items, advances the sentinel, and tears down started services. Solo = mechanical + standing-approved + no prose under Craig's name; prose sends and destructive non-mail actions stay gated. The stay-open-until-confirmed exit loop is retired. Same-day addendum: the "and reroute" modifier ("1 and reroute") — MISC items are surfaced-only by default (never filed to this project's todo.org); appending the modifier delivers each outside-project item to its owner's inbox via inbox-send per the cross-project rule.
+
**** 2026-06-15: Auto mode (unattended monitoring)
Added a self-running mode for when Craig is away but wants tight awareness — a =/loop= in the live session running accumulate-don't-mutate sweeps with "close the triage" / "stop the triage" as the gated checkpoint. Born the morning Craig cleared his day for a family emergency and wanted the desk watched while in and out. Design decisions (work-project proposal, ratified by Craig 2026-06-15): the unacked-responses list is durable in =.ai/triage-intake-unacked.org= (survives a crash/clear, the away-from-desk case it exists for); the sentinel advances only at close, preserving the scanned-before invariant; delivery is an in-session loop so MCP auth is inherited (a detached cron schedule belongs to the morning-ops orchestrator, not here, because of the headless-auth wall); it stays a mode of this engine, distinct from but reusable by that orchestrator. Same-day addendum (work, 2026-06-15): each sweep ends with a date/time/timezone stamp on its own final line (printed on quiet sweeps too, as proof the loop ran) so an away reader gauges freshness at a glance.
@@ -414,7 +462,7 @@ The sentinel is checked into git, but git tracks content, not mtime — so an mt
Craig, via the work project's same-day handoff: "we only need to report if anything's changed when we do triage intake." Sweep summaries report deltas only — a new invite, a new/moved/cancelled event, a new message needing attention. Unchanged sources get no block (the "Calendar — quiet" roll-call is retired), and an all-quiet sweep renders as a single "HH:MM sweep: no changes" line. Failures keep their loud banner (never folded into the no-change line) and the suggested-actions line stays when actions are queued. Same ruling: the telegram plugin's dev-community group traffic is dropped from reports entirely unless Craig asks (see that plugin's 2026-06-11 note).
**** 2026-06-10: Loud failure surfacing (Phase C item 0 + Common Mistake 9)
-Craig: "highlight any failures in daily triage loudly. I get important communication from all these channels." Trigger: the 2026-06-10 sweep shipped with Signal silently missing — a standalone receive hung on the account lock while the signel daemon owned it, and the failure looked identical to a quiet source. Failures now lead the summary in a ⚠ SCAN FAILED banner; the telegram plugin's failure path points at this rule.
+Craig: "highlight any failures in daily triage loudly. I get important communication from all these channels." Trigger: the 2026-06-10 sweep shipped with Signal silently missing — a standalone receive hung on the signal-cli account lock while another client held it, and the failure looked identical to a quiet source. Failures now lead the summary in a ⚠ SCAN FAILED banner; the telegram plugin's failure path points at this rule.
**** 2026-05-26: Refactor into engine + source plugins
Split the monolithic workflow into a source-agnostic engine (this file) and per-source plugins named =triage-intake.<source>.org=. The engine carries the anchor/sentinel logic, the four-bucket model, the Phase A-D orchestration, the todo.org persistence convention, and the exit criteria. Each source's scan/classify/render/action knowledge moved to its own plugin. General plugins (personal-gmail, personal-calendar, cmail, github-prs) live in =.ai/workflows/= and are template-synced; project-specific plugins (a work project's Linear, work Gmail, work Slack, enterprise PRs) live in the project's =.ai/project-workflows/= and are never synced. Phase 0 globs *both* directories — the loud requirement, because missing the project dir silently halves the sweep. Naming convention: first dot is the engine/plugin boundary, deeper dots reserved for sub-adapters. This removed all DeepSat/Linear specifics from the engine; they become work-project plugins.
diff --git a/claude-templates/.ai/workflows/triage-intake.personal-calendar.org b/claude-templates/.ai/workflows/triage-intake.personal-calendar.org
index bf7d543..b5ee67a 100644
--- a/claude-templates/.ai/workflows/triage-intake.personal-calendar.org
+++ b/claude-templates/.ai/workflows/triage-intake.personal-calendar.org
@@ -1,5 +1,5 @@
#+TITLE: Triage Intake — Personal Calendar Source
-#+AUTHOR: Craig Jennings & Claude
+#+AUTHOR: Craig Jennings
#+DATE: 2026-05-26
# Source plugin for the triage-intake engine. See triage-intake.org for the
diff --git a/claude-templates/.ai/workflows/triage-intake.personal-gmail.org b/claude-templates/.ai/workflows/triage-intake.personal-gmail.org
index aa0554d..7fb1231 100644
--- a/claude-templates/.ai/workflows/triage-intake.personal-gmail.org
+++ b/claude-templates/.ai/workflows/triage-intake.personal-gmail.org
@@ -1,5 +1,5 @@
#+TITLE: Triage Intake — Personal Gmail Source
-#+AUTHOR: Craig Jennings & Claude
+#+AUTHOR: Craig Jennings
#+DATE: 2026-05-26
# Source plugin for the triage-intake engine. See triage-intake.org for the
@@ -21,10 +21,29 @@ Personal Gmail unread in the inbox since the anchor:
mcp__google-docs-personal__listMessages q="is:unread in:inbox after:<anchor-epoch>" maxResults=100
#+end_src
-⚠ *Express the cutoff as the literal UNIX epoch* — =after:1778856990=, not =after:YYYY/MM/DD=. Gmail's =after:YYYY/MM/DD= operator only supports day resolution; the =YYYY/MM/DD HH:MM:SS= form is NOT valid syntax — Gmail parses the space as a term separator, treats =HH:MM:SS= as a search term that never matches, and returns 0 results, silently masking unread mail. The engine supplies =<anchor-epoch>= because this source declares =ANCHOR: epoch=.
+⚠ *Express every anchor cutoff as the literal UNIX epoch* — =after:1784177122= and =before:1784177122= for the same anchor, never the =YYYY/MM/DD= form. This governs *both* anchored queries: the scan above and the backlog-residue probe below. They must meet at the same instant or mail falls between them permanently. Gmail's day-resolution operators fail two different ways: =after:YYYY/MM/DD HH:MM:SS= is not valid syntax at all — Gmail parses the space as a term separator, treats =HH:MM:SS= as a search term that never matches, and returns 0 results, silently masking unread mail — while =before:YYYY/MM/DD= is valid but excludes the named day entirely, so pairing it with a second-resolution scan leaves the whole anchor day covered by neither query. The engine supplies =<anchor-epoch>= because this source declares =ANCHOR: epoch=.
+
+The rule binds the *anchor* windows only. The date-slice walk below deliberately uses =before:<oldest-full-day-seen>= at day resolution — safe there because consecutive slices overlap and get deduped by message id.
⚠ *Do NOT add =-category:promotions -category:social=.* That filter masked 67 promo+social messages across two runs (2026-05-04, 2026-05-06), both needing a follow-up sweep. Pull the full unfiltered set; the trash-leaning bias in Classify handles promotions and social directly.
+⚠ *The MCP caps at =maxResults=100= and exposes NO =pageToken= parameter.* The response carries a =nextPageToken=, but the tool can't consume it, so a pile over 100 is silently truncated — the tail below the cap never gets classified, and every later anchored sweep skips it (it predates the new anchor). This is exactly how a 300+ backlog accumulated invisibly by 2026-07-08. Two consequences:
+
+- *Never treat a 100-row result as complete.* When a scan returns exactly 100, walk the tail in *date slices*: re-query with =before:<oldest-full-day-seen>= (day resolution), repeat until a page returns fewer than 100, dedupe by message id across slices (the day-resolution boundary overlaps).
+- *Never report =resultSizeEstimate= as a count.* It's unreliable — observed stuck at "201" across three different queries whose real union exceeded 300.
+
+*** Backlog-residue check (every sweep — cheap, mandatory)
+
+The anchored scan is blind to anything unread from *before* the anchor. After it, run one probe for pre-anchor residue:
+
+#+begin_src text
+mcp__google-docs-personal__listMessages q="is:unread in:inbox before:<anchor-epoch>" maxResults=5
+#+end_src
+
+The cutoff is the epoch, matching the scan's =after:<anchor-epoch>= — see the epoch rule above.
+
+If it returns any messages, surface one loud line in the sweep summary: "Backlog: unread predating the anchor exists (N+ shown; date-slice to inventory)" and offer a backlog sweep. Never fold the residue into a quiet sweep — an anchored "no changes" claim is only true for the window the scan saw. (Added 2026-07-08 after ~300 pre-anchor unread accumulated unseen; the probe returns actual messages, so it works where the estimate lies. Shipped with a day-resolution cutoff that hid the entire anchor day; fixed to epoch 2026-07-16 after a home sweep reported the backlog clear while two July-15 messages sat unread.)
+
** Classify
Bias: *trash-leaning* — personal Gmail is high noise volume.
diff --git a/claude-templates/.ai/workflows/triage-intake.telegram.org b/claude-templates/.ai/workflows/triage-intake.telegram.org
index 9caa4e1..5039a8b 100644
--- a/claude-templates/.ai/workflows/triage-intake.telegram.org
+++ b/claude-templates/.ai/workflows/triage-intake.telegram.org
@@ -1,5 +1,5 @@
#+TITLE: Triage Intake — Telegram Source
-#+AUTHOR: Craig Jennings & Claude
+#+AUTHOR: Craig Jennings
#+DATE: 2026-06-09
# Source plugin for the triage-intake engine. See triage-intake.org for the
diff --git a/claude-templates/.ai/workflows/work-the-backlog.org b/claude-templates/.ai/workflows/work-the-backlog.org
new file mode 100644
index 0000000..a0b24a8
--- /dev/null
+++ b/claude-templates/.ai/workflows/work-the-backlog.org
@@ -0,0 +1,265 @@
+#+TITLE: Work the Backlog
+#+AUTHOR: Craig Jennings
+#+DATE: 2026-07-02
+
+* Overview
+
+The single home for the autonomous task-execution loop: take a set of marked, solo-doable tasks from the project's =todo.org= and work them unattended, each held to the full quality bar, under a fixed safety contract. Spec: =rulesets/docs/specs/2026-06-16-autonomous-batch-execution-spec.org=.
+
+Two callers feed it, differing only in how they build the task set and which session mode they pass:
+
+- The *inbox auto-loop* (=inbox.org= auto mode) chains here after its routing completes, with a tag/priority query, file-only mode, cap 1.
+- The *no-approvals speedrun* preset feeds an explicit ordered list with autonomous-commit + always-push + paging-on, after a pre-flight Q&A that front-loads every decision.
+
+This workflow owns the execution logic — eligibility gate, defer checklist, quality bar, run cap. Callers own input assembly and mode selection. Capture-routing (inbox surfaces) stays entirely in =inbox.org=; this file never reads an inbox.
+
+* When to Use This Workflow
+
+Invoked by its two callers, or directly by phrase:
+
+- *Speedrun triggers:* "speedrun", "no approvals speedrun", "speedrun these: <task set>" — run the no-approvals speedrun preset (below). The word "speedrun" always routes here, even when the phrase also says "no approvals": plain =no-approvals.org= is the general session mode; the speedrun is this workflow's preset over an explicit task set.
+- *Loop caller:* =inbox.org= auto mode chains here after its routing (below). Not phrase-triggered.
+
+Manual fallback: "work the backlog" / "work the backlog with <task set>" — gather the three inputs below (ask for whichever are missing, defaulting to file-only mode; default cap is the list length for an explicit set, 1 for a query) and run the loop.
+
+* Inputs — the caller contract
+
+A caller hands this workflow three things:
+
+1. *A task set* — an ordered list of candidate task headings from the project's =todo.org=. Either an explicit ordered list (speedrun) or the result of a tag/priority query (the loop). The loop does not care how the set was assembled; it receives an ordered list of candidates.
+2. *A session mode* — two orthogonal flags:
+ - *Commit autonomy:* =file-only= (default) or =autonomous-commit=. See "Commit autonomy" below.
+ - *Paging:* on or off. End-of-set only.
+3. *A run cap* — the hard maximum number of tasks to complete this run.
+
+It returns a per-task outcome and a run summary.
+
+* Outcomes — the per-task vocabulary
+
+Every task in the set ends in exactly one of:
+
+- =implemented-committed= — implemented, committed (and pushed per the project's flow) under =autonomous-commit=.
+- =implemented-diff-surfaced= — implemented, diff surfaced, *not* committed (=file-only=).
+- =deferred-VERIFY= — a defer-checklist hit; a =VERIFY= filed naming what's missing or risky.
+- =dropped-by-craig= — removed from the run at the speedrun pre-flight Q&A ("skip this").
+- =skipped-ineligible= — failed the mechanical eligibility gate.
+- =failed= — implementation was attempted and abandoned: the tree is left working (never commit a broken state), the failure is surfaced in the run summary, and the run continues to the next task.
+
+The run summary lists each task with its outcome, plus the remaining set when the cap stopped the run.
+
+* The loop
+
+For the task set, in order, until the run cap is hit:
+
+1. *Eligibility gate* (below). Ineligible → record =skipped-ineligible=, next task.
+2. *Scope read* of the relevant code. Cheap; just enough to run the defer checklist.
+3. *Defer checklist* (below). Any hit → defer: file the =VERIFY= naming the gap and record =deferred-VERIFY= (or, under the speedrun preset, route a quick-question gap to the pre-flight Q&A), next task.
+4. *Implement* under the project's commit discipline: TDD red→green→refactor, then =/review-code --staged=, fix all Critical/Important findings, then close the task per =todo-format.md='s completion rules. Decompose into as many logical commits as the change needs — size is not capped. If implementation fails partway, leave the tree working, record =failed=, surface it, and continue to the next task.
+5. *Commit autonomy branch:*
+ - =file-only= → surface the diff, do *not* commit. Record =implemented-diff-surfaced=.
+ - =autonomous-commit= → =/voice personal= on the message, commit individually, push per the project's flow. Record =implemented-committed=.
+6. *Record metrics* for the task (the JSONL append — see Metrics below).
+7. Decrement the cap. At zero, stop.
+
+After the set: if the paging flag is set, fire the end-of-set page (below). Surface the run summary either way.
+
+* Eligibility gate — mechanical, no judgment
+
+A task is autonomous-safe when *both* hold. This layer is a lookup, not a judgment; all the judgment lives in the defer checklist.
+
+1. *Status is =TODO=* — never =VERIFY=, =DOING=, =DONE=, or =CANCELLED=. =VERIFY= marks "awaiting Craig's input"; auto-implementing one defeats the check it represents. The do-not-implement set is safe-by-omission: anything not plainly =TODO= (plus any project-declared "hold" marker) is out.
+2. *Tagged =:solo:=* — the autonomy tag, resolved against the project's priority/tag scheme header in =todo.org= (never hardcoded). =:solo:= carries the hard definition in =todo-format.md=: completable and verifiable without Craig beyond at most one or two quick decisions answerable up front, no design deliberation. A project whose scheme declares a different autonomous-safe tag set overrides the default.
+
+Terminology: *speedrunnable means tagged =:solo:=*. It does not mean =:quick:= or require =:quick:solo:=. The =TODO= status check above is the execution-state gate over that speedrunnable set.
+
+Priority and =:next:= drive *ordering* within the eligible set, not eligibility ([#A] before [#B] before [#C], then the author's ordering). =:quick:= is an effort hint for batching and duration estimates — never a gate.
+
+Task *size* is deliberately absent from this gate. A large but well-specified, decision-free task is in scope and gets decomposed into per-logical-commit chunks during implementation. Size never sends a task away; only *deliberation* or *risk* does (the checklist below).
+
+*No scheme header → don't run.* The gate reads =:solo:= semantics from the project's scheme header; a =todo.org= without one leaves the tag undefined (=todo-format.md= makes the header mandatory). Surface that the header is missing and stop rather than guessing eligibility.
+
+* The defer checklist — act vs file
+
+After the scope read, run each eligible candidate through the checklist. Each item is a concrete, answerable question, not an adjective. *Any* hit — or any "unsure" — defers the task. Only a task that clears every item is implemented.
+
+1. *Test-writability (the keystone).* Can I write the failing test from the task text — plus any decisions gathered up front — without inventing a requirement? *No / unsure* → underspecified. Under the speedrun preset, if the gap is one or two quick answerable questions, route it to the pre-flight Q&A; otherwise file a =VERIFY= noting what's missing. Under the unattended loop, file the =VERIFY= (no one to ask). *Open-ended goals are a specific, recognizable failure of this item:* a task phrased as an absence ("find bugs until none remain," "refactor until nothing worthwhile is left," "clean it up") has no writable acceptance test and so isn't really =:solo:=, even when tagged. Don't guess a stopping point — defer it and note that it needs measurable acceptance criteria (bound the surface, characterization net, dispositioned findings, objective floor — see =todo-format.md='s "Making an open-ended task measurable"). Once those are in the task body, it becomes runnable.
+2. *Data-loss / irreversible / external operation.* Does implementing it require any of: =rm= of non-scratch data, =git reset --hard= / force-push, =DROP= / =DELETE= / =TRUNCATE=, file truncate/overwrite of persisted content, a schema or data migration, any external or shared-state mutation, any credential touch? *Yes* → do NOT implement; file a =VERIFY= naming the risk. This is the hard safety gate; an upfront answer never overrides it without an explicit checkpoint.
+3. *Already-satisfied.* Does the scope read show the desired end-state already holds? *Yes* → file a =VERIFY= noting it and move on. Don't make a no-op change.
+4. *Design deliberation.* Does the task carry an unresolved design question, a "weigh these approaches" with real tradeoffs, or a TBD that isn't a quick factual answer? *Yes* → under the speedrun preset, if it collapses to one or two quick questions, route to the pre-flight Q&A; otherwise file and surface as a =/start-work= candidate. Under the loop, file. The discriminator is *quick-answerable question* vs *deliberation* — never task size.
+
+When genuinely unsure which side a task falls on, defer — a wrong auto-implement costs a revert *and* the next-session correction.
+
+** Filing the deferral =VERIFY=
+
+Every checklist hit files a =VERIFY= in the project's =todo.org=, per =todo-format.md='s VERIFY rules:
+
+- *Dedup first.* If a =VERIFY= sibling for this deferral already exists (a prior run filed it), don't file another — record the outcome as =deferred-VERIFY= with a "previously filed" note and move on. The deferred task keeps its =TODO= status and tags, so without this check every subsequent run would re-defer and re-file.
+- *Placement:* sibling of the deferred task (the deferred task is the trigger) — a =**= task gets its =VERIFY= at =**=, a =***= sub-task gets it at =***= under the same parent, never deeper.
+- *Heading:* carries the question or risk on its own ("VERIFY <topic> — migration touches persisted rows").
+- *Body:* which checklist item hit, what's missing or risky, and what answer or action would make the task runnable. For an already-satisfied hit, the evidence that the end-state already holds.
+
+** Routing a quick-question gap (speedrun only)
+
+Under the speedrun preset, a checklist-1 or checklist-4 hit that collapses to one or two quick answerable questions routes to the pre-flight Q&A instead of deferring (see the preset section below). The discriminator: a *quick question* is a factual or preference pick answerable in one line without weighing tradeoffs ("cap at 5 or 8?", "which config key name?"); *deliberation* is anything that needs tradeoffs weighed, options explored, or code read by Craig. A task needing three or more questions isn't quick-question-gapped — it's underspecified; file the =VERIFY=. Checklist item 2 (data-loss / irreversible) never routes to the Q&A: an upfront answer doesn't override the hard safety gate.
+
+The unattended loop has no one to ask — every hit defers there.
+
+* Per-task quality bar
+
+Autonomy changes who approves, not what quality means. Per task, non-negotiable:
+
+- *TDD* per =testing.md=: red first, green, refactor. The keystone checklist item already proved the failing test is writable.
+- *Verification* per =verification.md=: fresh evidence, full suite green before any commit.
+- *=/review-code --staged=* before every commit; Critical and Important findings block until fixed.
+- *=/voice personal=* on every commit message on the =autonomous-commit= path (or the patterns walked inline if the skill is unavailable), message printed inline so the log shows what landed.
+- *Task closure* per =todo-format.md=: depth-based completion (keyword + =CLOSED:= at level 2, dated rewrite at level 3+).
+- *One logical change per commit.* A large task becomes several commits, not one omnibus.
+
+* Commit autonomy
+
+=file-only= is the default: surface the diff, never commit. =autonomous-commit= is honored only when the project carries the commit-autonomy waiver, read fresh each run — never from memory of past runs or "this project usually allows it."
+
+The waiver lives in the project's =.ai/notes.org= *Workflow State* section as marker lines, the same shape as the workflow markers already there:
+
+#+begin_example
+:COMMIT_AUTONOMY: yes
+:LOOP_MAY_COMMIT: yes
+#+end_example
+
+- =:COMMIT_AUTONOMY: yes= — the project has the waiver. An =autonomous-commit= request (the speedrun preset, or a manual run asking for it) is honored.
+- =:LOOP_MAY_COMMIT: yes= — the *unattended loop caller* may also commit. It requires =:COMMIT_AUTONOMY:= alongside it; the split exists because "Craig-initiated speedrun may commit" and "the recurring loop may commit unattended" are different levels of trust. Without this flag the loop stays =file-only= even when the project holds the waiver.
+
+An absent marker means no. Anything other than a plain =yes= value also means no. The read is one grep of the Workflow State section — a lookup, not a judgment.
+
+*The degrade contract.* When a caller requests =autonomous-commit= and the required marker is missing, degrade to =file-only= and surface it in both the run intro and the run summary: "autonomous-commit requested, no :COMMIT_AUTONOMY: waiver in notes.org — running file-only." Never honor the request without the marker, and never drop to file-only silently — the first commits into a project that didn't opt in, the second hides why nothing got committed.
+
+* Bounding the run
+
+The cap is a hard per-run task ceiling passed by the caller — the kill switch a runaway can't exceed:
+
+- *Loop caller default: 1.* Implement the highest-priority eligible candidate, record, stop; the next tick continues.
+- *Speedrun: the length of the explicit list*, capped at a ceiling — the human bounded the set by naming it.
+
+Even the speedrun stops at the cap and surfaces (and, with paging on, pages) the remainder. The cap bounds task *count*, not cost; a token budget is logged as vNext.
+
+* Context hygiene — auto-flush between tasks
+
+Task boundaries are clean boundaries by construction: the previous task is closed and committed (or filed), nothing is half-edited. When the context window grows heavy mid-run, run the flush skill's *auto mode* between tasks: checkpoint the session anchor with the remaining task set, session mode, and cap in Next Steps (so the resumed context continues the run blind), arm the self-injection (=.ai/scripts/self-inject.sh= via =tmux run-shell -b=), and end the turn. The fresh context resumes from the anchor and works on. Unattended runs only — the keystroke-collision hazard and the full mechanism live in the flush skill.
+
+* End-of-set page
+
+With paging on, fire one page when the set is done or the cap is hit — end-of-set only, never per-task:
+
+#+begin_src sh
+notify info "Page" "<project>: <N> done, <M> remaining — <one-line summary>" --persist
+#+end_src
+
+=--persist= keeps it on screen until dismissed, and =info= is the notification urgency convention (persistent but never crash-scary). The notification fires when the set completes *or* the cap stops the run, either way exactly once. The message carries the project name, the completed count, and the remaining count (with skipped tasks noted in the run summary) so Craig can confirm ready and name the next project in one reply. =notify= is the desktop channel (the "page me" surface); a run that expects Craig to be away also fires =agent-text= with the same message (the Signal phone channel, "text me"). See protocols.org "Reaching Craig".
+
+* Metrics
+
+Each task outcome appends one JSON line to the project's =.ai/metrics/work-the-backlog.jsonl= — git-tracked, append-only, =jq=-queryable. Create the directory and file on the first append. Logging is a side effect only: a failed append surfaces a warning in the run summary but never blocks, reorders, or aborts execution.
+
+One record per task, written at the moment its outcome is decided:
+
+| Field | Meaning |
+|--------------------+-------------------------------------------------------------------------------------------------|
+| =ts= | ISO-8601 timestamp of the task outcome |
+|--------------------+-------------------------------------------------------------------------------------------------|
+| =run_id= | UUID shared by every record in one run (=uuidgen= at run start) |
+|--------------------+-------------------------------------------------------------------------------------------------|
+| =project= | project basename |
+|--------------------+-------------------------------------------------------------------------------------------------|
+| =caller= | =loop= / =speedrun= / =manual= |
+|--------------------+-------------------------------------------------------------------------------------------------|
+| =task= | the task heading (slug) |
+|--------------------+-------------------------------------------------------------------------------------------------|
+| =outcome= | =implemented-committed= / =implemented-diff= / =deferred-verify= / =skipped-ineligible= / |
+| | =dropped-by-craig= / =failed= |
+|--------------------+-------------------------------------------------------------------------------------------------|
+| =defer_reason= | =underspecified= / =data-loss= / =already-satisfied= / =needs-deliberation= — set on |
+| | =deferred-verify= records only |
+|--------------------+-------------------------------------------------------------------------------------------------|
+| =upfront_decision= | =true= when a pre-flight answer was recorded and used for this task |
+|--------------------+-------------------------------------------------------------------------------------------------|
+| =wall_clock_s= | seconds from task start to outcome |
+|--------------------+-------------------------------------------------------------------------------------------------|
+| =commit_sha= | committed tasks: the commit SHA (comma-separated when the task decomposed into several); empty |
+| | otherwise |
+|--------------------+-------------------------------------------------------------------------------------------------|
+| =review_findings= | count of =/review-code= Critical + Important findings on this task |
+|--------------------+-------------------------------------------------------------------------------------------------|
+
+The =outcome= slugs map one-to-one onto the outcome vocabulary above (=implemented-diff= is =implemented-diff-surfaced=; =deferred-verify= is =deferred-VERIFY=). Per-run rollups (attempted / completed / deferred / dropped, wall-clock total, findings per commit) are computed at synthesis, not stored per record. The =commit_sha= field is what the synthesis step's corrections signal keys on — whether a later commit reverted or hand-fixed an autonomous one — so never omit it on a committed task.
+
+* Caller: the inbox auto-loop
+
+=inbox.org= auto mode chains here as an explicit second step *after* its routing completes — never as a phase inside inbox processing. When a cycle files new items and Craig answers "run this batch next?" with yes, auto mode invokes this workflow with:
+
+- *Task set:* the eligibility query over the queued/filed items — status =TODO= + =:solo:= per the scheme header, priority-ordered.
+- *Session mode:* =file-only=, paging off. (A project carrying both =:COMMIT_AUTONOMY:= and =:LOOP_MAY_COMMIT:= markers opts the loop into commits — see Commit autonomy above.)
+- *Cap: 1.* The highest-priority eligible candidate runs, gets recorded, and the loop's next tick (or the next yes) continues from there.
+
+The loop has no human at kickoff of each task, so a needs-quick-decisions task defers with a =VERIFY= — the pre-flight Q&A is a speedrun capability, not a loop one. Startup and wrap-up never invoke this workflow.
+
+* Preset: the no-approvals speedrun
+
+The named preset is a label for one flag combination, not a second code path: *explicit ordered list + =autonomous-commit= + always-push + paging-on*, with every approval front-loaded into a single pre-flight step. "No approvals" means all input first, then hands-off — not no input ever. =autonomous-commit= still requires the =:COMMIT_AUTONOMY:= waiver (Commit autonomy above); without it the preset degrades to =file-only= and says so in the pre-flight intro.
+
+When Craig names a task set and says "speedrun":
+
+1. *Gather* the named task set.
+2. *Scope-read and classify* each task against the eligibility gate + defer checklist: *ready* (clears everything), *needs-quick-decisions* (one or two upfront-answerable questions — checklist item 1 or 4), or *drop* (data-loss/irreversible, or deliberation that isn't a quick question).
+3. *Order* the list — priority, then the author's ordering / =:next:=.
+4. *Intro the work* — present the ordered plan: what will run, what was dropped and why, and the batched questions for the needs-quick-decisions tasks.
+5. *Craig answers each question or says "skip this"* — a skip removes the task (recorded =dropped-by-craig=; the task itself stays =TODO=); an answer is recorded so implementation works from the decision, not a guess.
+6. *Run the finalized list autonomously* — no further approvals until done. Cap = the list length (the human bounded the set by naming it), still one commit per logical change, always-push per the project's flow, auto-flushing between tasks when the context grows heavy (see Context hygiene above).
+7. *End-of-set page* with completed + remaining + skipped.
+
+The batch-ask (step 4-5) is one message: each question names its task, puts the recommended answer at item 1 when there is one (per =interaction.md= — inline numbered, no popup), and offers "skip this" as the last option. Before the run starts, write each answer into its task's body in =todo.org= as a dated line — the implementation works from the recorded decision, and the record survives the session. The Q&A fires only under this preset; the loop caller never asks (its decision-needing tasks defer).
+
+*** Per-item disposition rule
+
+For every item the run picks up (this holds for any executing caller, including an auto-inbox-zero run given a standing yes):
+
+- *Feature-level task* → write a spec first (=spec-create=), don't implement directly. The spec is the run's deliverable for that item.
+- *Needs decisions you can't confidently guess* → file it as a =VERIFY= carrying the question (under this preset, one or two quick questions route to the pre-flight Q&A instead).
+- *Well-defined* → implement it, taking the time it needs.
+
+This extends the defer checklist: the checklist decides *act vs file*; this rule decides the *shape* of the act.
+
+* Synthesis: metrics → org-roam KB
+
+Trigger: "synthesize backlog metrics" (optionally a weekly scheduled run). This is the read side of the metrics log — Craig's ask was "gather data and create org-roam articles we can look at later," and this step is the second half. It is read-only over the logs plus exactly one KB write.
+
+1. *Gather the JSONL union.* Discover =.ai/metrics/work-the-backlog.jsonl= across the project roots (dirs carrying =.ai/protocols.org= under =~/code=, =~/projects=, =~/.emacs.d=). Classify each project per =knowledge-base.md= (work-root denylist, never inference) before reading it into the union.
+2. *Enforce personal-only.* A work-classified or unknown project's metrics never enter the KB write — they stay in that project's own log. Report the exclusion per the KB refusal contract: the classification, a one-line redacted summary, and where the data stayed.
+3. *Compute the rollups and trends.* Per run: attempted / completed / deferred (by reason) / dropped / failed, wall-clock total, commits landed, review findings per commit. Trends across runs: completion rate over time, defer-reason distribution, findings-per-commit trend.
+4. *Compute the corrections signal* — the key metric. For each =commit_sha= in the window, check that project's history for a later commit (within ~14 days) that reverts it or carries a fix touching the same files. A clean run is one whose autonomous commits survive untouched; a flagged run is what Craig reviews by hand. This is a cheap proxy, not proof — it flags candidates, it doesn't convict.
+5. *Write one KB node* at =~/org/roam/agents/YYYYMMDDHHMMSS-backlog-metrics-<window>.org= per =knowledge-base.md=: =:agent:metrics:= filetags, a concise title, the rollup table, the trend narrative, and =[[id:...]]= links to prior synthesis nodes so the series is traceable. Pull before writing, commit and push after — the normal KB session discipline.
+
+The KB node is the artifact Craig reads later: "are the runs completing more and getting corrected less?" should read off the trend table without touching raw logs. Synthesis never mutates the JSONL, todo.org, or any project tree.
+
+* Common Mistakes
+
+1. *Implementing a =VERIFY= or =DOING= task.* The gate is status =TODO= only — a =VERIFY= exists precisely because Craig's input is pending.
+2. *Treating =:quick:= as eligibility.* It's an effort hint. =:solo:= is the gate.
+3. *Deferring on size.* A large, well-specified, decision-free task runs — decomposed into logical commits. Size is not a checklist item.
+4. *Guessing past the keystone.* If the failing test isn't writable from the task text, the task isn't ready. Inventing the requirement is the failure the checklist exists to stop.
+5. *Rationalizing through the data-loss list.* "The migration is small" doesn't clear checklist item 2. Enumerated operations defer, full stop.
+6. *Committing in =file-only= mode.* The diff is the deliverable; the commit is Craig's.
+7. *One omnibus commit for the whole run.* Every logical change is its own reviewed commit.
+8. *Skipping =/review-code= or =/voice= because nobody's watching.* Autonomy removes interaction gates, never engineering-discipline gates (same contract as =no-approvals.org=).
+9. *Running past the cap.* The cap is the kill switch; hitting it means stop and surface, even mid-set.
+10. *Paging per-task.* One page, end of set.
+11. *Honoring =autonomous-commit= from memory.* The waiver is the marker line in =notes.org=, read fresh each run. "This project usually allows it" isn't a read.
+12. *Re-filing the same deferral =VERIFY= every run.* The deferred task stays =TODO=, so a run that skips the existing-sibling check spams =todo.org= with duplicates.
+13. *Routing a data-loss hit to the pre-flight Q&A.* Checklist item 2 is the hard gate — an upfront answer never clears it without an explicit checkpoint.
+
+* Living Document
+
+Refine as the dogfooding signal arrives — the metrics log and the corrections-in-next-session signal are the feedback loop. Fold recurring adjustments in rather than accumulating caller-side workarounds.
+
+* History
+
+Created 2026-07-02 as Phase 1 of the autonomous-batch execution spec, reconciling the inbox-zero "Phase E" proposal and the =.emacs.d= speedrun proposal into one execution loop. The auto-inbox-zero execute step in =inbox.org= reverted to routing-only in the same change so this file is the loop's only home. Phases 2-6 (same day) wired both callers, pinned the commit-autonomy waiver markers, fleshed the defer/Q&A/page mechanics, and added the metrics record + KB synthesis step.
diff --git a/claude-templates/.ai/workflows/wrap-it-up.org b/claude-templates/.ai/workflows/wrap-it-up.org
index 5d2cdd2..d212195 100644
--- a/claude-templates/.ai/workflows/wrap-it-up.org
+++ b/claude-templates/.ai/workflows/wrap-it-up.org
@@ -1,5 +1,5 @@
#+TITLE: Session Wrap-Up Workflow
-#+AUTHOR: Craig Jennings & Claude
+#+AUTHOR: Craig Jennings
#+DATE: 2026-04-20
* Overview
@@ -24,7 +24,7 @@ The wrap-up is complete when:
2. *File is archived.* =.ai/session-context.org= has been renamed to =.ai/sessions/YYYY-MM-DD-HH-MM-description.org=. The old path no longer exists.
3. *todo.org is clean.* Cleanup script ran. Any auto-fixes are staged for the wrap-up commit. Orphan planning lines surfaced for manual fix if there are any.
4. *Linear board is honest* (skip if project doesn't use Linear). Any Dev-Review ticket whose PR has merged was moved to Done or PM Acceptance per the classification rule.
-5. *Git state is clean.* All changes committed + pushed to all remotes. Working tree clean.
+5. *Git state is certified clean.* All changes are committed + pushed to all remotes, =git-worktree-gate certify= succeeded at the current HEAD, and the working tree has no staged, unstaged, untracked, submodule, or in-progress-operation state.
6. *Valediction delivered.* Brief, warm closing with key accomplishments and reminders, ending with =session wrapped.= on its own line as the signoff marker.
The absence of =.ai/session-context.org= is the signal that the last session wrapped up cleanly. Its presence at session start means the previous session was interrupted.
@@ -43,8 +43,30 @@ This depends on three functions in =.emacs.d/modules/ai-term.el= (=cj/ai-term-qu
* The Workflow
+** Step 0: Refuse if sentry is live
+
+Before anything else, check whether sentry is running in this project. Sentry holds the working tree on its =sentry/<date>-<host>= branch and commits unattended; wrapping underneath it would archive the session anchor and tear down the buffer while the loop is still firing into it. If sentry's single-runner lock is held, stop and point at the shutdown path:
+
+#+begin_src bash
+proj="$(basename "$(git rev-parse --show-toplevel 2>/dev/null || pwd)")"
+if [ -x .ai/scripts/agent-lock ] && .ai/scripts/agent-lock status "sentry-$proj" | grep -q '^held'; then
+ echo "sentry is active — say 'stop sentry' first"
+ exit 1
+fi
+#+end_src
+
+The stop-sentry operation (defined in =sentry.org=) owns the shutdown: it cancels the loop, disposes of the branch, and walks the approval queue. Wrap-up carries only this one guard; a =stale= lock (a crashed fire) doesn't block — only a live =held= lock does.
+
** Step 1: Finalize the Summary
+*** Work the Before-Close Queue (before the Summary)
+
+If the session anchor (=.ai/session-context.org=) carries a =* Before-Close Queue= heading with items, work them now, oldest-first, before writing the Summary, so any resulting edits ride this wrap's commit and get described in it. The queue is the "put X on the list" shorthand (see =protocols.org=, Colloquialisms and Expansions): session-scoped work Craig deferred to wrap time.
+
+Per item: do it if it's clear and bounded, or promote it to a =todo.org= task if it turns out to need its own session. Never drop an item silently. Remove each line as it's handled; if one can't be finished, surface it in the valediction (Step 5) and either leave a follow-up task or state why it's dropped.
+
+If there's no =* Before-Close Queue= heading, or it's empty, this step is a silent no-op.
+
*** Early KB reflection (capture while fresh, before the Summary)
Before distilling the Summary, while the session is still fresh, ask: what did this session learn worth remembering, for yourself or a future agent? Reflect and stage any candidate durable facts — a decision and its why, an environment gotcha, a reference pointer, a transferable lesson. Self-answer silently; this adds no interactive turn (Craig already authorized the wrap). The candidates flow straight into the KB promotion check below, which does the actual writing and the receipt — this is the capture half, that is the commit half, one pipeline, one receipt. Reflecting here rather than reconstructing learnings after the Summary is the point: the early ask is what keeps the receipt from defaulting to "promoted 0" out of fatigue.
@@ -137,6 +159,22 @@ Run the report-only variant first if you want to see what would change without w
emacs --batch -q -l .ai/scripts/todo-cleanup.el --check todo.org
#+end_src
+*** Convert done sub-tasks to dated entries
+
+#+begin_src bash
+[ -f todo.org ] && emacs --batch -q -l .ai/scripts/todo-cleanup.el --convert-subtasks todo.org
+#+end_src
+
+=--convert-subtasks= rewrites every heading at level 3 or deeper 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. This enforces the =todo-format.md= depth rule that a completed *sub-task* (a heading under a parent task) becomes dated history, not a lingering DONE keyword — a shape an interactive org close (=org-log-done= → DONE + CLOSED) never applies and =--archive-done= (level-2 only) never reaches. The timestamp comes from each entry's own =CLOSED= cookie; a date-only close yields =00:00:00=. Heading text is kept verbatim. Idempotent (an already-dated heading has no keyword to match), and a done sub-task with no parseable =CLOSED= is flagged and left alone rather than stamped with a fabricated date.
+
+Run this *before* =--archive-done= so that when a completed level-2 parent is archived, its sub-tasks already carry their dated form. Any rewrites show up in the wrap-up commit's diff for review before push.
+
+Preview without writing:
+
+#+begin_src bash
+emacs --batch -q -l .ai/scripts/todo-cleanup.el --convert-subtasks --check todo.org
+#+end_src
+
*** Archive completed work
#+begin_src bash
@@ -151,6 +189,16 @@ Preview the moves without writing:
emacs --batch -q -l .ai/scripts/todo-cleanup.el --archive-done --check todo.org
#+end_src
+*** Clear temp/
+
+#+begin_src bash
+[ -d temp ] && find temp -mindepth 1 -delete && echo "temp/ cleared"
+#+end_src
+
+=temp/= holds throwaway artifacts — discarded prototypes, scratch output, intermediate data (see =working-files.md=). It's gitignored in every project, so nothing here rides a commit and nothing is recoverable from git once deleted. Clearing it at wrap is what keeps ephemeral work from silting up across sessions, and it's the counterpart to =working/=, which is tracked and *never* cleared here.
+
+Two guards. Confirm before deleting if =temp/= holds anything a reasonable reader would call in-progress rather than throwaway — misfiled work belongs in =working/=, so move it there instead of deleting it. And skip the step entirely in a project where =temp/= is not gitignored, since that means the project is using the directory for something else.
+
*** Sync child priorities
#+begin_src bash
@@ -186,9 +234,14 @@ else
followups=".ai/lint-followups.org"
fi
[ -f todo.org ] && emacs --batch -q -l .ai/scripts/lint-org.el \
- --followups-file="$followups" todo.org
+ --fix --followups-file="$followups" todo.org
#+end_src
+The =--fix= flag is required for the writes: lint-org's CLI default is
+report-only (a linter reports, it doesn't write), and this wrap-up pass is
+the deliberate exception that applies fixes — its diff rides the wrap-up
+commit for review.
+
=lint-org= runs =org-lint= over =todo.org=, auto-applies four mechanical
categories (=item-number= counters, bare =#+begin_src= → =#+begin_example=,
multi-line planning-info merged onto one line, =**X.**= → =*X.*=), and
@@ -220,7 +273,7 @@ For an interactive walk of the judgments mid-day, run =/lint-org todo.org=.
*** Inbox sanity check (surface unprocessed handoffs)
-If the project has an =inbox/= directory, verify it holds nothing but =.gitkeep=, =lint-followups.org= (the lint-org pipeline file the next morning's daily-prep consumes), and any explicitly-deferred =PROCESSED-*= files before the wrap completes. An inbox that arrived at session start with handoffs from other projects, or that received handoffs mid-session, needs =inbox.org= process mode to run and apply its value-gate dispositions. Wrapping with a dirty inbox silently defers the work to next session and accumulates handoff debt that the sender can't see.
+If the project has an =inbox/= directory, verify it holds nothing but =.gitkeep=, =lint-followups.org= (the lint-org pipeline file the next morning's daily-prep consumes), and =PROCESSED-*= files before the wrap completes. An inbox that arrived at session start with handoffs from other projects, or that received handoffs mid-session, needs =inbox.org= process mode to run and apply its value-gate dispositions. Wrapping with an unprocessed inbox silently defers the work to next session and accumulates handoff debt that the sender can't see.
#+begin_src bash
unprocessed=$(find inbox -maxdepth 1 -type f \
@@ -229,7 +282,7 @@ unprocessed=$(find inbox -maxdepth 1 -type f \
! -name 'PROCESSED-*' \
2>/dev/null | wc -l)
if [ "$unprocessed" -gt 0 ]; then
- echo "wrap-up: inbox/ has $unprocessed unprocessed item(s). Run inbox.org process mode before wrapping, or explicitly defer each item with a one-line reason in the valediction."
+ echo "wrap-up blocked: inbox/ has $unprocessed unprocessed item(s). Run inbox.org process mode before wrapping."
find inbox -maxdepth 1 -type f \
! -name '.gitkeep' \
! -name 'lint-followups.org' \
@@ -238,12 +291,38 @@ if [ "$unprocessed" -gt 0 ]; then
fi
#+end_src
-If the count is zero or the project has no =inbox/= directory, the check is a silent no-op. If non-zero, the wrap is incomplete by default. The user resolves each item (process now, defer with reason in the valediction, or delete with rationale) before the validation checklist passes.
+If the count is zero or the project has no =inbox/= directory, the check is a silent no-op. If non-zero, the wrap is blocked. Process each item through its value-gate disposition, or delete it only when that workflow's rationale authorizes deletion, before continuing.
The check exempts =lint-followups.org= explicitly because lint-org runs earlier in the same wrap-up workflow and writes its judgment items to that file in =inbox/= by design. The file is a pipeline artifact for the next morning's =daily-prep=, not a handoff that needs the value gate.
This integrates with =inbox.org= process mode, which stamps =:LAST_INBOX_PROCESS:= in =notes.org='s *Workflow State* section on completion. Wrap-up doesn't double-stamp. It only ensures the inbox carries nothing but the expected pipeline artifacts at session end.
+*** Cross-project router (optional — route filed keepers to their home projects)
+
+Runs directly after the inbox sanity check. The split between the two: the sanity check *gates* the wrap (a dirty inbox blocks until resolved); the router is *optional* (skipping it never blocks anything — the candidates just stay local until a future wrap). Spec: =docs/specs/wrapup-routing-spec.org= (D7/D8/D9).
+
+The candidate set is exactly the local tasks carrying a =:ROUTE_CANDIDATE:= property — keepers that inbox process mode filed this session whose inferred home is another project. Never scan the standing backlog.
+
+#+begin_src bash
+.ai/scripts/route-batch --list
+#+end_src
+
+*Empty set = zero interaction.* =--list= prints nothing when there are no candidates; continue the wrap silently — no prompt, no "0 items" line.
+
+When candidates exist, surface the batch as one line per task — the task heading, the destination project, the delivery mode (=inbox-send= file handoff), and the engine's confidence — then offer exactly two options: *go* (route the whole batch) or *skip* (leave everything local). Derive each confidence label by running the engine on the task's heading + body (=python3 .ai/scripts/route_recommend.py --item "..." --exclude "$(basename "$PWD")"=); label weak matches visibly ("weak — verify the destination") so a low-confidence route gets a human glance before the keystroke.
+
+On *go*:
+
+#+begin_src bash
+.ai/scripts/route-batch --go
+#+end_src
+
+Per candidate, the helper writes the task's subtree (children ride along; =:ROUTE_CANDIDATE:= stripped, headings promoted to top level) to a one-task handoff, delivers it via =inbox-send <destination> --file= (so the =from-<this-project>= provenance is stamped and the destination's inbox process mode dispositions it as a single item), and only after a successful send removes the subtree from the local =todo.org= — a single-file local edit the wrap is already committing. A failed send leaves that task in place and exits non-zero; report it and continue the wrap. Never write the destination's =todo.org= directly; its own inbox processing files the task per its conventions.
+
+On *skip*, leave every candidate in place, marker included — they resurface next wrap.
+
+Mis-routes are recoverable: the receiving project rejects via inbox process mode's reject-from-another-project flow, which returns the item to this project's inbox with the rationale. That reject path is why removing the local source on send is safe.
+
*** Review-habit health check (surface a slipped daily task-review)
The daily task-review habit walks the open top-level tasks on a rotating cycle, stamping =:LAST_REVIEWED:= as it goes (see =task-review.org=). This check is the watchdog for that habit. When tasks have gone too long unreviewed, the habit has slipped, and the wrap-up says so in one line — it does not re-list the tasks.
@@ -422,17 +501,17 @@ Behavior:
git status --short
#+end_src
-*Default policy: end every session with an empty =git status=.* The wrap is incomplete while anything remains dirty. There is no "leave it alone" default — every leftover gets an active resolution. The only way for a file to stay dirty across the wrap is the user explicitly saying "defer this one, leave it dirty." Surface each leftover with a concrete recommendation; the user has to actively opt out for the dirt to persist.
+*Hard policy: end every session with an empty =git status=.* The wrap is incomplete while anything remains dirty. There is no deferral exception and no "wrapped with known changes" state: unresolved dirt means the session remains open and wrap-up does not occur.
This inverts the older "intentional carryover" default, which let pre-existing dirty state accumulate across sessions silently. Carryover that lives for days or weeks is almost always one of: a forgotten commit from a prior wrap, a stale change that should be discarded, or genuine in-flight work that needs an explicit stash/branch home. None of those should default to "leave it dirty."
**** Three kinds of leftover
-| Pattern | What it is | Recommended action (apply unless user defers) |
+| Pattern | What it is | Recommended action |
|---+---+---|
| Generated, runtime, or lock files that no human edits — e.g., =.claude/scheduled_tasks.lock=, =.pytest_cache/=, build outputs, IDE state, editor swap files | *Runtime artifact* — created by tooling or the harness, not by the user, and shouldn't be tracked | Add the matching pattern to =.gitignore= (project-level, not =~/.gitignore_global=). For tracked files, =git rm --cached <path>=. Stage =.gitignore= and any =rm --cached= changes in *one* follow-up commit (=chore: gitignore X=), push. Re-run =git status= to confirm clean. |
| Modified or created during the session but not staged into the wrap-up commit | *Forgotten change* — real session work that should have been in the wrap commit but missed it | Stage and create a follow-up commit. Don't =--amend= the wrap-up commit once pushed (diverging history without a clear win). Push the follow-up to all remotes. |
-| Was dirty at session start and still dirty at session end — work this session deliberately didn't touch | *Pre-existing dirt that needs a decision* — could be a missed commit from a prior wrap, stale abandoned work, or real in-flight work without a home | Investigate (show diff + check the originating session). Recommend one of: (a) commit now if the work is complete, (b) stash with a descriptive message if it's genuine WIP, (c) =git checkout -- <path>= / =git clean -f <path>= if stale and unwanted, (d) move to a feature branch if it's longer-running, (e) user explicitly defers and accepts the dirt. Do not silently leave dirty. |
+| Was dirty at session start and still dirty at session end — work this session deliberately didn't touch | *Pre-existing dirt that needs a decision* — could be a missed commit from a prior wrap, stale abandoned work, or real in-flight work without a home | Investigate (show diff + check the originating session). Recommend one of: (a) commit now if the work is complete, (b) stash with a descriptive message if it's genuine WIP, (c) =git checkout -- <path>= / =git clean -f <path>= if stale and unwanted, or (d) move to a feature branch if it's longer-running. Do not silently leave dirty. |
**** Per-file flow
@@ -440,18 +519,40 @@ For each leftover line in =git status --short=:
1. Identify which of the three kinds above it matches.
2. State what the file is (one line) and the recommended action.
-3. Apply the action unless the user explicitly defers.
-4. Re-run =git status --short= after each follow-up commit until empty (or until every remaining line is an explicit user-deferred entry).
+3. Apply the action when it is safe and authorized.
+4. Re-run =git status --short= after each follow-up commit until empty.
The pre-existing-dirt case (third row) is the one this rule most cares about. Treat each pre-existing-dirty file as a question that must get an answer this session, not as "carryover that's fine to inherit." A file that was dirty for a week before this session probably isn't going to get cleaner by waiting another week. Look at the diff, check the originating session's notes, and recommend a real resolution.
-**** When the user defers
+**** When cleanup cannot be completed
+
+Stop the wrap. Do not deliver the valediction, print =session wrapped.=, drop a teardown/shutdown sentinel, or describe the session as complete. Report:
+
+1. Every remaining path and its exact Git state.
+2. What the file is and why the agent cannot safely resolve it alone.
+3. The concrete action or decision Craig needs to provide to make the tree clean.
+
+An explicit decision to keep a file dirty changes the outcome from "wrapping" to "leaving the session interrupted." It never satisfies this workflow.
+
+*** Final clean-tree certificate — hard gate
-If the user does say "leave this one dirty for now" after seeing the recommendation, that is fine — log the deferral in the valediction so the next session knows it was an explicit choice, not a miss. Format: "Deferred (per Craig's decision today): =path/to/file= — <one-line reason>". Without that note, the next session can't distinguish "we agreed to defer" from "we forgot again."
+After all commits are pushed and every leftover appears resolved, run the shared gate:
+
+#+begin_src bash
+gate="$(command -v git-worktree-gate 2>/dev/null || true)"
+[ -n "$gate" ] || gate="$HOME/code/rulesets/claude-templates/bin/git-worktree-gate"
+if [ ! -x "$gate" ]; then
+ echo "wrap blocked: git-worktree-gate is unavailable; install rulesets tooling and retry"
+ exit 1
+fi
+"$gate" certify "$PWD"
+#+end_src
+
+The certificate lives inside the Git directory, so it does not dirty the worktree. It records the exact verified HEAD. A non-zero result is a hard stop governed by "When cleanup cannot be completed" above. Step 5 is unreachable until certification succeeds.
** Step 5: Valediction
-Brief, warm closing. 3-4 sentences max.
+Only after the final clean-tree certificate succeeds, deliver a brief, warm closing. 3-4 sentences max.
Include:
- What was accomplished (specific, not generic)
@@ -488,7 +589,7 @@ Do nothing. The buffer, the =aiv-<project>= tmux session, and =claude= all stay
*** Teardown mode (default)
-Confirm commit + push succeeded (Exit Criteria 5 — never tear down over unpushed work), then drop the sentinel:
+Confirm commit + push and the final clean-tree certificate succeeded (Exit Criteria 5 — never tear down over unpushed or dirty work), then drop the sentinel:
#+begin_src bash
touch "/tmp/ai-wrap-teardown-$(basename "$PWD")"
@@ -496,6 +597,8 @@ touch "/tmp/ai-wrap-teardown-$(basename "$PWD")"
That is the whole step. Don't run any =tmux kill-session=, =emacsclient=, or buffer kill inline — the =Stop= hook reads the sentinel when this response ends and runs =cj/ai-term-quit=, which kills the =aiv-<project>= session (taking =claude= with it), kills the vterm buffer, and restores geometry. The basename of =$PWD= is the key the hook matches, so the sentinel names the session it tears down.
+*The sentinel is session-scoped.* If certification fails, the =Stop= hook blocks and leaves the sentinel armed on purpose, so a wrap blocked by a dirty tree retries on a later stop without re-running this workflow. It does *not* survive the session: =session-start-disarm.sh= clears it at =SessionStart=, because a wrap that never certified is not a pending teardown once its session is gone. Before that hook existed, an uncertified sentinel sat armed indefinitely and fired in whatever session next reached a clean tree — work's 2026-07-27 11:37 wrap killed the 13:20 session mid-work, and archsetup's sat armed on a live terminal for two days. If teardown is still wanted in a new session, run this workflow again.
+
*** Shutdown mode
Confirm commit + push succeeded, then evaluate the safety gate *before* committing to the shutdown — never power the box off out from under another live session:
@@ -526,7 +629,8 @@ If =emacsclient= isn't resolvable or the daemon is down, the gate can't run —
7. *Leaving =.ai/session-context.org= in place* — its presence means "interrupted session", confuses next startup
8. *Long preachy valediction* — brief beats thorough
9. *Leaving runtime/generated files dirty without gitignoring them* — pollutes every future =git status= and erodes trust in "working tree clean" as a signal. Fix =.gitignore= during the wrap, not later.
-10. *Treating "was dirty at session start, still dirty now" as fine by default* — that's how a forgotten commit from two sessions ago turns into "carryover" for two weeks. Every pre-existing dirty file needs an active resolution recommendation this session. Deferral is allowed only with an explicit user choice, logged in the valediction.
+10. *Treating "was dirty at session start, still dirty now" as fine* — that's how a forgotten commit from two sessions ago turns into "carryover" for two weeks. Every pre-existing dirty file must be resolved or the wrap remains blocked.
+11. *Calling a blocked cleanup a wrap* — if the strict gate fails, report the paths and needed decisions; do not valedict, certify completion, or tear down.
* Validation Checklist
@@ -536,22 +640,23 @@ Before considering wrap-up complete:
- [ ] The Summary ends with the =KB: promoted N / consulted yes-no= line (promotion check ran)
- [ ] File renamed to =.ai/sessions/YYYY-MM-DD-HH-MM-description.org=
- [ ] =.ai/session-context.org= no longer exists
-- [ ] =todo-cleanup.el= ran — hygiene pass + =--archive-done= + =--sync-child-priority= (if =todo.org= exists at project root)
+- [ ] =todo-cleanup.el= ran — hygiene pass + =--convert-subtasks= + =--archive-done= + =--sync-child-priority= (if =todo.org= exists at project root)
- [ ] =lint-org.el= ran on =todo.org= — mechanical fixes applied, judgments appended to follow-ups file (if =todo.org= exists)
- [ ] Any orphan-planning-line warnings reviewed (fix or accept)
-- [ ] Inbox carries nothing but expected pipeline artifacts (=.gitkeep=, =lint-followups.org=, =PROCESSED-*= prefixes), OR each remaining handoff has an explicit deferral logged in the valediction
+- [ ] Inbox carries nothing but expected committed or ignored pipeline artifacts (=.gitkeep=, =lint-followups.org=, =PROCESSED-*= prefixes); any untracked inbox delivery was processed before wrap
- [ ] Linear Dev-Review sweep ran; any merged-PR tickets moved to Done or PM Acceptance (skip if project doesn't use Linear)
- [ ] Template-sync churn committed as its own =chore: sync .ai tooling from templates= (consuming projects only; skipped in rulesets), or surfaced if a synced path didn't match canonical
-- [ ] After wrap-up commit + push, =git status --short= is empty OR every remaining line has an explicit user-deferred decision logged in the valediction
+- [ ] After wrap-up commit + push, =git-worktree-gate certify "$PWD"= succeeded at the current HEAD
- [ ] Each leftover was investigated and the user saw a concrete resolution recommendation
- [ ] Runtime artifacts added to =.gitignore=, follow-up commit pushed, =git status= re-verified
- [ ] Forgotten changes committed in a follow-up and pushed
-- [ ] Pre-existing dirty files resolved (committed / stashed / discarded / moved to a feature branch) or explicitly deferred with a one-line reason in the valediction
+- [ ] Pre-existing dirty files resolved (committed / stashed / discarded / moved to a feature branch); otherwise wrap stopped with an actionable blocker report
- [ ] Current branch pushed to ALL remotes (verified with =git remote -v=)
- [ ] All other local branches with a tracking upstream pushed to their remote
- [ ] Any untracked-upstream branches surfaced for manual =git push -u=
- [ ] Step 6 teardown matches the trigger phrase: no-teardown leaves the buffer; teardown drops only =/tmp/ai-wrap-teardown-<project>=; shutdown gates on =cj/ai-term-live-count= = 1 and drops only =/tmp/ai-wrap-shutdown-<project>=
- [ ] No teardown/shutdown sentinel was dropped before commit + push was verified
+- [ ] The teardown hook can re-verify the clean-tree certificate before consuming a sentinel
- [ ] Shutdown aborted (fell back to normal wrap, logged in the valediction) when another =aiv-*= session was live or the gate couldn't run
- [ ] Commit message follows format (no =session:=, no Claude attribution)
- [ ] Valediction delivered (brief, specific, warm)