aboutsummaryrefslogtreecommitdiff
path: root/.ai
diff options
context:
space:
mode:
Diffstat (limited to '.ai')
-rw-r--r--.ai/metrics/work-the-backlog.jsonl15
-rw-r--r--.ai/notes.org16
-rw-r--r--.ai/protocols.org89
-rw-r--r--.ai/references/calendar-reference.org66
-rwxr-xr-x.ai/scripts/agent-lock248
-rwxr-xr-x.ai/scripts/apkg-to-orgdrill.py251
-rwxr-xr-x.ai/scripts/capture-guard91
-rwxr-xr-x.ai/scripts/cj-remove-block.py81
-rwxr-xr-x.ai/scripts/cross-agent-comms/cross-agent-discover230
-rw-r--r--.ai/scripts/cross-agent-comms/cross-agent-discover.md155
-rwxr-xr-x.ai/scripts/cross-agent-comms/cross-agent-halt134
-rw-r--r--.ai/scripts/cross-agent-comms/cross-agent-halt.md134
-rwxr-xr-x.ai/scripts/cross-agent-comms/cross-agent-recv250
-rw-r--r--.ai/scripts/cross-agent-comms/cross-agent-recv.md218
-rwxr-xr-x.ai/scripts/cross-agent-comms/cross-agent-resume145
-rw-r--r--.ai/scripts/cross-agent-comms/cross-agent-resume.md117
-rwxr-xr-x.ai/scripts/cross-agent-comms/cross-agent-send356
-rw-r--r--.ai/scripts/cross-agent-comms/cross-agent-send.md199
-rwxr-xr-x.ai/scripts/cross-agent-comms/cross-agent-status185
-rw-r--r--.ai/scripts/cross-agent-comms/cross-agent-status.md139
-rwxr-xr-x.ai/scripts/cross-agent-comms/cross-agent-watch106
-rw-r--r--.ai/scripts/cross-agent-comms/cross-agent-watch.md130
-rwxr-xr-x.ai/scripts/flashcard-stats.py12
-rwxr-xr-x.ai/scripts/flashcard-to-anki.py133
-rwxr-xr-x.ai/scripts/inbox-send.py105
-rwxr-xr-x.ai/scripts/inbox-status1
-rw-r--r--.ai/scripts/lint-org.el388
-rwxr-xr-x.ai/scripts/route-batch175
-rw-r--r--.ai/scripts/route_recommend.py145
-rwxr-xr-x.ai/scripts/self-inject.sh68
-rwxr-xr-x.ai/scripts/spec-sort715
-rwxr-xr-x.ai/scripts/task-review-staleness.sh40
-rw-r--r--.ai/scripts/tests/agent-lock.bats214
-rw-r--r--.ai/scripts/tests/capture-guard.bats130
-rw-r--r--.ai/scripts/tests/flashcard-sync.bats25
-rw-r--r--.ai/scripts/tests/inbox-status.bats12
-rw-r--r--.ai/scripts/tests/lint-org-cli.bats18
-rw-r--r--.ai/scripts/tests/route-batch.bats202
-rw-r--r--.ai/scripts/tests/self-inject.bats78
-rw-r--r--.ai/scripts/tests/spec-sort.bats453
-rw-r--r--.ai/scripts/tests/task-review-staleness.bats53
-rw-r--r--.ai/scripts/tests/test-lint-org.el437
-rw-r--r--.ai/scripts/tests/test-todo-cleanup.el610
-rw-r--r--.ai/scripts/tests/test-wrap-org-table.el42
-rw-r--r--.ai/scripts/tests/test_apkg_to_orgdrill.py301
-rw-r--r--.ai/scripts/tests/test_cj_remove_block.py167
-rw-r--r--.ai/scripts/tests/test_cross_agent_discover.py204
-rw-r--r--.ai/scripts/tests/test_cross_agent_halt.py204
-rw-r--r--.ai/scripts/tests/test_cross_agent_recv.py176
-rw-r--r--.ai/scripts/tests/test_cross_agent_send.py210
-rw-r--r--.ai/scripts/tests/test_cross_agent_status.py165
-rw-r--r--.ai/scripts/tests/test_cross_agent_watch.py155
-rw-r--r--.ai/scripts/tests/test_flashcard_stats.py25
-rw-r--r--.ai/scripts/tests/test_flashcard_to_anki.py92
-rw-r--r--.ai/scripts/tests/test_inbox_send.py235
-rw-r--r--.ai/scripts/tests/test_route_recommend.py152
-rw-r--r--.ai/scripts/tests/test_upcoming_birthdays.py168
-rw-r--r--.ai/scripts/todo-cleanup.el582
-rwxr-xr-x.ai/scripts/upcoming_birthdays.py182
-rw-r--r--.ai/scripts/wrap-org-table.el58
-rw-r--r--.ai/sessions/2026-06-16-23-37-cross-agent-comms-removal-and-batch-specs.org194
-rw-r--r--.ai/sessions/2026-06-21-02-44-launcher-fix-kb-feature-wrapup-routing.org110
-rw-r--r--.ai/sessions/2026-06-22-01-33-spec-review-fold-coverage-fix-inbox-triage.org57
-rw-r--r--.ai/sessions/2026-06-23-22-36-inbox-guard-bash-bundle-consolidation-spec.org164
-rw-r--r--.ai/sessions/2026-06-24-00-14-inbox-consolidation-wrap-teardown-roam-fix.org85
-rw-r--r--.ai/sessions/2026-06-24-09-27-task-audit-blocked-deps-anki-wrap-teardown.org75
-rw-r--r--.ai/sessions/2026-06-28-15-57-inbox-proposals-shipped-and-task-audit.org278
-rw-r--r--.ai/sessions/2026-06-29-03-56-spec-lifecycle-decision-and-speedrun-ratified.org107
-rw-r--r--.ai/sessions/2026-06-30-13-55-pager-mcp-ssh-alias-and-emacsd-proposals.org101
-rw-r--r--.ai/sessions/2026-07-02-09-29-docs-lifecycle-speedrun-autonomous-loop.org110
-rw-r--r--.ai/sessions/2026-07-04-12-57-audit-closeouts-and-startup-sync-guard.org68
-rw-r--r--.ai/sessions/2026-07-11-02-30-inbox-clearout-six-proposals-shipped.org74
-rw-r--r--.ai/sessions/2026-07-13-23-48-runtime-portability-pager-local-llm.org191
-rw-r--r--.ai/sessions/2026-07-14-02-50-sentry-spec-review-lint-org-fix.org84
-rw-r--r--.ai/sessions/2026-07-17-10-55-inbox-fixes-emacs-wayland-sentry-prep.org238
-rw-r--r--.ai/sessions/2026-07-19-21-15-launcher-hardening-inbox-hook-colloquialisms.org238
-rw-r--r--.ai/sessions/2026-07-20-23-36-signal-pager-and-triage-phone-push.org185
-rw-r--r--.ai/sessions/2026-07-24-18-05-sentry-implement-pass-review-and-hook-fixes.org386
-rw-r--r--.ai/sessions/2026-07-25-09-24-invalid-block-filter-push-task-review.org83
-rw-r--r--.ai/sessions/2026-07-25-15-34-clean-wrap-and-inbox-safe-sync.org85
-rw-r--r--.ai/sessions/2026-07-27-17-02-context-engineering-rightsizing.org176
-rw-r--r--.ai/sessions/2026-07-29-06-36-adversarial-review-flow-and-telegram-fixes.org514
-rw-r--r--.ai/workflows/INDEX.org44
-rw-r--r--.ai/workflows/add-calendar-event.org2
-rw-r--r--.ai/workflows/broadcast.org6
-rw-r--r--.ai/workflows/clean-todo.org21
-rw-r--r--.ai/workflows/code-quality.org90
-rw-r--r--.ai/workflows/create-workflow.org8
-rw-r--r--.ai/workflows/cross-agent-comms.org334
-rw-r--r--.ai/workflows/daily-prep.org7
-rw-r--r--.ai/workflows/delete-calendar-event.org2
-rw-r--r--.ai/workflows/edit-calendar-event.org2
-rw-r--r--.ai/workflows/email-assembly.org2
-rw-r--r--.ai/workflows/extract-email.org2
-rw-r--r--.ai/workflows/find-email.org2
-rw-r--r--.ai/workflows/first-session.org2
-rw-r--r--.ai/workflows/flashcard-review.org2
-rw-r--r--.ai/workflows/helper-mode.org4
-rw-r--r--.ai/workflows/inbox-zero.org97
-rw-r--r--.ai/workflows/inbox.org524
-rw-r--r--.ai/workflows/journal-entry.org2
-rw-r--r--.ai/workflows/meeting-prep.org2
-rw-r--r--.ai/workflows/meeting-prep.pre-wire.org2
-rw-r--r--.ai/workflows/monitor-inbox.org122
-rw-r--r--.ai/workflows/no-approvals.org12
-rw-r--r--.ai/workflows/open-tasks.org34
-rw-r--r--.ai/workflows/page-me.org53
-rw-r--r--.ai/workflows/process-inbox.org220
-rw-r--r--.ai/workflows/process-meeting-transcript.org26
-rw-r--r--.ai/workflows/read-calendar-events.org2
-rw-r--r--.ai/workflows/readability-audit.org242
-rw-r--r--.ai/workflows/rename-artifact.org2
-rw-r--r--.ai/workflows/send-email.org2
-rw-r--r--.ai/workflows/sentry.org227
-rw-r--r--.ai/workflows/session-harvest.org2
-rw-r--r--.ai/workflows/spec-create.org22
-rw-r--r--.ai/workflows/spec-response.org67
-rw-r--r--.ai/workflows/spec-review.org113
-rw-r--r--.ai/workflows/startup.org115
-rw-r--r--.ai/workflows/status-check.org2
-rw-r--r--.ai/workflows/summarize-emails.org2
-rw-r--r--.ai/workflows/suspend.org143
-rw-r--r--.ai/workflows/sync-email.org2
-rw-r--r--.ai/workflows/task-audit.org40
-rw-r--r--.ai/workflows/task-review.org12
-rw-r--r--.ai/workflows/triage-intake.cmail.org2
-rw-r--r--.ai/workflows/triage-intake.github-prs.org2
-rw-r--r--.ai/workflows/triage-intake.org237
-rw-r--r--.ai/workflows/triage-intake.personal-calendar.org2
-rw-r--r--.ai/workflows/triage-intake.personal-gmail.org23
-rw-r--r--.ai/workflows/triage-intake.telegram.org143
-rw-r--r--.ai/workflows/work-the-backlog.org266
-rw-r--r--.ai/workflows/wrap-it-up.org215
133 files changed, 12687 insertions, 4912 deletions
diff --git a/.ai/metrics/work-the-backlog.jsonl b/.ai/metrics/work-the-backlog.jsonl
new file mode 100644
index 0000000..88c6764
--- /dev/null
+++ b/.ai/metrics/work-the-backlog.jsonl
@@ -0,0 +1,15 @@
+{"ts":"2026-07-02T05:14:42-04:00","run_id":"c726f526-2e35-4513-b25c-18ef61061333","project":"rulesets","caller":"speedrun","task":"id-link-conversion-pass","outcome":"implemented-committed","defer_reason":"","upfront_decision":false,"wall_clock_s":284,"commit_sha":"78bbaae","review_findings":0}
+{"ts":"2026-07-02T05:19:03-04:00","run_id":"c726f526-2e35-4513-b25c-18ef61061333","project":"rulesets","caller":"speedrun","task":"host-identity-guard-rule-plus-startup-lint","outcome":"implemented-committed","defer_reason":"","upfront_decision":true,"wall_clock_s":261,"commit_sha":"b6a977c","review_findings":0}
+{"ts":"2026-07-02T05:22:11-04:00","run_id":"c726f526-2e35-4513-b25c-18ef61061333","project":"rulesets","caller":"speedrun","task":"template-sync-gitignored-only-changes","outcome":"implemented-committed","defer_reason":"","upfront_decision":true,"wall_clock_s":188,"commit_sha":"ed75d3c","review_findings":0}
+{"ts":"2026-07-02T05:58:16-04:00","run_id":"a48f2977-4493-48a3-9238-9b2f5ff5383b","project":"rulesets","caller":"loop","task":"inbox-send-filename-collision-fix","outcome":"implemented-committed","defer_reason":"","upfront_decision":false,"wall_clock_s":300,"commit_sha":"8099377","review_findings":0}
+{"ts":"2026-07-02T05:58:16-04:00","run_id":"a48f2977-4493-48a3-9238-9b2f5ff5383b","project":"rulesets","caller":"loop","task":"page-me-notify-info-level","outcome":"implemented-committed","defer_reason":"","upfront_decision":false,"wall_clock_s":120,"commit_sha":"a6b534f","review_findings":0}
+{"ts":"2026-07-23T23:55:16-05:00","run_id":"711400a5-c6bc-4684-b928-6b818d0d33f2","project":"rulesets","caller":"speedrun","task":"inbox-send phantom empty handoff","outcome":"implemented-committed","upfront_decision":false,"commit_sha":"0f91a8e","review_findings":1}
+{"ts":"2026-07-23T23:57:59-05:00","run_id":"711400a5-c6bc-4684-b928-6b818d0d33f2","project":"rulesets","caller":"speedrun","task":"inbox-send two smaller defects","outcome":"implemented-committed","upfront_decision":false,"commit_sha":"a053e9d","review_findings":0}
+{"ts":"2026-07-24T00:02:14-05:00","run_id":"711400a5-c6bc-4684-b928-6b818d0d33f2","project":"rulesets","caller":"speedrun","task":"lint-org todo-format checkers fire on specs","outcome":"implemented-committed","upfront_decision":true,"commit_sha":"c38bab9","review_findings":0}
+{"ts":"2026-07-24T00:02:49-05:00","run_id":"711400a5-c6bc-4684-b928-6b818d0d33f2","project":"rulesets","caller":"speedrun","task":"notes.org template four lint flags","outcome":"already-satisfied","defer_reason":"already-satisfied","upfront_decision":false,"commit_sha":"","review_findings":0}
+{"ts":"2026-07-24T01:41:14-05:00","run_id":"sentry-fire2-1784875274","project":"rulesets","caller":"loop","task":"cj-remove-block over-deletion + unsafe write","outcome":"implemented-committed","upfront_decision":false,"commit_sha":"17f5d48","review_findings":0}
+{"ts":"2026-07-24T01:43:52-05:00","run_id":"sentry-fire2","project":"rulesets","caller":"loop","task":"route_recommend duplicate-name tier downgrade","outcome":"implemented-committed","upfront_decision":false,"commit_sha":"1b0f284","review_findings":0}
+{"ts":"2026-07-24T02:38:48-05:00","run_id":"sentry-fire3","project":"rulesets","caller":"loop","task":"audit.bats flaky teardown","outcome":"implemented-committed","upfront_decision":false,"commit_sha":"7f45d4b","review_findings":0}
+{"ts":"2026-07-24T03:39:16-05:00","run_id":"sentry-fire4","project":"rulesets","caller":"loop","task":"todo-cleanup missing backup","outcome":"implemented-committed","upfront_decision":false,"commit_sha":"0686784","review_findings":0}
+{"ts":"2026-07-24T04:36:09-05:00","run_id":"sentry-fire5","project":"rulesets","caller":"loop","task":"attachment filename sanitization","outcome":"deferred-verify","defer_reason":"needs-deliberation","upfront_decision":false,"commit_sha":"","review_findings":0}
+{"ts":"2026-07-24T07:38:47-05:00","run_id":"sentry-fire8","project":"rulesets","caller":"loop","task":"bin/ lint coverage gap","outcome":"implemented-committed","upfront_decision":false,"commit_sha":"f91feef","review_findings":1}
diff --git a/.ai/notes.org b/.ai/notes.org
index 0db9544..828fde3 100644
--- a/.ai/notes.org
+++ b/.ai/notes.org
@@ -1,5 +1,5 @@
#+TITLE: Claude Code Notes - Rulesets
-#+AUTHOR: Craig Jennings & Claude
+#+AUTHOR: Craig Jennings
#+DATE: 2026-05-06
* About This File
@@ -32,7 +32,7 @@ See [[file:../README.org][README.org]] for the full layout, install modes, and l
- =claude-rules/= — generic rules (=commits.md=, =testing.md=, =verification.md=, =subagents.md=) symlinked into =~/.claude/rules/= and applied to every Claude Code session on the machine.
- Top-level skill directories (=add-tests/=, =debug/=, =five-whys/=, =frontend-design/=, =pairwise-tests/=, =playwright-js/=, =playwright-py/=, =root-cause-trace/=, =voice/=) — each a Claude Code skill, symlinked into =~/.claude/skills/= by =make install=.
-- =languages/= — per-language bundles (rules + hooks + settings) copied into target projects via =make install-lang LANG=<name> PROJECT=<path>=. Both =LANG= and =PROJECT= are optional — fzf picks them interactively when omitted. Bundles currently shipping: =elisp=, =python=.
+- =languages/= — per-language bundles (rules + hooks + settings) copied into target projects via =make install-lang LANG=<name> PROJECT=<path>=. Both =LANG= and =PROJECT= are optional — fzf picks them interactively when omitted. Bundles currently shipping: =bash=, =elisp=, =go=, =python=, =typescript=.
- =.claude/= — repo-local Claude Code config: =settings.json= and =commands/=.
- =hooks/=, =scripts/= — install helpers and PostToolUse validators that ride along with bundles.
- =Makefile= — install / uninstall / list entry points.
@@ -61,7 +61,9 @@ This section tracks decisions that need Craig's input before work can proceed.
** Current Reminders
-(None currently — will be added as needed)
+- =[2026-07-27]= Finish the context-engineering rightsizing — Craig's explicit ask at wrap. Surface is 57,800 → 28,949 tokens; the remaining work needs *his decisions*, not execution: =verification.md= (C1 — its honesty core vs the Opus 5 over-verification warning), =interaction.md= (3,828 tok, largest remaining), the TDD rationalization table (cut or keep), and D3 the gate separation (which approval gates are preference vs guardrail). Task: "Finish context-engineering rightsizing" in todo.org. Docs in =working/context-engineering-rightsizing/= are one commit behind — reconcile them first.
+
+- =[2026-07-14]= Review the sentry spec (docs/specs/2026-07-14-sentry-workflow-spec.org) — Craig's explicit ask at wrap: strongly suggest he reviews it before ending the next session. All 12 review findings and 10 decisions are resolved and folded in; the spec is open in his Emacs; the READY flip and the [#B] build task both wait on his deep read.
** Instructions for This Section
@@ -76,9 +78,13 @@ Format:
* Workflow State
+:COMMIT_AUTONOMY: yes
+:LOOP_MAY_COMMIT: yes
+:SENTRY_MAY_IMPLEMENT: yes
+:LAST_SPEC_SORT: 2026-07-02
Markers maintained by workflows to record when they last ran. Read by other workflows that gate their behavior on freshness.
-:LAST_AUDIT: 2026-05-28
-:LAST_INBOX_PROCESS: 2026-06-13 (afternoon: .emacs.d inbox-zero ack cleared; no local action needed)
+:LAST_AUDIT: 2026-07-20 (open set current — this session's shipped work (working/temp, triage-source-activation, silent-until-signal, suspend detach) closed as it went; sentry cluster consolidated (merged the /schedule tasks, added cross-host-coordination); nothing shipped-but-open per git reconcile. Live finding: the Polyglot + Subprojects scouting tasks are SCHEDULED 2026-07-20 and due.)
+:LAST_INBOX_PROCESS: 2026-07-25 (consolidated home + work Claude-to-Codex MCP registry proposals into one [#B] parked spec decision; memory auditor split from the registry work)
Format: one =:MARKER: YYYY-MM-DD= line per workflow. Workflows overwrite their own marker on completion.
diff --git a/.ai/protocols.org b/.ai/protocols.org
index 05f889b..bf9f420 100644
--- a/.ai/protocols.org
+++ b/.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=.
@@ -203,7 +205,9 @@ Check =inbox/= at every task boundary (after finishing a unit of work, before re
.ai/scripts/inbox-status -q
#+end_src
-Exit 1 means handoffs are pending — process them per =process-inbox.org=. 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 process-inbox'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 =monitor-inbox.org=.
+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=
@@ -242,13 +246,33 @@ Execute the wrap-up workflow (details in Session Protocols section below):
2. Git commit and push all changes
3. Valediction summary
+** "Suspend the session" / "Suspend" / "I need to go" / "Stick a pin in everything"
+
+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
Three ways to access Craig's calendars: Google Calendar MCP (preferred, both personal + work accounts), gcalcli (fallback, personal only), Emacs org files (read-only viewer).
-For tool recipes, authentication details, and credentials, see [[file:references/calendar-reference.org][calendar-reference.org]].
+For tool recipes and account details, read the calendar workflows in =.ai/workflows/=: =add-calendar-event.org=, =edit-calendar-event.org=, =delete-calendar-event.org=, =read-calendar-events.org=. They carry the MCP tool names, both account ids, the gcalcli fallback, and the conflict-check discipline.
+
+Credentials are needed only for a re-auth Craig performs himself. The MCP bundle's =mcp/README.org= in the rulesets repo is the authority: =gcp-oauth.keys.json= is gitignored and regenerated at install from a base64 var in the bundle, never committed. Named in prose rather than linked, because that path isn't synced into consuming projects.
** GPG Keys
@@ -352,9 +376,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.
@@ -363,6 +397,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
@@ -402,6 +442,30 @@ Full usage: =notify --help= or see =~/.local/bin/notify=
- =atq= - list all scheduled alarms
- =atrm [number]= - remove an alarm by its queue number
+** 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.
+
+ #+begin_src bash
+ notify info "Title" "Message" --persist
+ #+end_src
+
+- *"text me" — away from his machine.* A Signal push to his phone via =agent-text=:
+
+ #+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
** CRITICAL: Git Commit Requirements
@@ -427,7 +491,7 @@ When creating commits:
- Keep messages clear and informative
3. **No Claude-tooling artifacts**: Commit messages describe project changes only — the meta-process of how work got shipped stays out of public git history.
- - **ABSOLUTELY NO** mentions of =notes.org=, =session-context.org=, =.ai/sessions/=, =todo.org=, "session wrap-up", or session timestamps (e.g., "Session YYYY-MM-DD HH:MM → ...")
+ - **ABSOLUTELY NO** mentions of =notes.org=, =session-context.org=, =.ai/= (including =.ai/sessions/=), =.claude/=, =CLAUDE.md=, =todo.org=, "session wrap-up", or session timestamps (e.g., "Session YYYY-MM-DD HH:MM → ..."), except when one of those files is itself the change — then name what changed by category, not the surrounding tooling layer
- Subject lines must NEVER start with =session:= as a conventional-commit type — use =docs:=, =refactor:=, =fix:=, =feat:=, =chore:=, etc. (real change categories)
- When a wrap-up commit bundles many changes from a session, describe what /shipped/ (e.g., =refactor: extract RAID logic + add bats testing infrastructure=), not that a session happened
- Same spirit as the no-Claude-attribution rule: the tooling stays invisible in =git log=
@@ -460,7 +524,7 @@ When Craig says this phrase:
- If exact match found: Read and guide through process
3. **Fuzzy match across both directories:** Ask for clarification
- - Example: User says "empty inbox" but we have "inbox-zero.org"
+ - Example: User says "empty inbox" but we have "inbox.org" (roam mode)
- Ask: "Did you mean the 'inbox zero' workflow, or create new 'empty inbox'?"
4. **No match at all:** Offer to create it
@@ -515,12 +579,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.
@@ -539,6 +604,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/.ai/references/calendar-reference.org b/.ai/references/calendar-reference.org
deleted file mode 100644
index b44c0f1..0000000
--- a/.ai/references/calendar-reference.org
+++ /dev/null
@@ -1,66 +0,0 @@
-#+TITLE: Calendar Reference
-#+AUTHOR: Craig Jennings & Claude
-
-Tool recipes, authentication, and credentials for Craig's calendar
-setup. Three access methods, in order of preference.
-
-* Google Calendar MCP Server (preferred for all calendar operations)
-
-Craig has the =@cocal/google-calendar-mcp= MCP server configured at user scope (=~/.claude.json=). It provides full read/write access to Google Calendar via MCP tools.
-
-Two accounts are authenticated:
-- *personal* — craigmartinjennings@gmail.com (primary: "Craig Google")
-- *work* — craig.jennings@deepsat.com (primary: "Craig Deepsat")
-
-MCP tools available:
-- =list-events=, =search-events=, =get-event= — read events
-- =create-event=, =create-events= — add events
-- =update-event= — modify events
-- =delete-event= — remove events
-- =list-calendars=, =list-colors= — calendar metadata
-- =get-freebusy= — check availability
-- =manage-accounts= — add/remove/list authenticated accounts
-- =respond-to-event= — accept/decline invitations
-- =get-current-time= — current time in any timezone
-
-Use =account_id: "personal"= or =account_id: "work"= to specify which account.
-
-Default calendar for adding events: "Craig Google" (personal account).
-
-Calendar workflows are available alongside this reference: add-calendar-event, edit-calendar-event, delete-calendar-event, read-calendar-events.
-
-If re-authentication is needed:
-- Use the =manage-accounts= MCP tool with =action: "add"= and the account nickname
-- OAuth credentials: =~/projects/homelab/assets/gcp-oauth.keys.json=
-- Google Cloud app is in production mode (tokens don't expire after 7 days)
-- See =~/projects/homelab/.ai/gcalcli-setup.org= for Google Cloud project details
-
-* gcalcli (fallback for personal account only)
-
-Craig has =gcalcli= installed via pipx, authenticated to his personal Google account only.
-
-#+begin_src bash
-gcalcli agenda # upcoming events
-gcalcli calw # weekly view
-gcalcli add --title "..." --when "..." --duration "60" # add event
-gcalcli search "..." # search events
-gcalcli delete "..." # delete event
-#+end_src
-
-Use =--calendar "Craig Google"= when adding events.
-
-gcalcli does NOT have access to the work (DeepSat) calendar. Use the MCP server for work calendar operations.
-
-If gcalcli needs re-authentication, credentials are stored in the homelab project: =~/projects/homelab/assets/gcalcli-client-secret.json.gpg= (GPG encrypted).
-
-* Emacs org files (read-only, for viewing schedules)
-
-Craig's calendars are at: =~/.emacs.d/data/*cal.org= (gcal.org, dcal.org, pcal.org)
-
-These files are **READ-ONLY** — NEVER add anything to them.
-
-Use this to:
-- Check meeting times and schedules
-- Verify when events occurred
-- See what's upcoming
-- Note: only updated periodically when Emacs is running — may be stale
diff --git a/.ai/scripts/agent-lock b/.ai/scripts/agent-lock
new file mode 100755
index 0000000..634412c
--- /dev/null
+++ b/.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/.ai/scripts/apkg-to-orgdrill.py b/.ai/scripts/apkg-to-orgdrill.py
new file mode 100755
index 0000000..79e24a4
--- /dev/null
+++ b/.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/.ai/scripts/capture-guard b/.ai/scripts/capture-guard
new file mode 100755
index 0000000..6c01f2f
--- /dev/null
+++ b/.ai/scripts/capture-guard
@@ -0,0 +1,91 @@
+#!/usr/bin/env bash
+# capture-guard — detect live org-capture buffers visiting a target file
+# before a workflow edits that file on disk.
+#
+# Editing a file on disk while Emacs has an indirect org-capture buffer
+# cloned from it reverts the base buffer underneath the capture, wedging it:
+# the capture can no longer finalize cleanly with C-c C-c, and a freshly-typed
+# item can be lost or written back against post-edit content. inbox.org
+# roam mode Phase D edits ~/org/roam/inbox.org, the file Craig captures into constantly,
+# so it calls this guard first. See claude-rules/emacs.md.
+#
+# Usage: capture-guard [--wait[=SECONDS]] [TARGET_FILE] (default ~/org/roam/inbox.org)
+#
+# Single-shot (default): check once.
+# exit 0 — safe to edit: no Emacs, daemon unreachable, or no capture buffer
+# visits TARGET_FILE.
+# exit 1 — a live capture buffer visits TARGET_FILE; its name(s) printed to
+# stdout, comma-separated.
+#
+# --wait[=SECONDS]: poll until the capture clears or SECONDS elapse (default
+# 30), re-checking every ~10s. Org captures are usually transient — a few
+# seconds of mid-finalize state — so a short wait clears most false alarms
+# before a caller has to surface or skip. Same exit codes: exit 0 the moment
+# it's clear, exit 1 if still blocked at the deadline (last buffer list on
+# stdout). The common case (nothing capturing) returns instantly without
+# sleeping.
+#
+# Conservative by construction: any uncertainty (no Emacs, query failure)
+# resolves to "safe," so the guard never blocks a workflow that would have
+# been fine. It only stops the one case it can positively confirm.
+
+set -euo pipefail
+
+WAIT_TOTAL=0
+case "${1:-}" in
+ --wait) WAIT_TOTAL=30; shift ;;
+ --wait=*) WAIT_TOTAL="${1#--wait=}"; shift ;;
+esac
+
+TARGET="${1:-$HOME/org/roam/inbox.org}"
+INTERVAL=10
+
+# Names of capture buffers whose base buffer visits TARGET. file-equal-p
+# normalizes symlinks and ./.. so the match survives path spelling; it also
+# returns nil when TARGET doesn't exist, which collapses to "safe" below.
+lisp='(let ((target (expand-file-name "'"$TARGET"'")))
+ (mapconcat (function buffer-name)
+ (seq-filter
+ (lambda (b)
+ (and (string-prefix-p "CAPTURE" (buffer-name b))
+ (let* ((base (or (buffer-base-buffer b) b))
+ (f (buffer-file-name base)))
+ (and f (file-equal-p f target)))))
+ (buffer-list))
+ ","))'
+
+LAST_BUFS=""
+
+# detect — return 0 (safe) or 1 (blocked, name(s) in LAST_BUFS). Any
+# uncertainty resolves to safe, matching the single-shot contract.
+detect() {
+ command -v emacsclient >/dev/null 2>&1 || return 0
+ emacsclient -e t >/dev/null 2>&1 || return 0
+ local bufs
+ bufs="$(emacsclient -e "$lisp" 2>/dev/null)" || return 0
+ bufs="${bufs#\"}"
+ bufs="${bufs%\"}"
+ if [ -n "$bufs" ]; then
+ LAST_BUFS="$bufs"
+ return 1
+ fi
+ return 0
+}
+
+# Poll loop. With WAIT_TOTAL=0 (single-shot) it checks once and falls straight
+# through to the exit-1 branch on a block, never sleeping. Each sleep is capped
+# to the remaining budget so a short --wait never overshoots its deadline.
+elapsed=0
+while :; do
+ if detect; then
+ exit 0
+ fi
+ if [ "$elapsed" -ge "$WAIT_TOTAL" ]; then
+ echo "$LAST_BUFS"
+ exit 1
+ fi
+ remaining=$((WAIT_TOTAL - elapsed))
+ step=$((remaining < INTERVAL ? remaining : INTERVAL))
+ sleep "$step"
+ elapsed=$((elapsed + step))
+done
diff --git a/.ai/scripts/cj-remove-block.py b/.ai/scripts/cj-remove-block.py
index 71c7b3d..d5137a3 100755
--- a/.ai/scripts/cj-remove-block.py
+++ b/.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/.ai/scripts/cross-agent-comms/cross-agent-discover b/.ai/scripts/cross-agent-comms/cross-agent-discover
deleted file mode 100755
index 152cf27..0000000
--- a/.ai/scripts/cross-agent-comms/cross-agent-discover
+++ /dev/null
@@ -1,230 +0,0 @@
-#!/usr/bin/env python3
-"""Enumerate cross-agent destinations: local projects + tailnet peers.
-
-See cross-agent-discover.md. Local: scan ~/projects/*/.ai/. Peers: read
-peers.toml, SSH-probe each for reachability. --enumerate-remote optionally
-runs `ls -d ~/projects/*/.ai/` over SSH to list remote projects.
-
-Cache results for 5 min at ~/.cache/cross-agent-comms/discovery.json so
-repeated invocations don't re-probe.
-
-HALT: prints a banner; otherwise continues.
-"""
-
-from __future__ import annotations
-
-import argparse
-import datetime as _dt
-import json
-import os
-import subprocess
-import sys
-import time
-import tomllib
-from pathlib import Path
-
-CONFIG_DIR = Path.home() / ".config" / "cross-agent-comms"
-PEERS_TOML = CONFIG_DIR / "peers.toml"
-HALT_FILE = CONFIG_DIR / "HALT"
-CACHE_DIR = Path.home() / ".cache" / "cross-agent-comms"
-CACHE_FILE = CACHE_DIR / "discovery.json"
-CACHE_TTL_SECONDS = 300
-
-EXIT_OK = 0
-EXIT_GENERAL = 1
-EXIT_PEERS_TOML = 1
-
-
-def err(msg: str) -> None:
- print(msg, file=sys.stderr)
-
-
-def render_banner_if_halt() -> None:
- if not HALT_FILE.exists():
- return
- try:
- reason = HALT_FILE.read_text().strip()
- except OSError:
- reason = "(HALT file unreadable; treated as halted)"
- print("⚠ HALT ACTIVE — cross-agent comms paused")
- if reason:
- print(f" reason: {reason}")
- print()
-
-
-def enumerate_local_projects() -> list[str]:
- projects_dir = Path.home() / "projects"
- if not projects_dir.is_dir():
- return []
- found = []
- for child in sorted(projects_dir.iterdir()):
- if child.is_dir() and (child / ".ai").is_dir():
- found.append(child.name)
- return found
-
-
-def load_peers() -> dict:
- if not PEERS_TOML.exists():
- return {"peers": {}}
- try:
- return tomllib.loads(PEERS_TOML.read_text())
- except (tomllib.TOMLDecodeError, OSError) as e:
- err(f"cannot parse peers.toml: {e}")
- sys.exit(EXIT_PEERS_TOML)
-
-
-def probe_peer_reachability(host: str, ssh_user: str | None) -> tuple[bool, str | None]:
- """Run a short SSH probe with BatchMode=yes (no interactive prompt)."""
- target = f"{ssh_user}@{host}" if ssh_user else host
- try:
- result = subprocess.run(
- ["ssh", "-o", "ConnectTimeout=2", "-o", "BatchMode=yes", target, "true"],
- capture_output=True,
- text=True,
- timeout=5,
- )
- except (FileNotFoundError, subprocess.TimeoutExpired):
- return False, "ssh probe failed"
- if result.returncode == 0:
- return True, None
- return False, (result.stderr.strip().splitlines() or [f"exit {result.returncode}"])[-1]
-
-
-def enumerate_remote_projects(host: str, ssh_user: str | None) -> list[str] | None:
- target = f"{ssh_user}@{host}" if ssh_user else host
- try:
- result = subprocess.run(
- [
- "ssh", "-o", "ConnectTimeout=3", "-o", "BatchMode=yes", target,
- "ls -d ~/projects/*/.ai/ 2>/dev/null",
- ],
- capture_output=True,
- text=True,
- timeout=10,
- )
- except (FileNotFoundError, subprocess.TimeoutExpired):
- return None
- if result.returncode != 0:
- return None
- projects = []
- for line in result.stdout.splitlines():
- # Each line looks like /home/<user>/projects/<name>/.ai/
- parts = line.rstrip("/").split("/")
- if len(parts) >= 2 and parts[-1] == ".ai":
- projects.append(parts[-2])
- return projects
-
-
-def read_cache() -> dict | None:
- if not CACHE_FILE.exists():
- return None
- try:
- age = time.time() - CACHE_FILE.stat().st_mtime
- if age > CACHE_TTL_SECONDS:
- return None
- return json.loads(CACHE_FILE.read_text())
- except (OSError, json.JSONDecodeError):
- return None
-
-
-def write_cache(payload: dict) -> None:
- CACHE_DIR.mkdir(parents=True, exist_ok=True)
- CACHE_FILE.write_text(json.dumps(payload, indent=2))
-
-
-def discover(peer_filter: str | None, enumerate_remote: bool) -> dict:
- local = enumerate_local_projects()
- peers_cfg = load_peers().get("peers", {})
-
- peers_out = []
- for name, cfg in sorted(peers_cfg.items()):
- if peer_filter and name != peer_filter:
- continue
- host = cfg.get("host", name)
- ssh_user = cfg.get("ssh_user")
- reachable, error = probe_peer_reachability(host, ssh_user)
- entry = {
- "name": name,
- "host": host,
- "reachable": reachable,
- }
- if not reachable:
- entry["error"] = error
- if enumerate_remote and reachable:
- entry["projects"] = enumerate_remote_projects(host, ssh_user) or []
- peers_out.append(entry)
-
- return {
- "scanned_at": _dt.datetime.now(_dt.timezone.utc).isoformat(),
- "halt_active": HALT_FILE.exists(),
- "local": local,
- "peers": peers_out,
- }
-
-
-def render_table(payload: dict, enumerate_remote: bool) -> None:
- local = payload.get("local", [])
- print(f"Local ({_local_hostname()}):")
- if local:
- wrapped = ", ".join(local)
- print(f" {wrapped} [{len(local)} project{'s' if len(local) != 1 else ''}]")
- else:
- print(" (no projects with .ai/ found)")
- print()
-
- peers = payload.get("peers", [])
- if not peers:
- print("Peers (from peers.toml):")
- print(" (no peers configured)")
- return
-
- print("Peers (from ~/.config/cross-agent-comms/peers.toml):")
- for p in peers:
- marker = "✓ reachable" if p.get("reachable") else f"✗ UNREACHABLE ({p.get('error', 'unknown')})"
- print(f" {p['name']:<16} {p['host']:<24} {marker}")
- if enumerate_remote and p.get("projects"):
- wrapped = ", ".join(p["projects"])
- print(f" projects: {wrapped}")
-
-
-def _local_hostname() -> str:
- import socket
- return socket.gethostname().split(".")[0]
-
-
-def main() -> int:
- parser = argparse.ArgumentParser(description="Discover cross-agent destinations.")
- parser.add_argument("--enumerate-remote", action="store_true",
- help="SSH into each peer and list ~/projects/*/.ai/")
- parser.add_argument("--no-cache", action="store_true", help="Skip cache; force fresh probe")
- parser.add_argument("--peer", help="Limit to a single peer name from peers.toml")
- parser.add_argument("--json", action="store_true", help="Machine-readable output")
- args = parser.parse_args()
-
- render_banner_if_halt()
-
- payload = None
- if not args.no_cache:
- cached = read_cache()
- if cached is not None:
- # Honor --peer filter on cached payload.
- if args.peer:
- cached["peers"] = [p for p in cached.get("peers", []) if p["name"] == args.peer]
- payload = cached
-
- if payload is None:
- payload = discover(args.peer, args.enumerate_remote)
- if not args.no_cache and not args.peer:
- # Only cache full (unfiltered) discoveries.
- write_cache(payload)
-
- if args.json:
- print(json.dumps(payload, indent=2))
- return EXIT_OK
-
- render_table(payload, args.enumerate_remote)
- return EXIT_OK
-
-
-if __name__ == "__main__":
- sys.exit(main())
diff --git a/.ai/scripts/cross-agent-comms/cross-agent-discover.md b/.ai/scripts/cross-agent-comms/cross-agent-discover.md
deleted file mode 100644
index 95134bb..0000000
--- a/.ai/scripts/cross-agent-comms/cross-agent-discover.md
+++ /dev/null
@@ -1,155 +0,0 @@
-# cross-agent-discover
-
-**Purpose.** Enumerate available cross-agent destinations — local projects on
-this machine and remote projects on tailnet peers. Validates SSH reachability
-for cross-machine destinations before reporting them as usable.
-
-## Usage
-
-```
-cross-agent-discover [--enumerate-remote] [--no-cache] [--peer <name>]
-```
-
-No args required for the common case (local enumeration + peer reachability).
-
-### Flags
-
-| Flag | Default | Purpose |
-|---|---|---|
-| `--enumerate-remote` | off | SSH into each peer and list projects under `~/projects/*/.ai/`. Off by default because SSH adds latency; turn on when you want to see what's available on a remote machine you haven't fully configured. |
-| `--no-cache` | off | Skip the 5-minute cache; force fresh discovery. |
-| `--peer <name>` | (all) | Limit to a single peer from `peers.toml`. |
-| `--json` | off | Machine-readable output. |
-
-## Output
-
-### Default
-
-```
-$ cross-agent-discover
-Local (ratio):
- career, claude-templates, clipper, danneel, documents, elibrary,
- finances, health, homelab, jr-estate, kit, little-elisper,
- philosophy, website [14 projects]
-
-Peers (from ~/.config/cross-agent-comms/peers.toml):
- velox.local reachable (last seen 2 sec ago)
- bastion.local UNREACHABLE (ssh exit 255: connection refused)
-```
-
-### With `--enumerate-remote`
-
-```
-$ cross-agent-discover --enumerate-remote
-Local (ratio):
- ... (as above)
-
-velox.local (reachable):
- career, homelab [2 projects]
-```
-
-## Configuration
-
-Reads `~/.config/cross-agent-comms/peers.toml`:
-
-```toml
-# Each peer is a remote machine reachable via SSH (typically over Tailscale).
-
-[peers.velox]
-host = "velox.local"
-ssh_user = "cjennings"
-
-[peers.bastion]
-host = "bastion.local"
-ssh_user = "cjennings"
-```
-
-Peers entries describe machines, NOT projects. Projects are enumerated
-on-demand under `~/projects/*/.ai/` either locally or via SSH.
-
-## Cache
-
-Successful discovery results are cached at
-`~/.cache/cross-agent-comms/discovery.json` for 5 minutes. Repeated invocations
-within the window read from cache.
-
-`--no-cache` forces a fresh probe. Useful when adding a new peer or after a
-network change.
-
-## SSH reachability check
-
-For each peer, runs:
-
-```
-ssh -o ConnectTimeout=2 -o BatchMode=yes <user>@<host> true
-```
-
-`BatchMode=yes` prevents interactive password prompts — peers that don't have
-key-based auth set up are reported as UNREACHABLE.
-
-If `--enumerate-remote` is set, on success runs:
-
-```
-ssh <user>@<host> 'ls -d ~/projects/*/.ai/ 2>/dev/null'
-```
-
-## Failure modes
-
-| Symptom | Likely cause | Fix |
-|---|---|---|
-| Peer reported UNREACHABLE | Tailscale not connected, SSH key not authorized, host firewalled | `tailscale status`; `ssh -v <peer>` to debug. |
-| Local list is empty | Glob misresolved, or `~/projects/` doesn't exist | Check `ls -d ~/projects/*/.ai/`. |
-| `--enumerate-remote` slow | Cold cache, slow tailnet, many peers | First run is slow, subsequent runs hit cache. Use `--peer <name>` to scope. |
-| Peer unexpectedly missing from output | Not in `peers.toml`, or `peers.toml` malformed | `cat ~/.config/cross-agent-comms/peers.toml` and validate. |
-
-## HALT awareness
-
-Checks `~/.config/cross-agent-comms/HALT` at start. If HALT exists, prints a
-prominent banner before normal output:
-
-```
-$ cross-agent-discover
-⚠ HALT ACTIVE — cross-agent comms paused
- Reason: <reason from HALT file body, if any>
- Resume with: cross-agent-resume
-
-(enumeration continues normally — HALT does not suppress visibility)
-
-Local (ratio):
- career, claude-templates, ...
-
-Peers:
- velox.local reachable
-```
-
-Discover is read-only. Like `cross-agent-status`, it always runs so the user
-keeps visibility into what destinations exist regardless of halt state. The
-banner makes the halt state impossible to miss.
-
-If the HALT file exists but is unreadable, print a warning banner and
-continue.
-
-See `cross-agent-halt.md` for the full halt mechanism.
-
-## Examples
-
-```bash
-# Common: see what's available
-cross-agent-discover
-
-# Force fresh probe after network change
-cross-agent-discover --no-cache
-
-# What's on velox specifically
-cross-agent-discover --peer velox --enumerate-remote
-
-# Pipe to grep
-cross-agent-discover --json | jq '.peers[] | select(.reachable)'
-```
-
-## See also
-
-- `cross-agent-send` — uses `peers.toml` for routing destinations.
-- `cross-agent-status` — local pending messages.
-- `cross-agent-comms.org` — protocol spec, `* Limitations` section
- explains the cross-machine model.
diff --git a/.ai/scripts/cross-agent-comms/cross-agent-halt b/.ai/scripts/cross-agent-comms/cross-agent-halt
deleted file mode 100755
index df25115..0000000
--- a/.ai/scripts/cross-agent-comms/cross-agent-halt
+++ /dev/null
@@ -1,134 +0,0 @@
-#!/usr/bin/env python3
-"""Failsafe halt for cross-agent comms.
-
-See cross-agent-halt.md. Touches ~/.config/cross-agent-comms/HALT and stops
-the cross-agent-watch systemd user service. With --tailnet, propagates the
-HALT file to every peer in peers.toml via SSH; reports per-peer status with
-non-zero exit on partial halt.
-
-Does NOT pkill in-flight scripts — they detect HALT on next iteration and
-stop themselves.
-"""
-
-from __future__ import annotations
-
-import argparse
-import subprocess
-import sys
-import tomllib
-from pathlib import Path
-
-CONFIG_DIR = Path.home() / ".config" / "cross-agent-comms"
-HALT_FILE = CONFIG_DIR / "HALT"
-PEERS_TOML = CONFIG_DIR / "peers.toml"
-
-EXIT_OK = 0
-EXIT_PARTIAL = 1
-
-
-def err(msg: str) -> None:
- print(msg, file=sys.stderr)
-
-
-def write_halt_file(reason: str) -> None:
- CONFIG_DIR.mkdir(parents=True, exist_ok=True)
- HALT_FILE.write_text((reason + "\n") if reason else "")
-
-
-def stop_watcher_service() -> None:
- """Best-effort stop of the systemd watcher service. Failures are logged but not fatal."""
- try:
- subprocess.run(
- ["systemctl", "--user", "stop", "cross-agent-watch.path"],
- capture_output=True, text=True, timeout=5,
- )
- except (FileNotFoundError, subprocess.TimeoutExpired):
- # Watcher service may not be installed — fine.
- pass
-
-
-def load_peers() -> dict:
- if not PEERS_TOML.exists():
- return {}
- try:
- return tomllib.loads(PEERS_TOML.read_text())
- except (tomllib.TOMLDecodeError, OSError) as e:
- err(f"cannot parse peers.toml: {e}")
- return {}
-
-
-def ssh_touch_halt(host: str, ssh_user: str | None, reason: str) -> tuple[bool, str]:
- target = f"{ssh_user}@{host}" if ssh_user else host
- # Build the remote command. Quote the reason carefully.
- remote_cmd = (
- f"mkdir -p ~/.config/cross-agent-comms && "
- f"printf %s {_sh_quote(reason)} > ~/.config/cross-agent-comms/HALT"
- )
- try:
- result = subprocess.run(
- ["ssh", "-o", "ConnectTimeout=3", "-o", "BatchMode=yes", target, remote_cmd],
- capture_output=True, text=True, timeout=10,
- )
- except (FileNotFoundError, subprocess.TimeoutExpired):
- return False, "ssh unavailable or timed out"
- if result.returncode == 0:
- return True, "HALT file written"
- return False, (result.stderr.strip().splitlines() or [f"exit {result.returncode}"])[-1]
-
-
-def _sh_quote(s: str) -> str:
- return "'" + s.replace("'", "'\"'\"'") + "'"
-
-
-def main() -> int:
- parser = argparse.ArgumentParser(description="Halt all cross-agent comms on this machine (and optionally tailnet).")
- parser.add_argument("reason", nargs="?", default="", help="Optional human-readable reason")
- parser.add_argument("--tailnet", action="store_true",
- help="Propagate HALT to every peer in peers.toml")
- args = parser.parse_args()
-
- # Local halt.
- write_halt_file(args.reason)
- stop_watcher_service()
- print("Halting locally ✓ (HALT file written)")
-
- if not args.tailnet:
- print()
- print(f"Halt active. Remove {HALT_FILE} or run cross-agent-resume to clear.")
- print("Agent polling will stop within ~5 min (one cadence cycle).")
- return EXIT_OK
-
- peers = load_peers().get("peers", {})
- if not peers:
- print()
- print("No peers configured in peers.toml — local-only halt complete.")
- return EXIT_OK
-
- print()
- successes = 1 # local already counted
- failures = []
- for name, cfg in sorted(peers.items()):
- host = cfg.get("host", name)
- ssh_user = cfg.get("ssh_user")
- ok, detail = ssh_touch_halt(host, ssh_user, args.reason)
- marker = "✓" if ok else "✗"
- print(f"Halting {host:<28} {marker} ({detail})")
- if ok:
- successes += 1
- else:
- failures.append(f"{name} ({host}): {detail}")
-
- print()
- total = len(peers) + 1
- if failures:
- print(f"PARTIAL HALT: {successes}/{total} machines halted.")
- for f in failures:
- print(f" - {f}")
- print("Resolve the failures or manually halt each machine.")
- return EXIT_PARTIAL
- print(f"Halt active across {total} machine(s).")
- return EXIT_OK
-
-
-if __name__ == "__main__":
- sys.exit(main())
diff --git a/.ai/scripts/cross-agent-comms/cross-agent-halt.md b/.ai/scripts/cross-agent-comms/cross-agent-halt.md
deleted file mode 100644
index b817fbc..0000000
--- a/.ai/scripts/cross-agent-comms/cross-agent-halt.md
+++ /dev/null
@@ -1,134 +0,0 @@
-# cross-agent-halt
-
-**Purpose.** Failsafe stop for all cross-agent activity on the local machine
-(or, with `--tailnet`, across all configured peers). Creates the HALT file
-that every component in the protocol checks; within one polling cadence
-(~5 min) all polling, sending, watching, and receiving stops.
-
-This is the user's emergency brake. Use when something is misbehaving and
-visiting individual sessions is too slow.
-
-## Usage
-
-```
-cross-agent-halt [reason] [--tailnet] [--no-stop-watcher]
-```
-
-### Positional argument
-
-| Position | Meaning | Example |
-|---|---|---|
-| 1 | Optional human-readable reason for the halt. Written into the HALT file's body. Helps future-you remember why you stopped things. | `"investigating runaway poll loop, 2026-04-27"` |
-
-### Flags
-
-| Flag | Default | Purpose |
-|---|---|---|
-| `--tailnet` | local only | Propagate halt to every peer in `peers.toml` via SSH over Tailscale. |
-| `--no-stop-watcher` | (stops watcher) | Skip stopping the `cross-agent-watch.path` systemd unit. Useful if the watcher is intentionally separate from comms (rare). |
-
-## Behavior
-
-### Local halt (default)
-
-1. Write the HALT file: `~/.config/cross-agent-comms/HALT`. If a `[reason]` was
- passed, write it as the file's body. Otherwise the file is empty (existence
- alone triggers halt).
-2. Stop the watcher service: `systemctl --user stop cross-agent-watch.path`
- (and the corresponding `.service` if running).
-3. Print a summary:
- ```
- ✓ HALT file written: ~/.config/cross-agent-comms/HALT
- ✓ Watcher service stopped (cross-agent-watch.path)
- - In-flight sends will complete their current rsync step (~seconds), then
- stop. New sends are blocked.
- - Active agent polling sessions stop within one cadence (~5 min).
- - Use `cross-agent-resume` to clear HALT.
- Per-session polling does NOT auto-resume — you re-engage each session by
- telling its agent to resume polling.
- ```
-4. Exit 0.
-
-### Cross-tailnet halt (`--tailnet`)
-
-1. Apply local halt steps 1-2 first.
-2. Read `peers.toml` for the list of remote machines.
-3. For each peer, SSH and write the HALT file:
- ```
- ssh <user>@<host> "echo '<reason>' > ~/.config/cross-agent-comms/HALT && \
- systemctl --user stop cross-agent-watch.path"
- ```
-4. Track per-peer success/failure. Print results:
- ```
- Halting velox.local ✓ (HALT file written)
- Halting bastion.local ✗ (ssh exit 255: no route to host)
- Halting locally ✓ (HALT file written)
-
- PARTIAL HALT: 2/3 machines halted. bastion.local needs manual halt.
- ```
-5. Exit 0 if all peers halted; exit 1 if any peer failed (so scripts can
- detect partial halt). The local halt always succeeds — even on `--tailnet`,
- if remote peers fail, local is still halted.
-
-## What "halt active" means for each component
-
-| Component | Behavior under HALT |
-|---|---|
-| `cross-agent-send` | Refuses to send. Exits 5 with "halt active; remove ~/.config/cross-agent-comms/HALT to resume." Checks HALT at start AND between each retry/rsync step, so an in-flight send completes its current step then stops. |
-| `cross-agent-recv` | Refuses to verify or dedup. Exits 5 with same message. Inbound files are **left in place** — not moved, not rejected — so resume picks them up cleanly via cold-start. |
-| `cross-agent-watch` | Continues running but suppresses notifications. Logs each event with `(suppressed by HALT)` so the operator can see what would have fired. |
-| `cross-agent-status` | Prints prominent `⚠ HALT ACTIVE` banner before normal output. Continues to enumerate (read-only). |
-| `cross-agent-discover` | Same banner. Continues (read-only). |
-| Agent polling loops | Check HALT on every wake. If set: write a final `progress` note to any active conversation ("HALT fired locally; pausing"), surface "(HALT active; cross-agent comms paused)" in every user response, and stop rescheduling. Polling decays naturally within one cadence. |
-| Conversation initiator | Refuses to write sequence 1 of any new conversation. Surfaces refusal to user. |
-| Startup workflow (Phase A) | Checks HALT at session boot. If set, surfaces immediately and skips cross-agent inbox checks. |
-
-## Failure modes
-
-| Symptom | Cause | Fix |
-|---|---|---|
-| `~/.config/cross-agent-comms/HALT` already exists | Halt was already active | OK — running halt again refreshes the reason text. Safe. |
-| `systemctl --user stop` fails | Watcher service not installed, or systemd not available | The HALT file is still written — components that check HALT will still stop. The systemctl failure surfaces as a non-fatal warning. |
-| `--tailnet` halts some peers but not others | One or more peers unreachable | Exit 1 with per-peer status. Manually halt the unreachable peers (visit each machine, `touch ~/.config/cross-agent-comms/HALT`), or fix the network and re-run. |
-| Permission denied writing the HALT file | `~/.config/cross-agent-comms/` doesn't exist or is owned by another user | `mkdir -p ~/.config/cross-agent-comms/`; check ownership. |
-
-## What halt does NOT do
-
-- Does not kill running Claude sessions. Polling stops within ~5 min, but the
- session itself stays alive and can be re-engaged after resume.
-- Does not delete pending messages. Inbound files in `inbox/from-agents/`
- remain; they get processed when polling resumes.
-- Does not abort in-flight rsync push mid-byte. Atomic-write semantics
- guarantee in-flight messages either complete cleanly or leave only `.tmp.*`
- files (which receivers ignore).
-
-## Examples
-
-```bash
-# Quick halt with no reason
-cross-agent-halt
-
-# Halt with a memo
-cross-agent-halt "runaway poll loop in homelab session, debugging"
-
-# Halt all tailnet peers + local
-cross-agent-halt --tailnet "shutting down for system update"
-
-# Halt protocol comms but leave the watcher service running
-cross-agent-halt --no-stop-watcher
-```
-
-## Recovery
-
-Always pair with `cross-agent-resume` when the situation is resolved:
-
-```bash
-cross-agent-resume # local
-cross-agent-resume --tailnet # all peers
-```
-
-## See also
-
-- `cross-agent-resume` — counterpart that clears HALT.
-- `cross-agent-status` — see HALT state at a glance.
-- `cross-agent-comms.org` — protocol spec, `* Halt mechanism` section.
diff --git a/.ai/scripts/cross-agent-comms/cross-agent-recv b/.ai/scripts/cross-agent-comms/cross-agent-recv
deleted file mode 100755
index b67533a..0000000
--- a/.ai/scripts/cross-agent-comms/cross-agent-recv
+++ /dev/null
@@ -1,250 +0,0 @@
-#!/usr/bin/env python3
-"""Cross-agent message receiver.
-
-See cross-agent-recv.md for the full contract. Reads one message file and
-emits a structured decision the agent acts on:
-
- process | dedup | query | reject
-
-Decision exit codes:
- 0 = process 1 = dedup 2 = query 3 = reject
-
-When HALT is set, the script refuses to verify or dedup and leaves the
-inbound file in place — resume picks it up via cold-start.
-"""
-
-from __future__ import annotations
-
-import argparse
-import hashlib
-import json
-import re
-import shutil
-import subprocess
-import sys
-from pathlib import Path
-
-CONFIG_DIR = Path.home() / ".config" / "cross-agent-comms"
-HALT_FILE = CONFIG_DIR / "HALT"
-EXPECTED_PROTOCOL_VERSION = "5"
-
-REQUIRED_FRONTMATTER = ["TITLE", "CONVERSATION_ID", "MESSAGE_TYPE", "SEQUENCE", "TIMESTAMP", "PROTOCOL_VERSION"]
-VALID_MESSAGE_TYPES = {"request", "progress", "query", "pushback", "complete", "release", "escalate"}
-
-DEC_PROCESS = "process"
-DEC_DEDUP = "dedup"
-DEC_QUERY = "query"
-DEC_REJECT = "reject"
-
-EXIT_FOR_DECISION = {
- DEC_PROCESS: 0,
- DEC_DEDUP: 1,
- DEC_QUERY: 2,
- DEC_REJECT: 3,
-}
-
-EXIT_HALT = 5
-
-
-def err(msg: str) -> None:
- print(msg, file=sys.stderr)
-
-
-def check_halt() -> None:
- if HALT_FILE.exists():
- try:
- reason = HALT_FILE.read_text().strip()
- except OSError:
- err("halt active (HALT file present but unreadable; treated as halted)")
- sys.exit(EXIT_HALT)
- msg = "halt active; leaving inbound message in place (resume will pick up)"
- if reason:
- msg = f"{msg}: {reason}"
- err(msg)
- sys.exit(EXIT_HALT)
-
-
-def parse_frontmatter(path: Path) -> dict[str, str]:
- try:
- text = path.read_text()
- except OSError as e:
- return {"_parse_error": f"cannot read: {e}"}
- fm: dict[str, str] = {}
- for line in text.splitlines():
- line = line.rstrip()
- if not line:
- if fm:
- break
- continue
- m = re.match(r"#\+([A-Z_]+):\s*(.*)", line)
- if m:
- fm[m.group(1)] = m.group(2).strip()
- elif fm:
- break
- return fm
-
-
-def emit_decision(
- decision: str,
- reason: str | None,
- fm: dict[str, str],
- sha256: str | None,
- args: argparse.Namespace,
-) -> int:
- payload = {
- "decision": decision,
- "reason": reason,
- "message_type": fm.get("MESSAGE_TYPE"),
- "conversation_id": fm.get("CONVERSATION_ID"),
- "sequence": fm.get("SEQUENCE"),
- "timestamp": fm.get("TIMESTAMP"),
- "sha256": sha256,
- }
- if args.json:
- print(json.dumps(payload, indent=None if args.compact_json else 2))
- else:
- print(f"decision: {decision}")
- if reason:
- print(f"reason: {reason}")
- for k in ("message_type", "conversation_id", "sequence", "timestamp"):
- v = payload[k]
- if v is not None:
- print(f"{k}: {v}")
- if sha256:
- print(f"sha256: {sha256}")
- return EXIT_FOR_DECISION[decision]
-
-
-def gpg_verify(message_path: Path, sig_path: Path) -> tuple[bool, str]:
- try:
- result = subprocess.run(
- ["gpg", "--verify", str(sig_path), str(message_path)],
- capture_output=True,
- text=True,
- )
- except FileNotFoundError:
- return False, "gpg not installed"
- if result.returncode == 0:
- return True, ""
- return False, result.stderr.strip().splitlines()[-1] if result.stderr.strip() else f"exit {result.returncode}"
-
-
-def sha256_of(path: Path) -> str:
- h = hashlib.sha256()
- with path.open("rb") as f:
- for chunk in iter(lambda: f.read(65536), b""):
- h.update(chunk)
- return h.hexdigest()
-
-
-def find_dedup_match(message_path: Path, fm: dict[str, str], my_hash: str) -> tuple[str, str | None]:
- """Scan the message's directory for same-CONVERSATION_ID/SEQUENCE files.
-
- Returns (decision, reason) — decision is DEC_DEDUP for an exact-hash match,
- or DEC_PROCESS when no match or hash differs (sequence collision is OK).
- """
- parent = message_path.parent
- conv_id = fm["CONVERSATION_ID"]
- sequence = fm["SEQUENCE"]
- for sibling in parent.iterdir():
- if sibling == message_path or not sibling.is_file() or sibling.suffix != ".org":
- continue
- sib_fm = parse_frontmatter(sibling)
- if sib_fm.get("CONVERSATION_ID") != conv_id or sib_fm.get("SEQUENCE") != sequence:
- continue
- # Same conv-id + same sequence — check hash.
- if sha256_of(sibling) == my_hash:
- return DEC_DEDUP, f"identical retry of {sibling.name}"
- return DEC_PROCESS, None
-
-
-def check_requires_tools(fm: dict[str, str]) -> tuple[bool, list[str]]:
- """REQUIRES_TOOLS is a comma-separated list of tool names.
-
- For v5, "tool available" is a heuristic: an executable on PATH whose name
- matches the tool slug. MCP availability is currently out of scope (no
- portable way to query it from a CLI).
- """
- tools_field = fm.get("REQUIRES_TOOLS")
- if not tools_field:
- return True, []
- tools = [t.strip() for t in tools_field.split(",") if t.strip()]
- missing = [t for t in tools if shutil.which(t) is None]
- return len(missing) == 0, missing
-
-
-def main() -> int:
- parser = argparse.ArgumentParser(description="Receive and decide on a cross-agent message.")
- parser.add_argument("message_file", type=Path)
- parser.add_argument("--no-verify", action="store_true", help="Skip GPG verification (testing only)")
- parser.add_argument("--no-dedup", action="store_true", help="Skip SHA-256 dedup against existing files")
- parser.add_argument("--protocol-version", default=EXPECTED_PROTOCOL_VERSION,
- help="Override expected protocol version (default: 5)")
- parser.add_argument("--json", action="store_true", help="Emit JSON output")
- parser.add_argument("--compact-json", action="store_true", help="Compact JSON (no indent)")
- args = parser.parse_args()
-
- check_halt()
-
- if not args.message_file.is_file():
- err(f"message file not found: {args.message_file}")
- return EXIT_FOR_DECISION[DEC_REJECT]
-
- fm = parse_frontmatter(args.message_file)
- if "_parse_error" in fm:
- return emit_decision(DEC_REJECT, fm["_parse_error"], {}, None, args)
-
- # Step 1: frontmatter sanity-check.
- missing = [k for k in REQUIRED_FRONTMATTER if k not in fm]
- if missing:
- return emit_decision(
- DEC_REJECT, f"frontmatter missing required fields: {', '.join(missing)}", fm, None, args
- )
- if fm["MESSAGE_TYPE"] not in VALID_MESSAGE_TYPES:
- return emit_decision(
- DEC_REJECT, f"invalid MESSAGE_TYPE: {fm['MESSAGE_TYPE']!r}", fm, None, args
- )
-
- # Step 2: PROTOCOL_VERSION check.
- if fm["PROTOCOL_VERSION"] != args.protocol_version:
- return emit_decision(
- DEC_QUERY,
- f"PROTOCOL_VERSION mismatch: expected {args.protocol_version}, got {fm['PROTOCOL_VERSION']}",
- fm,
- None,
- args,
- )
-
- # Step 3: GPG verify.
- if not args.no_verify:
- sig_path = args.message_file.with_suffix(args.message_file.suffix + ".asc")
- if not sig_path.is_file():
- return emit_decision(DEC_REJECT, f"signature file missing: {sig_path.name}", fm, None, args)
- ok, gpg_err = gpg_verify(args.message_file, sig_path)
- if not ok:
- return emit_decision(DEC_REJECT, f"gpg verify failed: {gpg_err}", fm, None, args)
-
- # Step 4: SHA-256 dedup.
- my_hash = sha256_of(args.message_file)
- if not args.no_dedup:
- decision, reason = find_dedup_match(args.message_file, fm, my_hash)
- if decision == DEC_DEDUP:
- return emit_decision(DEC_DEDUP, reason, fm, my_hash, args)
-
- # Step 5: REQUIRES_TOOLS check.
- ok, missing_tools = check_requires_tools(fm)
- if not ok:
- return emit_decision(
- DEC_QUERY,
- f"required tools unavailable: {', '.join(missing_tools)}",
- fm,
- my_hash,
- args,
- )
-
- # Step 6: process.
- return emit_decision(DEC_PROCESS, None, fm, my_hash, args)
-
-
-if __name__ == "__main__":
- sys.exit(main())
diff --git a/.ai/scripts/cross-agent-comms/cross-agent-recv.md b/.ai/scripts/cross-agent-comms/cross-agent-recv.md
deleted file mode 100644
index 247a27a..0000000
--- a/.ai/scripts/cross-agent-comms/cross-agent-recv.md
+++ /dev/null
@@ -1,218 +0,0 @@
-# cross-agent-recv
-
-**Purpose.** The canonical receiver-side processor. Reads a single incoming
-message file and reports a structured decision the agent acts on:
-process / dedup / query / reject.
-
-The script handles only mechanical checks (frontmatter, signature, dedup,
-version, tools). Substance-level decisions like `pushback` ("I disagree with
-this request") happen one layer up — after the agent reads the message body
-the script returns as `process`-able.
-
-This is the read-side counterpart to `cross-agent-send`. Together they are the
-two halves of the per-message contract. The agent's polling loop calls
-`cross-agent-recv` on every new file in `inbox/from-agents/` and dispatches on
-the decision.
-
-Without this script, every receiver implementation re-invents GPG verify +
-frontmatter sanity-check + SHA-256 dedup. With it, behavior is consistent
-across projects.
-
-## Usage
-
-```
-cross-agent-recv <message-file>
-```
-
-Single positional argument: a `.org` file in `inbox/from-agents/`. The matching
-`.asc` signature file must be present alongside it.
-
-### Flags
-
-| Flag | Default | Purpose |
-|---|---|---|
-| `--no-verify` | (verify on) | Skip GPG verification. Testing only. |
-| `--no-dedup` | (dedup on) | Skip SHA-256 dedup against existing files. Testing only. |
-| `--protocol-version <N>` | 5 | Override the expected protocol version. Useful for testing forward-compatibility checks. |
-| `--json` | off | Output decision as JSON for easier parsing by the agent. |
-
-## Behavior
-
-Runs the receiver checks in order. First failure determines the decision.
-
-### Step 1 — Frontmatter sanity-check
-
-Parse the message's org-mode frontmatter. Required fields:
-
-- `#+TITLE`
-- `#+CONVERSATION_ID`
-- `#+MESSAGE_TYPE` (must be one of: `request`, `progress`, `query`, `pushback`,
- `complete`, `release`, `escalate`)
-- `#+SEQUENCE` (integer)
-- `#+TIMESTAMP` (ISO 8601 with explicit offset)
-- `#+PROTOCOL_VERSION` (must match the expected version; default 5)
-
-Any required field missing, malformed, or the protocol version mismatched →
-decision = `reject` (frontmatter) or `query` (version mismatch — see below).
-
-### Step 2 — Protocol-version check
-
-If `PROTOCOL_VERSION` doesn't match the expected:
-
-- Decision = `query`. Action: receiver should write a `query` reply asking the
- sender to upgrade to the expected protocol version.
-
-### Step 3 — Signature verification
-
-Look for `<message-file>.asc` alongside the `.org`. If missing or `gpg
---verify` fails:
-
-- Decision = `reject` (signature). Surface to user; do not act.
-
-The `.asc` file MUST be present when the `.org` is — `cross-agent-send`
-guarantees this with its strict ordering (`.asc` lands first). If the `.asc`
-is missing despite the `.org` being present, the sender violated atomic-write
-ordering or the file was tampered with in transit.
-
-### Step 4 — SHA-256 dedup
-
-Compute SHA-256 of the message file. Scan the same directory for existing
-files matching `CONVERSATION_ID + SEQUENCE`:
-
-- No match → decision = `process` (new message, dispatch by type).
-- Match with **identical** SHA-256 → decision = `dedup` (silent retry; do not
- reprocess).
-- Match with **different** SHA-256 → decision = `process` (sequence collision
- with non-identical content; both are legitimate, ordered by `#+TIMESTAMP`).
-
-### Step 5 — REQUIRES_TOOLS optional check
-
-If the message has a `#+REQUIRES_TOOLS` field, verify each named tool/MCP is
-available in the receiver's environment.
-
-- All available → `process`.
-- One or more missing → decision = `query`. The agent should write a `query`
- reply naming the missing tools, asking the sender to reframe the request to
- avoid them.
-
-### Step 6 — Dispatch decision
-
-If all checks pass, decision = `process` with the parsed `MESSAGE_TYPE` so the
-agent's main loop knows which handler to invoke.
-
-## Output
-
-### Default (human-readable)
-
-```
-$ cross-agent-recv inbox/from-agents/20260427T091015Z-from-homelab-prep-fixup.org
-decision: process
-message_type: request
-conversation_id: prep-fixup
-sequence: 6
-sha256: a1b2c3d4...
-```
-
-### `--json`
-
-```json
-{
- "decision": "process",
- "reason": null,
- "message_type": "request",
- "conversation_id": "prep-fixup",
- "sequence": 6,
- "timestamp": "2026-04-27T04:11:42-05:00",
- "sha256": "a1b2c3d4..."
-}
-```
-
-For decisions other than `process`, `reason` carries a human-readable
-explanation:
-
-```json
-{
- "decision": "query",
- "reason": "PROTOCOL_VERSION mismatch: expected 5, got 4",
- "conversation_id": "prep-fixup",
- "sequence": 6
-}
-```
-
-## Decision exit codes
-
-| Decision | Exit code | Agent action |
-|---|---|---|
-| `process` | 0 | Dispatch to the message-type handler |
-| `dedup` | 1 | Silent — do nothing further |
-| `query` | 2 | Write a `query` reply (see `reason` for what to ask) |
-| `reject` | 3 | Surface to user; do not auto-reply |
-
-The agent reads stdout/JSON to learn the decision; it can also key off exit
-code for simpler bash-style dispatching.
-
-## Failure modes
-
-| Symptom | Cause | Fix |
-|---|---|---|
-| `decision: reject (frontmatter)` | Required field missing or malformed | Open the message; fix or surface to user. The sender should not have produced this file. |
-| `decision: reject (signature)` | `.asc` missing, GPG verify failed, or signer unknown | Check that `.asc` exists alongside `.org`. If yes, run `gpg --verify <msg>.asc <msg>` manually for diagnostic output. |
-| `decision: query (PROTOCOL_VERSION)` | Sender on older/newer protocol | Reply with a `query` asking sender to upgrade. Both sides should align before continuing. |
-| `decision: query (REQUIRES_TOOLS)` | Receiver lacks one of the named tools | Reply with a `query` naming the missing tools; sender should reframe to avoid. |
-| `decision: dedup` | Already-processed identical retry | No action. The script handled it correctly. |
-
-## HALT awareness
-
-Checks `~/.config/cross-agent-comms/HALT` at the start of every invocation. If
-HALT exists, exits with code 5 ("halt active; remove
-~/.config/cross-agent-comms/HALT to resume") without verifying, deduping, or
-returning a decision.
-
-**The inbound file is left in place** — not moved, not rejected, not
-deduped. When HALT clears and polling resumes, the file gets picked up via
-the normal cold-start handling (whichever surfaces first: watcher
-notification, startup workflow check, or the next agent poll). Reversibility
-is preserved.
-
-If the HALT file exists but is unreadable, fail-closed — treat as if HALT is
-set.
-
-See `cross-agent-halt.md` for the full halt mechanism.
-
-## Examples
-
-```bash
-# Basic invocation in an agent's polling loop
-for msg in inbox/from-agents/*.org; do
- decision=$(cross-agent-recv --json "$msg")
- case "$(echo "$decision" | jq -r '.decision')" in
- process) handle_message "$msg" ;;
- dedup) ;; # silent
- query) write_query_reply "$msg" "$decision" ;;
- reject) surface_to_user "$msg" "$decision" ;;
- esac
-done
-
-# Test signature verification only
-cross-agent-recv --no-dedup inbox/from-agents/test-msg.org
-
-# Test against a future protocol version
-cross-agent-recv --protocol-version 6 inbox/from-agents/future-msg.org
-```
-
-## Performance
-
-The script is fast (single SHA-256 compute, single GPG verify, frontmatter
-parse). For typical messages (single-digit KB), runs in well under 100ms.
-Dedup-scan is O(N) over files in the directory; if a project's
-`inbox/from-agents/` accumulates hundreds of files, archive released
-conversations to keep the scan fast.
-
-## See also
-
-- `cross-agent-send` — counterpart writer.
-- `cross-agent-watch` — fires when a new message arrives; agent then calls
- `cross-agent-recv` to process it.
-- `cross-agent-status` — pending-message snapshot (uses similar
- released-vs-unreleased logic, but doesn't process individual messages).
-- `cross-agent-comms.org` — protocol spec, the "what" the script implements.
diff --git a/.ai/scripts/cross-agent-comms/cross-agent-resume b/.ai/scripts/cross-agent-comms/cross-agent-resume
deleted file mode 100755
index 1fb83bc..0000000
--- a/.ai/scripts/cross-agent-comms/cross-agent-resume
+++ /dev/null
@@ -1,145 +0,0 @@
-#!/usr/bin/env python3
-"""Resume cross-agent comms after a halt.
-
-See cross-agent-resume.md. Removes ~/.config/cross-agent-comms/HALT and
-restarts the cross-agent-watch systemd user service. With --tailnet,
-propagates the removal to every peer in peers.toml via SSH; reports
-per-peer status with non-zero exit on partial resume.
-
-Per the asymmetry rule: clearing HALT does NOT auto-resume agent polling.
-Each session must explicitly re-engage.
-"""
-
-from __future__ import annotations
-
-import argparse
-import subprocess
-import sys
-import tomllib
-from pathlib import Path
-
-CONFIG_DIR = Path.home() / ".config" / "cross-agent-comms"
-HALT_FILE = CONFIG_DIR / "HALT"
-PEERS_TOML = CONFIG_DIR / "peers.toml"
-
-EXIT_OK = 0
-EXIT_PARTIAL = 1
-
-
-def err(msg: str) -> None:
- print(msg, file=sys.stderr)
-
-
-def remove_halt_file() -> bool:
- """Returns True if HALT was removed, False if it didn't exist."""
- if HALT_FILE.exists():
- try:
- HALT_FILE.unlink()
- return True
- except OSError as e:
- err(f"could not remove HALT: {e}")
- return False
- return False
-
-
-def start_watcher_service() -> None:
- """Best-effort start of the systemd watcher path unit."""
- try:
- subprocess.run(
- ["systemctl", "--user", "start", "cross-agent-watch.path"],
- capture_output=True, text=True, timeout=5,
- )
- except (FileNotFoundError, subprocess.TimeoutExpired):
- pass
-
-
-def load_peers() -> dict:
- if not PEERS_TOML.exists():
- return {}
- try:
- return tomllib.loads(PEERS_TOML.read_text())
- except (tomllib.TOMLDecodeError, OSError) as e:
- err(f"cannot parse peers.toml: {e}")
- return {}
-
-
-def ssh_remove_halt(host: str, ssh_user: str | None) -> tuple[bool, str]:
- target = f"{ssh_user}@{host}" if ssh_user else host
- remote_cmd = "rm -f ~/.config/cross-agent-comms/HALT"
- try:
- result = subprocess.run(
- ["ssh", "-o", "ConnectTimeout=3", "-o", "BatchMode=yes", target, remote_cmd],
- capture_output=True, text=True, timeout=10,
- )
- except (FileNotFoundError, subprocess.TimeoutExpired):
- return False, "ssh unavailable or timed out"
- if result.returncode == 0:
- return True, "HALT cleared"
- return False, (result.stderr.strip().splitlines() or [f"exit {result.returncode}"])[-1]
-
-
-def print_re_engage_instructions() -> None:
- print()
- print("Halt cleared. Watcher restarted.")
- print()
- print("Agent polling does NOT auto-resume — per the failsafe asymmetry rule,")
- print("agents stay paused until you explicitly re-engage each session.")
- print("Open the relevant Claude session and tell the agent to resume polling")
- print("for its conversation.")
-
-
-def main() -> int:
- parser = argparse.ArgumentParser(description="Resume cross-agent comms after a halt.")
- parser.add_argument("--tailnet", action="store_true",
- help="Propagate HALT removal to every peer in peers.toml")
- args = parser.parse_args()
-
- removed = remove_halt_file()
- start_watcher_service()
- if removed:
- print("Resuming locally ✓ (HALT cleared)")
- else:
- print("Resuming locally ✓ (no HALT was active)")
-
- if not args.tailnet:
- print_re_engage_instructions()
- return EXIT_OK
-
- peers = load_peers().get("peers", {})
- if not peers:
- print()
- print("No peers configured in peers.toml — local-only resume complete.")
- print_re_engage_instructions()
- return EXIT_OK
-
- print()
- successes = 1
- failures = []
- for name, cfg in sorted(peers.items()):
- host = cfg.get("host", name)
- ssh_user = cfg.get("ssh_user")
- ok, detail = ssh_remove_halt(host, ssh_user)
- marker = "✓" if ok else "✗"
- print(f"Resuming {host:<27} {marker} ({detail})")
- if ok:
- successes += 1
- else:
- failures.append(f"{name} ({host}): {detail}")
-
- print()
- total = len(peers) + 1
- if failures:
- print(f"PARTIAL RESUME: {successes}/{total} machines cleared.")
- for f in failures:
- print(f" - {f}")
- print("Resolve the failures or manually clear HALT on each machine.")
- print_re_engage_instructions()
- return EXIT_PARTIAL
-
- print(f"Resume complete across {total} machine(s).")
- print_re_engage_instructions()
- return EXIT_OK
-
-
-if __name__ == "__main__":
- sys.exit(main())
diff --git a/.ai/scripts/cross-agent-comms/cross-agent-resume.md b/.ai/scripts/cross-agent-comms/cross-agent-resume.md
deleted file mode 100644
index 8aa8357..0000000
--- a/.ai/scripts/cross-agent-comms/cross-agent-resume.md
+++ /dev/null
@@ -1,117 +0,0 @@
-# cross-agent-resume
-
-**Purpose.** Clear the HALT file and restart the watcher service. Counterpart
-to `cross-agent-halt`. Resuming agent polling is **explicit per-session** —
-this script doesn't auto-revive halted polling loops; you tell each session
-to re-engage.
-
-## Usage
-
-```
-cross-agent-resume [--tailnet]
-```
-
-### Flags
-
-| Flag | Default | Purpose |
-|---|---|---|
-| `--tailnet` | local only | Clear HALT on every peer in `peers.toml` via SSH over Tailscale. |
-
-## Behavior
-
-### Local resume (default)
-
-1. Remove the HALT file: `rm -f ~/.config/cross-agent-comms/HALT`. (Use `-f`
- so a missing file isn't an error — running resume when not halted is safe.)
-2. Restart the watcher service: `systemctl --user start cross-agent-watch.path`.
-3. Print a summary:
- ```
- ✓ HALT file removed
- ✓ Watcher service started (cross-agent-watch.path)
- - cross-agent-send and cross-agent-recv will accept new operations.
- - Inbound messages held during halt will be picked up by the watcher.
- - Agent polling does NOT auto-resume. To re-engage polling in a paused
- session, open that Claude session and tell the agent to resume.
- ```
-4. Exit 0.
-
-### Cross-tailnet resume (`--tailnet`)
-
-1. Apply local resume steps 1-2 first.
-2. Read `peers.toml` for the list of remote machines.
-3. For each peer, SSH:
- ```
- ssh <user>@<host> "rm -f ~/.config/cross-agent-comms/HALT && \
- systemctl --user start cross-agent-watch.path"
- ```
-4. Track per-peer success/failure:
- ```
- Resuming velox.local ✓ (HALT cleared, watcher started)
- Resuming bastion.local ✗ (ssh exit 255: no route to host)
- Resuming locally ✓
-
- PARTIAL RESUME: 2/3 machines resumed. bastion.local still halted.
- ```
-5. Exit 0 if all peers resumed; exit 1 on any failure.
-
-## Why agent polling doesn't auto-resume
-
-Two reasons the asymmetry is deliberate:
-
-1. *Auto-resume could silently invert intentional kills.* If you halted
- because a session was misbehaving, removing HALT shouldn't quietly revive
- that session's polling. You re-engage explicitly so you're aware of which
- sessions came back online.
-
-2. *You may want to inspect before resuming.* After a halt, you might want to
- read pending messages, fix configuration, or kill a particular Claude
- session entirely. Per-session resume forces that pause.
-
-## Re-engaging polling in a Claude session
-
-After `cross-agent-resume`, open the relevant Claude session and say something
-like:
-
-```
-HALT is cleared; resume polling.
-```
-
-The agent will check the HALT file (now absent), re-create its polling
-schedule, and continue the in-flight conversation from wherever it left off.
-The conversation file is intact; the receiver will pick up any new messages
-that arrived during the halt window.
-
-## Failure modes
-
-| Symptom | Cause | Fix |
-|---|---|---|
-| HALT file doesn't exist | Already resumed (or never halted) | OK — `-f` makes this a no-op. |
-| `systemctl --user start` fails | Watcher service not installed | Install per `cross-agent-watch.md`'s systemd recipe. |
-| `--tailnet` resumes some peers but not others | Same as halt: peer unreachable | Per-peer status reported; resolve manually for unreachable peers. |
-| Permission denied removing HALT file | File owned by another user | Check ownership; HALT files should be owned by the running user. |
-
-## Examples
-
-```bash
-# Local resume after a halt
-cross-agent-resume
-
-# Resume all tailnet peers + local
-cross-agent-resume --tailnet
-```
-
-## Recovery flow
-
-After a halt:
-
-1. Investigate whatever caused the halt (runaway loop, bad config, etc.).
-2. Fix the underlying issue.
-3. Run `cross-agent-resume`.
-4. Open each Claude session that was polling and tell its agent to re-engage.
-5. Confirm operation with `cross-agent-status`.
-
-## See also
-
-- `cross-agent-halt` — counterpart that creates the HALT file.
-- `cross-agent-status` — verify HALT cleared and see pending messages.
-- `cross-agent-comms.org` — protocol spec, `* Halt mechanism` section.
diff --git a/.ai/scripts/cross-agent-comms/cross-agent-send b/.ai/scripts/cross-agent-comms/cross-agent-send
deleted file mode 100755
index 68c010a..0000000
--- a/.ai/scripts/cross-agent-comms/cross-agent-send
+++ /dev/null
@@ -1,356 +0,0 @@
-#!/usr/bin/env python3
-"""Cross-agent message sender.
-
-See cross-agent-send.md for the full contract. Briefly:
-
-- Destination as <machine>.<project>; resolved via peers.toml.
-- Same-machine: cp to receiver's inbox/from-agents/ with atomic rename.
-- Cross-machine: rsync over SSH (typically Tailscale) with retry+backoff.
-- GPG-signs by default; .asc renames before .org so receivers never see
- a .org without its sibling signature.
-- Generates the canonical filename; user's input filename is ignored.
-- Honors the HALT file: refuses to send and exits with code 5 when set.
-"""
-
-from __future__ import annotations
-
-import argparse
-import datetime as _dt
-import json
-import os
-import re
-import shutil
-import socket
-import subprocess
-import sys
-import tempfile
-import time
-import tomllib
-from pathlib import Path
-
-CONFIG_DIR = Path.home() / ".config" / "cross-agent-comms"
-PEERS_TOML = CONFIG_DIR / "peers.toml"
-HALT_FILE = CONFIG_DIR / "HALT"
-STATE_DIR = Path.home() / ".local" / "state" / "cross-agent-comms"
-FAILED_SENDS_DIR = STATE_DIR / "failed-sends"
-
-EXIT_OK = 0
-EXIT_GENERAL = 1
-EXIT_DEST_NOT_FOUND = 2
-EXIT_CROSS_MACHINE_FAILED = 3
-EXIT_FRONTMATTER = 4
-EXIT_HALT = 5
-
-REQUIRED_FRONTMATTER = ["CONVERSATION_ID", "MESSAGE_TYPE", "SEQUENCE", "TIMESTAMP", "PROTOCOL_VERSION"]
-VALID_MESSAGE_TYPES = {"request", "progress", "query", "pushback", "complete", "release", "escalate"}
-
-
-def err(msg: str) -> None:
- print(msg, file=sys.stderr)
-
-
-def check_halt() -> None:
- """Exit with code 5 if HALT file exists."""
- if HALT_FILE.exists():
- try:
- reason = HALT_FILE.read_text().strip()
- except OSError:
- # Fail-closed on unreadable HALT.
- err("halt active (HALT file present but unreadable; treated as halted)")
- err(f"remove {HALT_FILE} to resume")
- sys.exit(EXIT_HALT)
- msg = "halt active"
- if reason:
- msg += f": {reason}"
- err(msg)
- err(f"remove {HALT_FILE} to resume")
- sys.exit(EXIT_HALT)
-
-
-def parse_frontmatter(path: Path) -> dict[str, str]:
- """Extract org-mode #+KEY: value frontmatter from the top of the file."""
- try:
- text = path.read_text()
- except OSError as e:
- err(f"cannot read message file: {e}")
- sys.exit(EXIT_GENERAL)
-
- frontmatter: dict[str, str] = {}
- for line in text.splitlines():
- line = line.rstrip()
- if not line:
- # Blank line ends the frontmatter block.
- if frontmatter:
- break
- continue
- m = re.match(r"#\+([A-Z_]+):\s*(.*)", line)
- if m:
- frontmatter[m.group(1)] = m.group(2).strip()
- else:
- # First non-frontmatter line ends parsing.
- if frontmatter:
- break
- return frontmatter
-
-
-def validate_frontmatter(fm: dict[str, str]) -> None:
- missing = [k for k in REQUIRED_FRONTMATTER if k not in fm]
- if missing:
- err(f"frontmatter missing required fields: {', '.join(missing)}")
- sys.exit(EXIT_FRONTMATTER)
- if fm["MESSAGE_TYPE"] not in VALID_MESSAGE_TYPES:
- err(f"invalid MESSAGE_TYPE: {fm['MESSAGE_TYPE']!r}; expected one of {sorted(VALID_MESSAGE_TYPES)}")
- sys.exit(EXIT_FRONTMATTER)
- try:
- int(fm["SEQUENCE"])
- except ValueError:
- err(f"SEQUENCE must be an integer; got {fm['SEQUENCE']!r}")
- sys.exit(EXIT_FRONTMATTER)
-
-
-def load_peers() -> dict:
- if not PEERS_TOML.exists():
- return {}
- try:
- return tomllib.loads(PEERS_TOML.read_text())
- except (tomllib.TOMLDecodeError, OSError) as e:
- err(f"cannot read {PEERS_TOML}: {e}")
- sys.exit(EXIT_GENERAL)
-
-
-def resolve_destination(dest: str, peers: dict) -> tuple[str, str, str | None, str | None]:
- """Resolve <machine>.<project> to (machine, project, host, ssh_user).
-
- host is None for same-machine destinations.
- """
- if "." not in dest:
- err(f"destination must be <machine>.<project>; got {dest!r}")
- sys.exit(EXIT_DEST_NOT_FOUND)
- machine, project = dest.split(".", 1)
-
- local_hostname = socket.gethostname().split(".")[0]
- is_local = machine == local_hostname or machine == "local"
-
- host = None
- ssh_user = None
- if not is_local:
- peer_cfg = peers.get("peers", {}).get(machine)
- if peer_cfg is None:
- available = list(peers.get("peers", {}).keys())
- err(f"destination not found in peers.toml; available peers: {available or '(none)'}")
- sys.exit(EXIT_DEST_NOT_FOUND)
- host = peer_cfg.get("host", machine)
- ssh_user = peer_cfg.get("ssh_user", os.environ.get("USER"))
-
- return machine, project, host, ssh_user
-
-
-def resolve_inbox_path(project: str, peers: dict) -> str:
- """Inbox path on the receiver. Defaults to ~/projects/<project>/inbox/from-agents."""
- proj_cfg = peers.get("projects", {}).get(project)
- if proj_cfg and "inbox_path" in proj_cfg:
- return os.path.expanduser(proj_cfg["inbox_path"])
- return f"~/projects/{project}/inbox/from-agents"
-
-
-def derive_sender_project() -> str:
- """Walk up from CWD looking for ~/projects/<name>/.
-
- Returns the project name if found; falls back to the basename of CWD.
- """
- cwd = Path.cwd().resolve()
- projects_root = (Path.home() / "projects").resolve()
- try:
- rel = cwd.relative_to(projects_root)
- return rel.parts[0]
- except ValueError:
- return cwd.name
-
-
-def generate_canonical_filename(sender: str, conv_id: str) -> str:
- """YYYYMMDDTHHMMSSZ-from-<sender>-<conv-id>.org"""
- now = _dt.datetime.now(_dt.timezone.utc)
- timestamp = now.strftime("%Y%m%dT%H%M%SZ")
- return f"{timestamp}-from-{sender}-{conv_id}.org"
-
-
-def sign(message_path: Path, sig_path: Path, key: str | None) -> None:
- """gpg --detach-sign --armor --output <sig> [--local-user <key>] <message>"""
- cmd = ["gpg", "--detach-sign", "--armor", "--yes", "--output", str(sig_path)]
- if key:
- cmd.extend(["--local-user", key])
- cmd.append(str(message_path))
- try:
- result = subprocess.run(cmd, capture_output=True, text=True)
- except FileNotFoundError:
- err("gpg not found; install gnupg or use --no-sign for testing")
- sys.exit(EXIT_GENERAL)
- if result.returncode != 0:
- err(f"signing failed: {result.stderr.strip()}")
- sys.exit(EXIT_GENERAL)
-
-
-def same_machine_deliver(message_path: Path, sig_path: Path | None, target_dir: Path, canonical_name: str) -> None:
- """Atomic-write delivery: stage .asc, mv to final, then stage .org, mv to final."""
- target_dir.mkdir(parents=True, exist_ok=True)
- final_msg = target_dir / canonical_name
- final_sig = target_dir / f"{canonical_name}.asc"
-
- if sig_path is not None:
- # Stage .asc first, mv to final, THEN stage .org and mv to final.
- with tempfile.NamedTemporaryFile(
- mode="wb", dir=target_dir, prefix=f".tmp.{canonical_name}.asc.", delete=False
- ) as tmp:
- tmp.write(sig_path.read_bytes())
- tmp_sig_path = Path(tmp.name)
- os.replace(tmp_sig_path, final_sig)
-
- # Re-check HALT between .asc and .org per the layered-checks rule.
- check_halt()
-
- with tempfile.NamedTemporaryFile(
- mode="wb", dir=target_dir, prefix=f".tmp.{canonical_name}.", delete=False
- ) as tmp:
- tmp.write(message_path.read_bytes())
- tmp_msg_path = Path(tmp.name)
- os.replace(tmp_msg_path, final_msg)
-
-
-def cross_machine_deliver(
- message_path: Path,
- sig_path: Path | None,
- canonical_name: str,
- host: str,
- ssh_user: str,
- inbox_path: str,
- retries: int,
-) -> bool:
- """rsync push the .asc first (if signed), re-check HALT, then push the .org.
-
- Returns True on success, False on persistent failure (after retries).
- """
- # Stage local copies with the canonical name so rsync sets the right
- # destination filename.
- with tempfile.TemporaryDirectory(prefix="cross-agent-send-") as staging:
- staging_dir = Path(staging)
- local_msg = staging_dir / canonical_name
- local_msg.write_bytes(message_path.read_bytes())
- local_sig = None
- if sig_path is not None:
- local_sig = staging_dir / f"{canonical_name}.asc"
- local_sig.write_bytes(sig_path.read_bytes())
-
- backoffs = [5, 30, 120]
- # Step 1: push .asc first if signed.
- if local_sig is not None:
- if not _rsync_with_retries(local_sig, host, ssh_user, inbox_path, retries, backoffs):
- return False
-
- # Re-check HALT between .asc and .org per the layered-checks rule.
- check_halt()
-
- # Step 2: push .org.
- if not _rsync_with_retries(local_msg, host, ssh_user, inbox_path, retries, backoffs):
- return False
-
- return True
-
-
-def _rsync_with_retries(
- src: Path, host: str, ssh_user: str, inbox_path: str, retries: int, backoffs: list[int]
-) -> bool:
- target = f"{ssh_user}@{host}:{inbox_path}/"
- last_err = ""
- for attempt in range(retries + 1):
- if attempt > 0:
- check_halt()
- wait = backoffs[min(attempt - 1, len(backoffs) - 1)]
- err(f"rsync attempt {attempt} failed: {last_err}; retrying in {wait}s")
- time.sleep(wait)
- try:
- result = subprocess.run(
- ["rsync", "-a", str(src), target],
- capture_output=True,
- text=True,
- )
- except FileNotFoundError:
- err("rsync not found; install rsync")
- return False
- if result.returncode == 0:
- return True
- last_err = result.stderr.strip() or f"exit {result.returncode}"
- err(f"rsync failed after {retries + 1} attempts: {last_err}")
- return False
-
-
-def write_failed_send_marker(dest: str, message_path: Path, error: str, retry_log: list[str]) -> None:
- FAILED_SENDS_DIR.mkdir(parents=True, exist_ok=True)
- timestamp = _dt.datetime.now(_dt.timezone.utc).strftime("%Y%m%dT%H%M%SZ")
- safe_basename = re.sub(r"[^A-Za-z0-9._-]", "_", message_path.name)
- marker = FAILED_SENDS_DIR / f"{timestamp}-{dest.replace('.', '-')}-{safe_basename}.json"
- marker.write_text(json.dumps(
- {
- "timestamp": timestamp,
- "destination": dest,
- "message_path": str(message_path),
- "error": error,
- "retry_log": retry_log,
- },
- indent=2,
- ))
- err(f"marker written: {marker}")
-
-
-def main() -> int:
- parser = argparse.ArgumentParser(description="Send a cross-agent message.")
- parser.add_argument("destination", help="Destination as <machine>.<project>")
- parser.add_argument("message_file", type=Path, help="Path to the message body file")
- parser.add_argument("--no-sign", action="store_true", help="Skip GPG signing (testing only)")
- parser.add_argument("--retries", type=int, default=3, help="Retry count for cross-machine sends")
- parser.add_argument("--key", help="GPG key id to sign with (default: user's primary)")
- args = parser.parse_args()
-
- check_halt()
-
- if not args.message_file.is_file():
- err(f"message file not found: {args.message_file}")
- return EXIT_GENERAL
-
- fm = parse_frontmatter(args.message_file)
- validate_frontmatter(fm)
-
- peers = load_peers()
- machine, project, host, ssh_user = resolve_destination(args.destination, peers)
- inbox_path = resolve_inbox_path(project, peers)
-
- sender = derive_sender_project()
- canonical_name = generate_canonical_filename(sender, fm["CONVERSATION_ID"])
-
- sig_tmp = None
- if not args.no_sign:
- sig_tmp = args.message_file.with_suffix(args.message_file.suffix + ".asc.tmp")
- sign(args.message_file, sig_tmp, args.key)
-
- try:
- if host is None:
- # Same-machine delivery.
- target_dir = Path(os.path.expanduser(inbox_path))
- same_machine_deliver(args.message_file, sig_tmp, target_dir, canonical_name)
- print(f"sent: {target_dir}/{canonical_name}")
- return EXIT_OK
- else:
- ok = cross_machine_deliver(
- args.message_file, sig_tmp, canonical_name, host, ssh_user, inbox_path, args.retries
- )
- if ok:
- print(f"sent: {ssh_user}@{host}:{inbox_path}/{canonical_name}")
- return EXIT_OK
- write_failed_send_marker(args.destination, args.message_file, "rsync failed after retries", [])
- return EXIT_CROSS_MACHINE_FAILED
- finally:
- if sig_tmp is not None and sig_tmp.exists():
- sig_tmp.unlink()
-
-
-if __name__ == "__main__":
- sys.exit(main())
diff --git a/.ai/scripts/cross-agent-comms/cross-agent-send.md b/.ai/scripts/cross-agent-comms/cross-agent-send.md
deleted file mode 100644
index 29bfb24..0000000
--- a/.ai/scripts/cross-agent-comms/cross-agent-send.md
+++ /dev/null
@@ -1,199 +0,0 @@
-# cross-agent-send
-
-**Purpose.** Send a cross-agent message file to a specific destination. Handles
-peer-config lookup, GPG signing, atomic write (same-machine) or rsync push
-(cross-machine), retry-with-backoff, and failure surfacing.
-
-This is the canonical writer. The protocol spec defers all writer mechanics to
-this script.
-
-## Usage
-
-```
-cross-agent-send <destination> <message-file> [--no-sign] [--retries N]
-```
-
-### Positional arguments
-
-| Position | Meaning | Example |
-|---|---|---|
-| 1 | Destination as `<machine>.<project>` | `homelab.career`, `velox.career` |
-| 2 | Message file (already-formatted `.org`) | `/tmp/my-message.org` |
-
-### Flags
-
-| Flag | Default | Purpose |
-|---|---|---|
-| `--no-sign` | (signing on) | Skip GPG signing. Use only for testing; receivers reject unsigned messages by default. |
-| `--retries N` | 3 | Override retry count for cross-machine sends. |
-| `--key <key-id>` | (user's primary key) | GPG key to sign with. Resolution order: `--key` flag, `GPG_USER` env, `git config user.signingkey`, then the first secret key in the keyring. |
-
-## Behavior
-
-### Filename generation (script-controlled)
-
-The script generates the canonical destination filename from the message's
-frontmatter and sender context. The user's input filename is ignored — pass any
-path, the script names the destination correctly:
-
-```
-<UTC-now>T<HHMMSS>Z-from-<sender-slug>-<short-conv-id>.org
-```
-
-`<sender-slug>` comes from the sender machine's project name (config or
-hostname-based). `<short-conv-id>` is read from the message's
-`#+CONVERSATION_ID` frontmatter field. UTC timestamp is generated at send time.
-
-The script also performs the **sender-side max-seen scan** before writing: it
-reads the receiver's `from-agents/` directory, finds the highest existing
-sequence in this conversation across both sender prefixes, and (best-effort)
-suggests `max(seen) + 1` for the next sequence. The user/agent is responsible
-for setting `#+SEQUENCE` in the message body; the script only advises.
-
-### Same-machine destinations
-
-Resolved when the destination's machine matches the current hostname (or is
-not in `peers.toml` as a remote). Steps:
-
-1. Parse frontmatter; extract `CONVERSATION_ID` and `TIMESTAMP`. Validate per
- the *Validation before send* section below.
-2. Generate canonical filename per *Filename generation* above.
-3. Sign: `gpg --detach-sign --armor --output <canonical>.asc --local-user <key> <input>`.
-4. Compute target: read `peers.toml` for the project's `inbox_path`. If
- missing, fall back to `~/projects/<project>/inbox/from-agents/`.
-5. **Atomic write with strict ordering** (signature must precede message):
- - Stage `.asc`: write to `<target>/.tmp.XXXXXX-<canonical>.asc`,
- then `mv` to `<target>/<canonical>.asc`.
- - **Then** stage `.org`: write to `<target>/.tmp.XXXXXX-<canonical>`,
- then `mv` to `<target>/<canonical>`.
- - Receivers only act on `.org` files; staging the `.asc` first guarantees
- the signature is present when the receiver opens the message. Out-of-order
- would race: receiver could read the `.org` before the `.asc` lands and
- fail GPG verify even though the sender did everything right.
-6. Exit 0 on success. Exit non-zero if any step fails.
-
-### Cross-machine destinations
-
-Steps:
-
-1. Parse + generate canonical filename, as same-machine steps 1-2.
-2. Sign locally to `<input>.asc` (or a tmp staging file).
-3. rsync push **with the same .asc-first ordering**:
- - `rsync -a <input>.asc <ssh-user>@<host>:<inbox_path>/<canonical>.asc`
- - **Then** `rsync -a <input> <ssh-user>@<host>:<inbox_path>/<canonical>`
- rsync writes to a hidden temp file then renames atomically by default
- (`--inplace` would defeat this; do not pass it).
-4. Retry on failure: 5s, 30s, 120s backoff, then surface error.
-5. On persistent failure: write a marker file to
- `~/.local/state/cross-agent-comms/failed-sends/<timestamp>-<dest>-<canonical>.json`
- containing the destination, message path, error, and retry log. Exit non-zero.
-
-### Validation before send
-
-- Destination resolves via `peers.toml` (or local fallback). If neither, exit
- immediately with `destination not found in peers.toml; available: <list>`.
-- Message file must be readable, non-empty, and have valid org-mode frontmatter
- with **all** of the following required fields:
- - `#+TITLE`
- - `#+CONVERSATION_ID`
- - `#+MESSAGE_TYPE`
- - `#+SEQUENCE`
- - `#+TIMESTAMP`
- - `#+PROTOCOL_VERSION` (must equal `5` for v5)
-
- If any required field is missing or malformed, exit immediately with a parse
- error naming the offending field.
-
-- Optional fields the script recognizes and passes through (no special
- handling beyond preservation):
- - `#+REQUIRES_TOOLS` — comma-separated tool/MCP slugs the receiver needs.
- - `#+RELEASE_STATUS` — valid only on `MESSAGE_TYPE: release`. Values per
- spec: `complete`, `cancelled`, `withdrawn-after-pushback`,
- `abandoned-after-escalation`.
- - `#+WORKFLOW_VERSION` — sender's version of the cross-agent-comms workflow
- file. Currently advisory; receiver may warn on mismatch but does not block.
-
-## Configuration
-
-Reads `~/.config/cross-agent-comms/peers.toml` for peer routing:
-
-```toml
-[peers.velox]
-host = "velox.local"
-ssh_user = "cjennings"
-
-# Optional: per-project inbox-path overrides for non-default layouts.
-[projects.work]
-inbox_path = "~/projects/work/inbox/from-agents"
-
-[projects.homelab]
-inbox_path = "~/projects/homelab/inbox/from-agents"
-```
-
-If a project entry is omitted, defaults to `~/projects/<project>/inbox/from-agents`.
-
-## Failure modes
-
-| Symptom | Cause | Fix |
-|---|---|---|
-| `destination not found in peers.toml` | Misspelled destination, or peer not configured | Run `cross-agent-discover` to see available destinations. |
-| `signing failed: no secret key` | GPG key missing or not in keyring | `gpg --list-secret-keys` to confirm. Override with `--key <id>`. |
-| `signing failed: pinentry timed out` | Headless session, GUI pinentry unavailable | Confirm `pinentry-program` in `gpg-agent.conf` matches available pinentry. Per protocols.org, GUI pinentry works from Claude Code. |
-| `rsync exit 255` | SSH unreachable | `cross-agent-discover --peer <name>` to confirm reachability. |
-| `rsync exit 23` | Permission denied at destination | Check destination directory perms (`chmod 700`) and ownership. |
-| Marker file written to `failed-sends/` | Persistent cross-machine failure | Inspect the marker's `error` field. After fixing, retry: `cross-agent-send <dest> <msg>` (the marker is for visibility; it does not auto-retry). |
-| Receiver complains "unsigned message" | `--no-sign` was used in production | Don't use `--no-sign` outside testing. |
-
-## HALT awareness
-
-Checks `~/.config/cross-agent-comms/HALT` at the start of every send AND
-between the `.asc` and `.org` rsync calls AND between each retry iteration.
-On HALT exists, exits with code 5 ("halt active; remove
-~/.config/cross-agent-comms/HALT to resume") without writing or pushing
-further.
-
-Worst case: one in-flight send completes its current rsync step within a few
-seconds before halt kicks in for the next step. New sends are blocked
-immediately. No `pkill` needed — the per-iteration check stops things
-naturally.
-
-If the HALT file exists but is unreadable (permissions wrong), fail-closed —
-treat as if HALT is set. Safer than fail-open.
-
-See `cross-agent-halt.md` for the full halt mechanism.
-
-## Examples
-
-```bash
-# Same-machine send
-cross-agent-send homelab.career /tmp/my-message.org
-
-# Cross-machine send via Tailscale
-cross-agent-send velox.career /tmp/my-message.org
-
-# Test send without signing (receiver will reject)
-cross-agent-send homelab.career /tmp/test.org --no-sign
-
-# Override retry count for a flaky link
-cross-agent-send velox.career /tmp/my-message.org --retries 10
-
-# After a delivery failure, inspect the marker
-cat ~/.local/state/cross-agent-comms/failed-sends/*.json | jq .
-```
-
-## Exit codes
-
-| Code | Meaning |
-|---|---|
-| 0 | Sent successfully. |
-| 1 | General error (parse failure, signing failure, etc.). |
-| 2 | Destination not found in peers.toml. |
-| 3 | Cross-machine delivery failed after retries. Marker file written. |
-| 4 | Frontmatter validation failed. |
-
-## See also
-
-- `cross-agent-discover` — validate destinations before sending.
-- `cross-agent-watch` — receiver-side notification.
-- `cross-agent-status` — see what's queued.
-- `cross-agent-comms.org` — protocol spec, the "what" the script implements.
diff --git a/.ai/scripts/cross-agent-comms/cross-agent-status b/.ai/scripts/cross-agent-comms/cross-agent-status
deleted file mode 100755
index 4eee75b..0000000
--- a/.ai/scripts/cross-agent-comms/cross-agent-status
+++ /dev/null
@@ -1,185 +0,0 @@
-#!/usr/bin/env python3
-"""Point-in-time snapshot of pending cross-agent messages across local projects.
-
-See cross-agent-status.md. Pending = messages in inbox/from-agents/ whose
-CONVERSATION_ID has no MESSAGE_TYPE: release at a later #+TIMESTAMP.
-
-HALT: prints a prominent banner before normal output, but continues to enumerate.
-"""
-
-from __future__ import annotations
-
-import argparse
-import glob
-import json
-import os
-import re
-import sys
-from pathlib import Path
-
-CONFIG_DIR = Path.home() / ".config" / "cross-agent-comms"
-HALT_FILE = CONFIG_DIR / "HALT"
-DEFAULT_GLOB = str(Path.home() / "projects" / "*" / "inbox" / "from-agents") + "/"
-
-
-def parse_frontmatter(path: Path) -> dict[str, str]:
- try:
- text = path.read_text()
- except OSError:
- return {}
- fm: dict[str, str] = {}
- for line in text.splitlines():
- line = line.rstrip()
- if not line:
- if fm:
- break
- continue
- m = re.match(r"#\+([A-Z_]+):\s*(.*)", line)
- if m:
- fm[m.group(1)] = m.group(2).strip()
- elif fm:
- break
- return fm
-
-
-def project_name_from_path(path: str) -> str:
- """Walk up from path to find ~/projects/<name>/..."""
- home = str(Path.home())
- parts = Path(path).parts
- for i, part in enumerate(parts):
- if part == "projects" and i + 1 < len(parts) and str(Path(*parts[: i + 1])) == os.path.join(home, "projects"):
- return parts[i + 1]
- # Fallback: dir three levels up from the .org file (project/inbox/from-agents/file.org)
- return Path(path).parent.parent.parent.name
-
-
-def scan_project(inbox_dir: Path) -> tuple[int, str | None, int | None]:
- """Return (pending_count, most_recent_filename_or_None, most_recent_age_seconds_or_None)."""
- if not inbox_dir.is_dir():
- return 0, None, None
-
- # Group .org files by CONVERSATION_ID, also collect release timestamps per conv.
- org_files = sorted(inbox_dir.glob("*.org"))
- if not org_files:
- return 0, None, None
-
- by_conv: dict[str, list[tuple[str, str, Path]]] = {} # conv_id -> [(timestamp, msg_type, path)]
- for f in org_files:
- fm = parse_frontmatter(f)
- conv = fm.get("CONVERSATION_ID")
- ts = fm.get("TIMESTAMP")
- mt = fm.get("MESSAGE_TYPE")
- if not conv or not ts or not mt:
- # Malformed file: count as pending under conv "_unparseable".
- by_conv.setdefault("_unparseable", []).append(("", "request", f))
- continue
- by_conv.setdefault(conv, []).append((ts, mt, f))
-
- pending_files: list[Path] = []
- for conv, entries in by_conv.items():
- entries.sort(key=lambda e: e[0])
- # Find the latest release timestamp.
- release_ts = None
- for ts, mt, _f in entries:
- if mt == "release" and (release_ts is None or ts > release_ts):
- release_ts = ts
- for ts, mt, f in entries:
- if mt == "release":
- continue
- if release_ts is not None and ts <= release_ts:
- continue
- pending_files.append(f)
-
- if not pending_files:
- return 0, None, None
-
- # Most-recent by mtime (proxy for arrival order).
- most_recent = max(pending_files, key=lambda p: p.stat().st_mtime)
- import time
- age = int(time.time() - most_recent.stat().st_mtime)
- return len(pending_files), most_recent.name, age
-
-
-def fmt_age(seconds: int | None) -> str:
- if seconds is None:
- return "—"
- if seconds < 60:
- return f"{seconds}s ago"
- if seconds < 3600:
- return f"{seconds // 60} min ago"
- if seconds < 86400:
- return f"{seconds // 3600} hr ago"
- return f"{seconds // 86400} day(s) ago"
-
-
-def render_banner_if_halt() -> None:
- if not HALT_FILE.exists():
- return
- try:
- reason = HALT_FILE.read_text().strip()
- except OSError:
- reason = "(HALT file unreadable; treated as halted)"
- print("⚠ HALT ACTIVE — cross-agent comms paused")
- if reason:
- print(f" reason: {reason}")
- print(f" clear: rm {HALT_FILE} (or: cross-agent-resume)")
- print()
-
-
-def main() -> int:
- parser = argparse.ArgumentParser(description="Snapshot of pending cross-agent messages across local projects.")
- parser.add_argument("--json", action="store_true", help="Emit JSON output")
- parser.add_argument("--projects-glob", default=DEFAULT_GLOB,
- help=f"Glob for project from-agents dirs (default: {DEFAULT_GLOB})")
- args = parser.parse_args()
-
- render_banner_if_halt()
-
- matched = sorted(glob.glob(args.projects_glob))
- rows = []
- for path in matched:
- inbox = Path(path)
- if not inbox.is_dir():
- continue
- proj = project_name_from_path(path)
- count, most_recent, age = scan_project(inbox)
- rows.append({
- "name": proj,
- "pending_count": count,
- "most_recent": (
- {"filename": most_recent, "age_seconds": age}
- if most_recent else None
- ),
- })
-
- # Sort: pending-first, then alphabetical by name.
- rows.sort(key=lambda r: (-r["pending_count"], r["name"]))
-
- if args.json:
- import datetime as _dt
- payload = {
- "scanned_at": _dt.datetime.now(_dt.timezone.utc).isoformat(),
- "halt_active": HALT_FILE.exists(),
- "projects": rows,
- }
- print(json.dumps(payload, indent=2))
- return 0
-
- if not rows:
- print("No projects with inbox/from-agents/ found — 0 pending.")
- return 0
-
- # Human-readable table.
- name_w = max(len("project"), max(len(r["name"]) for r in rows))
- print(f"{'project':<{name_w}} pending most-recent")
- for r in rows:
- most_recent_str = "—"
- if r["most_recent"]:
- most_recent_str = f"{r['most_recent']['filename']} ({fmt_age(r['most_recent']['age_seconds'])})"
- print(f"{r['name']:<{name_w}} {r['pending_count']:<7} {most_recent_str}")
-
- return 0
-
-
-if __name__ == "__main__":
- sys.exit(main())
diff --git a/.ai/scripts/cross-agent-comms/cross-agent-status.md b/.ai/scripts/cross-agent-comms/cross-agent-status.md
deleted file mode 100644
index 070330c..0000000
--- a/.ai/scripts/cross-agent-comms/cross-agent-status.md
+++ /dev/null
@@ -1,139 +0,0 @@
-# cross-agent-status
-
-**Purpose.** Point-in-time snapshot of pending cross-agent messages across
-every project on this machine. Run from any terminal. No daemon required.
-
-This is the user-pull layer of the cold-start story — `cross-agent-watch`
-pushes notifications, `cross-agent-status` lets the user query.
-
-## Usage
-
-```
-cross-agent-status [--json] [--projects-glob <glob>]
-```
-
-No args required.
-
-### Flags
-
-| Flag | Default | Purpose |
-|---|---|---|
-| `--json` | off (table) | Output as JSON for scripting. |
-| `--projects-glob <glob>` | `~/projects/*/inbox/from-agents/` | Override which directories to scan. |
-
-## Output
-
-### Default (table)
-
-```
-$ cross-agent-status
-project pending most-recent
-career 0 —
-claude-templates 0 —
-clipper 0 —
-homelab 1 20260427T085611Z-from-career-question.org (3 min ago)
-finances 0 —
-... (other 9 projects)
-```
-
-Sort: pending-first, then alphabetical.
-
-### `--json`
-
-```json
-{
- "scanned_at": "2026-04-27T04:13:00-05:00",
- "projects": [
- {
- "name": "homelab",
- "pending_count": 1,
- "most_recent": {
- "filename": "20260427T085611Z-from-career-question.org",
- "age_seconds": 180
- }
- },
- ...
- ]
-}
-```
-
-## Pending semantics
-
-A message is "pending" if it sits in `inbox/from-agents/` AND no
-`MESSAGE_TYPE: release` exists for the same `CONVERSATION_ID` after it.
-
-Concretely:
-
-1. Scan each project's `inbox/from-agents/` for `.org` files.
-2. Group by `CONVERSATION_ID` from frontmatter.
-3. For each conversation, find the highest-`#+TIMESTAMP` message with
- `MESSAGE_TYPE: release`.
-4. Messages with `#+TIMESTAMP` after that release (or in conversations with no
- release) count as pending.
-
-Files without parseable frontmatter are counted as pending and noted in the
-output (single warning row per project).
-
-## Failure modes
-
-| Symptom | Likely cause | Fix |
-|---|---|---|
-| Project missing from output | Project's `.ai/` directory exists but `inbox/from-agents/` does not | Created lazily on first cross-agent message; `mkdir -p` to surface in output. |
-| All projects show "0 pending" but you know one has messages | Glob misresolved, OR all messages are post-release | `cross-agent-status --projects-glob` with explicit path to confirm. |
-| Warning row "N files unparseable in <project>" | Message file has invalid frontmatter | Open the file, fix or move out. |
-
-## Performance
-
-Scans every `.org` file in every watched directory. For Craig's setup (14
-projects, single-digit messages each), runs in <100ms. If a project
-accumulates hundreds of post-release messages, archive them per the persistence
-guidance in the protocol spec.
-
-## HALT awareness
-
-Checks `~/.config/cross-agent-comms/HALT` at start. If HALT exists, prints a
-prominent banner before normal output:
-
-```
-$ cross-agent-status
-⚠ HALT ACTIVE — cross-agent comms paused
- Reason: investigating runaway poll loop, 2026-04-27
- HALT file: ~/.config/cross-agent-comms/HALT
- Resume with: cross-agent-resume
-
-(snapshot continues normally — HALT does not suppress visibility)
-
-project pending most-recent
-career 0 —
-homelab 1 20260427T085611Z-from-career-question.org (3 min ago)
-...
-```
-
-Status is read-only, so it always runs. The banner ensures the user can't
-miss that halt is active when checking inbox state. Reason text comes from
-the HALT file's body; if empty, omit the reason line.
-
-If the HALT file exists but is unreadable, print a warning banner ("HALT
-file present but unreadable; treat as halted") and continue with normal
-output.
-
-See `cross-agent-halt.md` for the full halt mechanism.
-
-## Examples
-
-```bash
-# Snapshot
-cross-agent-status
-
-# JSON for piping
-cross-agent-status --json | jq '.projects[] | select(.pending_count > 0)'
-
-# Single-project query
-cross-agent-status --projects-glob ~/projects/work/inbox/from-agents/
-```
-
-## See also
-
-- `cross-agent-watch` — push notifications on new arrivals.
-- `cross-agent-discover` — enumerate available agents (cross-machine).
-- `cross-agent-comms.org` — protocol spec.
diff --git a/.ai/scripts/cross-agent-comms/cross-agent-watch b/.ai/scripts/cross-agent-comms/cross-agent-watch
deleted file mode 100755
index f50ba26..0000000
--- a/.ai/scripts/cross-agent-comms/cross-agent-watch
+++ /dev/null
@@ -1,106 +0,0 @@
-#!/usr/bin/env bash
-# cross-agent-watch — desktop-notify on new cross-agent messages.
-#
-# See cross-agent-watch.md. Watches every ~/projects/*/inbox/from-agents/ by
-# default. inotifywait fires create + moved_to events; .tmp.* files are
-# filtered out. HALT suppresses notifications but the watcher keeps running
-# and logs each event with "(suppressed by HALT)".
-
-set -uo pipefail
-
-# Defaults.
-PROJECTS_GLOB="${HOME}/projects/*/inbox/from-agents/"
-LOG_FILE="${HOME}/.local/state/cross-agent-comms/watch.log"
-HALT_FILE="${HOME}/.config/cross-agent-comms/HALT"
-QUIET=0
-NO_NOTIFY=0
-
-# Arg parsing.
-while [[ $# -gt 0 ]]; do
- case "$1" in
- --projects-glob)
- PROJECTS_GLOB="$2"; shift 2 ;;
- --log)
- LOG_FILE="$2"; shift 2 ;;
- --quiet)
- QUIET=1; shift ;;
- --no-notify)
- NO_NOTIFY=1; shift ;;
- -h|--help)
- cat <<EOF
-Usage: cross-agent-watch [--projects-glob GLOB] [--log PATH] [--quiet] [--no-notify]
-
-Watches inbox/from-agents/ directories for new cross-agent messages and fires
-desktop notifications. See cross-agent-watch.md for details.
-EOF
- exit 0 ;;
- *)
- echo "unknown flag: $1" >&2; exit 1 ;;
- esac
-done
-
-# Resolve glob to a concrete list of directories.
-# shellcheck disable=SC2086
-DIRS=( $PROJECTS_GLOB )
-# Filter out non-existent paths (glob may include literal pattern when no match).
-EXISTING=()
-for d in "${DIRS[@]}"; do
- if [[ -d "$d" ]]; then
- EXISTING+=( "$d" )
- fi
-done
-
-if [[ ${#EXISTING[@]} -eq 0 ]]; then
- echo "cross-agent-watch: glob resolved 0 directories: $PROJECTS_GLOB" >&2
- exit 1
-fi
-
-# Ensure log dir exists.
-mkdir -p "$(dirname "$LOG_FILE")"
-
-[[ $QUIET -eq 0 ]] && echo "cross-agent-watch: watching ${#EXISTING[@]} dir(s); log: $LOG_FILE"
-
-# Helper: project name from path like /home/.../projects/<name>/inbox/from-agents/...
-project_name() {
- local path="$1"
- # Match ~/projects/<name>/...
- if [[ "$path" =~ ${HOME}/projects/([^/]+)/ ]]; then
- echo "${BASH_REMATCH[1]}"
- else
- basename "$(dirname "$(dirname "$path")")"
- fi
-}
-
-# Main loop. inotifywait emits one line per event in the format
-# "<full-path>" because we passed --format '%w%f'.
-inotifywait -m -e create,moved_to --format '%w%f' "${EXISTING[@]}" 2>/dev/null \
- | while IFS= read -r path; do
- filename="$(basename "$path")"
-
- # Filter .tmp.* staging files.
- case "$filename" in
- .tmp.*) continue ;;
- esac
-
- # Filter .asc sidecars — they land first per the atomic-write ordering;
- # the .org event will fire after.
- case "$filename" in
- *.asc) continue ;;
- esac
-
- proj="$(project_name "$path")"
- iso="$(date -u "+%Y-%m-%dT%H:%M:%SZ")"
-
- if [[ -e "$HALT_FILE" ]]; then
- printf '%s\t%s\t%s\t(suppressed by HALT)\n' "$iso" "$proj" "$filename" >> "$LOG_FILE"
- [[ $QUIET -eq 0 ]] && echo "[$iso] $proj: $filename (suppressed by HALT)"
- continue
- fi
-
- printf '%s\t%s\t%s\n' "$iso" "$proj" "$filename" >> "$LOG_FILE"
- [[ $QUIET -eq 0 ]] && echo "[$iso] $proj: $filename"
-
- if [[ $NO_NOTIFY -eq 0 ]]; then
- notify info "Cross-agent message" "${proj}: ${filename}" --persist 2>/dev/null || true
- fi
- done
diff --git a/.ai/scripts/cross-agent-comms/cross-agent-watch.md b/.ai/scripts/cross-agent-comms/cross-agent-watch.md
deleted file mode 100644
index 04e8005..0000000
--- a/.ai/scripts/cross-agent-comms/cross-agent-watch.md
+++ /dev/null
@@ -1,130 +0,0 @@
-# cross-agent-watch
-
-**Purpose.** Long-running watcher that fires desktop notifications when new
-cross-agent messages land in any project's `inbox/from-agents/` directory.
-This is the primary cold-start mechanism: messages get noticed even when no
-Claude session is active.
-
-## Usage
-
-```
-cross-agent-watch [--projects-glob <glob>] [--log <path>]
-```
-
-No args required. Defaults:
-
-- Watches `~/projects/*/inbox/from-agents/` (matches every project with the
- cross-agent-comms convention).
-- Logs each event to `~/.local/state/cross-agent-comms/watch.log`.
-
-### Flags
-
-| Flag | Default | Purpose |
-|---|---|---|
-| `--projects-glob <glob>` | `~/projects/*/inbox/from-agents/` | Override which directories to watch. Useful for testing on a single project. |
-| `--log <path>` | `~/.local/state/cross-agent-comms/watch.log` | Override log location. Set to `/dev/null` to disable logging. |
-| `--quiet` | off | Suppress stdout output. Notifications still fire. |
-| `--no-notify` | off | Skip `notify` calls. Useful for testing the watcher loop without spamming notifications. |
-
-## Behavior
-
-1. Resolves the projects-glob to a concrete list of directories at startup.
- New projects added to `~/projects/` after startup are NOT picked up — restart
- the watcher to re-resolve.
-2. Runs `inotifywait -m -e create,moved_to --format '%w%f'` against each
- watched directory.
-3. For each event, calls
- `notify info "Cross-agent message" "<project>: <filename>" --persist`. The
- `--persist` flag keeps the page on screen until dismissed, so an inbound
- message that arrives while Craig is away from the desk isn't missed.
-4. Appends an event line to the log:
- `<ISO-8601-timestamp>\t<project>\t<filename>`.
-
-## Event filtering
-
-- Watches `create` AND `moved_to` events. The `moved_to` part is critical for
- the atomic-write convention (`mktemp` + `mv` produces a `moved_to`, not a
- `create`).
-- Files starting with `.tmp.` are ignored — they're staging files from
- in-progress writes that should never produce a notification.
-
-## Installation
-
-### Option A — tmux pane (personal, easy)
-
-Run in a tmux pane that survives session disconnects:
-
-```
-tmux new -d -s cross-agent-watch 'cross-agent-watch'
-```
-
-### Option B — systemd user service (production)
-
-Provided files:
-
-- `~/.config/systemd/user/cross-agent-watch.service`
-- `~/.config/systemd/user/cross-agent-watch.path`
-
-Enable with:
-
-```
-systemctl --user enable --now cross-agent-watch.path
-```
-
-The path unit triggers the service unit on filesystem changes; the service
-unit re-execs `cross-agent-watch` if it dies. Survives reboot.
-
-## Failure modes
-
-| Symptom | Likely cause | Fix |
-|---|---|---|
-| No notifications fire on new files | inotifywait not running, or glob resolved to zero dirs | Check `cross-agent-watch --projects-glob ... --quiet` exits non-zero immediately. Log shows `"resolved 0 directories"`. |
-| Notifications fire on `.tmp.` files | Filter regression | Verify `inotifywait` events show the `.tmp.` files; if so check this script's filter logic. |
-| Some files missed under rapid bursts | inotify queue overflow | Increase `fs.inotify.max_queued_events` sysctl. Default 16384 is usually fine. |
-| Permission denied on a watched dir | Directory perms wrong | `chmod 700 <dir>` and confirm owner. |
-
-## HALT awareness
-
-Checks `~/.config/cross-agent-comms/HALT` on each iteration (each inotifywait
-event fired). If HALT exists, the watcher continues running but **suppresses
-the `notify` call**. The event is still logged, with `(suppressed by HALT)`
-appended:
-
-```
-2026-04-27T04:42:00-05:00 career 20260427T094200Z-from-homelab-test.org (suppressed by HALT)
-```
-
-Logged-but-suppressed events are useful for the operator to see what would
-have fired during the halt window — helpful for diagnosing whatever caused
-the halt.
-
-When HALT clears, suppression stops; subsequent events fire normally. Backlog
-events that arrived during halt are NOT replayed — they get picked up via
-cold-start handling (status CLI, agent startup check, or the next agent
-poll once polling resumes).
-
-If the HALT file exists but is unreadable, fail-closed (suppress) — safer
-than fail-open.
-
-See `cross-agent-halt.md` for the full halt mechanism.
-
-## Examples
-
-```bash
-# Watch all projects, log everything, fire notifications
-cross-agent-watch
-
-# Test against a single project, no notifications, verbose
-cross-agent-watch \
- --projects-glob "$HOME/projects/work/inbox/from-agents/" \
- --no-notify
-
-# Production-style: quiet stdout, log only
-cross-agent-watch --quiet
-```
-
-## See also
-
-- `cross-agent-status` — point-in-time snapshot of pending messages.
-- `cross-agent-send` — counterpart writer.
-- `cross-agent-comms.org` — protocol spec.
diff --git a/.ai/scripts/flashcard-stats.py b/.ai/scripts/flashcard-stats.py
index 1fa5afb..cb580ac 100755
--- a/.ai/scripts/flashcard-stats.py
+++ b/.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/.ai/scripts/flashcard-to-anki.py b/.ai/scripts/flashcard-to-anki.py
index 7227683..e369fd8 100755
--- a/.ai/scripts/flashcard-to-anki.py
+++ b/.ai/scripts/flashcard-to-anki.py
@@ -10,12 +10,21 @@
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>.
-
-Deck name defaults to the input basename, case preserved. Deck and model
-IDs are derived from the deck name via stable hash so re-importing the
-same deck updates existing cards instead of duplicating them.
+ - 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
+no #+TITLE. Deck and model IDs are derived from the deck name via stable
+hash so re-importing the same deck updates existing cards instead of
+duplicating them.
Output defaults to ~/sync/phone/anki/<input-basename>.apkg. The .apkg is
a mobile-Anki artifact the phone picks up from its sync dir, so it lands
@@ -25,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.
@@ -45,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`.
@@ -118,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
@@ -154,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
@@ -163,21 +199,46 @@ 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
-def default_deck_name(input_path: Path) -> str:
+def default_deck_name(input_path: Path, org_text: str) -> str:
+ """Deck name defaults to the org #+TITLE:, falling back to the basename.
+
+ The #+TITLE drives both the org-drill display in Emacs and the Anki
+ deck name on the phone, so the consumed deck reads as the curated
+ title ("Refutations") rather than the filename slug
+ ("refutation-drill"). Falls back to the input basename (case
+ preserved) when the source has no non-empty #+TITLE line.
+ """
+ for line in org_text.splitlines():
+ m = re.match(r"^#\+TITLE:\s*(.*\S)\s*$", line, re.IGNORECASE)
+ if m:
+ return m.group(1).strip()
return input_path.stem
@@ -197,7 +258,7 @@ def main() -> int:
)
parser.add_argument(
"--deck",
- help="Deck name. Defaults to the input basename.",
+ help="Deck name. Defaults to the org #+TITLE, or the input basename.",
)
parser.add_argument(
"--output",
@@ -205,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()
@@ -213,16 +284,22 @@ def main() -> int:
return 1
org_text = input_path.read_text(encoding="utf-8")
- deck_name = args.deck or default_deck_name(input_path)
+ deck_name = args.deck or default_deck_name(input_path, org_text)
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/.ai/scripts/inbox-send.py b/.ai/scripts/inbox-send.py
index 5373bd4..663efcb 100755
--- a/.ai/scripts/inbox-send.py
+++ b/.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
@@ -136,8 +148,21 @@ def slugify_filename(stem: str, max_length: int = MAX_SLUG_LENGTH) -> str:
return truncated.strip("-._")
+def display_name(path: Path) -> str:
+ """The name a project is referred to by — its basename with dots stripped.
+
+ Dotted directories (`.emacs.d`, `.dotfiles`) are awkward to name in
+ conversation, so they're addressed dot-stripped: `emacsd`, `dotfiles`.
+ """
+ return path.name.replace(".", "")
+
+
def find_target(target_name: str, projects: list[Path]) -> Path | None:
- """Resolve `target_name` against the project list (basename or numeric index)."""
+ """Resolve `target_name` against the project list (basename or numeric index).
+
+ An exact basename match wins. Failing that, a dot-stripped alias matches —
+ so `emacsd` resolves `.emacs.d` and `dotfiles` resolves `.dotfiles`.
+ """
if target_name.isdigit():
idx = int(target_name) - 1
if 0 <= idx < len(projects):
@@ -146,6 +171,10 @@ def find_target(target_name: str, projects: list[Path]) -> Path | None:
for p in projects:
if p.name == target_name:
return p
+ norm = target_name.replace(".", "")
+ for p in projects:
+ if display_name(p) == norm:
+ return p
return None
@@ -160,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,
@@ -174,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
@@ -194,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
@@ -206,9 +286,9 @@ def print_project_list(projects: list[Path], current: Path | None) -> None:
print("No projects (.ai/ + inbox/) found under the configured roots.")
return
print(f"Available .ai projects ({len(others)}):")
- width = max(len(p.name) for p in others)
+ width = max(len(display_name(p)) for p in others)
for i, p in enumerate(others, 1):
- print(f" {i}. {p.name:<{width}} {p}")
+ print(f" {i}. {display_name(p):<{width}} {p}")
def main() -> int:
@@ -276,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/.ai/scripts/inbox-status b/.ai/scripts/inbox-status
index b917144..17031af 100755
--- a/.ai/scripts/inbox-status
+++ b/.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/.ai/scripts/lint-org.el b/.ai/scripts/lint-org.el
index 8f55cc6..33dc52f 100644
--- a/.ai/scripts/lint-org.el
+++ b/.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
@@ -29,6 +32,15 @@
;; link-to-local-file broken file: links
;; invalid-fuzzy-link broken *Heading refs
;; suspicious-language-in-src-block unknown source-block language
+;; org-table-standard table wider than budget / missing rules
+;; level-2-dated-header ** dated header instead of a keyword
+;; indented-heading whitespace before stars (demoted to body)
+;; 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:
@@ -59,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.")
@@ -280,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))
@@ -292,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)
@@ -348,24 +427,266 @@ 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)
+;;
+;; A completed task or resolved VERIFY at level 2 must carry a terminal
+;; keyword (DONE/CANCELLED + CLOSED:), never a dated heading. A `** <date>'
+;; header has no keyword, so todo-cleanup's --archive-done can never archive
+;; it (it accumulates in Open Work forever) and task-review drops it from
+;; selection. Judgment-only, never auto-fixed: the repair needs a
+;; DONE-vs-CANCELLED call and the original heading text, which is a judgment
+;; the sweep can't make. Targets todo/task files; a dated-log-format org
+;; file using `** <date>' headings intentionally will false-positive here, in
+;; which case the human dismisses the judgment item.
+
+(defun lo--check-level2-dated-headers ()
+ "Flag level-2 headings whose text begins with a YYYY-MM-DD date.
+Emits one judgment item per offending heading (checker
+`level-2-dated-header')."
+ (save-excursion
+ (goto-char (point-min))
+ (while (re-search-forward
+ "^\\*\\* \\([0-9]\\{4\\}-[0-9]\\{2\\}-[0-9]\\{2\\}\\)" nil t)
+ (lo--emit-judgment
+ 'level-2-dated-header (line-number-at-pos)
+ "level-2 dated header is a completion defect (todo-format.md): a ** task or VERIFY closes with DONE/CANCELLED + CLOSED:, not a dated heading — convert it so --archive-done can archive it"))))
+
+;;; ---------------------------------------------------------------------------
+;;; structural heading checks (mistakes org-lint does not cover)
+;;
+;; org-lint validates links, drawers, blocks, and babel — but not heading
+;; well-formedness. These four catch hand-edit defects it misses, all
+;; judgment-only (each repair is a human call) and regex-based (no dependence on
+;; which TODO keywords the batch Emacs happens to recognize):
+;;
+;; indented-heading leading whitespace before two-or-more stars; org
+;; demotes it to body text, so the task vanishes from
+;; the agenda and never archives. The worst case — an
+;; invisible task — and silent. Single `*' is left
+;; alone (a valid indented plain-list bullet).
+;; empty-heading a line of bare stars with no title.
+;; malformed-priority-cookie a `[#x]'-shaped token org rejected (lowercase,
+;; multi-char, non-letter) sitting where a cookie
+;; would be.
+;; level2-done-without-closed a level-2 DONE/CANCELLED with no CLOSED line —
+;; directly relevant to todo-cleanup's aging step,
+;; which archives an undated completed task at once.
+
+(defconst lo-done-keywords '("DONE" "CANCELLED")
+ "Heading keywords treated as completed for `lo--check-level2-done-without-closed'.")
+
+(defun lo--check-indented-headings ()
+ "Flag lines that are whitespace + two-or-more stars + space outside any block.
+Org parses a heading only at column 0, so leading whitespace silently demotes a
+would-be heading to body text. Two-or-more stars is required: an indented
+single `*' is a valid plain-list bullet, not a lost heading, so flagging it
+false-positives on legitimate lists; `**'+ is never a bullet, so an indented one
+is unambiguously a demoted level-2+ heading turned invisible. Lines inside
+`#+begin_/#+end_' blocks are skipped — indented asterisks there are legitimate
+content."
+ (save-excursion
+ (goto-char (point-min))
+ (let ((in-block nil))
+ (while (not (eobp))
+ (cond
+ ((looking-at-p "^[ \t]*#\\+begin_") (setq in-block t))
+ ((looking-at-p "^[ \t]*#\\+end_") (setq in-block nil))
+ ((and (not in-block) (looking-at-p "^[ \t]+\\*\\*+[ \t]"))
+ (lo--emit-judgment
+ 'indented-heading (line-number-at-pos)
+ "indented heading: leading whitespace before the stars demotes this to body text — org won't treat it as a heading (it vanishes from the agenda and never archives); dedent to column 0")))
+ (forward-line 1)))))
+
+(defun lo--check-empty-headings ()
+ "Flag headings that are bare stars with no title text.
+A line of nothing but stars is an empty heading — a stray heading-star carrying
+no content."
+ (save-excursion
+ (goto-char (point-min))
+ (while (re-search-forward "^\\*+[ \t]*$" nil t)
+ (lo--emit-judgment
+ 'empty-heading (line-number-at-pos)
+ "empty heading: a line of stars with no title — delete it or give it a title"))))
+
+(defun lo--check-malformed-priority-cookies ()
+ "Flag a heading whose first cookie-shaped token is not a valid priority.
+A valid cookie is a single uppercase letter in `[#A]' form. Verbatim-wrapped
+cookies (`=[#D]=' quoted in a dated-log title) are skipped. Only the first
+token on the line is checked, so a real cookie earlier on the line means a
+later `[#x]' in the title is left alone."
+ (save-excursion
+ (goto-char (point-min))
+ ;; Case-sensitive: a cookie is uppercase only, and case-fold-search defaults
+ ;; to t (which would accept [#a] as valid).
+ (let ((case-fold-search nil))
+ (while (re-search-forward "^\\*+ " nil t)
+ (let ((eol (line-end-position)) (hline (line-number-at-pos)))
+ (when (re-search-forward "\\[#\\([^]]*\\)\\]" eol t)
+ (let ((inner (match-string 1))
+ (before (char-before (match-beginning 0)))
+ (after (char-after (match-end 0))))
+ (unless (or (eq before ?=) (eq after ?=)
+ (string-match-p "\\`[A-Z]\\'" inner))
+ (lo--emit-judgment
+ 'malformed-priority-cookie hline
+ (format "malformed priority cookie [#%s] — a cookie is a single uppercase letter ([#A]) right after the keyword; fix or remove it"
+ inner)))))
+ (goto-char eol))))))
+
+(defun lo--check-level2-done-without-closed ()
+ "Flag a level-2 DONE/CANCELLED heading with no CLOSED line in its own entry.
+todo-cleanup's `--archive-done' aging step archives a completed task with no
+parseable CLOSED date immediately, so an undated completed task silently leaves
+the live file on the next `task-sorted'."
+ (save-excursion
+ (goto-char (point-min))
+ ;; Case-sensitive: DONE/CANCELLED are uppercase keywords, not the words
+ ;; "done"/"cancelled" in a heading title (case-fold-search defaults to t).
+ (let ((case-fold-search nil)
+ (re (format "^\\*\\* \\(%s\\) "
+ (mapconcat #'regexp-quote lo-done-keywords "\\|"))))
+ (while (re-search-forward re 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]*CLOSED:[ \t]*\\[" entry-end t)
+ (lo--emit-judgment
+ 'level2-done-without-closed hline
+ "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
@@ -401,6 +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)
+ ;; 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)
+ ;; 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))
@@ -507,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)))
@@ -518,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)
@@ -537,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/.ai/scripts/route-batch b/.ai/scripts/route-batch
new file mode 100755
index 0000000..8f27d19
--- /dev/null
+++ b/.ai/scripts/route-batch
@@ -0,0 +1,175 @@
+#!/usr/bin/env python3
+"""route-batch — the wrap-up router's mechanical go path.
+
+The wrap-up cross-project router (wrap-it-up.org Step 3; wrapup-routing spec
+D7/D8/D9) surfaces the local tasks that inbox process mode stamped with
+:ROUTE_CANDIDATE: <destination> at file time, and on "go" delivers each to its
+destination project's inbox. This script does the mechanical half so the
+subtree surgery is deterministic:
+
+ route-batch --list [--todo todo.org]
+ One "<destination>\t<heading>" line per :ROUTE_CANDIDATE:-tagged task.
+ Silent with exit 0 when there are no candidates (the workflow's
+ empty-set-equals-zero-interaction rule). Read-only.
+
+ route-batch --go [--todo todo.org]
+ For each candidate, bottom-up: extract the task's whole subtree
+ (children ride along), drop the :ROUTE_CANDIDATE: line (and the
+ property drawer if that leaves it empty), promote the subtree so its
+ top heading is level 1, write it to a temp file, and deliver it via
+ the sibling inbox-send.py to the destination's inbox/ (one file per
+ task, from-<source> provenance stamped by inbox-send). Only after a
+ successful send is the subtree removed from the local todo.org — a
+ failed send leaves that task in place, is reported, and the run exits
+ non-zero after attempting the rest.
+
+The candidate set is exactly the tagged tasks — never the standing backlog.
+Discovery, roots, and the source-project name all come from inbox-send.py
+(INBOX_SEND_ROOTS sandboxes it in tests). The reject-from-another-project
+flow in inbox process mode is the mis-route recovery; that path is why
+removing the local source after a successful send is safe.
+"""
+
+import argparse
+import os
+import re
+import subprocess
+import sys
+import tempfile
+from pathlib import Path
+
+HEADING_RE = re.compile(r"^(\*+)\s+(.*)$")
+MARKER_RE = re.compile(r"^\s*:ROUTE_CANDIDATE:\s+(\S+)\s*$")
+
+
+def find_candidates(lines):
+ """[(heading_idx, end_idx, marker_idx, destination, heading_text)] —
+ end_idx is one past the subtree's last line."""
+ candidates = []
+ for i, line in enumerate(lines):
+ m = MARKER_RE.match(line)
+ if not m:
+ continue
+ head_idx = None
+ for j in range(i, -1, -1):
+ hm = HEADING_RE.match(lines[j])
+ if hm:
+ head_idx = j
+ level = len(hm.group(1))
+ heading = hm.group(2)
+ break
+ if head_idx is None:
+ continue
+ end = len(lines)
+ for k in range(head_idx + 1, len(lines)):
+ km = HEADING_RE.match(lines[k])
+ if km and len(km.group(1)) <= level:
+ end = k
+ break
+ candidates.append((head_idx, end, i, m.group(1), heading))
+ return candidates
+
+
+def extract_handoff(lines, head_idx, end):
+ """The subtree as handoff text: every :ROUTE_CANDIDATE: line dropped
+ (a marker is meaningless at the destination), empty drawers pruned,
+ headings promoted so the task is level 1."""
+ sub = [l for l in lines[head_idx:end] if not MARKER_RE.match(l)]
+
+ pruned = []
+ i = 0
+ while i < len(sub):
+ if sub[i].strip() == ":PROPERTIES:" and i + 1 < len(sub) and sub[i + 1].strip() == ":END:":
+ i += 2
+ continue
+ pruned.append(sub[i])
+ i += 1
+
+ shift = len(HEADING_RE.match(pruned[0]).group(1)) - 1
+ if shift > 0:
+ pruned = [l[shift:] if HEADING_RE.match(l) else l for l in pruned]
+ return "\n".join(pruned).rstrip() + "\n"
+
+
+def send(destination, handoff_text, slug):
+ inbox_send = Path(__file__).with_name("inbox-send.py")
+ with tempfile.NamedTemporaryFile(
+ "w", suffix=".org", prefix=f"route-{slug}-", delete=False, encoding="utf-8"
+ ) as tf:
+ tf.write(handoff_text)
+ tmp = tf.name
+ try:
+ result = subprocess.run(
+ [sys.executable, str(inbox_send), destination, "--file", tmp],
+ capture_output=True, text=True,
+ )
+ return result.returncode == 0, (result.stderr or result.stdout).strip()
+ finally:
+ os.unlink(tmp)
+
+
+def main():
+ ap = argparse.ArgumentParser(prog="route-batch")
+ mode = ap.add_mutually_exclusive_group(required=True)
+ mode.add_argument("--list", action="store_true", dest="list_mode")
+ mode.add_argument("--go", action="store_true")
+ ap.add_argument("--todo", default="todo.org")
+ args = ap.parse_args()
+
+ todo_path = Path(args.todo)
+ if not todo_path.is_file():
+ return 0 # no todo file, no candidates
+ lines = todo_path.read_text(encoding="utf-8").splitlines()
+ candidates = find_candidates(lines)
+
+ # Two markers in one task's drawer are one candidate, not two: same span +
+ # same destination dedupes. Everything else that overlaps — a tagged child
+ # inside a tagged parent, one task tagged for two destinations — is a
+ # conflict: routing either span would silently take the other (or, with a
+ # stale end index, a bystander task) along. Conflicts are left in place
+ # and reported; the human untangles which project the pieces belong to.
+ deduped = []
+ for cand in candidates:
+ if not any(c[0] == cand[0] and c[1] == cand[1] and c[3] == cand[3] for c in deduped):
+ deduped.append(cand)
+ conflicted = set()
+ for a in deduped:
+ for b in deduped:
+ if a is not b and a[0] <= b[0] and b[1] <= a[1]:
+ conflicted.add(a)
+ conflicted.add(b)
+ routable = [c for c in deduped if c not in conflicted]
+
+ if not deduped:
+ return 0
+
+ if args.list_mode:
+ for _h, _e, _m, dest, heading in deduped:
+ flag = "\tCONFLICT (overlapping candidates — resolve by hand)" if (_h, _e, _m, dest, heading) in conflicted else ""
+ print(f"{dest}\t{heading}{flag}")
+ return 0
+
+ failures = 0
+ for _h, _e, _m, dest, heading in sorted(conflicted):
+ failures += 1
+ print(f"CONFLICT: {dest}\t{heading}\t(overlapping candidate subtrees — left in place, resolve by hand)")
+
+ # Bottom-up so earlier indices stay valid as subtrees are removed; the
+ # file is rewritten after every successful send so a crash mid-run never
+ # leaves an already-sent task still present locally.
+ for head_idx, end, _marker_idx, dest, heading in sorted(routable, reverse=True):
+ handoff = extract_handoff(lines, head_idx, end)
+ slug = re.sub(r"[^a-z0-9]+", "-", heading.lower()).strip("-")[:40] or "task"
+ ok, detail = send(dest, handoff, slug)
+ if ok:
+ del lines[head_idx:end]
+ todo_path.write_text("\n".join(lines).rstrip("\n") + "\n", encoding="utf-8")
+ print(f"routed: {dest}\t{heading}")
+ else:
+ failures += 1
+ print(f"FAILED: {dest}\t{heading}\t({detail})")
+ return 1 if failures else 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/.ai/scripts/route_recommend.py b/.ai/scripts/route_recommend.py
new file mode 100644
index 0000000..12ab132
--- /dev/null
+++ b/.ai/scripts/route_recommend.py
@@ -0,0 +1,145 @@
+#!/usr/bin/env python3
+"""Wrap-up routing recommendation engine.
+
+Given an inbox keeper's text and a list of candidate project names, infer which
+project the item belongs to, with a confidence tier:
+
+ strong a project's name (or its dot-stripped form, or a path containing it)
+ appears literally in the item
+ weak a distinctive name token overlaps, but the full name doesn't
+ none no overlap; the item stays put
+
+A multi-way tie at the top tier is ambiguous, so it downgrades to weak with a
+deterministic pick (most token overlap, then alphabetical). An empty candidate
+list yields none.
+
+The pure core is `recommend(item, projects) -> (destination, confidence)` — the
+shape the wrap-up router (Phase 4) and the process-inbox marker (Phase 2) both
+call. The CLI wires it to inbox-send.py's `discover_projects` so the candidate
+set is the same project universe inbox-send already knows.
+
+CLI:
+ route_recommend.py --item "<text>" [--exclude <current-project>]
+prints "<destination>\\t<confidence>" on a match, or "none".
+"""
+
+import argparse
+import importlib.util
+import re
+import sys
+from pathlib import Path
+
+# A distinctive-enough token for weak matching; shorter tokens (of, to, id) are
+# too noisy to route on.
+MIN_WEAK_TOKEN = 4
+
+_TOKEN_RE = re.compile(r"[a-z0-9]+")
+
+
+def _tokens(text: str) -> set[str]:
+ return set(_TOKEN_RE.findall(text.lower()))
+
+
+def _name_variants(name: str) -> set[str]:
+ """A project name and its dot-stripped alias (.emacs.d -> emacsd)."""
+ return {v for v in (name.lower(), name.replace(".", "").lower()) if v}
+
+
+def _literal_present(name: str, item_lower: str) -> bool:
+ """True if a name variant appears in the item on word-ish boundaries.
+
+ Boundaries keep 'home' from matching inside 'homeowner' while still
+ matching it inside a path ('~/code/home/...') or a hyphenated name.
+ """
+ for variant in _name_variants(name):
+ if re.search(r"(?<![a-z0-9])" + re.escape(variant) + r"(?![a-z0-9])", item_lower):
+ return True
+ return False
+
+
+def _tiebreak(candidates: list[str], item_tokens: set[str]) -> str:
+ """Most token overlap first, then alphabetical — deterministic."""
+ return sorted(candidates, key=lambda p: (-len(_tokens(p) & item_tokens), p))[0]
+
+
+def recommend(item: str, projects: list[str]) -> tuple[str | None, str]:
+ """Infer the destination project for `item` from `projects`.
+
+ Returns (destination, confidence). confidence is "strong" / "weak" / "none";
+ destination is None exactly when confidence is "none".
+ """
+ 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)
+
+ strong: list[str] = []
+ weak: list[str] = []
+ for project in projects:
+ if _literal_present(project, item_lower):
+ strong.append(project)
+ continue
+ name_tokens = {t for t in _tokens(project) if len(t) >= MIN_WEAK_TOKEN}
+ if name_tokens & item_tokens:
+ weak.append(project)
+
+ if len(strong) == 1:
+ return (strong[0], "strong")
+ if len(strong) > 1:
+ return (_tiebreak(strong, item_tokens), "weak")
+ if len(weak) == 1:
+ return (weak[0], "weak")
+ if len(weak) > 1:
+ return (_tiebreak(weak, item_tokens), "weak")
+ return (None, "none")
+
+
+def _load_inbox_send():
+ """Load the sibling kebab-named inbox-send.py as a module for its discovery."""
+ path = Path(__file__).with_name("inbox-send.py")
+ spec = importlib.util.spec_from_file_location("inbox_send", path)
+ if spec is None or spec.loader is None:
+ raise ImportError(f"cannot load {path}")
+ module = importlib.util.module_from_spec(spec)
+ spec.loader.exec_module(module)
+ return module
+
+
+def discover_destination_names(exclude: str | None = None) -> list[str]:
+ """The candidate project names, reusing inbox-send's discovery.
+
+ `exclude` drops the current project (matched by exact name or dot-stripped
+ alias) so the engine never recommends routing an item to where it already is.
+ """
+ mod = _load_inbox_send()
+ names = [p.name for p in mod.discover_projects(mod.resolve_roots())]
+ if exclude:
+ drop = _name_variants(exclude)
+ names = [n for n in names if not (_name_variants(n) & drop)]
+ return names
+
+
+def main() -> int:
+ parser = argparse.ArgumentParser(description="Recommend a routing destination for an inbox keeper.")
+ parser.add_argument("--item", required=True, help="the keeper's text")
+ parser.add_argument("--exclude", help="current project to exclude from candidates")
+ args = parser.parse_args()
+
+ projects = discover_destination_names(exclude=args.exclude)
+ destination, confidence = recommend(args.item, projects)
+ print("none" if destination is None else f"{destination}\t{confidence}")
+ return 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/.ai/scripts/self-inject.sh b/.ai/scripts/self-inject.sh
new file mode 100755
index 0000000..e7340c1
--- /dev/null
+++ b/.ai/scripts/self-inject.sh
@@ -0,0 +1,68 @@
+#!/bin/sh
+# self-inject.sh — type text into the tmux pane running this agent session.
+#
+# The building block for AUTO-FLUSH: an agent checkpoints its session-context,
+# then has tmux type "/clear" and a resume prompt at its own idle prompt, so a
+# session flushes with no human at the keyboard.
+#
+# Usage:
+# self-inject.sh -t %PANE <delay> <text> [<delay2> <text2> ...]
+# self-inject.sh <delay> <text> [...] # derive pane from ancestry
+# self-inject.sh [-t %PANE] # no pairs: report the pane
+#
+# Each pair: sleep <delay> seconds, then type <text> literally and press Enter.
+#
+# TWO HARD-WON GOTCHAS (2026-07-02, archsetup session):
+# 1. A detached child (setsid/nohup/&) of an agent tool call DIES when the
+# tool call ends — the harness cleans up the process group. The arm step
+# must run under the tmux SERVER instead:
+# tmux run-shell -b "self-inject.sh -t %1 25 '/clear' 15 'go — resume...'"
+# 2. Under tmux run-shell the process is a child of the tmux server, so
+# ancestry-based pane detection CANNOT work there. Derive the pane FIRST,
+# synchronously from the agent's own shell (no -t), then pass it
+# explicitly with -t when arming.
+#
+# Collision hazard: if the user happens to be typing when the send fires, the
+# injected text merges into their input line (a real /clear became "/clearto"
+# mid-word). Auto-flush is for sessions running unattended; warn the user to
+# keep hands off for the armed window if they're present.
+
+PANE=""
+if [ "$1" = "-t" ]; then
+ PANE=$2; shift 2
+fi
+
+ppid_of() {
+ # /proc/<pid>/stat: pid (comm) state ppid ... — comm may contain spaces,
+ # so take the 2nd field after the LAST ')'.
+ stat=$(cat "/proc/$1/stat" 2>/dev/null) || return 1
+ # shellcheck disable=SC2086 # word-splitting the stat tail is the point
+ set -- ${stat##*) }
+ echo "$2"
+}
+
+find_pane() {
+ anc=" "
+ pid=$$
+ while [ -n "$pid" ] && [ "$pid" -gt 1 ] 2>/dev/null; do
+ anc="$anc$pid "
+ pid=$(ppid_of "$pid") || break
+ done
+ tmux list-panes -a -F "#{pane_pid} #{pane_id}" 2>/dev/null | \
+ while read -r ppid pane; do
+ case "$anc" in *" $ppid "*) echo "$pane"; break;; esac
+ done
+}
+
+[ -n "$PANE" ] || PANE=$(find_pane)
+[ -n "$PANE" ] || { echo "self-inject: no owning pane found (pass -t %PANE)" >&2; exit 1; }
+
+# With no delay/text pairs, just report the pane (the derive-first step).
+[ $# -ge 2 ] || { echo "$PANE"; exit 0; }
+
+while [ $# -ge 2 ]; do
+ sleep "$1"
+ tmux send-keys -t "$PANE" -l "$2"
+ tmux send-keys -t "$PANE" Enter
+ shift 2
+done
diff --git a/.ai/scripts/spec-sort b/.ai/scripts/spec-sort
new file mode 100755
index 0000000..ebfef82
--- /dev/null
+++ b/.ai/scripts/spec-sort
@@ -0,0 +1,715 @@
+#!/usr/bin/env python3
+"""spec-sort — one-time docs-pile retrofit for the docs-lifecycle convention.
+
+Classifies every docs/**/*.org outside docs/specs/ by one predicate: a doc
+carrying BOTH a "Decisions" heading AND an "Implementation phases" heading is
+a spec candidate; everything else is a note. For each candidate it shows an
+evidence panel (Status field, decision/finding cookies, the linking todo.org
+task, recent dated history, cheap existence checks on phase-named artifacts)
+and proposes a lifecycle keyword the evidence supports — conservative
+non-terminal (DRAFT) when inconclusive. The helper proposes; a human confirms
+every move.
+
+Dry-run report is the default. --apply executes under the fail-safe contract:
+
+ - Clean-worktree preflight: refuses on a dirty git tree (exit 2) unless
+ --allow-dirty, which prints exactly what recovery loses.
+ - Every candidate must be addressed with --confirm REL=KEYWORD or
+ --skip REL; terminal keywords (IMPLEMENTED SUPERSEDED CANCELLED) also
+ need --reason REL=TEXT, recorded in the status-history line.
+ - The full move + relink plan is computed and validated first (every
+ destination free, every link resolvable), written to a plan file, and
+ only then executed from that recorded plan.
+ - Bare-path mentions of a moving doc inside the rewritten roots are
+ reported, never rewritten; they block --apply until --acknowledge-bare
+ explicitly waives them.
+ - Mid-apply failure stops the run, names what was and wasn't applied, and
+ prints the git-restore recovery recipe (plus deletion of newly created
+ destination copies, which git restore can't remove).
+ - After a successful apply, a residue scan across the rewritten roots must
+ find no link still resolving to an old path, or spec-sort exits non-zero
+ naming the residue.
+
+Per move: rename to carry the -spec.org suffix, prepend the status heading
+(:ID: UUID + dated history line), rewrite the keyword header to the
+two-sequence form, mirror the keyword into the Metadata Status field, and
+recompute every affected file: link (inbound links to the moved doc AND the
+moved doc's own outbound relative links). Rewritten roots: todo.org,
+.ai/notes.org, docs/**, .ai/project-workflows/, .ai/project-scripts/.
+Reported-never-rewritten: .ai/sessions/ (frozen history) and synced template
+paths (.ai/workflows/, .ai/scripts/, .ai/protocols.org — the report names
+the canonical claude-templates file instead).
+
+Finally stamps :LAST_SPEC_SORT: YYYY-MM-DD in .ai/notes.org's
+* Workflow State section (created idempotently), which permanently clears
+the startup nudge. A run with zero candidates still stamps.
+
+Exit codes: 0 done (or clean report), 1 blocked (confirm gate, validation,
+bare mentions, residue, mid-apply failure), 2 usage / preflight refusal.
+
+Test hook: SPEC_SORT_INJECT_FAIL_AFTER=N aborts the apply after N write
+operations, exercising the recovery path in the bats suite.
+"""
+
+import argparse
+import json
+import os
+import re
+import subprocess
+import sys
+import tempfile
+import uuid
+from datetime import datetime
+
+LIFECYCLE = ("DRAFT", "READY", "DOING", "IMPLEMENTED", "SUPERSEDED", "CANCELLED")
+TERMINAL = {"IMPLEMENTED", "SUPERSEDED", "CANCELLED"}
+TODO_HEADER = [
+ "#+TODO: TODO | DONE",
+ "#+TODO: DRAFT READY DOING | IMPLEMENTED SUPERSEDED CANCELLED",
+]
+
+# Project-owned surfaces whose file: links get rewritten.
+REWRITE_ROOTS = ("todo.org", ".ai/notes.org", "docs", ".ai/project-workflows", ".ai/project-scripts")
+# Frozen or synced surfaces: occurrences are reported, never rewritten.
+REPORT_ROOTS = (".ai/sessions", ".ai/workflows", ".ai/scripts", ".ai/protocols.org")
+# Synced template paths map to their canonical rulesets file for the report.
+SYNCED_PREFIX = (".ai/workflows", ".ai/scripts", ".ai/protocols.org")
+
+LINK_RE = re.compile(r"\[\[file:([^\]\[]+)\](?:\[([^\]\[]*)\])?\]")
+HEADING_RE = re.compile(r"^(\*+)\s+(.*)$")
+COOKIE_RE = re.compile(r"\[\d+/\d+\]")
+DATED_RE = re.compile(r"\b\d{4}-\d{2}-\d{2}\b")
+
+
+def read_text(path):
+ try:
+ with open(path, encoding="utf-8") as f:
+ return f.read()
+ except (UnicodeDecodeError, OSError):
+ return None
+
+
+def heading_text(line):
+ """Heading text with the org keyword and priority cookie stripped."""
+ m = HEADING_RE.match(line)
+ if not m:
+ return None
+ text = re.sub(r"^[A-Z]+\s+", "", m.group(2))
+ text = re.sub(r"^\[#[A-Z]\]\s+", "", text)
+ return text.strip()
+
+
+def has_spine(content):
+ """The classification predicate: Decisions AND Implementation phases."""
+ dec = imp = False
+ for line in content.splitlines():
+ t = heading_text(line)
+ if t is None:
+ continue
+ tl = t.lower()
+ if tl.startswith("decisions"):
+ dec = True
+ elif tl.startswith("implementation phases"):
+ imp = True
+ return dec and imp
+
+
+def walk_files(root, rel_base):
+ """Yield project-relative paths of files under rel_base (file or dir)."""
+ abs_base = os.path.join(root, rel_base)
+ if os.path.isfile(abs_base):
+ yield rel_base
+ return
+ for dirpath, dirs, files in os.walk(abs_base):
+ dirs.sort()
+ for name in sorted(files):
+ yield os.path.relpath(os.path.join(dirpath, name), root)
+
+
+def classify(root):
+ """Split docs/**/*.org outside docs/specs/ into candidates / anomalies / notes."""
+ candidates, anomalies, notes = [], [], []
+ docs = os.path.join(root, "docs")
+ if not os.path.isdir(docs):
+ return candidates, anomalies, notes
+ for rel in walk_files(root, "docs"):
+ if not rel.endswith(".org"):
+ continue
+ parts = rel.split(os.sep)
+ if len(parts) > 1 and parts[1] == "specs":
+ continue
+ content = read_text(os.path.join(root, rel))
+ if content is None:
+ continue
+ if has_spine(content):
+ candidates.append(rel)
+ elif os.path.basename(rel).endswith("-spec.org"):
+ anomalies.append(rel)
+ else:
+ notes.append(rel)
+ return candidates, anomalies, notes
+
+
+def dest_for(rel):
+ base = os.path.basename(rel)
+ if not base.endswith("-spec.org"):
+ base = base[: -len(".org")] + "-spec.org"
+ return os.path.join("docs", "specs", base)
+
+
+# ---- Evidence panel ---------------------------------------------------
+
+
+def todo_task_for(root, rel):
+ """Heading of the first todo.org task whose subtree mentions the doc."""
+ content = read_text(os.path.join(root, "todo.org"))
+ if content is None:
+ return None
+ lines = content.splitlines()
+ basename = os.path.basename(rel)
+ for i, line in enumerate(lines):
+ if basename in line or rel in line:
+ for j in range(i, -1, -1):
+ if HEADING_RE.match(lines[j]):
+ return lines[j].lstrip("* ").strip()
+ return None
+ return None
+
+
+def gather_evidence(root, rel, content):
+ ev = {}
+ m = re.search(r"^\|\s*Status\s*\|\s*([^|]*)\|", content, re.MULTILINE | re.IGNORECASE)
+ ev["status"] = m.group(1).strip() if m else None
+
+ cookies = []
+ for line in content.splitlines():
+ t = heading_text(line)
+ if t and COOKIE_RE.search(t) and (
+ t.lower().startswith("decisions") or t.lower().startswith("review findings")
+ ):
+ cookies.append(t)
+ ev["cookies"] = cookies
+
+ ev["todo"] = todo_task_for(root, rel)
+ kw = None
+ if ev["todo"]:
+ m = re.match(r"([A-Z]+)\s", ev["todo"])
+ kw = m.group(1) if m else None
+ ev["todo_keyword"] = kw
+
+ dated = [ln.strip() for ln in content.splitlines() if DATED_RE.search(ln)]
+ ev["history"] = dated[-1][:100] if dated else None
+
+ # Cheap artifact check: =path= tokens inside the Implementation phases section.
+ artifacts, exists = [], 0
+ section = re.split(r"^\*+\s+.*implementation phases.*$", content, maxsplit=1, flags=re.MULTILINE | re.IGNORECASE)
+ if len(section) > 1:
+ for tok in re.findall(r"=([^=\s]+)=", section[1]):
+ if "/" in tok:
+ artifacts.append(tok)
+ if os.path.exists(os.path.join(root, tok)):
+ exists += 1
+ ev["artifacts"] = (exists, artifacts)
+ return ev
+
+
+def propose_keyword(ev):
+ s = (ev["status"] or "").lower()
+ words = set(re.findall(r"[a-z]+", s))
+ if words & {"implemented", "shipped", "complete", "completed", "done"}:
+ return "IMPLEMENTED"
+ if words & {"superseded"}:
+ return "SUPERSEDED"
+ if words & {"cancelled", "canceled", "dead", "abandoned"}:
+ return "CANCELLED"
+ if words & {"doing", "implementing"} or "in progress" in s or "in-progress" in s:
+ return "DOING"
+ if ev["todo_keyword"] == "DOING":
+ return "DOING"
+ if words & {"ready", "approved", "accepted"}:
+ return "READY"
+ return "DRAFT" # conservative non-terminal default
+
+
+# ---- Link scanning ----------------------------------------------------
+
+
+def rewrite_files(root):
+ """Project-relative *.org files under the rewritten roots."""
+ seen = []
+ for base in REWRITE_ROOTS:
+ if not os.path.exists(os.path.join(root, base)):
+ continue
+ for rel in walk_files(root, base):
+ if rel.endswith(".org") and rel not in seen:
+ seen.append(rel)
+ return seen
+
+
+def resolve_target(root, linker_rel, raw_target, moved):
+ """Resolve a file: link target to a project-relative path (org semantics
+ first — relative to the linking file's directory — then project-root
+ anchoring as a fallback for root-anchored links)."""
+ if raw_target.startswith(("/", "~", "http:", "https:")):
+ return None
+ rel_a = os.path.normpath(os.path.join(os.path.dirname(linker_rel), raw_target))
+ if rel_a in moved or os.path.exists(os.path.join(root, rel_a)):
+ return rel_a
+ rel_b = os.path.normpath(raw_target)
+ if rel_b in moved or os.path.exists(os.path.join(root, rel_b)):
+ return rel_b
+ return rel_a
+
+
+def plan_link_edits(root, moved):
+ """Compute every link rewrite: inbound links to moved docs and moved
+ docs' own outbound relative links. Returns ({linker_rel: [(old, new)]},
+ [ambiguity descriptions]) — a link whose file-relative and root-anchored
+ readings are both live and disagree about a moving doc blocks validation
+ rather than being rewritten against a guess."""
+ edits = {}
+ ambiguous = []
+ for linker in rewrite_files(root):
+ content = read_text(os.path.join(root, linker))
+ if content is None:
+ continue
+ linker_post = moved.get(linker, linker)
+ for m in LINK_RE.finditer(content):
+ raw = m.group(1)
+ desc = m.group(2)
+ target_path, sep, anchor = raw.partition("::")
+ target = resolve_target(root, linker, target_path, moved)
+ if target is None:
+ continue
+ rel_a = os.path.normpath(os.path.join(os.path.dirname(linker), target_path))
+ rel_b = os.path.normpath(target_path)
+ if rel_a != rel_b:
+ live_a = rel_a in moved or os.path.exists(os.path.join(root, rel_a))
+ live_b = rel_b in moved or os.path.exists(os.path.join(root, rel_b))
+ if live_a and live_b and (rel_a in moved or rel_b in moved):
+ ambiguous.append(
+ "%s: [[file:%s]] reads as %s (file-relative) or %s (root-anchored) "
+ "and a moving doc is involved — resolve the link by hand" % (linker, raw, rel_a, rel_b))
+ continue
+ if target not in moved and linker not in moved:
+ continue
+ if target not in moved and not os.path.exists(os.path.join(root, target)):
+ continue # already broken before this run; not ours to guess
+ target_post = moved.get(target, target)
+ new_path = os.path.relpath(target_post, os.path.dirname(linker_post) or ".")
+ new_raw = new_path + (sep + anchor if sep else "")
+ if new_raw == raw:
+ continue
+ new_link = "[[file:%s]%s]" % (new_raw, "[%s]" % desc if desc is not None else "")
+ if m.group(0) != new_link:
+ edits.setdefault(linker, []).append((m.group(0), new_link))
+ return edits, ambiguous
+
+
+def scan_bare_mentions(root, moved):
+ """Bare-path mentions of moving docs in the rewritten roots — text
+ occurrences outside any [[...]] link. Reported, never rewritten."""
+ found = []
+ for base in REWRITE_ROOTS:
+ if not os.path.exists(os.path.join(root, base)):
+ continue
+ for rel in walk_files(root, base):
+ content = read_text(os.path.join(root, rel))
+ if content is None:
+ continue
+ for i, line in enumerate(content.splitlines(), 1):
+ stripped = re.sub(r"\[\[[^\]]*\](?:\[[^\]]*\])?\]", "", line)
+ for src in moved:
+ if src in stripped:
+ found.append((rel, i, src))
+ return found
+
+
+def scan_report_only(root, moved):
+ """Occurrences of moving docs in frozen/synced surfaces."""
+ reports = []
+ for base in REPORT_ROOTS:
+ if not os.path.exists(os.path.join(root, base)):
+ continue
+ for rel in walk_files(root, base):
+ content = read_text(os.path.join(root, rel))
+ if content is None:
+ continue
+ for src in moved:
+ if src in content:
+ if rel.startswith(SYNCED_PREFIX):
+ note = ("synced template, not rewritten — a local edit is reverted by the "
+ "next sync; edit the canonical claude-templates/%s instead" % rel)
+ else:
+ note = "frozen history; not rewritten"
+ reports.append((rel, src, note))
+ return reports
+
+
+# ---- Content transforms -----------------------------------------------
+
+
+def transform_spec(content, keyword, reason, title, doc_id, link_edits):
+ """Apply the retrofit rewrite to a moving spec's content: two-sequence
+ keyword header, prepended status heading, Status-field mirror, and the
+ doc's own link edits."""
+ for old, new in link_edits:
+ content = content.replace(old, new)
+ lines = content.splitlines()
+
+ todo_idx = None
+ kept = []
+ for line in lines:
+ if line.startswith("#+TODO:"):
+ if todo_idx is None:
+ todo_idx = len(kept)
+ continue
+ kept.append(line)
+ lines = kept
+ if todo_idx is None:
+ todo_idx = 0
+ while todo_idx < len(lines) and lines[todo_idx].startswith("#+"):
+ todo_idx += 1
+ lines[todo_idx:todo_idx] = TODO_HEADER
+
+ head_end = 0
+ while head_end < len(lines) and (lines[head_end].startswith("#+") or not lines[head_end].strip()):
+ head_end += 1
+ ts = datetime.now().astimezone().strftime("%Y-%m-%d %a @ %H:%M:%S %z")
+ provenance = "reason: %s" % reason if reason else "evidence-based, human-confirmed"
+ block = [
+ "* %s %s" % (keyword, title),
+ ":PROPERTIES:",
+ ":ID: %s" % doc_id,
+ ":END:",
+ "- %s — retrofitted by spec-sort; status set to %s (%s)" % (ts, keyword, provenance),
+ "",
+ ]
+ lines[head_end:head_end] = block
+
+ out = []
+ mirrored = False
+ for line in lines:
+ m = re.match(r"^(\|\s*Status\s*\|)([^|]*)(\|.*)$", line, re.IGNORECASE)
+ if m and not mirrored:
+ value = " %s" % keyword.lower()
+ width = len(m.group(2))
+ line = m.group(1) + (value.ljust(width) if len(value) <= width else value + " ") + m.group(3)
+ mirrored = True
+ out.append(line)
+ return "\n".join(out) + "\n"
+
+
+def title_for(content, rel):
+ m = re.search(r"^#\+TITLE:\s*(.+)$", content, re.MULTILINE | re.IGNORECASE)
+ if m:
+ return m.group(1).strip()
+ base = os.path.basename(rel)[: -len(".org")]
+ return base[: -len("-spec")] if base.endswith("-spec") else base
+
+
+# ---- Marker ------------------------------------------------------------
+
+
+def stamp_marker(root, date):
+ path = os.path.join(root, ".ai", "notes.org")
+ os.makedirs(os.path.dirname(path), exist_ok=True)
+ content = read_text(path) or ""
+ line = ":LAST_SPEC_SORT: %s" % date
+ if ":LAST_SPEC_SORT:" in content:
+ content = re.sub(r":LAST_SPEC_SORT:.*", line, content, count=1)
+ elif re.search(r"^\* Workflow State\s*$", content, re.MULTILINE):
+ content = re.sub(r"(^\* Workflow State\s*$)", r"\1\n" + line, content, count=1, flags=re.MULTILINE)
+ else:
+ if content and not content.endswith("\n"):
+ content += "\n"
+ content += "\n* Workflow State\n\n%s\n" % line
+ with open(path, "w", encoding="utf-8") as f:
+ f.write(content)
+
+
+# ---- Apply -------------------------------------------------------------
+
+
+class ApplyFailure(Exception):
+ """Mid-apply failure: args are (applied_labels, remaining_ops, cause)."""
+
+
+def apply_plan(root, plan, fail_after):
+ """Execute the recorded plan. Returns the applied-op labels; raises
+ ApplyFailure mid-way on a write error or when the test hook fires."""
+ ops = []
+ for mv in plan["moves"]:
+ ops.append(("move", mv))
+ for linker, edits in plan["link_edits"].items():
+ if linker in {mv["src"] for mv in plan["moves"]}:
+ continue # a moving doc's own edits ride along in its transform
+ ops.append(("relink", (linker, edits)))
+
+ applied = []
+ specs_dir = os.path.join(root, "docs", "specs")
+ if plan["moves"] and not os.path.isdir(specs_dir):
+ os.makedirs(specs_dir)
+ plan["created_dirs"].append(os.path.join("docs", "specs"))
+
+ for n, (kind, payload) in enumerate(ops, 1):
+ if fail_after and n > fail_after:
+ raise ApplyFailure(applied, ops[n - 1:], "injected test failure")
+ try:
+ if kind == "move":
+ mv = payload
+ content = read_text(os.path.join(root, mv["src"]))
+ new = transform_spec(content, mv["keyword"], mv["reason"], mv["title"], mv["id"],
+ plan["link_edits"].get(mv["src"], []))
+ with open(os.path.join(root, mv["dest"]), "w", encoding="utf-8") as f:
+ f.write(new)
+ os.remove(os.path.join(root, mv["src"]))
+ applied.append("move %s -> %s" % (mv["src"], mv["dest"]))
+ else:
+ linker, edits = payload
+ path = os.path.join(root, linker)
+ content = read_text(path)
+ for old, new in edits:
+ content = content.replace(old, new)
+ with open(path, "w", encoding="utf-8") as f:
+ f.write(content)
+ applied.append("relink %s (%d link%s)" % (linker, len(edits), "s" if len(edits) != 1 else ""))
+ except OSError as exc:
+ raise ApplyFailure(applied, ops[n - 1:], str(exc))
+ return applied
+
+
+def residue_check(root, plan):
+ """Post-apply: no link in the rewritten roots may still resolve to an
+ old path; bare mentions beyond the acknowledged set fail too."""
+ moved = {mv["src"]: mv["dest"] for mv in plan["moves"]}
+ residue = []
+ for linker in rewrite_files(root):
+ content = read_text(os.path.join(root, linker))
+ if content is None:
+ continue
+ for m in LINK_RE.finditer(content):
+ target_path = m.group(1).partition("::")[0]
+ target = resolve_target(root, linker, target_path, {})
+ if target in moved:
+ residue.append("%s: link still resolves to %s" % (linker, target))
+ # Acknowledged mentions were recorded pre-apply; a mention inside a moved
+ # doc now lives at the doc's destination, so map the file side through the
+ # moves before comparing.
+ acknowledged = {(moved.get(f, f), src) for f, _ln, src in plan["bare"]}
+ for f, ln, src in scan_bare_mentions(root, moved):
+ if (f, src) not in acknowledged:
+ residue.append("%s:%d: bare mention of %s" % (f, ln, src))
+ return residue
+
+
+def print_recovery(plan, applied, not_applied):
+ print("FAILURE — the apply did not complete.")
+ print(" applied:")
+ for a in applied or ["(nothing)"]:
+ print(" %s" % a)
+ print(" not applied:")
+ for kind, payload in not_applied:
+ if kind == "move":
+ print(" move %s -> %s" % (payload["src"], payload["dest"]))
+ else:
+ print(" relink %s" % payload[0])
+ print("RECOVERY — restore the pre-run state (safe: preflight required a clean tree):")
+ touched = [mv["src"] for mv in plan["moves"]] + [l for l in plan["link_edits"] if l not in {mv["src"] for mv in plan["moves"]}]
+ print(" git restore -- %s" % " ".join(touched))
+ created = [mv["dest"] for mv in plan["moves"]]
+ print(" rm -f -- %s # git restore can't remove the created copies" % " ".join(created))
+ for d in plan.get("created_dirs", []):
+ print(" rmdir --ignore-fail-on-non-empty -- %s" % d)
+
+
+# ---- Main ---------------------------------------------------------------
+
+
+def parse_kv(pairs, label):
+ out = {}
+ for item in pairs or []:
+ if "=" not in item:
+ sys.exit("spec-sort: %s expects REL=VALUE, got %r" % (label, item))
+ k, v = item.split("=", 1)
+ out[os.path.normpath(k)] = v
+ return out
+
+
+def main():
+ ap = argparse.ArgumentParser(prog="spec-sort", add_help=True)
+ ap.add_argument("--project-root", default=".")
+ ap.add_argument("--apply", action="store_true")
+ ap.add_argument("--allow-dirty", action="store_true")
+ ap.add_argument("--acknowledge-bare", action="store_true")
+ ap.add_argument("--confirm", action="append", metavar="REL=KEYWORD")
+ ap.add_argument("--reason", action="append", metavar="REL=TEXT")
+ ap.add_argument("--skip", action="append", metavar="REL")
+ ap.add_argument("--plan-file")
+ args = ap.parse_args()
+
+ root = os.path.abspath(args.project_root)
+ confirms = parse_kv(args.confirm, "--confirm")
+ reasons = parse_kv(args.reason, "--reason")
+ skips = {os.path.normpath(s) for s in (args.skip or [])}
+
+ candidates, anomalies, notes = classify(root)
+ if not candidates and not anomalies and not notes and not os.path.isdir(os.path.join(root, "docs")):
+ return 0 # no docs pile at all — silent no-op
+
+ for named in list(confirms) + list(skips) + list(reasons):
+ if named not in candidates:
+ print("spec-sort: %s is not a spec candidate" % named)
+ return 1
+ for rel, kw in confirms.items():
+ if kw not in LIFECYCLE:
+ print("spec-sort: %r is not a lifecycle keyword (%s)" % (kw, " ".join(LIFECYCLE)))
+ return 1
+
+ # ---- Build the plan (shared by report and apply) ----
+ moves = []
+ for rel in candidates:
+ if rel in skips:
+ continue
+ if args.apply and rel not in confirms:
+ continue # gate failure reported below
+ content = read_text(os.path.join(root, rel))
+ moves.append({
+ "src": rel,
+ "dest": dest_for(rel),
+ "keyword": confirms.get(rel, None),
+ "reason": reasons.get(rel),
+ "title": title_for(content, rel),
+ "id": str(uuid.uuid4()),
+ })
+ moved_map = {mv["src"]: mv["dest"] for mv in moves}
+ link_edits, ambiguous = plan_link_edits(root, moved_map)
+ bare = scan_bare_mentions(root, moved_map)
+ reports = scan_report_only(root, moved_map)
+
+ # ---- Report ----
+ for rel in candidates:
+ content = read_text(os.path.join(root, rel))
+ ev = gather_evidence(root, rel, content)
+ proposed = propose_keyword(ev)
+ print("CANDIDATE %s -> %s" % (rel, dest_for(rel)))
+ suffix = " (terminal — requires --reason to apply)" if proposed in TERMINAL else ""
+ print(" proposed keyword: %s%s" % (proposed, suffix))
+ print(" evidence:")
+ print(" status field: %s" % (ev["status"] or "(none)"))
+ print(" cookies: %s" % ("; ".join(ev["cookies"]) or "(none)"))
+ print(" todo.org: %s" % (ev["todo"] or "(no linking task)"))
+ print(" history: %s" % (ev["history"] or "(none)"))
+ n_exist, artifacts = ev["artifacts"]
+ if artifacts:
+ print(" artifacts: %d/%d named paths exist (%s)" % (n_exist, len(artifacts), ", ".join(artifacts)))
+ else:
+ print(" artifacts: (none named)")
+ for rel in anomalies:
+ print("ANOMALY %s: named -spec.org but lacks the spec spine (Decisions + Implementation phases); surfaced, not moved" % rel)
+ for rel in notes:
+ print("NOTE %s" % rel)
+ for linker, edits in sorted(link_edits.items()):
+ for old, new in edits:
+ print("RELINK %s: %s -> %s" % (linker, old, new))
+ for a in ambiguous:
+ print("AMBIGUOUS %s" % a)
+ for f, ln, src in bare:
+ print("BARE-PATH %s:%d: %s (reported for manual handling, never rewritten)" % (f, ln, src))
+ for rel, src, note in reports:
+ print("REPORT %s: reference to %s (%s)" % (rel, src, note))
+
+ if not args.apply:
+ if candidates or anomalies or notes:
+ print("DRY RUN — no changes written. Pass --apply with per-candidate --confirm/--skip to execute.")
+ return 0
+
+ # ---- Apply: preflight ----
+ try:
+ porcelain = subprocess.run(
+ ["git", "status", "--porcelain"], cwd=root,
+ capture_output=True, text=True, check=True,
+ ).stdout
+ except (subprocess.CalledProcessError, FileNotFoundError):
+ print("spec-sort: --apply needs a git worktree (recovery depends on git restore)")
+ return 2
+ if porcelain.strip():
+ dirty = [ln[3:] for ln in porcelain.splitlines()]
+ if not args.allow_dirty:
+ print("spec-sort: refusing --apply on a dirty worktree (%d path%s). Commit or stash first, or pass --allow-dirty."
+ % (len(dirty), "s" if len(dirty) != 1 else ""))
+ return 2
+ print("WARNING --allow-dirty: recovery via git restore would also revert your pre-existing uncommitted changes:")
+ for p in dirty:
+ print(" %s" % p)
+
+ # ---- Apply: confirm gate ----
+ unaddressed = [rel for rel in candidates if rel not in confirms and rel not in skips]
+ if unaddressed:
+ print("spec-sort: unconfirmed candidate(s) — pass --confirm REL=KEYWORD or --skip REL for each:")
+ for rel in unaddressed:
+ print(" %s" % rel)
+ return 1
+ for mv in moves:
+ if mv["keyword"] in TERMINAL and not mv["reason"]:
+ print("spec-sort: %s -> %s is a terminal state and requires an explicit --reason %s=TEXT"
+ % (mv["src"], mv["keyword"], mv["src"]))
+ return 1
+
+ # ---- Apply: validation ----
+ problems = []
+ dests = {}
+ for mv in moves:
+ if os.path.exists(os.path.join(root, mv["dest"])):
+ problems.append("%s: destination exists (%s)" % (mv["src"], mv["dest"]))
+ if mv["dest"] in dests:
+ problems.append("%s and %s: destination exists twice (%s)" % (mv["src"], dests[mv["dest"]], mv["dest"]))
+ dests[mv["dest"]] = mv["src"]
+ for a in ambiguous:
+ problems.append("ambiguous link: %s" % a)
+ if bare and not args.acknowledge_bare:
+ problems.append("bare-path mention(s) listed above need manual handling — re-run with --acknowledge-bare to proceed without rewriting them")
+ if problems:
+ print("spec-sort: validation blocked — nothing written:")
+ for p in problems:
+ print(" %s" % p)
+ return 1
+
+ # ---- Apply: record the plan, then execute from it ----
+ today = datetime.now().astimezone().strftime("%Y-%m-%d")
+ plan = {
+ "root": root, "date": today, "moves": moves,
+ "link_edits": link_edits, "bare": bare,
+ "reports": [list(r) for r in reports], "created_dirs": [],
+ }
+ plan_path = args.plan_file or os.path.join(
+ tempfile.gettempdir(), "spec-sort-plan-%s.json" % os.path.basename(root))
+ with open(plan_path, "w", encoding="utf-8") as f:
+ json.dump(plan, f, indent=2)
+ print("plan written: %s" % plan_path)
+
+ fail_after = int(os.environ.get("SPEC_SORT_INJECT_FAIL_AFTER", "0") or 0)
+ try:
+ applied = apply_plan(root, plan, fail_after)
+ except ApplyFailure as exc:
+ print("write failed: %s" % exc.args[2])
+ print_recovery(plan, exc.args[0], exc.args[1])
+ return 1
+
+ residue = residue_check(root, plan)
+ if residue:
+ print("spec-sort: residue after apply — old paths still referenced:")
+ for r in residue:
+ print(" %s" % r)
+ print_recovery(plan, applied, [])
+ return 1
+
+ stamp_marker(root, today)
+ for a in applied:
+ print("applied: %s" % a)
+ print("spec-sort: done — %d spec(s) sorted, :LAST_SPEC_SORT: %s stamped" % (len(moves), today))
+ return 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/.ai/scripts/task-review-staleness.sh b/.ai/scripts/task-review-staleness.sh
index ed43712..50e0257 100755
--- a/.ai/scripts/task-review-staleness.sh
+++ b/.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/.ai/scripts/tests/agent-lock.bats b/.ai/scripts/tests/agent-lock.bats
new file mode 100644
index 0000000..dbcffe1
--- /dev/null
+++ b/.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/.ai/scripts/tests/capture-guard.bats b/.ai/scripts/tests/capture-guard.bats
new file mode 100644
index 0000000..31632a4
--- /dev/null
+++ b/.ai/scripts/tests/capture-guard.bats
@@ -0,0 +1,130 @@
+#!/usr/bin/env bats
+#
+# Tests for claude-templates/.ai/scripts/capture-guard — detects live
+# org-capture buffers visiting a target file before a workflow edits that
+# file on disk (the roam inbox, in inbox.org roam mode Phase D). Editing the file
+# underneath an indirect org-capture buffer wedges the capture (see emacs.md).
+#
+# Contract under test:
+# capture-guard [TARGET_FILE] (default TARGET_FILE = ~/org/roam/inbox.org)
+# exit 0 → safe to edit: emacsclient absent, daemon unreachable, or no
+# capture buffer visits TARGET_FILE.
+# exit 1 → a live capture buffer visits TARGET_FILE; its name(s) printed.
+#
+# Strategy: the emacsclient boundary is mocked with a PATH stub. The stub
+# answers the reachability probe (`-e t`) per STUB_REACHABLE and returns a
+# canned, real-emacsclient-shaped result (quoted string) for the buffer query
+# per STUB_BUFS. The script's own quote-stripping and exit logic is the code
+# under test; the file-equal-p precision is real-Emacs behavior we trust.
+
+SCRIPT="$(cd "$(dirname "$BATS_TEST_FILENAME")/.." && pwd)/capture-guard"
+BASH_BIN="$(command -v bash)"
+
+setup() {
+ TEST_DIR="$(mktemp -d -t capture-guard-bats.XXXXXX)"
+ STUB_DIR="$TEST_DIR/bin"
+ mkdir -p "$STUB_DIR"
+
+ cat > "$STUB_DIR/emacsclient" <<'STUB'
+#!/usr/bin/env bash
+# Mock emacsclient. `-e t` is the reachability probe; anything else is the
+# buffer query, answered with the real-emacsclient-shaped quoted string.
+expr="$2"
+if [ "$expr" = "t" ]; then
+ [ "${STUB_REACHABLE:-1}" = "1" ] && { echo t; exit 0; }
+ exit 1
+fi
+printf '%s\n' "${STUB_BUFS:-\"\"}"
+exit 0
+STUB
+ chmod +x "$STUB_DIR/emacsclient"
+
+ EMPTY_DIR="$TEST_DIR/empty"
+ mkdir -p "$EMPTY_DIR"
+}
+
+teardown() {
+ rm -rf "$TEST_DIR"
+}
+
+# ---- Safe-to-edit (exit 0) cases ------------------------------------
+
+@test "capture-guard: emacsclient absent is safe (exit 0, no output)" {
+ run env PATH="$EMPTY_DIR" "$BASH_BIN" "$SCRIPT"
+ [ "$status" -eq 0 ]
+ [ -z "$output" ]
+}
+
+@test "capture-guard: daemon unreachable is safe (exit 0)" {
+ run env PATH="$STUB_DIR:$PATH" STUB_REACHABLE=0 "$BASH_BIN" "$SCRIPT"
+ [ "$status" -eq 0 ]
+ [ -z "$output" ]
+}
+
+@test "capture-guard: reachable with no capture buffers is safe (exit 0)" {
+ run env PATH="$STUB_DIR:$PATH" STUB_REACHABLE=1 STUB_BUFS='""' "$BASH_BIN" "$SCRIPT"
+ [ "$status" -eq 0 ]
+ [ -z "$output" ]
+}
+
+# ---- Blocked (exit 1) cases -----------------------------------------
+
+@test "capture-guard: one live capture buffer blocks (exit 1, name printed)" {
+ run env PATH="$STUB_DIR:$PATH" STUB_REACHABLE=1 STUB_BUFS='"CAPTURE-inbox.org"' \
+ "$BASH_BIN" "$SCRIPT"
+ [ "$status" -eq 1 ]
+ [[ "$output" == *"CAPTURE-inbox.org"* ]]
+}
+
+@test "capture-guard: multiple live capture buffers all reported (exit 1)" {
+ run env PATH="$STUB_DIR:$PATH" STUB_REACHABLE=1 \
+ STUB_BUFS='"CAPTURE-inbox.org,CAPTURE-2-inbox.org"' \
+ "$BASH_BIN" "$SCRIPT"
+ [ "$status" -eq 1 ]
+ [[ "$output" == *"CAPTURE-inbox.org"* ]]
+ [[ "$output" == *"CAPTURE-2-inbox.org"* ]]
+}
+
+@test "capture-guard: blocked output does not contain stray surrounding quotes" {
+ run env PATH="$STUB_DIR:$PATH" STUB_REACHABLE=1 STUB_BUFS='"CAPTURE-inbox.org"' \
+ "$BASH_BIN" "$SCRIPT"
+ [ "$status" -eq 1 ]
+ [[ "$output" != \"* ]]
+ [[ "$output" != *\" ]]
+}
+
+# ---- Argument handling ----------------------------------------------
+
+@test "capture-guard: accepts an explicit target-file argument" {
+ run env PATH="$STUB_DIR:$PATH" STUB_REACHABLE=1 STUB_BUFS='""' \
+ "$BASH_BIN" "$SCRIPT" "$TEST_DIR/some-other-inbox.org"
+ [ "$status" -eq 0 ]
+ [ -z "$output" ]
+}
+
+# ---- --wait poll mode -----------------------------------------------
+
+@test "capture-guard --wait: returns 0 instantly when already safe (no sleep)" {
+ SECONDS=0
+ run env PATH="$STUB_DIR:$PATH" STUB_REACHABLE=1 STUB_BUFS='""' \
+ "$BASH_BIN" "$SCRIPT" --wait
+ [ "$status" -eq 0 ]
+ [ -z "$output" ]
+ [ "$SECONDS" -lt 2 ] # didn't poll-sleep
+}
+
+@test "capture-guard --wait=1: times out to exit 1 when persistently blocked" {
+ # Stub always reports the buffer, so it never clears — the short budget
+ # forces a timeout. Capped sleep keeps this near 1s.
+ run env PATH="$STUB_DIR:$PATH" STUB_REACHABLE=1 STUB_BUFS='"CAPTURE-inbox.org"' \
+ "$BASH_BIN" "$SCRIPT" --wait=1
+ [ "$status" -eq 1 ]
+ [[ "$output" == *"CAPTURE-inbox.org"* ]]
+}
+
+@test "capture-guard --wait=N accepts a target after the flag" {
+ run env PATH="$STUB_DIR:$PATH" STUB_REACHABLE=1 STUB_BUFS='""' \
+ "$BASH_BIN" "$SCRIPT" --wait=1 "$TEST_DIR/some-other-inbox.org"
+ [ "$status" -eq 0 ]
+ [ -z "$output" ]
+}
diff --git a/.ai/scripts/tests/flashcard-sync.bats b/.ai/scripts/tests/flashcard-sync.bats
index 608a280..e6ffc21 100644
--- a/.ai/scripts/tests/flashcard-sync.bats
+++ b/.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/.ai/scripts/tests/inbox-status.bats b/.ai/scripts/tests/inbox-status.bats
index bc8a734..27a497e 100644
--- a/.ai/scripts/tests/inbox-status.bats
+++ b/.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/.ai/scripts/tests/lint-org-cli.bats b/.ai/scripts/tests/lint-org-cli.bats
index d457696..b9faef6 100644
--- a/.ai/scripts/tests/lint-org-cli.bats
+++ b/.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/.ai/scripts/tests/route-batch.bats b/.ai/scripts/tests/route-batch.bats
new file mode 100644
index 0000000..84ded5f
--- /dev/null
+++ b/.ai/scripts/tests/route-batch.bats
@@ -0,0 +1,202 @@
+#!/usr/bin/env bats
+#
+# Tests for claude-templates/.ai/scripts/route-batch — the wrap-up router's
+# mechanical go path (wrapup-routing spec, Phase 4 / D7 / D9).
+#
+# Contract under test:
+# route-batch --list one "<destination>\t<heading>" line per task
+# carrying :ROUTE_CANDIDATE:; silent when none;
+# never modifies anything
+# route-batch --go per candidate: write the subtree (minus the
+# :ROUTE_CANDIDATE: line) as a one-task handoff,
+# deliver via inbox-send to the destination's
+# inbox/, then remove the subtree from the local
+# todo.org. Send failure leaves the task in
+# place and exits non-zero. Empty set: no-op.
+#
+# Strategy: fixture roots under $TEST_DIR hold a source project and two
+# destination projects; INBOX_SEND_ROOTS sandboxes inbox-send's discovery to
+# them (the same hook inbox-send's own tests use).
+
+SCRIPT="$(cd "$(dirname "$BATS_TEST_FILENAME")/.." && pwd)/route-batch"
+
+setup() {
+ TEST_DIR="$(mktemp -d -t route-batch-bats.XXXXXX)"
+ ROOTS="$TEST_DIR/roots"
+ SRC="$ROOTS/srcproj"
+ mkdir -p "$SRC/.ai" "$SRC/inbox" \
+ "$ROOTS/alpha/.ai" "$ROOTS/alpha/inbox" \
+ "$ROOTS/beta/.ai" "$ROOTS/beta/inbox"
+ touch "$ROOTS/alpha/todo.org" # alpha has a todo.org; beta deliberately not
+
+ cat > "$SRC/todo.org" <<'EOF'
+* Srcproj Open Work
+** TODO [#B] Alpha-bound task :feature:
+:PROPERTIES:
+:ROUTE_CANDIDATE: alpha
+:END:
+Body line about the alpha work.
+*** TODO Sub-task that rides along
+** TODO [#C] Purely local task
+Local body stays put.
+** TODO [#C] Beta-bound task :quick:
+:PROPERTIES:
+:CREATED: [2026-07-01 Tue]
+:ROUTE_CANDIDATE: beta
+:END:
+Beta body.
+EOF
+
+ export INBOX_SEND_ROOTS="$ROOTS"
+ cd "$SRC"
+}
+
+teardown() {
+ rm -rf "$TEST_DIR"
+}
+
+# ---- --list ------------------------------------------------------------
+
+@test "route-batch --list: one destination+heading line per candidate, backlog excluded" {
+ run "$SCRIPT" --list
+ [ "$status" -eq 0 ]
+ [[ "$output" == *"alpha"*"Alpha-bound task"* ]]
+ [[ "$output" == *"beta"*"Beta-bound task"* ]]
+ [[ "$output" != *"Purely local task"* ]]
+}
+
+@test "route-batch --list: empty candidate set is silent (exit 0)" {
+ sed -i '/:ROUTE_CANDIDATE:/d' todo.org
+ run "$SCRIPT" --list
+ [ "$status" -eq 0 ]
+ [ -z "$output" ]
+}
+
+@test "route-batch --list: modifies nothing (skip leaves all in place)" {
+ before="$(cat todo.org)"
+ run "$SCRIPT" --list
+ [ "$status" -eq 0 ]
+ [ "$(cat todo.org)" = "$before" ]
+ [ -z "$(ls "$ROOTS/alpha/inbox" "$ROOTS/beta/inbox" 2>/dev/null | grep -v ':')" ]
+}
+
+# ---- --go --------------------------------------------------------------
+
+@test "route-batch --go: delivers each candidate to its destination inbox with provenance" {
+ run "$SCRIPT" --go
+ [ "$status" -eq 0 ]
+ alpha_file=$(find "$ROOTS/alpha/inbox" -name '*from-srcproj*' -type f)
+ beta_file=$(find "$ROOTS/beta/inbox" -name '*from-srcproj*' -type f)
+ [ -n "$alpha_file" ]
+ [ -n "$beta_file" ]
+ grep -q 'Alpha-bound task' "$alpha_file"
+ grep -q 'Sub-task that rides along' "$alpha_file" # children ride along
+ grep -q 'Beta-bound task' "$beta_file"
+ ! grep -q ':ROUTE_CANDIDATE:' "$alpha_file"
+ ! grep -q ':ROUTE_CANDIDATE:' "$beta_file"
+}
+
+@test "route-batch --go: removes routed subtrees from todo.org, leaves local tasks" {
+ run "$SCRIPT" --go
+ [ "$status" -eq 0 ]
+ ! grep -q 'Alpha-bound task' todo.org
+ ! grep -q 'Sub-task that rides along' todo.org
+ ! grep -q 'Beta-bound task' todo.org
+ grep -q 'Purely local task' todo.org
+ grep -q 'Local body stays put' todo.org
+}
+
+@test "route-batch --go: a kept property drawer survives minus the marker" {
+ run "$SCRIPT" --go
+ [ "$status" -eq 0 ]
+ beta_file=$(find "$ROOTS/beta/inbox" -name '*from-srcproj*' -type f)
+ grep -q ':CREATED: \[2026-07-01 Tue\]' "$beta_file"
+}
+
+@test "route-batch --go: destination with inbox/ but no todo.org still delivers" {
+ run "$SCRIPT" --go
+ [ "$status" -eq 0 ]
+ [ ! -f "$ROOTS/beta/todo.org" ]
+ [ -n "$(find "$ROOTS/beta/inbox" -name '*from-srcproj*' -type f)" ]
+}
+
+@test "route-batch --go: empty candidate set is a silent no-op (exit 0)" {
+ sed -i '/:ROUTE_CANDIDATE:/d' todo.org
+ before="$(cat todo.org)"
+ run "$SCRIPT" --go
+ [ "$status" -eq 0 ]
+ [ -z "$output" ]
+ [ "$(cat todo.org)" = "$before" ]
+}
+
+@test "route-batch --go: a failed send leaves that task in place, marker intact, and exits non-zero" {
+ sed -i 's/:ROUTE_CANDIDATE: beta/:ROUTE_CANDIDATE: ghost/' todo.org
+ run "$SCRIPT" --go
+ [ "$status" -ne 0 ]
+ grep -q 'Beta-bound task' todo.org # failed route stays local
+ grep -q ':ROUTE_CANDIDATE: ghost' todo.org # marker survives so it resurfaces next wrap
+ ! grep -q 'Alpha-bound task' todo.org # the good route still landed
+ [ -n "$(find "$ROOTS/alpha/inbox" -name '*from-srcproj*' -type f)" ]
+}
+
+@test "route-batch --go: handoff headings are promoted to top level" {
+ run "$SCRIPT" --go
+ [ "$status" -eq 0 ]
+ alpha_file=$(find "$ROOTS/alpha/inbox" -name '*from-srcproj*' -type f)
+ grep -q '^\* TODO \[#B\] Alpha-bound task' "$alpha_file"
+ grep -q '^\*\* TODO Sub-task that rides along' "$alpha_file"
+}
+
+@test "route-batch --go: a drawer emptied by the marker strip is pruned from the handoff" {
+ run "$SCRIPT" --go
+ [ "$status" -eq 0 ]
+ alpha_file=$(find "$ROOTS/alpha/inbox" -name '*from-srcproj*' -type f)
+ ! grep -q ':PROPERTIES:' "$alpha_file"
+}
+
+# ---- Overlapping candidates (nested marker data-loss regression) --------
+
+@test "route-batch --go: nested candidates conflict — both stay, bystander survives, exit non-zero" {
+ cat > todo.org <<'EOF'
+* Srcproj Open Work
+** TODO [#B] Parent bound for alpha
+:PROPERTIES:
+:ROUTE_CANDIDATE: alpha
+:END:
+Parent body.
+*** TODO Child bound for beta
+:PROPERTIES:
+:ROUTE_CANDIDATE: beta
+:END:
+Child body.
+** TODO [#C] Innocent bystander task
+Bystander body.
+EOF
+ run "$SCRIPT" --go
+ [ "$status" -ne 0 ]
+ [[ "$output" == *"CONFLICT"* ]]
+ grep -q 'Parent bound for alpha' todo.org
+ grep -q 'Child bound for beta' todo.org
+ grep -q 'Innocent bystander task' todo.org
+ grep -q 'Bystander body' todo.org
+ [ -z "$(find "$ROOTS/alpha/inbox" "$ROOTS/beta/inbox" -name '*from-srcproj*' -type f)" ]
+}
+
+@test "route-batch: duplicate identical markers in one drawer dedupe to a single route" {
+ cat > todo.org <<'EOF'
+* Srcproj Open Work
+** TODO [#B] Double-tagged for alpha
+:PROPERTIES:
+:ROUTE_CANDIDATE: alpha
+:ROUTE_CANDIDATE: alpha
+:END:
+Body.
+EOF
+ run "$SCRIPT" --list
+ [ "$status" -eq 0 ]
+ [ "$(echo "$output" | grep -c 'Double-tagged')" -eq 1 ]
+ [[ "$output" != *"CONFLICT"* ]]
+ run "$SCRIPT" --go
+ [ "$status" -eq 0 ]
+ [ "$(find "$ROOTS/alpha/inbox" -name '*from-srcproj*' -type f | wc -l)" -eq 1 ]
+}
diff --git a/.ai/scripts/tests/self-inject.bats b/.ai/scripts/tests/self-inject.bats
new file mode 100644
index 0000000..482f61d
--- /dev/null
+++ b/.ai/scripts/tests/self-inject.bats
@@ -0,0 +1,78 @@
+#!/usr/bin/env bats
+# Tests for self-inject.sh — tmux is the external boundary, stubbed with a
+# recording fake so no real server is needed.
+
+setup() {
+ SCRIPT="$BATS_TEST_DIRNAME/../self-inject.sh"
+ STUB_DIR="$BATS_TEST_TMPDIR/bin"
+ LOG="$BATS_TEST_TMPDIR/tmux.log"
+ mkdir -p "$STUB_DIR"
+}
+
+# A tmux stub that records every invocation and answers list-panes from
+# $STUB_PANES (empty by default, so pane derivation fails unless a test
+# provides ancestry-matching output).
+make_stub() {
+ cat > "$STUB_DIR/tmux" <<'EOF'
+#!/bin/sh
+echo "$@" >> "$LOG"
+case "$1" in
+ list-panes) printf '%s\n' "$STUB_PANES" ;;
+esac
+EOF
+ chmod +x "$STUB_DIR/tmux"
+}
+
+@test "self-inject: -t pane with no pairs echoes the pane and exits 0" {
+ make_stub
+ run env PATH="$STUB_DIR:$PATH" LOG="$LOG" STUB_PANES="" sh "$SCRIPT" -t %42
+ [ "$status" -eq 0 ]
+ [ "$output" = "%42" ]
+ # Pane was supplied, nothing sent: tmux must not have been called.
+ [ ! -e "$LOG" ]
+}
+
+@test "self-inject: no pane derivable and no -t exits 1 with an error" {
+ make_stub
+ run env PATH="$STUB_DIR:$PATH" LOG="$LOG" STUB_PANES="" sh "$SCRIPT" 0 "hello"
+ [ "$status" -eq 1 ]
+ case "$output" in *"no owning pane"*) : ;; *) false ;; esac
+}
+
+@test "self-inject: derives the pane from process ancestry via list-panes" {
+ make_stub
+ # The stub reports the bats test process itself as a pane's pane_pid;
+ # the script runs as our child, so that pid is in its ancestry.
+ run env PATH="$STUB_DIR:$PATH" LOG="$LOG" STUB_PANES="$$ %7" sh "$SCRIPT"
+ [ "$status" -eq 0 ]
+ [ "$output" = "%7" ]
+}
+
+@test "self-inject: one delay/text pair sends literal text then Enter" {
+ make_stub
+ run env PATH="$STUB_DIR:$PATH" LOG="$LOG" STUB_PANES="" sh "$SCRIPT" -t %3 0 "/clear"
+ [ "$status" -eq 0 ]
+ run cat "$LOG"
+ [ "${lines[0]}" = "send-keys -t %3 -l /clear" ]
+ [ "${lines[1]}" = "send-keys -t %3 Enter" ]
+}
+
+@test "self-inject: multiple pairs send in order" {
+ make_stub
+ run env PATH="$STUB_DIR:$PATH" LOG="$LOG" STUB_PANES="" \
+ sh "$SCRIPT" -t %3 0 "/clear" 0 "go — resume"
+ [ "$status" -eq 0 ]
+ run cat "$LOG"
+ [ "${lines[0]}" = "send-keys -t %3 -l /clear" ]
+ [ "${lines[1]}" = "send-keys -t %3 Enter" ]
+ [ "${lines[2]}" = "send-keys -t %3 -l go — resume" ]
+ [ "${lines[3]}" = "send-keys -t %3 Enter" ]
+}
+
+@test "self-inject: dangling odd argument after pairs is ignored" {
+ make_stub
+ run env PATH="$STUB_DIR:$PATH" LOG="$LOG" STUB_PANES="" sh "$SCRIPT" -t %3 0 "one" 99
+ [ "$status" -eq 0 ]
+ run cat "$LOG"
+ [ "${#lines[@]}" -eq 2 ]
+}
diff --git a/.ai/scripts/tests/spec-sort.bats b/.ai/scripts/tests/spec-sort.bats
new file mode 100644
index 0000000..583e458
--- /dev/null
+++ b/.ai/scripts/tests/spec-sort.bats
@@ -0,0 +1,453 @@
+#!/usr/bin/env bats
+#
+# Tests for claude-templates/.ai/scripts/spec-sort — the one-time docs-pile
+# retrofit from the docs-lifecycle spec: classify docs/**/*.org outside
+# docs/specs/ (spec candidate iff it carries BOTH a Decisions heading AND an
+# Implementation phases heading), show an evidence panel, and on --apply
+# move + rename confirmed candidates to docs/specs/*-spec.org, prepend the
+# status heading (:ID:, dated history line), rewrite the keyword header to
+# the two-sequence form, relink file: links across the rewritten roots,
+# stamp :LAST_SPEC_SORT: in .ai/notes.org.
+#
+# Contract under test (docs/specs/2026-07-01-docs-lifecycle-spec.org,
+# "The retrofit"):
+# - dry-run report is the default; --apply writes
+# - --apply refuses on a dirty worktree (exit 2) unless --allow-dirty
+# - every candidate needs --confirm REL=KEYWORD or --skip REL (exit 1
+# otherwise); terminal keywords need --reason REL=TEXT
+# - plan validated before the first write; destination collisions block
+# - bare-path mentions in rewritten roots block --apply until
+# --acknowledge-bare waives them (reported, never rewritten)
+# - mid-apply failure names applied/not-applied + git restore recovery
+# - idempotent: a sorted project yields no candidates, no changes
+#
+# Strategy: each test builds a throwaway git project fixture and runs the
+# real script against it. Mid-apply failure is forced via the test-only
+# SPEC_SORT_INJECT_FAIL_AFTER env hook.
+
+SCRIPT="$(cd "$(dirname "$BATS_TEST_FILENAME")/.." && pwd)/spec-sort"
+
+setup() {
+ TEST_DIR="$(mktemp -d -t spec-sort-bats.XXXXXX)"
+ PROJ="$TEST_DIR/proj"
+ mkdir -p "$PROJ"
+}
+
+teardown() {
+ rm -rf "$TEST_DIR"
+}
+
+# Standard fixture: one spec candidate, one note, a stray root spec with a
+# spine, an anomaly (-spec.org name, no spine), inbound links from todo.org,
+# a sibling note, a session archive (report-only surface), and .ai/notes.org
+# with a Workflow State section.
+make_project() {
+ cd "$PROJ"
+ git init -q
+ git config user.email test@test
+ git config user.name test
+ mkdir -p docs/design .ai/sessions
+
+ cat > docs/design/widget.org <<'EOF'
+#+TITLE: Widget Feature
+#+DATE: 2026-05-01
+#+TODO: DRAFT REVIEW | SHIPPED
+
+* Metadata
+| Status | draft |
+| Owner | Craig |
+
+* Summary
+The widget feature. See [[file:scratch-note.org][the note]].
+
+* Decisions [1/2]
+** DONE Pick the widget shape
+** TODO Pick the color
+
+* Implementation phases
+** Phase 1 — build =src/widget.py=
+EOF
+
+ cat > docs/design/scratch-note.org <<'EOF'
+#+TITLE: Scratch Note
+
+* Metadata
+| Status | n/a |
+
+* Thoughts
+See [[file:widget.org][the widget spec]].
+EOF
+
+ cat > docs/rooty-spec.org <<'EOF'
+#+TITLE: Rooty
+
+* Decisions
+** DONE Only decision
+
+* Implementation phases
+** Phase 1 — nothing
+EOF
+
+ cat > docs/lonely-spec.org <<'EOF'
+#+TITLE: Lonely
+Just prose, no spine.
+EOF
+
+ cat > todo.org <<'EOF'
+* Open Work
+** DOING [#B] Widget feature
+Spec: [[file:docs/design/widget.org][widget spec]].
+Summary anchor: [[file:docs/design/widget.org::*Summary][the summary]].
+EOF
+
+ cat > .ai/notes.org <<'EOF'
+* Active Reminders
+
+* Workflow State
+:LAST_AUDIT: 2026-06-28
+EOF
+
+ cat > .ai/sessions/2026-06-01-old.org <<'EOF'
+Old log: [[file:../../docs/design/widget.org][widget]]
+EOF
+
+ git add -A
+ git commit -qm init
+}
+
+# Confirm flags that satisfy the gate for the standard fixture's candidates.
+CONFIRM_ALL=(--confirm docs/design/widget.org=DRAFT --confirm docs/rooty-spec.org=DRAFT)
+
+# ---- Classification (dry-run) ----------------------------------------
+
+@test "spec-sort: dry-run classifies the spine-carrying doc as a candidate" {
+ make_project
+ run "$SCRIPT"
+ [ "$status" -eq 0 ]
+ [[ "$output" == *"CANDIDATE docs/design/widget.org -> docs/specs/widget-spec.org"* ]]
+}
+
+@test "spec-sort: a Metadata table alone does not qualify — note stays a note" {
+ make_project
+ run "$SCRIPT"
+ [ "$status" -eq 0 ]
+ [[ "$output" == *"NOTE docs/design/scratch-note.org"* ]]
+ [[ "$output" != *"CANDIDATE docs/design/scratch-note.org"* ]]
+}
+
+@test "spec-sort: stray root spec with a spine is a candidate, suffix not doubled" {
+ make_project
+ run "$SCRIPT"
+ [ "$status" -eq 0 ]
+ [[ "$output" == *"CANDIDATE docs/rooty-spec.org -> docs/specs/rooty-spec.org"* ]]
+ [[ "$output" != *"rooty-spec-spec.org"* ]]
+}
+
+@test "spec-sort: -spec.org name without a spine is an anomaly, never auto-moved" {
+ make_project
+ run "$SCRIPT"
+ [ "$status" -eq 0 ]
+ [[ "$output" == *"ANOMALY docs/lonely-spec.org"* ]]
+ [[ "$output" != *"CANDIDATE docs/lonely-spec.org"* ]]
+}
+
+@test "spec-sort: docs/specs/ contents are excluded from classification" {
+ make_project
+ mkdir -p docs/specs
+ cp docs/design/widget.org docs/specs/sorted-spec.org
+ git add -A && git commit -qm more
+ run "$SCRIPT"
+ [ "$status" -eq 0 ]
+ [[ "$output" != *"CANDIDATE docs/specs/sorted-spec.org"* ]]
+}
+
+@test "spec-sort: no docs/ directory is a silent no-op" {
+ cd "$PROJ"
+ git init -q
+ git config user.email test@test
+ git config user.name test
+ echo x > README.md
+ git add -A && git commit -qm init
+ run "$SCRIPT"
+ [ "$status" -eq 0 ]
+ [ -z "$output" ]
+}
+
+# ---- Evidence panel ---------------------------------------------------
+
+@test "spec-sort: evidence panel shows status field, cookies, and todo.org task" {
+ make_project
+ run "$SCRIPT"
+ [ "$status" -eq 0 ]
+ [[ "$output" == *"status field: draft"* ]]
+ [[ "$output" == *"Decisions [1/2]"* ]]
+ [[ "$output" == *"todo.org:"*"DOING"*"Widget feature"* ]]
+}
+
+@test "spec-sort: keyword proposal follows the evidence — DOING from the linked DOING task" {
+ make_project
+ run "$SCRIPT"
+ [ "$status" -eq 0 ]
+ # status field says draft, but the linking todo.org task is DOING — the
+ # panel proposes the state the strongest evidence supports
+ [[ "$output" == *"proposed keyword: DOING"* ]]
+}
+
+@test "spec-sort: an 'incomplete' status field never proposes the terminal IMPLEMENTED" {
+ make_project
+ sed -i 's/| Status | draft |/| Status | incomplete |/' docs/design/widget.org
+ git add -A && git commit -qm status
+ run "$SCRIPT"
+ [ "$status" -eq 0 ]
+ [[ "$output" != *"proposed keyword: IMPLEMENTED"* ]]
+}
+
+# ---- Confirm gate -----------------------------------------------------
+
+@test "spec-sort --apply: refuses when a candidate is neither confirmed nor skipped" {
+ make_project
+ run "$SCRIPT" --apply --confirm docs/design/widget.org=DRAFT
+ [ "$status" -eq 1 ]
+ [[ "$output" == *"unconfirmed"* ]]
+ [[ "$output" == *"docs/rooty-spec.org"* ]]
+ [ -f docs/design/widget.org ] # nothing moved
+}
+
+@test "spec-sort --apply: a terminal keyword without --reason refuses" {
+ make_project
+ run "$SCRIPT" --apply --confirm docs/design/widget.org=IMPLEMENTED --skip docs/rooty-spec.org
+ [ "$status" -eq 1 ]
+ [[ "$output" == *"--reason"* ]]
+ [ -f docs/design/widget.org ]
+}
+
+@test "spec-sort --apply: a terminal keyword with --reason records it in the history line" {
+ make_project
+ run "$SCRIPT" --apply --confirm docs/design/widget.org=IMPLEMENTED \
+ --reason "docs/design/widget.org=shipped in v2, confirmed against src" \
+ --skip docs/rooty-spec.org
+ [ "$status" -eq 0 ]
+ grep -q '^\* IMPLEMENTED Widget Feature' docs/specs/widget-spec.org
+ grep -q 'shipped in v2, confirmed against src' docs/specs/widget-spec.org
+}
+
+@test "spec-sort --apply: --skip leaves the candidate in place and still stamps the marker" {
+ make_project
+ run "$SCRIPT" --apply --skip docs/design/widget.org --skip docs/rooty-spec.org
+ [ "$status" -eq 0 ]
+ [ -f docs/design/widget.org ]
+ grep -q ':LAST_SPEC_SORT:' .ai/notes.org
+}
+
+# ---- Preflight --------------------------------------------------------
+
+@test "spec-sort --apply: refuses on a dirty worktree (exit 2)" {
+ make_project
+ echo "drift" >> todo.org
+ run "$SCRIPT" --apply "${CONFIRM_ALL[@]}"
+ [ "$status" -eq 2 ]
+ [[ "$output" == *"dirty"* ]]
+ [ -f docs/design/widget.org ]
+}
+
+@test "spec-sort --apply --allow-dirty: proceeds and names what recovery loses" {
+ make_project
+ echo "drift" >> todo.org
+ git add todo.org && git commit -qm drift # keep the link intact; dirty a different file
+ echo "scratch" > untracked-note.txt
+ echo "local edit" >> .ai/notes.org
+ run "$SCRIPT" --apply --allow-dirty "${CONFIRM_ALL[@]}"
+ [ "$status" -eq 0 ]
+ [[ "$output" == *"pre-existing"* ]]
+ [[ "$output" == *".ai/notes.org"* ]]
+ [ -f docs/specs/widget-spec.org ]
+}
+
+# ---- Move + rename + rewrite ------------------------------------------
+
+@test "spec-sort --apply: moves, renames to -spec.org, prepends status heading with :ID: and history" {
+ make_project
+ run "$SCRIPT" --apply "${CONFIRM_ALL[@]}"
+ [ "$status" -eq 0 ]
+ [ -f docs/specs/widget-spec.org ]
+ [ ! -f docs/design/widget.org ]
+ grep -q '^\* DRAFT Widget Feature' docs/specs/widget-spec.org
+ grep -q ':ID:' docs/specs/widget-spec.org
+ grep -q 'retrofitted by spec-sort' docs/specs/widget-spec.org
+}
+
+@test "spec-sort --apply: keyword header rewritten to the two-sequence form" {
+ make_project
+ run "$SCRIPT" --apply "${CONFIRM_ALL[@]}"
+ [ "$status" -eq 0 ]
+ grep -q '^#+TODO: TODO | DONE$' docs/specs/widget-spec.org
+ grep -q '^#+TODO: DRAFT READY DOING | IMPLEMENTED SUPERSEDED CANCELLED$' docs/specs/widget-spec.org
+ ! grep -q 'DRAFT REVIEW | SHIPPED' docs/specs/widget-spec.org
+}
+
+@test "spec-sort --apply: Metadata Status field mirrors the confirmed keyword in lowercase" {
+ make_project
+ run "$SCRIPT" --apply --confirm docs/design/widget.org=READY --skip docs/rooty-spec.org
+ [ "$status" -eq 0 ]
+ grep -q '^\* READY Widget Feature' docs/specs/widget-spec.org
+ grep -Eq '^\| Status[[:space:]]*\|[[:space:]]*ready' docs/specs/widget-spec.org
+}
+
+# ---- Relink -----------------------------------------------------------
+
+@test "spec-sort --apply: rewrites the todo.org link, preserving the description" {
+ make_project
+ run "$SCRIPT" --apply "${CONFIRM_ALL[@]}"
+ [ "$status" -eq 0 ]
+ grep -q '\[\[file:docs/specs/widget-spec.org\]\[widget spec\]\]' todo.org
+ ! grep -q 'docs/design/widget.org' todo.org
+}
+
+@test "spec-sort --apply: preserves a ::anchor suffix through the rewrite" {
+ make_project
+ run "$SCRIPT" --apply "${CONFIRM_ALL[@]}"
+ [ "$status" -eq 0 ]
+ grep -q '\[\[file:docs/specs/widget-spec.org::\*Summary\]\[the summary\]\]' todo.org
+}
+
+@test "spec-sort --apply: recomputes a sibling note's relative link to the moved spec" {
+ make_project
+ run "$SCRIPT" --apply "${CONFIRM_ALL[@]}"
+ [ "$status" -eq 0 ]
+ grep -q '\[\[file:../specs/widget-spec.org\]\[the widget spec\]\]' docs/design/scratch-note.org
+}
+
+@test "spec-sort --apply: recomputes the moved spec's own outbound link to an unmoved note" {
+ make_project
+ run "$SCRIPT" --apply "${CONFIRM_ALL[@]}"
+ [ "$status" -eq 0 ]
+ grep -q '\[\[file:../design/scratch-note.org\]\[the note\]\]' docs/specs/widget-spec.org
+}
+
+@test "spec-sort: session archives are reported, never rewritten" {
+ make_project
+ run "$SCRIPT"
+ [ "$status" -eq 0 ]
+ [[ "$output" == *"REPORT .ai/sessions/2026-06-01-old.org"* ]]
+ run "$SCRIPT" --apply "${CONFIRM_ALL[@]}"
+ [ "$status" -eq 0 ]
+ grep -q 'docs/design/widget.org' .ai/sessions/2026-06-01-old.org
+}
+
+@test "spec-sort: a synced template path report names the canonical rulesets file" {
+ make_project
+ mkdir -p .ai/workflows
+ echo 'See [[file:../../docs/design/widget.org][widget]]' > .ai/workflows/startup.org
+ git add -A && git commit -qm wf
+ run "$SCRIPT"
+ [ "$status" -eq 0 ]
+ [[ "$output" == *"REPORT .ai/workflows/startup.org"* ]]
+ [[ "$output" == *"claude-templates/.ai/workflows/startup.org"* ]]
+}
+
+# ---- Bare-path mentions -----------------------------------------------
+
+@test "spec-sort --apply: a bare-path mention in a rewritten root blocks until acknowledged" {
+ make_project
+ echo "raw mention: docs/design/widget.org needs review" >> todo.org
+ git add -A && git commit -qm bare
+ run "$SCRIPT" --apply "${CONFIRM_ALL[@]}"
+ [ "$status" -eq 1 ]
+ [[ "$output" == *"BARE"* ]]
+ [ -f docs/design/widget.org ] # nothing moved
+ run "$SCRIPT" --apply --acknowledge-bare "${CONFIRM_ALL[@]}"
+ [ "$status" -eq 0 ]
+ grep -q 'raw mention: docs/design/widget.org' todo.org # reported, never rewritten
+}
+
+@test "spec-sort --apply: a moving doc's bare mention of its own old path is acknowledgeable, not post-apply residue" {
+ make_project
+ echo "History: docs/design/widget.org was drafted in May." >> docs/design/widget.org
+ git add -A && git commit -qm selfmention
+ run "$SCRIPT" --apply "${CONFIRM_ALL[@]}"
+ [ "$status" -eq 1 ]
+ [[ "$output" == *"BARE"* ]]
+ run "$SCRIPT" --apply --acknowledge-bare "${CONFIRM_ALL[@]}"
+ [ "$status" -eq 0 ] # the acknowledged mention rides along to docs/specs/; not residue
+ grep -q ':LAST_SPEC_SORT:' .ai/notes.org
+}
+
+# ---- Plan validation ---------------------------------------------------
+
+@test "spec-sort --apply: a destination collision blocks validation, nothing moved" {
+ make_project
+ mkdir -p docs/specs
+ echo "occupied" > docs/specs/widget-spec.org
+ git add -A && git commit -qm occupy
+ run "$SCRIPT" --apply "${CONFIRM_ALL[@]}"
+ [ "$status" -eq 1 ]
+ [[ "$output" == *"destination exists"* ]]
+ [ -f docs/design/widget.org ]
+ [ "$(cat docs/specs/widget-spec.org)" = "occupied" ]
+}
+
+@test "spec-sort --apply: writes the plan file before executing" {
+ make_project
+ run "$SCRIPT" --apply --plan-file "$TEST_DIR/plan.json" "${CONFIRM_ALL[@]}"
+ [ "$status" -eq 0 ]
+ [ -f "$TEST_DIR/plan.json" ]
+ grep -q 'widget-spec.org' "$TEST_DIR/plan.json"
+}
+
+# ---- Mid-apply failure recovery ----------------------------------------
+
+@test "spec-sort --apply: forced mid-apply failure yields named recovery, not a half-migrated shrug" {
+ make_project
+ run env SPEC_SORT_INJECT_FAIL_AFTER=1 "$SCRIPT" --apply "${CONFIRM_ALL[@]}"
+ [ "$status" -eq 1 ]
+ [[ "$output" == *"RECOVERY"* ]]
+ [[ "$output" == *"git restore"* ]]
+ [[ "$output" == *"applied"* ]]
+ [[ "$output" == *"not applied"* ]]
+ ! grep -q ':LAST_SPEC_SORT:' .ai/notes.org # no stamp on a failed apply
+}
+
+# ---- Idempotence + marker ----------------------------------------------
+
+@test "spec-sort --apply: stamps :LAST_SPEC_SORT: in the Workflow State section" {
+ make_project
+ run "$SCRIPT" --apply "${CONFIRM_ALL[@]}"
+ [ "$status" -eq 0 ]
+ grep -q ':LAST_SPEC_SORT: ' .ai/notes.org
+ # lands inside the Workflow State section, alongside the existing marker
+ awk '/^\* Workflow State/{ws=1} ws && /:LAST_SPEC_SORT:/{found=1} END{exit !found}' .ai/notes.org
+}
+
+@test "spec-sort --apply: creates the Workflow State section when notes.org lacks it" {
+ make_project
+ printf '* Active Reminders\n' > .ai/notes.org
+ git add -A && git commit -qm notes
+ run "$SCRIPT" --apply "${CONFIRM_ALL[@]}"
+ [ "$status" -eq 0 ]
+ grep -q '^\* Workflow State' .ai/notes.org
+ grep -q ':LAST_SPEC_SORT: ' .ai/notes.org
+}
+
+@test "spec-sort --apply: zero candidates still stamps the marker (clears the nudge)" {
+ make_project
+ rm docs/design/widget.org docs/rooty-spec.org docs/lonely-spec.org
+ git add -A && git commit -qm notes-only
+ run "$SCRIPT" --apply
+ [ "$status" -eq 0 ]
+ grep -q ':LAST_SPEC_SORT:' .ai/notes.org
+}
+
+@test "spec-sort: a second run after a successful apply finds nothing to do" {
+ make_project
+ run "$SCRIPT" --apply "${CONFIRM_ALL[@]}"
+ [ "$status" -eq 0 ]
+ git add -A && git commit -qm sorted
+ run "$SCRIPT"
+ [ "$status" -eq 0 ]
+ [[ "$output" != *"CANDIDATE"* ]]
+ run "$SCRIPT" --apply
+ [ "$status" -eq 0 ]
+ run git status --porcelain
+ # only the re-stamped marker (same date) may differ — tree stays clean
+ [ -z "$(git status --porcelain -- docs todo.org)" ]
+}
diff --git a/.ai/scripts/tests/task-review-staleness.bats b/.ai/scripts/tests/task-review-staleness.bats
index 488b023..79aad79 100644
--- a/.ai/scripts/tests/task-review-staleness.bats
+++ b/.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/.ai/scripts/tests/test-lint-org.el b/.ai/scripts/tests/test-lint-org.el
index 3a83602..ceee209 100644
--- a/.ai/scripts/tests/test-lint-org.el
+++ b/.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))
@@ -659,5 +790,311 @@ missing-rules violation."
(judgments (lo-test--judgments (plist-get run :issues))))
(should-not (memq 'org-table-standard (lo-test--checkers judgments)))))
+;;; ---------------------------------------------------------------------------
+;;; level-2 dated-header check (claude-rules/todo-format.md)
+
+(ert-deftest lo-level2-dated-header-is-judgment ()
+ "A level-2 heading beginning with a YYYY-MM-DD date is flagged."
+ (let* ((out (lo-test--run
+ "* Open Work\n\n** 2026-06-20 Sat @ 10:00:00 -0500 Something resolved\nBody.\n"))
+ (res (plist-get out :result))
+ (judgments (lo-test--judgments (plist-get out :issues))))
+ (should (= 0 (plist-get out :fixes))) ; judgment-only, never auto-fixed
+ (should (member 'level-2-dated-header (lo-test--checkers judgments)))))
+
+(ert-deftest lo-level2-done-task-not-flagged ()
+ "A level-2 task closed with a terminal keyword + CLOSED: is fine."
+ (let* ((out (lo-test--run
+ "* Open Work\n\n** DONE [#B] Something resolved\nCLOSED: [2026-06-20 Sat]\nBody.\n"))
+ (judgments (lo-test--judgments (plist-get out :issues))))
+ (should-not (member 'level-2-dated-header (lo-test--checkers judgments)))))
+
+(ert-deftest lo-level3-dated-entry-not-flagged ()
+ "A dated event-log entry at level 3 is the correct sub-task shape, not a defect."
+ (let* ((out (lo-test--run
+ "* Open Work\n\n** TODO [#B] Parent task\n*** 2026-06-20 Sat @ 10:00:00 -0500 sub-entry landed\nBody.\n"))
+ (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)
+
+(defun lo-test--checker-lines (issues checker)
+ "Lines of judgment ISSUES whose :checker is CHECKER, document order."
+ (mapcar (lambda (i) (plist-get i :line))
+ (cl-remove-if-not
+ (lambda (i) (and (eq (plist-get i :kind) 'judgment)
+ (eq (plist-get i :checker) checker)))
+ (reverse issues))))
+
+(ert-deftest lo-indented-heading-flags-leading-whitespace ()
+ "Error: a heading indented off column 0 is flagged (org demotes it to body)."
+ (let* ((out (lo-test--run "* Open\n ** TODO indented and lost\n** TODO fine\n"))
+ (j (lo-test--judgments (plist-get out :issues))))
+ (should (member 'indented-heading (lo-test--checkers j)))
+ (should (= 1 (length (lo-test--checker-lines (plist-get out :issues)
+ 'indented-heading))))))
+
+(ert-deftest lo-indented-heading-skips-stars-inside-blocks ()
+ "Boundary: indented stars inside a #+begin_/#+end_ block are legitimate content."
+ (let* ((out (lo-test--run "* Open\n#+begin_example\n ** not a heading\n#+end_example\n"))
+ (j (lo-test--judgments (plist-get out :issues))))
+ (should-not (member 'indented-heading (lo-test--checkers j)))))
+
+(ert-deftest lo-indented-heading-skips-single-star-list-bullets ()
+ "Normal: an indented single `*' is a valid plain-list bullet, not a demoted
+heading, so it is not flagged — only two-or-more indented stars are."
+ (let* ((out (lo-test--run "* Open\nintro line\n * first bullet\n * second bullet\n * nested bullet\n"))
+ (j (lo-test--judgments (plist-get out :issues))))
+ (should-not (member 'indented-heading (lo-test--checkers j)))))
+
+(ert-deftest lo-empty-heading-flags-bare-stars ()
+ "Error: a line of bare stars with no title is flagged."
+ (let* ((out (lo-test--run "* Open\n** \n** TODO real\n"))
+ (j (lo-test--judgments (plist-get out :issues))))
+ (should (member 'empty-heading (lo-test--checkers j)))))
+
+(ert-deftest lo-malformed-priority-flags-lowercase-and-skips-valid ()
+ "Error + Normal: a lowercase/oversized cookie flags; a valid [#B] stays silent."
+ (let* ((bad (lo-test--run "* Open\n** TODO [#a] lowercase cookie\n** TODO [#BB] oversized\n"))
+ (ok (lo-test--run "* Open\n** TODO [#B] valid cookie\n"))
+ (jo (lo-test--judgments (plist-get ok :issues))))
+ (should (= 2 (length (lo-test--checker-lines (plist-get bad :issues)
+ 'malformed-priority-cookie))))
+ (should-not (member 'malformed-priority-cookie (lo-test--checkers jo)))))
+
+(ert-deftest lo-malformed-priority-skips-verbatim-cookie-in-title ()
+ "Boundary: a dated-log title quoting =[#D]= verbatim is not a real cookie."
+ (let* ((out (lo-test--run "* Open\n** TODO [#B] parent\n*** 2026-05-14 reprioritized =[#D]= -> =[#B]=\n"))
+ (j (lo-test--judgments (plist-get out :issues))))
+ (should-not (member 'malformed-priority-cookie (lo-test--checkers j)))))
+
+(ert-deftest lo-done-without-closed-flags-undated-level2 ()
+ "Error: a level-2 DONE with no CLOSED line is flagged; a dated one is not."
+ (let* ((bad (lo-test--run "* Resolved\n** DONE undated finished\nbody\n"))
+ (jb (lo-test--judgments (plist-get bad :issues)))
+ (ok (lo-test--run "* Resolved\n** DONE dated\nCLOSED: [2026-06-29 Mon]\n"))
+ (jo (lo-test--judgments (plist-get ok :issues))))
+ (should (member 'level2-done-without-closed (lo-test--checkers jb)))
+ (should-not (member 'level2-done-without-closed (lo-test--checkers jo)))))
+
+(ert-deftest lo-done-without-closed-ignores-deeper-levels ()
+ "Boundary: a level-3 DONE (a dated-log sub-entry) need not carry CLOSED."
+ (let* ((out (lo-test--run "* Resolved\n** DONE parent\nCLOSED: [2026-06-29 Mon]\n*** DONE nested no-closed\n"))
+ (j (lo-test--judgments (plist-get out :issues))))
+ (should-not (member 'level2-done-without-closed (lo-test--checkers j)))))
+
+(ert-deftest lo-structural-checks-silent-on-clean-file ()
+ "Normal: a well-formed file trips none of the four structural checkers."
+ (let* ((out (lo-test--run "* Open Work\n** TODO [#A] a task :tag:\n** DOING [#B] another\n* Resolved\n** DONE [#C] done\nCLOSED: [2026-06-29 Mon]\n"))
+ (checkers (lo-test--checkers (lo-test--judgments (plist-get out :issues)))))
+ (dolist (c '(indented-heading empty-heading malformed-priority-cookie
+ level2-done-without-closed))
+ (should-not (member c checkers)))))
+
(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/.ai/scripts/tests/test-todo-cleanup.el b/.ai/scripts/tests/test-todo-cleanup.el
index ad9260b..1e964b3 100644
--- a/.ai/scripts/tests/test-todo-cleanup.el
+++ b/.ai/scripts/tests/test-todo-cleanup.el
@@ -30,16 +30,22 @@
;;; Harness
(defun tc-test--reset (&optional check)
- (setq tc-fixes 0 tc-archived 0 tc-bumped 0 tc-issues nil
+ (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))
+ tc-current-file nil
+ ;; Aging step OFF by default so the in-file-move tests are unaffected by
+ ;; the wall clock; the aging harness re-enables it with fixed params.
+ tc-archive-retain-days nil tc-archive-reference-date nil tc-archive-file nil))
(defun tc-test--reset-sync (&optional check)
- (setq tc-fixes 0 tc-archived 0 tc-bumped 0 tc-issues nil
+ (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))
+ tc-current-file nil
+ tc-archive-retain-days nil tc-archive-reference-date nil tc-archive-file nil))
(defun tc-test--drop-buffer (file)
(let ((buf (find-buffer-visiting file)))
@@ -355,6 +361,207 @@ from the heading line through (not including) the next level-1 heading or EOF."
(should (tc-test--has (plist-get out :report) "skipped"))))
;;; ---------------------------------------------------------------------------
+;;; --archive-done file-aging: keep last week in-file, move older to task-archive
+
+(defun tc-test--age (content &optional opts)
+ "Run `--archive-done' with the file-aging step enabled.
+OPTS is a plist: :retain (days; default 7, may be nil to disable), :ref
+\(YEAR MONTH DAY reference date), :runs (default 1), :check. Writes CONTENT to a
+temp todo file and points `tc-archive-file' at a not-yet-existing temp archive.
+Returns a plist: :result (todo contents), :archive (archive-file contents or
+nil), :archived (in-file move count), :to-file (aged count), :issues — all from
+the last run."
+ (let* ((retain (if (plist-member opts :retain) (plist-get opts :retain) 7))
+ (ref (plist-get opts :ref))
+ (runs (or (plist-get opts :runs) 1))
+ (check (plist-get opts :check))
+ (todo (make-temp-file "tc-age-todo-" nil ".org"))
+ (adir (make-temp-file "tc-age-arch-" t))
+ (afile (expand-file-name "task-archive.org" adir))
+ last)
+ (unwind-protect
+ (progn
+ (with-temp-file todo (insert content))
+ (dotimes (_ runs)
+ (tc-test--reset check)
+ (setq tc-archive-retain-days retain
+ tc-archive-reference-date ref
+ tc-archive-file afile)
+ (tc-process-file todo)
+ (setq last (list :archived tc-archived :to-file tc-archived-to-file
+ :issues tc-issues))
+ (tc-test--drop-buffer todo))
+ (append
+ last
+ (list :result (with-temp-buffer (insert-file-contents todo) (buffer-string))
+ :archive (and (file-readable-p afile)
+ (with-temp-buffer (insert-file-contents afile)
+ (buffer-string))))))
+ (tc-test--drop-buffer todo)
+ (delete-file todo)
+ (delete-directory adir t))))
+
+;; Reference "today" for these fixtures is 2026-06-29; with retain 7 the cutoff
+;; is 2026-06-22, so a task closed on or after 2026-06-22 stays in-file.
+(defconst tc-test--age-resolved "\
+* Age Open Work
+** TODO [#A] still open
+* Age Resolved
+** DONE [#B] recent within window
+CLOSED: [2026-06-25 Thu]
+recent body
+** DONE [#C] old beyond window
+CLOSED: [2026-05-01 Fri]
+old body line
+** CANCELLED [#C] old cancelled too
+CLOSED: [2026-04-15 Wed]
+** DONE [#B] exactly at cutoff stays
+CLOSED: [2026-06-22 Sun]
+** DONE [#C] undated no-date archived
+no closed date in this body
+")
+
+(defconst tc-test--age-straggler "\
+* Age Open Work
+** TODO [#A] still open
+** DONE [#C] old straggler
+CLOSED: [2026-03-01 Sun]
+straggler body
+* Age Resolved
+** DONE [#B] recent stays
+CLOSED: [2026-06-26 Fri]
+")
+
+(ert-deftest tc-age-moves-old-and-undated-resolved ()
+ "Normal: closed-beyond-window AND undated subtrees leave the file; only those
+closed within the window (cutoff inclusive) stay."
+ (let* ((out (tc-test--age tc-test--age-resolved '(:ref (2026 6 29))))
+ (resolved (tc-test--section (plist-get out :result) "Age Resolved"))
+ (arch (plist-get out :archive)))
+ (should (= 3 (plist-get out :to-file)))
+ (should-not (tc-test--has resolved "old beyond window"))
+ (should-not (tc-test--has resolved "old cancelled too"))
+ (should-not (tc-test--has resolved "undated no-date archived"))
+ (should (tc-test--has resolved "recent within window"))
+ (should (tc-test--has resolved "exactly at cutoff stays"))
+ (should arch)
+ (should (tc-test--has arch "Resolved (archived)"))
+ (should (tc-test--has arch "old beyond window"))
+ (should (tc-test--has arch "old body line"))
+ (should (tc-test--has arch "old cancelled too"))
+ (should (tc-test--has arch "undated no-date archived"))
+ (should-not (tc-test--has arch "recent within window"))))
+
+(ert-deftest tc-age-disabled-when-retain-nil ()
+ "Boundary: nil retain disables the aging step entirely (legacy behavior)."
+ (let ((out (tc-test--age tc-test--age-resolved '(:retain nil :ref (2026 6 29)))))
+ (should (= 0 (plist-get out :to-file)))
+ (should (equal tc-test--age-resolved (plist-get out :result)))
+ (should-not (plist-get out :archive))))
+
+(ert-deftest tc-age-is-idempotent ()
+ "Boundary: a second run finds nothing new to age; the todo file is stable."
+ (let ((once (tc-test--age tc-test--age-resolved '(:ref (2026 6 29) :runs 1)))
+ (twice (tc-test--age tc-test--age-resolved '(:ref (2026 6 29) :runs 2))))
+ (should (equal (plist-get once :result) (plist-get twice :result)))
+ (should (= 0 (plist-get twice :to-file)))))
+
+(ert-deftest tc-age-check-mode-previews-without-writing ()
+ "Boundary: --check reports the aged count but writes neither file."
+ (let ((out (tc-test--age tc-test--age-resolved '(:ref (2026 6 29) :check t))))
+ (should (= 3 (plist-get out :to-file)))
+ (should (equal tc-test--age-resolved (plist-get out :result)))
+ (should-not (plist-get out :archive))))
+
+(ert-deftest tc-age-straggler-moves-through-to-archive ()
+ "Normal: an old-dated DONE in Open Work moves to Resolved then ages out in one run."
+ (let* ((out (tc-test--age tc-test--age-straggler '(:ref (2026 6 29))))
+ (open (tc-test--section (plist-get out :result) "Age Open Work"))
+ (resolved (tc-test--section (plist-get out :result) "Age Resolved"))
+ (arch (plist-get out :archive)))
+ (should-not (tc-test--has open "old straggler"))
+ (should-not (tc-test--has resolved "old straggler"))
+ (should (tc-test--has arch "old straggler"))
+ (should (tc-test--has arch "straggler body"))
+ (should (tc-test--has resolved "recent stays"))
+ (should (= 1 (plist-get out :archived)))
+ (should (= 1 (plist-get out :to-file)))))
+
+(ert-deftest tc-age-append-preserves-existing-archive ()
+ "Error/edge: appending to a populated archive keeps prior entries and one scaffold."
+ (let* ((adir (make-temp-file "tc-arch-" t))
+ (afile (expand-file-name "task-archive.org" adir)))
+ (unwind-protect
+ (progn
+ (tc--append-subtrees-to-archive-file afile (list "** DONE one\n"))
+ (tc--append-subtrees-to-archive-file afile (list "** DONE two\n"))
+ (let ((content (with-temp-buffer (insert-file-contents afile)
+ (buffer-string)))
+ (n 0) (start 0))
+ (should (tc-test--has content "** DONE one"))
+ (should (tc-test--has content "** DONE two"))
+ (should (tc-test--before-p content "** DONE one" "** DONE two"))
+ (while (string-match "\\* Resolved (archived)" content start)
+ (setq n (1+ n) start (match-end 0)))
+ (should (= 1 n))))
+ (delete-directory adir t))))
+
+;;; ---------------------------------------------------------------------------
+;;; --archive-done aging: the archive follows the todo file's gitignore status
+
+(defun tc-test--age-in-git-repo (gitignore-todo)
+ "Init a temp git repo, write todo.org with an old Resolved entry, optionally
+gitignore todo.org, then run `--archive-done' aging with the DEFAULT archive path
+(archive/task-archive.org beside the todo file). Return a plist: :gitignore (final
+.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)))
+ (unwind-protect
+ (let ((default-directory root))
+ (call-process "git" nil nil nil "init" "-q")
+ (with-temp-file todo (insert tc-test--age-resolved))
+ (when gitignore-todo (with-temp-file gi (insert "/todo.org\n")))
+ (tc-test--reset nil)
+ (setq tc-archive-retain-days 7
+ tc-archive-reference-date '(2026 6 29)
+ tc-archive-file nil) ; default path, beside the todo file
+ (tc-process-file todo)
+ (tc-test--drop-buffer todo)
+ (list :gitignore (and (file-readable-p gi)
+ (with-temp-buffer (insert-file-contents gi)
+ (buffer-string)))
+ :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 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
+so it inherits the same privacy."
+ (let ((out (tc-test--age-in-git-repo t)))
+ (should (plist-get out :archive-exists))
+ (should (string-match-p "task-archive" (or (plist-get out :gitignore) "")))
+ (should (plist-get out :archive-ignored))))
+
+(ert-deftest tc-age-self-protect-leaves-tracked-todo-archive-tracked ()
+ "When the todo file is tracked, the archive is not gitignored — no .gitignore
+entry is added for it."
+ (let ((out (tc-test--age-in-git-repo nil)))
+ (should (plist-get out :archive-exists))
+ (should-not (plist-get out :archive-ignored))
+ (should-not (string-match-p "task-archive" (or (plist-get out :gitignore) "")))))
+
+;;; ---------------------------------------------------------------------------
;;; Realistic synthetic sample (committed under fixtures/)
(defun tc-test--sample-file ()
@@ -380,6 +587,95 @@ from the heading line through (not including) the next level-1 heading or EOF."
(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)
@@ -570,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 cycle). 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/.ai/scripts/tests/test-wrap-org-table.el b/.ai/scripts/tests/test-wrap-org-table.el
index 8d1ecb6..0b3b375 100644
--- a/.ai/scripts/tests/test-wrap-org-table.el
+++ b/.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/.ai/scripts/tests/test_apkg_to_orgdrill.py b/.ai/scripts/tests/test_apkg_to_orgdrill.py
new file mode 100644
index 0000000..6a95ea4
--- /dev/null
+++ b/.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/.ai/scripts/tests/test_cj_remove_block.py b/.ai/scripts/tests/test_cj_remove_block.py
index 2c8dade..3cdee46 100644
--- a/.ai/scripts/tests/test_cj_remove_block.py
+++ b/.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/.ai/scripts/tests/test_cross_agent_discover.py b/.ai/scripts/tests/test_cross_agent_discover.py
deleted file mode 100644
index f0d2bb7..0000000
--- a/.ai/scripts/tests/test_cross_agent_discover.py
+++ /dev/null
@@ -1,204 +0,0 @@
-"""Tests for cross-agent-discover (TDD: tests written before implementation)."""
-
-from __future__ import annotations
-
-import json
-import os
-import subprocess
-import textwrap
-from pathlib import Path
-
-import pytest
-
-SCRIPT = Path(__file__).resolve().parent.parent / "cross-agent-comms" / "cross-agent-discover"
-
-
-def _run(args: list[str], env: dict | None = None) -> subprocess.CompletedProcess:
- return subprocess.run([str(SCRIPT), *args], capture_output=True, text=True, env=env)
-
-
-@pytest.fixture
-def fake_home(tmp_path, monkeypatch):
- home = tmp_path / "home"
- home.mkdir()
- monkeypatch.setenv("HOME", str(home))
- return home
-
-
-def _make_project(home: Path, name: str) -> Path:
- proj = home / "projects" / name
- (proj / ".ai").mkdir(parents=True)
- return proj
-
-
-def _write_peers_toml(home: Path, content: str) -> Path:
- cfg = home / ".config" / "cross-agent-comms"
- cfg.mkdir(parents=True, exist_ok=True)
- peers = cfg / "peers.toml"
- peers.write_text(content)
- return peers
-
-
-def test_discover_help(fake_home):
- result = _run(["--help"], env={**os.environ, "HOME": str(fake_home)})
- assert result.returncode == 0
- assert "discover" in result.stdout.lower() or "enumerate" in result.stdout.lower()
-
-
-def test_discover_local_only_no_projects(fake_home):
- """Empty home → reports zero local projects, zero peers."""
- result = _run(["--no-cache"], env={**os.environ, "HOME": str(fake_home)})
- assert result.returncode == 0
- # No crash; mentions local somehow.
- assert "local" in result.stdout.lower() or "0 project" in result.stdout.lower()
-
-
-def test_discover_lists_local_projects(fake_home):
- _make_project(fake_home, "homelab")
- _make_project(fake_home, "career")
- _make_project(fake_home, "claude-templates")
- result = _run(["--no-cache"], env={**os.environ, "HOME": str(fake_home)})
- assert result.returncode == 0
- assert "homelab" in result.stdout
- assert "career" in result.stdout
- assert "claude-templates" in result.stdout
-
-
-def test_discover_excludes_dirs_without_ai_subdir(fake_home):
- """Directories under ~/projects/ that lack .ai/ are NOT projects."""
- _make_project(fake_home, "real-project")
- (fake_home / "projects" / "not-a-project").mkdir(parents=True)
- result = _run(["--no-cache"], env={**os.environ, "HOME": str(fake_home)})
- assert result.returncode == 0
- assert "real-project" in result.stdout
- assert "not-a-project" not in result.stdout
-
-
-def test_discover_no_peers_toml_just_local(fake_home):
- _make_project(fake_home, "homelab")
- result = _run(["--no-cache"], env={**os.environ, "HOME": str(fake_home)})
- assert result.returncode == 0
- # No peers section since no toml.
- assert "homelab" in result.stdout
-
-
-def test_discover_lists_peers_from_toml(fake_home):
- _write_peers_toml(fake_home, textwrap.dedent("""\
- [peers.velox]
- host = "velox"
- ssh_user = "cjennings"
-
- [peers.bastion]
- host = "bastion.local"
- ssh_user = "cjennings"
- """))
- _make_project(fake_home, "homelab")
- result = _run(["--no-cache"], env={**os.environ, "HOME": str(fake_home)})
- assert result.returncode == 0
- assert "velox" in result.stdout
- assert "bastion" in result.stdout
-
-
-def test_discover_malformed_peers_toml_errors_clearly(fake_home):
- _write_peers_toml(fake_home, "not valid toml at all = = =")
- result = _run(["--no-cache"], env={**os.environ, "HOME": str(fake_home)})
- assert result.returncode != 0
- assert "peers.toml" in result.stderr or "TOML" in result.stderr or "parse" in result.stderr.lower()
-
-
-def test_discover_json_output_schema(fake_home):
- _make_project(fake_home, "homelab")
- _make_project(fake_home, "career")
- _write_peers_toml(fake_home, textwrap.dedent("""\
- [peers.velox]
- host = "velox"
- """))
- result = _run(["--json", "--no-cache"], env={**os.environ, "HOME": str(fake_home)})
- assert result.returncode == 0
- payload = json.loads(result.stdout)
- assert "local" in payload
- assert "peers" in payload
- assert isinstance(payload["local"], list)
- assert isinstance(payload["peers"], list)
- assert "homelab" in payload["local"]
- assert "career" in payload["local"]
- velox = next((p for p in payload["peers"] if p["name"] == "velox"), None)
- assert velox is not None
- # Reachability is a key — value depends on actual SSH state.
- assert "reachable" in velox
-
-
-def test_discover_peer_scope(fake_home):
- _write_peers_toml(fake_home, textwrap.dedent("""\
- [peers.velox]
- host = "velox"
-
- [peers.bastion]
- host = "bastion.local"
- """))
- result = _run(["--peer", "velox", "--no-cache", "--json"], env={**os.environ, "HOME": str(fake_home)})
- assert result.returncode == 0
- payload = json.loads(result.stdout)
- peer_names = [p["name"] for p in payload["peers"]]
- assert "velox" in peer_names
- assert "bastion" not in peer_names
-
-
-def test_discover_unreachable_peer_marked(fake_home):
- """A peer with a definitely-unreachable host gets reachable=False."""
- _write_peers_toml(fake_home, textwrap.dedent("""\
- [peers.bogus]
- host = "definitely-not-a-real-host.invalid"
- ssh_user = "nobody"
- """))
- result = _run(["--no-cache", "--json"], env={**os.environ, "HOME": str(fake_home)}, )
- assert result.returncode == 0
- payload = json.loads(result.stdout)
- bogus = next((p for p in payload["peers"] if p["name"] == "bogus"), None)
- assert bogus is not None
- assert bogus["reachable"] is False
-
-
-def test_discover_cache_hit_within_window(fake_home):
- """Second invocation within 5 min reads cache (skip the SSH probe)."""
- _make_project(fake_home, "homelab")
- # First call populates cache.
- result1 = _run(["--json"], env={**os.environ, "HOME": str(fake_home)})
- assert result1.returncode == 0
- cache = fake_home / ".cache" / "cross-agent-comms" / "discovery.json"
- assert cache.exists()
- # Tamper with the cache to a marker only the cache path can produce.
- payload = json.loads(cache.read_text())
- payload["_test_marker"] = True
- cache.write_text(json.dumps(payload))
- # Second call (no --no-cache) should return the tampered payload.
- result2 = _run(["--json"], env={**os.environ, "HOME": str(fake_home)})
- assert result2.returncode == 0
- payload2 = json.loads(result2.stdout)
- assert payload2.get("_test_marker") is True
-
-
-def test_discover_no_cache_flag_bypasses(fake_home):
- """--no-cache ignores even a fresh cache."""
- _make_project(fake_home, "homelab")
- cache_dir = fake_home / ".cache" / "cross-agent-comms"
- cache_dir.mkdir(parents=True)
- cache_dir.joinpath("discovery.json").write_text(json.dumps({
- "_test_marker": True, "local": [], "peers": []
- }))
- result = _run(["--no-cache", "--json"], env={**os.environ, "HOME": str(fake_home)})
- assert result.returncode == 0
- payload = json.loads(result.stdout)
- # Cache marker should NOT appear in fresh result.
- assert payload.get("_test_marker") is None or payload.get("_test_marker") is False
- assert "homelab" in payload["local"]
-
-
-def test_discover_halt_shows_banner(fake_home):
- halt = fake_home / ".config" / "cross-agent-comms" / "HALT"
- halt.parent.mkdir(parents=True)
- halt.write_text("halted")
- _make_project(fake_home, "homelab")
- result = _run(["--no-cache"], env={**os.environ, "HOME": str(fake_home)})
- assert result.returncode == 0 # discover continues to print under HALT
- assert "HALT" in result.stdout
diff --git a/.ai/scripts/tests/test_cross_agent_halt.py b/.ai/scripts/tests/test_cross_agent_halt.py
deleted file mode 100644
index f8bf0b3..0000000
--- a/.ai/scripts/tests/test_cross_agent_halt.py
+++ /dev/null
@@ -1,204 +0,0 @@
-"""Tests for cross-agent-halt and cross-agent-resume (TDD)."""
-
-from __future__ import annotations
-
-import os
-import subprocess
-import textwrap
-from pathlib import Path
-
-import pytest
-
-HALT_SCRIPT = Path(__file__).resolve().parent.parent / "cross-agent-comms" / "cross-agent-halt"
-RESUME_SCRIPT = Path(__file__).resolve().parent.parent / "cross-agent-comms" / "cross-agent-resume"
-
-
-def _run(script: Path, args: list[str], env: dict | None = None) -> subprocess.CompletedProcess:
- return subprocess.run([str(script), *args], capture_output=True, text=True, env=env)
-
-
-@pytest.fixture
-def isolated_env(tmp_path, monkeypatch):
- """Isolated HOME + a fake systemctl that records calls without acting."""
- fake_home = tmp_path / "home"
- fake_home.mkdir()
- fake_bin = tmp_path / "bin"
- fake_bin.mkdir()
- # Fake systemctl: no-op, exit 0.
- fake_systemctl = fake_bin / "systemctl"
- fake_systemctl.write_text("#!/usr/bin/env bash\nexit 0\n")
- fake_systemctl.chmod(0o755)
- # Fake ssh: succeed only for known-good host.
- fake_ssh = fake_bin / "ssh"
- fake_ssh.write_text(textwrap.dedent("""\
- #!/usr/bin/env bash
- # Find the destination arg (skip flags).
- target=""
- for arg in "$@"; do
- case "$arg" in
- -*|*=*) ;;
- *@*|localhost|*.local|*.invalid) target="$arg"; break ;;
- *) target="$arg"; break ;;
- esac
- done
- case "$target" in
- *invalid*|*unreachable*) exit 255 ;;
- *) exit 0 ;;
- esac
- """))
- fake_ssh.chmod(0o755)
-
- monkeypatch.setenv("HOME", str(fake_home))
- # Prepend our fake bin so systemctl + ssh are intercepted, but keep real /bin etc.
- monkeypatch.setenv("PATH", f"{fake_bin}:{os.environ.get('PATH', '')}")
- return fake_home
-
-
-# ---- cross-agent-halt ----
-
-
-def test_halt_help(isolated_env):
- result = _run(HALT_SCRIPT, ["--help"], env={**os.environ, "HOME": str(isolated_env),
- "PATH": os.environ["PATH"]})
- assert result.returncode == 0
- assert "halt" in result.stdout.lower()
-
-
-def test_halt_creates_halt_file(isolated_env):
- halt_file = isolated_env / ".config" / "cross-agent-comms" / "HALT"
- assert not halt_file.exists()
- result = _run(HALT_SCRIPT, [], env={**os.environ, "HOME": str(isolated_env),
- "PATH": os.environ["PATH"]})
- assert result.returncode == 0
- assert halt_file.exists()
-
-
-def test_halt_with_reason_writes_body(isolated_env):
- result = _run(HALT_SCRIPT, ["pausing for incident review"],
- env={**os.environ, "HOME": str(isolated_env), "PATH": os.environ["PATH"]})
- assert result.returncode == 0
- halt_file = isolated_env / ".config" / "cross-agent-comms" / "HALT"
- assert halt_file.exists()
- assert "pausing for incident review" in halt_file.read_text()
-
-
-def test_halt_idempotent(isolated_env):
- """Running halt twice doesn't error."""
- halt_file = isolated_env / ".config" / "cross-agent-comms" / "HALT"
- r1 = _run(HALT_SCRIPT, [], env={**os.environ, "HOME": str(isolated_env), "PATH": os.environ["PATH"]})
- assert r1.returncode == 0
- assert halt_file.exists()
- r2 = _run(HALT_SCRIPT, [], env={**os.environ, "HOME": str(isolated_env), "PATH": os.environ["PATH"]})
- assert r2.returncode == 0
- assert halt_file.exists()
-
-
-def test_halt_does_not_pkill(isolated_env):
- """Per design: halt does NOT call pkill. Verify by checking no pkill process gets launched."""
- # Replace pkill in PATH with something that fails loudly so we'd see if halt invoked it.
- fake_bin = isolated_env.parent / "bin"
- pkill = fake_bin / "pkill"
- pkill.write_text("#!/usr/bin/env bash\necho 'PKILL CALLED' >&2\nexit 99\n")
- pkill.chmod(0o755)
- result = _run(HALT_SCRIPT, [], env={**os.environ, "HOME": str(isolated_env), "PATH": os.environ["PATH"]})
- assert result.returncode == 0
- assert "PKILL CALLED" not in result.stderr
-
-
-def test_halt_tailnet_reports_per_peer(isolated_env):
- """--tailnet iterates peers.toml and reports per-peer status."""
- cfg = isolated_env / ".config" / "cross-agent-comms"
- cfg.mkdir(parents=True)
- (cfg / "peers.toml").write_text(textwrap.dedent("""\
- [peers.velox]
- host = "velox"
- ssh_user = "cjennings"
-
- [peers.bogus]
- host = "definitely-unreachable.invalid"
- ssh_user = "cjennings"
- """))
- result = _run(HALT_SCRIPT, ["--tailnet"],
- env={**os.environ, "HOME": str(isolated_env), "PATH": os.environ["PATH"]})
- # Partial halt → exit 1.
- assert result.returncode == 1
- assert "velox" in result.stdout
- assert "bogus" in result.stdout
- # ✓ marker for velox, ✗ for bogus.
- assert "✓" in result.stdout
- assert "✗" in result.stdout
- assert "PARTIAL" in result.stdout or "partial" in result.stdout.lower()
-
-
-def test_halt_tailnet_all_reachable_exits_zero(isolated_env):
- cfg = isolated_env / ".config" / "cross-agent-comms"
- cfg.mkdir(parents=True)
- (cfg / "peers.toml").write_text(textwrap.dedent("""\
- [peers.velox]
- host = "velox"
- ssh_user = "cjennings"
- """))
- result = _run(HALT_SCRIPT, ["--tailnet"],
- env={**os.environ, "HOME": str(isolated_env), "PATH": os.environ["PATH"]})
- assert result.returncode == 0
- assert "velox" in result.stdout
-
-
-# ---- cross-agent-resume ----
-
-
-def test_resume_help(isolated_env):
- result = _run(RESUME_SCRIPT, ["--help"],
- env={**os.environ, "HOME": str(isolated_env), "PATH": os.environ["PATH"]})
- assert result.returncode == 0
- assert "resume" in result.stdout.lower()
-
-
-def test_resume_removes_halt_file(isolated_env):
- halt_file = isolated_env / ".config" / "cross-agent-comms" / "HALT"
- halt_file.parent.mkdir(parents=True)
- halt_file.write_text("halted")
- assert halt_file.exists()
- result = _run(RESUME_SCRIPT, [],
- env={**os.environ, "HOME": str(isolated_env), "PATH": os.environ["PATH"]})
- assert result.returncode == 0
- assert not halt_file.exists()
-
-
-def test_resume_when_no_halt_active_succeeds(isolated_env):
- """No HALT to clear is not an error."""
- result = _run(RESUME_SCRIPT, [],
- env={**os.environ, "HOME": str(isolated_env), "PATH": os.environ["PATH"]})
- assert result.returncode == 0
-
-
-def test_resume_prints_per_session_instructions(isolated_env):
- """Resume must surface that polling does NOT auto-resume."""
- halt_file = isolated_env / ".config" / "cross-agent-comms" / "HALT"
- halt_file.parent.mkdir(parents=True)
- halt_file.write_text("halted")
- result = _run(RESUME_SCRIPT, [],
- env={**os.environ, "HOME": str(isolated_env), "PATH": os.environ["PATH"]})
- assert result.returncode == 0
- out = result.stdout.lower()
- assert "polling" in out
- assert "auto" in out or "explicit" in out or "session" in out
-
-
-def test_resume_tailnet_partial_failure_exit_1(isolated_env):
- cfg = isolated_env / ".config" / "cross-agent-comms"
- cfg.mkdir(parents=True)
- (cfg / "peers.toml").write_text(textwrap.dedent("""\
- [peers.velox]
- host = "velox"
-
- [peers.bogus]
- host = "unreachable-host.invalid"
- """))
- halt_file = cfg / "HALT"
- halt_file.write_text("halted")
- result = _run(RESUME_SCRIPT, ["--tailnet"],
- env={**os.environ, "HOME": str(isolated_env), "PATH": os.environ["PATH"]})
- assert result.returncode == 1
- assert "velox" in result.stdout
- assert "bogus" in result.stdout
diff --git a/.ai/scripts/tests/test_cross_agent_recv.py b/.ai/scripts/tests/test_cross_agent_recv.py
deleted file mode 100644
index 27c53a5..0000000
--- a/.ai/scripts/tests/test_cross_agent_recv.py
+++ /dev/null
@@ -1,176 +0,0 @@
-"""Tests for cross-agent-recv."""
-
-from __future__ import annotations
-
-import json
-import os
-import subprocess
-from pathlib import Path
-
-import pytest
-
-SCRIPT = Path(__file__).resolve().parent.parent / "cross-agent-comms" / "cross-agent-recv"
-
-
-def _make_message(path: Path, *, conv_id: str = "test-conv", seq: int = 1, msg_type: str = "request",
- proto_version: str = "5", title: str = "Test", requires_tools: str | None = None,
- body: str = "Body.\n") -> Path:
- fm_lines = [
- f"#+TITLE: {title}",
- f"#+CONVERSATION_ID: {conv_id}",
- f"#+MESSAGE_TYPE: {msg_type}",
- f"#+SEQUENCE: {seq}",
- "#+TIMESTAMP: 2026-04-27T05:00:00-05:00",
- f"#+PROTOCOL_VERSION: {proto_version}",
- ]
- if requires_tools:
- fm_lines.append(f"#+REQUIRES_TOOLS: {requires_tools}")
- path.write_text("\n".join(fm_lines) + "\n\n" + body)
- return path
-
-
-def _run(args: list[str], env: dict | None = None) -> subprocess.CompletedProcess:
- return subprocess.run([str(SCRIPT), *args], capture_output=True, text=True, env=env)
-
-
-@pytest.fixture
-def isolated_env(tmp_path, monkeypatch):
- fake_home = tmp_path / "home"
- fake_home.mkdir()
- monkeypatch.setenv("HOME", str(fake_home))
- return fake_home
-
-
-def test_recv_help(isolated_env):
- result = _run(["--help"], env={**os.environ, "HOME": str(isolated_env)})
- assert result.returncode == 0
- assert "Receive and decide" in result.stdout
-
-
-def test_recv_missing_file_rejects(isolated_env, tmp_path):
- result = _run([str(tmp_path / "nope.org")], env={**os.environ, "HOME": str(isolated_env)})
- assert result.returncode == 3 # reject
-
-
-def test_recv_malformed_frontmatter_rejects(isolated_env, tmp_path):
- bad = tmp_path / "bad.org"
- bad.write_text("not org-mode at all\n")
- result = _run([str(bad), "--no-verify"], env={**os.environ, "HOME": str(isolated_env)})
- assert result.returncode == 3
- assert "decision: reject" in result.stdout
-
-
-def test_recv_missing_required_field_rejects(isolated_env, tmp_path):
- msg = tmp_path / "msg.org"
- # Missing PROTOCOL_VERSION among others.
- msg.write_text("#+TITLE: x\n#+CONVERSATION_ID: c\n\nBody.\n")
- result = _run([str(msg), "--no-verify"], env={**os.environ, "HOME": str(isolated_env)})
- assert result.returncode == 3
- assert "missing required" in result.stdout
-
-
-def test_recv_protocol_version_mismatch_query(isolated_env, tmp_path):
- msg = _make_message(tmp_path / "msg.org", proto_version="4")
- result = _run([str(msg), "--no-verify"], env={**os.environ, "HOME": str(isolated_env)})
- assert result.returncode == 2 # query
- assert "PROTOCOL_VERSION mismatch" in result.stdout
-
-
-def test_recv_invalid_message_type_rejects(isolated_env, tmp_path):
- msg = _make_message(tmp_path / "msg.org", msg_type="banana")
- result = _run([str(msg), "--no-verify"], env={**os.environ, "HOME": str(isolated_env)})
- assert result.returncode == 3
- assert "invalid MESSAGE_TYPE" in result.stdout
-
-
-def test_recv_missing_signature_rejects(isolated_env, tmp_path):
- """When verify is on, a missing .asc sibling rejects."""
- msg = _make_message(tmp_path / "msg.org")
- # No .asc sidecar.
- result = _run([str(msg)], env={**os.environ, "HOME": str(isolated_env)})
- assert result.returncode == 3
- assert "signature file missing" in result.stdout
-
-
-def test_recv_valid_processes(isolated_env, tmp_path):
- """A valid message with --no-verify and no dedup match → process."""
- msg = _make_message(tmp_path / "msg.org")
- result = _run([str(msg), "--no-verify"], env={**os.environ, "HOME": str(isolated_env)})
- assert result.returncode == 0 # process
- assert "decision: process" in result.stdout
- assert "sha256:" in result.stdout
-
-
-def test_recv_dedup_against_identical_existing(isolated_env, tmp_path):
- """Same content + same SEQUENCE in same dir → dedup."""
- inbox = tmp_path / "inbox"
- inbox.mkdir()
- first = _make_message(inbox / "20260427T100000Z-from-x-c.org", conv_id="c", seq=5)
- # Second message with same content — name differs (canonical-style would have different timestamp).
- second = _make_message(inbox / "20260427T100100Z-from-x-c.org", conv_id="c", seq=5)
- # Bodies must be byte-identical for hash equality.
- second.write_bytes(first.read_bytes())
- result = _run([str(second), "--no-verify"], env={**os.environ, "HOME": str(isolated_env)})
- assert result.returncode == 1 # dedup
- assert "decision: dedup" in result.stdout
-
-
-def test_recv_collision_with_different_content_processes(isolated_env, tmp_path):
- """Same SEQUENCE + same CONVERSATION_ID but different content → process both."""
- inbox = tmp_path / "inbox"
- inbox.mkdir()
- _make_message(inbox / "20260427T100000Z-from-x-c.org", conv_id="c", seq=5, body="First body.\n")
- second = _make_message(inbox / "20260427T100100Z-from-x-c.org", conv_id="c", seq=5, body="Different body.\n")
- result = _run([str(second), "--no-verify"], env={**os.environ, "HOME": str(isolated_env)})
- assert result.returncode == 0 # process
- assert "decision: process" in result.stdout
-
-
-def test_recv_requires_tools_missing_query(isolated_env, tmp_path):
- """REQUIRES_TOOLS naming a definitely-missing binary → query."""
- msg = _make_message(tmp_path / "msg.org", requires_tools="definitely-not-installed-xyzzy-9000")
- result = _run([str(msg), "--no-verify"], env={**os.environ, "HOME": str(isolated_env)})
- assert result.returncode == 2 # query
- assert "required tools unavailable" in result.stdout
-
-
-def test_recv_requires_tools_present_processes(isolated_env, tmp_path):
- """REQUIRES_TOOLS naming a real binary → process."""
- msg = _make_message(tmp_path / "msg.org", requires_tools="ls,cat")
- result = _run([str(msg), "--no-verify"], env={**os.environ, "HOME": str(isolated_env)})
- assert result.returncode == 0
- assert "decision: process" in result.stdout
-
-
-def test_recv_json_output(isolated_env, tmp_path):
- msg = _make_message(tmp_path / "msg.org")
- result = _run([str(msg), "--no-verify", "--json"], env={**os.environ, "HOME": str(isolated_env)})
- assert result.returncode == 0
- payload = json.loads(result.stdout)
- assert payload["decision"] == "process"
- assert payload["message_type"] == "request"
- assert payload["conversation_id"] == "test-conv"
-
-
-def test_recv_halt_blocks(isolated_env, tmp_path):
- halt = isolated_env / ".config" / "cross-agent-comms" / "HALT"
- halt.parent.mkdir(parents=True)
- halt.write_text("halted\n")
- msg = _make_message(tmp_path / "msg.org")
- result = _run([str(msg), "--no-verify"], env={**os.environ, "HOME": str(isolated_env)})
- assert result.returncode == 5
- assert "halt active" in result.stderr.lower()
-
-
-def test_recv_halt_leaves_message_in_place(isolated_env, tmp_path):
- """Per spec: under HALT, recv must NOT move/dedup/reject — leave file in place."""
- halt = isolated_env / ".config" / "cross-agent-comms" / "HALT"
- halt.parent.mkdir(parents=True)
- halt.write_text("halted\n")
- msg = _make_message(tmp_path / "msg.org")
- pre_content = msg.read_text()
- result = _run([str(msg), "--no-verify"], env={**os.environ, "HOME": str(isolated_env)})
- assert result.returncode == 5
- # File still exists with same content.
- assert msg.exists()
- assert msg.read_text() == pre_content
diff --git a/.ai/scripts/tests/test_cross_agent_send.py b/.ai/scripts/tests/test_cross_agent_send.py
deleted file mode 100644
index f716e95..0000000
--- a/.ai/scripts/tests/test_cross_agent_send.py
+++ /dev/null
@@ -1,210 +0,0 @@
-"""Tests for cross-agent-send.
-
-Subprocess-based: treat the script as a black-box CLI and assert on its
-exit codes, stdout, and the files it produces.
-"""
-
-from __future__ import annotations
-
-import os
-import subprocess
-import textwrap
-from pathlib import Path
-
-import pytest
-
-SCRIPT = Path(__file__).resolve().parent.parent / "cross-agent-comms" / "cross-agent-send"
-
-
-def _make_message(tmp_path: Path, conv_id: str = "test-conv", seq: int = 1, msg_type: str = "request",
- proto_version: str = "5") -> Path:
- msg = tmp_path / "msg.org"
- msg.write_text(textwrap.dedent(f"""\
- #+TITLE: Test message
- #+CONVERSATION_ID: {conv_id}
- #+MESSAGE_TYPE: {msg_type}
- #+SEQUENCE: {seq}
- #+TIMESTAMP: 2026-04-27T05:00:00-05:00
- #+PROTOCOL_VERSION: {proto_version}
-
- Body.
- """))
- return msg
-
-
-def _run(args: list[str], env: dict | None = None, cwd: Path | None = None) -> subprocess.CompletedProcess:
- return subprocess.run(
- [str(SCRIPT), *args],
- capture_output=True,
- text=True,
- env=env,
- cwd=cwd,
- )
-
-
-@pytest.fixture
-def isolated_env(tmp_path, monkeypatch):
- """Redirect HOME so peers.toml, HALT, marker files are scoped to the test."""
- fake_home = tmp_path / "home"
- fake_home.mkdir()
- monkeypatch.setenv("HOME", str(fake_home))
- # Pre-create projects/ so derive_sender_project has somewhere to look.
- (fake_home / "projects" / "homelab").mkdir(parents=True)
- return fake_home
-
-
-def test_send_help(isolated_env):
- """--help works without side effects."""
- result = _run(["--help"], env={**os.environ, "HOME": str(isolated_env)})
- assert result.returncode == 0
- assert "Send a cross-agent message" in result.stdout
-
-
-def test_send_missing_message_file(isolated_env):
- """Nonexistent message file returns general error."""
- import socket
- machine = socket.gethostname().split(".")[0]
- result = _run(
- [f"{machine}.homelab", str(isolated_env / "nonexistent.org")],
- env={**os.environ, "HOME": str(isolated_env)},
- )
- assert result.returncode == 1
- assert "not found" in result.stderr.lower()
-
-
-def test_send_invalid_destination_format(isolated_env, tmp_path):
- """Destination without . returns dest-not-found exit code."""
- msg = _make_message(tmp_path)
- result = _run(
- ["bogus", str(msg)],
- env={**os.environ, "HOME": str(isolated_env)},
- )
- assert result.returncode == 2
- assert "<machine>.<project>" in result.stderr or "destination" in result.stderr.lower()
-
-
-def test_send_dest_not_in_peers(isolated_env, tmp_path):
- """Cross-machine destination with no peers.toml entry exits 2."""
- msg = _make_message(tmp_path)
- result = _run(
- ["unknownmachine.homelab", str(msg)],
- env={**os.environ, "HOME": str(isolated_env)},
- )
- assert result.returncode == 2
- assert "not found in peers" in result.stderr
-
-
-def test_send_frontmatter_missing_required(isolated_env, tmp_path):
- """Message missing required fields exits 4."""
- bad = tmp_path / "bad.org"
- bad.write_text("#+TITLE: nope\n\nBody.\n")
- import socket
- machine = socket.gethostname().split(".")[0]
- result = _run(
- [f"{machine}.homelab", str(bad)],
- env={**os.environ, "HOME": str(isolated_env)},
- )
- assert result.returncode == 4
- assert "missing required fields" in result.stderr
-
-
-def test_send_invalid_message_type(isolated_env, tmp_path):
- """Unknown MESSAGE_TYPE exits 4."""
- msg = _make_message(tmp_path, msg_type="frobnicate")
- import socket
- machine = socket.gethostname().split(".")[0]
- result = _run(
- [f"{machine}.homelab", str(msg)],
- env={**os.environ, "HOME": str(isolated_env)},
- )
- assert result.returncode == 4
- assert "MESSAGE_TYPE" in result.stderr
-
-
-def test_send_halt_blocks(isolated_env, tmp_path):
- """When HALT exists, send refuses with exit 5."""
- halt = isolated_env / ".config" / "cross-agent-comms" / "HALT"
- halt.parent.mkdir(parents=True)
- halt.write_text("test halt\n")
- msg = _make_message(tmp_path)
- import socket
- machine = socket.gethostname().split(".")[0]
- result = _run(
- [f"{machine}.homelab", str(msg)],
- env={**os.environ, "HOME": str(isolated_env)},
- )
- assert result.returncode == 5
- assert "halt active" in result.stderr.lower()
-
-
-def test_send_same_machine_no_sign_delivers(isolated_env, tmp_path):
- """Same-machine delivery with --no-sign produces a canonically named file."""
- msg = _make_message(tmp_path, conv_id="my-conv")
- import socket
- machine = socket.gethostname().split(".")[0]
- # Sender is derived from CWD walking up to ~/projects/<name>/
- cwd = isolated_env / "projects" / "homelab"
- result = _run(
- [f"{machine}.homelab", str(msg), "--no-sign"],
- env={**os.environ, "HOME": str(isolated_env)},
- cwd=cwd,
- )
- assert result.returncode == 0, f"stderr={result.stderr}"
- inbox = isolated_env / "projects" / "homelab" / "inbox" / "from-agents"
- files = list(inbox.glob("*-from-homelab-my-conv.org"))
- assert len(files) == 1
- # No sig file with --no-sign.
- assert not list(inbox.glob("*.asc"))
- # Canonical filename pattern.
- assert files[0].name.startswith("2026") and files[0].name.endswith("-from-homelab-my-conv.org")
-
-
-def test_send_same_machine_signed_writes_asc(isolated_env, tmp_path):
- """Signed delivery writes both .org and .asc."""
- msg = _make_message(tmp_path, conv_id="signed-conv")
- import socket
- machine = socket.gethostname().split(".")[0]
- cwd = isolated_env / "projects" / "homelab"
- # Use the real GPG keyring (not isolating GPG — Craig's existing keys are fine for tests).
- real_env = {**os.environ, "HOME": str(isolated_env), "GNUPGHOME": str(Path.home() / ".gnupg")}
- result = _run(
- [f"{machine}.homelab", str(msg)],
- env=real_env,
- cwd=cwd,
- )
- if result.returncode != 0:
- pytest.skip(f"GPG signing unavailable in this environment: {result.stderr}")
- inbox = isolated_env / "projects" / "homelab" / "inbox" / "from-agents"
- org_files = list(inbox.glob("*-from-homelab-signed-conv.org"))
- asc_files = list(inbox.glob("*-from-homelab-signed-conv.org.asc"))
- assert len(org_files) == 1
- assert len(asc_files) == 1
-
-
-def test_send_filename_ignores_input_basename(isolated_env, tmp_path):
- """User's input filename is ignored; canonical filename is generated."""
- weird = tmp_path / "weird-user-name.org"
- weird.write_text(textwrap.dedent("""\
- #+TITLE: Title
- #+CONVERSATION_ID: ignored-input
- #+MESSAGE_TYPE: request
- #+SEQUENCE: 1
- #+TIMESTAMP: 2026-04-27T05:00:00-05:00
- #+PROTOCOL_VERSION: 5
-
- Body.
- """))
- import socket
- machine = socket.gethostname().split(".")[0]
- cwd = isolated_env / "projects" / "homelab"
- result = _run(
- [f"{machine}.homelab", str(weird), "--no-sign"],
- env={**os.environ, "HOME": str(isolated_env)},
- cwd=cwd,
- )
- assert result.returncode == 0
- inbox = isolated_env / "projects" / "homelab" / "inbox" / "from-agents"
- # No file named after the user's input.
- assert not (inbox / "weird-user-name.org").exists()
- # Canonical naming used.
- assert list(inbox.glob("*-from-homelab-ignored-input.org"))
diff --git a/.ai/scripts/tests/test_cross_agent_status.py b/.ai/scripts/tests/test_cross_agent_status.py
deleted file mode 100644
index bb5b8ba..0000000
--- a/.ai/scripts/tests/test_cross_agent_status.py
+++ /dev/null
@@ -1,165 +0,0 @@
-"""Tests for cross-agent-status (TDD: tests written before implementation)."""
-
-from __future__ import annotations
-
-import json
-import os
-import subprocess
-import textwrap
-from pathlib import Path
-
-import pytest
-
-SCRIPT = Path(__file__).resolve().parent.parent / "cross-agent-comms" / "cross-agent-status"
-
-
-def _make_msg(path: Path, *, conv_id: str, seq: int, msg_type: str = "request",
- proto_version: str = "5", timestamp: str = "2026-04-27T05:00:00-05:00") -> Path:
- path.parent.mkdir(parents=True, exist_ok=True)
- path.write_text(textwrap.dedent(f"""\
- #+TITLE: T
- #+CONVERSATION_ID: {conv_id}
- #+MESSAGE_TYPE: {msg_type}
- #+SEQUENCE: {seq}
- #+TIMESTAMP: {timestamp}
- #+PROTOCOL_VERSION: {proto_version}
-
- Body.
- """))
- return path
-
-
-def _run(args: list[str], env: dict | None = None) -> subprocess.CompletedProcess:
- return subprocess.run([str(SCRIPT), *args], capture_output=True, text=True, env=env)
-
-
-@pytest.fixture
-def fake_projects(tmp_path, monkeypatch):
- """Create a fake ~/projects/<name>/inbox/from-agents/ tree under tmp_path."""
- home = tmp_path / "home"
- home.mkdir()
- monkeypatch.setenv("HOME", str(home))
- return home
-
-
-def test_status_help(fake_projects):
- result = _run(["--help"], env={**os.environ, "HOME": str(fake_projects)})
- assert result.returncode == 0
- assert "snapshot" in result.stdout.lower() or "pending" in result.stdout.lower()
-
-
-def test_status_no_projects_clean_output(fake_projects):
- result = _run([], env={**os.environ, "HOME": str(fake_projects)})
- assert result.returncode == 0
- # Empty machine prints either header-only table or "no projects" — accept either.
- # No crash, no pending claims.
- assert "pending" in result.stdout.lower() or result.stdout.strip() == ""
-
-
-def test_status_one_pending_shows_up(fake_projects):
- inbox = fake_projects / "projects" / "homelab" / "inbox" / "from-agents"
- _make_msg(inbox / "20260427T100000Z-from-career-fixup.org", conv_id="fixup", seq=1)
- result = _run([], env={**os.environ, "HOME": str(fake_projects)})
- assert result.returncode == 0
- assert "homelab" in result.stdout
- assert "1" in result.stdout # pending count
- assert "20260427T100000Z-from-career-fixup.org" in result.stdout
-
-
-def test_status_released_conversation_zero_pending(fake_projects):
- """A conversation with a release message in it counts as 0 pending."""
- inbox = fake_projects / "projects" / "homelab" / "inbox" / "from-agents"
- _make_msg(inbox / "20260427T100000Z-from-career-done.org", conv_id="done", seq=1)
- _make_msg(inbox / "20260427T100100Z-from-homelab-done.org", conv_id="done", seq=2, msg_type="release")
- result = _run([], env={**os.environ, "HOME": str(fake_projects)})
- assert result.returncode == 0
- # Check the homelab row shows 0 pending.
- lines = [ln for ln in result.stdout.splitlines() if "homelab" in ln]
- # At least one homelab line should show 0 pending or "—".
- assert any("0" in ln or "—" in ln for ln in lines)
-
-
-def test_status_partial_release(fake_projects):
- """Conversation with release + a later message → that later message counts as pending."""
- inbox = fake_projects / "projects" / "homelab" / "inbox" / "from-agents"
- _make_msg(inbox / "20260427T100000Z-from-career-x.org", conv_id="x", seq=1,
- timestamp="2026-04-27T05:00:00-05:00")
- _make_msg(inbox / "20260427T100100Z-from-homelab-x.org", conv_id="x", seq=2, msg_type="release",
- timestamp="2026-04-27T05:01:00-05:00")
- # New message AFTER release: starts a fresh thread that's pending.
- _make_msg(inbox / "20260427T200000Z-from-career-x.org", conv_id="x", seq=3,
- timestamp="2026-04-27T15:00:00-05:00")
- result = _run([], env={**os.environ, "HOME": str(fake_projects)})
- assert result.returncode == 0
- homelab_line = next(ln for ln in result.stdout.splitlines() if "homelab" in ln)
- assert "1" in homelab_line # the post-release message is pending
-
-
-def test_status_multiple_projects(fake_projects):
- inbox_a = fake_projects / "projects" / "homelab" / "inbox" / "from-agents"
- inbox_b = fake_projects / "projects" / "career" / "inbox" / "from-agents"
- _make_msg(inbox_a / "20260427T100000Z-from-x-a.org", conv_id="a", seq=1)
- _make_msg(inbox_b / "20260427T100100Z-from-x-b.org", conv_id="b", seq=1)
- _make_msg(inbox_b / "20260427T100200Z-from-x-c.org", conv_id="c", seq=1)
- result = _run([], env={**os.environ, "HOME": str(fake_projects)})
- assert result.returncode == 0
- # career has 2 pending, homelab has 1.
- career_line = next(ln for ln in result.stdout.splitlines() if "career" in ln)
- homelab_line = next(ln for ln in result.stdout.splitlines() if "homelab" in ln)
- assert "2" in career_line
- assert "1" in homelab_line
-
-
-def test_status_json_output(fake_projects):
- inbox = fake_projects / "projects" / "homelab" / "inbox" / "from-agents"
- _make_msg(inbox / "20260427T100000Z-from-career-test.org", conv_id="test", seq=1)
- result = _run(["--json"], env={**os.environ, "HOME": str(fake_projects)})
- assert result.returncode == 0
- payload = json.loads(result.stdout)
- assert "projects" in payload
- assert isinstance(payload["projects"], list)
- homelab = next((p for p in payload["projects"] if p["name"] == "homelab"), None)
- assert homelab is not None
- assert homelab["pending_count"] == 1
-
-
-def test_status_sort_pending_first(fake_projects):
- """Projects with pending messages sort before projects with 0."""
- (fake_projects / "projects" / "alpha" / "inbox" / "from-agents").mkdir(parents=True)
- inbox_zeta = fake_projects / "projects" / "zeta" / "inbox" / "from-agents"
- _make_msg(inbox_zeta / "20260427T100000Z-from-x-z.org", conv_id="z", seq=1)
- result = _run([], env={**os.environ, "HOME": str(fake_projects)})
- assert result.returncode == 0
- lines = result.stdout.splitlines()
- zeta_idx = next(i for i, ln in enumerate(lines) if "zeta" in ln)
- alpha_idx = next(i for i, ln in enumerate(lines) if "alpha" in ln)
- assert zeta_idx < alpha_idx, "pending project should sort before zero-pending project"
-
-
-def test_status_halt_shows_banner(fake_projects):
- halt = fake_projects / ".config" / "cross-agent-comms" / "HALT"
- halt.parent.mkdir(parents=True)
- halt.write_text("halted for test")
- inbox = fake_projects / "projects" / "homelab" / "inbox" / "from-agents"
- _make_msg(inbox / "20260427T100000Z-from-x-x.org", conv_id="x", seq=1)
- result = _run([], env={**os.environ, "HOME": str(fake_projects)})
- assert result.returncode == 0 # status continues to print under HALT
- assert "HALT" in result.stdout
- # Banner should mention the reason.
- assert "halted for test" in result.stdout
-
-
-def test_status_projects_glob_override(fake_projects):
- inbox = fake_projects / "projects" / "homelab" / "inbox" / "from-agents"
- _make_msg(inbox / "20260427T100000Z-from-x-a.org", conv_id="a", seq=1)
- other_inbox = fake_projects / "projects" / "career" / "inbox" / "from-agents"
- _make_msg(other_inbox / "20260427T100100Z-from-x-b.org", conv_id="b", seq=1)
- # Glob limits to homelab only.
- result = _run(
- ["--projects-glob", str(fake_projects / "projects" / "homelab" / "inbox" / "from-agents") + "/"],
- env={**os.environ, "HOME": str(fake_projects)},
- )
- assert result.returncode == 0
- assert "homelab" in result.stdout
- # career not in scope.
- assert "career" not in result.stdout
diff --git a/.ai/scripts/tests/test_cross_agent_watch.py b/.ai/scripts/tests/test_cross_agent_watch.py
deleted file mode 100644
index 417cc19..0000000
--- a/.ai/scripts/tests/test_cross_agent_watch.py
+++ /dev/null
@@ -1,155 +0,0 @@
-"""Tests for cross-agent-watch.
-
-Black-box: spawn the script, drop files into a watched dir, read the log.
-Tests use --no-notify to avoid firing real desktop notifications.
-"""
-
-from __future__ import annotations
-
-import os
-import subprocess
-import time
-from pathlib import Path
-
-import pytest
-
-SCRIPT = Path(__file__).resolve().parent.parent / "cross-agent-comms" / "cross-agent-watch"
-
-
-def _spawn(watched_dir: Path, log_path: Path, env: dict) -> subprocess.Popen:
- return subprocess.Popen(
- [
- str(SCRIPT),
- "--projects-glob", str(watched_dir) + "/",
- "--log", str(log_path),
- "--no-notify",
- "--quiet",
- ],
- stdout=subprocess.DEVNULL,
- stderr=subprocess.PIPE,
- env=env,
- )
-
-
-def _wait_for_log_lines(log_path: Path, expected: int, timeout: float = 5.0) -> list[str]:
- deadline = time.time() + timeout
- while time.time() < deadline:
- if log_path.exists():
- lines = [ln for ln in log_path.read_text().splitlines() if ln]
- if len(lines) >= expected:
- return lines
- time.sleep(0.1)
- if log_path.exists():
- return [ln for ln in log_path.read_text().splitlines() if ln]
- return []
-
-
-@pytest.fixture
-def isolated_env(tmp_path, monkeypatch):
- fake_home = tmp_path / "home"
- fake_home.mkdir()
- monkeypatch.setenv("HOME", str(fake_home))
- return fake_home
-
-
-def test_watch_help(isolated_env):
- result = subprocess.run(
- [str(SCRIPT), "--help"],
- capture_output=True, text=True,
- env={**os.environ, "HOME": str(isolated_env)},
- )
- assert result.returncode == 0
- assert "Usage:" in result.stdout
-
-
-def test_watch_empty_glob_exits_nonzero(isolated_env):
- """Glob resolving to zero dirs should exit non-zero with a clear message."""
- result = subprocess.run(
- [str(SCRIPT), "--projects-glob", "/nonexistent/path/*/foo/", "--no-notify", "--quiet"],
- capture_output=True, text=True,
- env={**os.environ, "HOME": str(isolated_env)},
- timeout=3,
- )
- assert result.returncode != 0
- assert "0 directories" in result.stderr
-
-
-def test_watch_logs_org_file_create(isolated_env, tmp_path):
- watched = tmp_path / "watched"
- watched.mkdir()
- log = tmp_path / "watch.log"
- proc = _spawn(watched, log, {**os.environ, "HOME": str(isolated_env)})
- try:
- # Give inotifywait a moment to attach.
- time.sleep(0.3)
- (watched / "test-msg.org").write_text("hello")
- lines = _wait_for_log_lines(log, expected=1, timeout=3.0)
- assert len(lines) >= 1
- assert "test-msg.org" in lines[-1]
- finally:
- proc.terminate()
- proc.wait(timeout=2)
-
-
-def test_watch_filters_tmp_files(isolated_env, tmp_path):
- """Files starting with .tmp. must NOT trigger log entries."""
- watched = tmp_path / "watched"
- watched.mkdir()
- log = tmp_path / "watch.log"
- proc = _spawn(watched, log, {**os.environ, "HOME": str(isolated_env)})
- try:
- time.sleep(0.3)
- (watched / ".tmp.staging-file.org").write_text("hello")
- # Wait briefly to confirm nothing logs.
- time.sleep(0.5)
- if log.exists():
- content = log.read_text()
- assert ".tmp.staging-file" not in content
- # Then drop a real file to confirm watcher is alive.
- (watched / "real.org").write_text("real")
- lines = _wait_for_log_lines(log, expected=1, timeout=3.0)
- assert any("real.org" in ln for ln in lines)
- finally:
- proc.terminate()
- proc.wait(timeout=2)
-
-
-def test_watch_filters_asc_sidecars(isolated_env, tmp_path):
- """Only .org events fire; .asc sidecars are silent."""
- watched = tmp_path / "watched"
- watched.mkdir()
- log = tmp_path / "watch.log"
- proc = _spawn(watched, log, {**os.environ, "HOME": str(isolated_env)})
- try:
- time.sleep(0.3)
- (watched / "msg.org.asc").write_text("sig")
- time.sleep(0.5)
- if log.exists():
- assert "msg.org.asc" not in log.read_text()
- # .org event still works.
- (watched / "msg.org").write_text("body")
- lines = _wait_for_log_lines(log, expected=1, timeout=3.0)
- assert any(ln.endswith("msg.org") for ln in lines)
- finally:
- proc.terminate()
- proc.wait(timeout=2)
-
-
-def test_watch_halt_suppresses_but_logs(isolated_env, tmp_path):
- """When HALT is set, watcher logs the event with (suppressed by HALT) marker."""
- halt = isolated_env / ".config" / "cross-agent-comms" / "HALT"
- halt.parent.mkdir(parents=True)
- halt.write_text("halted")
- watched = tmp_path / "watched"
- watched.mkdir()
- log = tmp_path / "watch.log"
- proc = _spawn(watched, log, {**os.environ, "HOME": str(isolated_env)})
- try:
- time.sleep(0.3)
- (watched / "halted-event.org").write_text("body")
- lines = _wait_for_log_lines(log, expected=1, timeout=3.0)
- assert len(lines) >= 1
- assert "suppressed by HALT" in lines[-1]
- finally:
- proc.terminate()
- proc.wait(timeout=2)
diff --git a/.ai/scripts/tests/test_flashcard_stats.py b/.ai/scripts/tests/test_flashcard_stats.py
index 606f7c1..46deccc 100644
--- a/.ai/scripts/tests/test_flashcard_stats.py
+++ b/.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/.ai/scripts/tests/test_flashcard_to_anki.py b/.ai/scripts/tests/test_flashcard_to_anki.py
index 058b0cd..fa38b64 100644
--- a/.ai/scripts/tests/test_flashcard_to_anki.py
+++ b/.ai/scripts/tests/test_flashcard_to_anki.py
@@ -34,14 +34,33 @@ def test_default_output_path_targets_phone_anki_dir(drill):
assert result == Path.home() / "sync" / "phone" / "anki" / "health-drill.apkg"
-def test_default_deck_name_is_raw_basename(drill):
- """Deck name is the input basename with case preserved; #+TITLE is ignored."""
- assert drill.default_deck_name(Path("/x/deepsat.org")) == "deepsat"
+def test_default_deck_name_uses_org_title(drill):
+ """The #+TITLE drives the Anki deck name, not the filename slug."""
+ org = "#+TITLE: Refutations\n* Section\n** Q? :drill:\na\n"
+ assert drill.default_deck_name(Path("/x/refutation-drill.org"), org) == "Refutations"
-def test_default_deck_name_keeps_hyphens(drill):
- """A hyphenated basename is kept verbatim rather than title-cased."""
- assert drill.default_deck_name(Path("/x/health-drill.org")) == "health-drill"
+def test_default_deck_name_title_is_trimmed(drill):
+ """Surrounding whitespace on the #+TITLE value is stripped."""
+ org = "#+TITLE: DeepSat Flashcards \n"
+ assert drill.default_deck_name(Path("/x/deepsat.org"), org) == "DeepSat Flashcards"
+
+
+def test_default_deck_name_title_match_is_case_insensitive(drill):
+ """A lowercase #+title: keyword is still recognized."""
+ org = "#+title: Health Flashcards\n"
+ assert drill.default_deck_name(Path("/x/health-drill.org"), org) == "Health Flashcards"
+
+
+def test_default_deck_name_falls_back_to_basename_without_title(drill):
+ """No #+TITLE line falls back to the input basename, case preserved."""
+ org = "* Section\n** Q? :drill:\na\n"
+ assert drill.default_deck_name(Path("/x/deepsat.org"), org) == "deepsat"
+
+
+def test_default_deck_name_blank_title_falls_back_to_basename(drill):
+ """An empty #+TITLE value is ignored in favour of the basename."""
+ assert drill.default_deck_name(Path("/x/health-drill.org"), "#+TITLE: \n") == "health-drill"
# --- section_to_tag (pure) ---
@@ -139,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):
@@ -159,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/.ai/scripts/tests/test_inbox_send.py b/.ai/scripts/tests/test_inbox_send.py
index a0094dc..9b0a8c6 100644
--- a/.ai/scripts/tests/test_inbox_send.py
+++ b/.ai/scripts/tests/test_inbox_send.py
@@ -97,6 +97,52 @@ class TestInboxSendDiscovery:
result = run_script(["--list"], roots=[tmp_path / "does-not-exist"])
assert result.returncode == 0
+ def test_inbox_send_list_displays_dot_stripped_name(self, project_root, run_script, tmp_path):
+ """Dotted project basenames display dot-stripped (.emacs.d → emacsd)."""
+ project_root(".emacs.d")
+ result = run_script(["--list"], roots=[tmp_path / "projects"])
+ assert "emacsd" in result.stdout
+
+
+class TestInboxSendDotAlias:
+ """A dotted project basename resolves both verbatim and dot-stripped."""
+
+ def test_resolves_by_dot_stripped_alias(self, project_root, run_script, tmp_path):
+ """'emacsd' delivers to the .emacs.d project."""
+ project_root(".emacs.d")
+ cwd = project_root("source")
+ run_script(
+ ["emacsd", "--text", "hi"],
+ cwd=cwd, roots=[tmp_path / "projects"],
+ )
+ files = list((tmp_path / "projects" / ".emacs.d" / "inbox").iterdir())
+ assert len(files) == 1
+
+ def test_resolves_by_exact_dotted_name_still(self, project_root, run_script, tmp_path):
+ """Backward-compat: the verbatim '.emacs.d' target still resolves."""
+ project_root(".emacs.d")
+ cwd = project_root("source")
+ run_script(
+ [".emacs.d", "--text", "hi"],
+ cwd=cwd, roots=[tmp_path / "projects"],
+ )
+ files = list((tmp_path / "projects" / ".emacs.d" / "inbox").iterdir())
+ assert len(files) == 1
+
+ def test_exact_match_wins_over_alias(self, project_root, run_script, tmp_path):
+ """An exact basename match is preferred over a dot-stripped collision."""
+ project_root("emacsd") # exact
+ project_root(".emacs.d") # would also normalize to 'emacsd'
+ cwd = project_root("source")
+ run_script(
+ ["emacsd", "--text", "hi"],
+ cwd=cwd, roots=[tmp_path / "projects"],
+ )
+ exact = list((tmp_path / "projects" / "emacsd" / "inbox").iterdir())
+ dotted = list((tmp_path / "projects" / ".emacs.d" / "inbox").iterdir())
+ assert len(exact) == 1
+ assert dotted == []
+
# ----------------------------------------------------------------------
# Slug derivation from text and from filenames
@@ -355,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/.ai/scripts/tests/test_route_recommend.py b/.ai/scripts/tests/test_route_recommend.py
new file mode 100644
index 0000000..2ec900a
--- /dev/null
+++ b/.ai/scripts/tests/test_route_recommend.py
@@ -0,0 +1,152 @@
+"""Tests for route_recommend.py — the wrap-up routing recommendation engine.
+
+The core is a pure function recommend(item, projects) -> (destination, confidence):
+- strong: a project's name (or its dot-stripped form) appears literally in the item
+- weak: a distinctive name token overlaps, but the full name doesn't
+- none: no overlap; the item stays put (destination is None)
+
+A multi-way tie at the top tier downgrades to weak with a deterministic pick.
+An empty project list yields none.
+
+The CLI wires this to inbox-send.py's discover_projects (sandboxed here via the
+INBOX_SEND_ROOTS env var, the same hook inbox-send's own tests use).
+"""
+
+import subprocess
+import sys
+from pathlib import Path
+
+SCRIPTS = Path(__file__).parent.parent
+SCRIPT = SCRIPTS / "route_recommend.py"
+sys.path.insert(0, str(SCRIPTS))
+
+import route_recommend as rr # noqa: E402
+
+
+# --- pure function: the five spec'd cases -----------------------------------
+
+def test_strong_match_named_literally():
+ dest, conf = rr.recommend("fix the rulesets refactor command", ["rulesets", "home", "work"])
+ assert (dest, conf) == ("rulesets", "strong")
+
+
+def test_strong_match_via_dot_stripped_name():
+ # ".emacs.d" addressed as "emacsd" in the item is still a literal hit.
+ dest, conf = rr.recommend("update the emacsd ai-term module", [".emacs.d", "rulesets"])
+ assert (dest, conf) == (".emacs.d", "strong")
+
+
+def test_strong_match_dotted_name_verbatim():
+ dest, conf = rr.recommend("patch .emacs.d startup", [".emacs.d", "rulesets"])
+ assert (dest, conf) == (".emacs.d", "strong")
+
+
+def test_weak_match_topic_token_only():
+ # "wttrin" is a token of "emacs-wttrin" but the full name isn't present.
+ dest, conf = rr.recommend("the wttrin weather bug", ["emacs-wttrin", "rulesets"])
+ assert (dest, conf) == ("emacs-wttrin", "weak")
+
+
+def test_no_match_stays_put():
+ dest, conf = rr.recommend("calibrate the telescope mount", ["rulesets", "deepsat"])
+ assert dest is None
+ assert conf == "none"
+
+
+def test_two_project_strong_tie_downgrades_to_weak():
+ # Both named literally → ambiguous → weak, deterministic tie-break (alphabetical).
+ dest, conf = rr.recommend("sync rulesets and home configs", ["rulesets", "home", "work"])
+ assert conf == "weak"
+ assert dest == "home" # tie-break: most-overlap then alphabetical
+
+
+def test_empty_project_list_is_none():
+ assert rr.recommend("anything at all", []) == (None, "none")
+
+
+# --- boundary / robustness --------------------------------------------------
+
+def test_literal_name_requires_word_boundary():
+ # "home" must not match inside "homeowner".
+ dest, conf = rr.recommend("the homeowner association meeting", ["home", "rulesets"])
+ assert dest is None and conf == "none"
+
+
+def test_path_mention_counts_as_literal():
+ dest, conf = rr.recommend("edit ~/code/rulesets/Makefile", ["rulesets", "home"])
+ assert (dest, conf) == ("rulesets", "strong")
+
+
+def test_strong_beats_weak_when_both_present():
+ # "rulesets" named literally (strong) outranks an emacs-wttrin token hit (weak).
+ dest, conf = rr.recommend("the wttrin fix belongs in rulesets", ["rulesets", "emacs-wttrin"])
+ assert (dest, conf) == ("rulesets", "strong")
+
+
+# --- CLI + discovery reuse (sandboxed roots) --------------------------------
+
+def _run(args, roots, item):
+ import os
+ env = {"PATH": os.environ.get("PATH", ""), "HOME": os.environ.get("HOME", "/tmp"),
+ "INBOX_SEND_ROOTS": ":".join(str(r) for r in roots)}
+ return subprocess.run([sys.executable, str(SCRIPT), "--item", item, *args],
+ capture_output=True, text=True, env=env)
+
+
+def _mk_project(tmp_path, name):
+ proj = tmp_path / "projects" / name
+ (proj / ".ai").mkdir(parents=True, exist_ok=True)
+ (proj / "inbox").mkdir(exist_ok=True)
+ return proj
+
+
+def test_cli_discovers_and_recommends(tmp_path):
+ _mk_project(tmp_path, "foo")
+ _mk_project(tmp_path, "bar")
+ r = _run([], roots=[tmp_path / "projects"], item="fix the foo widget")
+ assert r.returncode == 0
+ assert r.stdout.strip() == "foo\tstrong"
+
+
+def test_cli_no_match_prints_none(tmp_path):
+ _mk_project(tmp_path, "foo")
+ r = _run([], roots=[tmp_path / "projects"], item="unrelated grocery list")
+ assert r.returncode == 0
+ assert r.stdout.strip() == "none"
+
+
+def test_cli_exclude_drops_current_project(tmp_path):
+ _mk_project(tmp_path, "foo")
+ _mk_project(tmp_path, "bar")
+ # Item names foo, but foo is excluded as the current project → no other match.
+ 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/.ai/scripts/tests/test_upcoming_birthdays.py b/.ai/scripts/tests/test_upcoming_birthdays.py
new file mode 100644
index 0000000..1e15183
--- /dev/null
+++ b/.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/.ai/scripts/todo-cleanup.el b/.ai/scripts/todo-cleanup.el
index 6b3081a..516e9b1 100644
--- a/.ai/scripts/todo-cleanup.el
+++ b/.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.
@@ -25,14 +29,60 @@
;; line isn't in canonical position. Reports these for manual fix; doesn't
;; auto-rewrite (preserving real state-log history is judgement work).
;;
-;; * --archive-done (opt-in). Moves every level-2 subtree whose TODO state is
-;; DONE or CANCELLED out of the "Open Work" section and into the "Resolved"
-;; section of the same file, subtree intact. The sections are matched by a
-;; unique level-1 heading containing "Open Work" (case-insensitive) and one
-;; containing "Resolved"; if either is missing or ambiguous, the file is
-;; skipped with a message. Only direct level-2 children move — a DONE entry
-;; nested under an open parent stays put. Archiving is consequential, so it's
-;; never run by default; it does *not* also run the hygiene passes.
+;; * --archive-done (opt-in). Two steps, in order:
+;;
+;; 1. Moves every level-2 subtree whose TODO state is DONE or CANCELLED out of
+;; the "Open Work" section and into the "Resolved" section of the same
+;; file, subtree intact. The sections are matched by a unique level-1
+;; heading containing "Open Work" (case-insensitive) and one containing
+;; "Resolved"; if either is missing or ambiguous, the file is skipped with
+;; 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 is moved
+;; out to `tc-archive-file' (default `archive/task-archive.org' beside the
+;; 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
@@ -50,18 +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
@@ -70,11 +143,40 @@ 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)
+
+(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 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
+file; nil means the real current date. Set in tests for determinism.")
+
+(defvar tc-archive-file nil
+ "Destination file for aged-out Resolved subtrees; nil means
+`archive/task-archive.org' beside the todo file being processed.")
;;; ---------------------------------------------------------------------------
;;; Hygiene mode
@@ -224,7 +326,8 @@ are reported but not performed."
:line (line-number-at-pos)
:heading (org-get-heading t t t t))
tc-issues)
- (cl-incf tc-archived))))
+ (cl-incf tc-archived)))
+ (tc-archive-old-resolved-to-file))
(t
(catch 'done
(while t
@@ -252,7 +355,216 @@ are reported but not performed."
(cl-incf tc-archived)
(push (list :kind 'archive-moved :file tc-current-file
:line line :heading heading)
- tc-issues)))))))))
+ tc-issues)))))
+ (tc-archive-old-resolved-to-file)))))
+
+;;; ---------------------------------------------------------------------------
+;;; --archive-done: age old Resolved subtrees out to a file
+
+(defconst tc-archive-file-scaffold
+ "#+TITLE: Task Archive\n#+FILETAGS: :archive:\n\n* Resolved (archived)\n"
+ "Initial content written to a fresh `tc-archive-file'. Aged subtrees are
+appended as level-2 children under the level-1 heading.")
+
+(defun tc--reference-absolute ()
+ "Absolute (Gregorian serial) day number of the aging reference date —
+`tc-archive-reference-date' when set, otherwise the real current date."
+ (if tc-archive-reference-date
+ (pcase-let ((`(,y ,m ,d) tc-archive-reference-date))
+ (calendar-absolute-from-gregorian (list m d y)))
+ (pcase-let ((`(,m ,d ,y) (calendar-current-date)))
+ (calendar-absolute-from-gregorian (list m d y)))))
+
+(defun tc--closed-absolute-in-region (beg end)
+ "Absolute day number of the first CLOSED: [YYYY-MM-DD ...] line in BEG..END,
+or nil when the region carries no parseable CLOSED date. The task's own CLOSED
+line sits in canonical position directly under the heading, so the first match
+in the subtree is the task's close."
+ (save-excursion
+ (goto-char beg)
+ (when (re-search-forward
+ "CLOSED:[ \t]*\\[\\([0-9][0-9][0-9][0-9]\\)-\\([0-9][0-9]\\)-\\([0-9][0-9]\\)"
+ end t)
+ (calendar-absolute-from-gregorian
+ (list (string-to-number (match-string 2))
+ (string-to-number (match-string 3))
+ (string-to-number (match-string 1)))))))
+
+(defun tc--archive-file-path ()
+ "Resolve the destination file for aged-out subtrees: `tc-archive-file' if set,
+else `archive/task-archive.org' beside the todo file being processed."
+ (or tc-archive-file
+ (and tc-current-dir
+ (expand-file-name "archive/task-archive.org" tc-current-dir))))
+
+(defun tc--git-ignored-p (path)
+ "Non-nil when PATH is gitignored (git check-ignore exits 0). nil on any git
+error or when git is unavailable."
+ (let ((default-directory (or tc-current-dir default-directory)))
+ (eq 0 (ignore-errors
+ (call-process "git" nil nil nil "check-ignore" "-q"
+ (expand-file-name path))))))
+
+(defun tc--ensure-archive-gitignored (archive-path)
+ "Keep the aged-out archive as private as the todo file it derives from. When the
+todo file being processed is gitignored but ARCHIVE-PATH is not, append a
+root-relative ignore entry for ARCHIVE-PATH to the project's .gitignore. No-op
+when the todo file is tracked, the archive is already ignored, or there is no git
+work tree — so track-mode projects (todo file tracked) leave the archive tracked
+too. This is what makes the aging step safe to ship to gitignore-mode projects,
+where todo.org is private: the archive inherits that privacy instead of leaking
+previously-ignored task history into a tracked path."
+ (when (and tc-current-file tc-current-dir)
+ (let* ((todo (expand-file-name tc-current-file tc-current-dir))
+ (default-directory tc-current-dir)
+ (root (with-temp-buffer
+ (when (eq 0 (ignore-errors
+ (call-process "git" nil (current-buffer) nil
+ "rev-parse" "--show-toplevel")))
+ (string-trim (buffer-string))))))
+ (when (and root (> (length root) 0) (file-directory-p root)
+ (tc--git-ignored-p todo)
+ (not (tc--git-ignored-p archive-path)))
+ (let ((entry (concat "/" (file-relative-name
+ (expand-file-name archive-path) root)))
+ (gi (expand-file-name ".gitignore" root)))
+ (with-temp-buffer
+ (when (file-readable-p gi) (insert-file-contents gi))
+ (unless (save-excursion
+ (goto-char (point-min))
+ (re-search-forward (concat "^" (regexp-quote entry) "$") nil t))
+ (goto-char (point-max))
+ (unless (bolp) (insert "\n"))
+ (insert "\n# Claude Code: task archive (follows todo file privacy)\n"
+ entry "\n")
+ (write-region (point-min) (point-max) gi nil 'silent))))))))
+
+(defun tc--append-subtrees-to-archive-file (path texts)
+ "Append TEXTS (subtree strings) under the level-1 heading in PATH, creating the
+file with `tc-archive-file-scaffold' and the parent directory when absent.
+Ensures the archive inherits the todo file's gitignore status first."
+ (when (and path texts)
+ (tc--ensure-archive-gitignored path)
+ (let ((dir (file-name-directory path)))
+ (when (and dir (not (file-directory-p dir)))
+ (make-directory dir t)))
+ (with-temp-buffer
+ (when (file-readable-p path)
+ (insert-file-contents path))
+ (when (= (point-min) (point-max))
+ (insert tc-archive-file-scaffold))
+ ;; Guarantee a level-1 heading to append under (older files might lack one).
+ (goto-char (point-min))
+ (unless (re-search-forward "^\\* " nil t)
+ (goto-char (point-max))
+ (unless (bolp) (insert "\n"))
+ (insert "* Resolved (archived)\n"))
+ (goto-char (point-max))
+ (unless (bolp) (insert "\n"))
+ (dolist (text texts)
+ (insert text)
+ (unless (bolp) (insert "\n")))
+ (write-region (point-min) (point-max) path nil 'silent))))
+
+(defun tc-archive-old-resolved-to-file ()
+ "Move level-2 DONE/CANCELLED subtrees in the \"Resolved\" section whose CLOSED
+date predates the `tc-archive-retain-days' window out to `tc--archive-file-path'.
+Only subtrees closed within the window stay; older ones, and those with no
+parseable CLOSED date, are moved out. A nil `tc-archive-retain-days' disables the
+step. Honors `tc-check-only' (report only)."
+ (when tc-archive-retain-days
+ (let ((res (tc--find-section "resolved")))
+ (when (integerp res)
+ (let* ((cutoff (- (tc--reference-absolute) tc-archive-retain-days))
+ (moves nil))
+ (dolist (pos (tc--done-level-2-children res))
+ (save-excursion
+ (goto-char pos)
+ (let* ((region (tc--subtree-region))
+ (beg (car region))
+ (end (cdr region))
+ (closed (tc--closed-absolute-in-region beg end)))
+ ;; Archive anything not provably within the window: closed
+ ;; before the cutoff, or with no parseable CLOSED date at all.
+ (when (or (null closed) (< closed cutoff))
+ (push (list :beg beg :end end
+ :heading (org-get-heading t t t t)
+ :line (line-number-at-pos beg))
+ moves)))))
+ (setq moves (nreverse moves)) ; document order
+ (cond
+ ((null moves) nil)
+ (tc-check-only
+ (dolist (m moves)
+ (cl-incf tc-archived-to-file)
+ (push (list :kind 'archive-file-would :file tc-current-file
+ :line (plist-get m :line) :heading (plist-get m :heading))
+ tc-issues)))
+ (t
+ ;; Capture text before any deletion (positions are still valid), then
+ ;; delete bottom-up so earlier subtree positions stay correct.
+ (let ((texts (mapcar
+ (lambda (m)
+ (concat (string-trim-right
+ (buffer-substring-no-properties
+ (plist-get m :beg) (plist-get m :end))
+ "[ \t\n]+")
+ "\n"))
+ moves)))
+ (dolist (m (sort (copy-sequence moves)
+ (lambda (a b) (> (plist-get a :beg) (plist-get b :beg)))))
+ (delete-region (plist-get m :beg) (plist-get m :end)))
+ (tc--append-subtrees-to-archive-file (tc--archive-file-path) texts)
+ (dolist (m moves)
+ (cl-incf tc-archived-to-file)
+ (push (list :kind 'archive-file-moved :file tc-current-file
+ :line (plist-get m :line) :heading (plist-get m :heading))
+ 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
@@ -377,10 +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 cycle), 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
@@ -388,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)
@@ -420,6 +912,21 @@ before their descendants — a [#A] → [#B] → [#D] chain collapses in one pas
(plist-get i :file)
(plist-get i :line)
(if tc-check-only "would move" "moved")
+ (plist-get i :heading)))))))
+ ;; Aged-out subtrees: only reported when some moved (or would). Additive to
+ ;; the in-file report above, and absent when the aging step is disabled.
+ (when (> tc-archived-to-file 0)
+ (princ (format "todo-cleanup --archive-done: %d aged subtree(s) %s task-archive.org%s\n"
+ tc-archived-to-file
+ (if tc-check-only "would move to" "moved to")
+ (if tc-check-only " — CHECK MODE (no writes)" "")))
+ (dolist (i (reverse tc-issues))
+ (pcase (plist-get i :kind)
+ ((or 'archive-file-moved 'archive-file-would)
+ (princ (format " %s:%d: %s %s\n"
+ (plist-get i :file)
+ (plist-get i :line)
+ (if tc-check-only "would archive" "archived")
(plist-get i :heading)))))))))
(defun tc--emit-hygiene-report ()
@@ -467,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 ()
@@ -484,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)
@@ -491,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)
@@ -510,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/.ai/scripts/upcoming_birthdays.py b/.ai/scripts/upcoming_birthdays.py
new file mode 100755
index 0000000..d3f30c0
--- /dev/null
+++ b/.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/.ai/scripts/wrap-org-table.el b/.ai/scripts/wrap-org-table.el
index ddbea65..173e44d 100644
--- a/.ai/scripts/wrap-org-table.el
+++ b/.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/.ai/sessions/2026-06-16-23-37-cross-agent-comms-removal-and-batch-specs.org b/.ai/sessions/2026-06-16-23-37-cross-agent-comms-removal-and-batch-specs.org
new file mode 100644
index 0000000..1857e58
--- /dev/null
+++ b/.ai/sessions/2026-06-16-23-37-cross-agent-comms-removal-and-batch-specs.org
@@ -0,0 +1,194 @@
+#+TITLE: Session Context
+#+DATE: 2026-06-15
+
+* Summary
+
+** Active Goal
+
+A long overnight session: process inbound, then run an autonomous 30-min work loop, ending with two directed tasks — write specs from Craig's cj instructions, and remove the unused cross-agent-comms subsystem.
+
+** Decisions
+
+- Reconciled the Phase E (inbox-zero) and "fix speedrun" proposals into ONE autonomous-batch-execution spec: a dedicated =work-the-backlog.org= holds the loop, inbox-zero stays routing-only, "fix speedrun" is a thin preset. (Craig's "your call".)
+- Demoted create-documentation + research-writer to [#D] (designed, unbuilt, awaiting a trigger).
+- Shared-asset change proposals park as [#B] VERIFYs in no-approvals mode, never self-apply (Phase E, dotfiles-discovery, archsetup ai-launcher).
+- cross-agent-comms is removable: an unused parallel system; inbox-send is the live handoff mechanism (kept). Removed it; repointed helper-mode escalation to inbox-send.
+- Wrap-up-routing implementation deferred: it moves tasks across projects' todo.org files (data-loss-adjacent), needs a focused /start-work, not a tail-end rush.
+
+** Data Collected / Findings
+
+- The 30-min loop ran ~28 idle cycles (cycles 1-30); most inbound was =emacs:=-prefixed roam items (foreign, left for .emacs.d). No rulesets task was ever tagged :next: / :solo:+:quick:, so the loop never had eligible work.
+- Candidate quick-wins surfaced: shellcheck warnings across 6 scripts (2 latent-bug suspects in bin/ai — SC2088 tilde-in-quotes, SC1083 literal brace), the parked dotfiles one-liner, token-rotation helper.
+- cross-agent-comms footprint removed: 7 scripts + 7 READMEs (canonical + mirror), cross-agent-comms.org workflow, INDEX entry, 6 test files, 3 startup.org wirings + 2 summary mentions, helper-mode escalation ref, 7 legacy ~/.local/bin symlinks. Verified: no residual refs, sync-check clean, make test green.
+
+** Files Modified
+
+- Removed: claude-templates/.ai/scripts/cross-agent-comms/ + mirror, the 6 test_cross_agent_*.py, cross-agent-comms.org (+ mirror).
+- Edited (canonical + sync'd mirror): startup.org (3 wirings + 2 summary lines, renumbered Phase A), INDEX.org (dropped entry), helper-mode.org (escalation → inbox-send).
+- docs/design/: 2026-06-16-autonomous-batch-execution-spec.org (new), 2026-06-16-encourage-kb-contribution-spec.org (new), generic-agent-runtime-spec.org (removal status note), 2026-06-15-fix-speedrun-workflow-proposal.org (filed source).
+- todo.org: many task-state edits (specs filed with review VERIFYs, audit reconciliation, demotions, parked VERIFYs, Craig's reorder).
+- working/: inbox-zero-phase-e/, ai-dotfiles-discovery/ (staged parked changes).
+- ~/.dotfiles: bootstrapped .ai/ in gitignore mode (install-ai).
+
+** Next Steps
+
+- Three parked VERIFYs await Craig: dotfiles-discovery (one line), autonomous-batch spec (6 decisions), KB-contribution spec (5 decisions).
+- Wrap-up-routing implementation — the next focused /start-work build (data-loss checkpoint).
+- Quick-win backlog to tag :solo:+:quick: when wanted: the shellcheck cleanup (with the 2 bin/ai latent-bug suspects flagged for eyes).
+
+KB: promoted 0 / consulted no
+
+* Session Log
+
+** Startup + inbox pass (2026-06-15 23:29 CDT)
+
+Ran startup clean: no crashed anchor, templates synced, no reminders/pending decisions, roam inbox 0, no cross-agent messages. Staleness nudge: 1 top-level task unreviewed >7 days. Two DOING items remain open (wrap-up routing spec ready for spec-review; helper-instance Phase 1.5 awaiting go to build).
+
+Processed 3 inbox items:
+- .emacs.d "fix speedrun" reusable-workflow proposal — ran the Skeptical Review with the cross-project battery. Passes the value gate but carries 4 unresolved design questions (new-workflow-vs-preset, page firing point + mechanism amid the page-signal removal, auto-pull vs explicit list, guardrails against design/data-loss work). Craig chose option 1: file as a task, build via spec-create later. Preserved the proposal at docs/design/2026-06-15-fix-speedrun-workflow-proposal.org and filed a [#C] :feature:spec: task carrying the skeptical-review open questions.
+- Two pearl FYIs (gitignore-tooling applied; memory sweep Phase 1.5 done) — acks of handoffs we sent, nothing asked. Deleted.
+
+Updated :LAST_INBOX_PROCESS: marker to 2026-06-15. Inbox empty.
+
+** Autonomous 30-min loop started (2026-06-15 23:44 CDT)
+
+Craig set up an autonomous loop: every 30 min, inbox-zero (project inbox + roam global), then find a task tagged :next: OR both :solo: and :quick:, evaluate it, write a spec if useful, implement it in no-approvals mode until the commit is pushed, then inbox-zero again. Runs until told to stop.
+
+Committed + pushed the inbox pass (4fe184e → e7be0de..4fe184e on origin/main).
+
+Cycle 1 (23:44): both inboxes already at zero. No eligible task — nothing tagged :next:, nothing tagged both :solo:+:quick: (no open task carries :solo: at all). One task has :quick: alone (Token-rotation helper, line 1031) but not :solo:, so it doesn't qualify. No-op cycle. Scheduled next wake in 30 min.
+
+Guardrails honored each cycle: shared-asset/convention change proposals never self-apply even in no-approvals (defer-and-stage as [#B] VERIFY per process-inbox Skeptical Review); refuse to speedrun tasks needing design decisions or carrying data-loss risk without a checkpoint (the fix-speedrun guardrail).
+
+** Full task audit (2026-06-15, Craig-requested, interrupts the loop)
+
+Ran task-audit.org over all 11 open tasks. Fanned out 3 read-only Explore subagents over batches; reconciled against session summaries + git + repo state; applied edits serially.
+
+STALE → updated autonomously:
+- Helper-instance (line 46): bumped LAST_REVIEWED 2026-06-12 → 2026-06-15 (this morning's session shipped agent-roster f8bdf30 + helper-mode.org 0b681dc; the 2026-06-15 progress note already captures the shipped-vs-wiring split accurately, so only the stamp lagged).
+- Memories-sync (line 87): rewrote the stale preamble (it claimed phases + validation both "remaining"; Phases 0-4 are all shipped) and bumped LAST_REVIEWED → 2026-06-15. Implementation complete; manual-testing child + other-machine roam.git clone (archsetup handoff) remain before DONE.
+
+CURRENT (left as-is): wrap-up routing (line 37, still awaiting spec-review), morning-ops (182), c4-rename (885), token-rotation (1031, :solo: deliberately withheld), spec-storage (1084) + fix-speedrun (1104) both filed today.
+
+NEEDS-USER (flagged, awaiting Craig): create-documentation [#C] vs [#D]; research-writer [#C] vs [#D]; generic-agent-runtime (1058) overlap/dependency with helper-instance (46).
+
+Phase E: stamped :LAST_AUDIT: 2026-06-15 in notes.org. Phase F (task-review chain) pending the NEEDS-USER adjudication.
+
+Craig adjudicated the 3 NEEDS-USER flags (chose accept-all): demoted create-documentation (line 191) and research-writer (line 828) [#C]→[#D]; kept generic-agent-runtime separate from helper-instance (open dependency question stays the recorded blocker). Phase F completed: re-reviewed all remaining open tasks (reconciled CURRENT in the audit) and bumped their LAST_REVIEWED to 2026-06-15. No kills. Committed + pushed the audit (ab9f79a → 4fe184e..ab9f79a on origin/main).
+
+** install-ai on ~/.dotfiles (2026-06-16, Craig-requested, cross-project)
+
+Craig asked to install-ai ~/.dotfiles in gitignore mode and page him. Ran scripts/install-ai.sh --gitignore $HOME/.dotfiles: created .ai/ (protocols, workflows, scripts, notes.org seeded project=.dotfiles) + inbox/, and appended .ai/ .claude/ CLAUDE.md AGENTS.md to ~/.dotfiles/.gitignore. Verified .ai/ is git-ignored (git check-ignore IGNORED; absent from git status). (.claude shows not-ignored via check-ignore only because the dir doesn't exist yet — directory-scoped pattern; will ignore once created.) Left the .gitignore change uncommitted — ~/.dotfiles had 11 unrelated modified WIP files, so the commit decision is Craig's. Paged him via notify success --persist (exit 0).
+
+** Loop cycle 2 (2026-06-16 00:15 CDT)
+
+Inbox had real work this time: 2 .emacs.d handoffs + 1 roam item. No eligible task (no :next:, no :solo:+:quick:).
+
+- .emacs.d inbox-zero Phase E proposal (adds autonomous task execution to the synced inbox-zero.org): shared-asset change in a no-approvals session → defer-and-stage per the Skeptical Review gate. Staged the proposed file + diff + sender note under working/inbox-zero-phase-e/, filed a [#B] VERIFY (recommend spec it, not apply: assumes .emacs.d's commit waiver, hardcodes eligibility tags, undefined do-not-implement set + kill-switch, unresolved seam question; overlaps the fix-speedrun task — reconcile into one spec). Replied to .emacs.d that it's parked.
+- Roam item "rulesets: encourage building a knowledge base..." (:next:, rulesets-prefixed): claimed and filed as [#C] :feature: "Encourage org-roam KB contribution across workflows." Dropped the :next: tag — it touches 4 synced workflows and needs a best-practices curation decision, so it's a design task, not a loop auto-implement (guardrail). Removed from roam inbox, committed + pushed roam (af1e09f).
+- Rulesets commit 26bcae6 (todo.org VERIFY + task, working/ staged). Both inboxes verified empty. Rescheduled next wake +30 min.
+
+** /respond-to-cj-comments on todo.org (2026-06-16, Craig-invoked)
+
+cj-scan found 4 cj comments. Craig had also flipped Helper-instance and memories-sync to VERIFY in his buffer (his edits, folded into the same commit).
+
+- cj on Phase E VERIFY ("write a spec, file a verify subtask") + cj on fix-speedrun ("your call" on workflow-vs-preset + effectiveness measurement): reconciled into ONE autonomous-batch execution spec (docs/design/2026-06-16-autonomous-batch-execution-spec.org). Design call (mine, per "your call"): a dedicated work-the-backlog.org holds the loop; "fix speedrun" is a thin preset; inbox-zero stays routing-only. Spec also designs the effectiveness-measurement trial (per-task JSONL + org-roam synthesis articles). Phase E VERIFY folded to a dated entry; fix-speedrun got a dated answer + a *** VERIFY "Review the autonomous-batch execution spec".
+- cj on KB-encouragement ("write a spec, file a verify subtask"): docs/design/2026-06-16-encourage-kb-contribution-spec.org (4 light workflow prompts + curated best-practices node, sources cited). Filed *** VERIFY "Review the KB-contribution spec".
+- cj on Wrap-up routing ("approved, take through spec-response → implementation"): LEFT IN PLACE / deferred. The build moves tasks between projects' todo.org files = data-loss-adjacent cross-project mutation; per the guardrail it needs a focused /start-work session with a checkpoint, not a tail-end rush after two specs. Craig's approval edit to wrapup-routing-spec.org is unstaged (belongs with that build).
+
+Two spec drafts came from parallel general-purpose subagents (held the design decisions myself; they drafted the docs). Commit 7467d1f pushed. Both review VERIFYs await Craig.
+
+Pending at task boundary: a new inbox handoff arrived mid-task — inbox/2026-06-16-0054-from-archsetup-ai-launcher-dotfiles-discovery.org — not yet processed (next loop cycle or a follow-up).
+
+** Loop cycle 3 (2026-06-16 00:59 CDT)
+
+Processed the archsetup handoff (add =~/.dotfiles= to bin/ai's build_candidates() discovery, completing the dotfiles bootstrap). Synced asset + no-approvals → defer-and-stage. Skeptical review confirmed correct (verified build_candidates only scans .emacs.d/code/projects; maybe_add_candidate guards on .ai/protocols.org; no bin/ai discovery test to also update). Staged the one-line diff under working/ai-dotfiles-discovery/, filed a [#B] VERIFY "Parked: add ~/.dotfiles to the ai launcher's project discovery", replied to archsetup (parked). Commit 4e2db8f pushed. No eligible task (no :next:, no :solo:+:quick:). Both inboxes verified empty. Rescheduled +30 min.
+
+** Loop cycle 4 (2026-06-16 01:32 CDT)
+
+No-op cycle. Both inboxes empty, no eligible task. Craig reordered todo.org in his Emacs buffer (moved the Phase E / wrap-up-routing / KB-contribution blocks down below the memories-sync task) — a pure reorder, byte-identical content, no new cj/approval/tag. That edit plus his earlier wrapup-routing-spec.org approval annotation are uncommitted; left untouched (his in-progress WIP, not the loop's to commit). No commit this cycle. Rescheduled +30 min.
+
+** Loop cycle 5 (2026-06-16 02:04 CDT)
+
+No-op (3rd idle cycle). Inboxes empty, no eligible task, no new cj comments (the wrap-up-routing one is still the only one, still deferred). Craig's todo.org reorder + wrapup-routing-spec.org approval still uncommitted, still left alone. No commit. Rescheduled +30 min.
+
+** Loop cycle 6 (2026-06-16 02:35 CDT)
+
+No-op (4th idle cycle). State unchanged from cycle 5: inboxes empty, no eligible task, one deferred cj (wrap-up routing), Craig's WIP still uncommitted. No commit. Rescheduled +30 min.
+
+** Loop cycle 7 (2026-06-16 03:06 CDT)
+
+No-op (5th idle cycle). State frozen since cycle 4: inboxes empty, no eligible task, wrap-up-routing cj still deferred, Craig's reorder + spec approval still uncommitted. No commit. Rescheduled +30 min.
+
+** Loop cycle 8 (2026-06-16 03:37 CDT)
+No-op (6th idle cycle). State unchanged. No commit. Rescheduled +30 min.
+
+** Loop cycle 9 (2026-06-16 04:08 CDT)
+No-op (7th idle cycle). State unchanged. No commit. Rescheduled +30 min.
+
+** Loop cycle 10 (2026-06-16 04:39 CDT)
+No-op (8th idle cycle). State unchanged. No commit. Rescheduled +30 min.
+
+** Loop cycle 11 (2026-06-16 05:10 CDT)
+No-op for rulesets (9th idle cycle). A new roam inbox item appeared but it's =emacs:=-prefixed (difftastic "d" diff option for the buffer-differs prompt, :next:) — foreign to rulesets, left untouched for .emacs.d's session to claim per the ownership rule. No rulesets-owned roam items, no project inbox, no eligible todo.org task. No commit. Rescheduled +30 min.
+
+** Loop cycle 12 (2026-06-16 05:41 CDT)
+No-op (10th idle cycle). The foreign =emacs:= roam item from cycle 11 is gone (claimed by .emacs.d). Roam + project inboxes empty, no eligible task. No commit. Rescheduled +30 min.
+
+** Loop cycle 13 (2026-06-16 06:12 CDT)
+No-op for rulesets (11th idle cycle). Another =emacs:=-prefixed roam item appeared (studio view-assignment dropdown next/prev buttons, :next:) — foreign, left for .emacs.d. No rulesets-owned items, no project inbox, no eligible task. No commit. Rescheduled +30 min.
+
+** Loop cycle 14 (2026-06-16 06:43 CDT)
+No-op (12th idle cycle). Cycle 13's foreign =emacs:= roam item cleared (.emacs.d claimed it). All inboxes empty, no eligible task. No commit. Rescheduled +30 min.
+
+** Loop cycle 15 (2026-06-16 07:14 CDT)
+No-op (13th idle cycle). All inboxes empty, no eligible task, state unchanged. No commit. Rescheduled +30 min.
+
+** Loop cycle 16 (2026-06-16 07:45 CDT)
+No-op (14th idle cycle). All inboxes empty, no eligible task, state unchanged. No commit. Rescheduled +30 min.
+
+** Loop cycle 17 (2026-06-16 08:16 CDT)
+No-op (15th idle cycle). All inboxes empty, no eligible task, state unchanged. No commit. Rescheduled +30 min.
+
+** Loop cycle 18 (2026-06-16 08:47 CDT)
+No-op for rulesets (16th idle cycle). Two new =emacs:=-prefixed roam items (theme-studio palette-closed default, assignment-view dropdown improvements, both :next:) — foreign, left for .emacs.d. No rulesets-owned items, no project inbox, no eligible task. No commit. Rescheduled +30 min.
+
+** Loop cycle 19 (2026-06-16 09:18 CDT)
+No-op (17th idle cycle). Cycle 18's foreign =emacs:= roam items cleared (.emacs.d claimed them). All inboxes empty, no eligible task. No commit. Rescheduled +30 min.
+
+** Loop cycle 20 (2026-06-16 09:49 CDT)
+No-op (18th idle cycle). All inboxes empty, no eligible task, state unchanged. No commit. Rescheduled +30 min.
+
+** Loop cycle 21 (2026-06-16 10:20 CDT)
+No-op (19th idle cycle). All inboxes empty, no eligible task, state unchanged. No commit. Rescheduled +30 min.
+
+** Loop cycle 22 (2026-06-16 10:51 CDT)
+No-op (20th idle cycle). All inboxes empty, no eligible task, state unchanged. No commit. Rescheduled +30 min.
+
+** Loop cycle 23 (2026-06-16 11:22 CDT)
+No-op (21st idle cycle). All inboxes empty, no eligible task, state unchanged. No commit. Rescheduled +30 min.
+
+** Loop cycle 24 (2026-06-16 11:53 CDT)
+No-op (22nd idle cycle). All inboxes empty, no eligible task, state unchanged. No commit. Rescheduled +30 min.
+
+** Loop cycle 25 (2026-06-16 12:24 CDT)
+No-op (23rd idle cycle). All inboxes empty, no eligible task, state unchanged. No commit. Rescheduled +30 min.
+
+** Loop cycle 26 (2026-06-16 12:55 CDT)
+No-op (24th idle cycle). All inboxes empty, no eligible task. Craig edited .claude/settings.json (removed the pinned =model: opus= line) — his config WIP, left uncommitted with his other edits. No commit. Earlier this turn surfaced candidate work for Craig (shellcheck warnings across 6 scripts incl. 2 latent-bug-suspect ones in bin/ai; the parked dotfiles one-liner; token-rotation helper; spec-storage decision) — awaiting his pick on what to tag/fix. Rescheduled +30 min.
+
+** Loop cycle 27 (2026-06-16 13:27 CDT)
+No-op (25th idle cycle). All inboxes empty, no eligible task, state unchanged (Craig's settings.json + reorder WIP still uncommitted). No commit. Rescheduled +30 min.
+
+** Loop cycle 28 (2026-06-16 13:58 CDT)
+No-op (26th idle cycle). All inboxes empty, no eligible task, state unchanged. No commit. Rescheduled +30 min.
+
+** Loop cycle 29 (2026-06-16 14:29 CDT)
+No-op (27th idle cycle). All inboxes empty, no eligible task, state unchanged. No commit. Rescheduled +30 min.
+
+** Loop cycle 30 (2026-06-16 14:59 CDT)
+No-op (28th idle cycle). All inboxes empty, no eligible task, state unchanged. No commit. Rescheduled +30 min.
+
+** Loop STOPPED + remove cross-agent-comms (2026-06-16 ~15:05 CDT)
+
+Craig stopped the 30-min loop (the queued 15:31 wakeup fired once after; not continued). Then directed removing the unused cross-agent-comms subsystem (option 2: remove now, repoint helper-mode/spec refs to inbox-send). Distinct from inbox-send (the live handoff mechanism, kept). Removing: 7 scripts + 7 READMEs, cross-agent-comms.org workflow, INDEX entry, 5 test files, 3 startup.org wirings (Phase A step 6, Phase B step 4, Phase C bullet) + 2 summary mentions, helper-mode.org escalation ref (repoint to inbox-send / report-to-Craig), 7 legacy ~/.local/bin/cross-agent-* symlinks. Editing canonical (claude-templates/.ai/) then sync-check --fix to mirror. generic-agent-runtime-spec.org gets a removal note (not a full rewrite of its 9 historical refs).
diff --git a/.ai/sessions/2026-06-21-02-44-launcher-fix-kb-feature-wrapup-routing.org b/.ai/sessions/2026-06-21-02-44-launcher-fix-kb-feature-wrapup-routing.org
new file mode 100644
index 0000000..935e61d
--- /dev/null
+++ b/.ai/sessions/2026-06-21-02-44-launcher-fix-kb-feature-wrapup-routing.org
@@ -0,0 +1,110 @@
+#+TITLE: Session Context
+#+DATE: 2026-06-20
+
+* Summary
+
+** Active Goal
+
+Started as "~/.dotfiles not showing in the ai launcher," then ran long across tooling and convention work: the launcher fix, a level-2 VERIFY completion rule change, the KB-contribution feature + a new lint checker, an eight-project conversion broadcast, and the wrap-up routing spec taken through review + response to Ready with a task breakdown.
+
+** Decisions
+
+- ~/.dotfiles → track mode for its .ai/ (private cjennings.net remote, so no leak concern) — the durable fix so the scaffold travels to every machine instead of needing per-machine bootstrap.
+- Level-2 (=**=) VERIFY completes task-shaped (DONE/CANCELLED + CLOSED:), never a dated header; dated rewrites are =***=+ only (from .emacs.d; applied across 4 producers + a lint checker).
+- KB-contribution spec ratified, plus a new decision D6 (read-side startup consult-nudge — the counterpart to the write-side encouragement the spec lacked).
+- Wrap-up routing redesigned (Craig's challenge): deliver routable keepers via =inbox-send= to the destination's inbox, not a direct cross-repo =todo.org= move. Spec now Ready, [9/9] decisions.
+- ratio's diverged rulesets commit f118905 (stty-susp "fix") discarded — its premise (LAUNCH_PREFIX breaks launch) was not reproducible; backed up to a patch first.
+
+** Data Collected / Findings
+
+- A gitignore-mode .ai/ scaffold is per-machine — it doesn't travel via git, so a project bootstrapped on one machine fails the launcher's marker check on another. (Promoted to KB.)
+- inbox-send's resolve_roots REPLACES the built-in roots when ~/.claude/inbox-roots.txt exists, so parent roots must be re-listed alongside single-$HOME project dirs.
+- Several ~/code projects lacked an inbox/ dir entirely (little-elisper, rsyncshot, winvm, chime, yt-sync) — created during the conversion broadcast.
+- chime and yt-sync have inbox/ but no todo.org — which clinched the wrap-up routing redesign (direct-move would silently drop keepers headed there; inbox-route delivers).
+- stty susp undef exits 0 in a pty on both machines; the exact tmux send-keys launch path runs clean — no reproducible launch bug.
+
+** Files Modified
+
+- rulesets (committed + pushed): claude-templates/bin/ai (launcher line, f5609ec); todo-format.md + respond-to-cj-comments.md + process-inbox.org + 2 dated-header repairs (8a50088); KB-contribution spec + startup/triage-intake/inbox-zero/wrap-it-up prompts (76e5559); lint-org.el level-2-dated-header + tests + lint-org.md (f6dde4e); 3 filed-handoff tasks + design bundles (d9d3be9); wrap-up routing spec-review (23fac08), spec-response redesign (af15fae), task breakdown (df1555d).
+- ~/.dotfiles (e4a7cee): .ai/ converted to track mode. ratio: dotfiles converged, rulesets reset to origin.
+- ~/.claude/inbox-roots.txt (velox-local): added ~/.emacs.d + ~/.dotfiles so inbox-send reaches them.
+- org-roam KB: best-practices node + 2 lesson nodes (gitignored-.ai-per-machine, inbox-over-foreign-edit).
+- Handoffs sent: home + work (3 filed tasks), .emacs.d (VERIFY rule applied), and conversion handoffs to 8 ~/code projects.
+
+** Next Steps
+
+- RECOMMENDED NEXT SESSION: build the wrap-up routing feature. Spec is Ready ([[file:../docs/design/wrapup-routing-spec.org]]); 4 =:solo:= tasks under the parent at todo.org (recommendation engine + discovery, =:ROUTE_CANDIDATE:= marker in process-inbox, the wrap-it-up router sub-step, the test surface) plus 1 manual end-to-end validation. A clean fresh-session build.
+- Pending Craig review: the home "spec-response readiness-gate" proposal sitting in rulesets/inbox/ (deferred from this session — it's a proposal needing review).
+- The 8 ~/code conversion handoffs apply on each project's own next session.
+- Backlog filed this session: ntfy agent-comms, flashcard reconcile, triage-intake phone-push (all [#C], the latter two depend on decisions).
+
+KB: promoted 2 / consulted yes
+
+* Session Log
+
+** Dotfiles launcher discovery — full resolution
+
+- Root cause was two-part (launcher line missing + .ai/ gitignored so the bootstrap didn't travel). velox: applied launcher line (f5609ec), bootstrapped .ai/, then converted ~/.dotfiles to track mode (.ai/ now tracked, dotfiles commit e4a7cee) so the scaffold travels via git. velox fully working.
+- ratio: dotfiles converged to tracked .ai/ (ff-pull after removing a blocking untracked inbox/.gitkeep). But ratio's ~/code/rulesets is DIVERGED: local commit f118905 (1 ahead) vs my f5609ec (1 behind). f118905 = ratio's local fix for "stty susp undef; LAUNCH_PREFIX prevents ai launch" — but it's broken (printf split across two lines = bash syntax error), reverts C-z protection wholesale, and includes experimental "Mr. Moto" agent-id content. NOT pushable as-is. Left untouched pending Craig's decision.
+- Possible real bug flagged: LAUNCH_PREFIX="stty susp undef; " may actually break `ai` launch (ratio symptom). Needs proper verify/fix on velox canonical, separate from ratio's broken commit.
+- RESOLVED (option 1): launch bug not reproducible. Tested stty susp undef in a pty (velox + ratio, zsh + bash, coreutils 9.11) → exit 0; tested the exact tmux send-keys launch path on both → command runs (LAUNCHED-OK). f118905 misdiagnosed it (or hit a transient half-synced bin/ai on Jun 17), bundled with a broken printf split + experimental "Mr. Moto" build_instructions. Canonical needs no change; LAUNCH_PREFIX/C-z protection stays.
+- ratio fixed: backed up f118905 to ~/0001-fixing-the-stty-susp-undef-*.patch (recoverable), git reset --hard origin/main → HEAD f5609ec, launcher line present, 0/0 with origin, build_candidates lists ~/.dotfiles. Untracked ratio inbox handoffs left in place (ratio's own session concern).
+- Both machines now: ~/.dotfiles shows in the launcher picker. Done.
+
+** Processed ratio's stranded rulesets inbox (11 files, same project)
+
+Copied ratio's untracked ~/code/rulesets/inbox/ handoffs to velox, examined, none done in canonical. Three batches, all filed + preserved + originals deleted on both machines (commit d9d3be9):
+- B1 ntfy agent-comms proposal (from home, 2026-06-17): [#C] task + docs/design/2026-06-17-ntfy-agent-comms-proposal.org.
+- B2 flashcard multi-tag tooling (from work, 2026-06-17): [#C] reconcile task + docs/design/2026-06-17-flashcard-multitag-{note.md,to-anki.py,stats.py} (kept 0953 over superseded 0924).
+- B3 triage-intake auto-mode phone push (from work, 2026-06-18): [#C] task (depends on B1) + docs/design/2026-06-18-triage-intake-phone-push-{note,workflow}.org (kept 1515; 1512 identical).
+Replied to home + work confirming filed. ratio gets the todo/docs on its next launch (same project, pulled).
+
+** Processed: .emacs.d level-2 VERIFY completion rule change (commit 8a50088)
+
+.emacs.d directive: a level-2 (**) VERIFY must complete task-shaped (DONE/CANCELLED + CLOSED:), never a dated header; dated rewrites are ***+ only. Reason: a ** dated header has no keyword, so todo-cleanup --archive-done can't archive it and task-review drops it. Skeptical-reviewed → sound, agreed.
+Applied all four producer locations: claude-rules/todo-format.md (dropped the attached edited version; diff confirmed it touched only the 3 VERIFY-completion passages), .claude/commands/respond-to-cj-comments.md (3 edits + the "resolved body" nicety), claude-templates/.ai/workflows/process-inbox.org Park step (+ mirror via sync-check). Also repaired two pre-existing ** dated headers in rulesets todo.org (line 179 Phase E, line ~2698 new-personal-projects) → DONE + CLOSED. And fixed the dotfiles VERIFY I dated earlier this session (todo.org:37) → DONE [#B] + CLOSED.
+Verified: make test exit 0, sync-check clean. Replied to .emacs.d by hand-writing into ~/.emacs.d/inbox/ (inbox-send can't reach .emacs.d — no ~/.claude/inbox-roots.txt on velox). Inbox files deleted.
+
+** Fixed: inbox-send can't reach .emacs.d / .dotfiles
+
+Created ~/.claude/inbox-roots.txt (velox-local) listing ~/projects, ~/code, ~/.emacs.d, ~/.dotfiles. inbox-send's resolve_roots REPLACES defaults when the file exists, so the parent roots had to be re-listed alongside the single-$HOME project dirs. Verified: inbox-send --list now shows 10 projects incl .emacs.d + .dotfiles. Machine-local — ratio needs its own if Craig sends from there.
+
+** Investigated: KB lessons-learned strength (wrap-up write + startup read)
+
+Findings for Craig's recommendation ask: wrap-up HAS a Step-1 KB-promotion check + mandatory "KB: promoted N / consulted yes-no" receipt, but it's NOT effective — last ~5 session receipts all "promoted 0 / consulted no" (one "promoted 1"); KB's newest node is 2026-06-15 and most growth came from explicit memory-sweeps, not the wrap habit. Startup has NO KB-read step at all (only the roam *task* inbox nudge; the agent KB at ~/org/roam/agents/ is never surfaced). Existing spec docs/design/2026-06-16-encourage-kb-contribution-spec.org + DOING task todo.org:198 address the write side (4 prompts + best-practices node) but its 5 decisions are unratified AND it omits Craig's read-side ask (surfacing relevant lessons at startup). KB has 30 nodes.
+
+** Built: KB-contribution feature + read-side + lint checker (all 3 of Craig's asks)
+
+Craig approved all six decisions (5 spec + D6 new). Shipped:
+- inbox-send fix: ~/.claude/inbox-roots.txt (covered above).
+- Spec amended: docs/design/2026-06-16-encourage-kb-contribution-spec.org — 5 decisions ratified, D6 (read-side consult nudge) added + threaded through Design/Phase 2/acceptance/history, status draft→approved.
+- Best-practices KB node: ~/org/roam/agents/20260620232112-agent-kb-best-practices.org (8 sections, links how-kb-works node, defers boundary to knowledge-base.md). Committed+pushed to roam (48e50f1), findable by the documented rg.
+- 4 workflow prompts (canonical+mirror): startup.org Phase A KB-surface step + 2 Phase C nudges (D1 contribute link, D6 consult titles, both roam-gated); triage-intake + inbox-zero conditional end-of-flow capture reminders; wrap-it-up early reflection at top of Step 1 feeding the existing receipt. Commit 76e5559.
+- Lint checker: lint-org.el level-2-dated-header judgment check (custom, like org-table-standard) + 3 ERT tests + lint-org.md doc + mirrors. Commit f6dde4e.
+- Closed DONE: todo.org:200 "Encourage org-roam KB contribution" (parent DONE+CLOSED, sub-VERIFY → dated entry). Not yet committed (rides wrap-up).
+Verified: make test exit 0 (350+54+12 pytest, all bats, 36 ERT), sync-check clean. All pushed.
+
+** ~/code AI-project conversion broadcast + worktree cleanup + page
+
+Surveyed all ~/code AI projects for project-owned tooling currency (todo.org/Priority Scheme, .claude/CLAUDE.md, .ai layout). Sent tailored conversion handoffs to 7 behind projects: emacs-wttrin, archangel, website (via inbox-send); little-elisper, rsyncshot, winvm, chime (direct write — they had NO inbox/ dir, so created inbox/ + .gitkeep first, which is itself a gap fixed). Each handoff lists only that project's actual gaps. Fully-current already: auto-dim-other-buffers.el, archsetup, pearl.
+- emacs-wttrin's first send (23:37) vanished from its inbox (dir mtime 23:40, likely a live emacs-wttrin session swept it); re-delivered at 23:44, verified persists.
+- yt-sync: Craig confirmed convert it (reversing the 2026-06-12 no-todo call). Delivered the full conversion handoff (created inbox/ first; no todo.org/.claude/CLAUDE.md, old layout). 8 of 8 behind projects now handed off.
+Worktree cleanup (Craig's request, so downstream can pull rulesets): committed todo.org KB-task closure (9bcc8d1), pushed, origin 0/0, tracked tree clean. Left untracked: .ai/session-context.org (live anchor, not wrapping up) + a new home handoff (2026-06-20-2339 spec-response-readiness-gate-proposal — a proposal, left for next session's review). Paged Craig via notify --persist (23:46).
+
+** Running spec-review on wrapup-routing-spec (Craig's inbox-route challenge)
+
+Craig picked option 2 (spec-review) and raised a strong design challenge: why does the router move keepers directly into the destination's todo.org (spec D2 = atomic cross-project move helper) rather than inbox-send them to the destination's inbox/ (file-per-task or one file), letting the destination's own process-inbox file them? The inbox route reuses the sanctioned cross-project path (cross-project.md), avoids cross-repo todo.org writes (the data-loss-adjacent risk), and makes a wrong-confidence destination recoverable (receiving session rejects) instead of corrupting another tracker. Dispatched a fresh-context adversarial reviewer to evaluate this as the headline finding and write docs/design/wrapup-routing-spec-review.org. Note also surfaced this session: several ~/code projects lacked inbox/ entirely (created during the conversion broadcast), which bears on the inbox-route's "destination must have an inbox" precondition.
+
+** spec-response: wrap-up routing redesigned to inbox-send, now Ready (af15fae)
+
+Ran spec-response on wrapup-routing-spec. Accepted the review's H1 (inbox-route) + H2 (tag-at-file-time); one modify (D9: local source removal + reject-flow undo, closing the review's vague source-handling). Superseded D2/D3; added D7/D8/D9; rewrote Summary/Goals/mechanics/Phases/Acceptance for inbox-send delivery. Decisions [9/9], Status Ready. Deleted the review file. Updated todo.org:187 task body (removed its cj comment — instruction satisfied; added dated sub-entry). cj comment in the spec also removed (processed). Pending: spec-response Phase 6 (implementation-task breakdown into todo.org) on Craig's go — offered, deferrable given it's ~2am.
+
+** Session-scoped instruction
+
+Craig: always commit and push this session — don't ask for push confirmation. (2026-06-20)
+
+* Session Log
+
+** Diagnosing dotfiles-not-in-launcher
+
+Startup ran clean (no crash anchor, empty inbox). Craig reported ~/.dotfiles missing from the ai launcher list. Traced to two independent causes documented above. Craig chose to fix both.
diff --git a/.ai/sessions/2026-06-22-01-33-spec-review-fold-coverage-fix-inbox-triage.org b/.ai/sessions/2026-06-22-01-33-spec-review-fold-coverage-fix-inbox-triage.org
new file mode 100644
index 0000000..98e745e
--- /dev/null
+++ b/.ai/sessions/2026-06-22-01-33-spec-review-fold-coverage-fix-inbox-triage.org
@@ -0,0 +1,57 @@
+#+TITLE: Session Context
+#+DATE: 2026-06-21
+
+* Summary
+
+** Active Goal
+
+Process the rulesets inbox — 10 logical proposals across three groups (spec-workflow tightening, emacs-wttrin coverage/rules, home flashcard family) plus two archsetup handoffs. Built Groups A and B; filed Group C + archsetup as backlog tasks; wrapped.
+
+** Decisions
+
+- Spec review/response: incorporate the review INTO the spec. Findings are now =* Review findings= TODO tasks with a =[/]= cookie (mirroring =* Decisions=), =:blocking:= marks high-priority; the responder completes each in place. This supersedes the proposal's keep-vs-delete-review-file fork (no file at all). A1 (rerun readiness rubric on scope-expanding response) folded into the spec-response Phase 4 gate; A3 (roles explicit) + A4 (source external-dep checks) added as spec-review principles.
+- coverage-summary.el: applied the generated-package-file exclusion bugfix (=-autoloads.el=/=-pkg.el=); did NOT adopt emacs-wttrin's header rewrite (=.claude/scripts/= → tracked =scripts/=) — flagged as a separate install-location question.
+- no-attribution tightening: option 1 (documented scan discipline + exemptions, no hook). Accepted proposal changes 1-3; modified change 4 (rigid token-grep → documented Before-Committing scan) because a blanket grep false-positives on legit subject mentions, file-is-the-change commits, and private repos. Both new rules carry file-is-the-change + private-single-user-repo exemptions, framed around public/shared-remote repos.
+- Group C (flashcard) + archsetup: filed as detailed, prioritized backlog tasks rather than built (Craig paused).
+
+** Data Collected / Findings
+
+- Pre-existing committed drift in inbox-zero.org (canonical = new two-inbox version, mirror = old) — fixed by mirror sync (98ebb2f). This also satisfied the roam "inbox zero should check two places" item (already gone from roam by session end).
+- review-code (Step 1) caught that the spec refold left spec-create.org + INDEX.org still describing the old review-file convention — fixed in the same commit.
+- coverage-summary ERT tests already shipped (b46619c, 12 cs-* tests), so the "add tests" proposal was mostly pre-satisfied. Added 3 (autoloads-exclusion + =--source-files= non-recursive + =--under-dir= filter/rekey); 15 cs-* tests now.
+- Open: coverage-summary.el installs to =.claude/scripts/= (gitignored in code projects) so CI can't run =make coverage-summary= — filed [#C].
+- Roam inbox evolved mid-session: new "rulesets: multiple agent source improvements" item (naming the agent, Codex-friendly workflow wording, multi-LLM ai-term) — left for a future session.
+
+** Files Modified
+
+Committed + pushed (origin 0/0): 98ebb2f (chore: inbox-zero mirror sync), ed27e3c (refactor: spec review fold — spec-review/response/create + INDEX, canonical+mirror), fb86736 (fix: coverage-summary autoloads exclusion), 0751b3c (test: coverage-summary source-files + under-dir), 91217d9 (docs: no-attribution tooling-path tightening — commits.md + protocols.org).
+
+Wrap-up commit (this session's tail): todo.org (6 new tasks), 6 docs/design files (anki-titlefix bundle, apkg buildreq, refutation proposal, host-identity proposal), session archive.
+
+Handoffs sent: home (spec-workflow reconcile; flashcard items filed), emacs-wttrin (coverage cluster; no-attribution tightening), archsetup (host-identity filed).
+
+** Next Steps
+
+- Flashcard cluster coordination: "Anki deck name from #+TITLE" [#B, ready code], "Reconcile flashcard multi-tag tooling" (:315), and "flashcard-stats refutation mode" [#C] all edit flashcard-to-anki.py / flashcard-stats.py — build together to avoid conflicting edits.
+- apkg → org-drill converter [#C], host-identity guard [#C], coverage-summary install location [#C], warn-only enum hook [#D] — all filed.
+- New roam item "rulesets: multiple agent source improvements" awaits a future session.
+
+KB: promoted 0 / consulted yes (session was process/workflow edits — no durable cross-project facts to promote).
+
+* Session Log
+
+** Startup + inbox triage
+
+Clean startup (no crash anchor). Inbox held 12 files / 10 logical proposals + (mid-session) 2 archsetup handoffs. Grouped into A (spec-workflow), B (emacs-wttrin), C (flashcard). Walked group by group per Craig.
+
+** Group A — spec review folded into the spec
+
+Read both canonical workflows + diffed home's edited spec-review.org. Four candidate changes (A1 readiness rerun, A2 review-file retention, A3 roles-explicit, A4 source-checks). Craig chose to incorporate the review into the spec (option 1), reusing the decisions-as-tasks machinery. Applied all edits to spec-review.org + spec-response.org; review-code caught spec-create.org + INDEX.org stragglers (fixed). make test green ×2. Commits 98ebb2f + ed27e3c, pushed. Replied to home; deleted 3 inbox files.
+
+** Group B — coverage-summary cluster + no-attribution tightening
+
+Found canonical coverage-summary.el is the elisp bundle and tests already existed. Applied the autoloads bugfix via TDD (red→green, fb86736) + 3 tests (0751b3c). Did not adopt the header relocation — flagged install-location as a follow-up. No-attribution tightening: option 1, edited commits.md (2 spots) + protocols.org item 3 (91217d9). Replied to emacs-wttrin twice; deleted 3 inbox files.
+
+** Pause + inbox zero
+
+Filed Group C (3 flashcard) + archsetup host-identity as 4 backlog tasks, plus 2 session follow-ups (coverage-summary location, enum hook) — 6 tasks total under Rulesets Open Work, properly prioritized. Preserved the anki edited code + all proposals in docs/design/. Replied to home + archsetup; project inbox empty. Then wrapped.
diff --git a/.ai/sessions/2026-06-23-22-36-inbox-guard-bash-bundle-consolidation-spec.org b/.ai/sessions/2026-06-23-22-36-inbox-guard-bash-bundle-consolidation-spec.org
new file mode 100644
index 0000000..19e9a2c
--- /dev/null
+++ b/.ai/sessions/2026-06-23-22-36-inbox-guard-bash-bundle-consolidation-spec.org
@@ -0,0 +1,164 @@
+#+TITLE: Session — inbox guard, bash bundle, agent-neutral rules, inbox-consolidation spec
+
+* Summary
+
+** Active Goal
+
+Started by processing two inbox handoffs, then ran long across a queue of work:
+two handoff fixes, a new bash bundle, item 1-2-3 from the roam inbox, and a spec
+for consolidating the inbox workflows. Five commits landed; the inbox-consolidation
+spec reached Ready for a next-session build.
+
+** Decisions
+
+- Item 1: build the guard as a shared, testable script (.ai/scripts/), not
+ inline in the workflow. Fix the handoff's malformed lisp (#(quote …) →
+ #'buffer-name). Add an emacs.md cross-reference.
+- Item 2 (Craig's design answers, all recommendations): (Q1) language-neutral
+ default *fallback* template install-lang seeds when the bundle ships none —
+ per-bundle templates still win where present; (Q2) the default names *no*
+ language — Craig fills it in; (Q3) inbox-zero's wrap-up roam sub-step, on a
+ live-capture collision, *skips that sub-step non-blocking* and finishes the
+ rest of wrap-up.
+- Bash/shell language bundle: built this session (shellcheck the gate, shfmt left
+ advisory since shell has no canonical style). Confirmed the five bundles cover
+ Craig's ecosystem — no further bundles warranted.
+- Item 1 (inbox-zero empty sweep): built. Item 2 (agent-source): claude-rules/
+ neutralized + .emacs.d note sent; the workflow sweep parked behind consolidation
+ (don't neutralize files about to be merged). Item 3 (wrap-teardown): filed, not
+ built (open decisions).
+- Inbox consolidation: spec it before building (load-bearing 3→1 merge of synced
+ workflows). Engine shape = Option A (one inbox.org, process/monitor/roam modes).
+ "auto inbox zero" = interactive /loop only in v1; fully-unattended /schedule
+ cron pass deferred to vNext (Codex finding, narrowed).
+
+** Data Collected / Findings
+
+- Verified handoff #2 against the tree: claim "only elisp ships CLAUDE.md" is
+ stale — go ships one too; python + typescript ship none; install-lang guards
+ on [ -f "$SRC/CLAUDE.md" ]. Core gap holds.
+- Only inbox-zero.org Phase D writes the roam inbox on disk (startup reads,
+ wrap-up delegates to inbox-zero). So one guard call covers it.
+- Canonical/mirror: claude-templates/.ai/ is canonical; .ai/ root is the mirror
+ kept in sync by scripts/sync-check.sh (pre-commit enforced). Edit canonical,
+ then sync --fix.
+
+** Files Modified
+
+Item 1 (capture guard): NEW claude-templates/.ai/scripts/capture-guard + its
+bats test; edited claude-templates/.ai/workflows/inbox-zero.org (Phase D guard
+step, before the pull); edited claude-rules/emacs.md (new "don't edit on disk a
+file the daemon is capturing into" section). Mirror synced to .ai/.
+
+Item 2 (CLAUDE.md fallback): NEW languages/default-CLAUDE.md (neutral, names no
+language); edited scripts/install-lang.sh (fall back to default when bundle
+ships none); Makefile LANGUAGES glob hardened to dirs-only; scripts/lint.sh
+lints the default; scripts/tests/install-lang.bats +3 tests.
+
+Housekeeping: bash bundle filed todo.org [#C]; both handoffs preserved to
+docs/design/ (2026-06-22-inbox-zero-capture-hardening.org,
+2026-06-23-install-lang-claude-md-gap.org); TODO link repointed; replies sent to
+home + archangel; notes.org :LAST_INBOX_PROCESS: stamped 2026-06-23.
+
+Second half: NEW languages/bash/ bundle (rules, validate-bash.sh shellcheck hook
++ 8 bats tests, pre-commit githook, settings, CLAUDE.md), Makefile glob for
+languages/*/tests/*.bats, README + notes bundle-list fixes. inbox-zero.org Phase
+B/D empty-sweep. claude-rules/ (interaction, cross-project, working-files,
+triggers) agent-neutralized. NEW docs/inbox-workflow-consolidation-spec.org
+(Ready). todo.org: bash bundle DONE, + filed wrap-teardown [#B], inbox-empty
+[#C], agent-source [#C], consolidation [#B], unattended-cron [#D].
+
+Commits this session: 603abc4 (capture-guard), 71db71b (install-lang fallback),
+3626285 (bash bundle), 3da2725 (inbox empty sweep), 6ad0442 (agent-neutral rules).
+Roam repo: 436d646 (route rulesets tasks). All un-pushed until this wrap.
+
+Verification: full make test exit 0 (200 ok, 0 not-ok); lint clean except a
+pre-existing remove.sh chmod warning (untouched); capture-guard lisp eval'd in the
+live daemon; bash hook + githook dogfood shellcheck-clean.
+
+** Next Steps
+
+Build the inbox consolidation from the Ready spec (docs/inbox-workflow-consolidation-spec.org):
+Phase 1 author inbox.org, Phase 2 reconcile callers + retire the 3 old files +
+stale-ref grep, Phase 3 auto-inbox-zero, Phase 4 verify. Then the parked agent-
+neutrality workflow sweep (over the consolidated file), then item 3 (wrap-teardown,
+needs Craig's 3 open decisions). Queue tasks #5, #6, #7 carry these.
+
+KB: promoted 0 / consulted no
+
+* Session Log
+
+** Committed both fixes (603abc4, 71db71b)
+
+Two feat commits landed as Craig: capture-guard (603abc4) and install-lang
+neutral CLAUDE.md (71db71b). Author verified c@cjennings.net. Push deferred to
+wrap-up. notes.org marker + session-context.org stay for the wrap commit.
+
+Built the bash/shell bundle (languages/bash/): rules (bash.md, bash-testing.md),
+shellcheck validate hook (8 bats tests, handles extensionless shell via shebang,
+caught a real path-dot bug during TDD), shellcheck pre-commit githook,
+settings.json, gitignore-add.txt, "Bash/shell project" CLAUDE.md. Makefile test
+target extended to discover languages/*/tests/*.bats. README bundle table + notes
+bundle list corrected (were stale at elisp-only / elisp+python). bash-bundle TODO
+closed DONE. Full make test exit 0 (200 ok), lint clean, hook+githook dogfood
+shellcheck-clean, installs + fingerprint-detects correctly.
+
+Committed bash bundle (3626285). Awaiting Craig's call on further bundles (I
+recommended none — the five cover his visible ecosystem).
+
+** Inbox zero (local + roam)
+
+Local inbox: a new home handoff arrived mid-session — wrap-it-up teardown +
+"wrap it up and shutdown" (Craig's own design). Filed [#B] :feature:, preserved
+to docs/design/2026-06-23-wrap-teardown-shutdown-proposal.org, replied to home,
+NOT built (shared-asset + open decisions). Companion cj/ai-term-quit routes to
+.emacs.d when built.
+
+Roam inbox (11 items, 2 rulesets-claimed): imported both to rulesets todo.org —
+"inbox-zero: delete empty roam entries on triage" [#C], and "Multiple
+agent-source improvements" [#C] :spec: (naming the agent, agent-neutral rule
+source / Codex review, multi-LLM ai-term note to .emacs.d). Removed both from the
+roam inbox via the new capture-guard (exit 0, safe) + pull, committed the roam
+repo (436d646). 9 foreign items left untouched (emacs, pocketbook, archsetup x4,
+chime, emacs-wttrin, pearl). No empty entries existed to delete this pass.
+
+Pending push: rulesets 4 commits ahead (603abc4, 71db71b, 3626285 + the inbox
+filing), roam 1 commit (436d646, roam-sync timer will push). Hold for wrap-up.
+
+** Startup + inbox triage
+
+Ran startup. Clean prior wrap-up (no session-context). 16 stale tasks, 11 roam
+inbox items (2 rulesets-related). Two pending inbox handoffs, both shared-asset
+change proposals → skeptical review, surfaced to Craig. Craig chose to implement
+both now (bash bundle filed separately) and answered the three design questions
+with all recommendations.
+
+** Items 1-2 + the consolidation pivot to a spec
+
+Craig set up a visible task queue (TaskCreate #1-#7) and worked items 1, 2, 3 in
+order. Item 1 (inbox-zero empty-entry sweep): added a Phase B "empty" bucket +
+Phase D removal so aborted/blank roam headings get swept every triage; committed
+3da2725. Item 2 (agent-source): thread 3 (.emacs.d multi-LLM ai-term note) sent;
+claude-rules/ neutralized (7 agent-as-actor "Claude" → "the agent" edits, commits.md
+correctly untouched) committed 6ad0442; the 45-file workflow-neutralization sweep
+PARKED.
+
+Mid-flow Craig dropped a roam item — "consolidate inbox workflows, there's too
+many" + how to schedule inbox checks. Routing a 45-file neutrality sweep over
+workflows about to be consolidated would be wasted, so parked #5 behind a new
+consolidation task #7, routed the roam item to todo [#B], and mapped the inbox
+landscape (3 overloaded surfaces: local inbox/ dir → process-inbox+monitor-inbox;
+roam → inbox-zero; external → triage-intake). Proposed 3→1 engine; Craig chose to
+SPEC it.
+
+Ran the spec trio: spec-create authored docs/inbox-workflow-consolidation-spec.org
+(Option A: one inbox.org engine, process/monitor/roam modes, shared core);
+Craig resolved all 4 decisions and specified "auto inbox zero" (interactive /loop,
+ask-interval, acknowledge-only-on-empty, find → summarize → file → displayed queue
+→ ask-to-execute, cross-cycle dedup). Codex reviewed (2 findings, 1 blocking).
+spec-response folded both: finding 1 (unattended behavior unspecified) accepted via
+narrow — v1 = interactive auto inbox zero only, fully-unattended /schedule cron pass
+deferred to vNext [#D] with its open contract questions; finding 2 (checker doesn't
+validate workflow links) accepted — added an explicit stale-reference grep as an
+acceptance item, dropped the over-claim. Spec Status → Ready, Decisions [4/4],
+Findings [2/2]. The build (Phase 1 author inbox.org) is the next session's work.
diff --git a/.ai/sessions/2026-06-24-00-14-inbox-consolidation-wrap-teardown-roam-fix.org b/.ai/sessions/2026-06-24-00-14-inbox-consolidation-wrap-teardown-roam-fix.org
new file mode 100644
index 0000000..084a233
--- /dev/null
+++ b/.ai/sessions/2026-06-24-00-14-inbox-consolidation-wrap-teardown-roam-fix.org
@@ -0,0 +1,85 @@
+#+TITLE: Session — inbox consolidation, chime fix, wrap-teardown, roam-sync fix
+
+* Summary
+
+** Active Goal
+
+Continuation past an earlier wrap (Craig chose to keep going). Ran a "1 then 2
+then 3" sequence, then a follow-on fix and a recurring-loop setup. All shipped
+and pushed; ended on a clean wrap. No open work item carried forward.
+
+** What shipped (all pushed to origin/main)
+
+1. *Inbox consolidation* (24ca58d). Merged process-inbox + monitor-inbox +
+ inbox-zero into one =inbox.org= engine: shared core (value gate, skeptical
+ review, disposition ladder, reply discipline, capture-guard, priority-scheme)
+ + process/monitor/roam/auto modes. Repointed every caller (INDEX, protocols,
+ startup, wrap-up, triage-intake, broadcast, two script comments, two
+ claude-rules files), deleted the three old files. Built from the Ready spec
+ (all 4 phases). Closed todo.org [#B] consolidation + [#C] empty-sweep; the
+ fully-unattended /schedule pass stays the [#D] vNext task.
+2. *Chime validate-el.sh fix* (e5aab19). Added the one-line =(cd tests/)= before
+ the Phase 2 ERT load in the canonical elisp hook — restores regression b2e9038
+ lost when .claude refreshed to canonical. Verified identical to chime's diff +
+ shellcheck-clean; replied to chime.
+3. *Wrap-teardown rulesets side* (f87f59c) + *Stop-hook wiring* (96cd34f).
+ Craig's decisions: both summary qualifiers ("with summary" / "and summarize"),
+ Emacs-timer countdown, cj/ai-term-live-count gate. Built hooks/ai-wrap-
+ teardown.sh (Stop hook, sentinel-gated, 8 bats green), settings-snippet +
+ live .claude/settings.json Stop block, wrap-it-up Teardown-mode section +
+ Step 6 + checklist, INDEX. Companion spec (cj/ai-term-quit, -live-count,
+ -shutdown-countdown) routed to .emacs.d; it confirmed receipt + filed it.
+4. *Roam-sync fix* (f83d4bb). Roam mode no longer git-pulls the chronically-dirty
+ roam repo — the scan reads the working tree, the rare write edits + triggers
+ roam-sync (which commits-first-then-rebases). Fixes the loop failing every
+ cycle on a dirty tree.
+
+** Decisions
+
+- Wrap-teardown: both non-destructive qualifiers accepted; Emacs run-at-time
+ countdown; cj/ai-term-live-count safety gate (Craig, 2026-06-23).
+- Roam triage hands git to roam-sync rather than pulling (Craig picked option 1,
+ 2026-06-24). Trade-off accepted: generic roam-sync commit message; provenance
+ lives in todo.org + session log.
+- This wrap is a NORMAL wrap, not the new teardown — that feature isn't
+ operational until the Stop hook activates next session and the .emacs.d
+ companion lands.
+
+** Open / carryover
+
+- *wrap-teardown task is DOING*, blocked on: (c) .emacs.d lands the three
+ companion functions (handoff in its inbox, confirmed received); (d) the manual-
+ validation checklist under the task in todo.org (needs Craig's live Emacs/tmux).
+ Install steps (a) hook symlink + (b) settings Stop block are done.
+- *Deferred dirty (Craig's call):* docs/design/2026-06-15-fix-speedrun-workflow-
+ proposal.org — his in-flight "fix speedrun" → "speedrun" rename, only in that
+ file; ~20 references remain elsewhere. Left untouched again.
+- *12 top-level tasks unreviewed >30 days* — the daily task-review keeps slipping;
+ a task-review cycle is overdue.
+- The one :quick: task (token-rotation helper) is held by decision until a real
+ token rotation is imminent (can't verify without a live browser re-grant).
+
+KB: promoted 0 / consulted no. The durable lessons this session (don't pull the
+roam repo during triage; Stop-hook sentinel decoupling for teardown) landed in
+the canonical workflows + hook themselves, so a KB node would only duplicate what
+the repo now records.
+
+* Session Log
+
+** 2026-06-23 Tue @ 22:52:33 -0400 — flushed
+Clean boundary after the earlier wrap/push. Resume = build the inbox consolidation from the Ready spec, then wrap-teardown.
+
+** 2026-06-23 Tue @ 23:07:23 -0400 — inbox consolidation built
+Built the full inbox.org engine from the Ready spec (all four phases): shared core + process/monitor/roam/auto modes, absorbing the three old workflows. Repointed every live caller, deleted the old files, synced the mirror. Verified: workflow-integrity OK both trees (42 workflows), stale-ref grep clean, sync-check clean, make test green. /review-code → Approve, /voice personal on the message. Pushed 24ca58d. Closed [#B] consolidation + [#C] empty-sweep; [#D] /schedule stays vNext.
+
+** 2026-06-23 Tue @ ~23:15 -0400 — chime validate-el fix
+Processed the chime handoff (inbox.org process mode): applied the one-line Phase 2 cd-to-tests fix to the canonical elisp hook, verified identical to chime's diff + shellcheck-clean. Pushed e5aab19, replied to chime, cleared the inbox.
+
+** 2026-06-23 Tue @ ~23:30 -0400 — wrap-teardown rulesets side
+Craig decided the three open questions. Built the Stop hook (8 bats green, shellcheck clean), settings wiring, wrap-it-up Step 6 + Teardown-mode section + checklist, INDEX. Pushed f87f59c. Routed the cj/ai-term-* companion spec to .emacs.d. On Craig's "why can't you do install steps" push: wired the live .claude/settings.json Stop block (it's a tracked repo file) + ran make install-hooks — pushed 96cd34f. .emacs.d confirmed receipt + filed the companion.
+
+** 2026-06-24 Wed @ ~00:00 -0400 — auto inbox zero loop + roam-sync fix
+Set up the auto inbox zero /loop (cron, every 10 min). First two cycles found only a .emacs.d FYI (companion received) + nothing for rulesets in roam. The roam pull failed on a dirty tree; root-caused it (constant captures + 15-min roam-sync timer = chronically dirty) and Craig picked the full fix (option 1): roam mode never pulls — read-only scan + edit-then-trigger-roam-sync. Pushed f83d4bb, replaced the loop prompt (job a37f53bc), then stopped the loop on Craig's go.
+
+** 2026-06-24 Wed @ 00:14:02 -0400 — wrap
+Stopped the loop. Checked todo.org: nothing speedrunnable (the one :quick: task is held by decision; the rest are substantive specs/features or blocked DOING). Ran a normal wrap (teardown feature not operational this session). todo-cleanup archived 2 done subtrees, lint reformatted one table, inbox clean, roam sweep a no-op.
diff --git a/.ai/sessions/2026-06-24-09-27-task-audit-blocked-deps-anki-wrap-teardown.org b/.ai/sessions/2026-06-24-09-27-task-audit-blocked-deps-anki-wrap-teardown.org
new file mode 100644
index 0000000..b0a4994
--- /dev/null
+++ b/.ai/sessions/2026-06-24-09-27-task-audit-blocked-deps-anki-wrap-teardown.org
@@ -0,0 +1,75 @@
+#+TITLE: Session — task audit, blocked/blocker deps, Anki fix, wrap-teardown unblocked
+
+* Summary
+
+** Active Goal
+
+Long post-wrap continuation (past the 00:14 wrap). Ran a task audit, built two
+task-workflow features (cross-project dependency tags + audit consolidation),
+shipped the Anki #+TITLE fix, and unblocked the wrap-teardown feature when the
+.emacs.d companion landed. Ended on a wrap + a live test of the wrap-teardown
+workflow. 15 rulesets commits pushed (5cdbf13..9709638) plus a roam sync.
+
+** What shipped (all pushed to origin/main)
+
+- *Roam sync* — committed + pushed the dirty roam tree via roam-sync (65514c2).
+- *Task audit, Phases A-F* (5cdbf13): reconciled 23 open tasks (19 current, 2
+ stale fixed, 2 VERIFY flags). Resolved the helper-instance dependency question
+ to a buildable TODO (cc93fa8). Memory-sync VERIFY parked. Added the
+ =daily-drivers.md= rule (ratio/velox machine-sync, 03ad150). Chained a
+ task-review pass: stamped 12 never-reviewed tasks, tagged two :quick:solo:
+ (558624e).
+- *"session wrapped." signoff* (d5cc37c): wrap-it-up valediction now ends with
+ =session wrapped.= on its own line. (Your roam-inbox request.)
+- *capture-guard --wait* (1eaec82): poll mode so a transient org-capture clears
+ itself instead of bouncing a roam edit; roam mode + auto-loop fall back only
+ after the wait. 3 new bats cases.
+- *Cross-project dependency tags* (4d2f83d, 0d87c80, then 06b6cbc + 9709638 for
+ the bidirectional + tag-form revision): =:blocked:= on the waiting task,
+ =:blocker:= on the task that owes the work, detail in the body (no property).
+ Setting :blocked: requires a reciprocal inbox-send so the blocker learns;
+ open-tasks surfaces :blocker: first and pulls :blocked: out of the cascade.
+ Global (todo-format.md + open-tasks.org + inbox.org). The two filed
+ task-mgmt ideas (6de1712) that spawned this are DONE.
+- *Task-audit consolidation* (bcfce0e): Phase C.5 proposes merge-or-parent for
+ related-task clusters.
+- *Anki #+TITLE fix* (060a938, closed 3b48416): default_deck_name reads the
+ org #+TITLE, not the filename. TDD red->green, 29 pass. Coordination note left
+ on the flashcard multi-tag task (its preserved file now predates this fix).
+- *wrap-teardown unblocked* (0127889): .emacs.d landed the three ai-term
+ companion functions (double-checked the bodies — they match + exceed the
+ contract: TOCTOU re-check, configurable shutdown command). Dropped :blocked:.
+
+** State of the wrap-teardown feature
+
+Code-complete on both sides (rulesets Stop hook + wrap-it-up Step 6; .emacs.d
+companion + 13 ERT tests). The feature is ARMED: a bare "wrap it up" now tears
+the session down, "wrap it up and shutdown" powers off after the gate. The only
+remaining item is the manual end-to-end validation (the checklist under the
+task). IMPORTANT: the Stop hook was wired mid-session, and the harness loads
+hooks at session start — so the teardown fires reliably from the NEXT session,
+or this session only after =/hooks= is opened once. Don't drop a teardown
+sentinel blind, or it misfires on the next session's first stop.
+
+** Open / carryover
+
+- wrap-teardown: DOING, manual validation pending (your env). Feature armed.
+- Wrap-up inbox/transcript routing: DOING, spec Ready, 5 sub-tasks; the
+ recommendation-engine sub-task (:solo:) is the clean entry point. Craig may
+ pick this or another up next session.
+- fix-speedrun proposal: still the deferred dirty file (docs/design/2026-06-15),
+ untouched.
+- flashcard multi-tag task: re-derive against the post-Anki-fix canonical.
+
+KB: promoted 0 / consulted no. Durable lessons (the blocked/blocker convention,
+the roam-no-pull rule, capture-guard --wait) all landed in the synced rules +
+workflows themselves, so a KB node would duplicate the repo.
+
+* Session Log
+
+** 2026-06-24 Wed @ 09:27 — wrap + wrap-teardown live test
+Closed out a long continuation: task audit, the blocked/blocker dependency
+feature (built property-based, then refactored to plain tags on Craig's call),
+the Anki #+TITLE fix, and the wrap-teardown unblock. todo-cleanup archived 2
+done subtrees (Anki, Morning-ops cancel); lint reflowed a table; roam sweep +
+inbox both clean. Wrapping to test the now-armed wrap-teardown workflow live.
diff --git a/.ai/sessions/2026-06-28-15-57-inbox-proposals-shipped-and-task-audit.org b/.ai/sessions/2026-06-28-15-57-inbox-proposals-shipped-and-task-audit.org
new file mode 100644
index 0000000..bf67b8e
--- /dev/null
+++ b/.ai/sessions/2026-06-28-15-57-inbox-proposals-shipped-and-task-audit.org
@@ -0,0 +1,278 @@
+#+TITLE: Session Context
+#+DATE: 2026-06-28
+
+* Summary
+
+** Active Goal
+
+Startup → processed all 11 inbox handoffs (7 shared-asset proposals, skeptical-
+reviewed via parallel subagents, walked A–G with Craig), then two follow-ups
+(code-quality umbrella workflow + a task review), then a full task audit, then
+wrap. Everything shipped is committed + pushed to origin/main.
+
+** Decisions
+
+- A simplification mode added to /refactor, and Craig chose to include it in the
+ default full scan (not own-mode-only).
+- locating-craig.md is a standalone rule (not folded into daily-drivers.md).
+- suspend.org built standalone-but-lean (not folded into flush), always-commit
+ step dropped.
+- Commit gate: prose tightening AND a hard PreToolUse deny on bundled
+ test+commit (Craig picked the hook backstop over prose-only).
+- Bug-priority matrix is BINDING for any project with a codebase (not opt-in);
+ mapping P1→[#A], P2→[#B], P3→[#C], P4→[#D]; home + work notified and work has
+ adopted it.
+- Dot-stripped project names: alias approach (exact match still wins).
+- readability-audit kept separate from /refactor (it feeds /refactor by filing
+ :refactor: tasks).
+- Wrap done as NORMAL wrap, not teardown — the teardown feature is unvalidated
+ (its manual test was deferred this session), so no teardown sentinel dropped.
+
+** Data Collected / Findings
+
+- Task audit verdict: contrary to "many shipped," NONE of the 21 open tasks are
+ fully-done-but-open. The two closest (wrap-teardown, memories-sync) are
+ code-complete, gated only on Craig's manual validation.
+- The git-commit-confirm hook's deny path: `** TODO` is a substring of
+ `*** TODO`, so Edit old_strings on demoted headings need a leading-newline
+ boundary to disambiguate.
+- route_recommend matching: word-boundary literal match avoids home/homeowner
+ false positives; weak matching on common-word names (home, work) can
+ over-route — accepted v1 risk (labeled weak, reject-flow recovers).
+
+** Files Modified (all committed + pushed)
+
+- b621914 .claude/commands/refactor.md — simplification mode (later folded into full scan, 96dfa63 era edits)
+- d4e9d7d claude-rules/locating-craig.md
+- 797c426 suspend.org + readability-audit.org (+ INDEX, protocols)
+- 92dfc35 verification.md + commits.md + hooks/{git-commit-confirm,_common}.py + tests (bundled-test deny)
+- 9753d03 triggers.md + inbox-send.py (+ mirror, tests) — dot-stripped names
+- 798ef02 claude-rules/todo-format.md + docs/design bug-priority bundle — binding matrix
+- 6fb6797 notes.org inbox marker
+- 96dfa63 code-quality.org umbrella workflow (+ INDEX)
+- 5263cd6 todo.org — task review (3 restamped, generic-runtime → [#D])
+- 6be62ae .ai/scripts/route_recommend.py (+ mirror, tests) — wrap-up routing recommendation engine (spec Phases 1+3)
+- 749566c todo.org — task audit reconciliation + flashcard tooling cluster
+
+** Next Steps — PICK UP HERE
+
+Phase D of the task audit was started but only 1 of 5 items handled. Remaining,
+in suggested order:
+
+1. *wrap-teardown (task 42) manual validation — DEFERRED today.* Feature shipped
+ + pushed both sides; only the 5-test checklist remains (in the task body).
+ HAZARDS when running: test 1 tears down whatever session runs it (use a
+ SCRATCH ai-term session, never the live one); test 4 powers off (stub
+ `sudo shutdown now` → echo first); each "wrap" test ends its session so they
+ don't chain. On all-pass close task 42 DONE + CLOSED:; a failure becomes a bug.
+2. *memories-sync VERIFY (task 214).* Implementation fully shipped; can't close
+ until (a) its manual-validation child runs and (b) ratio gets the one-time
+ roam.git clone + roam-sync timer (velox confirmed; ratio still outstanding,
+ can't verify from velox). See daily-drivers.md.
+3. *Spec storage location + lifecycle convention (task 362).* Stalled on one
+ decision: filename-suffix vs org-keyword for lifecycle status. Needs Craig's call.
+4. *"fix speedrun" autonomous-batch (task 383, DOING).* Stalled at spec-review;
+ needs Craig to ratify (or re-park) the 6 open spec decisions before building
+ work-the-backlog.org.
+5. *Tooling-path warn-hook (task 434, [#D]).* Craig chose docs-only before;
+ greenlight building the warn-only hook, or leave [#D].
+
+Also queued (not Phase D):
+- Wrap-up routing feature (task 133, DOING): engine landed (6be62ae). Next
+ sub-tasks under it: :ROUTE_CANDIDATE: marker in inbox process mode, the
+ wrap-it-up router sub-step, the test surface, then manual e2e validation. All
+ call route_recommend.py. Spec: docs/design/wrapup-routing-spec.org.
+- Flashcard tooling cluster (task parent created this session): apkg converter,
+ refutation (generic header-exemption per cj), multi-tag reconcile — build
+ together, same scripts; re-derive against the post-#+TITLE-fix canonical.
+
+KB: promoted 0 / consulted no
+
+* Session Log
+
+** 2026-06-28 Sun — Startup + inbox triage
+
+Ran startup (Phase A.0/A/B). Clean prior wrap (no session-context.org). .ai/
+synced from templates fine. Findings: 11 pending inbox handoffs, 3 top-level
+tasks unreviewed >7 days, roam inbox 2 items, KB 51 nodes (none relevant).
+
+The 11 inbox items resolve to ~7 substantive proposals (some carry a cover
+note):
+- A: Simplification lens for the refactor skill (.emacs.d)
+- B: new rule locating-craig.md (home) + cover note
+- C: new workflow suspend.org (.emacs.d) + cover note
+- D: bug-priority severity×frequency matrix (wttrin) + cover note
+- E: harden commit gate to require green full suite (wttrin)
+- F: generalize readability-audit.org into a template workflow (.emacs.d) + cover
+- G: strip dots from project names .emacs.d→emacsd (.emacs.d roam item)
+
+All are shared-asset/convention changes → skeptical review + Craig approval, none
+self-applies. Dispatching parallel read-only skeptical-review subagents for A-F;
+G is a lightweight file-as-task. Surfacing dispositions to Craig next.
+
+Six skeptical reviews returned (all "do it with named changes"):
+- A: keep only new lenses 1/2/3/4/7; cross-ref 5(dead-code)/6(duplication)/8(dead-code)
+ instead of re-specifying; own mode, NOT in default full scan; state /refactor↔/simplify
+ boundary. Lands in .claude/commands/refactor.md (it's a slash command, no refactor/ dir).
+- B: standalone claude-rules/locating-craig.md. Changes: add whereami-fails fallback
+ (never fabricate), positive gate (velox→run else ask), drop -lts secondary tell,
+ add public-artifact-scope line (geolocated address is personal). whereami confirmed
+ real at ~/.local/bin/whereami; host is velox.
+- C: REAL DESIGN FORK — substantial overlap with the flush skill (suspend ≈ flush's
+ pre-clear checkpoint minus /clear, plus a commit, reframed for departure). Reviewer
+ prefers folding into flush as a "checkpoint-and-stop" variant over a standalone
+ workflow. Also: drop the always-commit-working-files step (.emacs.d-ism; blind commits
+ fight wrap-it-up's leftover discipline). Startup's resume path already exists.
+ Companion: INDEX + protocols triggers if kept standalone.
+- D: fold into todo-format.md as an OPTIONAL opt-in scheme; drop the global
+ letter-mapping mandate (present as template); keep security/privacy carve-out; mark
+ release-vehicle column illustrative. Defer the non-coding companion matrix as a
+ separate task.
+- E: only the new mechanic is load-bearing (run suite as own command, gate on
+ failures==0, never bundle `make test; git commit`). Two-line edits to verification.md
+ #1 and commits.md #5; exact wording provided. Asks #2/#4 already covered.
+- F: generalize Elisp specifics; drop concrete ;;; markers (keep principle); add
+ graceful degradation; add cross-links to /refactor + /simplify + A. Coherent division
+ of labor confirmed (readability = prose/naming clarity; /refactor = structural metrics;
+ /simplify + A = behavior-preserving logic reduction). Lands as a template workflow.
+- G: file-as-task (strip dots from project names; touches triggers.md + inbox-send).
+
+*** Item A — APPLIED (not yet committed)
+Craig approved option 1, then changed his mind: simplification IS part of the
+default full scan. Added Mode:
+Simplification to .claude/commands/refactor.md (frontmatter Six→Seven modes,
+argument-table row, new section after Mode: Dead Code) with the 5 new lenses,
+cross-refs to Dead Code (twin branches, plain deletion) + Duplication (repeated
+literals), verify-all-call-sites rule, and a /simplify boundary note. Harness
+picked up the skill change live mid-session. Confirmed for Craig: Rename mode
+executes renames but doesn't flag bad names; no organization scan exists — both
+gaps are proposal F's territory.
+
+*** Item B — APPLIED (not yet committed)
+Craig approved. Wrote claude-rules/locating-craig.md (standalone) with the four
+review changes: whereami-fails fallback (never fabricate), positive host gate
+(velox→run, any other host→ask), dropped the -lts secondary tell, added a
+keep-out-of-shared-artifacts section. make install linked it into
+~/.claude/rules/locating-craig.md.
+
+*** Item C — APPLIED (not yet committed)
+Craig picked option 1 (standalone, lean). Wrote
+claude-templates/.ai/workflows/suspend.org with the review changes: drop the
+always-commit step (note uncommitted work, leave tree as-is; project-opt-in
+always-commit set only), cross-refs to flush + wrap-it-up, states it's the
+capture half (startup is the resume half), flags "I need to go" breadth.
+Registered in INDEX.org (Session lifecycle, after wrap-it-up) + protocols.org
+trigger section. sync-check --fix synced canonical→mirror; re-verified exit 0,
+suspend.org mirror matches.
+
+*** Item D — REVERTED then RE-APPLIED (binding), not yet committed
+First applied an opt-in version; Craig reverted it ("do it differently"). His
+intent: the matrix is BINDING, not opt-in — any project with a codebase (incl.
+home + work, which have one despite being non-code) must prioritize its codebase
+bugs by the matrix. Re-applied to claude-rules/todo-format.md as a mandatory
+subsection. Mapping per Craig (2a): P1→[#A], P2→[#B], P3→[#C], P4→[#D] (fixed,
+not a per-project knob). Bands defined per codebase; matrix structure + mapping
+fixed. Severity-alone carve-out kept. Sent adoption handoffs to home + work
+(inbox-send, 2026-06-28-1212). Non-coding companion matrix dropped — scope is
+codebase bugs (home/work codebases covered).
+
+*** Item E — APPLIED (not yet committed)
+Craig picked option 1 (prose tightening + bundling-detection hard gate in the
+PreToolUse hook); asked first whether a hard gate existed — it didn't (githooks
+pre-commit only runs sync-check; git-commit-confirm.py only scanned attribution).
+Applied: verification.md "Before Committing" #1 and commits.md #5 rewritten to
+"run the full suite as its own command, gate on zero failures, never bundle the
+run with the commit." Added detect_bundled_test_run() + respond_deny() to the
+hook (hooks/git-commit-confirm.py + hooks/_common.py): denies a test runner
+chained into git commit via any ungated connector (;, &, |, ||, newline, or a
+pipe that masks exit), allows the gated && form, matches the runner only in the
+prefix before git commit so a runner name in the message doesn't trip it. TDD:
+13 new tests red→green; full make test exit 0; end-to-end smoke test confirms
+deny on bundled / pass on gated+plain.
+
+*** Item F — APPLIED (not yet committed)
+Craig asked "should this be part of refactoring?" — concluded separate-but-linked
+(it's a multi-phase workflow that FILES structural work as :refactor: tasks, i.e.
+feeds /refactor rather than being a mode of it; /refactor is structure-only
+scan-and-apply). Craig approved option 1. Wrote
+claude-templates/.ai/workflows/readability-audit.org (generalized from .emacs.d's
+Elisp draft: header convention / public-private naming / doc-linters all
+"the project's X if it has one"; dropped concrete ;;; markers, kept the
+mechanical-applier principle; added graceful degradation for no-suite/no-header/
+no-linter; added the pipeline cross-links to /refactor + /simplify). INDEX entry
+under new "Code quality" section. sync-check exit 0, mirror matches.
+Told Craig the run sequence: /refactor (incl. simplification) + readability-audit
+= existing-code sweep; /simplify = in-flight-diff cleanup. Offered (not built) a
+code-quality umbrella workflow to chain them.
+
+*** Item G — APPLIED (not yet committed)
+Craig picked option 2 (do it now) + alias approach. Implemented dot-stripped
+project-name resolution: inbox-send.py gained display_name() (basename with dots
+stripped), find_target() falls back to a dot-stripped alias after exact match
+(exact wins), print_project_list shows the stripped name. triggers.md launch
+resolution gained the dot-stripped match rule. TDD: 3 new alias tests red→green
+(incl. exact-wins-over-alias), 26 inbox-send tests pass; sync-check exit 0; full
+make test exit 0. .emacs.d→emacsd, .dotfiles→dotfiles now resolve in both ai
+launch and inbox-send.
+
+** Walk complete; inbox close-out done — commits pending
+A,B,C,D,E,F,G all applied + verified, uncommitted. Inbox cleared (0 pending):
+bug-priority proposal + cover preserved to docs/design/2026-06-27-*; 9 other
+handoffs deleted (content in canonical files). :LAST_INBOX_PROCESS: stamped
+2026-06-28. Replies sent: emacsd (4 items, via the new alias), home (locating-craig),
+work (bug-matrix FYI); plus binding-adoption handoffs to home + work.
+Commits DONE — 6 landed on main (publish flow: /review-code over staged diff =
+Approve; /voice personal over all 6 messages; Craig approved all):
+ b621914 feat(refactor): add simplification scan mode (A)
+ d4e9d7d feat(rules): add locating-craig rule (B)
+ 797c426 feat(workflows): add suspend and readability-audit workflows (C+F)
+ 92dfc35 feat(hooks): block bundled test+commit, require full suite before commit (E)
+ 9753d03 feat(inbox-send): resolve dot-stripped project names (G)
+ 798ef02 feat(todo-format): make the bug-priority matrix binding for codebases (D)
+PUSHED to origin/main (ecd33e0..798ef02); in sync (0/0). Pre-push reconcile
+confirmed ahead-only. Working tree: only .ai/notes.org marker +
+.ai/session-context.org (both for wrap-up).
+
+** Post-push follow-ups (Craig: "do both")
+- Task review: 3 stale tasks (reviewed 2026-06-15) re-stamped 2026-06-28; generic
+ agent-runtime spec re-graded [#C]→[#D] (speculative large arc, not committed);
+ memories-sync VERIFY + token-rotation helper kept. Staleness now 0. todo.org
+ uncommitted.
+- Umbrella workflow: created claude-templates/.ai/workflows/code-quality.org — one
+ trigger sequencing /refactor → readability-audit over a scope, surfaces the
+ filed :refactor: backlog, documents the /simplify boundary. INDEX entry under
+ Code quality; sync-check exit 0. Uncommitted.
+Both committed + pushed (publish flow: /review-code Approve, /voice personal on
+the workflow body, Craig approved):
+ 96dfa63 feat(workflows): add code-quality sweep workflow
+ 5263cd6 chore(todo): task review — restamp stale tasks, downgrade generic-runtime to [#D]
+origin/main in sync (0/0). Staleness nudge cleared (0).
+Then committed the notes.org inbox marker (6fb6797 chore) to clean the tree;
+working tree now only .ai/session-context.org (live anchor). 1 ahead of origin
+(the marker commit, unpushed). [Pushed 6fb6797.]
+
+** Next work: wrap-up routing feature (Craig: "1 then 2")
+*** 1 — Recommendation engine (spec Phase 1+3) — BUILT, tested
+Added .ai/scripts/route_recommend.py (canonical+mirror): pure recommend(item,
+projects)→(destination, confidence) — strong/weak/none, word-boundary literal
+match, dot-stripped alias aware, top-tier tie→weak deterministic, empty→none.
+CLI (--item/--exclude) reuses inbox-send discover_projects via importlib. 13
+tests green, full make test exit 0, mirror synced. Sub-task in todo.org rewritten
+to dated entry. UNCOMMITTED — committing via publish flow next.
+*** 2 — wrap-teardown manual validation — DEFERRED (Craig redirected)
+Engine committed + pushed (6be62ae). Then Craig redirected: "many tasks in
+todo.org were shipped — let's do a full task audit." Pivoted to task-audit.
+
+** TASK AUDIT (Craig: many tasks shipped)
+Phase A: 21 open tasks (lines 42-1134 in Open Work). Phase B: dispatched 4
+parallel read-only reconciliation subagents over batches, each checking tasks
+vs git log + repo tree + sessions, returning CURRENT/DONE/STALE/NEEDS-USER.
+Verdict: contrary to "many shipped," NONE are fully-done-but-open. Most CURRENT
+(backlog). The 2 closest (wrap-teardown 42, memories-sync VERIFY 214) are
+code-complete, gated only on Craig's manual validation.
+Phase C autonomous updates applied: task 186 (folded cj generic-header redirect,
+superseded 2-option fix), task 203 (folded cj "document as local-only"; :bug:→
+:chore:, reframed as docs task), task 428 (precondition-landed note + LAST_REVIEWED).
+Phase E: :LAST_AUDIT: stamped 2026-06-28. Phase F: skip task-review chain (ran
+today). NEEDS-USER + clusters surfaced to Craig next. todo.org + notes.org
+uncommitted (audit edits).
diff --git a/.ai/sessions/2026-06-29-03-56-spec-lifecycle-decision-and-speedrun-ratified.org b/.ai/sessions/2026-06-29-03-56-spec-lifecycle-decision-and-speedrun-ratified.org
new file mode 100644
index 0000000..2a61f75
--- /dev/null
+++ b/.ai/sessions/2026-06-29-03-56-spec-lifecycle-decision-and-speedrun-ratified.org
@@ -0,0 +1,107 @@
+#+TITLE: Session Context
+#+DATE: 2026-06-28
+
+* Summary
+
+** Active Goal
+Handle todo.org items 4 (spec storage location + lifecycle convention) and 5
+(speedrun / autonomous-batch) — both decision-gated — then wrap.
+
+** Decisions
+- Item 4 status mechanism: org-keyword authoritative + Status field in Metadata,
+ drop the filename suffix (Craig chose option 1 over his earlier filename-suffix
+ lean, 2026-06-28).
+- Item 4 scope addition: retrofit existing docs across ALL projects, not just
+ document the convention going forward (Craig, 2026-06-28).
+- Speedrun naming: the workflow is "speedrun" / "no approvals speedrun" (not
+ "fix speedrun"); threaded through task heading, body, and the spec prose.
+- Item 5 criteria recast (Craig found them too soft): removed the task-size gate
+ entirely (large tasks decompose into per-commit chunks; size gating defeated the
+ away-from-desk use case); replaced act-vs-file adjectives with a crisp 4-item
+ defer checklist keyed on test-writability; eligibility simplified to status TODO
+ AND :solo:.
+- :solo: / :quick: get hard definitions in todo-format.md, applied at creation and
+ enforced as a mandatory step in task-review + task-audit.
+- Added the speedrun pre-flight decision-gathering step: batch all quick decisions
+ up front, "skip this" drops a task, then run hands-off. Unattended loop has no
+ kickoff human, so it still defers decision-needing tasks.
+- Craig ratified all 8 revised decisions; spec Status → ready.
+
+** Data Collected / Findings
+- No abandoned work from any shutdown: clean wrap last session (no crash anchor,
+ clean tree, last commit was the wrap archive at 15:59). Craig's "machine shut
+ down" recollection didn't match the record; deferred work (wrap-teardown
+ validation) was the closest match.
+- The autonomous-batch spec already existed and reconciled the old fix-speedrun +
+ inbox-zero Phase E proposals; it had 6 drafted decisions awaiting ratification.
+ The revision grew it to 8 (added tag-definitions/enforcement + pre-flight Q&A).
+
+** Files Modified
+- docs/design/2026-06-16-autonomous-batch-execution-spec.org — major revision
+ (size gate removed, defer checklist, tag definitions, pre-flight Q&A, naming),
+ then ratified: Status ready, cookie [8/8], all 8 decisions DONE, history entries.
+- todo.org — item 4 (:373) decision + retrofit requirement recorded; item 5 (:394)
+ heading/body renamed to "No-approvals speedrun"; the spec-review VERIFY rewritten
+ to a dated event-log entry.
+
+** Next Steps
+- Item 5 build (when prioritized): Phase 0 (todo-format.md :solo:/:quick: definitions
+ + task-review/task-audit enforcement) through Phase 6 (synthesis). Parent task
+ stays DOING.
+- Item 4 build (when prioritized): spec-create via the recorded decisions; ship the
+ retrofit helper + startup nudge; pilot on rulesets' own docs/design first.
+- Naming cleanup: the proposal-doc filenames still carry "fix-speedrun"; a rename
+ pass with link updates is deferred.
+- Other open carryover from startup: wrap-teardown manual validation (task 42),
+ memories-sync VERIFY (needs ratio), plus the remaining what's-next candidates.
+
+KB: promoted 0 / consulted no
+
+* Session Log
+
+** 2026-06-28 — Startup + what's-next triage
+Ran full startup: clean wrap last session (no crash anchor), repos current,
+inbox empty, no reminders/pending decisions. Roam inbox had 4 items, all for
+other projects (.emacs.d, emacs-wttrin) — none for rulesets. Surfaced 5
+what's-next candidates; Craig picked items 4 and 5 to handle, then wrap.
+
+** 2026-06-28 — Item 4 decision recorded
+Craig chose option 1 for the spec lifecycle status mechanism (org-keyword
+authoritative + Status field, drop filename suffix; adopt location split +
+org-id links). He added a requirement: existing spec/design files in ALL
+projects must be sorted into docs/specs/ vs docs/design/ — a one-time per-project
+migration template sync can't do, so the spec must design the reach mechanism
+(proposed: synced classify-and-move helper under .ai/scripts/ + startup nudge
+gated on a :LAST_SPEC_SORT: marker). Recorded both into todo.org:373.
+
+** 2026-06-28 — Item 5 (speedrun) spec revised per Craig's direction
+Craig found the eligibility criteria too soft. Revised the autonomous-batch spec
+(docs/design/2026-06-16-autonomous-batch-execution-spec.org) substantially:
+- Removed the task-size gate entirely (Craig: size shouldn't matter; large tasks
+ decompose into per-commit chunks; speedrun is the away-from-desk mode and size
+ gating forced him to stay at the desk). I agreed; only caveat is the unattended
+ loop's cost ceiling, handled by the vNext token budget.
+- Recast act-vs-file as a crisp 4-item defer checklist keyed on test-writability
+ ("can I write the failing test from the task text without inventing a
+ requirement"), an enumerated data-loss operation list, already-satisfied, and
+ design-deliberation. Replaces the old adjectives.
+- Eligibility simplified to status TODO AND :solo: (size gone, so :quick: drops to
+ an effort hint, not a gate). :solo:/:quick: get hard definitions in
+ todo-format.md, applied at creation + enforced as a mandatory step in
+ task-review and task-audit (Craig's ask).
+- Added the speedrun pre-flight decision-gathering step: gather → classify → order
+ → intro → batch-ask the quick decisions → "skip this" drops a task → run
+ hands-off. Makes "no approvals" = all approvals front-loaded. The unattended
+ loop has no kickoff human, so it still defers decision-needing tasks.
+- Naming: "fix speedrun" → "no-approvals speedrun" in spec prose + todo.org:394
+ heading/body. Proposal-doc filenames keep their on-disk names (rename pass is
+ separate). Spec Status stays draft pending ratification of the revised decisions.
+Spec opened in emacs for Craig's review. Companion build edits still pending:
+todo-format.md definitions + task-review/task-audit enforcement (Phase 0).
+
+** 2026-06-29 — Item 5 ratified
+Craig ratified all 8 decisions. Spec Status → ready, cookie → [8/8], all 8
+decision headings DONE, ratification entry added to iteration history. The
+*** VERIFY "Review the autonomous-batch execution spec" (todo.org) rewritten to a
+dated event-log entry. Parent task stays DOING (build pending: Phase 0–6).
+Items 4 and 5 both handled. Ready to wrap.
diff --git a/.ai/sessions/2026-06-30-13-55-pager-mcp-ssh-alias-and-emacsd-proposals.org b/.ai/sessions/2026-06-30-13-55-pager-mcp-ssh-alias-and-emacsd-proposals.org
new file mode 100644
index 0000000..3cc8501
--- /dev/null
+++ b/.ai/sessions/2026-06-30-13-55-pager-mcp-ssh-alias-and-emacsd-proposals.org
@@ -0,0 +1,101 @@
+#+TITLE: Session Context
+#+DATE: 2026-06-30
+
+* Summary
+
+** Active Goal
+Startup, then process the .emacs.d inbox: chase how the 2026-06-29 Signal page was sent, clean up the dead page-signal path, and review + ship the three shared-asset proposals. Verify ratio's roam-sync, then wrap + tear down.
+
+** Decisions
+- Pager: no CLI wrapper. The signal-mcp tool is one call for in-session agents (the only paging caller); a signal-cli wrapper only if a non-session caller ever needs Signal paging. Pager guidance = notify --persist at the machine, signal-mcp send_message_to_user (to Craig's UUID) when away.
+- SSH alias drift: restore the bare =cjennings= alias in ~/.ssh/config (root fix, both daily drivers) rather than repointing one repo's remote.
+- green-baseline: accept with 3 changes (no-suite guard, cross-ref When You Cannot Verify, placement as start-work 0.3).
+- todo-cleanup aging: accept retain 7, default ON; ADD script self-protect so the archive inherits the todo file's gitignore status (Craig's rule).
+- lint-org checkers: accept with indented-heading tightened to 2+ stars (1+ false-positives on valid `*` list bullets).
+
+** Data Collected / Findings
+- The 2026-06-29 page went out via the signal-mcp tool (transcript 924ea200), not a revived account. The pager account (+15045173983) was never deregistered — the 2026-06-12 "deregistered" premise conflated the page-signal *script* removal with account loss. signal-cli registered, signal-mcp connected, live page reached Craig's phone.
+- "Network down" was a misread: internet was up, DNS fine, cjennings.net resolves. Real cause = git@cjennings remote alias missing from ~/.ssh/config (only cjennings.net defined). Fixed; both repos' remotes work again.
+- gitignore-mode code projects (chime, pearl, archangel, .emacs.d) all gitignore todo.org → the aging archive must follow or it leaks private task history to a tracked path on public repos.
+- indented-heading 1+-star regex flagged 3 valid `*` list bullets (reproduced); 2+ stars is the correct, collision-free target.
+- ratio confirmed over tailscale: roam clone + roam-sync.timer live and syncing; both daily drivers now set up.
+
+** Files Modified (all committed + pushed)
+- a266250 — Makefile prune step for dangling bin symlinks + protocols.org pager section.
+- dotfiles 3119bbb — ~/.ssh/config: restore bare =cjennings= alias (propagated to ratio over tailscale).
+- d0ab047 — verification.md green-baseline section + start-work Phase 0.3.
+- 324a52b — daily-drivers.md reframed around direct tailscale reach + mechanics section.
+- f67e724 — todo-cleanup.el Resolved-section aging + tc--ensure-archive-gitignored self-protect + 2 tests.
+- d9d8ce7 — lint-org.el four structural checkers, indented-heading at 2+ stars + negative-case test.
+- a1f87b1 — inbox-process marker. 08772c5 — ratio roam-sync confirmed (todo log + cleared daily-drivers open instance).
+- Three proposal source notes preserved under docs/design/.
+
+** Next Steps
+- Memory-sync VERIFY (todo.org:225) cross-machine half done; only its manual-validation child (work/unknown-project refusal checks, needs Craig's eyes) remains before DONE.
+- This wrap runs --archive-done with the new aging step: rulesets is track-mode, so ~most of its 65 Resolved entries move to a tracked archive/task-archive.org (intended).
+- Pager: still-open caveat is signal-cli's 27-day receive gap (send unaffected; long gaps can desync eventually).
+
+KB: promoted 0 / consulted no
+
+* Session Log
+
+** 2026-06-30 ~08:50 EDT — Startup
+Ran startup workflow. Network is down for git remotes (host =cjennings= unresolvable) — both the rulesets pull and the project-repo fetch failed; session continues on local state, no remote sync this session. No crash anchor (clean prior wrap). notes.org: no active reminders, no pending decisions. =make install= nothing new to link. =.ai/= synced from templates (no tracked change).
+
+Roam inbox: 5 items, all =emacs:= / =wttrin:= prefixed — none owned by rulesets; nothing to claim.
+
+Local inbox: 9 unprocessed handoffs, all from .emacs.d:
+- green-baseline-proposal.org — shared-asset change (verification.md + start-work skill): add a "green baseline before starting work" gate.
+- todo-cleanup.el + test-todo-cleanup.el + rulesets-note-archive-aging.org — shared-asset change to synced =.ai/scripts/todo-cleanup.el=: add Resolved-section file-aging to =--archive-done=.
+- lint-org.el + test-lint-org.el + rulesets-note-lint-checkers.org — shared-asset change to synced =.ai/scripts/lint-org.el=: four structural heading checkers.
+- dangling-page-signal note (2026-06-30 0112) — FYI + ask: =~/.local/bin/page-signal= is a dead symlink (canonical removed 2026-06-12); ask make install/uninstall to clean it on both daily drivers + confirm pager = =notify --persist=.
+- follow-up page-signal note (0115) — FYI: GV pager account (+15045173983) may be live again as of 2026-06-29; capture the revival method, update pager guidance.
+
+Three of these are shared-asset proposals → skeptical review + Craig approval required (no silent apply). The two page-signal notes are one actionable cleanup + FYIs. Confirmed =~/.local/bin/page-signal= target is dead (exit 2). Working tree clean except the 9 untracked inbox files.
+
+** 2026-06-30 ~09:05 EDT — Signal pager investigation (Craig's option 2)
+Craig asked how the 2026-06-29 Signal page was done. Traced it: the .emacs.d session (transcript 924ea200) called the signal-mcp tool =send_message_to_user= at 19:19 to Craig's UUID b1b5601e-…, after desktop notify couldn't reach his session bus. No re-registration / captcha — the pager account was never down.
+
+Finding: the "GV account deregistered 2026-06-12" premise in the follow-up note (and the PROCESSED-2026-06-12 note) is WRONG. Only the =page-signal= shell script was removed from canonical on 6-12; its =~/.local/bin/page-signal= symlink dangles. The account stayed registered the whole time. Verified live: signal-cli listAccounts shows +15045173983 registered; =claude mcp list= shows signal-mcp ✔ Connected. Two independent paths, opposite status: page-signal script = dead; signal-mcp tool = alive (the working pager). Caveat: signal-cli warns last *receive* 27 days ago — send unaffected, but long receive gaps can desync eventually.
+
+Updated the =project_signal_pager_account= memory to correct the page-signal-vs-signal-mcp distinction and kill the deregistration premise.
+
+** 2026-06-30 ~09:25 EDT — page-signal cleanup + pager guidance (Craig directed: proceed)
+Craig confirmed the live signal-mcp page reached his phone. Directed: point pager guidance at signal-mcp for when away from laptop/desktop, clean up the dead script, commit+push when remote is reachable.
+
+Found page-signal already fully removed from the repo on 2026-06-12 (13256aa) — Makefile, workflow, mcp/README all clean. Only the on-disk =~/.local/bin/page-signal= symlink dangled. Root gap: the install bin loop links =claude-templates/bin/*= but never prunes orphans, so any removed script leaks a dangling symlink. Fixed durably: added a prune step to the install bin section (Makefile) that removes symlinks in =~/.local/bin= pointing into =claude-templates/bin/= whose target is gone. Ran =make install= — pruned page-signal on velox; ratio self-cleans on its next session's make install.
+
+Pager guidance: added a "Paging Craig — desktop vs. away" section to protocols.org (canonical), distinguishing =notify --persist= (at-machine) from signal-mcp =send_message_to_user= to the UUID (away), and explicitly retiring the page-signal script. sync-check --fix synced the mirror.
+
+Files modified: Makefile (prune step), claude-templates/.ai/protocols.org + .ai/protocols.org (pager section). Reviewed (/review-code --staged → Approve), /voice personal on the message. Committed a266250 (authored as Craig).
+
+** 2026-06-30 ~09:55 EDT — Decision: no CLI wrapper; push diagnosis (SSH alias drift)
+Craig's design call: don't rebuild a page-signal-style script. The signal-mcp tool is one call for in-session agents (the only paging caller we have); a thin signal-cli wrapper gets added only if a non-session caller (hook/cron) ever needs Signal paging. protocols wording already reflects this.
+
+Push diagnosis: the "network down" read this session was WRONG. Internet is up, DNS works, cjennings.net resolves (IPv6). The real cause: =origin= is =git@cjennings:rulesets.git= (bare host alias =cjennings=), but =~/.ssh/config= only defines =Host cjennings.net= — no bare =cjennings= alias — so every git remote op failed to resolve the literal hostname. =git@cjennings.net:rulesets.git= works (ls-remote returned HEAD). Pushed a266250 via the explicit .net URL (clean fast-forward e4aeea6..a266250); remote main now at a266250.
+
+OPEN: the =cjennings= SSH alias drift is a root issue affecting every repo whose remote uses =git@cjennings:=. Durable fix is a Craig decision — restore =Host cjennings= in =~/.ssh/config= (covers all repos, dotfile-synced to both daily drivers) vs. repoint this repo's remote to =cjennings.net=. Daily-driver concern: ratio may differ. Surfaced to Craig.
+
+** 2026-06-30 ~10:05 EDT — SSH alias fix (Craig: option 1) + both pushes landed
+Craig chose the root fix. =~/.ssh/config= is a stow symlink → canonical =~/.dotfiles/common/.ssh/config= (the ~/.dotfiles git repo). Edited the cjennings block from =Host cjennings.net / LogLevel ERROR= to =Host cjennings cjennings.net / HostName cjennings.net / LogLevel ERROR= so the bare alias maps to the real host. Verified: =ssh -G cjennings= → hostname cjennings.net, identity id_ed25519; =git ls-remote origin= (the original git@cjennings URL) returns a266250 — unbroken.
+
+Committed in dotfiles as 3119bbb (fix(ssh): restore the bare cjennings host alias, authored as Craig) and pushed via git@cjennings (alias works). rulesets a266250 also confirmed on remote.
+
+DAILY-DRIVER (ratio): ratio likely has the same alias drift. Once ratio runs =git -C ~/.dotfiles pull=, the stow symlink picks up the fix immediately (no re-stow needed if the symlink exists). Until then ratio's pushes to cjennings.net keep failing. Flagged to Craig per daily-drivers.md.
+
+NOTE: dotfiles repo has its own .ai/ scope + 5 pending inbox handoffs — left untouched (cross-project boundary; they belong to a dotfiles session).
+
+** 2026-06-30 ~10:15 EDT — ratio synced over tailscale; alias drift fully closed
+Craig: do the ratio pull now, it's on tailscale (ratio = 100.71.182.1). Confirmed ratio had the same alias drift (=ssh -G cjennings= → hostname cjennings). ratio's dotfiles remote already used the .net form (=git@cjennings.net:dotfiles.git=), so a plain ff-only pull worked there — no bootstrap problem. Pulled 995f7d7..3119bbb (clean ff; ratio's untracked cross-agent-comms WIP left untouched). ratio's =~/.ssh/config= is a stow symlink, so the fix went live immediately: =ssh -G cjennings= now → cjennings.net, and =git@cjennings:rulesets.git= ls-remote returns a266250. Both daily drivers fixed.
+
+Page-signal arc fully closed. Replied to .emacs.d (inbox file 2026-06-30-1310-from-rulesets) and deleted both page-signal handoffs from rulesets inbox. 7 inbox handoffs remain = the 3 shared-asset proposals.
+
+** Reviewing shared-asset proposals in turn (Craig's direction)
+1. green-baseline — DONE. Accepted with 3 changes (no-suite guard, cross-ref When You Cannot Verify, placement as start-work 0.3). Implemented in verification.md + .claude/commands/start-work.md, proposal preserved to docs/design/2026-06-29-green-baseline-proposal.org. Committed d0ab047, pushed.
+4. daily-drivers tailscale correction (arrived mid-session 13:20) — DONE. Reframed daily-drivers.md from "can't reach, flag it" to "CAN reach over tailscale, sync/verify/repair directly" + a tailscale-mechanics section; my tweak: bare hostname resolves only with MagicDNS (ssh ratio worked from velox), so IP/MagicDNS is the reliable path. Note preserved to docs/design/2026-06-30-daily-drivers-tailscale-correction.org. Committed 324a52b, pushed. Replied to .emacs.d on both.
+2. todo-cleanup.el Resolved-section aging — DONE. Accepted (retain 7, default ON, Craig confirmed). Applied proposed .el + tests to canonical. ADDED self-protect (Craig's gitignore rule): confirmed gitignore-mode code projects (chime/pearl/archangel/.emacs.d) all gitignore todo.org, so the archive must too or it leaks private task history to a tracked path on public repos. tc--ensure-archive-gitignored appends the archive path to .gitignore when the todo file is ignored but the archive isn't; track-mode leaves both tracked. +2 ERT tests (temp git repo, per branch). 36 todo-cleanup tests green, full make test green. Committed f67e724, pushed. Note preserved to docs/design/2026-06-29-todo-cleanup-aging-proposal.org. Replied to .emacs.d (incl. heads-up: its next --archive-done sheds backlog + auto-adds the .gitignore line). NOTE: rulesets is track-mode → its next wrap sheds ~most of 65 Resolved entries to a tracked archive/task-archive.org.
+3. lint-org.el four structural heading checkers — DONE. Accepted with a fix: tightened indented-heading from one-or-more stars to TWO-or-more. The 1+ regex false-positives on valid org plain-list bullets (indented single `*` is a list bullet, not a demoted heading — reproduced: a normal `*` list flagged 3 valid bullets). `**`+ is never a bullet, so an indented one is unambiguously a demoted invisible heading. Added a negative-case test. 45 lint-org ERT green, full make test green. Committed d9d8ce7, pushed. Note preserved to docs/design/2026-06-29-lint-org-structural-checkers-proposal.org. Replied to .emacs.d.
+
+ALL inbox handoffs processed (inbox empty). Commits this session: a266250 (page-signal/paging), d0ab047 (green-baseline), 324a52b (daily-drivers tailscale), f67e724 (todo-cleanup aging + self-protect), d9d8ce7 (lint-org checkers); dotfiles 3119bbb (ssh alias). All pushed.
+
+OPPORTUNITY (now actionable): daily-drivers.md "Current open instance" wants ratio's roam clone + roam-sync timer verified — now doable directly over tailscale rather than waiting on Craig. Flagged, not yet done.
diff --git a/.ai/sessions/2026-07-02-09-29-docs-lifecycle-speedrun-autonomous-loop.org b/.ai/sessions/2026-07-02-09-29-docs-lifecycle-speedrun-autonomous-loop.org
new file mode 100644
index 0000000..8fc23e9
--- /dev/null
+++ b/.ai/sessions/2026-07-02-09-29-docs-lifecycle-speedrun-autonomous-loop.org
@@ -0,0 +1,110 @@
+#+TITLE: Session Context — Docs-lifecycle build, wrap-up router, speedrun build
+#+DATE: 2026-07-02
+
+* Summary
+
+** Active Goal
+(Session closed 2026-07-02 ~09:30 — the standing loop directive below ran through the night and morning and ended with this wrap; cron job 752624d1 deleted at wrap.)
+Standing directive (Craig, 2026-07-02 ~01:30): run auto-inbox-zero every 30 minutes with a STANDING YES — execute on all items found each cycle. Per-item disposition: feature-level task → write a spec (spec-create); decisions I can't confidently guess → file a VERIFY; well-defined → implement with the full quality bar. Autonomous commit + push under rulesets' :COMMIT_AUTONOMY: waiver. Auto-flush (/flush auto, self-inject) at clean boundaries when context grows heavy. Earlier directive items all DONE tonight: wrap-up routing, speedrun Phases 1-6 (work-the-backlog.org), roam inbox zero, auto-flush implementation + speedrun incorporation + disposition rule.
+
+** Decisions
+- Inbox: Craig approved all five dispositions (convert-subtasks bundle + planning-line fix, task-audit C.6, sweep security fix + public-reachability convention + broadcast, spec-review UI-traps promotion, KB orphan report → [#C] task).
+- Wrap-teardown: Craig ran all five manual tests, all passed — feature closed DONE, armed as the default (a bare "wrap it up" tears the session down; "with summary" keeps the buffer).
+- Speedrun Phase 0: hard :solo:/:quick: definitions are fixed cross-project in todo-format.md; review/audit tag assessment is mandatory; task-review gate 3 realigned to no-deliberation (1-2 upfront-answerable quick decisions allowed).
+- Docs-lifecycle spec: ratified through two independent review rounds (Codex + fresh-context Claude agent; 14 findings, all fixed, verify passes held); Codex flipped READY; spec-response decomposition flipped DOING. Key forks: two-sequence keyword header; :SPEC_ID: parent-keyword binding; fail-safe --apply; file: links through the pilot with id: conversion gated on the .emacs.d id-index mechanism; evidence-based status confirmation.
+- Lesson: never flip a lifecycle state in the same pass that authored the fixes — the reviewer owns the flip.
+
+** Data Collected / Findings
+- Spec: docs/specs/2026-07-01-docs-lifecycle-spec.org, status DOING, :ID: 80b0787b-4a60-4c82-8a16-b383d3e3c8f2; build parent in todo.org carries :SPEC_ID: (task "Spec storage location + lifecycle-status convention", ~line 361).
+- Pilot surface: docs/design has 41 files (3 spec-spine candidates: Decisions AND Implementation phases); 2 stray root specs (agent-knowledge-base-spec.org, inbox-workflow-consolidation-spec.org); docs/design/task-review.org is the note counter-case (Metadata only).
+- Validations: KB check 1 verified (55 rg = 55 org-roam DB); check 4 velox half verified (probe node agents/20260701214910-kb-sync-validation-probe.org pushed f0252bb, 0 conflicts) — ratio half blocked (ssh times out; probe left in place; confirm command logged in todo.org). KB checks 2+3 need work/unknown-project sessions. Roam inbox holds 2 rulesets items (ai-term colors → .emacs.d territory; "wrap it up closes window" → already delivered by wrap-teardown).
+- Bare [N/N] tokens in org prose get mangled by cookie updates — spell counts in words.
+
+** Files Modified
+All committed and pushed through 9ad415d. Tonight: 80ca5d0 spec-sort helper + 33-test bats; f4b64d6 pilot (5 specs sorted to docs/specs/, board live, :LAST_SPEC_SORT: stamped); 21639cb startup nudge (find-based probe, compgen was bash-only) + .emacs.d convention-live/id-index note; 7c12007 wrap-up router (route-batch + 13-test bats, inbox.org :ROUTE_CANDIDATE: stamp, wrap-it-up router step, cross-project.md); 9ad415d speedrun decomposition + spec DOING flip. Earlier (2026-07-01): d0c92d0 docs-lifecycle Phase 1 and everything before it.
+
+** Next Steps
+SESSION CLOSED — the auto-inbox-zero loop ended with the wrap (job deleted; a future session re-arms it only on a fresh directive from Craig). What carries forward: Craig's parked [#C] wrap-summary keep-or-cut think-through; ratio probe confirm (ssh was timing out — verify agents/20260701214910-kb-sync-validation-probe.org landed on ratio); KB refusal checks 2+3 (need work/unknown-project sessions); docs-lifecycle leftovers (Craig's manual tests: nudge visibility + Emacs id-link click-through; 4 anomaly renames; id-conversion gated on .emacs.d id-index). All build work from this session is DONE and pushed.
+
+Original loop contract (historical): continue the hourly auto-inbox-zero loop (cron job 752624d1, fires at :37, session-only). Each cycle: inbox-status + roam scan (capture-guard before roam writes); quiet → one acknowledgement line; finds → file, then execute ALL under Craig's standing yes with autonomous-commit + push (:COMMIT_AUTONOMY: + :LOOP_MAY_COMMIT: both stamped in notes.org Workflow State), disposition feature→spec / unguessable→VERIFY / well-defined→implement, full quality bar, metrics JSONL per task, session-log update per state-mutating cycle, /flush auto at heavy-context clean boundaries. Parked for Craig at his choosing: wrap-summary keep-or-cut think-through ([#C] in todo.org). Carryovers: ratio probe confirm (ssh), KB refusal checks (work/unknown sessions), docs-lifecycle leftovers (Craig's manual tests: nudge visibility + Emacs id-link click-through; 4 anomaly renames). Everything else from tonight is DONE and pushed (speedrun spec IMPLEMENTED after live trial; auto-flush + self-inject shipped; inbox-send collision fix; page info-styling; host-identity rule; template-sync policy; id-link conversion).
+
+KB: promoted 1 / consulted no
+(Node: agents/20260702093025-reviewer-owns-the-lifecycle-flip.org — the don't-flip-your-own-fixes lesson from the docs-lifecycle spec review.)
+
+OLD (completed): build the speedrun / autonomous-batch phases per the spec docs/specs/2026-06-16-autonomous-batch-execution-spec.org (DOING, :ID: 90f623cd-fdbe-4f5c-b63d-b2f84d9151cf; build parent "No-approvals speedrun" in todo.org carries :SPEC_ID:, children Phases 1-6 + live-trial + flip). Read the spec's Design section (lines ~66-177: loop at both altitudes, eligibility gate, defer checklist, pre-flight Q&A, session modes/preset, run cap + kill switch, paging) before writing. Phase 1: write claude-templates/.ai/workflows/work-the-backlog.org (eligibility gate, defer checklist, per-task quality bar, run-cap; inputs task set + session mode + cap) AND revert inbox.org's "auto inbox zero" per-cycle item 3 yes-path to routing-only in the same commit (one home for execution). Phase 2: wire both callers (auto-inbox-zero yes-path → work-the-backlog tag-query/file-only/cap-1; speedrun preset → explicit list/autonomous-commit/always-push/paging after pre-flight Q&A). Phases 3-6 per the child task bodies. Canonical-side edits, sync-check --fix, make test, commit per phase. THEN: roam inbox zero (inbox.org roam mode; 2 known rulesets-related items: ai-term colors → .emacs.d territory, wrap-it-up-closes-window → already delivered) and react. Docs-lifecycle leftovers for Craig: flip decision pending his manual tests (nudge visibility + Emacs link click-through), 4 anomaly renames, id-conversion gated on .emacs.d. Carryovers: ratio probe confirm (ssh still timing out), KB refusal checks.
+
+* Session Log
+
+** 2026-07-02 Thu @ 09:30 -0400 — Session wrapped (teardown mode)
+Morning loop cycles after the 07:51 auto-flush were all quiet (0 pending handoffs, roam inbox empty; last manual cycle ~09:15). Craig called the wrap. Cron job 752624d1 deleted; one KB node promoted (reviewer-owns-the-lifecycle-flip); todo cleanup + lint ran; wrap commit pushed. Teardown sentinel dropped per the validated default.
+
+** 2026-07-02 Thu @ 07:51:13 -0400 — flushed (auto-flush, self-injected)
+Clean boundary: 07:50 loop cycle came back empty, tree clean, everything pushed through the metrics commit after a6b534f, suites green. Nothing in flight. First live use of the auto-flush mechanism shipped tonight (self-inject via tmux run-shell -b). Post-clear resume: read this Summary and continue the hourly loop per Next Steps — the cron job survives the clear (session-only, not conversation-only). Craig's standing yes remains in force (Active Goal).
+
+** 2026-07-02 Thu @ 06:00 -0400 — Loop cycle executed 3 finds (commits through a6b534f + metrics, pushed)
+First executing loop cycle under the marker-granted autonomy. Found 3 archsetup handoffs + 2 rulesets roam entries (duplicates of the routed ones). Shipped: inbox-send collision fix (uniquify -2/-3 suffix, 4 red-first deterministic tests, 30/30 — a wild data-loss find, graded [#B] P2), page styling alarm → info --persist (page-me.org + work-the-backlog page; status-check untouched), dupre-blue ai-term refinement forwarded to .emacs.d (#67809c). Roam inbox: swept the 2 rulesets entries (archsetup's 3 left, roam-sync owns the git). Local inbox 0 pending; archsetup replied. Suites green, sync clean (one drift caught by the pre-commit check and re-synced). Loop continues hourly at :37 (job 752624d1).
+
+** 2026-07-02 Thu @ 05:30 -0400 — Live trial validated, spec IMPLEMENTED, loop now hourly (5eae9e0, pushed)
+Craig's "1" granted :LOOP_MAY_COMMIT: (stamped in notes.org Workflow State) and validated the run. Closed the live-trial + flip children dated, parent "No-approvals speedrun" DONE + CLOSED, spec flipped DOING → IMPLEMENTED (board: 2 IMPLEMENTED / 2 READY / 2 DOING). Wrap-summary keep-or-cut think-through PARKED (stays filed [#C] in todo.org). Auto-inbox-zero rescheduled: old 30-min job deleted, new hourly job 752624d1 (at :37, off-minute per fleet guidance), same standing-yes contract, now with loop commits authorized by the marker rather than only the directive.
+
+** 2026-07-02 Thu @ 05:25 -0400 — First no-approvals speedrun complete: 3/3 (78bbaae, b6a977c, ed75d3c + metrics commit, all pushed)
+Live-trial run c726f526 over Craig's ordered set. Pre-flight Q&A fired once (2 questions, Craig took both recommendations, answers stamped into task bodies as dated lines). Task 1 id-link conversion: 13 links → id: form, Review-findings heading got its own :ID: for the 2 search-target links, residue zero, all ids verified. Task 2 host-identity: claude-rules/host-identity.md (linked machine-wide, verified) + startup probe 13 (fixture-verified bash+zsh) + Phase C flag line. Task 3 template-sync: freshness policy in startup Phase A.0 (dirty = tracked-only; WIP-guard named as deliberate exception), monitor-inbox precondition fixed to --untracked-files=no + close-out symmetrized. Every task: /review-code, /voice, make test green, sync clean, one JSONL record (3 records in .ai/metrics/work-the-backlog.jsonl). End-of-set page fired via notify --persist. AWAITING: Craig's read on the run → then close the live-trial child dated + flip the autonomous-batch spec DOING → IMPLEMENTED. Then his two parked decisions: :LOOP_MAY_COMMIT: grant, wrap-summary keep-or-cut think-through.
+
+** 2026-07-02 Thu @ 01:40 -0400 — Inbox pass done; auto-flush shipped (d4f132b, 794b248, pushed); 30-min executing loop armed
+Local inbox zero + roam inbox zero (roam was already empty — archsetup routed it). Processed: .emacs.d org-id delivery → id-conversion task ungated (:solo: now); archsetup's three roam items → template-sync-gitignored filed [#C], ai-term colors forwarded to .emacs.d, wrap-summary keep-or-cut filed [#C]; auto-flush bundle → self-inject.sh canonicalized + 6-test bats, flush skill auto mode (gate order preserved: verify anchor write BEFORE arming), work-the-backlog auto-flush section + preset step 6 + per-item disposition rule (feature→spec, unguessable→VERIFY, well-defined→implement). Design note preserved at docs/design/2026-07-02-auto-flush-mechanism-note.org. Replies sent to .emacs.d (x2) + archsetup (x2). All suites green, sync clean. NOW: /loop 30m auto-inbox-zero with Craig's standing yes (see Active Goal).
+
+** 2026-07-02 Thu @ 01:30 -0400 — Speedrun Phases 2-6 shipped (263138a, 8d790c0, 04561b2, eea93f1, 44c8cc2 — all pushed)
+Phase 2: both callers wired (auto-mode chain ask scoped to the queued batch — a deliberate judgment, logged in the commit; speedrun preset section + trigger routing, "speedrun" always beats "no approvals" with disambiguation in no-approvals.org + INDEX). Phase 3: waiver pinned as :COMMIT_AUTONOMY:/:LOOP_MAY_COMMIT: markers in notes.org Workflow State; rulesets stamped :COMMIT_AUTONOMY: yes, :LOOP_MAY_COMMIT: deliberately left for Craig; .emacs.d told to stamp its own (inbox-send 0118). Phase 4: VERIFY-filing dedup (existing-sibling check), quick-question discriminator, batch-ask contract, page finalized. Phase 5: JSONL field table (+failed outcome, +manual caller, +comma-separated commit_sha — three traceable spec gaps closed). Phase 6: synthesis section + "synthesize backlog metrics" trigger. Every phase: /review-code, /voice, make test green, sync clean, todo.org child flipped dated. Parent stays DOING pending Craig's live trial + the flip task. NEXT: local inbox (2 handoffs: .emacs.d org-id delivery unblocks the docs-lifecycle id-conversion task; archsetup roam-routed item unread), then roam inbox zero + react.
+
+** 2026-07-02 Thu @ 01:15 -0400 — Speedrun Phase 1 shipped (d379a23, pushed)
+work-the-backlog.org created (canonical + mirror + INDEX entry): caller contract, five-outcome vocabulary, mechanical eligibility gate (TODO + :solo:, no-scheme-header → don't run), four-item defer checklist, quality bar, cap semantics, Phase 3-5 stubs. inbox.org auto-mode item 3 reverted to routing-only. Review fixed a cap-default contradiction (explicit set defaults to list length, not 1) pre-commit. make test green twice, sync clean, todo.org Phase 1 child rewritten dated. NOTE: new inbox handoff from .emacs.d (2026-07-02-0056) — org-id resolution delivered, the gated id-conversion task is unblocked; process during the inbox pass after the speedrun build. Next: Phase 2 (wire the two callers).
+
+** 2026-07-02 Thu @ 00:47:00 -0400 — flushed
+Clean boundary: wrap-up router shipped (7c12007 — route-batch helper + 13-test bats, inbox.org marker stamp, wrap-it-up router step, cross-project.md note; review fixed a reproduced nested-candidate data-loss bug pre-commit) and the speedrun spec decomposed + flipped DOING (9ad415d). Nothing half-edited; tree clean, pushed through 9ad415d, suite green. Post-clear resume goes straight to speedrun Phase 1 (contract pointers in Next Steps). Remaining under wrapup-routing: manual e2e (Craig's) + vNext transcript task.
+
+** 2026-07-02 Thu @ 00:25 -0400 — Phases 3 + 4 shipped: pilot ran, nudge live, .emacs.d notified
+Craig confirmed all five pilot keywords as-is (option 1) plus the IMPLEMENTED reason for agent-knowledge-base-spec. Applied with --allow-dirty (only the untracked session anchor was dirty): 5 specs moved to docs/specs/, 12 todo.org links + the moved specs' outbound links rewritten, :LAST_SPEC_SORT: 2026-07-02 stamped, residue zero, board live (6 specs: 1 IMPLEMENTED, 3 READY, 2 DOING). Phase 4: spec-sort probe added to startup.org Phase A + Phase C nudge line; replaced the spec's compgen sketch with a find-based check (compgen is bash-only, zsh false-negatived on stray root specs) — fixture-verified both shells, four project shapes; fixed startup.org's stale path to the moved encourage-kb spec; sent .emacs.d the convention-live note + id-index ask (2026-07-02-0022 handoff). f4b64d6 + 21639cb pushed, make test green, sync clean.
+
+** 2026-07-02 Wed @ 00:15 -0400 — Phase 2 shipped: spec-sort + 33-test bats suite (80ca5d0, pushed)
+TDD'd the retrofit helper (bats red-first, then the Python implementation). A fresh-context review agent found 4 real issues, all fixed pre-commit: acknowledged bare mentions weren't mapped through moves (a self-mention turned a successful apply into a false FAILURE with a destructive recovery recipe — regression-tested), real OSError mid-apply lost the applied-ops list (both failure paths now share ApplyFailure), "incomplete" status proposed terminal IMPLEMENTED (word-boundary matching now), and file-relative vs root-anchored link ambiguity now blocks validation as AMBIGUOUS. Real-data dry run matches predictions (5 candidates / 4 anomalies / 30 notes / 1 self bare mention / 10 report-only incl. the Codex-flagged startup.org case). make test green, sync clean. Next: Phase 3 pilot — candidate keywords + anomaly dispositions need Craig (see Next Steps).
+
+** 2026-07-01 Wed @ 23:41:36 -0400 — flushed
+Clean boundary after docs-lifecycle Phase 1 (d0c92d0, pushed, tree clean, suite green). In flight: nothing half-edited. Post-clear resume goes straight to Phase 2 — the spec-sort build (contract pointers in Next Steps above).
+
+** 2026-07-01 21:05 EDT — Session resumed after interruption; inbox first, then validations/specs
+The 2026-06-30 session died right after the validation pre-flight (nothing lost beyond the plan itself — no edits had landed). New session startup found this anchor plus 15 pending inbox handoffs. Craig's call: process the inbox first, then return to the validations + spec writing goal above. Also committed the one-line .claude/settings.json change from his /model command (c976f5b, "chore: set fable as project default model"). Green baseline confirmed before the commit: make test exit 0 — pytest 370+67+12 passed, all ERT suites 0 unexpected, 0 bats failures.
+
+Inbox inventory (15): the .emacs.d convert-subtasks bundle (10 files — todo-cleanup/lint-org + tests + 5 workflow/rule wirings), the .emacs.d task-audit Phase C.6 follow-on (2 files), the .emacs.d sweep-gitignore anchored-pattern security fix ask, the archsetup spec-review UI-traps promotion proposal, and a KB hygiene report (42 orphan agent nodes). Plan: skeptical review via diff-against-canonical for the bundles, then surface dispositions for approval.
+
+** 2026-07-01 ~21:45 EDT — Inbox items A, B, C shipped
+Craig approved all five recommendations (A1 B1 C1 D1 E1). Shipped so far:
+- A (19ba7cb): convert-subtasks bundle applied to canonicals + mirror — todo-cleanup --convert-subtasks, lint-org subtask-done-not-dated checker, wiring in wrap-it-up/clean-todo/open-tasks/task-review/todo-format.md. TDD'd the planning-line edge on top (CLOSED removal now preserves a DEADLINE/SCHEDULED sharing the line; red then green). Suites 45/45, 49/49, full make test green.
+- B (356b905): task-audit Phase C.6 (retire completed parents / promote stragglers) applied as sent.
+- C (909b21b + bac3fe4): sweep-gitignore-tooling.sh now recognizes anchored /.ai/ (mode detection + per-pattern presence + style-matched append) and WARNs on tracked tooling reachable via a non-cjennings.net remote; bare cjennings ssh-alias counts as private (false positive on rulesets itself caught by the real-data dry run, fixed with a 13th bats test). protocols.org gained the public-reachability convention. Real sweep run: 6 projects backfilled (archsetup, chime, emacs-wttrin in anchored style), archsetup's tracked CLAUDE.md flagged. Broadcast sent to 14 projects via inbox-send.py (the inbox-send wrapper isn't on PATH here — used the .py directly); archsetup got an extra tailored note about its tracked CLAUDE.md.
+Still open in this pass: D (spec-review UI-traps), E (KB orphan task), replies to .emacs.d + archsetup, inbox file cleanup, LAST_INBOX_PROCESS stamp, push.
+
+** 2026-07-01 ~21:55 EDT — Inbox pass complete: D, E, replies, cleanup
+D (9814b94): archsetup's six UI-traps checks promoted into spec-review.org Phase 4 as the conditional "Operational-panel UI traps" dimension; accept reply sent to archsetup. E: KB orphan review filed as [#C] :chore: in todo.org (orphan-ness isn't a defect; periodic prune/merge/link pass, regenerate the list before running); report deleted, no reply (script source). Full reply to .emacs.d sent covering all three of its handoffs, including the planning-line divergence from what it sent. All 15 inbox files deleted; inbox-status 0 pending; :LAST_INBOX_PROCESS: stamped 2026-07-01. Next: commit todo/notes, push all five commits, then return to the interrupted goal — manual validations (wrap-teardown task 42, Agent-KB refusal checks task 309) and the queued specs.
+
+** 2026-07-01 ~22:00 EDT — Validations: agent-runnable half done
+Inbox pass pushed (7 commits, e36e932..6ec05bb). Resumed the validations goal. Done tonight: wrap-teardown plumbing re-verified fresh (Stop hook + symlink + no stale sentinel + companion fns (t t t); 3 live aiv-* sessions available for the gate test); Agent-KB check 1 verified (55 :agent: nodes in rg inventory AND 55 in the live org-roam DB — match); check 4 velox half verified (probe node agents/20260701214910-kb-sync-validation-probe.org committed + pushed by roam-sync in seconds, f0252bb, 0 conflicts). BLOCKED: ratio ssh times out (tailscale ping pongs via DERP, TCP:22 unreachable — likely suspended); probe left in place for later confirmation. Still needing Craig: wrap-teardown 5-test checklist (scratch session), KB checks 2+3 (work/unknown-project refusal sessions), work-machine-no-clone check. Evidence logged in todo.org under both tasks. Next: surface status + spec-writing choice to Craig.
+
+** 2026-07-01 ~22:00 EDT — Wrap-teardown feature validated and closed
+Craig ran all five manual tests live — teardown-after-valediction, both summary qualifiers, the multi-session shutdown refusal, the cancellable countdown + stubbed shutdown, and the push-failure guard. All passed ("works great"). Rewrote the checklist sub-task to a dated entry and closed the parent DONE + CLOSED [2026-07-01 Wed] (archives on the next --archive-done). Sent .emacs.d a one-line FYI since its companion functions are half the feature. NOTE for this session's own wrap: teardown is now the validated default — a bare "wrap it up" here will tear this session down; use "with summary" to keep the buffer. Remaining validation carryover: KB refusal checks (work/unknown project sessions), ratio probe confirmation, work-machine-no-clone check.
+
+** 2026-07-01 ~22:15 EDT — Speedrun Phase 0 + docs-lifecycle spec drafted
+Craig picked "2 then 1". Phase 0 shipped (2a45f07): hard :solo:/:quick: definitions in todo-format.md (fixed cross-project; :solo: = buildable + agent-verifiable + no deliberation with 1-2 upfront-answerable quick decisions allowed; :quick: = ≤30-min effort hint, never a gate), mandatory-assessment language in task-review + task-audit, and task-review gate 3 realigned from "no upfront decision" to the ratified no-deliberation form. Then the docs-lifecycle spec drafted at docs/specs/2026-07-01-docs-lifecycle-spec.org from the five 2026-06-28 decisions, dogfooding itself (first resident of docs/specs/, status heading DRAFT, :ID: link). Design call made in the draft: the authoritative keyword lives on a prepended top-level status heading (vocabulary DRAFT READY DOING | IMPLEMENTED SUPERSEDED CANCELLED) — additive, retrofittable, grep- and agenda-scannable. lint-org --check on the spec: 0 mechanical, 6 judgment items that are todo-shape checkers misreading spec conventions (spec DONE decisions carry no CLOSED; review-history dated headers are template shape) — noted, no action. Task flipped DOING with a dated entry. Awaiting Craig's spec review to flip DRAFT → READY.
+
+** 2026-07-01 ~22:30 EDT — Dual review of the docs-lifecycle spec; all nine findings fixed
+Craig had two independent agents review the spec: Codex (4 blocking findings, recorded in the spec's Review findings section) and my dispatched fresh-context reviewer (9 findings: same top blocker as Codex plus 5 unique, incl. the unowned DOING→IMPLEMENTED flip). Both rated Not ready; both independently caught that my #+TODO replacement destroyed the decision-task keyword machinery — the spec's own cookie was hand-faked. Craig approved fixing all nine. Responder pass done: merged ledger [9/9] with per-finding responses, two-sequence keyword header (verified: org computes [5/5] and [9/9]), transition-ownership table + spec-response flip task + task-audit safety net, single classification predicate, -spec.org rename, full relink contract, marker/nudge contract, compatibility rule, org-id prerequisite, three-line transition. Lint judgments on the spec are known todo-shape false positives (spec DONE entries and dated history headers). Status stays DRAFT; Craig decides the READY flip (option: send the fixed spec back to the reviewer agent, a082bea09c72a4e15, for a verify pass).
+
+** 2026-07-01 ~22:55 EDT — Second review round: five more findings, fixed and verified
+After the first nine fixes, my premature READY flip raced Codex's re-review — no data lost (commit 642be35 carries both my flip and Codex's demotion + five new blocking implementation-readiness findings; twelve seconds of READY). Craig approved a second responder pass, including the fork on the org-id finding (keep file: links through the pilot; id: conversion gated on a concrete .emacs.d id-index mechanism). Fixed all five (b163637): canonical-placement contract, :SPEC_ID: parent-keyword binding for task-audit (dissolves the flip-task chicken-and-egg, survives --convert-subtasks), fail-safe --apply (preflight/plan/recovery), staged id conversion, evidence-based status confirmation. Also de-cookified bracket [N/N] prose tokens org's cookie updater would mangle. My reviewer's second verify pass: ready, all held, nothing regressed, three minor nits — folded in (43cecd4): scoped id-link criterion, untracked-copy cleanup in recovery, two stale prose spots. Spec parked at DRAFT [14/14]; the authoritative READY flip is left to Codex's rerun or Craig. Lesson recorded: don't flip a lifecycle state the same pass that authored the fixes.
+
+** 2026-07-01 ~23:40 EDT — Spec READY (Codex flip), decomposed, Phase 1 built
+Codex's rerun flipped the spec READY at 23:22 (all fourteen findings closed). Craig: run with it, then flush and do Phase 2. Committed the reviewer flip, then ran spec-response Phase 6 as the first live exercise of the convention: :SPEC_ID: stamped on the build parent in todo.org, six child tasks (Phases 1-4, the gated id-conversion pass, the flip-to-IMPLEMENTED task) plus a manual-testing child (nudge visibility, link click-through), spec flipped READY→DOING (328ca18). Phase 1 then built and committed: claude-rules/docs-lifecycle.md (new rule, linked machine-wide), spec-create location + template updates (two-sequence header, DRAFT status heading with :ID:), spec-review location expectation + compatibility rule + READY flip ownership (incl. the demote path), spec-response DOING flip + :SPEC_ID: + mandatory flip task, task-audit :SPEC_ID: reconcile query. Mirror synced, make test green. NEXT AFTER FLUSH: Phase 2 — build claude-templates/.ai/scripts/spec-sort + bats per the spec's retrofit contract (classify predicate, evidence panel, plan/validate/apply, preflight, recovery, relink, marker stamp); the spec section "The retrofit" and the Phase 2 task body carry the full contract.
+
+** 2026-06-30 ~14:25 EDT — Startup + validation plan (interrupted session's log)
+Fresh session, clean startup (no crash anchor, clean tree, repos current). Craig: "do all the validations now, then write all the specs." Two validation items pending: wrap-teardown (task 42, 5-test checklist) and Agent-KB refusal checks (task 309, work/unknown project refusal — the cross-machine half is already confirmed on velox+ratio).
+
+Pre-flight on wrap-teardown plumbing: Stop hook wired in settings.json, hook script present+executable, all three companion functions (cj/ai-term-quit, -live-count, -shutdown-countdown) live in the daemon (t t t). Four live aiv-* sessions right now (aiv-_emacs_d, aiv-archsetup, aiv-rulesets [this], aiv-work). Read the hook + wrap-it-up Teardown mode + the three functions: cj/ai-term-quit is a safe idempotent no-op on a nonexistent project; shutdown-countdown aborts when >1 session live. So the gate + hook-wiring pieces are safely verifiable from this session without endangering it; only the buffer-teardown/geometry-restore and the countdown render/C-g need Craig's scratch session + eyes.
diff --git a/.ai/sessions/2026-07-04-12-57-audit-closeouts-and-startup-sync-guard.org b/.ai/sessions/2026-07-04-12-57-audit-closeouts-and-startup-sync-guard.org
new file mode 100644
index 0000000..c0d569b
--- /dev/null
+++ b/.ai/sessions/2026-07-04-12-57-audit-closeouts-and-startup-sync-guard.org
@@ -0,0 +1,68 @@
+#+TITLE: Session Context
+#+DATE: 2026-07-04
+
+* Summary
+
+** Active Goal
+
+Work a short list: commit the fable→opus model switch, run a task review, double-check the speedrun and finish it, then the routing feature. The review turned into a full task audit ("how many are actually completed"), which drove the rest.
+
+** Decisions
+
+- Craig redirected task-review → full task audit, scoped to completion.
+- Closed out all three code-complete tasks (docs-lifecycle, wrap-up routing, memory-sync): flip spec to IMPLEMENTED, close the parent, promote pending manual validation to standalone tasks. Same shape each time.
+- Accepted + applied home's startup skip-sync-when-behind proposal (shared-asset change, skeptical-reviewed, Craig-approved).
+- Pushed all three session commits to origin.
+
+** Data Collected / Findings
+
+- Of 18 open tasks, none were sitting done-and-verified; three were code-complete pending validation. The speedrun (autonomous-batch) was already fully done — IMPLEMENTED, live-trialed, archived in 5dc7da3.
+- Cross-machine memory sync verified as a live round-trip: on ratio (this machine) the velox-created probe was present with no conflicts; then over tailscale to velox, the probe deletion had propagated, both clones at HEAD 8c5ee01, both roam-sync timers active. Probe cleaned up.
+- Spec board now carries no DOING specs.
+- The commit no-attribution rule caught an accidental Claude-Session trailer on the first model-switch commit (amended out before it went anywhere).
+
+** Files Modified
+
+- .claude/settings.json — model fable→opus (73835a2).
+- todo.org + docs/specs/{docs-lifecycle,wrapup-routing}-spec.org + notes.org — three tasks closed, two specs flipped IMPLEMENTED, :LAST_AUDIT: stamped (44af1b2).
+- claude-templates/.ai/workflows/startup.org + mirror — Phase A proj_behind guard skips the template rsync when the project branch is behind upstream (bc4befa).
+
+** Next Steps
+
+- The three promoted validation tasks (docs-lifecycle manual checks; wrap-routing manual e2e; work/unknown-project KB write-refusal) need live cross-project or in-Emacs sessions to close.
+- home is applying the startup fix as a local stopgap; it can drop the stopgap after pulling rulesets.
+
+KB: promoted 0 / consulted no
+
+* Session Log
+
+** 2026-07-04 Sat @ 11:06 CDT — Startup + inbox
+
+Ran startup. Session-context was absent — previous session wrapped cleanly. Project repo clean/current (no ff needed); rulesets skipped its pull on a dirty tree (uncommitted =.claude/settings.json= model switch fable→opus, plus this session's own drops). =.ai/= synced from templates clean.
+
+One pending inbox handoff: =inbox/2026-07-01-0041-kb-hygiene-report.org=, a report from the automated =kb-hygiene.sh= script listing 41 orphan =:agent:= KB nodes. Checked todo.org — already filed as =** TODO [#C] KB orphan-node review pass :chore:= (line 42), whose body cites this exact report and instructs regenerating the list rather than trusting the snapshot. So the inbox file is a leftover; deleted it (script source, already captured, no reply owed). Stamped =:LAST_INBOX_PROCESS:=.
+
+Noted but not touched: =.claude/settings.json= carries an uncommitted model switch (fable→opus) — Craig's local config, left as-is. =inbox/lint-followups.org= is a task-review-health nudge (2 top-level tasks unreviewed >30 days), correctly excluded by inbox-status.
+
+** 2026-07-04 Sat @ 11:xx CDT — Model commit, task audit, three closeouts
+
+Committed the fable→opus switch (73835a2; first amend removed an accidental Claude-Session trailer that violates the no-attribution rule). Then ran a full task audit (Craig redirected from task-review to "how many are actually completed").
+
+Audit finding: of 18 open tasks, zero were sitting done-and-verified, but three were code-complete with only validation pending. Also surfaced that the no-approvals speedrun (Craig's item 3) was already fully done — autonomous-batch spec IMPLEMENTED, live-trialed (metrics commits), task archived in 5dc7da3.
+
+Closed out all three code-complete tasks (Craig approved A, then walked B and C):
+- A: docs-lifecycle convention — flipped spec to IMPLEMENTED, closed parent, promoted manual-validation + vNext to standalone tasks.
+- B: wrap-up routing (Craig's item 4) — same shape; build was fully shipped + green, flipped spec IMPLEMENTED, closed parent, promoted manual e2e + transcript vNext.
+- C: memories-sync VERIFY — implementation was IMPLEMENTED already; verified check 4 live this session. We're on ratio, so the ratio half was a local check (probe from velox present, zero conflicts). Then velox came back reachable — did the full round-trip over tailscale: velox pulled ratio's probe deletion, both clones at the same HEAD (8c5ee01), zero conflicts, both timers active. Deleted the spent probe. Closed the VERIFY; promoted the residual work/unknown-project write-refusal checks to a standalone task.
+
+Spec board now has no DOING specs. Stamped :LAST_AUDIT: 2026-07-04.
+
+Committed the audit closeouts as 44af1b2 (todo.org + both specs + notes.org), voice-passed. Two commits now sit unpushed ahead of origin (73835a2 model switch, 44af1b2 audit).
+
+** 2026-07-04 Sat @ ~12:53 CDT — Inbox: home's startup behind-guard fix applied
+
+Two inbox handoffs from home arrived mid-session (intro + proposal): startup Phase A step 3 rsyncs templates onto a project's stale committed .ai/ baseline when the project branch is behind/diverged, producing phantom drift that conflicts on reconcile. Ran the skeptical review (correct, complete, safe, universal); recommended accept; Craig approved applying now.
+
+Applied in canonical claude-templates/.ai/workflows/startup.org: a second guard computes proj_behind from git rev-list @{u}...HEAD and skips the rsync when behind>0, composing with the rulesets-clean guard. Added companion prose (step-3 heading + a rsync-notes bullet, incl. the no-auto-discard reasoning and the 6/22 flashcard-revert precedent). sync-check --fix propagated to the mirror; make test green (all ERT 0-unexpected, bats ok). Committed bc4befa. Replied to home (delivered to their inbox), deleted both inbox files, updated :LAST_INBOX_PROCESS:. Inbox clean.
+
+Three unpushed commits now: 73835a2, 44af1b2, bc4befa.
diff --git a/.ai/sessions/2026-07-11-02-30-inbox-clearout-six-proposals-shipped.org b/.ai/sessions/2026-07-11-02-30-inbox-clearout-six-proposals-shipped.org
new file mode 100644
index 0000000..3555f8f
--- /dev/null
+++ b/.ai/sessions/2026-07-11-02-30-inbox-clearout-six-proposals-shipped.org
@@ -0,0 +1,74 @@
+#+TITLE: Session Context
+#+AUTHOR: Craig Jennings
+#+DATE: 2026-07-11
+
+* Summary
+
+** Active Goal
+
+Process the full project inbox (11 handoffs) and then the roam inbox. Craig chose to work the shared-asset proposals in order rather than deferring them, so the session became a full inbox clear-out with six proposals shipped.
+
+** Decisions
+
+- Craig approved all six shared-asset proposals after skeptical review, one batch at a time: triage-intake gmail cap, PR-review drop-praise, ui-prototyping rule, desktop-capture rule, bug-matrix double-count, staleness org-date parsing.
+- Placement calls: ui-prototyping and desktop-capture each got a standalone claude-rules/ file (not folded into existing workflows), matching the docs-lifecycle.md pattern spec-create already points at.
+- chime's staleness fix used the "both" approach: accept the org-native bracketed stamp AND warn loudly on genuinely-unparseable values, rather than silently counting them stale.
+- settings.json model flip (opus→fable, harness-written at 02:12) reverted to opus per Craig — matches the deliberate 2026-07-04 switch (73835a2).
+- Bare "wrap it up" → teardown mode.
+
+** Data Collected / Findings
+
+- The AI-attribution handoff's work had already landed as commit 6def7c4 (tree was clean); the 15 remaining "& Claude" files are all in .ai/sessions/ (historical, correctly held back). Confirmed agreement with everything the work session held back.
+- personal-gmail is the only gmail-family plugin rulesets owns, so the 100-cap fix touched one plugin.
+- The sent triage-intake copy carried the old "& Claude" author line; applied only the two Scan blocks, did not reintroduce it.
+- ~/.claude/settings.json is a symlink to the repo file, so a git checkout reverts both at once (not a hardlink).
+- Roam inbox churns fast (12 items at 01:01 startup, 6 at 02:16 process). Git history confirmed exactly 3 rulesets-owned items ever passed through and all 3 were filed — no rulesets item was missed; the count drop was foreign-item/other-machine churn.
+
+** Files Modified
+
+- Six proposal commits (see Next Steps for shas). Rules/skills: review-code/SKILL.md, voice/SKILL.md, voice/references/voice-profile.org, claude-rules/{commits,todo-format,verification}.md; new claude-rules/{ui-prototyping,desktop-capture}.md. Workflows: triage-intake.personal-gmail.org, spec-create.org, spec-review.org, task-review.org (+ mirrors). Script: task-review-staleness.sh + its bats (+4 tests) + mirrors.
+- todo.org: 5 tasks filed (org-table bug [#B], Signal pager [#C], 3 roam ideas [#C]). notes.org: :LAST_INBOX_PROCESS: stamped 2026-07-11. 11 inbox handoffs renamed PROCESSED-*.
+
+** Next Steps
+
+- All inbox work done; nothing carried forward. Filed backlog tasks await future sessions: org-table helpers data-loss bug ([#B] :bug:solo:, has a repro test), Signal pager doc ([#C], reconcile signal-mcp vs signal-cli first), and the 3 roam ideas.
+- Shipped shas: 81ca16f, 99bd213, 53f6ce6, 8ddd3d3, 06ca6c7, cfbabf1 (+ housekeeping b2aeaf6, 95dd661).
+
+KB: promoted 0 / consulted no
+
+* Session Log
+
+** Startup (2026-07-11 01:01 CDT)
+
+Ran startup.org Phases A.0/A/B. rulesets pull clean, make install nothing new, project repo current. .ai/ synced from templates. No session-context anchor (prior session wrapped cleanly). Read the 5 most recent session summaries and all 11 inbox handoffs. Verified the AI-attribution handoff's work already landed as commit 6def7c4 (tree clean). Created this anchor.
+
+** Inbox processing (Craig chose: file the 2 tasks, work proposals in order)
+
+Filed two todo.org tasks: [#B] :bug:solo: org-table helpers corrupt example blocks (graded Critical×rare = P2 = [#B]); [#C] :feature:spec: document/own the Signal pager (flagged the signal-mcp vs signal-cli/account-404211 reconcile). Replied to work (bug + attribution) and home (signal), marked those 3 inbox files PROCESSED.
+
+Skeptical-reviewed the two work proposals:
+- triage-intake.personal-gmail.org: clean 2-block Scan addition (100-cap date-slice walk + mandatory backlog-residue probe). Only other diff is the "& Claude" author-line regression, which I will NOT reintroduce. Low-risk correctness fix. Verdict: approve. personal-gmail is the only gmail-family plugin in rulesets.
+- PR-review tightening: (1) no praise at all on approvals — drop the "bare positive" carve-out at review-code/SKILL.md:453, :278, examples :458-465, and /voice pattern #40; lead with substantive pointer then verdict. (2) always print full inline prose at the gate — tighten review-code Phase 5 (:276) + commits.md Shape 1. Craig's own ruling from a real session. Verdict: approve.
+
+Both awaiting Craig's approve/park.
+
+** Work proposals shipped (Craig approved both)
+
+- 81ca16f fix(triage-intake): personal-gmail 100-cap date-slice walk + backlog-residue probe (canonical + mirror). Did NOT reintroduce the "& Claude" author line from the sent copy.
+- 99bd213 docs(review): drop praise from approve summaries (removed bare-positive carve-out from review-code Posted Summary Voice + voice #40 + voice-profile §40 examples/history + quick-ref table), require full inline prose at the gate (review-code Phase 5 + commits.md Shape 1).
+- make test green (269 ok, pytest 12), both authored as Craig, not pushed yet.
+- Replied to work on both; marked the 3 work inbox files PROCESSED.
+
+Remaining inbox proposals to work (Craig's chosen order): archsetup ui-prototyping (2 files), archsetup off-workspace-captures rule, chime staleness-parse, chime bug-matrix double-count.
+
+** Full inbox cleared — 6 proposals shipped, all 11 handoffs processed
+
+Shipped commits: 81ca16f (triage 100-cap), 99bd213 (drop-praise), 53f6ce6 (ui-prototyping rule), 8ddd3d3 (desktop-capture rule), 06ca6c7 (bug-matrix), cfbabf1 (staleness org-date TDD, +4 tests, suite 273 ok), b2aeaf6 (housekeeping: 2 tasks filed + processed markers + inbox marker). Replied to every sender (work, home, archsetup, chime). notes.org :LAST_INBOX_PROCESS: stamped 2026-07-11. Nothing pushed yet.
+
+FLAG: .claude/settings.json model flipped opus→fable at 02:12 (hardlinked to ~/.claude/settings.json, harness-written, not my edit). Reverts the deliberate 2026-07-04 opus switch (73835a2). Left unstaged — needs Craig's call (revert to opus, or accept fable).
+
+** Roam inbox-zero (Craig: "then inbox-zero again")
+
+Roam inbox had 6 items at process time (roam-sync churns fast). Bucketed: 3 claimed (rulesets:), 3 foreign (emacs.d ×1, archsetup ×2), 0 unowned, 0 empty. Filed the 3 rulesets items into todo.org (all [#C]: roam-only-startup investigation :spec:, keep-WIP-from-blocking-sync-gate :feature:, install-ai-on-PATH :chore:) — committed 95dd661. Removed the 3 rulesets items from the roam inbox (foreign left untouched), handed git to roam-sync → adeb011, pushed clean. Phase E done.
+
+Session state: 8 rulesets commits this session (6 inbox proposals + 2 housekeeping), roam inbox reconciled. Nothing pushed to rulesets origin yet. settings.json model-flip still unstaged/unresolved.
diff --git a/.ai/sessions/2026-07-13-23-48-runtime-portability-pager-local-llm.org b/.ai/sessions/2026-07-13-23-48-runtime-portability-pager-local-llm.org
new file mode 100644
index 0000000..8e55623
--- /dev/null
+++ b/.ai/sessions/2026-07-13-23-48-runtime-portability-pager-local-llm.org
@@ -0,0 +1,191 @@
+#+TITLE: Session Context — 2026-07-13
+#+AUTHOR: Craig Jennings
+
+* Summary
+
+** Active Goal
+
+A marathon (05:14–23:59) that became four arcs: (1) the runtime-portability children — all eight resolved, ending with a working local-LLM agent lane; (2) the EAT sixel investigation — persistent terminal images plus two dead .emacs.d agents root-caused (OOM from an EXDATE infinite loop, fixed cross-project); (3) the agent pager — Signal paging reconciled, verified, wrapped in a universal agent-page command; (4) full backlog hygiene — inbox zero on both surfaces repeatedly, task-review staleness driven to zero.
+
+** Decisions
+
+- Craig kept the fable model pin (d5bc9b3 accidentally committed only a case change — harness rewrote the file pre-staging; corrected in e91073d).
+- Thin-pointer AGENTS.md over a generated monolith for non-Claude bootstrap ("1 is the way to go. definitely").
+- All four inventory decisions approved (PreCompact prose downgrade, Stop-teardown via codex notify/manual, Signal-pager portability note, knowledge-base.md capture-layer sentence).
+- Signal pager re-graded [#C]→[#B]; ntfy two-way-comms task KILLED (infrastructure torn down 07-04; agent-page + Signal runbook are successors).
+- Drop all four legacy ollama models after verification; MoE over dense for Strix Halo; Vulkan backend forced over ROCm (systemd override).
+- "Agent pager" naming (the Slough House of pagers); paging documented as two channels everywhere.
+- EXDATE fix applied from here (cross-project, Craig-directed); jotto SVG rule and takuzu EAT-visuals rule both accepted with changes; .emacs.d's validate-el.sh load-prefer-newer fix accepted verbatim.
+
+** Data Collected / Findings
+
+- tmux transmits sixel only when TERM_SIXEL is set AND the client's cell pixel size is known; stock EAT 0.9.4 silently drops the CSI 14 t query (no t case in its dispatch). EAT persists sixel in scrollback natively; through tmux, passthrough images are write-once but native-path images survive redraws.
+- Both .emacs.d agent deaths were kernel OOM kills (111GB emacs) from calendar-sync--get-exdates reading (match-end 0) after split-string clobbered the match data — infinite loop only on comma-separated EXDATE lines far enough into the string. My first two diagnoses (stale sentinel; double-reply keystroke leak) were wrong; the double-reply was real but non-lethal.
+- signal-mcp is a velox-local MCP server, not claude.ai-side; the pager identity +15045173983 lives in velox's signal-cli; ratio's signal-cli is Craig's own number (no push). Receive-staleness warnings on both accounts.
+- Strix Halo: ROCm allocates correctly but first-touch loads at <25MB/s (unusable for 61GB); Vulkan loads the same model in 44s. gpt-oss:120b: 37.9 tok/s; qwen3-coder:30b: 76 tok/s; both 100% GPU. num_ctx matters (native 128K balloons loads). codex --oss needs --local-provider=ollama explicitly (config.toml append lands in the last TOML table, silently inert).
+- The remembered old launcher was aix + hey, removed from dotfiles 2026-04-20 (dab0d5a); bin/ai is their unified successor. lint-org.el's mutate-on-lint defect self-demonstrated during this wrap (no damage — no pipes in example blocks).
+
+** Files Modified
+
+Rulesets (16 commits, all pushed through 3984392): SVG rule (cac1aa1), visuals rule + zsh note (8703150), model pin ×2 (d5bc9b3, e91073d), runtime child tasks (1b638e6), inventories doc + decisions + skill parity (44bc344, 0ee94ab), AGENTS.md entry + install paths (6cd3aa3), launcher --runtime / local lane / agent picker (04c3b29, 1b98a9e, 3eed2e1), agent-page + two-channel paging docs (49be354), validate-el.sh fix (7e61dc0), model-floor status (21d14e0), review close-outs (0de6e09, 3984392). Plus dotfiles 5055ef1 (tmux sixel conf, pushed) and .emacs.d 56a3ed86 (EXDATE fix, committed there). System: ollama 0.31.2 + Vulkan override; ~/.codex/AGENTS.md link; two KB node writes (roam-synced).
+
+** Next Steps
+
+- Craig's maiden voyage: ai → pick local:gpt-oss:120b → takuzu; watch for skipped startup/gates/session-log/commit-rules — that's the model-floor eval running live. The formal scripted eval remains on the child.
+- Signal-pager [#B]: runbook, receive timer on velox, ssh-vs-linked-device decision.
+- Watch for .emacs.d's ai-term multi-LLM work (they're shelling out to ai --print-runtimes); upstream EAT PR is their [#D].
+- Backlog is fully current; org-table [#B] bug and launcher-hardening [#C] are the natural next builds.
+
+* Session Log
+
+** 2026-07-13 Mon 15:25 — SECOND .emacs.d death root-caused: OOM from an EXDATE infinite loop (my first diagnosis was wrong)
+
+Craig reported death #2 (launch 14:32, [exited] 14:55:36) — AFTER my double-reply fix, so that diagnosis fell. Corrections to the record: the transcript "queue-operations" are harness task-notification bookkeeping, NOT leaked keystrokes (my death-1 keystroke story loses its evidence — the double-reply leak was real and measured but likely never killed anything); the final "background task killed" entries are claude's SIGHUP handling. Hard evidence chain: EAT scrollback shows no bash prompt before [exited] (session destroyed under a live claude); journalctl nails BOTH deaths to the second — kernel OOM killer at 13:41:57 (emacs, 111GB anon-rss!) and 14:55:36, each inside the session's tmux-spawn scope, which systemd then failed, killing the pane → single-window session gone. The bomb: tests/test-calendar-sync--get-exdates.el's two comma-separated tests (NEW in a9e61207, the off-anchor EXDATE fix) hang forever: calendar-sync--get-exdates sets pos from (match-end 0) AFTER split-string, whose internal string-match clobbers match data → pos becomes the last comma's offset within the VALUE; when that's smaller than the EXDATE line's position, the loop re-matches the same line forever, growing the list to OOM. Verified: exact test string hangs the loaded .elc; bytecode disassembly confirms match-end after split-string; comma-less values don't clobber (failed string-match preserves match data), which is why only the two new tests bomb. Fix shape: capture (match-end 0) into a local BEFORE split-string (or save-match-data). Daemon check: calendar-sync loaded but NO timer → no live-daemon exposure right now. Side finding for the handoff: test-ai-term--show-or-create.el leaks one real tmux list-sessions call past its mocks (read-only, benign, but a mock gap; caught with a tmux PATH shim). Suite-under-shim full run unnecessary — OOM evidence is conclusive. Instruction-bootstrap build parked mid-scoping (no edits yet). Next: Craig decides fix-from-here vs handoff to the .emacs.d agent.
+
+** 2026-07-13 Mon 23:50 — inbox cleared again + task review COMPLETE (staleness zero)
+
+Handoff round: .emacs.d's validate-el.sh load-prefer-newer fix accepted verbatim (diff = exactly the two claimed evals; suite green; commit 7e61dc0 PUSHED; reply sent); takuzu ack deleted (all five routed captures filed there as three [#B] tasks); two more acks arrived mid-work and were processed (.emacs.d multi-LLM work underway, will shell out to ai --print-runtimes — interop achieved; home recorded the pager recipe) — sent home a one-line addendum pointing at agent-page so its memory doesn't fossilize the raw ssh recipe. Then the remaining 11 reviews, Craig-delegated: ntfy two-way-comms KILLED (CANCELLED — home tore down ntfy 07-04; Signal pager + agent-page are the successors); triage-intake phone-push re-pointed at agent-page (send half buildable now, recv waits on the Signal runbook); coverage-summary docs task tagged :quick:solo:; three tasks gained missing drawers; the rest confirmed. STALENESS NOW ZERO — the whole backlog is current. Commit 3984392 PUSHED. Emacsd handoff sent earlier re ai-term alignment. Everything on origin through 3984392. Remaining: Craig's takuzu local trial (his keyboard, likely tomorrow) and the wrap.
+
+** 2026-07-13 Mon 23:22 — agent picker shipped: bare ai asks which brain first
+
+Craig asked where fzf went and wanted the flow to offer LLMs before projects. Built TDD (9/9 launcher bats, suite 441/0): build_runtime_choices (claude first, codex-as-ChatGPT, one local:<model> line per live ollama model, 3s-timeout query, dead server drops lines), pick_runtime feeding the runtime map, dispatch restructured (picker only on bare ai without --runtime/AI_RUNTIME; check_deps moved after final runtime known), --print-runtimes test seam, usage updated (sed range widened to 2,23 for the grown header). Live print verified: all four lines. Commit 3eed2e1, PUSHED. Also answered: the remembered old launcher was aix + hey in dotfiles, removed 2026-04-20 (dab0d5a) when bin/ai unified them — no shadowed script exists; and the rofi/zenity UI-picker exploration stands offered but unbuilt (rofi -dmenu -multi-select = nearest drop-in; zenity = real GTK checklist; fuzzel single-select only). Roam-ask "agentically democratic" now substantially delivered. Still open: Craig's takuzu local trial, 11 task reviews, wrap. 23:24: sent emacsd the ai-term alignment handoff (runtime map strings incl. the --local-provider gotcha, picker shape + --print-runtimes as a shell-out option, verified perf numbers + Vulkan/num_ctx caveats, AGENTS.md bootstrap note) — Craig is actively working ai-term multi-LLM with that project.
+
+** 2026-07-13 Mon 23:06 — local runtime LIVE: ai --runtime local = codex --oss over ollama
+
+Craig pushed (e91073d..21d14e0 landed) and chose to wire the local lane. Shipped TDD (7/7 launcher bats, suite 439/0): local → codex --oss --local-provider=ollama -m $AI_LOCAL_MODEL (default gpt-oss:120b), AGENT_BIN/AGENT_CMD split for the deps check. End-to-end verified: codex exec --oss through local gpt-oss returned a correct completion (~7K tokens). Gotchas learned: codex needs the provider named (flag beats config — appending oss_provider to config.toml lands in the last TOML table and silently does nothing; a stray append was made and removed, ~/.codex/config.toml back to original). Also: upstream-patch question answered — .emacs.d shipped its durable home as c53671b0 (DONE, pushed) and filed the upstream PR as its own [#D] with working/eat-sixel-patch/; nothing needed from rulesets. Recommendation given for the live local trial: takuzu (well-traveled, 78+ tests, 5 fresh small tasks, known Claude baseline); watch for skipped startup/gates/session-log/commit-rules + practical tok/s. Commit 1b98a9e PUSHED. During the edit I briefly clobbered the 16:39 dated heading in todo.org (Edit replaced a heading instead of inserting above it) — caught and repaired in the same minute. Remaining for the session: Craig's live takuzu trial (his keyboard), the leftover 11 task reviews, wrap.
+
+** 2026-07-13 Mon 20:20 — local inference stack verified end to end; model thread CLOSED
+
+The gpt-oss saga resolved: ROCm on gfx1151 allocates/offloads correctly but first-touch loads at <25MB/s — two 40-min load attempts died to client timeouts (one mine, one the ceiling; a third was killed externally ~19:00, unexplained). qwen3-coder verified first on ROCm (5s load, 76 tok/s, 100% GPU) proving the generate path, isolating the problem to big-model load speed. Fix: systemd override /etc/systemd/system/ollama.service.d/strix-halo-vulkan.conf (OLLAMA_IGPU_ENABLE=1 + ROCR_VISIBLE_DEVICES="") → Vulkan/RADV as sole inference device → gpt-oss:120b loads in 44 SECONDS, "verified", 37.9 tok/s, 100% GPU. Craig's approved drops executed: tinyllama + llama3 rm'd from the service store, the orphaned 45GB ~/.ollama user store purged. KB node rewritten with the end-state (backend, override path + revert note, per-model numbers, num_ctx gotcha, MoE-not-dense lesson) and roam-synced. Model-floor child updated: remaining work is purely the instruction-following eval (gpt-oss as target, qwen as comparator). Commit 21d14e0. Unpushed now 9: 44bc344..21d14e0. All threads from today are closed or cleanly parked; ready to wrap whenever Craig calls it.
+
+** 2026-07-13 Mon 18:32 — agent pager shipped; pager thread effectively closed for today
+
+Full sequence since 17:15: routed all 10 foreign roam items to owners via inbox-send (roam inbox at true zero, takuku typo flagged); Signal reconcile — pager identity +15045173983 lives in velox's signal-cli (signal-mcp = velox-local MCP config, NOT claude.ai-side; corrected the 14:40 inference), ratio's signal-cli is Craig's own number; live page from velox buzzed Craig's phone (confirmed). Then Craig directed universal paging + the "agent pager" rename: NEW claude-templates/bin/agent-page (velox-direct/ssh-relay, UUID baked in, fallback hint; 4 bats, PATH-stubbed; live-verified through the real script — second buzz), protocols.org Paging-Craig rewritten (two channels, signal-mcp demoted), page-me.org + work-the-backlog + INDEX updated, mirror synced, make install linked it (velox inherits next session). Suite 438/0. Commits: 0de6e09 (review+filings), 49be354 (agent pager, incl. the Signal task's dated entries + interim-recipe-to-home note). home got the recipe handoff at 18:14. Signal task remains [#B] with sharpened deliverables: runbook, receive timer, ssh-vs-linked-device. Unpushed count now 8 (44bc344..49be354). Model verification still pending: gpt-oss smoke test backgrounded (bkpjmp2iu), then qwen3-coder smoke, then the four legacy drops + KB update.
+
+** 2026-07-13 Mon 17:15 — task review finished (Craig delegated the close)
+
+Craig said "finish the task review" — applied my judgments across the remaining 6 (+1 from the morning = 8 of 18 total; 11 remain for future daily rotations). Changes: Signal pager re-graded [#C]→[#B] (paging is a real capability gap post-inventory — flagged for veto); install-ai-on-PATH tagged :quick:solo:; wrap-up-routing validation gained its missing PROPERTIES drawer; the rest confirmed as-is, all stamped 2026-07-13. Committed 0de6e09 together with the inbox-zero filings. Next per Craig: "handle some of those roam inbox items" — all 10 remaining are foreign, so the sanctioned shapes are inbox-send routing or launching owner agents; presenting options.
+
+** 2026-07-13 Mon 17:08 — inbox zero (roam mode) run
+
+Craig called inbox zero mid task-review (review paused at item 2 of 7, roam-only startup, recommendation pending). Local inbox clear. Roam scan: 12 total, 2 rulesets-claimed, 9 foreign (4 archsetup, 4 takuzu, 1 emacsd), 1 mis-prefixed "takuku:" (plainly takuzu's — left in place, typo flagged to Craig), 0 unowned, 0 empty. Claimed items deduped against today's work: the agent-switching ask is partly shipped (--runtime claude|codex 04c3b29; codex = the ChatGPT CLI; ollama/qwen = the reserved local runtime on the model-floor child) — folded as a dated entry on the generic-agent-runtime parent. The new work filed as [#C] "ai launcher hardening — bug hunt + refactor pass" :refactor:solo: (interactive runtime picker rides it). Roam edit: both claimed items removed under a clean capture-guard, roam-sync triggered (10 items remain, all foreign). todo.org edits uncommitted. Monitor for pulls retired at Craig's cue earlier; pull ETA ~18:30 at 12MB/s.
+
+** 2026-07-13 Mon 16:45 — session plumbing closed with a no-build verdict (8 of 8 children resolved; model-floor eval pending pulls)
+
+Assessed the four plumbing pieces: session-context-path runtime-aware by design; anchor cycle plain-files-portable; self-inject harness-agnostic (payload is the only Claude-shaped part — codex auto-flush = payload variant, second injected line carries the resume instruction, no hook needed); session-clear-resume.sh stays Claude-only. Section added to the inventories doc, child closed, commit 3ef5580 (suite 434/0). ALL EIGHT runtime children are now resolved or reduced: 6 shipped/closed, launcher shipped, model-floor's remaining work is the evaluation itself, blocked only on the pulls (~19:00 ETA). Unpushed: 44bc344, 0ee94ab, 6cd3aa3, 04c3b29, 3ef5580.
+
+** 2026-07-13 Mon 16:42 — launcher runtime flag shipped (7 of 8 children done)
+
+While the pulls run (rate settled ~0.5GB/min, ETA ~19:00): built ai --runtime claude|codex TDD — 6-test bats file driving a new --print-launch seam (prints the pane command, no tmux), runtime→CLI map with local reserved (clear error naming the model-eval dependency), AI_RUNTIME env support, runtime-aware deps check, CLAUDE_CMD→AGENT_CMD at all three launch sites. Verified: codex --help confirms positional-prompt parity with claude; live smoke prints correct commands both runtimes. Suite 434/0. Commit 04c3b29; child closed as dated entry. NOTE: Craig's real sessions launch via .emacs.d's ai-term (aiv-<proj> per-project sessions), NOT bin/ai (shared "ai" session, window per project) — the Emacs-side multi-LLM support is .emacs.d's June handoff, unchanged here. Craig also approved dropping tinyllama + llama3:8b ("annoying little pest") — all four legacy models go after verification. Remaining child: session plumbing, local model floor (eval blocked on pulls). Unpushed: 44bc344, 0ee94ab, 6cd3aa3, 04c3b29.
+
+** 2026-07-13 Mon 16:40 — ollama upgraded 0.17.7→0.31.2, model pulls running
+
+Craig picked option 1 (drop both llamas after the new models verify) and directed the full sequence. Research (WebSearch): Strix Halo wants MoE models (bandwidth-bound decode; dense 70B ≈ single-digit t/s) — gpt-oss:120b (~55 t/s reported on this chip) + qwen3-coder:30b (~100 t/s, 256K ctx) are the picks; ollama 0.17.7 was far behind. Upgraded via the official installer (sudo, NOPASSWD): 0.31.2, systemd unit recreated, service active. GPU discovery healthy out of the box: ROCm sees gfx1151 iGPU, 107.4GiB available; Vulkan RADV device present but gated behind OLLAMA_IGPU_ENABLE=1 (community says Vulkan outperforms ROCm on gfx1151 — A/B during the model-floor eval, not now). DISCOVERY: the service uses /usr/share/ollama/.ollama (system store) — holds two MORE models my earlier "complete" inventory missed (tinyllama 637MB, llama3:8b 4.7GB); the 45GB ~/.ollama store is separate and invisible to the service. KB node needs both corrections (stores + new versions) after pulls complete. Pulls running in background (btkrc1y0n, sequential: gpt-oss:120b 65GB then qwen3-coder:30b 19GB) into the system store; du-based Monitor armed (be6nrzl5d). Disk: 3.0T free, no constraint (df is aliased to dfc — use command df). Pending Craig: extend the drop list to tinyllama + llama3? Pending me after pulls: smoke-test both models w/ GPU offload check, ollama rm the drops, purge the orphaned ~/.ollama user store, update KB node + model-floor child, session log.
+
+** 2026-07-13 Mon 16:15 — local-runtime inventory gathered and promoted to the KB
+
+Craig asked about ollama; gathered the full local-inference picture. ratio: ollama 0.17.7 (/usr/local/bin, no systemd unit, on-demand), 45GB store with llama3.1:8b + llama3.3 70B, Strix Halo Radeon 8060S iGPU + 125GiB unified RAM — a real inference box, and the on-disk 70B matches the model-floor estimate. velox: no ollama, Iris Xe, 60GiB — out of scope for local inference. codex CLI installed + authed on ratio. Promoted to the KB as agents/20260713161222-local-llm-inference-inventory-daily-drivers.org (committed, roam-sync triggered — first KB write in a long while, the contribute nudge finally earned). Folded the inventory into the model-floor child's body: the evaluation can run TODAY on ratio against the on-disk 70B (scripted startup + publish-flow transcript, grade instruction-following) — no procurement needed. todo.org edit uncommitted.
+
+** 2026-07-13 Mon 16:10 — instruction bootstrap shipped (6 of 8 children done)
+
+Memory sentry ran clean during Craig's relaunch (zero alerts, daemon flat 1.97GB, third .emacs.d session alive since 15:44 and healthy). Queue resumed: built the thin-pointer AGENTS.md TDD (bats red→green): canonical claude-templates/AGENTS.md; make install CODEX_DIR stanza (→ ~/.codex/AGENTS.md, live on ratio now; velox auto-picks-up via startup's make install); install-ai.sh seed-only copy at bootstrap; rulesets root tracked symlink as dogfood. NEW scripts/tests/install-agents-entry.bats (3 tests) + 2 install-ai.bats tests. Riders: todo.org line-1357 prose reworded (lint misread "file:→id:" as a dead link), lint-followups.org cleared. Suite 428/0. Commit 6cd3aa3 (also closes the child + adds a dated entry on the agent-source task's thread 2). Inbox: two .emacs.d acks processed + deleted (the dead 14:34 one, and the live session's 15:47 — HEAD 56a3ed86 confirmed, staged eat work intact, mock-leak filed [#C] there, no action needed). Unpushed rulesets commits: 44bc344, 0ee94ab, 6cd3aa3. Remaining children: launcher runtime flag (scoping next — codex mapping clear, local mapping blocked on the model-floor/default-runtime decision), session plumbing, local model floor. Then the org-table bug + 6 task reviews.
+
+** 2026-07-13 Mon 15:32 — EXDATE bomb fixed, .emacs.d suite green end-to-end, agent cleared to relaunch
+
+Craig chose fix-from-here. Applied the capture-line-end-before-split fix to calendar-sync-recurrence.el (with a why-comment naming the clobber mechanism), deleted + recompiled the .elc, reloaded into the daemon (live Emacs calendar-safe again). Verification: the two previously-infinite tests pass in microseconds; all 12 exdates tests green; all 56 calendar-sync files green; then the FULL suite ran to completion for the first time today — all 599 elisp files + 18 bats green, including the dead agent's uncommitted eat-config guard tests. Committed 56a3ed86 in .emacs.d with a pathspec so the agent's staged eat work stayed untouched; not pushed. Superseding handoff sent to .emacs.d inbox (2026-07-13-1530): corrected diagnosis (journalctl OOM evidence), fix details, the show-or-create mock-leak finding, the double-reply note downgraded to latent-bug status, explicit safe-to-relaunch incl. the suite. Craig can relaunch the .emacs.d agent now. Resume thread: instruction-bootstrap build (thin-pointer AGENTS.md, Craig approved shape) was mid-scoping when death #2 interrupted — Makefile install stanza + install-ai.sh seeding + canonical claude-templates/AGENTS.md + bats, TDD.
+
+** 2026-07-13 Mon 14:55 — pin pulled: four decisions applied, skill parity done
+
+Craig pulled the pin and approved all four inventory decisions. Applied: knowledge-base.md capture-layer sentence (non-Claude runtimes capture into the session log), Signal-pager task gained the runtime-portability motivation entry, decisions recorded in the doc, VERIFY closed as a dated entry. Commit 44bc344. Then the skill-parity child: inventoried 11 skills + 18 commands, key finding is that one resolution sentence in the bootstrap entry file makes all 29 portable to any file-reading harness (auto-invocation degrades to by-name, which the publish flow already uses; flush excluded, session-plumbing owns it; native registration optional nicety). Section appended to the inventories doc, child closed as dated entry, commit 0ee94ab. Both commits local/unpushed (with 44bc344). 5 of 8 children done. Remaining: instruction bootstrap (needs Craig's design input, overlaps the Multiple agent-source improvements task), launcher runtime flag, session plumbing, local model floor.
+
+** 2026-07-13 Mon 14:30 — PIVOT RESOLVED: .emacs.d agent death root-caused and fixed
+
+Craig's urgent pivot: the .emacs.d agent session (launched 13:20) died — tmux session aiv-_emacs_d gone, [exited] in the launcher shell. Investigation chain: stale-sentinel theory disproved (multiple turns survived; no sentinels; hook tests clean). Real cause: DOUBLE XTWINOPS REPLIES. The .emacs.d agent had picked up our eat-xtwinops handoff, designed an advice-based fix (cj/--eat-answer-xtwinops :before eat--t-handle-output, written to modules/eat-config.el 13:33, test file 13:32), and evaluated it into the live daemon — where MY morning spike (patched parser with its own CSI t clause, loaded 10:52) was still active. Two answerers → tmux consumes reply #1, forwards reply #2 to the active pane as raw keystrokes (ESC + junk) — and Emacs ptys report 0 pixel size so EVERY frame resize re-queries. Its transcript (83070ecd, ends 13:41:57) shows the signature: four empty queue-operations, then background make test killed as claude exited. Exact final exit trigger unreconstructable from the transcript; the leak itself confirmed empirically: probe showed 2 replies per query. Fix: reloaded stock eat.elc into the daemon 14:27 (advice survives redefinition and is now sole answerer); re-probe = 1 reply. Probe buffer cleaned up. Handoff sent to .emacs.d (2026-07-13-1427): root cause, safe-to-resume, the latent no-op-claim bug in its advice comment (must guard against upstream shipping the clause or the double-reply returns), rerun its killed ERT, and the coordination lesson (our 10:58 handoff should have disclosed the live spike — our omission). Craig can relaunch the .emacs.d agent now; it resumes from its own anchor.
+
+** 2026-07-13 Mon @ 14:16:49 -0500 — SUSPENDED (urgent pivot; supersedes the 13:22 entry)
+
+Open threads, most active first:
+- ACTIVE: runtime-portability children under the generic-agent-runtime parent (todo.org ~line 231). Done: hook parity, MCP portability, memory story — all three now dated entries pointing at docs/design/2026-07-13-runtime-portability-inventories.org. AWAITING CRAIG: the *** VERIFY with four decisions (PreCompact prose downgrade; Stop-teardown via Codex notify/manual; Signal-pager portability note; knowledge-base.md capture-layer sentence) — all recommended yes, he was about to answer when the pivot hit. Next child queued: skill parity (decision matrix over voice/review-code/flush etc.: re-register per harness vs fold into .ai/workflows/), then instruction bootstrap, launcher flag, session plumbing, local model floor.
+- PINNED (from 13:22, still queued): org-table helpers bug ([#B] :bug:solo:, todo.org ~line 66) + the todo.org "→id" dead-link rider from lint-followups.org.
+- PINNED: task review, 6 of 7 unwalked; they stay at the head of the next batch.
+- SET ASIDE: sixel/EAT — .emacs.d has the patch filed [#B] with the durable-home design call coming to Craig; after it lands + velox pulls dotfiles and .emacs.d, run the velox test (client_cell_width nonzero, then magick sixel in a tmux window, Craig's eyes).
+
+Pending decisions: the four VERIFY items above. Nothing else blocks.
+
+Shipped since the 13:22 suspend (all pushed): 828f70a..1b638e6 (SVG rule cac1aa1, visuals rule 8703150, model d5bc9b3, todo folds dc13bce, child-task filing 1b638e6) and e91073d (model fix — d5bc9b3 had committed opus→Opus from a harness rewrite, not the fable flip; e91073d lands fable as Craig chose).
+
+Uncommitted work: todo.org (three children converted to dated entries + the new *** VERIFY), docs/design/2026-07-13-runtime-portability-inventories.org (new file, untracked), this anchor. Commit shape when resumed: docs+todo together as the inventories chore/feat commit.
+
+Key findings not recorded elsewhere: none — everything is in the inventories doc, the todo entries, or this log. Inbox is clear (the 13:23 .emacs.d ack was read and deleted).
+
+Background work: none running.
+
+Resume hint: read the *** VERIFY under the generic-agent-runtime parent, get Craig's four answers, apply the two tiny approved edits (Signal-pager note + knowledge-base.md sentence), commit todo.org + the inventories doc, then start the skill-parity matrix.
+
+** 2026-07-13 Mon @ 13:22:43 -0500 — SUSPENDED
+
+Craig stuck a pin in everything mid task-review walk. Resume-weighted state:
+
+Open threads, most active first:
+- PINNED: org-table helpers bug ([#B] :bug:solo:, todo.org line 66). Craig queued it as the next work item ("2 then 1" — review done-ish, this is the 1). Fully specified in the task body: two defects (line-based table detection mangles example/src blocks; lint-org.el writes without --fix), repro from work 2026-07-09, regression test named. TDD it: canonical scripts live in claude-templates/.ai/scripts/, mirror-synced; tests are ERT via make test. Start here.
+- PINNED: task review, stopped after 1 of 7. Batch was the staleness --list top 7; task 1 (build-to-prototype [#B]) kept + stamped 2026-07-13. Items 2-7 (roam-only startup, WIP sync gate, install-ai PATH, org-table bug, Signal pager, KB orphan pass) unreviewed — they stay at the head of the next run's batch automatically.
+- SET ASIDE: sixel/EAT thread — rulesets + dotfiles halves are DONE and shipped; waiting on Craig to fast-track the eat-xtwinops.patch handoff sitting in .emacs.d/inbox/ (2026-07-13-1058-from-rulesets-*). After .emacs.d lands it and velox pulls dotfiles + .emacs.d, run the velox test: restart EAT/tmux server on velox, tmux display -p '#{client_cell_width}' nonzero, then magick logo: sixel:- into a tmux window, Craig eyeballs. Note ratio's working state is runtime-only until the .emacs.d patch lands (Emacs restart loses it; tmux server restart loses the feature flags — the dotfiles conf now covers that half).
+- DEFERRED: lint-followups.org rider — dead file link at todo.org (was line 1314 pre-edit; re-grep for "→id"), promised as a rider on the next work item.
+- POST-SUSPEND ADDITION (13:27): Craig asked for the ChatGPT/local-LLM gap assessment to be filed. Found the existing parent (** TODO [#D] Generic agent runtime support — Codex spec v0) and added 8 child tasks under it (instruction bootstrap, skill parity, hook parity, launcher flag, session plumbing, memory story, MCP portability, local model floor) plus a dated decomposition entry. todo.org edit uncommitted, riding with the review stamp.
+
+Pending decisions / open questions for Craig: none blocking. (Model flip resolved: keep fable, committed.)
+
+Shipped this session (all local, UNPUSHED except dotfiles):
+- rulesets cac1aa1 — SVG-first rendering rule (emacs.md + ui-prototyping.md cross-link), jotto proposal accepted, reply sent.
+- rulesets 8703150 — Showing Craig Visuals rule (interaction.md) + zsh word-split note (protocols.org canonical+mirror), takuzu proposal accepted with corrections, reply sent.
+- rulesets d5bc9b3 — model opus→fable (Craig: keep). rulesets dc13bce — todo.org Signal-pager fold.
+- dotfiles 5055ef1 — tmux terminal-features sixel lines, COMMITTED AND PUSHED to origin; inbox handoff there rewritten as applied-FYI.
+- Handoffs delivered: jotto (acceptance), takuzu (acceptance + capability correction), emacsd (eat-xtwinops.patch + intro), dotfiles (superseded by direct apply).
+
+Uncommitted work: todo.org (the :LAST_REVIEWED: 2026-07-13 stamp on the build-to-prototype task — one drawer line, safe to commit with the next housekeeping/wrap). .ai/session-context.org is this live anchor (untracked, stays).
+
+Key findings not recorded elsewhere: none — the sixel investigation's durable facts live in the two rule commits, the emacsd/dotfiles handoffs, and this log. The four unpushed rulesets commits are the main crash exposure; push happens at wrap.
+
+Background work: none running. tmux sixel test windows killed; *sixel-probe* buffer killed; tmux server carries runtime-only settings (terminal-features sixel entries, allow-passthrough all) that are now redundant with the pushed dotfiles conf (passthrough) or pending the .emacs.d patch (features).
+
+Resume hint: start the org-table helpers bug via TDD (regression test first: a #+begin_example block of pipe-prefixed lines must survive wrap-org-table.el byte-identical), and fold in the todo.org:1314 link fix as the rider.
+
+** 2026-07-13 Mon 05:14 — Startup + inbox processing begins
+
+Ran startup: rulesets pull skipped (dirty tree — .claude/settings.json shows model opus→fable, same harness flip Craig reverted on 2026-07-11), nothing new to link, no branches behind, .ai/ synced from templates. No crashed session. Notes.org: no reminders, no pending decisions. Staleness: 18 top-level tasks unreviewed >7 days. Roam inbox: 8 items, none rulesets-owned (4 takuzu, 3 archsetup, 1 emacs.d).
+
+Inbox had 3 handoffs. Processed the home FYI (2026-07-11-1208, Signal pager ack): folded as a dated sub-entry into the [#C] "Document (and own) the Signal pager" task body — home confirms rulesets owns it, and reports signal-mcp wasn't connected in its 2026-07-09 session and its signal-cli is registered as Craig's own number (note-to-self pushes nothing), so home currently has no live page channel. Deleted the inbox file. No reply sent — the handoff was itself home's ack of our earlier reply; nothing to close.
+
+Remaining: two shared-asset proposals (jotto SVG-rendering rule, takuzu EAT-image-display rule) — skeptical reviews done, surfacing to Craig for approval next.
+
+** 2026-07-13 Mon 05:25 — jotto SVG rule shipped
+
+Craig approved option 1 (accept with changes). Added the SVG Rendering section to claude-rules/emacs.md (consider-by-default phrasing, full constraint sheet, hybrid guidance verbatim) and the svg.el port-target paragraph to claude-rules/ui-prototyping.md section 3. /review-code --staged: approve, one Minor wording nit self-fixed (1:1 repetition). make test green (273 ok, ERT clean — the 9 grep hits were test names containing "error"). Commit cac1aa1 as Craig. Replied to jotto via inbox-send.py (landed in jotto's inbox), deleted the inbox file. Note: bare inbox-send isn't on PATH in this shell — used .ai/scripts/inbox-send.py directly. Settings.json model flip still undecided (Craig hasn't answered keep-or-revert). Next: takuzu EAT-image proposal.
+
+** 2026-07-13 Mon 09:40 — EAT sixel capability verified live (takuzu claim corrected)
+
+Craig challenged takuzu's "EAT can't display images" claim; verified it's wrong on capability. Findings: EAT 0.9.4 fully implements Sixel — advertises it in DA (\e[?12;4c, feature 4 = sixel), answers XTSMGRAPHICS, renders via eat-sixel-render-formats (xpm first; daemon has xpm+svg). Live test: magick logo → sixel, wrapped in tmux passthrough DCS (ESC Ptmux; with doubled ESCs), cat from a VISIBLE pane → full-color 640px image rendered in the EAT buffer (Craig's screenshot 09:36:42). Caveats found: (1) image is a transient screen-anchored overlay — any scroll/redraw drops it; (2) tmux allow-passthrough "on" drops passthrough from invisible panes — set allow-passthrough "all" globally at RUNTIME ONLY (needs tmux.conf for persistence, both daily drivers); (3) img2sixel/libsixel 1.10.5 on ratio is broken — exit 0, zero bytes, all formats; use magick ... sixel: instead; (4) writes into the Claude Code TUI pane get instantly repainted over — draw in a separate visible pane. Test window killed, cleanup done. Next: re-present takuzu disposition with corrected facts.
+
+** 2026-07-13 Mon 09:55 — Root cause of sixel vanishing: tmux grid, not EAT
+
+Craig asked why the image is irretrievable once offscreen. Isolated it: created a background plain-EAT buffer (*sixel-probe*, eat-make, no window shown) in the daemon, fed it the RAW sixel (no tmux), scrolled 60 lines, re-scanned: all 60 image cells persist at pos 163, before display-begin 654 — EAT keeps sixel in scrollback natively (each cell is a buffer char with an XPM-tile display property, per eat--t-sixel-flush-line). So tmux is the killer: passthrough bypasses tmux's grid, tmux's model of those lines is text-only, and every repaint (scroll/window-switch/output) rewrites them, destroying EAT's image cells. Nothing upstream stores the pixels → irretrievable. Follow-up lead: tmux 3.7b binary has sixel compiled in, but client_termname is xterm-256color (TERM override somewhere in the launch chain — EAT would normally set eat-truecolor), whose terminfo doesn't advertise sixel, so tmux's native sixel path never engages. Native path would need terminal-features 'xterm*:sixel' + client reattach to test. *sixel-probe* buffer left alive so Craig can see the scrollback image; kill at wrap.
+
+** 2026-07-13 Mon 11:00 — EAT XTWINOPS spike: full native-sixel fix, verified, handed off
+
+Craig chose the spike (option 1). Found the final gate in tmux source (cloned 3.7b shallow to scratchpad): tty_cmd_sixelimage falls back to the text placeholder when TERM_SIXEL is unset OR tty xpixel/ypixel are 0. First was cleared by terminal-features 'xterm*:sixel'; second was the blocker — Emacs never fills pty pixel fields, and tmux's fallback query (CSI 18t + CSI 14t at attach) is silently swallowed by EAT (no 't' case in its CSI dispatch; confirmed client_cell_width 0x0). Spike: copied eat.el to scratchpad, added eat--t-send-window-size-report (answers CSI 14/16/18 t from char-width/height + disp dims, mirroring the XTSMGRAPHICS reply) + one dispatch case. Byte-compiled clean, loaded into the live daemon (survived — this session runs in EAT), probe confirmed replies (R14 4;576;880, R16 6;24;11, R18 8;24;80). After Craig's reattach: client_cell_width 11x24, and the invisible-window grid-redraw test PASSED — wizard image drawn from tmux's grid, survives window switches, scrolling, AND resizing (Craig verified visually). Note: first probe buffer vanished mid-session (unexplained; daemon uptime 1.5d rules out restart), recreated fine. img2sixel (libsixel 1.10.5) broken on ratio — magick sixel: is the working encoder. Velox verified over tailscale: same sixel-enabled tmux 3.7b + imagemagick — ready once configs land. Handoffs sent: emacsd got the eat-xtwinops.patch + intro (durable home, TDD productionization, upstream to codeberg akib/emacs-eat); dotfiles got the two tmux.conf lines. Everything on ratio is RUNTIME-ONLY until those land (Emacs restart loses the patch, tmux server restart loses the flags). Remaining: takuzu disposition (rule now reflects working persistent sixel), model-flip question, cleanup (sixel-final window, *sixel-probe* buffer).
+
+** 2026-07-13 Mon 12:00 — takuzu rule shipped, dotfiles committed + pushed
+
+Craig approved option 1 and directed the dotfiles commit+push. Applied: (1) rulesets 8703150 — Showing Craig Visuals section in interaction.md (never rely on SendUserFile display; browser/imv lane per desktop-capture.md; sixel quick-glance lane behind the client_cell_width capability gate) + zsh no-word-split note in protocols.org canonical + mirror (sync-check --fix). Suite green 423/0. Corrective acceptance reply sent to takuzu (its capability claim corrected with the CSI 14 t findings); inbox file deleted — local inbox now clear. (2) dotfiles 5055ef1 committed as Craig and pushed to origin (cjennings.net): two terminal-features sixel lines in common/.tmux.conf (allow-passthrough already on); dotfiles suite 10/10 OK; the pending rulesets handoff in dotfiles/inbox rewritten as an already-applied FYI. Cleanup done earlier this turn-series: sixel-final tmux window killed, *sixel-probe* buffer killed. Plan: Craig fast-tracks the emacsd patch, then a velox end-to-end test after velox pulls both repos. Still open: model flip (settings.json, unanswered), todo.org Signal-pager fold uncommitted.
+
+** 2026-07-13 Mon 13:35 — resumed: runtime-portability work begun, three inventories done
+
+Craig pulled the pin ("commit and push that, then let's work on all those remaining tasks"). Pushed 828f70a..1b638e6 (5 commits incl. the child-task filing), then found and fixed a defect: d5bc9b3 had committed opus→Opus (harness rewrote the file to "Opus" before staging), NOT the fable flip Craig chose — corrected + pushed as e91073d. Mid-work inbox arrival: .emacs.d ack of the EAT patch (filed [#B] there, design call going to Craig) — pure FYI, deleted. Then the three inventory children: wrote docs/design/2026-07-13-runtime-portability-inventories.org (hooks: only PreCompact + Stop carry porting work, AskUserQuestion moot, validators ride githooks; MCP: nine local servers portable, signal-mcp is claude.ai-side only so paging has NO off-Claude path — Signal-pager task is the fix; memory: KB already cross-agent, one sentence gap in knowledge-base.md). Converted the three children to dated entries, added a *** VERIFY carrying the four decisions (all recommended yes). todo.org + new doc uncommitted. Next children: skill parity (decision matrix), instruction bootstrap, launcher flag, session plumbing, local model floor.
+
+** 2026-07-13 Mon 12:15 — task review stopped early at Craig's call
+
+Craig picked "2 then 1" (task review, then org-table bug). Review batch was the 7 oldest-unreviewed; task 1 (build-to-prototype rule extension [#B]) kept as-is and stamped :LAST_REVIEWED: 2026-07-13 (assessed not-quick, not-solo — placement decision pending). Craig then said "let's stop here" — review closed early, 6 tasks untouched for the next run. The org-table bug (queued item 1) not started. todo.org edit uncommitted; ambiguity noted: "stop here" might mean stop-review or end-session — asked Craig which.
+
+** 2026-07-13 Mon 12:05 — model flip kept, housekeeping committed
+
+Craig chose to keep the fable model flip (deliberate this time, unlike the 2026-07-11 revert). Committed d5bc9b3 (settings model opus→fable) and dc13bce (todo.org Signal-pager fold). Both mechanical, subject-only messages; the 11:45 green suite run covers this tree state. Working tree now clean except the live session-context file. Rulesets has 4 unpushed commits (cac1aa1, 8703150, d5bc9b3, dc13bce); push at wrap per usual flow. Session remaining: nothing pending from the inbox; awaiting Craig's next move (emacsd fast-track, then velox test).
diff --git a/.ai/sessions/2026-07-14-02-50-sentry-spec-review-lint-org-fix.org b/.ai/sessions/2026-07-14-02-50-sentry-spec-review-lint-org-fix.org
new file mode 100644
index 0000000..280ab5b
--- /dev/null
+++ b/.ai/sessions/2026-07-14-02-50-sentry-spec-review-lint-org-fix.org
@@ -0,0 +1,84 @@
+#+TITLE: Session Context — 2026-07-14
+#+DATE: 2026-07-14
+
+* Summary
+
+** Active Goal
+
+Take the work project's sentry proposal (one supervisor loop running a project's hygiene passes overnight, with locking) from inbox arrival to a fully-dispositioned spec: decision walk, spec-create, adversarial review, findings walk. Mid-walk, fix the lint-org data-corruption bug the review surfaced as a sentry prerequisite. Plus two inbox waves (8 handoffs total) processed along the way.
+
+** Decisions
+
+- Sentry design, all resolved live with Craig (recorded as 10 DONE decisions + 12 DONE findings in the spec): host-suffixed sentry branch (Craig's call — unpushed commits on main diverge across two daily drivers); host-local locks under /run/user/<uid>/agent-locks/ (a lock inside the roam repo would ride roam-sync's git add -A to the other machine); mkdir-atomic lock helper with heartbeat refresh + bounded-wait-then-defer (flock can't span tool calls); interactive entry gates, NO report-only mode anywhere (Craig's explicit direction — passes run fully or skip); roam-sync stays the roam repo's only committer; :COMMIT_AUTONOMY: yes gates sentry entirely; detection-over-configuration pass portability; launch contract with in-place checkout (worktrees rejected: untracked inbox drops invisible there); inbox pass under the no-approvals contract; suite at entry + conditional fire-end run only; KB promotion pass cut to vNext (heuristic task filed); wrap-up refuses during a live loop, "stop sentry" owns shutdown; entry ff-only reconcile; persistent stall notify after 2 skips.
+- Craig corrected a misread mid-turn: the .emacs.d signel request meant "remove signel mentions from live workflows," not "run a broadcast + question smoke's scope." No broadcast ran; smoke's scope stands; nothing had been sent.
+- Spec stays DRAFT deliberately — Craig reviews deeply before the READY flip.
+
+** Data Collected / Findings
+
+- The 2026-07-09 org-file corruption's true mechanism: wrap-org-table.el's load-time CLI dispatch fired when lint-org.el merely required it, running the table reformatter over lint-org's file arguments (--check runs were immune because the flag made wot's dispatch decline). The filed defects (block-blind scanning, mutate-by-default) were real but secondary.
+- flock binds to a living process; agent Bash calls are short-lived shells, so locks for agent workflows need mkdir-atomicity + staleness semantics, never flock.
+- rg skips hidden directories by default — a grep over .ai/ paths silently misses everything without --hidden (bit this session during the signel audit).
+- No locking existed anywhere in .ai/scripts/ (verified); the roam sync-conflict forks are the observed cost.
+
+** Files Modified
+
+- docs/specs/2026-07-14-sentry-workflow-spec.org — NEW: the sentry spec, DRAFT, 10/10 decisions, 12/12 findings resolved, voice-passed. docs/design/2026-07-14-sentry-workflow-proposal.org — NEW: preserved origin proposal + cover note.
+- Commit 951b6fc — lint-org/wrap-org-table corruption fix, test-first (6 new tests): block-type-aware scanning, lint-org CLI report-only by default (--fix required; wrap-it-up + /lint-org command updated), wot entry-script dispatch guard. Commit 32afa41 — work's .flac threading adopted into process-meeting-transcript.org. Plus the signel history-line chore. All pushed (ad5bb07).
+- todo.org — sentry build task [#B] + cross-host [#D] + /schedule [#D] + KB-heuristic [#D] filed; org-table bug closed DONE and archived to Resolved.
+- notes.org — :LAST_INBOX_PROCESS: 2026-07-14; Active Reminder: Craig reviews the sentry spec before ending the next session.
+
+** Next Steps
+
+- FIRST: Craig's deep read of the sentry spec (his explicit ask — surface the reminder at startup). READY flip, then spec-response decomposes the build.
+- The paused task review: batch of 7 selected, task 1 (sentry build) walked but no action picked; 6 untouched. The sentry build task is also the one 30-day-staleness hit (never-reviewed, filed tonight) — the next review cycle covers it.
+- Sentry build follows the spec's 4 phases once READY (agent-lock helper first).
+
+KB: promoted 2 / consulted no
+
+* Session Log
+
+** Startup + inbox processing
+
+Session started 2026-07-14 00:11 CDT on ratio. Fresh session, templates synced, no reminders, staleness 0, roam inbox 0.
+
+Inbox: three items. (1+2) Sentry workflow proposal from work (intro + full proposal) — passed value gate (Q2), skeptical review found: real problem (verified no flock anywhere in .ai/scripts/), sound invariants, but four issues: roam lock held across commit-push conflicts with inbox.org's edit-then-roam-sync discipline; flock can't span short-lived tool-call shells (needs PID/staleness lockfile or mkdir semantics); unattended per-pass commits need gating on :COMMIT_AUTONOMY:; passes 3 and 10 are sender-specific (need auto-skip/config). Craig picked option 2: work it now, walk decisions, spec/build tonight. Then a task review. (3) archsetup FYI (maint update-queue CVE item done, dotfiles 6757b51) — acknowledged, deleted. Craig also directed deleting the standing inbox/lint-followups.org.
+
+Proposal preserved to docs/design/2026-07-14-sentry-workflow-proposal.org (cover note merged in). Reply sent to work: accepted, being worked now.
+
+** Sentry decision walk (all decided with Craig, one by one)
+
+- D1 commit target: dedicated sentry branch, host-suffixed (sentry/YYYY-MM-DD-<host>). Craig's call, overriding my current-branch rec: unpushed commits on main across two daily drivers = diverged main, breaks startup ff + template sync. Morning flow: review, squash-merge, delete branch. Prior unmerged sentry branch at entry → skip fire.
+- D2 roam-write lock scope: host-local only; cross-host conflict-surfacing filed as follow-up.
+- D3 interval: hourly default, config knob.
+- D4 gates: interactive at entry (Craig is at terminal when he launches) — dirty tree stops and surfaces what's dirty + options (finish the job / stash / rollback which changes); red suite same shape. NO report-only mode anywhere (Craig's explicit direction): unattended fire hitting unsafe state skips with one digest line; next fire retries. Morning-approval queue for destructive/judgment actions stays (permanent division of labor, not a degraded mode).
+- D5 roam git: roam-sync stays the only committer; lock wraps edit + trigger. Reconciliation surfaced: knowledge-base.md + roam-sync.sh header still say agents pull/commit/push inline — join companion-file list.
+- D6 locks: shared .ai/scripts/ lock helper, mkdir atomicity, PID/host/timestamp metadata, age-based staleness reclaim, acquire/release/status, bats-tested. (flock can't span tool calls.)
+- D7 autonomy: :COMMIT_AUTONOMY: yes gates sentry entirely — absent/no → declines to start, names the marker.
+- D8 portability: auto-skip by detection (existence probes per pass), no config.
+- D9 path: spec-first (spec-create → docs/specs/2026-07-14-sentry-workflow-spec.org).
+
+** Sentry spec authored
+
+Wrote docs/specs/2026-07-14-sentry-workflow-spec.org (DRAFT, ID f6c51f27-d7a2-4b63-9ff9-5ba005a66dfb): full spine — summary, problem, goals/non-goals/scope, two-altitude design (user + implementer), 5 alternatives with why-nots, 9 DONE decisions, 4 implementation phases, acceptance criteria, readiness dimensions, risks. Voice pass ran (/voice general per spec-create Phase 4): ~20 em-dashes reduced to structural-only, 2 prose fragments completed, passives activated, one correctness catch (design said "never pulls" roam but pass 1 is an ff-only pull — reconciled). Self-rubric: Ready. Cross-linked: todo.org gets the [#B] build task + two [#D] vNext deferrals (cross-host conflict surfacing, unattended /schedule variant). Next: spec-review gate, then Craig's queued task review.
+
+** Adversarial spec review (Craig's weakness-loop method)
+
+Task review started (batch of 7, task 1 = sentry build) but Craig redirected: run the spec through review now via a weakness loop — 20 weaknesses, check each against the spec, loop until <5 new, then notify him for deep review. Ran 3 rounds (20 → 7 → <5). Recorded 10 blocking + 2 non-blocking findings in the spec's Review findings section. Heavy hitters: overnight working-tree ownership (sentry checkout vs Craig's live session — biggest open design question); roam-write lock is sentry-only unless inbox.org/knowledge-base.md writers also acquire it; lock dir inside roam repo would be committed by roam-sync's git add -A (relocate to /run/user); sentry's session-context writes trip its own dirty-tree skip; pass 8 depends on the filed lint-org mutate-on-lint [#B] bug; unattended inbox semantics; per-pass commits vs verification.md suite rule (needs a recorded decision); KB lesson source undefined; lock wait/heartbeat mechanics; wrap-up-during-loop undefined. Rubric: Not ready; status stays DRAFT. Iteration history entry added. Craig notified for his deeper review. Task review batch (7 tasks) still pending — resumes after.
+
+** Findings walk + lint-org fix shipped mid-walk
+
+Walking the 12 findings with Craig one by one. Decisions so far: F1 = launch contract (in-place checkout, sentry owns the repo overnight, dirty-skip backstop, Emacs revert caveat documented). F2 = all roam writers acquire the lock (inbox.org core §5 + knowledge-base.md write recipe gain acquire/release; graceful degradation when helper absent; bounded-wait-then-surface for interactive callers). F3 = both locks under /run/user/<uid>/agent-locks/, helper owns path scheme, ~/.cache fallback. F4 = spine-set exclusion in dirty check + fire-end digest commit + path via session-context-path.
+
+At F5 Craig redirected: fix the lint-org bug now. Done, TDD (red shown first): block-type-aware scanning in wrap-org-table.el wot-process-file + lint-org.el lo--check-tables (type-matched end marker so literal inner markers can't re-expose a block); lint-org CLI report-only by default, writes behind --fix (--check kept as alias; wrap-it-up + /lint-org command updated to pass --fix). Root cause discovered during red-phase debugging: wrap-org-table's load-time CLI dispatch fired on lint-org's require and ran the reformatter over lint-org's file args — THE 2026-07-09 corruption mechanism (--check runs were immune because the flag made wot's dispatch decline). Entry-script guard added (dispatch only when -l names wrap-org-table.el itself). /review-code --staged ran: one Important (boolean in-block flag cleared by literal inner end-marker) fixed in-review + regression test; verdict Approve. Suite 0 failures. Committed 951b6fc (unpushed). todo.org bug task closed DONE; spec finding F5 completed DONE. Minor follow-up noted (not filed): lo--check-indented-headings still has the naive boolean flag, benign (suppression-only).
+
+Second archsetup FYI (tailscale-amber fixed, dotfiles 4840463 + archsetup aed151b, velox TOML hand-refresh is archsetup's item) acknowledged + deleted. Inbox clean.
+
+Next: resume walk at F6 (unattended inbox semantics), then F7-F12, then Craig's deep spec review, then the paused task review (task 1 of 7 pending — sentry build task, now also needs the F5 resolution reflected... its body references the spec; fine as-is).
+
+** Findings walk completed — all 12 dispositioned, spec updated
+
+F6 = inbox pass under the no-approvals contract, everything to the morning queue. F7 = suite policy recorded as Decision 10 (entry run + conditional fire-end run when non-org files changed, no per-pass runs). F8 = KB promotion pass CUT to vNext (Craig), lesson-detection-heuristic task filed [#D] :spec: (blocks re-adding). F9 = bounded-wait-then-defer + heartbeat refresh, reclaims surfaced. F10 = wrap-up REFUSES during live sentry (Craig picked simple over shutdown-pre-step); "stop sentry" operation owns loop-cancel + branch/queue disposition. F11 = entry ff-only reconcile, diverged joins interactive gate. F12 = persistent notify after 2nd consecutive unmerged-branch skip, then at most daily. All 12 findings flipped DONE in place; design/scope/decisions/acceptance/risks updated to match (pass list now 10, Decisions [10/10], locks at /run/user/<uid>/agent-locks/, launch-contract paragraph in user altitude). Status stays DRAFT for Craig's deep read; responder entry added to iteration history.
+
+** Second inbox wave (5 handoffs) + Craig's broadcast correction
+
+Two more archsetup FYI closures (memory-killer = live-refresh hairline artifact, filed [#C] their side; net-panel speedtest history filed [#C] their side) deleted. work's process-meeting-transcript .flac update: diff-verified verbatim (12 hunks, pure .flac threading), adopted into canonical, committed 32afa41, confirmation sent to work. .emacs.d's signel-deprecation broadcast request: I misread it as run-a-broadcast + flag smoke's scope; Craig corrected mid-turn — NO broadcast, smoke's scope is well defined and needed, and the actual ask was removing signel mentions from live workflows. Broadcast draft deleted from /tmp before anything sent; nothing reached smoke. Grep gotcha: rg skips hidden dirs by default, so .ai/ paths need --hidden. One live-workflow mention found (triage-intake.org:417 incident history line) — rewritten to keep the lesson, drop the client name (commit next to 32afa41: chore(workflows): drop retired Signal-client name). Session archives + frozen design doc keep theirs as history. Reply sent to .emacs.d. Inbox clean (0 pending).
diff --git a/.ai/sessions/2026-07-17-10-55-inbox-fixes-emacs-wayland-sentry-prep.org b/.ai/sessions/2026-07-17-10-55-inbox-fixes-emacs-wayland-sentry-prep.org
new file mode 100644
index 0000000..da2ca40
--- /dev/null
+++ b/.ai/sessions/2026-07-17-10-55-inbox-fixes-emacs-wayland-sentry-prep.org
@@ -0,0 +1,238 @@
+#+TITLE: Session Context
+#+AUTHOR: Craig Jennings
+#+DATE: 2026-07-16
+
+* Summary
+
+** Active Goal
+
+Process the project inbox (5 handoffs across the session), then remove a dormant clone, switch ratio's Emacs to the pgtk build, and prep the sentry spec for Craig's READY-gate review. Two of the five handoffs were bug fixes that shipped; two were design questions filed for a Monday scouting pass; one was rejected on evidence.
+
+** Decisions
+
+- *gmail residue-probe fix (home):* took home's one-word epoch fix, but hoisted the rule to cover both anchored queries once and scoped it to the anchor windows so the deliberate day-resolution date-slicing isn't implicated. Dropped home's proposed count-check — it depends on resultSizeEstimate + a 100-cap the same file documents as untrustworthy.
+- *secret-scan fix (takuzu):* accepted their case-sensitivity split (measured: ~6% false-positive on 100KB base64 → 0), rejected the =;base64,= line-skip (guards a ~1-in-1.7M event, costs a real blind spot on minified data-URI lines — reproduced). All three variants (elisp/bash/go).
+- *subproject promotion (home):* deferred, not rejected. N=1 (home is the only project with subprojects across 27 scopes); promoting 282 lines into the always-on rules layer contradicts the proposal's own thin-always-on principle. Scheduled 2026-07-20 for a scouting pass.
+- *polyglot bundle collision (home):* fixed the silent half (install-lang guard refuses a colliding second bundle), deferred the design half. Reframed: the guard is NOT "polyglot unsupported" — non-overlapping pairs (bash+python) compose today. The line is overlap-vs-not, and nobody chose it. Scheduled 2026-07-20, paired with subprojects (same scouting question).
+- *wl-copy proposal (clock-panel):* REJECTED on evidence. Their diagnosis was inverted — plain =wl-copy= persists (while its process lives); =--foreground= is what disables the fork. Failed review Q1 (is it right), not the value gate.
+- *settings.json model:* fable → opus, Craig's call (bd76d98). Reverses the deliberate 07-13 fable pin; the field has flip-flopped 4x. =~/.claude/settings.json= symlinks here, so machine-wide.
+- *gloss:* removed the local clone (Craig's direction), remote deliberately left alive. Verified fully pushed before deleting.
+- *ratio Emacs:* installed =emacs-wayland= (pgtk), restoring archsetup's own intent (line 2605, unconditional since 2026-04-11); ratio had drifted for ~3 months. Craig restarted; verified pgtk/native-Wayland.
+
+** Data Collected / Findings
+
+- *gloss:* 24 commits all pushed, live bare remote at 7073b16; local-only =.ai/=/=todo.org=/=inbox/= all regenerable or moot. Safe to delete.
+- *Emacs on ratio was the X11 build* (=extra/emacs=, =:pgtk nil :x t=), on XWayland via DISPLAY=:0 while WAYLAND_DISPLAY sat unused — why xclip worked despite the pure-Wayland rule. archsetup line 2605 says =emacs-wayland=; ratio drifted. archsetup's package tracking is a before/after run diff (=comm -13=, "Statistics"), never an intent-vs-reality audit — the class of drift that hid this.
+- *=emacs.service= failed since 2026-07-09* (start-limit-hit, 5x exit-1, cause undiagnosed — no Emacs stderr in journal). Live daemon is a bare =emacs --daemon= PPID 1, unsupervised. Unit is dotfiles-owned (stow symlink into =~/.dotfiles=, its own repo/scope), package is archsetup's.
+- *I broke Craig's clipboard* testing clock-panel's claim — killed wl-copy owners repeatedly; each kill emptied the clipboard (owner holds the selection only while alive). Restored via Emacs. This corroborated clock-panel's symptom while disproving their mechanism.
+- *sentry spec bookkeeping was broken:* Decision 10 (suite policy) was misfiled under Review findings; Decisions cookie hand-typed [10/10], findings cookie dead =[/]=. Moved it, let org recompute → [10/10] and [12/12], both live. Zero new lint findings vs HEAD.
+- *pkill -f self-matches the shell running it* (exit 144, twice) — promoted to KB.
+
+** Files Modified
+
+- Commits (all pushed to origin/main): cf3eadc (gmail epoch fix + mirror), 794a8bd (secret-scan case-split, 3 variants + 9 bats), c98fda5 (install-lang collision guard + 9 bats + report preserved), the subproject-deferral docs commit, bd76d98 (opus model), 0cc1256 (sentry spec cookie fix).
+- =todo.org=: 2 tasks filed [#C] :spec: SCHEDULED 2026-07-20 (subproject promotion, polyglot support), both carrying the review findings as decision inputs.
+- =docs/design/=: 3 files preserved (subproject proposal + home instance, polyglot collision report).
+- KB: =agents/20260717105401-pkill-f-self-match.org= written + pushed.
+- Replies sent: home ×3 (probe, subprojects, polyglot), takuzu ×1 (secret-scan), clock-panel ×1 (wl-copy reject), archsetup ×2 (emacs drift + package-audit follow-up). gloss local clone removed.
+
+** Next Steps
+
+- *FIRST (Craig's standing ask):* deep-read the sentry spec (=docs/specs/2026-07-14-sentry-workflow-spec.org=), now structurally clean. Flip DRAFT → READY, then spec-response decomposes the build. The load-bearing decision to weigh: launching sentry hands it the repo until the morning merge.
+- *2026-07-20 scouting pass:* subproject promotion + polyglot support, one conversation — "which projects would actually be polyglot / have subprojects, and why."
+- *Owed, unsent:* home's applicability line for github-prs + personal-calendar (residue probe N/A there — one query, no seam). Wording-only.
+- *Unread inbound:* clock-panel's wl-copy review correction (arrived 14:56) — deferred to next session, non-urgent.
+- *Open elsewhere:* velox unverified for the emacs-wayland drift (offline); =emacs.service= failure undiagnosed (dotfiles' + archsetup's, handed off); archsetup asked whether it wants an intent-vs-reality package audit.
+
+KB: promoted 1 / consulted yes
+
+* Session Log
+
+** 2026-07-16 09:11 CDT — Startup
+
+Ran the startup workflow. Phase A.0: rulesets pull skipped (dirty tree — =.claude/settings.json= carries an uncommitted =fable= → =opus= model flip against a committed =fable= pin Craig deliberately kept on 07-13). =make install= had nothing new to link. Project fetch/reconcile clean.
+
+Phase A: no crash anchor (previous session wrapped cleanly). =.ai/= synced from templates, no churn. Staleness zero, language-bundle silent, spec-sort and host-identity probes silent. Roam inbox: 1 item. KB: 87 =:agent:= nodes, no project matches, best-practices node path not resolved.
+
+Phase B: read the three most recent session summaries. Inbox carries 6 new files = 3 handoffs (home's triage-intake probe-gap fix + edited plugin; takuzu's pre-commit secret-scan false-positive fix + fixed hook; home's subproject-pattern promotion proposal + reference =subprojects.org=). All three are shared-asset proposals, so all three go through the inbox engine's skeptical review rather than acting silently.
+
+Craig picked: work the inbox in order (two bug fixes first, subproject proposal last).
+
+** 2026-07-16 10:44 CDT — Inbox item 1: home's gmail residue-probe gap (landed cf3eadc)
+
+Home's diagnosis verified against the canonical and accepted. The probe cut off at =before:<anchor-YYYY/MM/DD>= (day resolution) while the scan started at =after:<anchor-epoch>= (second resolution), and Gmail's =before:<date>= excludes the named day, so the anchor day belonged to neither query and each sweep advanced past it without revisiting. An evening anchor hid most of a day. Exactly the failure the probe was added 2026-07-08 to prevent.
+
+Skeptical review (cross-project battery) produced three findings beyond home's ask:
+
+1. *No engine change needed* — the plugin declares =ANCHOR: epoch= and the engine already computes + substitutes every format, so =before:<anchor-epoch>= works as-is. Verified in =triage-intake.org= (the ANCHOR contract at line 266).
+2. *Answered home's "do other plugins share this?" — no.* Checked all five: cmail and telegram are =ANCHOR: none= (state-based, no since-window, no second query to gap); github-prs and personal-calendar are =ANCHOR: iso8601= but carry no residue probe, so there's only one query. personal-gmail is the only source pairing an anchored scan with a residue probe.
+3. *Declined home's count-equality verification check.* It depends on =resultSizeEstimate= and a full unread count, and the same file documents at lines 28/31 that the estimate is unreliable (stuck at "201" across queries whose union exceeded 300) and the MCP caps at 100 with no =pageToken=. The check would have pointed the next reader at numbers the plugin already says not to trust.
+
+Applied home's one-word fix, plus the rule consolidation home themselves suggested: the epoch rule now states once that it governs *both* anchored queries, and the probe cross-references rather than restating. Critically, the rule had to be *scoped to the anchor windows* — the date-slice walk at line 30 uses =before:<oldest-full-day-seen>= at day resolution deliberately, safe because slices overlap and dedupe by message id. A blanket "always epoch" rule would have implicated it.
+
+=/review-code --staged= surfaced one Minor I fixed before committing: the two epoch examples were ~2 months apart (=after:1778856990=, =before:1784177122=) in a paragraph whose whole point is that both queries must meet at the same instant. Now both show =1784177122=.
+
+Canonical edited first, mirror synced via =scripts/sync-check.sh --fix=, both sides identical. =make test= green (374 pytest + 67 + 12 + ERT + bats, zero failures). Commit cf3eadc, *not yet pushed*.
+
+Replied to home: unblocked (they'd filed it =[#B] :infra:blocker:=), answered both their questions, named the two declines with reasons, and raised one thing for their eyes — github-prs and personal-calendar have an anchored scan and no residue probe at all, so they're blind to pre-anchor items with nothing to catch it. Different question from home's gap; may be fine for open-PR/calendar state.
+
+Deleted both inbox files for the item.
+
+*Observation, not filed:* =cross-project.md= shows bare =inbox-send <target>= invocations, but it's not on PATH — only =.ai/scripts/inbox-send.py= exists, and =claude-templates/bin/= carries just =agent-page= and =ai=. The prose names the script path correctly; the examples imply a command that doesn't resolve. Worth a decision (link it in =make install=, or fix the examples).
+
+** 2026-07-16 11:20 CDT — Inbox item 2: takuzu's pre-commit secret-scan fix (landed 794a8bd)
+
+takuzu reported the hook false-positiving on an embedded PNG sprite data URI, forcing =--no-verify=. They diagnosed two causes and sent a fixed elisp-variant copy. I accepted cause 1, *measured and rejected cause 2*.
+
+*Cause 1 (accepted, correct).* =grep -iE= applied case-insensitivity to the fixed-case AWS token, so =AKIA[0-9A-Z]{16}= matched any mixed-case 20-char run (the =[0-9A-Z]= class also goes case-insensitive under =-i=). Their fix splits fixed-case tokens (AKIA, sk-, PEM) into a case-sensitive pass, leaving only keyword=value patterns under =-i=.
+
+*Cause 2 (rejected on evidence).* Their claim: "even case-sensitive, a 100KB base64 blob can contain AKIA+16 uppercase by chance," justifying a skip of any line containing =;base64,=. I generated ~10MB of real random base64 (100 sprite-sized blobs) and measured:
+
+- case-insensitive (the shipped hook): *7 matches, 6 of 100 blobs would false-positive* (~6%) — this is the live bug.
+- case-sensitive (their fix): *0 matches.*
+- theoretical rate for the residual: ~1 in 1.7M per 100KB blob ((1/64)^4 x (36/64)^16 x 1e5 positions).
+
+So cause 1 is the entire bug; cause 2's justification is ~1e-6. And the skip *costs* something: it drops whole lines. I verified with a minified-bundle line carrying both a data URI and a live =api_key= — under their fix the line is skipped and the key sails through; without it, the key is caught. Minified JS/CSS is exactly where data URIs live and where a bundle is one enormous line, so the blind spot lands precisely where both can co-occur.
+
+Craig picked option 1: case-sensitivity split only, drop the skip, with tests.
+
+*TDD.* Wrote =scripts/tests/pre-commit-secret-scan.bats= first (9 cases across all three variants). Red confirmed: cases 5 and 6 failed on the unfixed hooks, reproducing the live failure. Case 7 (a real credential sharing a line with a data URI) is the guard against anyone re-adding the skip. Green after the fix. Verified the new bats file is discovered by the Makefile glob rather than assuming it (=verification.md='s "a passing gate can skip your new file").
+
+*Self-review caught one regression.* Splitting one grep into two meant a line matching both passes printed twice, reading as two separate leaks. Verified empirically, wrote a 10th test (red), fixed with =awk '!seen[$0]++]'= (green). Verdict was Request Changes on my own diff until that landed.
+
+Applied to all three canonical variants (=languages/{elisp,bash,go}/githooks/pre-commit=); scan block verified byte-identical across them by md5. =bash -n= clean, shellcheck clean modulo two *pre-existing* findings I confirmed against HEAD and left alone: SC2164 on the unguarded =cd "$REPO_ROOT"= at line 8 of elisp+go (bash has =|| exit 1=), and SC2016 on go's gofmt printf. =make test= exit 0, zero failures.
+
+Replied to takuzu with the measurement, the blind-spot repro, the honest caveat (I don't have their actual sprite — if it holds a genuine uppercase AKIA run, case-sensitivity alone won't clear it), credit that their two-pass plumbing is what landed, and the two pre-existing findings.
+
+*Follow-up candidates (not filed yet):* the SC2164 inconsistency across the three variants, and go's em-dash.
+
+*Two commits unpushed:* cf3eadc, 794a8bd.
+
+*Inbox grew mid-item:* two new handoffs from home arrived (polyglot-coverage note at 10:06, reply-probe at 11:14).
+
+** 2026-07-16 11:35 CDT — Pushed cf3eadc + 794a8bd
+
+Craig picked push-then-continue. Pre-push reconcile clean (0 behind, 2 ahead), one remote (=git@cjennings.net:rulesets.git=). Pushed f14dd87..794a8bd, verified upstream at 794a8bd, 0/0.
+
+** 2026-07-16 11:58 CDT — Inbox item 3: home's subproject-pattern promotion (deferred, filed [#C])
+
+home proposed promoting its subproject pattern into =claude-rules/=. I recommended a thin rule (~50 lines) over their 282-line doc; *Craig chose option 3 — defer entirely and scout first.*
+
+His framing, which is the task's actual content: scout which projects would plausibly get subprojects, and why, before shaping any rule. If he hasn't scouted by the time it comes up, offer to do it together — brainstorm candidates, explore the reasons behind each. That evidence decides drop-vs-adjust. Don't shape the rule before the scouting.
+
+*Review findings* (recorded in the task body so the decision has its inputs):
+
+- *N=1, verified.* Scanned all 27 =.ai= scopes: home is the only project with subprojects, all nine from the 2026-06-11 fold.
+- *The placement contradicts the proposal's own principle.* =claude-rules/*.md= loads into every session of every project — the always-on layer. home's doc argues that layer is "a tax paid whether or not it's relevant today" and depth belongs "one open away". At 282 lines it'd be the 3rd-largest rule and +11% on the layer (20 files, 2491 lines), so every unrelated project's session would carry a one-project convention.
+- *Precedent for the shape if it ever promotes:* =patterns.md= (29 lines, explicit "don't carry the catalog in context") and =docs-lifecycle.md= (75 lines, depth in a spec).
+- *Dangling reference:* the doc cites =claude-rules/git-hosting-privacy-model=; no such file (verified). Real content is protocols.org's gitignore-vs-track + public-reachability decision.
+- *Instance vs rule:* metrics, self-improvement log, kill criteria, rollout dates, adoption table are home's.
+
+Content preserved: both files moved to =docs/design/2026-07-15-subproject-pattern-proposal.org= and =docs/design/2026-07-15-subprojects-convention-home-instance.org=, linked from the task.
+
+*Route-candidate: deliberately NOT stamped.* =route_recommend.py= returned "home strong", but that's a false positive — it matched on "home" appearing throughout as the proposer. The work is rulesets' own (deciding rulesets' rules layer, scheduled for Craig here), so it's a local keeper and stays unstamped per core §3.
+
+Replied to home: deferred not rejected, the N=1 + placement reasoning, the two fix-regardless items (dangling ref, instance-vs-rule split), and an ask — if they have a view on which other projects would plausibly fold, that's exactly the scouting input. Also answered their probe reply: agreed on their github-prs/personal-calendar analysis (a gap needs two queries with a seam; those have one query and no seam), taking their suggested doc line to Craig with today's batch, and owned half the =:blocked:=/=:blocker:= mix-up (my reply took their tag at face value rather than checking it against the convention).
+
+*Still open:* home's polyglot bundle-collision note (the last inbox item), and home's suggested applicability line for github-prs + personal-calendar.
+
+** 2026-07-16 13:43 CDT — Inbox item 4: home's polyglot bundle collision (guard landed c98fda5)
+
+home reported that python + typescript both ship =coverage-makefile.txt=, so the second install prints =[skip]= and silently drops its fragment. *The investigation found two worse collisions home hadn't checked.*
+
+*The full collision map* (5 shared filenames, 3 real):
+
+| file | bundles | behavior |
+|------|---------|----------|
+| =gitignore-add.txt= | 5 | appended + deduped → *composes* ✓ |
+| =CLAUDE.md= | 3 | seed-only; its fallback comment shows multi-bundle was already considered ✓ |
+| =coverage-makefile.txt= | 4 (elisp,go,python,typescript) | visible =[skip]=, fragment dropped ✗ |
+| =claude/settings.json= | 3 (bash,elisp,go) | =cp -rT= *silent overwrite*, prints =[ok]= ✗✗ |
+| =githooks/*= | 3 (bash,elisp,go) | =cp -rT= *silent overwrite*, prints =[ok]= ✗✗ |
+
+*Reproduced* (elisp then bash into one project): settings.json flips validate-el.sh → validate-bash.sh; pre-commit loses check-parens; validate-el.sh left orphaned on disk; output prints =[ok] .claude/= and =[ok] githooks/=. So home's severity read was inverted — the =[skip]= they flagged at least announces itself, while these two claim success while removing quality gates. Same class as the 794a8bd secret-scan bug: a gate you believe you have and don't.
+
+*No live damage:* checked every project with a bundle; the colliding trio isn't doubled anywhere, and clock-panel (python+typescript) could only ever have hit the coverage fragment.
+
+*The fix* (Craig picked option 1: fix the silent half, defer the design half). Guard runs *before any write*, so a refusal can't half-install. Detection reuses =sync-language-bundle.sh='s rule fingerprint (project has bundle X iff one of X's rule files is in =.claude/rules/=) — no marker file, works on pre-guard installs. Refuses when the incoming bundle would overwrite a file another shipped bundle also ships, naming each file. =FORCE=1= still overrides and the message now says out loud that FORCE also re-seeds CLAUDE.md (home's catch).
+
+*TDD:* 9 bats cases, red-first. Notably *test 8 initially false-passed* — it asserted the message contained "FORCE=1" and "CLAUDE.md", both of which already appear in the pre-existing =[skip] CLAUDE.md already exists (use FORCE=1 to overwrite)= line, so it passed against an unguarded install. Tightened to assert the refusal fired first and to exclude =[skip]= lines.
+
+*Self-review caught a coverage gap.* The guard had an untested path: a *detected* bundle sharing *no* overwritten file (bash ships settings+githooks, python ships coverage only → zero overlap), which hits =[ -n "$files" ] && ...= under =set -euo pipefail=. Verified by hand that it works, then added test 7 because "worked when I ran it once" isn't coverage and a refactor to a plain =if= could turn it into a false refusal with nothing to catch it.
+
+*That gap changed the framing.* The guard is NOT "polyglot unsupported" — =bash= + =python= composes cleanly today and yields a real polyglot project with both rule sets. The line is *overlap-vs-not*, not polyglot-vs-not, and nobody chose it; it fell out of which bundle happens to ship what. Corrected the task body before filing so Monday's decision doesn't run on a wrong premise.
+
+Also caught a factual error in my own commit draft pre-voice: it credited takuzu for home's report.
+
+*Filed:* =[#C] Polyglot projects — supported, or refused? :spec:= SCHEDULED 2026-07-20, paired with the subproject task (same scouting question). Report preserved at =docs/design/2026-07-16-polyglot-bundle-collision.txt=.
+
+*Separate finding, unfiled:* gloss has =validate-el.sh= on disk but *no settings.json at all*, so its hook has never fired. Single bundle, so not this bug — a different gap.
+
+Replied to home with the full map, the inverted-severity finding, the overlap-vs-polyglot correction, and the gloss note. *Inbox is empty.* Pushed c98fda5.
+
+** 2026-07-16 14:05 CDT — Removed the gloss clone; committed the opus model switch
+
+*gloss removed* (=rm -rf /home/cjennings/code/gloss=) on Craig's direction. Verified before deleting rather than trusting the description: 24 commits all pushed (0 behind / 0 ahead after fetch), one branch, no stashes, no tags, clean tree, and =git ls-remote git@cjennings.net:gloss.git= confirmed a live bare repo at 7073b16. Code is fully recoverable by re-cloning.
+
+Local-only content assessed before the delete (=.ai/=, =todo.org=, =inbox/= are all gitignored there, so none of it was on the remote):
+- =.ai/= 1.5M but *zero* archived sessions and no live context; contents are template-synced from rulesets, so regenerable. Not a real loss.
+- =todo.org= 3 open tasks — all gloss-scoped (integration tests, gloss-core refactor, shakedown), so moot once gloss is gone.
+- =inbox/= one unprocessed handoff: rulesets' own 2026-06-12 priority-scheme ask. Also moot.
+
+Craig's "not a real work in progress" checked out (last commit 2026-05-07, no sessions). *Remote deliberately left alive* — he confirmed "leave the remote", so the project is still re-clonable; =rm -rf= on the clone is not retirement.
+
+*settings.json: fable → opus committed* (bd76d98, pushed). Craig's call. Investigated provenance rather than assuming: the file was last written *2026-07-14 15:32*, i.e. NOT this session and after the last archived session ended (07-14 02:50) — so my initial "the harness wrote it this session" read was wrong and I corrected it before reporting. The field has now flip-flopped four times (73835a2 opus→, d5bc9b3 →fable, e91073d "pin ... as intended", bd76d98 →opus). =~/.claude/settings.json= symlinks here, so it's the machine-wide default.
+
+*Process slip, owned:* I invoked the trivial-one-liner exception to skip =/voice personal= on bd76d98, then wrote a three-line body containing an em-dash. The body took it out of trivial territory; the pass should have run. Not force-pushing over punctuation, but the exception is for subject-only commits.
+
+*Correction Craig is owed on the earlier gloss finding:* I reported "gloss has validate-el.sh but no settings.json, so its hook never fired" and passed that to home. gloss is now deleted, so the note is moot — but the underlying question (does anything else have an orphaned hook?) stands unanswered for the remaining projects.
+
+*New handoff mid-work:* clock-panel sent a Wayland clipboard finding (=wl-copy= selection owner dies with the command). Its own src block arrived with literal =\n= sequences instead of newlines — the sender's formatting is mangled and worth flagging back.
+
+** 2026-07-16 14:30 CDT — Inbox item 5: clock-panel's wl-copy proposal (REJECTED on evidence)
+
+clock-panel proposed adding a =setsid zsh -c '... | exec wl-copy --foreground' &= form to the shared Wayland rules, claiming a plain =printf | wl-copy= "leaves the desktop clipboard empty once the command exits, because the wl-copy selection owner no longer survives."
+
+*Tested all three forms on Craig's machine* (backed up his clipboard first — it held "jotto" — and restored it after). Read back from a *separate* Bash call each time so the originating shell was gone:
+
+| form | result |
+|------|--------|
+| =printf %s "..." \vert wl-copy= (plain) | *works* — survived a 3s wait + unrelated commands; owner alive throughout |
+| their =setsid ... --foreground &= form | *works* — but only by re-creating the default behavior |
+| =wl-copy --foreground= alone, no setsid/=&= | *FAILS* — blocks until killed, clipboard empty after. Their exact symptom. |
+
+*The diagnosis is inverted.* =wl-copy --help=: "-f, --foreground Stay in the foreground instead of forking." wl-copy forks and persists *by default*; =--foreground= is what disables it. So =--foreground= is the *cause* of their symptom, not the cure. Their fix works only because =setsid= + =&= restore the daemonization plain wl-copy already does for free.
+
+*Failed the review, not the value gate.* Wayland clipboard guidance is in scope (gate Q2 passes). It fails review Q1 (is it right) — adopting it would ship a more complex incantation that lands back at default behavior, plus a false mechanism in the rules layer, which is what the next person reasons from.
+
+*The real kernel:* =--foreground= is a genuine trap; if reached for, it must be detached. Roughly the inverse of the proposal. Craig chose option 1 — hold even that line until we know what clock-panel actually hit.
+
+*My own test bug worth remembering:* =pkill -f 'wl-copy'= self-matched (the Bash tool's own command line contains the string "wl-copy"), killing the shell running it — exit 144. Used =pgrep -x= + =kill <pid>= instead. =pkill -f= on a string that appears in your own command line is a self-kill.
+
+Replied to clock-panel with the three test results, the =--help= quote, the inverted-mechanism read, an explicit "this isn't a value-gate failure" note, three specific things that would change my mind (verbatim failing command — my guess is it already had =--foreground=; how they observed the empty clipboard, since a same-shell read can race the fork; any timeout/trap/sandbox reaping the process group), and the mangled-transport flag with a =--file= heredoc suggestion.
+
+*Inbox back to zero.*
+
+** 2026-07-16 15:18 CDT — Clipboard incident (self-inflicted), then the Emacs XWayland finding
+
+*I broke Craig's clipboard.* Testing clock-panel's claim on his live machine, I killed wl-copy owner processes repeatedly. wl-copy holds the selection only while its process lives, so each kill emptied the clipboard. His original content ("jotto") died with the first one. Symptoms he reported: =C-y= showing "Mark set" with nothing inserted (that's =yank= on an empty string) and Chrome greying out paste (empty clipboard). I had backed up + restored once, then kept testing on the same live clipboard *without re-checking* — the discipline was applied once and abandoned. Restored "jotto" via Emacs (=gui-set-selection=) rather than wl-copy, since Emacs is long-lived and won't evaporate.
+
+*Owed to clock-panel: a partial retraction.* I rejected their proposal partly on "I tested it, plain wl-copy works" — but I read the clipboard back *seconds* after copying, before the owner had any chance to die, and then reproduced their exact failure on Craig's machine. The =--foreground=-is-backwards half of the analysis holds; the confidence didn't. *Not yet sent.*
+
+*The real finding.* Craig's Emacs reported =framep= = =x=. Investigation:
+
+- Installed package was =extra/emacs 30.2-3= (X11 build): =(:pgtk nil :x t)=, =window-system x=, =DISPLAY=:0= with =WAYLAND_DISPLAY=wayland-1= unused. On XWayland, contradicting the documented pure-Wayland setup — and why =xclip= reads his clipboard fine despite protocols.org saying never to use it.
+- =archsetup= line 2605 has run =pacman_install emacs-wayland= *unconditionally since bb9c9bb (2026-04-11, "fix: emacs-wayland package ... velox config sync")*. So *ratio drifted from archsetup's own intent for ~3 months*; the commit named velox, so the fix likely only ever reached velox. This also explains =emacs.md='s "Verified on Emacs 30.2 pgtk" claim — true on velox, false on ratio.
+- Host derived at runtime per the host-identity rule: =uname -n= → ratio. velox offline 2d, unverified.
+
+*Installed =emacs-wayland 30.2-3= on ratio* (Craig's call, option 2). =emacs= had =Required By: None=; =emacs-wayland= =Provides: emacs= so ledger/mu optdeps stay satisfied. =--noconfirm= *failed* (it takes the default N on a conflict prompt) — used =yes | sudo pacman -S emacs-wayland= for one atomic remove+install. Snapper snapshots 7680/7681 taken. New binary verified =pgtk:t x:nil=; running daemon still X11 (holds the deleted inode). *Restart is Craig's to make.*
+
+Blast radius checked before advising: this Claude session is tmux =aiv-rulesets= and *survives* an Emacs restart (as do 6 other agent sessions); his 4 frames and EMMS's mpv don't.
+
+*Bonus finding — =emacs.service= failed since 2026-07-09 06:00:04* (1 week), =start-limit-hit= after 5x exit-1. Cause *undiagnosed* (journal has no Emacs stderr; my untested hypothesis is a daemon-already-running conflict). The live daemon (PID 3004861, bare =emacs --daemon=, started Jul 14) has PPID 1 and nothing in the Hyprland config starts it. So =Restart=on-failure= isn't protecting anything and a reboot wouldn't restore the intended shape.
+
+*Ownership split, flagged to Craig:* the unit is a stow symlink into =~/.dotfiles/= — its own repo (=git@cjennings.net:dotfiles.git=) with its own =.ai/= scope. Package = archsetup's; unit = dotfiles'. Craig routed the handoff to archsetup; I sent there only and named the boundary, leaving the dotfiles side his call.
+
+Handoff sent: =archsetup/inbox/2026-07-16-1518-from-rulesets-handoff-archsetup-emacs.org=. The open question posed to them: nothing appears to detect "archsetup says X, machine has Y" on an *existing* box — intent is expressed for fresh installs only.
+
+*Test-methodology lessons worth keeping:* (1) =pkill -f '<pattern>'= self-matches when the pattern appears in your own command line — it killed the shell running it (exit 144), twice. Use =pgrep -x= + explicit kill. (2) Verifying a persistence claim seconds after the write proves nothing about persistence.
diff --git a/.ai/sessions/2026-07-19-21-15-launcher-hardening-inbox-hook-colloquialisms.org b/.ai/sessions/2026-07-19-21-15-launcher-hardening-inbox-hook-colloquialisms.org
new file mode 100644
index 0000000..8f5207c
--- /dev/null
+++ b/.ai/sessions/2026-07-19-21-15-launcher-hardening-inbox-hook-colloquialisms.org
@@ -0,0 +1,238 @@
+#+TITLE: Session Context
+#+AUTHOR: Craig Jennings
+#+DATE: 2026-07-18
+
+* Summary
+
+** Active Goal
+
+Three tasks shipped this session, all closed, pushed, velox synced (HEAD f6a2701). No active goal remaining; next is a fresh backlog pick.
+
+1. ai-launcher-hardening [#C] (113e8d8, 2b619f1, closed 33c6d7b): hardened =claude-templates/bin/ai= per its measurable acceptance criteria. Launcher tests 9 → 42, four pure decision cores extracted (=_git_prep_action=, =_order_windows=, =_match_window_id=, =_git_is_dirty=) each N/B/E, footgun audit + /refactor pass fully dispositioned, shellcheck clean + shfmt -i2 -ci + suite green before/after + live smoke correct.
+2. inbox-boundary-check hook [#B] (94e54f6, closed f6a2701): soft-nudge Stop hook enforcing the task-boundary inbox check. 6 bats, wired in settings.json + snippet, protocols note. Takes effect next session.
+3. colloquialisms / "the list" convention [#B] (8fd9e39, closed f6a2701): protocols.org Colloquialisms section + wrap-it-up Step 1 Before-Close Queue sub-step. 4 bats. Live now.
+
+KB: promoted 0 / consulted no
+
+** Decisions
+
+- The task's acceptance criteria are FIXED and live in the task body (todo.org, the 4 moves per todo-format.md's "Making an open-ended task measurable"). Follow them; don't re-derive scope.
+- Interactive runtime picker is OUT of this :solo: scope (design call) — file separately if wanted.
+- Characterization discipline per testing.md (refined this session, commit 179c495): record-not-spec, full Normal/Boundary/Error set per unit, negative/boundary cases are the bug-finders, extract-pure-core-when-IO-blocks IS the hardening.
+
+** Data Collected / Findings
+
+- Surface (22 fns). COVERED (via scripts/tests/ai-launcher-runtime.bats, 9 tests, runtime path): resolve_agent_cmd, build_runtime_choices, pick_runtime, build_instructions, print modes. UNCOVERED (17 to net): usage, check_deps, attach_session, create_window, maybe_add_candidate, build_candidates, fetch_candidates, git_status_indicator, annotate_candidates, auto_pull_if_clean, read_selections, sort_windows, find_window_id, prep_git_single, attach_mode, single_mode, multi_mode, print_launch_mode.
+- Pure/near-pure (characterize directly, N/B/E): git_status_indicator, maybe_add_candidate (dedup), annotate_candidates (format), read_selections (parse), usage. tmux/git-coupled (extract pure core + thin wrapper): sort_windows (ordering), create_window, attach_session, find_window_id, prep_git_single, auto_pull_if_clean. ~3 functional tests over single_mode/multi_mode/attach_mode against a throwaway tmux session.
+- Canonical bin/ai is claude-templates/bin/ai; installed via make install's bin loop (symlink to ~/.local/bin/ai). Tests live in scripts/tests/ (not .ai mirror). shellcheck + shfmt present; kcov NOT installed.
+
+** Files Modified
+
+This session (all pushed to origin/main, velox synced to d49be09): sentry build a8b6cf4/ccc9c26/c6383e9/8c0a56b, trial fix beb7f0b, gui-open b3195e9, flashcard apkg converter a143679 + multi-tag a14e43b, task closes a760d8e, knowledge-arch landing 179c495/2e19048/94df71e, flake fix 94015e6, launcher scoping d49be09. Nothing in flight — tree clean at the flush.
+
+** Next Steps
+
+Launcher hardening is done and pushed. Pick the next backlog task. Strong candidates surfaced this session: the two [#B] :feature: shared-asset proposals (todo-cleanup dated-seal already shipped ddbd47f; remaining backlog includes the inbox-boundary-check hook, the colloquialisms/the-list convention, and the build-to-prototype ui-prototyping extension). Manual: the sentry overnight live-trial on ratio (4-part task) stays Craig's to run.
+
+STANDING (still in force this session): run the suite as its OWN step and read it green before each commit; sync velox (git pull + make install, ssh 100.127.238.103) after each push; /review-code + /voice personal per commit; keep velox current.
+
+* Session Log
+
+** 2026-07-18 Sat 17:52 CDT — Startup + inbox inventory
+
+Ran startup.org. Phase A.0: rulesets pull skipped (dirty tree — =.claude/settings.json= carries a harness-written model flip opus → claude-fable-5[1m] against the committed opus pin bd76d98; needs Craig's call). =make install= nothing new. Project fetch clean. Phase A: no crash anchor (clean prior wrap), =.ai/= synced from templates, staleness 3 tasks >7 days, roam inbox 18 items (all foreign — archsetup/takuzu/home/clock-panel, none rulesets-claimed), KB 97 nodes / no relevant titles / best-practices path unresolved, spec-sort + host-identity probes silent.
+
+Inbox: 7 pending. Acted on the two trivial ones immediately:
+- website priority-scheme FYI — deleted (pure FYI, loop already closed by their "done").
+- website roam-KB-hosting-moved — applied the factual origin-URL fix in =claude-rules/knowledge-base.md= (git@cjennings.net:roam.git → cjennings@cjennings.net:git/roam.git), verified ratio's =~/org/roam= remote already points at the new URL. Deleted the inbox file. Commit + reply to website pending. Surfaced to Craig: rulesets.git itself is still publicly browsable on cgit (his open decision, per website's note).
+
+** 2026-07-18 Sat 18:02 CDT — Triage-intake redesign applied (item 1 of 3)
+
+Craig approved option 1 (apply as sent). Copied both sent canonicals over =claude-templates/.ai/workflows/{triage-intake,daily-prep}.org=, synced the mirror via =sync-check.sh --fix=, full suite green (374 + 67 pytest, all ERT expected, all bats ok, make exit 0). Review ran (/review-code --staged, inline): verdict Approve — the two remaining "suggested-actions" mentions are dated history entries (correct to keep), ORDER still governs the on-request long form, daily-prep line 58 keeps old "Action items" wording but is behaviorally accurate (Minor, not fixed). Commit 6e48714 (voice-passed, gate skipped — .ai/ tracked). Deleted the 3 inbox files, replied to work (delivered to their inbox 18:02).
+
+Three substantive shared-asset proposals queued for skeptical-review surfacing:
+1. work 07-17: todo-cleanup.el --archive-done dated-seal model (retain 7→31, unparseable-CLOSED archiving, --seal rename). Craig ratified the design at work. Verified: todo-cleanup.el still has retain default 7, no seal — proposal premise current.
+2. home 07-17: strip SCHEDULED/DEADLINE on dated-rewrite completion (todo-format.md + todo-cleanup --convert-subtasks) + new lint-org checker dated-log-heading-active-timestamp.
+3. work 07-18: triage-intake Phase C/D redesign (three-section digest TASKS/FYI/MISC, close-by-default, reroute modifier) + daily-prep 3b one-liner — Craig's 2026-07-18 ruling, edited canonicals attached. Verified: diffs coherent, author lines clean (no "& Claude" regression), no other template references the retired format, plugins untouched by design.
+
+** 2026-07-18 Sat 18:11 CDT — Items 2 & 3 filed; 2 new home handoffs arrived
+
+Craig said "proceed" → filed both items 2 and 3 as [#B] :feature:solo: in todo.org, each with the design preserved to docs/design/ and cross-linked (both touch todo-cleanup.el, build as one batch). Replied to work (filed) for item 2. Route checks: both none (local keepers). settings.json resolved itself — Craig's /model opus set it back to match the committed opus pin (bd76d98), tree now clean on that file.
+
+Task-boundary inbox check caught 5 NEW home handoffs (17:53 + 18:04), two distinct proposals:
+- Item 4: upcoming-birthdays feature promotion (cover + upcoming_birthdays.py + test + full daily-prep copy). Reviewed the code — clean, stdlib-only, 19 pytest cases (Normal/Boundary/Error, leap-day, placeholder-year 1900→None, window boundaries, CLI). Isolated the daily-prep reconcile: home's copy PREDATES my triage change, so only two birthday hunks apply (Heads-Up item 2 + Phase A source 8) — NOT a wholesale overwrite, which would revert my 3b triage edit. Home flagged this correctly.
+- Item 5: "Colloquialisms and Expansions" + "the list" before-close-queue convention. Touches protocols.org + wrap-it-up.org (both synced) — a norm-adoption design call.
+
+** 2026-07-18 Sat 18:22 CDT — Item 4 (birthdays) applied
+
+Craig approved. Copied upcoming_birthdays.py + test into claude-templates/.ai/scripts/, applied ONLY the two birthday hunks to canonical daily-prep (verified my 3b triage edit survived — both copies still carry "three-section digest"), synced mirror. Suite green pytest 374→393 (19 new, all pass). Commit 80ebb74. Deleted 4 inbox files, replied to home (delivered 18:22). Also answered Craig's side question: all 27 projects carry the task-boundary inbox-check instruction (protocols.org synced), but it's a prose behavioral rule, not an enforced hook.
+
+Remaining: item 5 (colloquialisms/the-list convention). Still uncommitted from earlier: knowledge-base.md roam-URL fix + todo.org filings (items 2,3) + 2 docs/design proposals — batch at close-out. Craig also asked to explore a hook design for the inbox check after item 4.
+
+** 2026-07-18 Sat 19:56 CDT — Hook exploration, item 5 filed, inbox closed, all committed
+
+Explored the inbox-check hook with Craig. Key framing that emerged: two rails split by "must fire at a wall-clock instant, or just needs to be seen soon?" — boundary rail (Stop hook / UserPromptSubmit, fires when the agent yields, never interrupts mid-task) vs cron/at (must-fire-now: meeting alarms, meds, deadlines). The Stop event maps onto the rule's own boundary definition ("before reporting back"). Recommended soft-nudge Stop hook (stop_hook_active guard, once per turn) + UserPromptSubmit visibility injector. Filed as [#B] :feature: with full design. Craig extended the idea to a general boundary-check reminder rail (session-save nudge, uncommitted-drift nudge, soft reminders coming due) — offered a design note, he declined (not restarting, so no cold-start concern).
+
+Craig aborted the wrap (not restarting after all) and said complete the tasks. Actions:
+- Filed item 5 (colloquialisms / "the list" convention) as [#B] :feature: — a cross-project convention adoption touching protocols.org + wrap-it-up.org, so filed not applied; needs Craig's adoption decision + a short design pass. Replied to home, design preserved to docs/design/.
+- New archsetup FYI arrived mid-work (package-drift audit accepted on their side) — pure loop-close, deleted, no reply owed.
+- Committed everything: 6523ed5 (knowledge-base roam URL fix), bc1d81d (4 backlog task filings + 3 docs/design proposals). Plus earlier 6e48714 (triage redesign), 80ebb74 (birthdays).
+
+STATE: inbox at zero, tree clean, suite green. 4 commits ahead of origin/main (6e48714, 80ebb74, 6523ed5, bc1d81d), 0 behind — UNPUSHED, awaiting Craig's push call. :LAST_INBOX_PROCESS: stamped 2026-07-18.
+
+Backlog filed this session (all [#B]): todo-cleanup dated-seal, dated-log planning-line strip + lint checker (batches with the seal), inbox-boundary-check hook, colloquialisms/the-list convention.
+
+** 2026-07-18 Sat 20:06 CDT — Two [#C] :quick:solo: closeouts (power-through)
+
+Craig picked the quick wins first. Both DONE + CLOSED, task-shaped (top-level):
+- coverage-summary.el local-only doc (2cb7c1b, pushed after this): stated local-only status in the .el commentary header + elisp-testing.md "Measuring it" section (the gitignored .claude/scripts/ install is by-design, not a CI gap — Craig's 2026-06-28 decision). Sent emacs-wttrin a handoff to revert its contradicting header claim.
+- install-ai on PATH (d2b1bef): new claude-templates/bin/install-ai thin launcher, resolves its own symlink chain and execs scripts/install-ai.sh. make install's existing bin loop links it to ~/.local/bin/install-ai (same as ai/agent-page) — resolves the task's open question (no dedicated sync, no dotfiles copy; the symlink is the canonical). 3 launcher bats incl. symlink-invocation. Verified live: install-ai --help runs from PATH. Suite green pytest 393, all bats/ERT pass.
+
+Remaining top solo work: the two batched todo-cleanup [#B] :feature:solo: tasks (dated-seal + planning-line strip) — the strongest next power-through target.
+
+** 2026-07-18 Sat 21:04 CDT — Batched todo-cleanup build (both [#B] :feature:solo: DONE)
+
+Craig said yes to the batch. Built both TDD in one commit ddbd47f (10 files, +746/-79), both DONE+CLOSED.
+
+Task A (dated-seal): retain default 7→31 via new defconst tc-archive-retain-days-default; unparseable-CLOSED archiving made explicit in the contract/commentary; new --seal mode (tc-seal-archive-file) renames task-archive.org → resolved-YYYY-MM-DD.org beside it, dated by seal run (not quarter — avoids late-quarter mislabeling), next --archive-done recreates fresh working file; sealed file inherits gitignore status; --seal flag + dispatch + report + CLI recognizer. 5 ERT tests.
+
+Task B (planning strip + lint): --convert-subtasks now strips the WHOLE planning line (CLOSED+SCHEDULED+DEADLINE) via tc--strip-planning-lines-in-entry, reversing the old CLOSED-only behavior (the enshrining test tc-convert-preserves-deadline... was rewritten to assert stripping). Stops at first non-planning line so body prose survives. New lint-org checker dated-log-heading-active-timestamp (flags active <..> SCHEDULED/DEADLINE on a keyword-less dated heading, ignores inactive [..]). todo-format.md sub-task rule step 5 + VERIFY path. 5 convert + 5 lint tests.
+
+Debugging note: seal tests failed only in the FULL suite — root cause was a latent bug, tc-test--reset never cleared tc-convert-subtasks, so a convert test's mode flag leaked and won the tc-process-file cond over seal. Fixed the reset + made the seal harness set all mode flags explicitly. Full suite green (todo-cleanup 52, lint-org 57, pytest 393, all bats). Replied to work + home (delivered).
+
+STATE at 21:04: ddbd47f committed, NOT yet pushed (2 quick-win commits 2cb7c1b/d2b1bef already pushed earlier). Reconcile clean before commit (0/0).
+
+** 2026-07-19 Sun 04:35 CDT — SENTRY BUILD STARTED (no-approvals + auto-flush)
+
+Craig approved the sentry spec after his deep read and told me to build it in no-approvals + auto-flush mode. Big pivot from the launcher-hardening [#C] (that was read-only, no edits to bin/ai — left as-is, still TODO).
+
+Spec flipped DRAFT → READY → DOING (docs/specs/2026-07-14-sentry-workflow-spec.org, ID f6c51f27-d7a2-4b63-9ff9-5ba005a66dfb). Metadata Status → doing. Build task decomposed into 4 phase sub-tasks under "** DOING [#B] Sentry workflow" with :SPEC_ID: binding.
+
+BUILD PLAN (resume anchor — re-read the spec's Design + Decisions + Implementation phases if context was cleared):
+- Phase 1: =agent-lock= helper (canonical claude-templates/.ai/scripts/) — mkdir-atomic acquire, PID/host/ISO metadata, age-staleness reclaim (surfaced), bounded-wait contention (~30s), heartbeat refresh, acquire/release/status subcommands. Home: /run/user/<uid>/agent-locks/<name>/ with ~/.cache/agent-locks/ fallback. bats-tested. TDD. NOTHING calls it yet.
+- Phase 2: =sentry.org= engine (.ai/workflows/, mirror synced) — entry ticket :COMMIT_AUTONOMY:, interactive entry gates, ff-only reconcile, sentry/YYYY-MM-DD-<host> branch, 10-pass runner (probe→work→session-context→commit), digest + approval queue, skip-and-note, spine-exclusion + fire-end digest commit, stall-notify after 2 skips, stop-sentry op. INDEX.org.
+- Phase 3: companion reconciliations — knowledge-base.md, inbox.org core §5, roam-sync.sh header, triage-intake.org, wrap-it-up.org active-sentry guard.
+- Phase 4: make test green; file live-trial follow-ups (Craig runs the trial); flip spec → IMPLEMENTED, close parent.
+
+Mode: no-approvals (commit+push per logical unit, Step 0 reconcile + pre-push check still run), auto-flush armed. COMMIT_AUTONOMY: yes + LOOP_MAY_COMMIT: yes in notes.org.
+
+Starting Phase 1 (agent-lock, TDD).
+
+** 2026-07-19 Sun @ 04:38:35 -0500 — flushed
+Auto-flush before the sentry build. Clean boundary: spec flipped DRAFT→READY→DOING and decomposed (495e36b, pushed), nothing half-edited. Resuming into Phase 1 (agent-lock helper, TDD) per Summary Next Steps.
+
+** 2026-07-19 Sun @ 04:52 CDT — PHASE 1 DONE (agent-lock), pushed
+
+Built the agent-lock helper TDD (canonical claude-templates/.ai/scripts/agent-lock + mirror). Subcommands acquire/refresh/release/status/path. mkdir-atomic acquire, PID/host/ISO/TTL metadata, mtime-based staleness reclaim (surfaced note), heartbeat refresh, XDG_RUNTIME_DIR/agent-locks home with ~/.cache fallback. 18 bats tests.
+
+Self-review (review-code --staged, inline) caught one real concurrency bug pre-commit: the stale-reclaim path was rm -rf + mkdir in two steps, letting two acquirers who both see a lock stale double-acquire (loser deletes winner's fresh dir). Fixed with atomic-rename claim (mv wins-or-fails, then mkdir stays sole grant); strengthened test 7 to assert fresh metadata after reclaim. Verdict cleared to Approve.
+
+Full suite green (make test exit 0, 0 not-ok; agent-lock 18/18). Commit a8b6cf4, pushed to origin/main (0 behind, pre-push reconcile clean). /voice personal ran on the message.
+
+NEXT: Phase 2 — sentry.org engine (.ai/workflows/, mirror synced). Entry ticket :COMMIT_AUTONOMY:, interactive entry gates (dirty-tree / red-suite), ff-only reconcile, sentry/YYYY-MM-DD-<host> branch, pass runner (probe→work→session-context→commit contract), digest + morning-approval queue, skip-and-note semantics, spine-exclusion + fire-end digest commit, stall-notify after 2 unmerged-branch skips, stop-sentry op, INDEX.org entry. Re-read spec Design paragraphs (branch mechanics, locks, roam writes, unattended safety, pass list, digest) + the 10 decisions.
+
+** 2026-07-19 Sun @ 05:06 CDT — SENTRY BUILD COMPLETE (all 4 phases, pushed)
+
+All four sentry phases shipped, committed, pushed to origin/main, suite green throughout:
+- Phase 1 a8b6cf4 — agent-lock helper (18 bats). Pre-commit review caught + fixed a reclaim double-acquire race (atomic-rename claim).
+- Phase 2 ccc9c26 — sentry.org engine + INDEX entry. All 10 decisions / 12 findings reflected.
+- Phase 3 c6383e9 — roam writers (knowledge-base.md, inbox.org §5) acquire roam-write lock + edit-plus-trigger (roam-sync sole committer); roam-sync.sh header; triage-intake note; wrap-it-up Step 0 active-sentry guard. Lock-name derivation pinned identically in sentry.org + wrap-it-up (sentry-<repo-basename>).
+- Phase 4 8c0a56b — make test green at HEAD; spec DOING→IMPLEMENTED (dated history + Metadata mirror); build task + phase sub-tasks closed (sub-tasks → dated event-log entries); live trial filed as structured "Manual testing and validation" task.
+
+FINAL STATE: tree clean (only untracked spine), 0/0 vs origin/main, spec IMPLEMENTED, sync-check clean. Live agent-lock smoke test on the real runtime dir passed (acquire→held on ratio→release). Each commit ran /review-code (inline for docs) + /voice personal.
+
+REMAINING (Craig's, not agent-buildable): the overnight live-trial night on ratio — arm sentry, exercise the entry gates, observe one fire end to end, run the morning branch review. Filed as the 4-part manual-testing task in todo.org. Its findings become follow-up tasks. The launcher-hardening [#C] (read-only bin/ai) is still TODO, untouched (was pre-empted by the sentry build).
+
+** 2026-07-19 Sun @ 15:38 CDT — Live trial feedback #1 processed (roam-denylist mis-park)
+
+Craig ran a full sentry session from the WORK project. First trial finding: the inbox-zero pass (P2), running from ~/projects/work, parked the whole 19-item roam inbox as a cross-project boundary crossing and refused to tidy it — over-reading knowledge-base.md's work-denylist as "don't touch roam from work." Craig ruled: roam is a shared resource; the denylist only ever gated durable agents/ KB-node writes (a confidentiality guard). Reading roam + tidying the roam inbox are allowed from any project, work included.
+
+Fix (commit beb7f0b, pushed): applied work's prepared knowledge-base.md "Scope of the denylist — durable KB-node writes only" paragraph (naming the 2026-07-19 mis-park), plus one-line companion notes in sentry.org P2 and inbox.org roam mode pointing at the rule. Suite green. Replied to work (inbox-send, delivered 15:35), deleted the two work inbox items.
+
+Commits so far: a8b6cf4, ccc9c26, c6383e9, 8c0a56b (build), beb7f0b (trial fix #1). All pushed, tree clean.
+
+PENDING: 2 untracked archsetup items in inbox/ (11:47, gui-open proposal — unrelated to sentry, separate pass). Awaiting any further trial findings from Craig.
+
+** 2026-07-19 Sun @ 15:44 CDT — Inbox cleared: archsetup gui-open proposal applied
+
+Processed the two archsetup items (gui-open proposal). gui-open is a dotfiles-shipped launcher (a34d479, on PATH via ~/.dotfiles) that shows a file from a short-lived agent shell reliably — detaches through systemd-run --user (no shell reap), resolves the Hyprland instance after a restart, verifies a visible client. Cleared the value gate (fixes documented fragility both rules already flagged).
+
+Fix (commit b3195e9, pushed): interaction.md "Showing Craig Visuals" + desktop-capture.md "Showing the user something" now launch via gui-open instead of google-chrome-stable ... & / hyprctl dispatch exec imv. Guidance-only (tool is dotfiles-owned, not a rulesets script); added an "if not on PATH, needs a dotfiles pull" note. Replied to archsetup (delivered 15:42), deleted both inbox files. Inbox now clear.
+
+DAILY-DRIVER NOTE: gui-open ships via dotfiles a34d479 — confirmed on ratio (symlinked ~/.local/bin/gui-open → ~/.dotfiles). velox needs a dotfiles pull to have it, or the new rule guidance references a missing tool there. Flag to Craig.
+
+Session commits total: a8b6cf4, ccc9c26, c6383e9, 8c0a56b (sentry build) + beb7f0b (trial fix #1) + b3195e9 (gui-open guidance). All pushed, tree clean, inbox zero.
+
+** 2026-07-19 Sun @ 15:52 CDT — velox brought current (standing: keep it synced this session)
+
+Craig: do the dotfiles pull on velox now, and keep velox up to date with our changes for the rest of the session. Reached velox over tailscale (100.127.238.103, cjennings@).
+- dotfiles: git pull → c3ef604 (was 9914a25), make restow hyprland (clean, no conflicts). gui-open symlinked ~/.local/bin/gui-open → dotfiles common tier; verified: direct exec prints usage, ~/.local/bin on interactive-login PATH (positions 1-2), interactive shell resolves it. Earlier "MISSING" was only the non-interactive SSH PATH.
+- rulesets: git fetch + merge --ff-only → b3195e9 (was 3ae71be; also caught up on upcoming_birthdays, install-ai, docs/design proposals). make install relinked install-ai + agent-page, rest skipped.
+
+STANDING INSTRUCTION (rest of session): after each push, also update velox — git pull + make install on ~/code/rulesets, and a dotfiles pull + restow if dotfiles changed. ssh via 100.127.238.103. zsh gotcha: don't word-split an unquoted $VAR for the ssh command; run ssh inline. Non-interactive SSH PATH lacks ~/.local/bin — test tools via resolved path or zsh -lic.
+
+** 2026-07-19 Sun @ 18:42 CDT — Speedrun complete (2 flashcard tasks shipped)
+
+No-approvals speedrun over the 2 flashcard :solo: tasks, both TDD + review + voice, each its own commit, pushed, velox synced after each.
+- apkg-to-orgdrill.py (a143679): inverse converter, stdlib zipfile+sqlite3, 17 tests + real-genanki round-trip. Grounded the apkg schema by generating/inspecting a real one first.
+- flashcard multi-tag reconcile (a14e43b): broadened CARD_RE in to-anki + stats for :fundamental:drill:, added --tag-filter + --guid-salt + drill-membership guard; re-derived against current canonical (kept the #+TITLE fix); parse() 3rd element now anki-tag list. End-to-end verified (2→1 with --tag-filter). 465/100 real-deck check needs the work deck (not runnable here).
+- Closed both as dated event-log entries (a760d8e).
+
+Suite green throughout (419 pytest). Session commits now: sentry build (4) + trial fix + gui-open + flashcard (2) + closes. All pushed, velox current at a760d8e.
+
+NEXT (Craig asked): discuss the ai launcher hardening [#C] task (todo.org line ~208). Remind him what it is.
+
+** 2026-07-19 Sun @ 19:04 CDT — Landed the testing/acceptance knowledge-architecture change
+
+Craig's directive: build the characterization-test + measurable-acceptance discipline into the workflows, and decide the KB-vs-rule boundary. Answer settled in discussion: apply-every-time discipline → rules (single source, auto-loaded, review-gated contributions via inbox); pull-when-relevant cross-project facts → KB. Almost none of this is KB material (it has a rule home); the KB is the capture/holding-pen, promotion moves it OUT into rules.
+
+Two commits:
+- 179c495 (characterization discipline): testing.md — defined a characterization test (record-not-spec, Feathers recipe), required the full Normal/Boundary/Error set per unit, and the key lens that negative/boundary cases are the bug-finders; + the "extracting the pure core IS the hardening" framing in the refactor-for-testability section. code-quality.org gained the precondition that behavior-preserving rests on a characterization net.
+- 2e19048 (measurable acceptance): todo-format.md new subsection "Making an open-ended task measurable (so it can be :solo:)" — bound surface / characterization net / disposition findings / objective floor; qualifying answer = dispositioned report. work-the-backlog's keystone defer item now recognizes absence-phrased open-ended goals and routes them to get criteria.
+
+Scope note: start-work and /refactor are NOT rulesets files (not installed at ~/.claude/skills/; registered elsewhere). Their pointers are a follow-up in whatever repo defines them — flagged to Craig. add-tests + review-code (rulesets skills) already well-wired to testing.md.
+
+Suite green, lint 0/0, velox synced to 2e19048.
+
+** 2026-07-19 Sun @ 19:14 CDT — Flaky-test fix + a discipline note
+
+While landing the start-work pointer (94df71e), chained make test into the commit command and the commit went through on a RED run — the bundling verification.md warns against. The red turned out flaky (rename-ai-artifact.bats teardown race, unrelated to the md edit; suite green on re-run, change sound), but the gate failed open. Correction going forward: run the suite as its own step, read it, then commit.
+
+Root-caused + fixed the flake (94015e6): rename-ai-artifact.bats teardown hit "rm: cannot remove .git: Directory not empty" because git background auto-maintenance (maintenance run --auto after commit) wrote into .git after the body, racing rm -rf. Fix: gc.auto 0 + maintenance.auto false in the throwaway repo before any git command arms them. Diagnose-not-mask (no rm retry). Verified 30 file runs + 2 full-suite runs green.
+
+Also corrected an earlier wrong claim: start-work + refactor ARE rulesets files (.claude/commands/, symlinked to ~/.claude/commands), already wired to testing.md for characterization. The acceptance-pattern landing is fully complete — no out-of-repo follow-up.
+
+** 2026-07-19 Sun @ 19:20 CDT — Scoped the ai-launcher-hardening task (dogfooded the pattern)
+
+Applied the measurable-acceptance pattern to the launcher task (d49be09). Rewrote the open-ended body with the 4 moves: bounded surface (22 fns, 5 covered via ai-launcher-runtime.bats / 17 uncovered, all named), characterization-net plan (pure-core extraction called out for the tmux/git-coupled fns), dispositioned audit (per-fn footgun matrix + /refactor pass), objective floor (shellcheck/shfmt/green suite/per-fn coverage + ~3 functional tests over single/multi/attach_mode). Split the interactive runtime picker OUT of :solo: (design call). Task is now genuinely :solo:-ready. :LAST_REVIEWED: bumped to 2026-07-19.
+
+The actual hardening work (bring 17 fns under characterization tests, extract pure cores, run the audit + refactor) is a substantial next-session speedrun candidate — now bounded.
+
+** 2026-07-19 Sun @ 20:19:15 -0500 — flushed
+Interactive flush at a clean boundary (launcher task scoped + committed d49be09, tree clean, nothing half-edited). Resuming into option 2: work the ai-launcher-hardening characterization sweep per the refreshed Summary Next Steps.
+
+** 2026-07-19 Sun @ ~21:30 CDT — LAUNCHER HARDENING COMPLETE (2 commits + close, pushed, velox synced)
+
+Worked the ai-launcher-hardening [#C] :refactor:solo: end to end per its measurable acceptance criteria. Craig present, chose push+sync+close.
+
+Sequence:
+- Grounded in bin/ai (540 lines), confirmed green baseline (make test exit 0), pinned the gate targets: shellcheck-clean (the bash hook enforces shellcheck only; shfmt is deliberately NOT hook-gated), shfmt house style = -i 2 -ci (agent-page conforms, bin/ai did not).
+- Commit 113e8d8 (net + source-testable): wrapped dispatch in main() behind a run-vs-sourced guard so the file is sourceable for unit tests; cleared 4 pre-existing shellcheck warnings on touched paths (@{u} quoted ×3 = SC1083, literal display tilde disabled = SC2088). New scripts/tests/ai-launcher-characterization.bats, 20 tests: pure/near-pure N/B/E (git_status_indicator, maybe_add_candidate, annotate_candidates, read_selections, build_candidates, usage) + 4 functional (create_window, find_window_id, sort_windows, attach_mode) against a PRIVATE tmux socket (TMUX_TMPDIR + unset TMUX) so nothing touches Craig's live ai session; throwaway repos disable gc.auto/maintenance.auto (the rename-ai flake).
+- Commit 2b619f1 (refactor): extracted four pure cores — _git_prep_action (git-prep none/pull/report classifier, shared by prep_git_single + auto_pull_if_clean), _order_windows (sort_windows ordering), _match_window_id (find_window_id), _git_is_dirty (DRY the dirty triad ×3). Added 13 unit tests for the cores incl. the safety case (dirty repo never auto-pulled). shfmt -i2 -ci applied. 42 launcher tests green, live black-box smoke correct for claude+codex.
+- Commit 33c6d7b: closed the task DONE+CLOSED (top-level ** → task-shaped) with the dispositioned resolution note.
+
+Footgun audit fully dispositioned (report delivered inline to Craig): unquoted-expansion/word-split — clean; errexit-off — intentional/documented, kept; subshell state loss — none (globals set in main shell); exit-code propagation — intentional; sort_windows race — none (two-pass park-then-reassign is correct); create_window sleep 0.1 — declined (intentional); git-prep error paths (dirty/detached/no-upstream) — all correct, dirty-safety now a named test. /refactor: 4 extractions + shfmt applied; build_candidates exit-status + create_window sleep declined.
+
+Honest coverage limit stated: attach_session and full end-to-end of single/multi/fetch stay partially covered (terminal step attaches/blocks on fzf, can't run headless); their decision logic was extracted into the netted cores.
+
+STATE: 33c6d7b pushed, 0/0 vs origin/main, velox synced to 33c6d7b (make install: ai linked), tree clean (only untracked session anchor), suite 367 ok / 0 not-ok. Interactive runtime picker stayed OUT of scope (design call, on the generic-agent-runtime parent). No memory promotion needed (nothing durable/cross-project beyond the repo record).
+
+** 2026-07-19 Sun @ ~22:00 CDT — No-approvals speedrun: inbox hook + colloquialisms (both [#B] DONE)
+
+Craig said "no approvals speedrun time" after the launcher task. Built two [#B] backlog tasks, each TDD + inline review + /voice personal, committed + pushed + velox-synced.
+
+Task 1 — inbox-boundary-check hook (94e54f6): soft-nudge Stop hook backing the "check inbox/ at every task boundary" rule (was prose-only). Blocks the yield once + injects the pending count when =inbox-status -q= exits 1, steps aside on =stop_hook_active= re-entry (soft, not hard-block, so a mid-task pause never wedges), self-skips on no-inbox/no-inbox-status/clean. Prefers project-local =.ai/scripts/inbox-status=, PATH fallback. Wired ahead of =ai-wrap-teardown= in =.claude/settings.json= (which the live =~/.claude/settings.json= symlinks to → live global on both boxes) + =settings-snippet.json= + README row; glob-installed by =make install-hooks=. protocols.org Inbox Monitoring Cadence notes the enforcement. 6 bats. Live-verified on ratio (clean no-op, pending fixture blocks). Loads next session (hooks read at session start), so it didn't interfere with this wrap.
+
+Task 2 — colloquialisms / "the list" (8fd9e39): new =* Colloquialisms and Expansions= section in protocols.org. "put X on the list" → session-scoped Before-Close Queue (=* Before-Close Queue= heading in the session anchor, resets on archive, todo.org for must-outlive); "tell <project> <msg>" → =inbox-send=. wrap-it-up Step 1 gained a "Work the Before-Close Queue (before the Summary)" sub-step so queued work rides the wrap commit. Design calls: reference in protocols.org not per-project notes.org (synced = shared norm); queue in the session anchor as home did; wrap step at front of Step 1 not a half-step (keeps "Steps 1-5" framing). 4 documentation-integrity bats. Canonical+mirror synced.
+
+Both closes in f6a2701. Full suite green throughout (0 not-ok). velox synced to f6a2701 (make install ok). Then Craig said wrap it up.
diff --git a/.ai/sessions/2026-07-20-23-36-signal-pager-and-triage-phone-push.org b/.ai/sessions/2026-07-20-23-36-signal-pager-and-triage-phone-push.org
new file mode 100644
index 0000000..fe43f61
--- /dev/null
+++ b/.ai/sessions/2026-07-20-23-36-signal-pager-and-triage-phone-push.org
@@ -0,0 +1,185 @@
+#+TITLE: Session Context — Sentry live trial (ratio, 2026-07-19)
+#+AUTHOR: Craig Jennings
+
+* Summary
+
+** Active Goal
+
+Post-flush session. Two items shipped: (1) the archsetup voice-#46 (comma-budget) handoff, reviewed and committed rulesets-side (2ea5d9a); (2) the triage-intake auto-mode phone-push, built onto agent-text, signal-only per Craig's ruling (e27aea2). Items 2 (reply-correlation spec) and 3 (token-rotation discussion) from the prior next-steps list remain untouched, carried forward.
+
+** Decisions (this session, all shipped)
+
+- Voice pattern #46 (comma budget, max two per sentence): personal mode only, in the attestation high-recurrence set. Craig's 2026-07-20 direction via archsetup; committed rulesets-side 2ea5d9a.
+- Triage auto-mode phone-push is signal-only (Craig's option 1): a quiet sweep's "nothing" heartbeat never pushes to the phone — silent-until-signal governs the phone channel too. Send half shipped (e27aea2); reply-polling deferred to the reply-correlation spec.
+- Sentry live trial: ran on ratio, 8 hourly fires, stopped on "sentry off", branch fast-forwarded to main and deleted.
+- working/ is tracked-from-creation + gitignored temp/ in both modes (Shape A). Committed.
+- Triage source activation: general (synced) plugins gate on a per-project =:TRIAGE_SOURCES:= declaration; project-specific plugins active by presence; gate in triage-intake Phase 0 (interactive + unattended). Spec IMPLEMENTED. Migrations done (home + work declared their sources).
+- Silent-until-signal: a POLICY not a mechanism — an in-session monitor fire heartbeats =<workflow> at HH:MM: nothing= on an empty check; detection stays in-session (MCP-safe). Applied to sentry, auto triage-intake, auto inbox-zero. Spec IMPLEMENTED.
+- Suspend detach change applied to canonical. Sentry cluster consolidated (merged /schedule tasks, added cross-host-coordination task). Task audit stamped 2026-07-20.
+- Polyglot: case-by-case, no option-2 machinery (bundles already compose; only coverage-makefile.txt collides, and it's a manual paste). Subprojects: don't promote (N=1). Both closed.
+
+** Data Collected / Findings — Signal pager (what the resume needs)
+
+RECONCILED (2026-07-13, in the task's dated log): there is ONE pager identity, =+15045173983=, registered in *velox's* signal-cli (account file 465310). =signal-mcp= is a velox-local MCP server (invisible from ratio). ratio's signal-cli holds only Craig's personal number (note-to-self, no push). =agent-page= already shipped (=claude-templates/bin/agent-page=): runs signal-cli directly on velox, ssh-relays from anywhere else, desktop-fallback on failure, 4 bats, live-verified. protocols.org "Paging Craig" was already rewritten around the two channels (notify desktop + agent-page phone). Craig's Signal UUID: =b1b5601e-6126-47f8-afaa-0a59f5188fde=. Reliability finding: both signal-cli accounts throw receive-staleness warnings (velox ~40 days, ratio ~26) — the Signal protocol wants regular receives, and a systemd receive timer on velox is the roam-sync-shaped fix.
+
+REMAINING deliverables (this is the work to do): (1) the runbook proper — send + read-replies + receive-timer + signal-cli account/setup notes, the Signal equivalent of the retired ntfy runbook, canonical home in rulesets docs/; (2) a systemd receive timer on velox (roam-sync-shaped) so receives don't go stale; (3) the ssh-over-tailnet-only vs register-ratio-as-a-linked-device decision (a design call for Craig); (4) confirm protocols.org "Paging Craig" is accurate (agent-page did most of it — verify, don't redo). Task body has full history. Source: home handoff 2026-07-04.
+
+** Files Modified (this session — all committed + pushed to main)
+
+Post-flush commits: 2ea5d9a (voice #46 comma budget — SKILL.md + voice-profile.org), 9721a49 (chore: mark archsetup handoff PROCESSED), e27aea2 (feat: triage phone-push via agent-text — canonical + mirror triage-intake.org, todo.org task closed). Left unstaged: .claude/settings.json model change (opus→fable, not this session's work — deferred).
+
+Earlier same-session (pre-flush): f625cf5 (working/temp feat), b02eade + 4d87f35 (triage source activation spec + build), 986d6ca + 0767af8 + 0e9958a (silent-until-signal spec + Phases), af565ba (suspend detach), 70fbe01 (link fixes), 93a2e6d (working-dir filing), c82b625 (sentry cluster), up to fecdf8c, plus the pager work (302b062, 6145489) and overnight sentry commits.
+
+** Next Steps
+
+Item 1 (triage phone-push) shipped this session. Two remain from the prior list:
+
+1. *Spec the reply-correlation follow-up.* The two-way gap: with the account linked on velox + ratio, a Signal reply reaches BOTH devices (one account, Signal fans out per-device, independent queues) and neither knows which page/session it answers. Only bites when two sessions page-and-wait at once (fire-and-forget is fine). Options laid out to Craig: (1) correlation tag stamped on the page, echoed in reply; (2) quote-reply matching (needs verifying signal-cli surfaces quotes); (3) single reply-owner (velox-only). Overlaps the helper-instance work (same shared-channel problem). This spec also owns the deferred triage-intake reply-polling (phone-recv) half. Also fix the runbook "Reading replies" section, which oversells it. Write as a spec in docs/specs/ (spec-create spine).
+
+2. *Discuss the token-rotation helper* (todo.org =[#C] Token-rotation helper for @a-bonus/google-docs-mcp OAuth refresh=, :feature:quick:) — a discussion first, not a build. Read the task body.
+
+Publish flow (review + voice + suite) for any commits.
+
+KB: promoted 0 / consulted no
+
+* Session Log
+
+** 2026-07-19 23:52 CDT — Sentry armed (entry)
+
+Startup ran clean: rulesets pull no-op, make install nothing new, project repo up to date on main (0/0). Session-context was absent (prior session 2026-07-19-21-15 wrapped cleanly).
+
+Sentry entry gates, all passed with Craig present:
+- Autonomy ticket: =:COMMIT_AUTONOMY: yes= in notes.org Workflow State.
+- Host: ratio (intended live-trial machine).
+- Dirty-tree gate: tracked tree clean; only two untracked inbox files (=2026-07-19-2141-from-.emacs.d-version-working-directories.org=, =2026-07-19-2350-from-home-craig-approved-the-colloquialisms-the.org=) — do not block; sentry's inbox-zero pass (pass 2) handles them.
+- Green-suite gate: =make test= exit 0, 377 bats ok, all pytest + ERT green.
+- Prior sentry branch: none.
+- Reconcile: main 0 behind / 0 ahead of upstream.
+- =agent-lock= helper present.
+
+Created daily branch =sentry/2026-07-19-ratio= from HEAD (main @ f76bf40). Working tree now sits on this branch overnight. Arming the hourly loop next.
+
+Two inbox items pending at entry, both to be handled by pass 2 (inbox zero):
+- .emacs.d working/ version-control ruling — a shared-asset change (working-files convention + install .gitignore behavior across projects). Parks for morning approval; does not fire unattended.
+- home colloquialisms wiring approval — already shipped on the rulesets side in the prior session (protocols.org Colloquialisms section + wrap-it-up Before-Close Queue step, commits 8fd9e39/f6a2701). Inbox zero replies-and-files, confirming it's already wired.
+
+** 2026-07-20 00:06 CDT — Fire 1 digest (manual first fire, Craig present)
+
+Ran end to end on branch =sentry/2026-07-19-ratio=. Single-runner lock =sentry-rulesets= acquired at fire start, on the sentry branch, tree clean. Pass-by-pass:
+
+- P1 roam pull — SKIPPED: =~/org/roam= working tree dirty; roam-sync owns that case (pass is read-only ff-only otherwise). No write.
+- P2 inbox zero — RAN. Two project handoffs processed. (a) home's colloquialisms + "the list" before-close-queue: already wired canonically before it arrived — replied to home confirming, marked PROCESSED. (b) .emacs.d's working/ tracked-from-creation ruling: shared-asset + convention change with a temp/-placement design decision → PARKED (see approval queue). Staged proposal at =working/sentry-2026-07-19-working-files-ruling/proposed.org=, filed a [#B] VERIFY, replied to .emacs.d with two findings. Committed 727a900.
+- P3 triage intake — SKIPPED (probe-too-loose; queued as a finding). The template-synced general plugins (personal Gmail/calendar/cmail/Telegram/GitHub-PRs) are present in *every* project, so the "plugins present" probe self-activates triage everywhere — including rulesets, which is not a triage target. Running it would file Craig's personal action items into rulesets' todo.org and run trash/mark-read/star hygiene on his real accounts unattended. Skipped for scope + safety. No write.
+- P4 todo cleanup — RAN: hygiene 0 fixes, --convert-subtasks 0, --archive-done 0. todo.org already clean. No write.
+- P5 task audit — RAN (mechanical subset). No unambiguous autonomous staleness fixes: all open tasks carry a priority cookie + type tag except the intentional "Manual testing and validation" container; spec-lifecycle clean (P7); no dead file: links in todo.org. The full reconciliation (per-task fact-check, consolidation, parent-retirement, judgment flags) is interactive → queued. Did NOT bump :LAST_AUDIT: (only the mechanical subset ran). No write.
+- P6 working-files hygiene — RAN: flagged =working/inbox-zero-phase-e/= (tracked, backs the now-IMPLEMENTED autonomous-batch-execution spec) as a filing candidate. Filing is a judgment move (3 inbound spec links need updating) → queued. No write.
+- P7 spec status board — RAN: clean. All specs READY or IMPLEMENTED; the sentry spec is IMPLEMENTED; no DOING spec with a closed/missing bound parent. No write.
+- P8 link integrity — RAN (report-only): ~4 candidate broken file: links in live docs — the autonomous-batch spec's =../../.ai/workflows/inbox-zero.org= (renamed to inbox.org), two folded-in spec-review siblings (wrapup-routing, pattern-catalog), and =subprojects-log.org=. Some may be intentional (review docs folded in and deleted). No unattended rewrites. No write.
+- P9 git health — RAN: clean. main = origin/main (0/0), no unpushed commits on other branches, no stale merged branches, sentry branch is the only local extra. No write.
+- P10 prep + symlink freshness — SKIPPED: no prep dir (work/home only). No write.
+
+Fire-end: only org/spine files touched this fire (todo.org, inbox renames, working/ proposal — all org) → conditional suite skipped per contract. Session-context spine is untracked in rulesets, so no digest commit needed; tracked tree is clean. Single-runner lock released.
+
+Branch state after fire: one commit ahead of main (727a900). Next scheduled fire at :17.
+
+** 2026-07-20 00:21 CDT — Fire 2 digest (scheduled, :17 cron)
+
+Lock =sentry-rulesets= acquired, on the sentry branch, tree clean. Little changed in the 14 min since fire 1 — one new inbox handoff, everything else steady.
+
+- P1 roam pull — RAN: =~/org/roam= clean this fire (was dirty in fire 1), ff-only pull → already up to date. No write.
+- P2 inbox zero — RAN: one new handoff. .emacs.d routed three sentry-workflow design considerations from its own hand-run trial (codebase-gated bug/enhancement logging pass; system-health pass; sibling-machine freshness pass). Filed as =** TODO [#C] Sentry vNext passes — from live-trial design input= rather than applied (design input for the Living Document; two hinge on an unresolved cross-driver coordination question). Replied to .emacs.d, marked PROCESSED. Committed 067ed55.
+- P3 triage intake — SKIPPED: same probe-too-loose reason as fire 1; the finding is already in the approval queue, not re-queued.
+- P4 todo cleanup — RAN: 0 fixes. No write.
+- P5 task audit — no change since fire 1; the full-audit-due item stays in the queue, not re-added.
+- P6 working-files hygiene — RAN: =working/inbox-zero-phase-e/= still a filing candidate (already queued fire 1, not re-queued). =working/sentry-2026-07-19-working-files-ruling/= is an *active* working dir backing the open VERIFY task — correctly not a candidate. No new queue item.
+- P7 spec status board — RAN: clean, no DOING specs. No write.
+- P8 link integrity — not re-scanned: no doc changes since fire 1's scan except the new backlog task, whose one internal =file:todo.org::*...= link resolves (KB lesson-detection heading exists). Fire 1's report stands. No write.
+- P9 git health — RAN: clean, main = origin/main (0/0), branch now 2 ahead. No write.
+- P10 prep freshness — SKIPPED: no prep dir. No write.
+
+Fire-end: only org files touched → conditional suite skipped. Spine untracked → no digest commit; tracked tree clean. Lock released. Branch 2 commits ahead of main (727a900, 067ed55). No new approval-queue items this fire.
+
+** 2026-07-20 01:20 CDT — Fire 3 digest (scheduled, :17 cron)
+
+Quiet read-only fire — nothing changed in the hour since fire 2. All passes no-op or skip; no writes, no commit, no new approval-queue items.
+
+- P1 roam pull — RAN: clean, ff-only → already up to date.
+- P2 inbox zero — RAN: 0 new items.
+- P3 triage intake — SKIPPED: same probe-too-loose reason (finding already queued fire 1).
+- P4 todo cleanup — RAN: 0 fixes.
+- P5 task audit — no change; full-audit-due item stays queued.
+- P6 working-files hygiene — RAN: same two dirs (inbox-zero-phase-e already queued; sentry-ruling active for the open VERIFY). No new item.
+- P7 spec status board — RAN: clean, no DOING specs.
+- P8 link integrity — not re-scanned: no doc changes since fire 1.
+- P9 git health — RAN: clean, main = origin/main, branch 2 ahead.
+- P10 prep freshness — SKIPPED: no prep dir.
+
+Branch unchanged at 2 commits ahead. Lock released.
+
+** 2026-07-20 02:20 CDT — Fire 4 digest (scheduled, :17 cron)
+
+Quiet read-only fire, same as fire 3 — nothing changed in the hour. All passes no-op or skip; no writes, no commit, no new queue items. P1 roam up to date; P2 inbox 0 new; P3 triage skipped (finding already queued); P4 0 fixes; P5 no change (full audit still queued); P6 same two working dirs (inbox-zero-phase-e queued, sentry-ruling active); P7 clean, no DOING specs; P8 not re-scanned (no doc changes); P9 clean, branch 2 ahead; P10 skipped (no prep dir). Lock released.
+
+** 2026-07-20 03:20 CDT — Fire 5 digest (scheduled, :17 cron)
+
+Quiet read-only fire, same as fires 3-4. No changes in the hour; all passes no-op or skip; no writes, no commit, no new queue items. P1 roam up to date; P2 inbox 0 new; P3 triage skipped (queued); P4 0 fixes; P5 no change; P6 same two working dirs; P7 clean; P8 not re-scanned; P9 clean, branch 2 ahead; P10 skipped. Lock released.
+
+** 2026-07-20 04:20 CDT — Fire 6 digest (scheduled, :17 cron)
+
+Quiet read-only fire, same as fires 3-5. No changes in the hour; all passes no-op or skip; no writes, no commit, no new queue items. P1 roam up to date; P2 inbox 0 new; P3 triage skipped (queued); P4 0 fixes; P5 no change; P6 same two working dirs; P7 clean; P8 not re-scanned; P9 clean, branch 2 ahead; P10 skipped. Lock released.
+
+** 2026-07-20 05:20 CDT — Fire 7 digest (scheduled, :17 cron)
+
+Quiet read-only fire, same as fires 3-6. No changes in the hour; all passes no-op or skip; no writes, no commit, no new queue items. P1 roam up to date; P2 inbox 0 new; P3 triage skipped (queued); P4 0 fixes; P5 no change; P6 same two working dirs; P7 clean; P8 not re-scanned; P9 clean, branch 2 ahead; P10 skipped. Lock released.
+
+** 2026-07-20 06:20 CDT — Fire 8 digest (scheduled, :17 cron)
+
+Quiet read-only fire, same as fires 3-7. No changes in the hour; all passes no-op or skip; no writes, no commit, no new queue items. P1 roam up to date; P2 inbox 0 new; P3 triage skipped (queued); P4 0 fixes; P5 no change; P6 same two working dirs; P7 clean; P8 not re-scanned; P9 clean, branch 2 ahead; P10 skipped. Lock released.
+
+* Sentry approval queue (2026-07-19)
+
+Judgment/parked items from the overnight fires. Review top to bottom; run or discard each.
+
+** [Fire 1 · P2] Apply the .emacs.d working/ tracked-from-creation ruling
+- *What:* update the canonical working-files convention + add a gitignored temp/ pattern across projects.
+- *Why:* Craig's ruling relayed from .emacs.d (2026-07-19). Parked because it's a shared-asset + convention change and carries a design decision (temp/ must be ignored in both track and gitignore modes — it's orthogonal to the personal-tooling set).
+- *Prepared:* =working/sentry-2026-07-19-working-files-ruling/proposed.org= (exact edits + the two findings). Filed as the =** VERIFY [#B] working/ tracked-from-creation + gitignored temp/= task in todo.org.
+- *Fires on approval:* edit =claude-rules/working-files.md= (tracked-from-creation + temp/ subsections), =scripts/install-ai.sh= + =scripts/sweep-gitignore-tooling.sh= (emit temp/ ignore in both modes — decide shape (a) vs (b) in the proposal), =.ai/protocols.org= Working-Files Convention (one-line mirror); then =scripts/sync-check.sh --fix= for the .ai mirror, run =make test=, commit.
+
+** [Fire 1 · P3] Tighten the sentry triage-intake pass probe — SPECCED during morning review
+- *What:* the narrow "fix the probe" framing grew, on Craig's flexibility question, into a per-project source-activation model for triage-intake.
+- *Resolution:* written up as a spec for review — =docs/specs/2026-07-20-triage-source-activation-spec.org= (DRAFT), linked from the =** TODO [#B] Triage source activation= task. General plugins gate on a per-project =:TRIAGE_SOURCES:= declaration; project-specific plugins stay active by presence; the activation layer lives in triage-intake Phase 0 so it fixes the interactive over-pull too; sentry's pass-3 probe reads the same signal.
+- *Next:* Craig's deep read → DRAFT → READY → spec-response decomposes the 5 phases. Two decisions still open (declaration format, whether interactive adopts the same gate). No code landed — this item is now tracked by the spec + task, not the queue.
+
+** [Fire 1 · P5] A full interactive task-audit is due
+- *What:* run task-audit.org interactively (its consolidation / parent-retirement / judgment-flag phases need Craig).
+- *Why:* :LAST_AUDIT: is 2026-07-04 (~16 days); task-review-staleness flags 2 top-level tasks unreviewed >7 days. Fire 1's mechanical subset found nothing unambiguous to auto-fix, so the marker was deliberately not bumped.
+- *Fires on approval:* "let's do a task audit" (or task-review for the lighter pass).
+
+** [Fire 1 · P6] File working/inbox-zero-phase-e/ to a permanent home
+- *What:* file the three artifacts under =working/inbox-zero-phase-e/= per working-files.md and update inbound links.
+- *Why:* the backing work (autonomous-batch-execution spec) is IMPLEMENTED, so the working dir is a filing candidate. Filing is a judgment move — 3 inbound =file:= links in =docs/specs/2026-06-16-autonomous-batch-execution-spec.org= point at it and need updating in the same move.
+- *Fires on approval:* rename+move the 3 files flat into their permanent home, update the spec's 3 links, delete the empty working subdir.
+
+** [Fire 1 · P8] Resolve ~4 candidate broken file: links in live docs (report-only)
+- *What:* fix or confirm-intentional the broken links P8 flagged.
+- *Why:* report-only pass; no unattended rewrites. The clearest real one: =docs/specs/2026-06-16-autonomous-batch-execution-spec.org= links =../../.ai/workflows/inbox-zero.org= (renamed to inbox.org). Others (=wrapup-routing-spec-review.org=, a pattern-catalog inbox source, =subprojects-log.org=) may be folded-in/deleted review docs — Craig's call.
+- *Fires on approval:* update the inbox-zero.org→inbox.org link; decide the rest.
+
+** 2026-07-20 Mon @ 15:28:41 -0500 — flushed (auto)
+Auto-flush checkpoint mid-session. Session's shipped work is all committed + pushed (main == origin == velox). In flight: resuming into finishing the Signal pager task ([#B] in todo.org) — the runbook, velox receive timer, and the ssh-vs-linked-device decision. See Summary → Next Steps and Data Collected for the reconciled facts needed to resume blind.
+
+** 2026-07-20 Mon @ 16:48 CDT — Notification vocabulary split (page/text) + agent-page → agent-text rename
+Craig's follow-on from the reply-ambiguity discussion: reserve trigger words by channel. "page me" = desktop notify, "text me" = Signal, "text and page me" = both. Renamed the tool agent-page → agent-text to match (deprecated agent-page shim delegates to it, removable later). Rewrote protocols.org "Paging Craig" → "Reaching Craig", page-me.org, work-the-backlog's away-run logic, INDEX, and the runbook; updated install-ai doc comments; bats renamed agent-page.bats → agent-text.bats (5 pass incl. shim delegation). /review-code + /voice ran; make test exit 0, shellcheck clean, no new lint. Commit 6145489, pushed; make install re-run on both ratio + velox, agent-text + shim resolve on both. Vocabulary decision arc (this session): first "page=Signal, notify=desktop" (rejected, inverted current default), then "page=desktop, message=Signal" (Craig's), then settled on "text me" for Signal (agent-text). NOTE excluded from staging: .claude/settings.json shows model opus→fable (not mine — left unstaged).
+
+** 2026-07-20 Mon @ 23:36 CDT — Post-flush resume: voice #46 handoff + triage phone-push (item 1)
+Resumed from the auto-flush anchor. Two things landed.
+
+Inbox handoff (archsetup, voice pattern #46 comma budget): archsetup edited voice/SKILL.md + voice-profile.org through the shared ~/.claude/skills/voice symlink (points into rulesets) at Craig's 2026-07-20 direction — "no more than two commas per sentence" in the /voice personal pass. Reviewed both files (count 45→46 everywhere, personal-only set four→five, #46 in the attestation high-recurrence set, before/after from the Hyprland draft). /review-code approved, /voice personal on the message, make test green. Committed 2ea5d9a; replied to archsetup; marked PROCESSED (chore 9721a49). The .claude/settings.json model change (opus→fable) was left unstaged — not this session's work.
+
+Item 1 — triage-intake auto-mode phone-push, built. Folded a "Phone delivery" subsection into canonical triage-intake.org: a full-three-section sweep pushes to Craig's phone over Signal via agent-text, with a pointer from End-of-sweep output and a Living Document note. Craig's design ruling (option 1, signal-only): a quiet sweep's "nothing" heartbeat never reaches the phone — silent-until-signal governs the phone channel too — while the in-session heartbeat stays as proof-of-life. Send half only; reply-polling (old phone-recv) deferred to the reply-correlation follow-up (item 2), which owns the recv wiring. sync-check --fix synced the mirror; make test green; /review-code approved; /voice personal. Committed e27aea2. todo.org task closed DONE (send half shipped, recv deferred).
+
+Next: items 2 (reply-correlation spec) and 3 (token-rotation discussion) remain untouched — carried to the next session.
+
+** 2026-07-20 Mon @ 16:14 CDT — Signal pager DONE (all four deliverables + topology decision)
+Finished the task end to end. (1) Runbook: docs/design/2026-07-20-signal-pager-runbook.org — send, read-replies, receive timer, signal-cli account/setup notes, the resolved decision. (2) Receive timer: scripts/signal-receive.sh + scripts/systemd/signal-receive.{service,timer} (roam-sync-shaped, 15-min, no-ops where the account isn't local, 4 bats); stowed via dotfiles common; enabled + verified on both ratio and velox; a manual drain cleared the 47-day staleness live. (3) Topology decision (Craig, option 2 — linked device over ssh-relay-only): ratio linked as Device 2 "ratio-pager", direct send verified; agent-page generalized from a velox-only check to "any machine holding the account sends directly, else relay" (bats updated). (4) protocols.org "Paging Craig": verified accurate, no edit. Publish flow: /review-code caught a runbook/script contradiction (runbook claimed no-op-clean; script errored) → added the account-presence guard + a bats case. make test green, shellcheck clean. Commits: rulesets 302b062 (feat(pager)), dotfiles d8b0462 (feat(systemd)); both pushed; velox synced. todo.org task closed DONE. Craig's follow-up worry addressed: the pager only fires on an explicit page-me or an away-run's end-of-set page; the receive timer is silent (receive-only, no push).
diff --git a/.ai/sessions/2026-07-24-18-05-sentry-implement-pass-review-and-hook-fixes.org b/.ai/sessions/2026-07-24-18-05-sentry-implement-pass-review-and-hook-fixes.org
new file mode 100644
index 0000000..6c60a8f
--- /dev/null
+++ b/.ai/sessions/2026-07-24-18-05-sentry-implement-pass-review-and-hook-fixes.org
@@ -0,0 +1,386 @@
+#+TITLE: Session Context — 2026-07-23
+#+AUTHOR: Craig Jennings
+#+DATE: 2026-07-23
+
+* Summary
+
+** Active Goal
+
+A long session (2026-07-23 into 2026-07-24) spanning several arcs: applied two Craig-ordered sentry amendments, ran a no-approvals speedrun over four solo tasks, shipped the sentry implement-pass feature itself, ran eight overnight sentry fires that found and fixed real bugs, then subjected the whole night's output to an adversarial review that found defects in the fixes, repaired those, absorbed a repo-wide fail-open security fix from .emacs.d, and processed the inbox to zero. Ended with the branch merged to main (unpushed by Craig's choice) and the session wrapped.
+
+** Decisions
+
+- Sentry gains an opt-in solo-implementation pass (pass 12, gated on =:SENTRY_MAY_IMPLEMENT:=, separate from =:COMMIT_AUTONOMY:=) plus refactor-finding in pass 11. Craig's direction, after the discussion that the branch already contains blast radius and a skeptical premise-first review is the fact-checker that makes fixing-on-a-branch safe. Shipped to main.
+- Reviews must fact-check the *premise* (reproduce the bug) before judging the diff, not just check the diff is clean. Craig's correction; saved as harness memory =feedback-reviews-verify-premise=. Across the night the premise check killed roughly one wrong hypothesis per real bug.
+- Craig chose NOT to push main at wrap. The hook fail-open fix therefore stays undelivered to consuming projects until he pushes. Flagged and reaffirmed.
+
+** Data Collected / Findings
+
+- The dominant defect class across the session, five instances over two projects: a quality gate that enumerates its inputs instead of discovering them, so a new input is silently skipped and the green check reads as covered. Promoted to a KB node this wrap.
+- The secret-scan pre-commit hook failed open on any git error, in ALL FIVE language bundles (not just the elisp one .emacs.d reported). Two of the five were hooks I wrote the day before by copying bash — I propagated the defect. Fixed across all eleven sites; graded [#A].
+- The adversarial review round found four real defects in my overnight fixes (mode-widening and symlink-clobbering in cj-remove-block's atomic write, a same-second backup collision in two tools, a test that deleted real backups from shared /tmp) plus one I'd left: the cj-block range check still can't prove it's deleting the block that was scanned. All repaired except the last, which needs a CLI-contract decision and is filed [#B].
+- I repeated my own worst mistake pattern three times: shipping a change whose correctness depended on shared /tmp state (the backup tests), and twice concluding causation from a single-sample measurement (the audit flake A/B, the /tmp-copy comparison). The re-run/isolation habit caught each.
+
+** Files Modified
+
+- Merged to main (267d1de): cj-remove-block range guard + atomic write, todo-cleanup backup, route_recommend dedupe, audit.bats flake fix, lint.sh bin/ coverage, plus the review-round repairs.
+- Hook fail-open fix (f0c1bc4): all five bundles' pre-commit, the elisp validate-el cap removal, the cross-bundle test now discovering variants, two adopted .emacs.d bats suites.
+- Earlier: sentry.org pass 11/12 + marker (pushed), the speedrun's four fixes, the two .dotfiles amendments, voice #47, four approved parked proposals.
+- KB: =agents/20260724180443-enumerate-vs-discover-gate-failure.org=.
+
+** Next Steps
+
+- Push main (5+ commits ahead, all local). The hook security fix is the load-bearing one.
+- The cj-block wrong-block design question: content assertion vs re-scan vs bottom-up removal. Filed [#B] at the top of todo.org.
+- Seven parked VERIFYs await Craig, [#A] account-binding guard from home first, then the telegram down-is-launch fix and its engine sibling.
+- Optional: ~1600 backup files accumulated in /tmp from the night's runs (harmless, cleared on reboot).
+
+KB: promoted 1 / consulted no
+
+* Session Log
+
+** 02:34 — Startup
+
+Ran startup.org. Rulesets already current; =make install= had nothing new to link; project repo clean and up to date. =.ai/= synced from templates. No prior =session-context.org= — last session (2026-07-20 23:36, signal-pager + triage phone-push) wrapped cleanly.
+
+Findings surfaced: 13 top-level tasks unreviewed >7 days; roam inbox holds 9 items; 8 unprocessed project inbox handoffs plus =inbox/lint-followups.org=; KB has 101 =:agent:= nodes but no best-practices node resolved and nothing matching "rulesets". Active Reminder from 2026-07-14 (Craig's deep read of the sentry spec) reads stale — sentry has since shipped and dogfooded live in takuzu and archangel.
+
+Craig's instruction on arrival: finish startup, then begin sentry.
+
+** 02:35 — Craig-ordered amendments applied before launching sentry
+
+Two of the eight inbox handoffs are Craig's own orders relayed from the dotfiles session, and both gate a correct sentry run, so they land before the loop starts. The rest of the inbox stays for sentry's own inbox pass.
+
+Amendment 1 — sentry pass list (=.dotfiles=, 2026-07-21). Sentry never checks email or messengers, and gains a bug-finding pass. The handoff shipped dotfiles' whole edited =sentry.org=, but that copy forked from a pre-silent-until-signal canonical: rsyncing it would have reverted the heartbeat/digest split committed 2026-07-20. Applied their three intended changes onto current canonical by hand instead, and kept the =:TRIAGE_SOURCES:= activation-probe language their Pass 3 rewrite had dropped.
+
+Reviewed the staged diff before committing and found three defects in my own edit. Pass 11 as Craig worded it ran "the project's linters and suite," which would have violated sentry's own anti-pattern 5 (no per-pass suite run) eleven times a night; it now runs linters and static analysis only and reads the entry baseline's suite result. The Living Document line still said "ten mechanical passes." And the KB-deferral parenthetical called KB promotion "the proposal's eleventh pass," which collided with the real pass 11 — reworded. Suite green (exit 0) before and after. Committed 33949c5, unpushed pending Craig's call.
+
+** 02:43 — Sentry armed, fire 1 (working)
+
+Entry gates all passed: =:COMMIT_AUTONOMY: yes=, tracked tree clean, suite green (exit 0, run twice on this content), no prior =sentry/*= branch, zero behind upstream. Branch =sentry/2026-07-23-ratio= created from HEAD. Loop armed hourly at :07 (job ef8eb9d4, session-only, auto-expires in 7 days). Craig's repo working tree belongs to sentry until the morning merge — reclaiming it early means saying "stop sentry". If he has rulesets files open in Emacs, buffers want reverting after the merge.
+
+Fire 1 digest:
+
+- P1 roam pull — ran, already up to date.
+- P2 inbox zero — ran. Project inbox: 1 of 5 executed (org-drill's memory-sweep completion note, informational, marked PROCESSED + reply sent). 4 park (below). Roam inbox: 9 items, 8 belong to other projects and route at wrap-up, not here; the 1 rulesets item ("every project should have a working and a temp directory") is shipped except for one clause, queued below.
+- P3 triage intake — skipped: no project-specific plugin and no =:TRIAGE_SOURCES:= declaration, so no active source. Correct behavior under the activation gate.
+- P4 todo cleanup — ran, no-op.
+- P5 task audit — deferred to a later fire; takuzu's dogfood note says the full audit is too heavy hourly, and fire 1 already carries the bug-finding load.
+- P6 working-files hygiene — skipped: no =working/= directory.
+- P7 spec status board — ran. No =DOING= spec with a closed build parent.
+- P8 link integrity — ran, and the checker itself turned out to be broken (see P11).
+- P9 git health — ran. =main= is 1 ahead of =origin/main= (the amendments commit, unpushed pending Craig's call). Sentry branch correctly has no upstream. No stale branches.
+- P10 prep freshness — skipped: no =daily-prep/=.
+- P11 bug finding — ran. Rotating area this fire: =lint-org.el= and the synced =.ai/= templates. Three defects verified and filed as graded tasks (commit dc7791d): link resolution against cwd rather than the linted file's directory [#B], todo-format checkers firing on spec files [#C], and the notes.org template tripping four flags in every project [#C]. Two of the three were independently reported by takuzu and smoke tonight, which is what prompted checking them.
+
+The three bug tasks record their severity x frequency arithmetic in the body, including where the two inputs disagreed, so the grades can be argued rather than just overridden.
+
+** 02:47 — Finished the park path the fire skipped
+
+The inbox-boundary Stop hook caught a real gap. Fire 1 wrote the four proposals into the approval queue and stopped there, but =inbox.org='s park path is three things, not one: move the proposal into =working/<slug>/= beside a prepared diff, file a =[#B]= VERIFY carrying the decision package, and reply to the sender. I'd done the queue entry and none of the rest, so the senders were waiting on nothing and the decisions lived only in a session anchor that gets archived.
+
+Completed all four. Each =working/= dir now holds the original proposal, a =proposed.diff=, and the full proposed file. Both org-file diffs were verified by linting the proposed version: the notes.org template goes from four flags to zero, and the fix turned out to need one thing smoke didn't identify — the =invalid-block= pair isn't the example content confusing the parser generally, it's the literal =** Feature Name or Topic= line inside the block, which org reads as a heading. A comma-escape on that one line clears both. Also found two more column-0 bold lines beyond the two reported.
+
+Replies sent to takuzu, archangel, and smoke. dotfiles' ack needed nothing back and is marked PROCESSED. Inbox is at zero.
+
+** 03:35 — Fire 2 (working)
+
+Lock acquired, branch state verified. Digest:
+
+- P1 roam pull — ran, already up to date.
+- P2 inbox zero — ran, no-op. Project inbox at zero. Roam inbox unchanged at 9; 8 belong to other projects and route at wrap-up, the 1 rulesets item is already queued.
+- P3 triage intake — skipped: no active source surviving the mail/messenger exclusion.
+- P4 todo cleanup — ran in check mode across all three passes, zero fixes, zero conversions, zero archives.
+- P5 task audit — mechanical subset only, per the parked guidance and takuzu's note that the full audit is too heavy hourly. Staleness is 20, up from 13 at startup, which is just tonight's 7 new tasks arriving never-reviewed. Not a defect.
+- P6 working-files hygiene — ran, and this fire is the first where its probe fires, since fire 1's park work created =working/=. All four dirs have open backing VERIFY tasks, so nothing to flag. The pass works.
+- P7 spec status board — ran. Seven IMPLEMENTED, two READY (inbox-workflow-consolidation, encourage-kb-contribution). No DOING spec with a closed build parent.
+- P8 link integrity — ran across todo.org, notes.org, the anchor, and all nine specs. Two findings, both in the docs-lifecycle spec, both prose containing a bare =file:= that org parses as a bracketless link (=file:→id:= in a sentence about converting link types, and =keep-file:-links-through-pilot= in a hyphenated phrase). Org behaving as documented rather than a defect, so a digest line and no task.
+- P9 git health — main still 1 ahead of origin, unpushed, awaiting the approval-queue item. Sentry branch correctly has no upstream. No stale branches.
+- P10 prep freshness — skipped: no =daily-prep/=.
+- P11 bug finding — rotating area this fire: the =.ai/scripts/= shell helpers. Shellcheck clean on all seven of the sentry-critical ones (agent-lock, agent-roster, capture-guard, flashcard-sync, inbox-status, self-inject.sh, session-context-path). One SC2034 pair in task-review-staleness.sh, verified as a false positive — the two names are positional fields in a =read -r= that exist to put =value= in the right slot. Digest line, not a task.
+
+*** Retraction: the [#B] I filed in fire 1 was wrong
+
+The main result of this fire is negative. Fire 1 filed a =[#B]= claiming lint-org resolves =file:= links against the process cwd rather than the linted file's directory. It doesn't, and the task is CANCELLED.
+
+The claimed evidence was that linting the notes.org template from the repo root reports two siblings missing while linting from its own directory doesn't. The first half was never run. Those findings came from the =/tmp= copy I made while preparing smoke's diff, and in =/tmp= the siblings genuinely are missing, so org-lint was right. I compared two different files and read the difference as a bug, then wrote a fix direction for a mechanism I hadn't checked.
+
+Caught it here only because P8 surfaced =link-to-local-file= again and I went looking for the checker in lint-org.el to fix it. It isn't there — it's org-lint's own, which contradicted my stated fix and forced the retest. Three runs plus a direct =default-directory= probe settled it.
+
+Correction sent to takuzu, who had been told about it. Their own finding is unaffected and still filed. The useful residue: both =link-to-local-file= and the todo-format checkers are upstream org-lint checkers, so scoping them means filtering org-lint's output, not editing a local checker — which changes the fix direction on the [#C] task too.
+
+** 04:35 — Fire 3 (working)
+
+Lock acquired, branch state clean. Digest:
+
+- P1 roam pull — ran, already up to date.
+- P2 inbox zero — ran, no-op. Both inboxes unchanged.
+- P3 triage intake — skipped: no active source surviving the mail/messenger exclusion.
+- P4 todo cleanup — ran. Hygiene and convert-subtasks no-op; =--archive-done= moved one subtree, the =CANCELLED= lint-org task fire 2 retracted. Committed 962f3c0.
+- P5 task audit — mechanical subset. Staleness 19, down one from the archive.
+- P6 working-files hygiene — ran. All four =working/= dirs still have open backing VERIFY tasks. Nothing to flag.
+- P7 spec status board — ran. Two READY specs, no DOING with a closed parent.
+- P8 link integrity — ran across todo.org, notes.org, and all nine specs. Same two known prose false positives in the docs-lifecycle spec, unchanged. No new findings.
+- P9 git health — main 1 ahead of origin, unpushed, still queued. No stale branches.
+- P10 prep freshness — skipped: no =daily-prep/=, no broken symlinks.
+- P11 bug finding — rotating area this fire: the Python scripts under =.ai/scripts/=. All 15 compile; no Python linter on this box, so the pass became a targeted read of =inbox-send.py=, the script this session has exercised hardest. Three defects verified, filed as two tasks (8995016).
+
+*** The inbox-send finding
+
+The one that matters: =inbox-send= writes straight to the destination path, and =write_text= truncates on open, so any mid-write failure leaves a zero-byte =.org= in *another project's* inbox. That phantom isn't inert — =inbox-status= counts it, so it trips the receiving project's boundary hook and blocks a turn there over a file with no content and no sender context. Meanwhile the sender saw an error and retries, so the target collects a second one.
+
+Reproduced end to end with a non-ASCII message under a C locale with UTF-8 mode disabled: send fails, zero-byte file lands in the destination, =inbox-status= there reports it pending. The encoding case is just the trigger I could force; the defect is the non-atomic write, which a full disk or an interrupted process reaches the same way. Graded [#B], fix direction is temp-file-plus-=os.replace= with =encoding="utf-8"= pinned.
+
+Two smaller ones grouped as [#C]: =send_file= raises an uncaught =PermissionError= traceback because =main= catches only =ValueError= and =FileNotFoundError=, and =discover_projects= doesn't dedupe, so a roots config naming both a parent and its child lists the same project at two indices.
+
+Notable that this is the first fire where the rotating area produced findings in code the night's own work depended on. Fire 1 read the linter, fire 2 the shell helpers, fire 3 the script that carried every reply I sent.
+
+** 05:35 — Fire 4 (working)
+
+Passes 1 through 10 all quiet: roam already current, both inboxes unchanged, todo cleanup no-op across all three passes, all four =working/= dirs still backed by open VERIFY tasks, two READY specs and nothing stuck, the same two known prose false positives in the docs-lifecycle spec, main still 1 ahead and unpushed, no prep dir and no broken symlinks. Staleness 21, up two from fire 3's new tasks.
+
+P11 rotating area: =claude-templates/bin/= — the launcher and paging scripts. Shellcheck clean on all four. The findings came from reading and from a config-sanity check, and one of them is a live condition on this machine rather than a latent code defect.
+
+*** ratio's second Signal account has been cold for 8 days
+
+=signal-cli listAccounts= on ratio warns that messages were last received 8 days ago. The natural read is that the pager channel has gone stale, which would matter — it's the "text me" path. It hasn't. Established by elimination: ratio holds two accounts, =signal-receive.sh= hardcodes the pager (=+15045173983=) as its only target, and the timer's own journal shows it draining that account cleanly every 15 minutes, the last run 2 minutes before the warning printed. So the cold account is Craig's personal number, and nothing on this machine keeps it warm.
+
+What I can't establish is whether that matters. The session history describes that registration as note-to-self with no push, and Signal's tolerance for a quiet linked device isn't something I verified. Filed as [#C] with the uncertainty stated rather than graded up on a guess. If it does matter, the fix is a second timer instance rather than a code change, since =signal-receive.sh= already takes an account argument — which makes it a dotfiles handoff, that repo owning the unit. Not sent: the content is speculative and a 05:35 handoff asserting a problem I haven't confirmed would land as noise.
+
+*** agent-text's direct send is unbounded
+
+The relay path passes =ConnectTimeout=10= to ssh; the local-account path calls =signal-cli send= with no bound. Since agent-text is invoked by agents, a stall blocks the calling turn with no output and no way to tell a hang from a slow send. Not hypothetical contention — the receive timer holds the same account for ~16 seconds every cadence. Filed in the same [#C].
+
+** 06:35 — Fire 5 (working, but the bug hunt came up empty)
+
+Passes 1 through 10 all no-op: roam current, both inboxes unchanged, todo cleanup clean across all three passes, four =working/= dirs still backed, two READY specs, the same two known prose false positives, main 1 ahead unpushed, no broken symlinks. Staleness 22, up one from fire 4's new task.
+
+P11 rotating area: =hooks/= and =scripts/= — the machine-wide hooks and the repo's own install and maintenance scripts, the last major uncovered surface. *Zero real bugs.* All five shell hooks shellcheck clean, all Python hooks compile, =hooks/tests= present, =hooks/__pycache__= correctly gitignored with nothing tracked. Six shellcheck findings across =scripts/=, every one dispositioned as a non-bug:
+
+- =SC2094= (read and write the same file in one pipeline) in =install-ai.sh= and =sweep-gitignore-tooling.sh= — false positive both times. The pattern is a =>>= append with a =[ -s "$gi" ]= stat inside the group. Appending doesn't truncate, and a stat isn't a content read, so the leading-blank-line logic is correct in both the file-exists and file-absent cases.
+- =SC2088= (tilde doesn't expand in quotes) in =doctor.sh= and =audit.sh= — both are display strings printed to the user, not paths used for I/O. =audit.sh= says so in a comment on the line above.
+- =SC2295= (unquoted expansion inside =${..}=) in =audit.sh= and =diff-lang.sh= — technically correct, no live trigger; =$HOME= carries no glob characters.
+- =SC2164= (=cd= without =|| exit=) in =lint.sh= and =status.sh= — =status.sh= already guards its path upstream. =lint.sh= is genuinely unguarded and, since it sets =-u= but not =-e=, a failed =cd= would let it lint the invocation directory instead of the repo root. Confirmed =cd ""= fails rather than silently succeeding, so the hazard is real in shape but needs the running script's own parent to be unreachable. Not reachable in practice; a one-line =|| exit= would close it if anyone touches the file.
+
+This is the first fire whose hunt found nothing, which is the expected shape — takuzu's dogfood report predicted the rotating hunt goes quiet after the first few fires clear the standing defects. Recording the area covered so the next fire doesn't re-read it.
+
+*** Two measurement errors this fire, both caught before they became findings
+
+Worth flagging as a pattern, since fire 1's retraction was the same class. First, a probe loop passed =--convert-subtasks --check= through an unquoted variable; zsh doesn't word-split, so it arrived as one argument and =todo-cleanup= printed a bare =normal-top-level()=. That looked like a tool crash and was my loop. Protocols warns about exactly this.
+
+Second, chasing that, I read =exit=0= from a bad-flag run and nearly filed a silent-pass defect — a cleanup tool that exits 0 on a bad flag would let =wrap-it-up= believe a pass ran when it didn't. The =0= was =tail='s exit code, not emacs'. Measured without the pipeline, both =todo-cleanup= and =lint-org= exit 255 on an unknown flag and on a missing file, and 0 on success. No defect.
+
+Three times tonight a conclusion came from a bad measurement. Twice it was caught in the same fire; once (fire 1) it reached a filed task and a sent handoff before the retest caught it.
+
+** 07:35 — Fire 6 (working) — the night's most consequential finding
+
+Passes 1 through 10 all no-op again: roam current, inboxes unchanged, todo cleanup clean, working dirs backed, two READY specs, the same two prose false positives, main 1 ahead unpushed, no broken symlinks. Staleness 22, flat.
+
+P11 rotating area: =languages/= and the bundle install machinery — the last major uncovered surface. Not quiet.
+
+*** The python and typescript bundles ship no secret-scan hook
+
+=bash=, =elisp=, and =go= each carry =githooks/pre-commit=, =claude/hooks/validate-*.sh=, =claude/settings.json=, and a seed =CLAUDE.md=. =python= and =typescript= carry none of the four. The pre-commit hook is the credential scanner, so installing either of those bundles gives a project no secret scan on commit — while README's Bundle structure section documents all four as what every bundle follows.
+
+Verified live rather than inferred, by scanning every project with =.claude/rules/=: =work= (python) and =clock-panel= (python + typescript) both have no =githooks/= and no settings. All four elisp projects have both. =work= is the one that matters — a work repo is where a leaked credential costs most and is likeliest to reach a company remote.
+
+The history settles intent. Both bundles were added 2026-05-31; =go='s githooks landed 2026-06-02 and =bash='s 2026-06-23. The rollout swept the bundles added *after* these two and skipped these two. And =install-lang.sh= guards its copy with =[ -d "$SRC/githooks" ]=, so the install succeeds and reports nothing missing, which is how this stayed invisible for almost two months.
+
+Filed [#A], SCHEDULED today — the only [#A] of the night. The fix is find-not-fix per the pass contract, so nothing was changed. The task carries a second half worth as much as the port itself: make =install-lang= warn when a bundle lacks a component the README documents, so the next partial bundle announces itself rather than installing quietly.
+
+*** Same drift shape, smaller
+
+=bash='s pre-commit has =cd "$REPO_ROOT" || exit 1=; the =go= and =elisp= copies of that line dropped the guard. Impact is genuinely low — git chdirs to the working-tree root before running a hook, so the checks run against the right tree regardless, and neither script sets =-e=. Filed [#C] for the two-character fix, mostly because the shape is the same as the [#A]: a fix landed in one bundle copy and stopped there.
+
+Two fires' worth of evidence now says bundle-to-bundle propagation is where this repo leaks changes.
+
+** 00:51 — Fire 1 of the 2026-07-24 run (working) — first fire with pass 12 live
+
+Lock acquired, branch =sentry/2026-07-24-ratio= verified, tree clean outside the spine. Digest:
+
+- P1 roam pull — ran, already up to date.
+- P2 inbox zero — ran, no-op. Inbox at zero (the question-capture proposal was parked during entry).
+- P3 triage intake — skipped: no active source surviving the mail/messenger exclusion.
+- P4 todo cleanup — ran with real work. =--archive-done= moved three completed speedrun tasks out of Open Work into Resolved; =--convert-subtasks= normalized the tree. Committed.
+- P5 task audit — mechanical subset. Staleness 13, back to the pre-speedrun baseline now that the four solo tasks closed.
+- P6 working-files hygiene — ran. All =working/= dirs still have open backing VERIFY tasks (the parked proposals). No orphans.
+- P7 spec status board — ran. Two READY specs, nothing stuck.
+- P8 link integrity — ran. The docs-lifecycle spec still shows its two known prose false positives (=file:→id:= and =keep-file:-links-through-pilot=, bare =file:= tokens org parses as bracketless links). Correctly unaffected by tonight's spec-scoping, since =link-to-local-file= is org-lint's own checker, not a todo-format one. No new findings.
+- P9 git health — main level with origin, sentry branch correctly has no upstream, no stale branches.
+- P10 prep freshness — skipped: no =daily-prep/=.
+- P11 bug and refactor finding — ran, first fire under the widened pass. Rotating area: the cross-project routing scripts (=route_recommend.py=, =broadcast.py=), untouched by the previous six areas. One verified latent bug filed [#D] (below). Refactor note not worth a task: =recommend='s two weak-tier branches collapse to a single =_tiebreak= call, since =_tiebreak= on a one-element list returns that element. Two lines, no behavior change, so it's a digest line rather than backlog noise.
+- P12 solo-task implementation — *active this run* (=:SENTRY_MAY_IMPLEMENT: yes=) but a correct no-op: zero eligible tasks. All four open =:solo:= TODOs were completed in tonight's speedrun, so the ready bucket is empty. Nothing to implement, nothing deferred.
+
+*** The finding
+
+=route_recommend='s =discover_destination_names= collapses projects to bare basenames, so two projects sharing a basename across roots would both literal-match and read as an ambiguous tie, downgrading a correct strong match to weak. Reproduced by direct probe. Latent rather than live: 27 projects, 27 distinct basenames today. The destination stays right, only the tier is wrong, so the cost is an extra routing prompt. Filed [#D] with the order-preserving dedupe as the fix.
+
+** 01:33 — Fire 2 (working) — pass 12's first real implementations
+
+Digest:
+
+- P1 roam pull — ran, already up to date.
+- P2 inbox zero — ran. Project inbox at zero. The roam inbox dropped 9 → 5 (another session filed some); all 5 remaining belong to archsetup or work, none rulesets-claimed, so roam mode is correctly a no-op here.
+- P3 triage intake — skipped: no active source.
+- P4 todo cleanup — ran, all three checks clean (fire 1 did the archiving).
+- P5 task audit — mechanical subset. Staleness 13, flat.
+- P6 working-files hygiene — ran. All =working/= dirs still backed by open VERIFYs.
+- P7 spec status board — ran. Two READY specs, nothing stuck.
+- P8 link integrity — ran. Only the docs-lifecycle spec's two known prose false positives.
+- P9 git health — main level with origin, sentry branch correctly upstream-less.
+- P10 prep freshness — skipped: no =daily-prep/=.
+- P11 bug and refactor finding — rotating area: the cj-comment tooling (=cj-scan.py=, =cj-remove-block.py=). Two findings filed, one of them serious.
+- P12 solo-task implementation — *two tasks implemented and committed to the branch* (17f5d48, 1b0f284). Both premise-checked before a line was written.
+
+*** The serious find: cj-remove-block destroys content
+
+=looks_like_cj_range= validated only the first and last lines of a range. A span from one cj block's opener to a *later* block's closer passed, and the removal then deleted everything between — prose, headings, whole tasks — silently, exit 0. That is exactly the failure the check exists to prevent, and drift is its normal case, since =respond-to-cj-comments= edits the file while processing and a file under cj review usually holds several blocks. Reproduced on a two-block fixture that lost a heading and two content lines. Filed [#B], then implemented in pass 12 after an independent re-verification on a different fixture shape.
+
+Same file, second defect fixed alongside: =remove_range= rewrote the org file with a bare =write_text= (truncates on open) and took no backup, so a mid-write failure would leave =todo.org= truncated with nothing to recover from. =lint-org.el= already backs these files up to =/tmp= before mutating; cj-remove-block now matches that and writes atomically.
+
+*** The measurement lesson, third time tonight
+
+A full-suite run went red on =audit.bats= test 4. I stashed my changes, saw it pass clean, and had a one-sample A/B pointing straight at my own diff. Re-ran three times with the changes restored and it passed every time. The failure is an intermittent teardown flake (=rm -rf= racing something still writing into a fixture =.git/objects=), not my change, and I nearly filed the wrong cause off a single sample. Filed [#C] with the git-background-gc theory explicitly labelled a lead rather than a verified cause.
+
+Committed only on a genuinely green re-run, not on the red with a hand-wave.
+
+** 02:33 — Fire 3 (working) — the flake's cause traced, and my own lead disproved
+
+Digest:
+
+- P1 roam pull — ran, already up to date.
+- P2 inbox zero — ran, no-op. Project inbox at zero; roam holds 5, none rulesets-claimed.
+- P3 triage intake — skipped: no active source.
+- P4 todo cleanup — ran. =--archive-done= moved fire 2's two completed tasks to Resolved. Committed.
+- P5 task audit — mechanical subset. Staleness 13, flat.
+- P6 working-files hygiene — ran, all dirs backed.
+- P7 spec status board — ran, two READY, nothing stuck.
+- P8 link integrity — ran, only the known docs-lifecycle prose false positives.
+- P9 git health — main level with origin, branch upstream-less, nothing stale.
+- P10 prep freshness — skipped.
+- P11 bug and refactor finding — no new findings. The fire's whole investigative budget went to confirming fire 2's filed flake, which is the honest place for it; a hunt that finds nothing new is a result.
+- P12 solo-task implementation — one task implemented and committed (7f45d4b).
+
+*** Confirming the cause before fixing, and disproving my own lead
+
+Fire 2 filed the =audit.bats= flaky teardown with a stated theory: =gc.auto='s loose-object threshold. Pass 12's premise check went after that theory rather than the fix, and killed it — a fixture holds five objects against a default threshold of 6700, so that mechanism cannot fire.
+
+The real cause came from a =GIT_TRACE= run: =git commit= spawns =git maintenance run --auto --quiet --detach= on git 2.55. The commit returns while the detached process is still writing a pack, and teardown's =rm -rf= races it. Every failed run had left a =tmp_pack_*= behind, which is the thread that led there.
+
+Fix: =maintenance.auto false= plus =gc.auto 0= in the fixture, killing the background writer rather than retrying the delete (a retry loop hides a live process instead of removing it). Validated over 20 consecutive clean runs against a ~1-in-8 baseline, and recorded honestly in the task that 20 clean runs alone would be ~7% likely by luck — the trace is the evidence, the runs confirm.
+
+This is the second night running where the premise check changed the outcome. Fire 2 it stopped a wrong causation call; here it stopped me implementing a fix for a mechanism that was never operating. Both times the cost of checking was minutes and the cost of not checking would have been a plausible, wrong, committed change.
+
+** 03:33 — Fire 4 (working) — two hypotheses killed, one real gap closed
+
+Digest:
+
+- P1 roam pull — ran, up to date. P2 inbox zero — no-op, both surfaces clean. P3 triage — skipped, no active source.
+- P4 todo cleanup — ran. Archived fire 3's completed task to Resolved. Committed.
+- P5 task audit — mechanical subset. Staleness 13, flat all night.
+- P6 working-files — all dirs backed. P7 spec board — two READY, nothing stuck. P8 link integrity — only the known prose false positives. P9 git health — clean. P10 — skipped, no prep dir.
+- P11 bug and refactor finding — rotating area: the org-file mutators, chosen because =cj-remove-block= yielded a serious find in that same class last fire. One gap filed.
+- P12 solo-task implementation — one task implemented and committed (0686784).
+
+*** What the area review actually found, and what it disproved
+
+The lead was that =todo-cleanup.el= might lose data. It rewrites =todo.org=, creates archive files, and moves subtrees *between* files, which is the shape that bit =cj-remove-block=. Two specific hypotheses, both tested, both wrong:
+
+- *"A mid-move failure loses a subtree from both files."* No. The order is delete-from-buffer, write-archive, save-todo.org-last, so an archive-write failure aborts before the save. Verified by making the archive directory unwritable: exit 255, =todo.org= byte-identical, content intact.
+- *"Errors are swallowed on the mutation path."* No. The only =ignore-errors= in the file wrap =call-process "git"=, never a write.
+
+What survived was narrower and real: todo-cleanup mutates with *no backup*, while both sibling mutators (=lint-org.el=, =wrap-org-table.el=) copy to =/tmp= first, and =cj-remove-block= joined them last fire. It is also the one that runs most often. Confirmed empirically that Emacs's own backup does not fire under =--batch -q=, so there was genuinely no undo short of git. Filed [#C], then implemented in P12.
+
+Also checked my own change for regression rather than assuming: a missing input file exits 255 and creates nothing, identical to the pre-change version tested from git.
+
+*** Running tally on the premise habit
+
+Three fires, three times it changed the outcome. Fire 2 it stopped a wrong causation call. Fire 3 it disproved my own filed =gc.auto= theory before I could implement against it. Here it killed two data-loss hypotheses before they became tasks, leaving only the gap that was actually there. The pattern is consistent: the cheap check keeps a plausible story from becoming a committed change.
+
+** 04:33 — Fire 5 (working) — the first real defer, and last fire's fix proving itself
+
+Digest:
+
+- P1 roam pull — ran, up to date. P2 inbox zero — no-op, both surfaces clean. P3 triage — skipped, no active source.
+- P4 todo cleanup — ran, archived fire 4's completed task. *Confirmed last fire's backup fix working live*: the real =--archive-done= run left =/tmp/todo.org.before-todo-cleanup.20260724-043331=. Dogfooded within an hour of shipping.
+- P5 task audit — staleness 13, flat all night. P6 working-files — all backed. P7 spec board — two READY. P8 link integrity — only the known prose false positives. P9 git health — clean. P10 — skipped.
+- P11 bug and refactor finding — rotating area: the attachment/email handlers, chosen because they parse genuinely untrusted input, unlike every internal-tooling area covered so far. One finding filed.
+- P12 solo-task implementation — *deferred*, and correctly. First defer of the run.
+
+*** The find: attachment filenames are partly sanitized, in two different ways
+
+Both writers derive on-disk names from the =filename= an email declares, and both sanitize incompletely, covering *different* gaps. =eml-view= cleans the name but interpolates the extension raw. =gmail-fetch='s =safe_filename= handles path separators and leading =..= and nothing else. Probed both with the same adversarial set: a =; rm -rf ~= extension survives in both, a literal newline survives in both, a 300-character extension produces a 314-character filename in both.
+
+Two things I checked so this doesn't get over-graded later. Not RCE — files are written through Python =open=, never a shell. Not path traversal — =splitext= only returns an extension when the last dot follows the last separator, so =ext= can never hold a slash, and the traversal case is neutralized in both scripts. The genuine harms are narrower: a newline in a filename breaks downstream tooling that reads the directory as a line-delimited list, and an unbounded extension blows the 255-byte limit so a crafted attachment aborts extraction. Graded [#C] on that honest read rather than the scarier one.
+
+I also corrected my own framing mid-investigation. I first read this as the familiar "one sibling hardened, the other not" pattern from the last two fires. It isn't — =safe_filename= is narrower than it looks, and the two scripts are *differently* incomplete. Neither handles newlines or length.
+
+*** Why pass 12 deferred instead of implementing
+
+The fix itself is clear, but where the shared sanitizer lives is a design call: a shared helper module (clean, but a new synced template file plus =importlib= gymnastics for kebab-named scripts), duplicate it in both (self-contained, but drift — the exact defect class that produced three separate findings tonight), or patch each in place (smallest diff, permanent divergence). That is deliberation, not a quick factual question, so checklist item 4 fires and the unattended loop defers. Filed the VERIFY with the three options and my lean, and left the task *un-=:solo:=-tagged* so a later run doesn't pick it up and guess.
+
+This is the checklist discriminating rather than rubber-stamping. Four fires implemented; this one correctly didn't.
+
+sentry at 05:33: nothing (bug hunt swept =scripts/*.py=; two candidate gaps both disproved — =workflow-integrity.py= *is* gated, its bats runs the real checker against the real canonical tree under =make test=, and =update-skills.py= is an on-demand maintenance command rather than a gate. Recorded so neither gets re-investigated.)
+
+sentry at 06:33: nothing (bug hunt swept =wrap-org-table.el=, the third org-file mutator and the one with prior history — its load-time dispatch caused the 2026-07-09 corruption. Clean on every probe: the entry-script guard correctly refuses to dispatch when lint-org merely =require='s it, it backs up before writing like its siblings, and it is block-aware — a table inside =#+begin_example= stayed verbatim while a real over-budget table wrapped onto continuation rows with rules. Second consecutive quiet hunt.)
+
+** 07:33 — Fire 8 (working) — a time bomb I planted four fires ago went off
+
+Digest: P1-P10 all no-op (roam current, both inboxes clean, todo cleanup clean, staleness 13, working dirs backed, two READY specs, only the known prose link false positives, git clean). P11 found a real coverage gap. P12 implemented it, and the suite caught a regression of my own making.
+
+*** The find: the only ungated shell in the repo
+
+=scripts/lint.sh= sweeps =scripts/*.sh=, the language hooks, and the language githooks. It never touched =claude-templates/bin/= — zero references. Those four scripts (=ai=, =agent-text=, =agent-page=, =install-ai=) are the ones =make install= symlinks onto PATH, which makes them the *most* exposed shell in the repo and left them the only shell with no gate over it. All four are clean today, so the guard is a no-op by design; it exists so a future regression can't pass silently. The =ai= launcher was hardened to 42 tests recently and nothing enforced that going forward.
+
+The test pins *coverage*, not cleanliness: it plants a broken file in each swept location and asserts lint complains, so a location that stops being swept fails the suite rather than passing quietly.
+
+Surfaced a bigger question I did *not* answer overnight: rulesets ships shellcheck enforcement to consuming projects (the bash bundle's pre-commit, =validate-bash.sh=) and runs none on itself. Filed as a VERIFY, because turning it on would surface the false positives dispositioned earlier this session and choosing between fixing them or adding disable-directives is a preference, not a fact.
+
+*** The regression: my own test, detonating on schedule
+
+The full suite went red on the two backup tests I added in fire 4. Not a flake — a time bomb. They globbed =/tmp/todo.org.before-todo-cleanup.*=, but the backup name derives from the file's *basename*, and the real =todo.org= shares it. So a live sentry run's genuine backup was indistinguishable from the test's own artifact, and the check-mode test (which asserts *no* backup exists) failed the moment fire 5's real archive pass created one.
+
+They passed when written only because no real backup existed yet. Three now sit in =/tmp=. Both tests rebind =temporary-file-directory= to a private dir, and I verified they pass with the real backups present rather than by clearing them.
+
+Worth naming plainly: I shipped a test whose correctness depended on the state of a shared directory that the code under test writes to in production. That is the "no shared mutable state" rule in =testing.md=, and I broke it while fixing a different durability bug. The suite caught it two fires later, which is the argument for running the full suite every fire rather than only the touched file.
+
+* Sentry approval queue (2026-07-23)
+
+Five items. Each names what, why, and the exact edit. Items 2 through 5 are also filed as =VERIFY [#B]= tasks in =todo.org= with prepared diffs under =working/=, so they survive this anchor being archived — say "approve the parked <topic>" for any of them.
+
+** 1. Push main to origin
+
+What: =git switch main && git push origin main= (then switch back, or leave main checked out if sentry is done).
+
+Why: commit 33949c5 (the two .dotfiles amendments) is on main and unpushed. The sentry spec called this out — an unpushed commit on main diverges across ratio and velox and breaks the next startup fast-forward. It's ahead-only, so the push is clean. Held because =commits.md= requires explicit confirmation before any push.
+
+** 2. interaction.md — remove the fenced-code-block carve-out (org-drill)
+
+What: in =claude-rules/interaction.md=, the "No Reverse-Video Highlighting in Chat Output" rule currently says fenced code blocks "are acceptable when the user explicitly wants a block to copy". Replace that sentence with a plain-text-always statement covering fences as well.
+
+Why: org-drill relayed Craig's 2026-05-30 direction ("always always list it out without markup"), saved there as a project memory. The rule as written contradicts it, and the directive belongs in the shared rule rather than one project's memory. It's a convention change to an always-on rule, so it parks rather than lands.
+
+Note: this session violated the tightened form of the rule several times already (fenced blocks in chat), which is evidence for the proposal rather than against it.
+
+** 3. sentry.org Living Document — fold in the two dogfood runs (takuzu, archangel)
+
+What: append to sentry.org's pass list and notes: (a) make the rotating-angle bug hunt an official pass — already done tonight as pass 11, so this reduces to noting takuzu's corroboration; (b) add randomized property sweeps as a sanctioned quiet-fire activity; (c) note that =todo-cleanup --archive-done= touches =.gitignore= on its first archive, so an "org-only" pass can produce a real commit and trigger the fire-end suite; (d) note that in a project gitignoring =.ai/=, quiet fires produce zero commits, so =git log main..sentry/*= understates the night and the anchor's heartbeat list is the only record; (e) replace the per-fire full task audit with a mechanical subset hourly plus judgment items queued once nightly.
+
+Why: two independent first-live-run reports, no engine defects in either. Item (e) matches what fire 1 did by instinct (P5 deferred). sentry.org is a shared synced asset, so the edit parks.
+
+** 4. notes.org template — clear the four lint flags (smoke)
+
+What: in =claude-templates/.ai/notes.org=, rephrase the two column-0 =**bold**= lines so neither starts with =**=, and comma-escape the =#+begin_example= block's own markers. Then =scripts/sync-check.sh --fix=.
+
+Why: filed as a [#C] bug task tonight with the reproduction. The edit itself parks because it changes a template every project inherits. Related finding worth Craig's attention: those two flags are mechanical, not judgment, so =lint-org --fix= run anywhere would rewrite the template and create drift against canonical.
+
+** 5. wrap-it-up.org — delete temp/ at wrap (from Craig's own roam capture)
+
+What: add a cleanup sub-step to =.ai/workflows/wrap-it-up.org= that removes the project's =temp/= contents during teardown.
+
+Why: Craig's roam inbox item asks for exactly this ("temp is ... deleted as a part of the wrap up sequence"). The rest of that capture shipped on 2026-07-20 (=working/= tracked from creation, =temp/= gitignored, the sweep backfilling it), but =wrap-it-up.org= has no mention of =temp/= at all, so this clause was missed. Parks as both a shared-asset edit and a destructive one.
+
+** 02:35 — Craig-ordered amendments applied before launching sentry (earlier)
+
+Amendment 2 — KB personal roots (=.dotfiles=, 2026-07-21). =~/.dotfiles= classified Unknown under =knowledge-base.md='s personal-roots list, blocking KB writes from that project twice (the sentry pass-list rule tonight, the xdg-desktop-portal gotcha on 2026-07-04). Grepped for other copies of the enumeration: =knowledge-base.md:25= is the only place that lists roots for *classification*. The other three hits (=triggers.md=, =broadcast.org=, =session-harvest.org=, =work-the-backlog.org=) enumerate roots for project *discovery*, a different question, and =~/.dotfiles= reaches those through the machine-local =~/.claude/inbox-roots.txt=. Edited the one line.
diff --git a/.ai/sessions/2026-07-25-09-24-invalid-block-filter-push-task-review.org b/.ai/sessions/2026-07-25-09-24-invalid-block-filter-push-task-review.org
new file mode 100644
index 0000000..d0814f9
--- /dev/null
+++ b/.ai/sessions/2026-07-25-09-24-invalid-block-filter-push-task-review.org
@@ -0,0 +1,83 @@
+#+TITLE: Session Context — 2026-07-24
+#+AUTHOR: Craig Jennings
+
+* Summary
+
+** Active Goal
+
+An evening of backlog clearing in the order Craig picked: push the delivery-blocking commits, fix the lint-org =invalid-block= false positive that home had just unblocked, then run a task-review cycle. All three finished.
+
+** Decisions
+
+- The =invalid-block= fix is a filter on org-lint's output, not a local checker edit. Home settled the open question and I re-verified both halves before acting: the string appears nowhere in =lint-org.el=, and =org-lint--checkers= enumerates it in batch Emacs. Same resolution as the earlier =link-to-local-file= episode.
+- Left the =,**= comma-escape in =claude-templates/.ai/notes.org= in place. The task said the fix "lets that escape be reverted," but the escape is correct org for a literal =**= inside a verbatim block, so reverting trades correctness for nothing once the finding is suppressed. Reasoning recorded in the closed task rather than acting on the permission silently.
+- Task review: all seven in the batch kept as-is, no new =:quick:= or =:solo:= tags, confirmed by Craig in one pass.
+- Work's sentry loop was left alone. Craig said "sentry stop" here, but the running loop belongs to the work project (branch =sentry/2026-07-25-ratio=, firing hourly into =aiv-work:1.1=), and the stop procedure's later steps — lock release, branch disposition, approval queue — need that session's context. Surfaced rather than half-executed.
+
+** Data Collected / Findings
+
+- =invalid-block= is org-lint's own checker. Reproduced the false positive three ways: a paired example block with a heading-shaped body line flags both delimiters, a paired src block holding a literal =#+end_example= flags three lines, and a genuinely unterminated block flags once and must keep doing so.
+- Verified the fix against home's live fixture in both directions: its =.ai/notes.org= produced exactly the two reported findings (lines 386 and 398) under the pre-change script and zero under the new one, file untouched.
+- The uppercase-delimiter path (=#+BEGIN_EXAMPLE=) was handled but untested. Confirmed it was a real trigger — 2 findings before, 0 after — before adding the boundary test.
+- Part of the task-review staleness count measures work that can't be delegated rather than work nobody read. This batch was the deliberation-heavy tail, where every task needs a decision from Craig mid-stream, which is why the speedruns kept stepping past them.
+- The KB orphan task cites a 2026-07-01 snapshot of 53 agent nodes. Startup counted 104, so the KB has doubled and the snapshot is worth even less than the task body assumed.
+- Five =[#D]= tasks carry no =:LAST_REVIEWED:= at all. The staleness script excludes =[#D]= but =lint-org= flags them, so the two tools disagree permanently. Left alone; needs a decision about which is right.
+
+** Files Modified
+
+- =claude-templates/.ai/scripts/lint-org.el= (+ mirror) — =lo--matched-block-regions= pairs blocks by line scan under org's real rule, memoized on the buffer modification tick; =lo--handle-item= drops an =invalid-block= finding inside a paired region.
+- =claude-templates/.ai/scripts/tests/test-lint-org.el= (+ mirror) — five tests: heading-in-example, literal =#+end_example= in src, uppercase delimiters, unterminated block still reports, and one file with both proving per-block scoping.
+- =todo.org= — closed the =invalid-block= task with its verification record, stamped seven review dates, inserted a missing properties drawer.
+- KB: =agents/20260725093500-parser-cannot-verify-its-own-misreading.org=.
+
+** Next Steps
+
+- Work's sentry is still running on ratio and untouched. Stopping it properly means saying "sentry stop" in the work session (pane =aiv-work:1.1=), which handles the branch disposition and the overnight approval queue.
+- Home is waiting on this push to re-run its =invalid-block= fixture.
+- Next review batch starts with the agent-source improvements and flashcard tooling tasks.
+- Seven parked VERIFYs still await Craig, the =[#A]= account-binding guard from home first.
+- The sentry spec still wants Craig's deep read before the READY flip.
+
+KB: promoted 1 / consulted no
+
+* Session Log
+
+** Startup
+
+Startup ran clean: rulesets already current, =make install= had nothing new to link, project repo up to date with origin (5 commits ahead, unpushed — carried over from the prior session by Craig's choice). =.ai/= synced from templates with no churn. No crash anchor. Task staleness reported 13 top-level tasks unreviewed for >7 days.
+
+** Inbox — home's answer on invalid-block
+
+One pending handoff: home answering the open question left in the =[#C] lint-org invalid-block false-positives= task — is =invalid-block= lint-org.el's own checker or org-lint's? Home says org-lint's, and I re-verified both halves rather than taking it: =grep invalid-block= over =claude-templates/.ai/scripts/lint-org.el= returns nothing, and =org-lint--checkers= enumerates =invalid-block= in batch Emacs alongside =link-to-local-file=. So the fix is a filter on org-lint's output, matching the =link-to-local-file= episode.
+
+Folded into the existing task as a dated sub-entry rather than filing anything new — the task was already filed and only needed its open question closed. Also recorded home's regression fixture: its own =.ai/notes.org= PENDING DECISIONS block (lines 386-398) is left unescaped on purpose and trips both delimiters, so the filter should take those two findings to zero without touching the file. Replied to home confirming, and asked them to keep the fixture unescaped pending a ping when the filter lands. Inbox back to zero.
+
+** Pushed main
+
+Craig picked the evening's order: push, then the invalid-block fix, then a task review. Pre-push reconcile showed ahead-only by 5, no divergence, so =git push origin main= went out and verified at 0/0. The secret-scan fail-open fix is now delivered to consuming projects.
+
+** invalid-block filter
+
+Built test-first. Reproduced the failure three ways before writing anything: a paired example block with a heading-shaped body line flags both delimiters; a paired src block holding a literal =#+end_example= flags three lines; a genuinely unterminated block flags once and must keep doing so. Wrote four ERT tests covering those plus a mixed file, watched three fail for the right reason, then implemented.
+
+The fix is =lo--matched-block-regions=: a line scan that pairs blocks under org's real rule (once open, only the block's own =#+end_TYPE= closes it), memoized on the buffer's modification tick. =lo--handle-item= then drops an =invalid-block= finding falling inside a matched region, delimiters included, since org-lint reports at the delimiters. Line-scanning instead of asking org is the whole point — org's parser is the thing that's confused.
+
+One test failed after the implementation on an off-by-one in my own expectation (the unterminated opener is line 7, not 8); the code was right and I corrected the test. Verified against home's live fixture both ways: two findings under the pre-change mirror copy, zero under the new canonical, home's file untouched. Synced canonical → mirror, full =make test= green at exit 0.
+
+Review caught two things I fixed rather than filed: the uppercase-delimiter path was handled but untested (confirmed it was a real trigger — 2 findings before, 0 after — then added the boundary test), and the cache-tick comparison used =eq= where =eql= is strictly correct. Verdict Approve, committed 8822b0d after the voice pass. Pinged home that the filter landed and the fixture can be re-run.
+
+Left the =,**= comma-escape in the notes template alone. The task said the fix "lets that escape be reverted," but the escape is correct org for a literal =**= in a verbatim block, so reverting buys nothing now that the finding is suppressed. Recorded the reasoning in the closed task rather than acting on the permission silently.
+
+** Task review
+
+Batch of 7 from the staleness script, oldest first. Every one came back Keep with no new =:quick:= or =:solo:= tag, and Craig confirmed the batch in one pass. Backed todo.org up to /tmp first (matching the mutator convention) and stamped by exact line number rather than a global replace, since ten other tasks carried the same 2026-07-13 date and only six were in the batch. The Sentry vNext task had no properties drawer at all, so it got one.
+
+Two observations worth keeping. The uniform Keep-with-no-tags result isn't the review going soft: this batch is the deliberation-heavy tail, where every task needs a decision from Craig somewhere in the middle, which is exactly why the speedruns kept stepping past them and why they aged. So part of the staleness count is measuring work that can't be handed off rather than work nobody read. And the KB orphan task cites a 2026-07-01 snapshot of 53 agent nodes; tonight's startup counted 104, so the KB has doubled and the snapshot is worth even less than the task body already assumed.
+
+** Inbox — home's acknowledgment
+
+Home replied confirming both messages landed, the fixture stays unescaped, and they'll pick the filter up after the rulesets push and their next clean startup sync. A pure FYI asking nothing, so it skipped the skeptical review and got no reply (acking an ack loops). Deleted; inbox back to zero. It does corroborate that the unpushed commit is the only thing standing between home and the fix.
+
+** Task review (cont.)
+
+Staleness went 13 → 6. Lint flags five more tasks missing =:LAST_REVIEWED:= entirely (lines 327, 336, 434, 593, 602) — all =[#D]=, which the staleness script excludes but the checker doesn't. Pre-existing, not touched.
diff --git a/.ai/sessions/2026-07-25-15-34-clean-wrap-and-inbox-safe-sync.org b/.ai/sessions/2026-07-25-15-34-clean-wrap-and-inbox-safe-sync.org
new file mode 100644
index 0000000..737b1c5
--- /dev/null
+++ b/.ai/sessions/2026-07-25-15-34-clean-wrap-and-inbox-safe-sync.org
@@ -0,0 +1,85 @@
+#+TITLE: Session Context — 2026-07-25
+#+AUTHOR: Craig Jennings
+
+* Summary
+
+** Active Goal
+
+Guarantee that rulesets can only report a successful wrap with a completely clean Git worktree, while allowing other projects to refresh rulesets when its only residue is untracked inbox deliveries. Implemented, reviewed, fully tested, and prepared for a strict self-hosted wrap.
+
+** Decisions
+
+- One executable, =git-worktree-gate=, owns both repository-state policies. =strict= means no staged, unstaged, untracked, dirty-submodule, or in-progress-operation state; =sync-safe= permits only untracked paths beneath =inbox/=.
+- Cleanup failure is not a degraded wrap. It leaves the session open and must report each path, its Git state, why it cannot be resolved safely, and the decision Craig needs. Dirty-file deferrals, valediction, and teardown are forbidden in that state.
+- Final wrap verification is a HEAD-bound certificate stored in the Git directory and freshly rechecked inside the existing teardown hook. Integrating the check avoids the concurrent-hook race documented by Codex.
+- Another project's structured Edit/Write call may not resolve through an installed symlink into rulesets. The runtime hook denies it and points the sender to =inbox-send rulesets=.
+- The two MCP-registry handoffs were consolidated into one parked =[#B]= specification decision. The memory auditor remains separate; no machine-owned MCP configuration was promoted into canonical rulesets.
+
+** Data Collected / Findings
+
+- The prior rulesets archive completed at 09:24; the inherited three tracked changes were written at 10:27. The repository was dirtied after wrap through a later write path, which is why wrap certification alone needed the symlink-aware boundary guard.
+- The previous wrap prose contradicted itself: clean Git state was an exit criterion, but the leftover and inbox sections allowed explicit deferral. The teardown hook checked only the sentinel.
+- Adversarial review found a fail-open process-substitution edge: a low-level =git status= failure could appear as an empty stream. The gate now captures status and its exit code in Git-directory temporary files and blocks on failure.
+- Current Codex hooks support Stop blocking with =continue=false= and run matching commands concurrently. A user-level =hooks.json= is installed; a new Codex session must complete the normal hook review/trust step.
+- Full =make test= passed twice on the implementation. The final run includes 436 core Python tests, 72 hook tests, language suites, ERT suites, and all Bats suites. Focused additions cover inbox-only pulls, staged/unstaged/untracked/submodule/operation states, status failure, certificate/HEAD drift, Claude and Codex Stop outputs, installation, and realpath-based write denial.
+- The wrap roam sweep found Craig's 120-column table question. It was already enforced by =org-tables.md=, =lint-org='s =org-table-standard= judgment, and =wrap-org-table.el=; the live lint pass flagged the existing over-wide table. Removed the duplicate capture and synced roam.
+
+** Files Modified
+
+- =claude-templates/bin/git-worktree-gate= — shared strict/sync-safe classifier plus certificate/verify modes.
+- Startup protocol/workflow mirrors and =claude-templates/bin/ai= — inbox-only state remains visible but no longer blocks fast-forward refresh.
+- Wrap protocol/workflow mirrors and =hooks/ai-wrap-teardown.sh= — no deferral escape, actionable hard blocker, final certificate, fresh teardown verification.
+- =hooks/rulesets-write-boundary.py=, Claude/Codex hook configuration, cross-project rule, Makefile, and hook documentation — prevent structured writes through installed symlinks and install the enforcement on both runtimes.
+- Bats and pytest suites — repository-state, launcher, teardown, installer, and cross-project boundary regressions.
+- =todo.org= and workflow state — parked the consolidated MCP registry spec decision and recorded inbox processing.
+- Three inherited backlog files — retain the post-09:24 definition that speedrunnable means =:solo:=.
+
+** Next Steps
+
+- Start a new Codex session and review/trust the new user-level hooks when =/hooks= prompts; Claude already reads the linked hook configuration.
+- Say "spec the MCP registry sync" when ready to design the separate host-level registry reconciler.
+- The existing =inbox/lint-followups.org= pipeline retains its 14 current judgment items, including the over-wide table and older missing review stamps/links; they do not represent uncommitted work after this wrap.
+
+KB: promoted 0 / consulted no
+
+* Session Log
+
+** Startup and clean-worktree investigation
+
+Ran the required startup workflow. The canonical rulesets pull and template sync were blocked by three tracked modifications; two untracked inbox handoffs were also pending. Read the project and global behavioral rules, recent session archive, wrap workflow, relevant hook and launcher code, and the current Codex MCP and hook documentation needed to evaluate the inbox proposals.
+
+Investigated Craig's requirement that rulesets finish with an absolutely clean worktree while still allowing downstream projects to sync when rulesets has received inbox deliveries. The current wrap workflow states a clean exit criterion but later permits explicitly deferred dirty files, and its teardown hook checks only the wrap sentinel rather than Git state. The startup shell's tracked-change check already ignores untracked inbox files, but the general launcher dirty check does not distinguish inbox deliveries from other untracked residue.
+
+The three tracked files now dirty were written at 10:27, after the latest archived rulesets session wrapped at 09:24. That establishes a post-wrap write path: a strict wrap gate can guarantee the state at completion, but preventing later contamination also needs a realpath-aware cross-project write guard because globally installed rules and workflows are symlinks into this repository.
+
+The proposed design is one shared repository-state classifier with two policies. Strict wrap requires no staged, unstaged, or untracked entries and no dirty submodules. Inbox-safe sync permits an otherwise clean tracked/index state with untracked entries only below =inbox/=; those deliveries do not block pull or template sync. The strict check should run after the final push and again inside the ordered teardown hook, tied to the checked HEAD, while the inbox-safe policy should drive both startup and the =ai= launcher. Tests should cover every Git state, unusual path names, inbox-only sync, hook behavior, and the canonical/template mirrors.
+
+Two pending inbox handoffs both propose a shared Claude-to-Codex MCP registry mirror. They pass the value gate but overlap. The recommendation is to consolidate them into one =[#B]= specification with Codex-only entries preserved, atomic and redacted updates, dependency and health checks, both-machine verification, and the Claude-memory audit split into a separate task. No inbox disposition or project implementation has been applied.
+
+Completed the investigation plan without changing product code. The worktree proposal is ready for Craig's approval; implementation, tests, and inbox filing remain deliberately pending.
+
+** Clean-wrap invariant clarified
+
+Craig confirmed that cleanup failure must prevent wrap-up entirely. The agent must keep the session open and report exactly what remains in the Git worktree, why it could not resolve each item safely, and the specific action or decision Craig needs to supply. A warning, deferred-file exception, valediction, archived-as-complete status, or teardown is not an acceptable substitute for a clean tree.
+
+** Clean-wrap enforcement implemented
+
+Craig approved implementation and asked for a full wrap when it is done. Added =git-worktree-gate= as the single policy executable: strict mode rejects every staged, unstaged, untracked, dirty-submodule, and in-progress-operation state; sync-safe mode permits only untracked =inbox/= deliveries. Certificate and verify modes bind the final clean check to HEAD inside the Git directory.
+
+Wired sync-safe behavior into both startup workflow copies and the =ai= launcher. The picker labels inbox-only state distinctly and now fast-forwards a behind repository with inbox deliveries present while refusing other untracked residue.
+
+Made wrap cleanup fail closed: removed every dirty-file and inbox deferral escape, added the post-push clean certificate as a hard prerequisite to valediction, and required an exact path/state/needed-decision report when cleanup cannot finish. The existing teardown hook now freshly verifies the certificate and HEAD before consuming either sentinel, emits the runtime-appropriate Claude or Codex Stop blocker, and leaves the sentinel/session intact on failure. Added global Codex hook configuration and installed its symlink; Codex will require its normal hook review/trust on a new session.
+
+Added =rulesets-write-boundary.py= and configured Claude and Codex Edit/Write hooks. It resolves targets through symlinks and denies another project's write when the real path lands in rulesets, directing the proposal through =inbox-send rulesets=. The cross-project rule now states the installed-symlink case explicitly.
+
+Focused verification is green: 10 state-gate Bats cases, 13 teardown-hook cases including dirty/changed-HEAD/missing-certificate and both runtime outputs, 36 launcher cases including inbox-only pull, 5 installer cases, and 5 Python write-boundary cases.
+
+** Inbox — MCP registry proposals consolidated
+
+Processed the two pending 2026-07-25 handoffs from work and home. They were duplicate evidence for a host-level Claude-to-Codex MCP registry reconciler, not project-level implementation requests. Filed one =[#B]= parked specification decision in =todo.org= with preservation, redaction, atomicity, transport, health-check, two-machine, malformed-input, and token-rotation gates; split the memory auditor into separate future work. Deleted both inbound handoffs, stamped =:LAST_INBOX_PROCESS:=, sent acknowledgements to both source projects, and verified zero pending project handoffs.
+
+** Review, full verification, and wrap cleanup
+
+The first full =make test= run passed. Adversarial self-review then found the Git-status process-substitution fail-open and a path-resolution fail-open in the cross-project hook; fixed both, added status-error and sequencer regressions, aligned =protocols.org= with the new policies, and reran the complete suite successfully.
+
+Executed wrap cleanup: no sentry lock, todo hygiene/convert/archive/priority passes made no changes, lint-org applied zero mechanical changes and refreshed the 14-item judgment pipeline, project inbox remained at zero, route-batch had no candidates, and 30-day staleness was zero. The shared roam inbox held one rulesets capture about 120-column tables; verified the enforcement already exists in three layers, removed the duplicate, and pushed the roam update with the repository's sync helper.
diff --git a/.ai/sessions/2026-07-27-17-02-context-engineering-rightsizing.org b/.ai/sessions/2026-07-27-17-02-context-engineering-rightsizing.org
new file mode 100644
index 0000000..54020e4
--- /dev/null
+++ b/.ai/sessions/2026-07-27-17-02-context-engineering-rightsizing.org
@@ -0,0 +1,176 @@
+* Summary
+
+** Active Goal
+
+Review three Anthropic posts on context engineering against what this repo ships downstream, then act on the findings. Ended with the always-loaded rules surface cut from ~57,800 tokens to ~28,949 (plus 13,461 path-scoped), two mechanisms proven in a live session, and two of my own bugs found and fixed — one of which had killed Craig's work session.
+
+** Decisions
+
+- *Split rules by blast radius, not by size.* What must hold whether or not you're publishing, and where a violation is permanent and reaches other people, stays always-loaded. Everything recoverable can ride a trigger. That's what let =commits.md= and =testing.md= ship without waiting on any pilot.
+- *Goal is output quality first, tokens second* (Craig's correction). Anthropic's 80% was a finding, not a target. P4/Phase 8 (effort reduction) dropped outright for trading quality for cost; P5 (positive framing over prohibition) promoted as the lever that actually targets guardrails working against output.
+- *Don't apply the posts additively.* The harness system prompt already carries most of what the Opus 5 guide recommends adding, near-verbatim. Adding it to =claude-rules/= would worsen the duplicate-and-conflict problem the first post opens with. The posts' value here is subtractive.
+- *Everything authored in or about the repo is first person* (Craig's instruction), with one carve-out: a comment describing what the code does stays third person, since there the code is the actor.
+- *No unmerging home.* Its domains are already separated by tag (24 finances, 18 kit, 17 jrestate). The real problem is a tag namespace flattening four orthogonal axes, and cross-domain priority is a judgment no scheme can make — splitting projects hides the question rather than answering it.
+
+** Data Collected / Findings
+
+- *Path-scoping works at user level*, confirmed by =/context= in a live work session: 17 generic rules listed, the three path-scoped ones absent. Deterministic glob match, so no trial needed for that tier.
+- *User-level and project-level rules both load, project wins.* work and =.emacs.d= carried 19 byte-identical duplicates, and a stale project copy silently overrode the fresh global rule.
+- *My token estimates were 45% low.* Real ratio 2.28 tok/word. =commits.md= was 12,800 tokens, not the ~7,000 I claimed. =/context= reported the true per-file numbers the whole time and I used a word-count estimate because it was easier to compute from inside the repo.
+- *Two loading paths, not one.* Memory files arrive via the harness; =protocols.org= and the workflows are read by startup and land in Messages. They shrink by editing the workflow, not by scoping a rule.
+- *41 execution/hygiene workflows against 6 discovery/design.* The system is heavily built on the half of the problem that got easier.
+- *The instructions don't practice what they demand:* =commits.md= argued terseness at 5,561 words, =interaction.md= bans bold while the rules carry 591 bold markers, =testing.md= argues TDD across eight more rows of rationalizations.
+- *Two of my own mechanical guards failed the same day, both certifying success while doing damage.* =wrap-org-table.el= reflowed a table into a worse shape and =lint-org= then passed it; the wrap-teardown hook consumed a two-hour-old sentinel and killed Craig's live work session.
+
+** Files Modified
+
+Seven commits, all pushed, velox synced throughout. =6c1ea8b= peer-reasoning rule + Chrome convention + KB probe fix. =0adcb1a= =paths:= frontmatter on the three file-type rules + the lint checker that catches prose/frontmatter mismatch. =7ea1d7b= generic rules no longer ship per project, sweep + gitignored session anchor. =79ed3b0= the three rightsizing docs. =2c664cb= =hooks/session-start-disarm.sh= for the sentinel bug. =d74d98d= docs corrected against live measurements. =931f364= =commits.md= → invariant core + =publish= skill. =2f45b6e= =testing.md= → directive core + =testing-standards= skill, approval-gate signal fixed, first-person directive.
+
+** Next Steps
+
+Everything remaining needs Craig's decisions rather than execution — see the =[#B] Finish context-engineering rightsizing= task and the =[2026-07-27]= reminder. In order: reconcile the three working docs (one commit behind), then C1 (=verification.md='s honesty core vs the over-verification warning), =interaction.md=, the TDD rationalization table, and D3 (which approval gates are preference vs guardrail).
+
+Also open: the work sentry triage split and the recurring-loop proposal, both filed =[#B]= with their reviews. The sentry spec review is still waiting, now two weeks old.
+
+KB: promoted 2 / consulted no
+
+* Session Log
+
+** Startup — 2026-07-27 10:25 CDT
+
+Ran startup. Rulesets already current; =make install= had nothing new to link; project repo clean at f2609d9 with no upstream drift. =.ai/= synced from templates (no churn — the sync is a no-op mirror refresh in this repo). Previous session wrapped cleanly (no session-context anchor present).
+
+Startup signals: 6 top-level tasks unreviewed for >7 days; roam inbox empty; KB at 106 =:agent:= nodes but the best-practices node path resolved empty (=rg -l 'agent-kb-best-practices'= found nothing — worth checking whether that node exists); no spec-sort or host-identity flags; language-bundle sync silent.
+
+Five new inbox handoffs arrived since the last wrap. Read all five and ran the skeptical review on each before surfacing dispositions.
+
+Disposed of one without asking: home's 07-26 10:21 file was a pure FYI acknowledging that the parked MCP-registry spec decision and the separate memory-auditor track matched its handoff. It asked for nothing, so it needed no reply and no approval — deleted it. Four remain, all shared-asset or convention changes, all waiting on Craig's approval per the inbox engine's core §2.
+
+Skeptical-review conclusions on the four pending:
+
+- *archsetup publish-lock* — design sound, acceptance checks testable. Three gaps: TTL sizing has to survive an indefinite human approval wait (refresh-on-re-entry covers the agent, not a Craig who steps away); the blocked second session's behavior (wait / defer / stop) is unspecified; and it's a real build, not a quick edit. Recommend filing =[#B] :feature:=.
+- *home Chrome convention* — protocols.org already prefers google-chrome-stable over xdg-open, so the new parts are =--new-tab=, multi-URL, and the confirmation line. The confirmation half contradicts the existing =&>/dev/null &= form, which discards exactly the message to be verified. Recommend applying with a foreground-when-running / background-on-cold-start reconciliation.
+- *work sentry triage correction* — Craig's 07-27 correction supersedes his 07-21 ruling; today's work fire missed a Hayk DM and a Kostya PR-review request. The gap is that the current rule excludes by category (mail / messenger) and the new split is work-vs-personal, which category can't express. Shipping plugins: cmail, personal-gmail, personal-calendar, telegram, github-prs — no general work-mail plugin, so work's source is project-specific. A denylist of personal plugin names fails open on the next personal source added; a per-plugin eligibility declaration is the durable shape, and that's a design call. Recommend filing =[#B] :bug:= (Major × most-users-frequently = P2).
+- *work peer-reasoning rule* — approved exact text, well-formed. Two notes: it's a reasoning contract in a file scoped to communication style (the framing line should widen), and "process serves the outcome" sits one reading away from licensing deviation from the mandatory gates. Its own wording says surface-before-proceeding, so no edit needed, but that's the line to watch. Recommend installing as written.
+
+** Inbox pass applied — commit 6c1ea8b
+
+Craig approved all four dispositions plus the probe fix. Two corrections from him along the way: I had inverted the render-merge guard (numerals belong to the options list, dashes to every other enumeration in the same message — I did the reverse), and processed items shouldn't be left sitting in =inbox/=.
+
+Shipped: the peer-reasoning section at the top of =claude-rules/interaction.md= with the file's framing line widened; the Chrome convention rewritten in canonical =protocols.org=; the KB best-practices probe switched from a content grep to a filename =find=. Filed two =[#B]= tasks (sentry triage split, repository publish-lock), both stamped =:LAST_REVIEWED: 2026-07-27=. Swept the 40-file =PROCESSED-*= backlog out of =inbox/= along with the four handoffs; =inbox-status= now reports 0. Replies sent to archsetup, home, and work.
+
+*The review caught my own error.* I had written that Chrome's confirmation line prints to stderr and told every project to capture it with =2>&1=. It prints to *stdout*; stderr is empty. Verified both directions on ratio before correcting. An agent following the original text would have captured stderr, seen nothing, and concluded the tab failed to open — the exact silent-failure shape as the KB probe it shipped alongside. I asserted a stream rather than checking it, inside the same change that told others to verify. Side effect: four =about:blank= tabs opened in Craig's live browser during the check.
+
+Deliberate departure recorded: =route_recommend= returned =work strong= for the sentry task, but the work happens in rulesets' canonical =sentry.org=, so it's a local keeper and I left it unstamped rather than letting the wrap router offer to ship it away.
+
+Possible KB promotion at wrap: an identifier that lives only in a filename is invisible to a content search, and the lookup fails *silently* rather than erroring. Adjacent to the existing enumerate-vs-discover node but a distinct failure surface. Two instances in one commit (the KB probe, the Chrome stream) argue it generalizes.
+
+Startup-workflow bug found while checking the KB nudge: Phase A resolves the best-practices node with =rg -l 'agent-kb-best-practices' "$ra"=, which greps file *content*. The node's slug lives in its filename, so the probe returns empty and the contribute nudge points at nothing — in every project, every session. The node exists at =~/org/roam/agents/20260620232112-agent-kb-best-practices.org=. Synced-workflow change, so it waits on approval too.
+
+** Pushed and synced velox
+
+Pushed 6c1ea8b to origin/main (ahead-only, reconciled immediately before). On ratio, so velox needed the pull: it fast-forwarded and =make install= linked three things it had been missing since 2026-07-25 — the Codex =hooks.json=, =rulesets-write-boundary.py=, and =git-worktree-gate=. That drift is exactly the one-time-setup case =daily-drivers.md= names: the files traveled with the pull, but nothing re-runs the installer, so the symlinks only land where someone runs it. A new Codex session on velox will now hit the hook review/trust prompt, which was already on the 2026-07-25 next-steps list.
+
+** Context-engineering rightsizing — analysis and rollout plan
+
+Craig supplied three Anthropic posts (the 2026-07-24 Claude 5 context-engineering post, the Opus 5 prompting guide, the 2026-07-06 Fable field guide) and asked for a review, proposals, and a consistency audit of what this repo ships downstream. Then he reframed twice, and both reframes were better than the question I'd been answering.
+
+*First reframe:* consider the files as *his prompts*, not my context. That changed the finding. My first pass measured the always-loaded surface (32,123 words — =claude-rules/= 25,386 + =protocols.org= 6,620 + CLAUDE.md 117, roughly 40k tokens before the user's first word) and proposed shrinking it. Read as a map he hands every project, the finding is different: 41 execution/hygiene workflows against 6 discovery/design, seven to one. That ratio was right when the risk was the model doing things wrong. The field guide's claim is the bottleneck moved to the human's ability to clarify unknowns, so the system is heavily built on the half that got easier.
+
+*Second reframe:* metrics per claim, not one go/no-go. Turns the rollout into a set of separable testable claims rather than one bet.
+
+Three checkable "doesn't practice what it demands" findings: =commits.md= argues terseness at 5,561 words (longest file in the set); =interaction.md= bans bold in chat while the rules carry 591 bold markers; =testing.md= mandates TDD then argues eight more rows against rationalizations.
+
+*The finding that changed the plan:* the harness system prompt already carries most of what the Opus 5 guide recommends adding — its task-scope block, correction-narration block, and subagent cap are present nearly verbatim, and post 1's replacement comment guidance is present as the post's own new wording. So applying the posts additively would make the duplicate-and-conflict problem worse. The posts' value here is subtractive. It also exposes a third dedup axis nobody has audited: =claude-rules/= against the harness prompt, invisible from inside the repo.
+
+*Pilot selection rule* (the part that matters more than the list): the six pilot files were chosen because a silent miss is *detectable*, not because they're small. Four have a mechanical checker (=lint-org= =org-table-standard=, spec-board grep, =spec-review=), two produce an error Craig sees in seconds. =daily-drivers.md= and =emacs.md= were considered and held back — low risk, but a miss surfaces too slowly to learn from inside the trial window.
+
+Artifacts in =working/context-engineering-rightsizing/=: =proposals.org= (P1-P6, conflicts C1-C2, the from-your-side-of-the-desk section), =rollout.org= (Phases 0-8, decisions D1-D7, target trajectory), =metrics.org= (claim-by-claim testability, pilot go/no-go with the denominator rule, turn-back vs abandon triggers).
+
+Two honesty notes carried into the docs: I have a stake in arguing my own instructions should be shorter, so the plan weights mechanical detectors over my self-report; and about half the posts' claims aren't testable here without an eval harness, so those are labelled judgment rather than measurement so a future session doesn't mistake an adopted opinion for a tested result.
+
+Not started. Awaiting D1 (confirm pilot set) and D2 (skill index in the core).
+
+** Path-scoping shipped (0adcb1a) and work pre-synced
+
+The session's biggest finding: Claude Code scopes a rule by a =paths:= field in YAML frontmatter, and none of the 20 rules had one — even though three already declared a file-type scope in their =Applies to:= prose line. So =todo-format.md= (4,494), =org-tables.md= (464), and =emacs.md= (923) loaded into every session in every project, contradicting their own first line. 5,896 words. Fixed by adding the frontmatter, plus a =lint.sh= checker that warns when prose names a concrete extension without matching frontmatter (flags exactly those three, nothing else), plus teaching the heading check to skip a frontmatter block. Always-loaded rules surface: 25,386 → 19,505.
+
+Also confirmed from the docs: user-level and project-level rules *both* load, and project rules take priority. So work and =.emacs.d= carry 19 byte-identical duplicate copies, and a stale project copy overrides a fresh global one — which is exactly what was happening to work's =interaction.md= between this morning's commit and its next startup.
+
+Pre-synced work via =scripts/sync-language-bundle.sh ~/projects/work= (rulesets' own installer, run early rather than waiting for work's startup) so Craig's next work session is a valid test rather than one running the set it loaded before the sync. Verified: all four files now match canonical, frontmatter present, and work's =.claude/= is gitignored there so nothing was dirtied.
+
+Open question the next session answers: does =paths:= frontmatter apply to *user-level* rules or project-level only? The docs don't draw the distinction. =/context= in a fresh session settles it — if =todo-format.md= is absent from Memory files until an org file is opened, it works. If it's listed, the frontmatter is inert (no harm) and semantic skills are the only route.
+
+Not done: the double-load fix. Removing the 19 duplicates means changing what =install-lang= pushes into projects, and there may be a teammate-facing reason for them. Surfaced as Craig's call, not urgent — wasteful, not harmful.
+
+** De-duplicated the rules layer, unblocked sync (7ea1d7b, 79ed3b0)
+
+Craig confirmed no teammates depend on the per-project rule copies, so I removed them. =install-lang.sh= no longer copies the generic rules; =sync-language-bundle.sh= sweeps the ones earlier installs left, guarded on the global rule existing so a machine mid-bootstrap isn't stranded with none. Swept 20 files each from work and =.emacs.d=, leaving only their language rules plus work's =publishing.md= overlay. Three existing tests encoded the old contract and were rewritten; the generic-drift test now asserts sweep-not-repair, which is the stronger fix since the drifted copy outranked the global rule while it existed. Four new tests cover the sweep, the two keep-cases, and the no-global-rule guard.
+
+Also gitignored =.ai/session-context.org= and =.ai/session-context.d/=. This repo tracks =.ai/=, so the live anchor read as untracked all session and =git-worktree-gate= reported rulesets sync-blocked — meaning every other project skipped its rulesets pull until wrap, every session. Craig spotted the blocked state and inferred it was why I pre-synced work; it wasn't (rules load at launch, before the startup sync runs, which was the real reason), but chasing his inference found the anchor problem, which was the better bug.
+
+Corrections from Craig this stretch: the goal is output quality first, token reduction second — my docs led with the wrong number and P4 (effort reduction) should be demoted or dropped since it trades quality for cost. And all authored prose goes first person; I amended the first commit rather than leaving it. Code comments stay third-person by agreement, since they describe what the code does for the next reader.
+
+Docs not yet updated for either the goal reordering or the last two hours of findings (path-scoping, the double-load, the harness overlap). That's the next task.
+
+** Inbox: archsetup ack
+
+archsetup acknowledged the publish-lock acceptance and the three implementation gaps, confirming the decision stays closed on its side. Pure FYI, nothing asked, no reply owed. Deleted it. Inbox back to zero.
+
+** Killed Craig's work session with my own hook, then fixed it (2c664cb)
+
+Craig's 13:20 work session was blocked repeatedly and then had its terminal closed under it. The cause was mine, from Saturday's clean-wrap work.
+
+=wrap-it-up= drops =/tmp/ai-wrap-teardown-<project>= so the =Stop= hook tears down once the wrap certifies clean. I deliberately made a failed certification *preserve* the sentinel, so a wrap blocked by a dirty tree could retry on a later stop. I never bounded that retry to the session. work's 11:37 wrap left an uncertified sentinel armed; the 13:20 session's stops were all blocked by it failing certification; then startup's two commits (task filing, template sync) made the tree clean, the next stop certified, and =cj/ai-term-quit= killed the tmux session mid-work.
+
+Two others were armed and dangerous at the same moment: archsetup's since Saturday 15:02 on a live attached terminal, and home's from 13:21 on a live session. Disarmed all three by hand (backed up to =/tmp/disarmed-sentinels=) before writing any fix, since both were minutes from the same fate.
+
+Fix: =hooks/session-start-disarm.sh= clears the project's sentinels at =SessionStart= — a new session means the wrap that armed one is gone. Within-session retry is untouched (the hook only runs at session start) and a test pins that so the deliberate behavior isn't lost to the fix. Four tests on the disarm including project-scoping, one on the retry. Wired into =.claude/settings.json=, installed on both machines, =wrap-it-up.org= documents the session-scoping with the worked failure.
+
+Diagnostic note worth keeping: I found it by reading work's own crashed session anchor, which showed startup completing normally and then stopping dead, plus its git log showing two commits at 13:21 — the exact moment the tree went clean. The anchor being left behind by the interrupted session is what made the timeline reconstructable. That's the crash-recovery purpose earning itself.
+
+** /context settled both open questions; docs corrected (d74d98d)
+
+Craig ran =/context= in work. Memory files lists 17 generic rules; =todo-format.md=, =org-tables.md=, and =emacs.md= are absent, and only =python-testing.md= and =publishing.md= come from the project's own rules dir. So *path-scoping works at user level* and *the de-duplication holds*. Both were open.
+
+Three corrections the live numbers forced:
+
+1. *My token figures were low by ~45%.* Real ratio is 2.28 tok/word, not the ~1.3 I assumed. =commits.md= is 12,800 tokens (I said ~7,000); =claude-rules/= was ~57,800/session before today, now 44,410, with 13,390 path-scoped out. Worth naming the actual error: =/context= reports per-file token counts and I used a word-count estimate instead because it was easier to compute from inside the repo. The instrument existed the whole time.
+2. *Two loading paths, not one.* Memory files arrive via the harness at session start. =protocols.org= and the workflows are *read by startup*, so they land in Messages and never appear under Memory files. They shrink by editing the workflow, not by scoping a rule. My "always-loaded surface" number conflated them.
+3. *The harness's own suggestion* names =commits.md=, =testing.md=, =MEMORY.md= as the top three to prune — independently the same Phase 4 list I'd proposed.
+
+Because a glob match is deterministic, the remaining work splits: path-scopable rules ship with no trial (=docs-lifecycle.md= on =docs/**= is next), and only semantic-condition rules need the skills route and the stop conditions. =commits.md= is the real test there — largest single item, and almost all publish machinery that only applies when a commit is in play.
+
+Recorded a caution the confirmation doesn't cover: path-scoping fires on a *read* of a matching file, so creating a new org file from scratch never triggers =todo-format.md=. Edits are safe (Edit requires a prior read).
+
+Also folded in Craig's goal correction (quality first, tokens second): P4/Phase 8 dropped outright since lowering effort trades quality for cost, P5 promoted since positive-framing-over-prohibition is what targets guardrails working against output.
+
+** Split commits.md: 12,800 tokens → 2,342 always-loaded (931f364)
+
+Craig picked the commits.md split over docs-lifecycle after I checked the latter and found I'd overstated it — =docs-lifecycle.md= scopes to "any project carrying a docs/ tree," a *project-level* condition a glob can't express, and 2 of its 6 trigger points are creation cases a read-triggered path rule misses. Only three rules ever named a concrete extension and all three are already converted, so there is no other clean path-scope candidate.
+
+The split line is *blast radius*, not size. Stayed always-loaded (1,027 words / ~2,342 tokens): author identity, the no-AI-attribution ban, the generated-document byline rule, the public-artifact content-scope rules, and "If You Catch Yourself." Moved to =publish/SKILL.md= (4,871 words): message format, Voice and Focus, PR description structure, Review and Publish Steps 0-2, the three review shapes, hook authorization, merge strategy, the pre-commit checklist.
+
+Why that line: if the skill fails to trigger I don't know the flow and have to be told — visible and recoverable. I don't silently commit with AI attribution, because that guard never moved. Only the recoverable half rides the skill-triggering bet, which is what let this ship without waiting on the pilot.
+
+*Verified by using it.* The skill registered mid-session and I invoked =/publish= to publish its own commit; it loaded with the full flow present. Content conserved and checked rather than assumed: 5,561 words in, 5,898 across both files (delta = frontmatter + the pointer added to the core). Repointed five cross-references in =voice=, =review-code=, =inbox.org=, and =no-approvals.org= that named moved sections.
+
+Always-loaded rules surface: 44,410 → ~33,950 tokens. Started the day at ~57,800.
+
+Noted and deliberately not done: =publish/SKILL.md= is a single 4,871-word blob, and both posts argue a long skill should use progressive disclosure internally. It loads on demand now, which is the win worth taking; splitting it further is its own change.
+
+Also surfaced: the Step 2 =.ai=-tracking heuristic misfires here. It reads tracked =.ai/= as "shared team repo → skip the approval gate," but rulesets tracks =.ai/= as a committed mirror while being a private single-user repo. I kept asking rather than skipping, and flagged it to Craig.
+
+** testing.md split, gate fixed, first-person directive added (2f45b6e)
+
+Three changes. *testing.md split* the same way as commits.md — by what has to be resident, not by size. Core keeps TDD-is-default and the three-category requirement (347 words), because those fire *before* any code is written, which is exactly when no skill has been summoned. Everything else → =testing-standards= skill (2,903 words): characterization recipes, per-category detail, property/mutation testing, pyramid, integration rules, naming, test-quality and mocking rules, coverage targets, spike exception, anti-patterns.
+
+*Approval-gate fix.* The publish flow decided whether to ask by checking whether =.ai/= is tracked, as a proxy for "team repo." Wrong in the direction that matters: rulesets, home, and work all track =.ai/= and all three are private single-user repos, so the rule skipped the gate on Craig's three most-used projects. Now checks whether any remote is on a host other than cjennings.net. Verified both directions including a synthetic GitHub remote. Every current project → gate applies, which matches how the flow has actually been run all session.
+
+*First-person directive* added to the always-loaded core, at Craig's instruction. One existed for commit bodies/PR prose but it moved into the publish skill, and it never covered code comments at all. Now: everything authored in or about the repo is first person, with one carve-out — a comment describing what the code *does* stays third person, since there the code is the actor.
+
+Also split =publish/SKILL.md= internally: PR descriptions + the three review shapes → =references/pull-requests.md=, since a plain commit never needs them. SKILL.md 5,012 → 3,888 words.
+
+*Surface: ~57,800 tokens this morning → ~28,949 always-loaded now* (plus 13,461 path-scoped). Largest remaining: =interaction.md= 3,828, =verification.md= 3,388, =commits.md= core 2,804, =subagents.md= 2,373, =cross-project.md= 2,305.
+
+Risk recorded rather than buried: testing.md's margin is thinner than commits.md's. If =testing-standards= fails to trigger mid-test-writing I lose the mocking-boundary rules — a quality regression, visible in review, but a real bet where commits.md's moved half was purely procedural. Also moved the TDD rationalization table rather than cutting it; the posts say that kind of over-argument is counterproductive now, but deleting Craig's defense against me skipping TDD is his call.
diff --git a/.ai/sessions/2026-07-29-06-36-adversarial-review-flow-and-telegram-fixes.org b/.ai/sessions/2026-07-29-06-36-adversarial-review-flow-and-telegram-fixes.org
new file mode 100644
index 0000000..12cbe3d
--- /dev/null
+++ b/.ai/sessions/2026-07-29-06-36-adversarial-review-flow-and-telegram-fixes.org
@@ -0,0 +1,514 @@
+#+TITLE: Session Context — 2026-07-28
+#+AUTHOR: Craig Jennings
+#+DATE: 2026-07-28
+
+* Summary
+
+** Active Goal
+
+Started as inbox triage on a telegram-plugin bug report and became two things: shipping the cross-project fixes that arrived overnight, then designing and dogfooding a mandatory isolated adversarial review before every commit — which immediately found real defects in its own design and in everything filed afterward.
+
+** Decisions
+
+- *Merge colliding fixes rather than sequence them.* The parked down-is-launch diff still carried the bad =loadChats= call and cited the segfault gotcha the other fix rewrites. Applying either alone would have shipped a file arguing against itself.
+- *"Adversarial", not "hostile"* (Craig). An agent told to attack manufactures findings, so the stance carries a substantiation floor: a finding not substantiated against the diff is dropped.
+- *Re-review until the reviewer approves* (Craig's addition, the thing I had missed). Same reviewer continued, not a fresh one — a fresh reviewer can't tell an addressed finding from one that never existed. Bounded at three rounds or first recurrence.
+- *Dispatch on every commit*, with the reviewer's own Phase 0 ruling triviality. A floor written as "small" or "mechanical" puts the judgment back with the author, whose judgment is the thing being checked.
+- *Pass the requirement source, withhold the rationale.* A ticket is not the author's model; it was written first and by someone else, so it's the only input that can contradict the author's claim.
+- *=cycle=, not =pass=, for one sentry loop* (Craig). home proposed =pass=; =sentry.org= already uses it as a numbered noun for the eleven hygiene passes, so =Pass 11= would have collided with =pass 12=.
+- *Drop the =references/= link rather than sync the directory.* The four calendar workflows already travel and already carry the recipes.
+- *Strip the wrap-org-table task to Verified / Open questions* after its review loop bounded out. The measurements were never what failed.
+
+** Data Collected / Findings
+
+- *The telegram bug.* =(telega--loadChats 'main)= sends a bare symbol on the wire; =tdat_plist_value= (=telega-dat.c=) accepts only =(=, =[=, ="=, =-=, digit, =t=, =:=, =n= and calls =assert(false)= on =m=. Verified against telega's source at four points rather than trusting the handoff. Exposure was manual triage only — sentry excludes messengers, so home's eleven overnight cycles never loaded the plugin.
+- *=wrap-org-table.el= splits logical rows*, and =lint-org= doesn't merely miss it — it *causes* it. =lint-org.el:424= calls the same broken predicate, so it reports the tool's own correct output as "missing rule between rows — wrap-org-table.el reflows it" when nothing is missing, then reports the corrupted result clean. Idempotence is broken: the tool corrupts its own output on a second run.
+- *The isolated reviewer earned its keep on its first four uses*, finding: that withholding the ticket made my claim self-certifying; that my =subagents.md= override reaffirmed the Prompt Contract field that would destroy the isolation; a fourth verdict (=Needs Discussion=) I'd asserted didn't exist; and four successive wrong root-cause analyses on the table bug.
+- *rulesets is itself exposed* to the table bug: =todo.org='s four-row attachment-sanitization table. Don't reflow until fixed.
+- *Seven =../../= link sites* across four synced workflows resolve only in rulesets. =scripts/lint.sh='s =check_md_links= was built for that class and misses them because it matches markdown syntax only.
+
+** Files Modified
+
+Seven commits, all pushed, velox synced after each. =bff0138= merged telegram fixes. =43a4cf7= post-load liveness check. =614e3b1= removed finished working dirs. =ca508a1= filed three handoff findings. =f3f5bfd= the =fire= → =cycle= rename (72 sites plus three that had leaked outside =sentry.org=). =3a933a2= the =references/= analysis. =5999f88= dropped the dead link and deleted the stale file behind it. =ecd5d7b= filed the wrap-org-table bug.
+
+Rules changed: =publish/SKILL.md= Step 1 rewritten (dispatch contract, four defined verdicts, the loop, bounds), =review-code/SKILL.md= (two levels of dispatch, adversarial contract, re-review mode), =claude-rules/subagents.md= (Isolation Override; Prompt Contract field 2 inverts), and the three unattended callers taught to park.
+
+** Next Steps
+
+- *Three of the four items Craig queued are untouched*: the winvm =[#C]= lint defects, the context-engineering rightsizing (needs his four decisions), and the sentry spec deep read (two weeks old).
+- *Rule gap found by using the rule*: =Needs Discussion= exits to the user, but nothing says what happens after the user answers — whether the round counter resets. I treated it as a fresh review; that judgment isn't written down.
+- *Item 2 is now qualified*: its fix says "run =wrap-org-table.el=", and that tool has a live corruption bug. The specific table is safe, but verify the output rather than trust it.
+- Four =[#B]= bugs filed tonight and unstarted: the table splitter, the =../../= links, plus the two carried in.
+
+KB: promoted 1 / consulted no
+
+* Session Log
+
+** 11:55 — Startup
+
+Ran startup. Rulesets already current, project repo clean and current, =make
+install= had nothing new to link, =.ai/= synced from templates. No crash anchor
+— previous session (context-engineering rightsizing, 2026-07-27 17:02) wrapped
+cleanly.
+
+Findings: 6 tasks unreviewed >7 days; roam inbox holds 4 items; KB at 108
+=:agent:= nodes with nothing matching this project. Spec-sort and host-identity
+probes silent. Language-bundle check silent.
+
+** 12:05 — Inbox: the telegram segfault root cause
+
+Four new inbox files from =.emacs.d=, two pairs: a 06:15 intro note + plugin
+file, then a 07:21 correction + superseding plugin file. The correction retracts
+one secondary claim from the 06:15 write-up (that the "19 of ~50 chats" reading
+was truncation caused by the bug — it wasn't; 19 is the real account size,
+measured by work at the wire level). Root cause and fix unchanged.
+
+The proposal: =triage-intake.telegram.org= Step 1 calls =(telega--loadChats
+'main)=, and that bare symbol kills =telega-server= outright.
+
+I verified the whole chain against telega's own source rather than taking the
+handoff's word for it (=elpa/telega-20260706.2147/=):
+
+- =telega--loadChats= (telega-tdlib.el:2190) drops its argument straight into
+ the request as =:chat_list= with no conversion. Confirmed.
+- The C parser =tdat_plist_value= (server/telega-dat.c:466) accepts only =(=,
+ =[=, ="=, =-=, a digit, =t=, =:=, or =n= to start a value; anything else
+ prints "Unexpected char '%c' in plist value" and calls =assert(false)=.
+ =main= starts with =m=. Confirmed, and the accepted-char list in the handoff
+ is exactly right.
+- telega's own callers all pass the object: telega.el:290, telega.el:295,
+ telega-tdlib-events.el:516. Confirmed.
+- The symbol shorthand lives in a different layer — telega-filter.el:472 and
+ telega-folders.el:99 convert =(eq cl-fspec 'main)= to ='(:@type
+ "chatListMain")=. Confirmed.
+
+Every claim holds. Grepped all workflows for other bare-symbol arguments to
+=telega--*= wrappers: only the two sites this diff already fixes.
+
+*The complication I found.* There is already a parked =[#B]= VERIFY (todo.org:136,
+from =.emacs.d= 2026-07-24) proposing a different fix to this same file — the
+down-is-launch fix, with a prepared diff in
+=working/triage-telegram-down-launch/=. Its proposed file still carries the bad
+=loadChats= call at its lines 52 and 122, and its third hunk adds prose citing
+"tdlib segfaults in native mode (SEGFAULT gotcha below)" — pointing at the very
+section this new handoff rewrites to say the crashes were our bug, not tdlib's.
+Applying either one alone leaves the file half-right. They need merging, not
+sequencing.
+
+Also: home and work both declare =telegram= in =:TRIAGE_SOURCES:=, so both have
+been killing telega-server on every sweep. Work already patched locally and
+verified at the wire level. Home has not been told.
+
+** 11:58 — Staged the batch, closed the loops
+
+The Stop hook counted the four files still sitting in =inbox/=. The triage was
+done; what remained was Craig's merge decision, not mine. So I staged all four
+into =working/triage-telegram-segfault-rootcause/= (the 07:21 pair as
+=.proposed= + correction note, the 06:15 pair kept as superseded context — its
+blast-radius and follow-up sections aren't repeated in the correction). Inbox
+back to clean, =inbox-status= rc=0.
+
+Left =working/triage-telegram-down-launch/= untouched: the parked VERIFY's body
+links its =proposed.diff= by path, and moving it would break the link before
+Craig has decided anything. If he takes the merge, that consolidates then.
+
+Replied to =.emacs.d= — confirmed the verification, and told them the one thing
+they couldn't see: their own 2026-07-24 parked diff collides with this, both in
+the stale =loadChats= call it still carries and in the native-mode prose it adds
+citing the gotcha this rewrites. Also declined their suggested bare-symbol lint:
+one consumer file, two call sites both fixed, so the stated rule is cheaper than
+a checker with a single input.
+
+Notified home directly with the mechanism and the two-site fix, told it to patch
+locally rather than wait on the canonical, and carried the correction forward
+explicitly so it doesn't inherit the retracted truncation story.
+
+Nothing applied to the canonical. That still waits on Craig.
+
+** 12:10 — Craig picked the merge. Applied and committed.
+
+Merged both fixes into one version rather than sequencing them. Base was the
+segfault-corrected file, then the three parked hunks on top: the down-is-launch
+directive, the SCAN-FAILED-only-after-launch-attempted rewording, and =(setq
+telega-use-docker t)= restored to the Step 1 code block.
+
+The reconcile that made merging worth doing. The parked hunk's new comment said
+"tdlib segfaults in native mode (SEGFAULT gotcha below)", pointing at the
+section the other fix rewrites to say those deaths were our own bad argument.
+Left alone the file would have argued against itself. I changed the Step 1
+comment to state plainly that the two are separate concerns (the deaths happened
+*in* docker mode, so docker mode is neither a defense against the loadChats bug
+nor evidence for itself), and reworded the Quick Reference line from "tdlib
+segfaults outside docker mode" to "crashed in native mode (2026-06-09)" with the
+same disambiguation.
+
+That reword also removed a host-identity violation I hadn't gone looking for.
+The original asserted "Craig's daemon currently has telega-use-docker nil" — a
+mutable machine fact stated as fixed in a synced doc. I checked the actual
+default (=telega-customize.el:514=, =defcustom telega-use-docker nil=) and wrote
+the durable claim instead.
+
+Verified: both live call sites use the TL object, the two remaining ='main=
+occurrences are inside the gotcha prose describing the bug, lint-org clean on the
+changed file, mirror synced, =make test= green before (exit 0) and after (exit
+0).
+
+todo.org: closed the parked =**= VERIFY as =DONE= + =CLOSED:= per todo-format.md
+with the merge rationale in the body. Promoted its =***= engine child (SCAN
+FAILED must not advance the sentinel) to top-level =**= VERIFY so it doesn't get
+buried under a DONE parent. Kept its =:LAST_REVIEWED: 2026-07-24= rather than
+stamping today — I moved it and judged it separate, but nobody re-derived its
+content, so the older date keeps it honest.
+
+Review: Approve, no Critical or Important. Two Minor, both surfaced rather than
+fixed. The gotcha now advises a post-load =(process-live-p ...)= check that the
+Step 1 recipe doesn't actually do, and adding it would extend the recipe past the
+two fixes Craig approved.
+
+Committed =bff0138=. Not pushed — that's a separate confirmation.
+
+** 12:20 — home replied, and corrected my urgency read
+
+home accepted, patched both call sites locally, and re-verified the diagnosis
+independently rather than trusting it. Useful correction back: home declares
+telegram in =:TRIAGE_SOURCES:=, but sentry excludes messengers from triage
+intake, so the plugin never loaded on a sentry fire. Eleven overnight fires ran
+clean against the broken file. I had assumed the sweeps were affected; the real
+exposure is manual triage intake only.
+
+Told home its stopgap won't be reverted into a broken state — the next rsync
+replaces it with canonical content carrying the same fix. On this machine that
+lands as soon as its next startup runs, since the rsync reads the local rulesets
+working tree. velox needs the push.
+
+Inbox back to zero.
+
+** 14:10 — Pushed, and closed the velox gap
+
+Pushed =1675613..bff0138= to origin after the pre-push reconcile (still 1 ahead,
+0 behind).
+
+=.emacs.d= replied while the push was in flight. It had already verified bff0138
+here, re-run its own workflows rsync, and confirmed the corrected form landed.
+It withdrew the bare-symbol lint suggestion, conceded the home omission, and
+flagged one real gap: bff0138 was committed but not pushed, so velox stayed
+exposed. That was true when written and stale by the time it arrived.
+
+Checked velox over tailscale (this host is ratio, per =uname -n=). It was 1
+behind / 0 ahead and sync-safe, so I fast-forwarded it through the same
+=git-worktree-gate sync-safe= check startup uses. Velox is now at bff0138 and its
+workflow file carries the corrected call, with the only bare ='main= occurrences
+inside the gotcha prose. Both daily drivers covered.
+
+Corrected read carried into both replies: the exposure was manual triage intake
+only, not the automated sweeps, because sentry excludes messengers.
+
+** Open follow-ups (surfaced to Craig, not acted on)
+
+1. The gotcha tells callers to check =(process-live-p (telega-server--proc))=
+ after a load, but the Step 1 recipe doesn't do it. Now that the corrected call
+ shouldn't kill the server, that check is what would catch a regression. Left
+ out deliberately as scope creep past the two approved fixes.
+2. Both =working/triage-telegram-*= dirs are completed-task artifacts and want
+ filing per working-files.md. Revised read after checking: delete both
+ outright. Every file is tracked (=b19d420= and =bff0138=), so git holds them
+ permanently and a copy in =assets/= would only duplicate history. Nothing
+ links to them.
+
+** 15:00 — Second inbox round: .emacs.d self-correction + winvm lint findings
+
+=.emacs.d= wrote back to say it had overcorrected on home: it accepted "home was
+in the blast radius" and then recorded that home "had been killing telega-server
+on every sweep too", which home's own sentry data refutes. It fixed its task
+record rather than leaving it. I told it the pattern wasn't one-sided — I made
+the same move this morning, estimating home's blast radius instead of measuring
+it, and home's data is what corrected me. It also offered the emacs-side half of
+a completed-vs-truncated signal, which it has filed as =[#C]=, if the
+=process-live-p= recipe change lands.
+
+=winvm= sent a link-integrity pass with three findings, all reproduced against
+the rulesets source rather than only its local copy. I verified all three:
+
+1. =protocols.org:273= links =references/calendar-reference.org=, but the rsync
+ set is only =protocols.org=, =workflows/=, =scripts/=. Dead link in every
+ consuming project. home and =.emacs.d= have no =.ai/references/= at all.
+2. =retrospectives/PRINCIPLES.org:38= violates the org-table standard.
+ =lint-org= confirms, checker =org-table-standard=.
+3. =protocols.org= lints 8 mechanical + 19 judgment =misplaced-heading=, all
+ from Markdown =**bold**= in an org file. 48 bold spans, 14 line-initial.
+
+The check winvm couldn't run: adding =references/= to the sync brings =--delete=
+with it, which would destroy project-owned files there. work is the only project
+with a =.ai/references/= and its copy is byte-identical to the source, so nothing
+would be lost. That clears the path to sync-it over drop-the-link.
+
+One refinement back to winvm on finding 3: the 8 mechanical hits are would-fix,
+and the 14 line-initial bold spans are exactly what =lint-org --fix= would
+rewrite without knowing they're emphasis rather than headings. So that half is
+not safe to run mechanically even though the count suggests it is.
+
+Filed as =[#B] :bug:= (the dead link, Minor x every-project-every-sync = P2) and
+=[#C] :chore:= (the two cosmetic defects, P3), both stamped =:LAST_REVIEWED:=
+today since I graded them today. Verified todo.org's lint counts are unchanged
+from the pre-edit baseline (mechanical=2, judgment=12, both pre-existing and
+already in =inbox/lint-followups.org=).
+
+Inbox back to zero. todo.org is left uncommitted deliberately — Craig has a
+pending decision that will likely touch it again, so batching beats a churn
+commit.
+
+** 18:45 — Craig took both follow-ups. Liveness check staged.
+
+Added the liveness check to the Step 1 load call. It now returns ='server-died=
+instead of an unconditional ='loaded=, and new prose routes that into the
+existing SCAN FAILED definition rather than inventing a category: a server that
+dies during load leaves a thin hash, which reads exactly like a quiet account,
+which is the same false all-clear the down/not-loaded rule already prevents.
+
+Self-review caught a real defect in my own first draft. I called
+=(telega-server-live-p)= bare, but Step 0 guards the same predicate with
+=fboundp= because telega may not be loaded. A launch that failed outright would
+have signalled void-function instead of returning the clean contract. Added the
+guard, matching Step 0's idiom. Verified the predicate is exactly the
+=process-live-p= expression the gotcha names (=telega-server.el:221=).
+
+Verified: parens balance at depth 0, lint-org 0/0, mirror identical, =make test=
+green (exit 0) on the final state.
+
+Not committed — waiting on the approval gate.
+
+** 18:48 — Third inbox round
+
+=.emacs.d= sent a closing FYI marked no-action, agreeing my framing of the shared
+failure (blast radius estimated rather than measured) named the trigger rather
+than the failure. Deleted without reply, since replying to "nothing owed back"
+is noise.
+
+home proposed renaming sentry.org's noun-sense "fire" to "pass", after Craig read
+its "nine fires" as nine emergencies: "I assume you mean nine crises, not nine
+loop cycles and I begin to get scared." The problem is real, well-evidenced, and
+reaches Craig directly through digest headings.
+
+But the proposed term is wrong, and the reason home gave for it is the
+disqualifier. =sentry.org= already uses "pass" as a precise numbered noun — the
+pass list, the Pass Runner, "eleven finding/hygiene passes", "pass 12". With
+exactly eleven hygiene passes, home's proposed =** Pass 11= heading collides with
+an existing referent. That trades a term Craig misreads as urgent for one that is
+genuinely ambiguous.
+
+Counter-proposed *cycle*: zero occurrences in the file, and Craig's own word in
+the quote home cited. Checked and rejected "sweep" (3 uses) and "run" (used as a
+noun). Filed =[#C] :chore:= with the grading and the collision analysis; replied
+to home with the counter-proposal.
+
+** 18:50 — Three commits, and the collision confirmed from live evidence
+
+Craig approved both follow-ups and the =cycle= term. Three commits:
+
+- =43a4cf7= the liveness check.
+- =614e3b1= removed both telegram working dirs. Filing by deletion, since every
+ file was already in git via =b19d420= and =bff0138= and an =assets/= copy would
+ only duplicate history.
+- =ca508a1= filed the three handoff findings with their gradings.
+
+home wrote back confirming the "pass" collision was real, and that it had already
+walked into it: its anchor now carries =** Pass 11= meaning the eleventh cycle,
+three lines from =pass 12 (solo-task implementation)= meaning the twelfth item in
+the pass list. Same file, two referents, introduced by its own normalization an
+hour earlier. It found that in live evidence faster than reading the file would
+have caught it.
+
+It was blocked on Craig's confirmation and had written a memory saying "pass", so
+I sent the confirmation immediately. The memory was the urgent half — a stale one
+teaches every future home session the ambiguity, where the anchor is one file.
+
+Recording Craig's approval flipped the sentry task to =:solo:=. The term was the
+only judgment it carried, and the completion check is objective, so it can ride a
+backlog run rather than waiting for someone to touch =sentry.org=.
+
+Pushed =bff0138..ca508a1=, velox fast-forwarded to match.
+
+** 19:45 — The cycle rename, and the leak home's scope missed
+
+home did its side first: renormalized its anchor by restoring the
+pre-normalization backup and re-running fire→cycle from clean, rather than
+reverse-mapping pass→cycle. That was the right call — reverse-mapping would have
+needed a judgment on every instance to separate its own conversions from genuine
+pass-list references, where re-running from clean makes it structural. It also
+corrected its memory and handed the canonical back.
+
+Did the canonical rename with a script rather than by eye: protect the verb sites
+by explicit pattern, assert zero unclassified =-ed/-ing= forms survive, then
+substitute. 72 noun instances converted, 4 verb sites untouched (=/loop= fires
+again, two "fires on approval", "record of what fired").
+
+*The leak home's scope missed.* The term wasn't confined to =sentry.org=.
+=wrap-it-up.org= said "a crashed fire", and =todo-cleanup.el= and its test both
+said "every sentry fire" — all three naming a sentry cycle. Renaming only
+=sentry.org= would have split the vocabulary across files. Found by grepping
+every file that mentions sentry, then re-grepping without a context window after
+the first pass truncated short-line matches and hid them.
+
+Verified: exactly 3 verb instances left in sentry.org, no placeholder leaked, the
+digest commit template now reads =<date> <time> cycle=, capitalized plurals
+handled, lint 0/0 on sentry.org, suite green (exit 0 — load-bearing here, since
+=todo-cleanup.el= and its test are under test). The two =wrap-it-up.org= lint
+findings are pre-existing and identical at HEAD.
+
+Committed =f3f5bfd=, pushed, velox fast-forwarded and verified. Notified home
+(with the leak it hadn't seen) and =.emacs.d=. Closed the task =DONE=.
+
+Four commits this session, all pushed, both daily drivers current, inbox at zero.
+
+** 20:00 — Roam inbox zero, then the adversarial-review design
+
+Roam scan: 3 items, 1 claimed (=rulesets:= prefix), 2 unowned gear links left
+for Craig. Filed the claimed one, removed it from roam under capture-guard +
+roam-write lock, triggered =roam-sync=. A local =.emacs.d= FYI also cleared.
+
+The claimed item: "code reviews must occur before every commit an agent does,
+and they should be hostile reviews from a subagent without the agent's context."
+
+Craig's decisions: *adversarial* rather than hostile (he took my push-back that
+an agent told to attack manufactures findings), plus a requirement I had missed —
+a re-review loop that runs until the reviewer approves — and that the rules must
+not contradict afterward. Unopposed recommendations I proceeded on: dispatch on
+every commit with the reviewer's own gate deciding triviality, and the flow lands
+in the =publish= skill.
+
+Wrote it into three files: =publish/SKILL.md= Step 1 (dispatch contract,
+adversarial-with-substantiation, the loop, bounds), =review-code/SKILL.md= (the
+two levels of dispatch, the adversarial contract, re-review mode), and
+=claude-rules/subagents.md= (a new Isolation Override section, since three
+separate size rules there said don't dispatch small work).
+
+** 20:30 — Dogfooded it, and the reviewer found nine things
+
+Ran the new flow on its own diff: dispatched an isolated adversarial reviewer
+with the diff plus a one-line claim, withholding everything else. It returned
+REQUEST CHANGES with seven Important and two Minor, every one substantiated. I
+checked each against the files rather than accepting them, and all nine were
+real:
+
+1. =Skipped= from Phase 0 is not =Approve=, so trivial diffs dead-ended at a gate
+ with no defined pass.
+2. =no-approvals.org= line 73 (the actual execution step) still described the old
+ inline unbounded flow; I had only updated the preamble at line 49.
+3. =work-the-backlog.org= and =sentry.org= — the unattended callers — had no
+ receiver for "stop and surface to the user". The speedrun routes to
+ work-the-backlog, not the file I updated.
+4. The Step 2 exception still said the review runs "when it applies", which my
+ rewrite had made false.
+5. *The sharpest one.* Withholding the ticket/plan makes the author's claim
+ self-certifying and strands =review-code='s Intent-vs-Delivery criterion — the
+ one aimed at exactly the inherited-scope error this gate exists to catch. A
+ ticket is not the author's model; it is the independent record of what was
+ asked. I had not considered this.
+6. The loop turned on the verdict token, so a single Minor could burn all three
+ rounds and escalate.
+7. My override said it "doesn't relax the Prompt Contract" while field 2 of that
+ contract says paste your context verbatim — which would destroy the isolation
+ the whole change is built on.
+8. todo.org carried an unrelated =references/= rewrite, and the new task body
+ listed as open the questions the same commit answered.
+9. =subagents.md= still says subagent output is a claim to verify, unreconciled
+ with "approval is the reviewer's to give".
+
+All nine fixed. The =references/= hunk is split out as =3a933a2=. Round 2 sent
+back to the same reviewer, which is the loop working as designed.
+
+** 21:30 — The loop closed at three rounds
+
+Round 2 (three findings): =review-code='s adversarial contract still said two
+inputs, so the round-1 fix landed in =publish= but not in the text the reviewer
+reads — the two files disagreed at the one seam the change was about. A
+count/list regression my own fix introduced ("exactly two things" over a
+three-item list). And a fourth verdict I had missed entirely: =review-code=
+emits =Needs Discussion=, which is exactly what an adversarial reviewer reaches
+for on an architectural objection, and the flow had no handling for it.
+
+Fixing =Needs Discussion= reproduced round 1's finding 3 one level down, which I
+caught myself: all four unattended parking clauses keyed on "the loop hits its
+bound", and a first-round =Needs Discussion= is not a bound hit. Rewrote all four
+to trigger on "the review can't reach approval" with the three causes named, and
+asked the reviewer to verify that independently rather than take it from me.
+
+Round 3: APPROVE, with one Minor — a recurrence of the count/list mismatch, since
+my fourth-verdict fix left the lead-in saying "three outcomes" above four
+bullets. Fixed the numeral. The committed diff therefore differs from the
+approved one by exactly that word, which the flow's own Minor-only rule permits
+rather than spending a fourth round.
+
+The reviewer also verified things I had not asked about and would not have
+checked: that =failed= is already a legal outcome slug in work-the-backlog's
+metrics table, that the three workflow mirrors carry identical blob hashes rather
+than merely similar text, and that no fifth verdict token exists anywhere in
+=review-code=. It filed one follow-up correctly rather than fixing it in-diff —
+=start-work.md= Phase 7 still summarizes the publish flow instead of pointing at
+it, and was stale before today. Filed =[#C] :chore:solo:=.
+
+Convergence shape across the three rounds: nine findings, three, one Minor. The
+residue in later rounds was integration error from the previous round's fixes
+rather than new design problems, which is the shape the bound is calibrated for.
+
+Committed =8062460=, pushed, velox fast-forwarded and verified.
+
+Six commits this session, all pushed, both daily drivers current, inbox at zero,
+tree clean.
+
+** 2026-07-29 — Walking the remaining items, and a long lesson
+
+*Item 1, the =references/= dead link* (=5999f88=). Craig picked drop-the-link.
+The adversarial review returned =Needs Discussion= — the fourth verdict, on its
+first real use — and widened the fix twice, both correctly. My replacement prose
+said credentials "live in the rulesets repo" without naming a file, which would
+have sent readers to =calendar-reference.org=, whose three paths had been dead
+since May. Now names =mcp/README.org=. And the file itself was orphaned by the
+link removal, so both copies and the empty =references/= dirs are gone. Round 2
+approved with three Low findings, all against the task record: my count was wrong
+(seven sites, not five) and =scripts/lint.sh='s =check_md_links= already exists
+for that class, missing them only because it matches markdown syntax.
+
+*Rule gap found by using the rule.* =Needs Discussion= exits to the user, but I
+never wrote what happens after the user answers. Treated it as a fresh review on
+the reasoning that Craig adjudicated and the scope changed. Flagged to Craig; not
+yet written into the skill.
+
+*The wrap-org-table bug* (=ecd5d7b=). work reported that the tool splits a logical
+row and lint passes the result. Everything I *measured* held. Everything I
+*inferred* on top was refuted, four times:
+
+1. Root cause "absence of rules in the input" — refuted by the double-run repro
+ (the tool corrupts its own correct, rule-delimited output).
+2. "Tested and killed the empty-cell hypothesis" — the fixture was confounded.
+ With no hlines the code short-circuits at =:184= before the predicate is
+ reached, so I varied the empty cell while the path that reads it was switched
+ off, got a negative, and wrote it down as settled.
+3. "The reporter's row-below observation discriminates between the paths" — it
+ doesn't; both produce that signature. Claimed twice.
+4. "A static scan can't see primary-path exposure, needs simulation" — wrong, and
+ worse, I sent it to work, who built on it. Then my *corrected* advice (run the
+ predicate over rule-delimited groups) was also wrong: work implemented it and
+ showed it can't discriminate at any threshold, with worked examples where the
+ same structural signature has opposite correct verdicts.
+
+Three corrections sent to work, plus a fourth acknowledging their disproof. The
+review loop *bounded out* at three rounds — the first bound-out under the new
+rule, working as designed. Craig adjudicated by stripping the task to Verified /
+Two-fixes-that-work / Open-questions, which is the right shape: the measurements
+were never the problem.
+
+The fix that survived is work's read, not mine: check idempotence (reflow twice,
+diff) rather than build a detector. It's true by construction and needs nobody to
+decide what a group means.
+
+*The lesson, stated plainly.* Every refutation across three rounds landed on
+inference, never on a measurement. I ran experiments and then over-read them,
+repeatedly, at full confidence. work — after I'd sent them three wrong analyses —
+labelled their own uncertain number as a floor on a population they couldn't
+cleanly define, unprompted. That discipline is the thing to copy.
+
+I also found the 2026-07-27 note where I'd already observed this exact failure
+and left it in a session summary instead of filing it. work's framing: a correct
+observation recorded and then read as fine is the same failure as a green check
+on a corrupted table.
diff --git a/.ai/workflows/INDEX.org b/.ai/workflows/INDEX.org
index 42119b4..f18d953 100644
--- a/.ai/workflows/INDEX.org
+++ b/.ai/workflows/INDEX.org
@@ -1,5 +1,5 @@
#+TITLE: Workflow Index
-#+AUTHOR: Craig Jennings & Claude
+#+AUTHOR: Craig Jennings
#+DATE: 2026-04-25
* Purpose
@@ -18,8 +18,12 @@ This index must list every =.org= file in =.ai/workflows/= except this one and e
- =helper-mode.org= — role contract for a helper instance (a second Claude in the same project as a live primary). No manual trigger; the spawn paths route to it, "you are a helper" is the manual fallback.
- =first-session.org= — initialize =.ai/= for a brand-new project.
- Triggers: "this is a new project", "let's set this project up". Auto-runs if =.ai/sessions/= is empty.
-- =wrap-it-up.org= — end-of-session: write summary, archive, commit, push.
+- =wrap-it-up.org= — end-of-session: write summary, archive, commit, push, then a phrase-dependent Step 6 teardown. Bare "wrap it up" tears the session down (kills the ai-term buffer + =aiv-<project>= tmux session via a =Stop=-hook sentinel, after the valediction flushes); a "with summary" / "and summarize" wrap keeps the buffer; "and shutdown" gates on being the only live ai-term session, then powers the machine off via an abort-able Emacs countdown.
- Triggers: "wrap it up", "that's a wrap", "let's call it a wrap"
+ - No-teardown triggers: "wrap it up with summary", "wrap it up and summarize"
+ - Shutdown trigger: "wrap it up and shutdown"
+- =suspend.org= — capture-only mid-session pause for an abrupt departure: append a resume-weighted =SUSPENDED= entry to the Session Log, note uncommitted work, and LEAVE =.ai/session-context.org= in place so the next startup resumes from it. The capture-only counterpart to =wrap-it-up= (which archives + tears down) and to =flush= (=/flush=, which prompts =/clear= and resumes the same session). Provides only the capture half; startup's interrupted-session path is the resume half.
+ - Triggers: "suspend the session", "suspend", "I need to go", "stick a pin in everything"
- =retrospective.org= — post-mortem after a tough session.
- Triggers: "let's do a retrospective", "retrospective time"
@@ -44,12 +48,19 @@ This index must list every =.org= file in =.ai/workflows/= except this one and e
- Triggers: "let's do a journal entry", "create a journal entry"
- =clean-todo.org= — tidy =todo.org=: hygiene pass + =--archive-done=, then summarize. Wrap-up does this automatically; this is the manual entry point.
- Triggers: "clean up todo.org", "clean-todo", "tidy the todo file", "archive the done items in todo.org", "run the todo cleanup"
-- =process-inbox.org= — evaluate each inbox item against a three-question value gate (advances an existing TODO / improves the project / serves the mission), then implement, fold, file, defer, or reject per source (Craig / project handoff / script). Auto-invoked by startup when inbox is non-empty. Source-aware rejection flow: handoff rejections write a response back via =inbox-send= naming the failed gate question and any reconsideration condition.
- - Triggers: "process inbox", "process the inbox", "handle the inbox", "what's in inbox", "what's in the inbox", "let's clear the inbox", "let's process the inbox items"
-- =monitor-inbox.org= — the cadence + act-vs-file + reply layer over process-inbox: "monitor the inbox" runs a pass now then loops process-inbox every 15 min; gates on a clean tree + green suite at both ends; in no-approvals mode auto-executes only agreed + quick + solo items (else files or parks); also the ambient =inbox-status= task-boundary check and the reply-to-sender discipline.
- - Triggers: "monitor the inbox", "watch the inbox", "respond to the handoffs", "handle the handoffs"
-- =inbox-zero.org= — route the *global roam inbox* (=~/org/roam/inbox.org=) to owning projects by =<project>:= heading prefix. Distinct from =process-inbox.org= (the project's own =inbox/= dir). The current session claims only its own prefixed items, files them into =todo.org=, removes them from the shared inbox, and leaves foreign/unowned items. Every scan reports the total item count plus how many appear related to this project. v1 is single-destination (prefix-claim only); domain-aware whole-inbox routing is deferred. Called read-only from startup (count + offer) and as a wrap-up Step 3 sub-step.
- - Triggers: "inbox zero", "empty the inbox", "process the roam inbox", "triage my roam inbox"
+- =inbox.org= — one engine for the project's inbox surfaces, with the shared value gate / skeptical review / disposition ladder / reply discipline / capture-guard / priority-scheme check in one place, plus thin per-surface modes. *Process mode* evaluates each project-local =inbox/= item against the three-question value gate, then implements / folds / files / defers / rejects per source (auto-invoked by startup when inbox is non-empty). *Monitor mode* runs process mode now then loops it every 15 min, gating on a clean tree + green suite and adding the act-vs-file + no-approvals-execute + reply discipline. *Roam mode* routes the global roam inbox (=~/org/roam/inbox.org=) to owning projects by =<project>:= prefix (read-only nudge at startup, sweep at wrap-up). *Auto inbox zero* runs roam mode on an interactive =/loop= at a Craig-chosen interval. Distinct from =triage-intake.org= (external accounts), which stays separate.
+ - Process-mode triggers: "process inbox", "process the inbox", "handle the inbox", "what's in inbox", "what's in the inbox", "let's clear the inbox", "let's process the inbox items"
+ - Monitor-mode triggers: "monitor the inbox", "watch the inbox", "respond to the handoffs", "handle the handoffs"
+ - 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
@@ -81,11 +92,18 @@ This index must list every =.org= file in =.ai/workflows/= except this one and e
- =spec-create.org= — author a design/feature spec before non-trivial work (more than ~6 hours, or real trade-offs): a when-to-spec gate, then problem-first framing, design + alternatives + inline mini-ADR decisions, implementation phases + acceptance criteria + a readiness-dimensions menu, a terseness pass, and a hand-off self-check against the review rubric. The *author* side that starts the trio; its output feeds =spec-review.org=.
- Triggers: "let's write a spec", "spec this out", "create a spec for X", "spec-create workflow"
-- =spec-review.org= — review a design/feature spec for implementation-readiness: run the readiness gate, read the code first, evaluate across dimensions, assign a rubric (Ready / Ready-with-caveats / Not-ready / Needs-research), and write a =<spec>-review.org= file when not ready. The *reviewer* side; its output feeds =spec-response.org=.
+- =spec-review.org= — review a design/feature spec for implementation-readiness: run the readiness gate, read the code first, evaluate across dimensions, assign a rubric (Ready / Ready-with-caveats / Not-ready / Needs-research), and record findings as =TODO= tasks in the spec's =* Review findings= section when not ready. The *reviewer* side; its output feeds =spec-response.org=.
- Triggers: "review the spec", "is this spec implementation-ready?", "spec-review workflow", "review the design"
-- =spec-response.org= — fold an external spec review back in: decide accept / modify / reject for every recommendation, weave accepts into the spec body, document modifies and rejects in a "Review dispositions" section, reconcile cross-spec tensions, iterate to implementation-ready. The *author* side; consumes the =<spec>-review.org= file =spec-review.org= produces.
+- =spec-response.org= — fold a spec review back in: decide accept / modify / reject for every finding, weave accepts into the spec body, complete each finding task in place (the reason recorded on modifies and rejects), reconcile cross-spec tensions, iterate to implementation-ready. The *author* side; consumes the =* Review findings= =spec-review.org= produces.
- Triggers: "respond to the review", "process the spec reviews", "spec-response workflow", "fold in the review"
+** Code quality
+
+- =code-quality.org= — one trigger that sequences every behavior-preserving quality pass over a scope of existing code: =/refactor= (complexity, duplication, dead-code, simplification) then =readability-audit= (comments, headers, names, organization), then surfaces the =:refactor:= tasks readability filed and any deferred =/refactor= findings. A thin orchestrator — each pass keeps its own gate. Excludes =/simplify= (that's for the current diff, not existing code).
+ - Triggers: "code quality sweep", "quality sweep", "run every quality pass on <scope>", "give me every pass on <scope>"
+- =readability-audit.org= — make code readable to a future maintainer: audit file-top commentary, inline comments (why-not-what), names (intention-revealing), and organization (co-location / stepdown / cohesion). The cheap comment- and name-only fixes (dimensions A/B/C) land inline, verified by a green suite; the structural findings (dimension D — split a module, rename a public symbol) are *filed* as =:refactor:= tasks, not done here. Language-agnostic. Feeds =/refactor= (which executes the filed structural work); distinct from =/refactor='s metric scans and =/simplify='s diff cleanup.
+ - Triggers: "let's run the readability-audit workflow", "audit the comments and commentary in <area>", "clean up the structure/organization of <module>", "readability audit"
+
** Tools and meta
- =process-meeting-transcript.org= — record → transcript → labeled archive.
@@ -95,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.
@@ -107,7 +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"
-- =cross-agent-comms.org= — protocol for cross-project agent coordination via =inbox/from-agents/= (file-based IPC, GPG-signed, supports cross-machine over Tailscale). Auto: when =cross-agent-watch= detects a new inbound message, or when an agent decides to initiate a cross-project conversation. Operational scripts (=cross-agent-send=, =-recv=, =-watch=, =-status=, =-discover=, =-halt=, =-resume=) and their READMEs live at =.ai/scripts/cross-agent-comms/=.
+ - Exception: any phrase containing "speedrun" routes to =work-the-backlog.org='s no-approvals speedrun preset instead
* Living Document
diff --git a/.ai/workflows/add-calendar-event.org b/.ai/workflows/add-calendar-event.org
index 2650fb7..5dd6c42 100644
--- a/.ai/workflows/add-calendar-event.org
+++ b/.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/.ai/workflows/broadcast.org b/.ai/workflows/broadcast.org
index 1be07d2..cc14f00 100644
--- a/.ai/workflows/broadcast.org
+++ b/.ai/workflows/broadcast.org
@@ -1,5 +1,5 @@
#+TITLE: Broadcast Workflow
-#+AUTHOR: Craig Jennings & Claude
+#+AUTHOR: Craig Jennings
#+DATE: 2026-05-29
* Overview
@@ -159,11 +159,11 @@ broadcast, not a task and not tailored to this project.
- Ask Craig any follow-up questions then — this message is deliberately general.
#+end_example
-The "For the receiving agent" block is fixed text — it travels with every situational broadcast so the message is self-describing. A receiving project's =process-inbox= reads it and acts on those instructions without needing any special-casing; the value gate accepts it as situational awareness that improves how the project works.
+The "For the receiving agent" block is fixed text — it travels with every situational broadcast so the message is self-describing. A receiving project's =inbox.org= process mode reads it and acts on those instructions without needing any special-casing; the value gate accepts it as situational awareness that improves how the project works.
** Receiving behavior (what a project does with an incoming situational broadcast)
-When =process-inbox= encounters a =Broadcast:= item, the disposition is *record-and-hold*, not file-as-task:
+When =inbox.org= process mode encounters a =Broadcast:= item, the disposition is *record-and-hold*, not file-as-task:
1. Add a dated entry to =notes.org= Active Reminders capturing the situation and its end date (if any).
2. If the event bears on an open task, note the connection in that task's body.
diff --git a/.ai/workflows/clean-todo.org b/.ai/workflows/clean-todo.org
index dd33056..48d3084 100644
--- a/.ai/workflows/clean-todo.org
+++ b/.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/.ai/workflows/code-quality.org b/.ai/workflows/code-quality.org
new file mode 100644
index 0000000..3c4ed8f
--- /dev/null
+++ b/.ai/workflows/code-quality.org
@@ -0,0 +1,90 @@
+#+TITLE: Code-Quality Sweep Workflow
+#+AUTHOR: Craig Jennings
+#+DATE: 2026-06-28
+
+* Overview
+
+One trigger that runs every behavior-preserving quality pass over a scope of
+*existing* code, in order, then surfaces what got filed for later. It's a thin
+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,
+ duplication, dead-code) plus the simplification lens.
+2. =readability-audit= ([[file:readability-audit.org][readability-audit.org]]) — prose and human-reader clarity
+ (comments, file headers, names, organization).
+
+It deliberately does *not* run =/simplify=: that works the current uncommitted
+diff, not existing committed code, so it belongs to the moment you've just made a
+change, not to a sweep of code already in the tree (see "The /simplify boundary"
+below).
+
+* When to Use This Workflow
+
+- "code quality sweep" / "quality sweep"
+- "run every quality pass on <scope>" / "full quality pass on <scope>"
+- "give me every pass on <file/module/tree>"
+
+Do NOT use it for:
+- *In-flight diff cleanup* — that's =/simplify= on the change you just made.
+- *Bug hunting* — these passes are behavior-preserving; for defects use =debug=
+ or =/review-code=.
+- *Performing the structural refactors it files* — those become =:refactor:=
+ tasks; work them later via =/refactor rename= / =/refactor simplification= or
+ =/start-work=.
+
+* Steps
+
+** 1. Scope
+
+Pick the target: one file, a named module set, or the whole tree (honor
+=.aiignore=). The same scope is passed to both passes so they cover the same
+code.
+
+** 2. /refactor <scope>
+
+Run =/refactor= on the scope. Its default full scan covers complexity,
+duplication, dead-code, and simplification. It presents findings and applies
+only what's approved (its own gate) — structure and logic first, so the
+readability pass audits the cleaned-up code.
+
+** 3. readability-audit on <scope>
+
+Run the readability-audit workflow on the same scope. Its cheap comment- and
+name-only fixes (dimensions A/B/C) land inline and are verified by a green
+suite; its structural findings (dimension D — split a module, rename a public
+symbol) are *filed* as =:refactor:= tasks rather than done here.
+
+** 4. Surface the residue
+
+Collect and report what the sweep left behind for later work:
+
+- The =:refactor:= tasks readability-audit filed (the structural backlog).
+- Any =/refactor= findings deferred rather than applied in step 2.
+
+That residue is the "do this next" list the sweep produces; it's not a failure
+to finish, it's the structural work that needs its own design and test pass.
+
+* The /simplify boundary
+
+=/simplify= and this sweep don't overlap: =/simplify= cleans the *current diff*
+and applies its fixes directly, so reach for it right after making a change,
+before committing. This sweep works *existing committed code* and runs the
+scan-and-present passes. One trigger can't sensibly do both — a diff you're
+holding and a tree you're auditing are different inputs.
+
+* Verification
+
+Each pass owns its verification (=/refactor= runs the suite after applying;
+readability-audit verifies inline fixes against a green suite). The umbrella
+adds nothing beyond sequencing, so when both passes report green, the sweep is
+clean — confirm that before reporting done rather than assuming it.
diff --git a/.ai/workflows/create-workflow.org b/.ai/workflows/create-workflow.org
index 6060df1..393fce5 100644
--- a/.ai/workflows/create-workflow.org
+++ b/.ai/workflows/create-workflow.org
@@ -195,7 +195,7 @@ After the Q&A, ask together:
Decide on a name for this workflow.
*Naming convention:* Action-oriented (verb form)
-- Examples: "refactor", "inbox-zero", "create-workflow", "review-code"
+- Examples: "refactor", "clean-todo", "create-workflow", "review-code"
- Why: Shorter, natural when saying "let's do a [name] workflow"
- Filename: =.ai/workflows/[name].org=
@@ -240,10 +240,10 @@ Update =notes.org=:
Example entry:
#+begin_src org
-,** inbox-zero
-File: =.ai/workflows/inbox-zero.org=
+,** journal-entry
+File: =.ai/workflows/journal-entry.org=
-Workflow for processing inbox to zero:
+Workflow for capturing a daily journal entry:
1. [Brief workflow summary]
2. [Key steps]
diff --git a/.ai/workflows/cross-agent-comms.org b/.ai/workflows/cross-agent-comms.org
deleted file mode 100644
index 430b4b0..0000000
--- a/.ai/workflows/cross-agent-comms.org
+++ /dev/null
@@ -1,334 +0,0 @@
-#+TITLE: Cross-Agent Communication Workflow (v5)
-#+AUTHOR: Craig Jennings & Claude (homelab + career sessions)
-#+DATE: 2026-04-27
-#+VERSION: 5
-
-* Status
-
-Draft. Iterating between the homelab and career sessions through a multi-round design discussion. Awaiting Craig's review for promotion to =~/code/rulesets/claude-templates/.ai/workflows/=.
-
-v5 changes from v4:
-- *Script absorption.* Seven operational scripts (=cross-agent-send=, =cross-agent-recv=, =cross-agent-watch=, =cross-agent-status=, =cross-agent-discover=, =cross-agent-halt=, =cross-agent-resume=) now own most implementation detail. Their READMEs are the operational source of truth. The spec stays declarative.
-- *Failsafe halt.* Layered HALT-file mechanism stops all cross-agent activity on a machine within ~5 min, without visiting individual sessions or restarting Claude Code. =cross-agent-halt= and =cross-agent-resume= are the convenience entry points; every other component checks the HALT file independently.
-- *Identity.* Messages are GPG-signed by sender and verified by receiver. Combined with POSIX permissions on =from-agents/= and Tailscale-level network auth, identity becomes a three-layer story.
-- *Atomic writes.* Writers MUST use temp-file + rename. =cross-agent-send= handles this; the spec just states the contract.
-- *Dedup.* Sequence-collision dedup is now binary SHA-256 equality, not a fuzzy ">90% match" threshold.
-- *Cold-start handling.* Layered: =cross-agent-watch= (push notifications via =inotifywait=) is the primary mechanism; startup-workflow check and user-direct-injection are coverage layers.
-- *Spec stays roughly the same length but does more protocol work.* Operational detail (rsync retry numbers, inotifywait recipes, peers.toml schema, GPG flags, dedup mechanics) moved to the script READMEs. The spec adds new protocol elements (identity layer, atomic-writes contract, SHA-256 dedup, =escalate= type, =RELEASE_STATUS= values, =REQUIRES_TOOLS= optional field) in the freed space. Total documentation surface (spec + seven READMEs ≈ 1000 lines) is larger than v4's 259 lines, but the spec and the READMEs serve different audiences — protocol-thinkers and CLI-users — and a reader of just the spec can comprehend the protocol without consulting any README.
-
-* When to use
-
-When two Claude sessions in different projects (same machine or different machines on the same Tailscale tailnet) need to coordinate on a shared task that one session can't complete alone — typically because one has tooling, context, or MCP access the other doesn't.
-
-Examples that fit:
-- Session A asks session B to apply a workflow patch in B's project, then verify it.
-- Session A runs a long task and needs session B to monitor results in B's domain.
-- Two sessions co-design a workflow.
-
-Examples that don't fit:
-- A simple file handoff that doesn't require iteration.
-- A task one session can do alone.
-- Cross-tailnet or cross-organization. The protocol is local-tailnet-scoped.
-
-* Protocol
-
-** File location
-
-Each project has =inbox/from-agents/= as its agent-comms mailbox. Create the directory if it doesn't exist; set permissions =chmod 700= and ownership to the user.
-
-- Sender writes to receiver's =inbox/from-agents/=.
-- Receiver polls (or watches) =inbox/from-agents/=, *not* the parent =inbox/=.
-- The parent =inbox/= stays reserved for human-triage items.
-- Out-of-band artifacts (PDFs, datasets) live at =inbox/from-agents/artifacts/=. Reference by relative path in the message body.
-
-The user does NOT write directly to =from-agents/=. To inject input into a running conversation, the user tells one of the agents in that agent's session; the agent writes the input as a normal message attributed to the user.
-
-** File naming
-
-=YYYYMMDDTHHMMSSZ-from-<sender>-<short-conv-id>.org=
-
-- Timestamp is UTC ISO 8601 compact. The trailing =Z= is mandatory.
-- =from-<sender>= prefix.
-- =<short-conv-id>= is a stable kebab-case slug across the back-and-forth. Reusable across time; ordering relies on filename timestamps.
-
-Frontmatter =#+TIMESTAMP= carries the same instant in local time with explicit offset. The two MUST refer to the same instant.
-
-The implementation (=cross-agent-send=) generates the canonical filename from the message's frontmatter (=CONVERSATION_ID=, current UTC time) and the sender's project context. Senders supply only the message body file; the script handles naming. Senders MUST NOT pre-name files in this format and pass them through; the script overwrites with its own canonical name to ensure consistency and enable the sender-side max-seen sequence-collision-reduction scan.
-
-GPG signatures live in a sibling file =YYYYMMDDTHHMMSSZ-from-<sender>-<short-conv-id>.org.asc=. Receivers verify before processing. See =* Writes are atomic= for the two-file delivery ordering rule.
-
-** Frontmatter
-
-Required:
-
-#+begin_example
-#+TITLE: <human-readable subject>
-#+CONVERSATION_ID: <stable across the thread>
-#+MESSAGE_TYPE: <see types below>
-#+SEQUENCE: <integer hint>
-#+TIMESTAMP: <ISO 8601 with explicit offset>
-#+PROTOCOL_VERSION: 5
-#+end_example
-
-Optional:
-
-#+begin_example
-#+REQUIRES_TOOLS: <comma-separated tool/MCP slugs, e.g. gmail-mcp, slack-mcp>
-#+RELEASE_STATUS: <see release-statuses; valid only on MESSAGE_TYPE: release>
-#+WORKFLOW_VERSION: <sender's version of cross-agent-comms.org; informational only in v5 — no enforcement>
-#+end_example
-
-Receiver sanity-checks frontmatter before acting. Missing or malformed frontmatter → surface to user, don't proceed. Mismatched =PROTOCOL_VERSION= → receiver writes a =query= asking the originator to upgrade.
-
-** Identity
-
-Messages are GPG-signed by the sender. Receivers verify the detached signature before processing the message body.
-
-The implementation (=cross-agent-send=) signs automatically with the sender's configured key (the user's primary GPG key by default; configurable via =--key= flag or environment). Receivers verify automatically against the keys in their GPG keyring.
-
-Identity is a three-layer story:
-
-1. *Tailscale layer.* Only tailnet members can reach the rsync-over-SSH endpoint at all.
-2. *POSIX layer.* =chmod 700= on =from-agents/= means only processes running as the directory's owner can write.
-3. *GPG layer.* Sender's signature on each message proves the message originated from a process holding the key.
-
-Three independent layers. Per-user GPG (using existing keys) gives a correctness check more than a security boundary — unsigned messages are almost certainly bugs, not attackers. That's still load-bearing.
-
-** Writes are atomic
-
-Writers MUST use a temp-file + rename pattern (=mktemp= + =mv= within the same filesystem) so receivers never see partial files. The implementation script (=cross-agent-send=) handles this.
-
-Receivers ignore =.tmp.*= files, processing only the final renamed name.
-
-*Two-file ordering.* When a message has a sibling GPG signature file (=.org.asc=), the writer MUST rename the =.asc= to its final name *before* renaming the =.org=. Two =mv= operations are not atomic together — without this ordering, a receiver could read the =.org= in the window between the two renames and fail GPG verify because the =.asc= hasn't landed yet. The rule: receiver only acts on =.org= files, and a =.org= without a corresponding =.asc= means the signature is genuinely missing (not still in flight).
-
-** Sequence numbering
-
-=#+SEQUENCE= is a *hint*, not a strict counter. Canonical order is =#+TIMESTAMP=. Sequences may collide under rapid back-and-forth (both sides write what they think is sequence N near-simultaneously). Treat collision as a normal protocol event.
-
-*Receiver-side dedup rule.* When a new file shares =CONVERSATION_ID= + =SEQUENCE= with an already-processed message, compare SHA-256 hashes. Identical hashes → silent dedup, treat as a retry. Different hashes → process both, ordered by =#+TIMESTAMP=.
-
-*Sender-side collision-reduction (best-effort).* Before picking sequence, scan the receiver's =from-agents/= for the highest existing sequence in this conversation across both sender prefixes. Use =max(seen) + 1=.
-
-** Message types
-
-- *request* — a side asks for work, input, or a decision. Sequence 1 is always =request=.
-- *progress* — work-in-progress checkpoint. "Here's where I am, no action needed from you, more coming." Originator's poll loop should NOT page the user on progress messages.
-- *query* — either side asks a clarifying question that blocks further work. Originator's poll loop SHOULD surface this immediately. Originator answers and work continues.
-- *pushback* — receiver formally disagrees with the request and has *not* started the work. Carries reasoning. Distinct from =query= because the originator's response path differs.
-- *complete* — receiver signals the requested work is done. Triggers verification.
-- *release* — terminal type. Originator writes after verifying =complete=. Carries =RELEASE_STATUS= to disambiguate the closure mode.
-- *escalate* — punts the conversation to the user for adjudication. Both sides pause polling on =escalate=; the user resolves.
-
-Reply expectation is implied by type: =request=, =query=, =pushback=, =escalate= expect a reply; =progress=, =complete=, =release= don't.
-
-** Conversation lifecycle
-
-A conversation is a directed loop between an originator (issued sequence 1) and a receiver:
-
-1. Originator writes =request= (sequence 1). Begins polling for replies.
-2. *Optional acknowledgment.* Receiver may write a =progress= at sequence 2 to acknowledge receipt and set expectations. Required if work will take >5 minutes (so the originator's poll loop doesn't waste wakes).
-3. *Optional echo-back.* For ambiguous or large requests, receiver writes a =progress= that restates work items and announces "starting now unless you push back within N minutes."
-4. Receiver works. May write =progress= updates. =query= mid-work if blocked. =pushback= if the request is wrong.
-5. Receiver writes =complete=. Begins polling for =release=.
-6. Originator reads, *verifies the deliverable directly*. For subjective deliverables, verification is the originator's editorial accept.
-7. If verified: =release= with =RELEASE_STATUS: complete=. If problems: new =request= (next sequence number).
-8. Receiver sees =release=, stops polling.
-
-The verification step is load-bearing. =complete= is a *claim*; =release= is *verification*.
-
-** Pushback path
-
-On receiving a =pushback=, the originator chooses:
-
-1. *Revise* — new =request= with adjusted scope.
-2. *Insist* — new =request= addressing the pushback's reasoning, standing by direction.
-3. *Withdraw* — =release= with =RELEASE_STATUS: withdrawn-after-pushback=.
-
-*Deadlock cap.* After two pushback-insist exchanges, the next message MUST be =MESSAGE_TYPE: escalate=. Both agents pause polling; the user resolves.
-
-** =RELEASE_STATUS= values
-
-| Status | Meaning |
-|---+---|
-| =complete= | Goal achieved, originator verified |
-| =cancelled= | Originator changed their mind mid-conversation |
-| =withdrawn-after-pushback= | Originator chose option 3 on receiver's =pushback= |
-| =abandoned-after-escalation= | User adjudicated and chose to close the conversation |
-| =abandoned-after-timeout= | Receiver auto-closed after originator never returned to verify |
-
-** Async fallback
-
-If the originator session ends between =request= and =complete=, the receiver's =complete= goes unverified. Receiver behavior:
-
-- Polls for =release= up to ~24 hours of cycles (implementation default).
-- After timeout, writes a final =progress= message ("treating as terminal-without-verification; originator never returned to release") and stops polling. Receiver does NOT write =release= itself — that would contradict the lifecycle rule that =release= is the originator's terminal action.
-- Next time the originator project starts, the unreleased =complete= is surfaced as a startup item. The user can issue a late =release= (with whichever =RELEASE_STATUS= fits) or open a fresh conversation to revisit. =RELEASE_STATUS: abandoned-after-timeout= is used at that point if the user wants to formally close the orphaned thread.
-
-** Escalation
-
-A side writes =escalate= when:
-- Pushback-insist deadlock cap reached.
-- Conversation has stalled (no productive movement in N exchanges).
-- A reply-expecting message has gone unanswered past timeout.
-
-Body summarizes both sides' positions in 60 seconds of reading. Both agents pause polling; the user resolves.
-
-* Implementation notes
-
-This sub-section describes how to operate the protocol. Operational detail lives in the seven scripts' READMEs.
-
-** Recommended scripts
-
-| Script | Replaces user action | README |
-|---+---+---|
-| =cross-agent-send <dest> <msg>= | Filename generation, GPG sign, atomic write, peer lookup, rsync push, retry+backoff, failure surfacing — seven mechanical sender-side steps. Frontmatter and message body are still author-supplied. | =cross-agent-send.md= |
-| =cross-agent-recv <msg>= | Frontmatter sanity-check, =PROTOCOL_VERSION= verify, GPG verify, SHA-256 dedup, =REQUIRES_TOOLS= check — five mechanical receiver-side steps. Output is a structured decision (=process= / =dedup= / =query= / =reject=) the agent acts on. | =cross-agent-recv.md= |
-| =cross-agent-watch= | Manually checking inboxes; "did I get a message?" | =cross-agent-watch.md= |
-| =cross-agent-status= | Walking each project to count pending messages | =cross-agent-status.md= |
-| =cross-agent-discover= | Remembering project topology and reachability | =cross-agent-discover.md= |
-| =cross-agent-halt [reason] [--tailnet]= | Visiting each session to stop polling, restarting Claude Code, or hand-killing processes when comms go runaway. =--tailnet= propagates HALT to all peers. | =cross-agent-halt.md= |
-| =cross-agent-resume [--tailnet]= | Manually clearing the HALT state and restarting the watcher. Per-session polling does NOT auto-resume — the user re-engages each session explicitly. | =cross-agent-resume.md= |
-
-The scripts are tools the user runs from any terminal. They do not depend on agent context — =cross-agent-status= run from a fresh shell works.
-
-A reader can comprehend this protocol from this spec alone. Script READMEs add operational detail that makes the protocol practical to use, but understanding the protocol's semantics requires only this document.
-
-** Polling
-
-Default cadence: 270 seconds (≈4.5 min). Sits just under the 5-minute prompt-cache TTL.
-
-If a side needs to slow down (heads-down work, idle wait), it writes a =progress= message saying so in prose. The other side adapts. There are no named polling modes.
-
-After ~12 empty polls in a row, the poll loop surfaces the silence to the user.
-
-A future runtime with native filesystem-event support could replace polling for active sessions; =cross-agent-watch= already provides event-driven notifications outside active sessions.
-
-** User multi-tasking
-
-- *Deferral.* If the user's last message in the agent's session was less than 60 seconds ago AND a poll fires, queue the inbox check until either the user sends another message OR 5 minutes pass without further input.
-- *Surfacing.* On the next user-facing response: "While we were working on X, a cross-agent message landed from <project>. It's a =<type>= — want me to handle it now or after we finish?"
-- *Mid-question.* Answer the user first.
-- *Project switch.* If the user moves to the receiver project mid-conversation, the receiver agent surfaces the in-flight thread on first user prompt.
-- *Conversation state.* Always include in any response that mentions a cross-agent thread: "<conv-id> at sequence N, awaiting <event>."
-
-** Failure modes
-
-The seven scripts surface most failures with concrete error messages. Spec-level failure modes:
-
-- *Malformed frontmatter on a received file.* Surface to user; do not act.
-- *Mismatched =PROTOCOL_VERSION=.* Receiver writes =query= asking originator to upgrade.
-- *Missing or invalid GPG signature.* Receiver surfaces "unsigned/unverified message"; refuses to act.
-- *Sequence collision* with non-matching SHA-256. Process both, ordered by timestamp.
-- *Required tool unavailable.* Receiver checks =REQUIRES_TOOLS= during frontmatter-sanity-check (before any work begins). On a missing tool, receiver writes =query= asking the originator to reframe the request to avoid the unavailable tool. Originator may revise (new =request=) or withdraw (=release= with =RELEASE_STATUS: cancelled=). =query= is the right type rather than =pushback= because missing-tool is a capability gap, not disagreement.
-- *Runaway resource usage.* User invokes =cross-agent-halt= globally (or =cross-agent-halt --tailnet= for cross-machine). HALT file stops all components within one polling cycle (~5 min). See =* Halt mechanism= for the layered checks.
-- *User halts mid-conversation.* Both sides write a final =progress= note ("HALT fired; pausing"); polling stops within one cadence; conversations resume on explicit per-session re-engage after HALT clears.
-- *HALT file accidentally created* (typo, errant =touch=). =cross-agent-status= prominently flags HALT active; user clears with =cross-agent-resume=. Cost: no messages send during the typo window.
-- *HALT file unreadable* (perms wrong, partial write). Each component fails-closed (treats as halted) and reports "HALT file present but unreadable; treat as halted." Safer than fail-open.
-
-Operational failures (rsync push fails, watcher dies, peer unreachable) live in the script READMEs' failure-mode tables.
-
-* Halt mechanism
-
-A failsafe to stop all cross-agent activity on a machine without visiting individual sessions or restarting Claude Code. Designed for the runaway-polling case: an agent has spun up conversations with N other agents, polling is eating CPU, and the user needs to stop everything *now*.
-
-** The HALT file
-
-Path: =~/.config/cross-agent-comms/HALT=.
-
-Existence triggers halt across all components on the machine. The file's body may carry an optional human-readable reason (reviewed by the user later when deciding to resume).
-
-User commands:
-
-#+begin_example
-$ touch ~/.config/cross-agent-comms/HALT # halt
-$ rm ~/.config/cross-agent-comms/HALT # resume
-#+end_example
-
-Or via convenience scripts (=cross-agent-halt= / =cross-agent-resume=) that also handle the watcher service and cross-machine propagation.
-
-** Layered checks (the failsafe property)
-
-Every component MUST check the HALT file. The "any one component stops the system independently" property is what makes this failsafe — the system doesn't depend on a single point doing the right thing.
-
-| Component | Check timing | Behavior on HALT |
-|---+---+---|
-| =cross-agent-send= | At start of send + between =.asc= and =.org= rsync + between retry iterations | Refuse to start new send; complete current step then exit. Worst case: one in-flight send finishes within a few seconds. |
-| =cross-agent-recv= | Before any verify or dedup | Leave inbound message in place — do NOT dedup, reject, or move. Resume picks it up via cold-start handling. |
-| =cross-agent-watch= | At iteration start | Suppress notifications; log only. Continues running, no-op until HALT clears. |
-| =cross-agent-status= | At start | Print prominent "⚠ HALT ACTIVE" banner before normal output. Read-only, continues. |
-| =cross-agent-discover= | At start | Print HALT banner; continue read-only enumeration. |
-| Agent polling loop | First action on every wake | Write a final =progress= note to any active conversation ("HALT fired; pausing"), do NOT reschedule, surface "halt active" to user. Polling decays within one cadence (~5 min). |
-| Agent user-facing responses | Every response while HALT is set | Append "(HALT active; cross-agent comms paused)" to the response. On HALT clear, the next response says "(HALT cleared; cross-agent comms ready to resume — say so to re-engage polling)." Persistent, not just first-response — keeps awareness alive. |
-| Conversation initiator | Before writing sequence 1 of any new conversation | Refuse and surface to user. |
-| Startup workflow | Phase A on session start | If HALT exists, surface immediately and skip cross-agent inbox checks. |
-
-The agent polling-loop check is the load-bearing one for "stops eating CPU." Wake-ups already scheduled fire, but each wake on-HALT is a no-op + reschedule-prevention. Within one polling cadence (~5 min) all polling stops.
-
-*Fail-closed on unreadable HALT.* If the HALT file exists but is unreadable (wrong permissions, partial write), components MUST treat as halted. Safer than fail-open.
-
-** Resume asymmetry (deliberate)
-
-Halt is automatic everywhere. Resume requires explicit user intent per-session.
-
-When the user removes HALT (or runs =cross-agent-resume=), components stop refusing to act, but agent polling does NOT auto-resume. The user must open each session and tell that agent to resume polling for its conversations.
-
-The asymmetry exists because:
-
-1. Auto-resume could silently invert intentional kills. If the user halted because a session was misbehaving, removing HALT shouldn't quietly revive it.
-2. Per-session resume forces the user to look at each session and confirm the situation is resolved before re-engaging.
-
-** Cross-machine halt
-
-=cross-agent-halt --tailnet= iterates =peers.toml= and SSH-touches HALT on each peer. Same shape for resume.
-
-Reports per-peer status with non-zero exit on partial halt:
-
-#+begin_example
-$ cross-agent-halt --tailnet
-Halting velox.local ✓ (HALT file written)
-Halting bastion.local ✗ (ssh exit 255: no route to host)
-Halting locally ✓ (HALT file written)
-
-PARTIAL HALT: 2/3 machines halted. bastion.local needs manual halt.
-Exit 1.
-#+end_example
-
-Scripting can detect partial halt via the exit code. Same pattern for =--tailnet= on resume.
-
-* Limitations
-
-- *Local-tailnet only.* Filesystem IPC + rsync over SSH. Cross-tailnet or cross-organization is out of scope.
-- *Identity has three layers (Tailscale + POSIX + GPG)* but no message-content encryption. Confidentiality is not the goal; signing is correctness, not secrecy.
-- *Single-receiver per conversation.* Fan-out to multiple receivers requires manually orchestrating multiple parallel conversations.
-- *Polling is best-effort.* A wake may be delayed by an in-flight tool call until the runtime is idle. =cross-agent-watch= mitigates by offering event-driven notifications.
-- *Project-extension drift.* If two projects' =.ai/project-workflows/= modify shared workflow definitions in incompatible ways, cross-agent assumptions can diverge silently. The optional =#+WORKFLOW_VERSION= advisory field is informational only in v5 — no implementation reads or acts on it. A future version may add enforcement on mismatch (e.g. receiver writes =query= asking which side is stale). Today, alignment is verified manually before high-stakes conversations.
-
-* Persistence after release
-
-Conversation files persist by default. The conversation log is the audit trail.
-
-Manual archival is fine if the inbox grows unmanageable. Suggested cadence: once the conversation has been =release='d AND the work it produced has shipped, archive both projects' message files into =.ai/sessions/cross-agent/= as a flat directory — no per-conversation subdirectories. Rename each archived file to lead with the conversation-id so messages from the same conversation cluster on =ls=: =<conv-id>-<TIMESTAMP>-from-<sender>.org= (and the matching =.asc= sibling, if present). Inbox filenames lead with the timestamp because chronological arrival is what matters in =from-agents/=; archives invert that because grouping by conversation is what matters when reading history. Keep the =.asc= signatures alongside the =.org= files in archive — they're small and document the GPG verification chain.
-
-Old messages don't affect protocol behavior (=cross-agent-status='s pending semantics correctly ignore released messages) but the =from-agents/= directory grows indefinitely without manual archival. =cross-agent-status= performance degrades noticeably when a project's =from-agents/= exceeds a few hundred files. =cross-agent-init= (deferred to v6) would include an archival sub-command.
-
-* Open questions
-
-- *=cross-agent-init= and =cross-agent-compose= helper scripts.* =-init= would be one-command project bootstrap (creates =inbox/from-agents/= with =chmod 700=, installs the =cross-agent-watch= systemd path unit, validates peer config, runs a discovery probe). =-compose= would be interactive frontmatter authoring (prompts for required fields, produces a draft message file). Both deferred to v6. Current onboarding requires manual =mkdir= + systemd setup per =cross-agent-watch.md='s install recipe; current message authoring requires writing the file by hand or via a small in-agent template.
-- *Hard conversation timeout.* The async-fallback timeout is implementation-default ~24 hours. Right number depends on use case; tighten as patterns emerge.
-- *=paused= polling state.* Today there's no clean signal for "pause without ending." Add when first user complaint surfaces.
-- *Multi-LLM context.* If we ever bring in a non-Claude agent, the protocol's natural-language framing may need formalization.
-
-* Examples
-
-** =prep-fixup= conversation (2026-04-26 → 2026-04-27)
-
-Eleven exchanges between homelab and career produced the v4 spec by iterative critique-and-simplification. Three real-time sequence collisions during the conversation drove the sequence-as-hint rule that landed in v4 and persists in v5.
-
-Files at =~/projects/{homelab,career}/inbox/from-agents/= named =*-prep-fixup.org=. Worth re-reading when designing future cross-agent flows.
-
-** =comms-cold-start-discovery= conversation (2026-04-27)
-
-The follow-up that produced this v5 spec. Cold-start, watcher tooling, agent discovery, GPG identity, sha256 dedup, atomic writes, POSIX perms, script absorption, and process-vs-text simplification. Tonight's first cold-start in real time (career session went dormant after =prep-fixup= release; Craig's user-injection re-engaged it) is the worked demonstration of the v5 user-injection rule.
-
-Files at =~/projects/{homelab,career}/inbox/from-agents/= named =*-comms-cold-start-discovery.org=.
diff --git a/.ai/workflows/daily-prep.org b/.ai/workflows/daily-prep.org
index b6989e7..3f21214 100644
--- a/.ai/workflows/daily-prep.org
+++ b/.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/.ai/workflows/delete-calendar-event.org b/.ai/workflows/delete-calendar-event.org
index 5bb92a1..7de0086 100644
--- a/.ai/workflows/delete-calendar-event.org
+++ b/.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/.ai/workflows/edit-calendar-event.org b/.ai/workflows/edit-calendar-event.org
index 662f0b4..27a9dd3 100644
--- a/.ai/workflows/edit-calendar-event.org
+++ b/.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/.ai/workflows/email-assembly.org b/.ai/workflows/email-assembly.org
index 003459c..699dbc0 100644
--- a/.ai/workflows/email-assembly.org
+++ b/.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/.ai/workflows/extract-email.org b/.ai/workflows/extract-email.org
index 3a70bea..c68bafe 100644
--- a/.ai/workflows/extract-email.org
+++ b/.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/.ai/workflows/find-email.org b/.ai/workflows/find-email.org
index 0ef9615..d71ed3e 100644
--- a/.ai/workflows/find-email.org
+++ b/.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/.ai/workflows/first-session.org b/.ai/workflows/first-session.org
index 60118a2..147026f 100644
--- a/.ai/workflows/first-session.org
+++ b/.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/.ai/workflows/flashcard-review.org b/.ai/workflows/flashcard-review.org
index 31027b3..09af348 100644
--- a/.ai/workflows/flashcard-review.org
+++ b/.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/.ai/workflows/helper-mode.org b/.ai/workflows/helper-mode.org
index 8ead37b..a6acfa7 100644
--- a/.ai/workflows/helper-mode.org
+++ b/.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
@@ -65,7 +65,7 @@ The git ban is concurrency-scoped. /Helper wrap-up/ below lifts it for exactly o
** Escalation
-Anything the contract blocks routes through the cross-agent message form (=machine.project.agent-id=), or just gets reported to Craig. The helper leaves its tree changes for the primary's next commit, or describes them in a targeted message.
+Anything the contract blocks gets reported to Craig, or — for a cross-project handoff — routed through =inbox-send= to the owning project's =inbox/=. The helper leaves its tree changes for the primary's next commit, or describes them in a note to Craig.
* Data-Integrity Rules
diff --git a/.ai/workflows/inbox-zero.org b/.ai/workflows/inbox-zero.org
deleted file mode 100644
index aa7c273..0000000
--- a/.ai/workflows/inbox-zero.org
+++ /dev/null
@@ -1,97 +0,0 @@
-#+TITLE: Inbox Zero Workflow
-#+AUTHOR: Craig Jennings & Claude
-#+DATE: 2026-06-13
-
-* Overview
-
-The roam global inbox (=~/org/roam/inbox.org=) is Craig's cross-project GTD capture: one shared file every project can see. This workflow routes each inbox 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. The aspiration is inbox zero: over time, every item lands in its owning project.
-
-This is NOT =process-inbox.org=. That workflow handles the project's own =inbox/= directory (handoffs from other projects, scripts, Craig). This one handles the single global =roam/inbox.org= and the cross-project routing a shared file creates.
-
-This is also 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.
-
-** Scope: single-destination (v1)
-
-This version routes each item to its one owning project, identified by an explicit =<project>:= heading prefix. The multi-project domain-aware mode, which would guess the owner of every unprefixed item and empty the whole inbox in one run, is deferred (see "Deferred: domain-aware routing" at the end). v1 claims only what's prefixed for the current project, surfaces the rest, and never guesses.
-
-** Three callers
-
-Reused from three callers so the steps live in one place:
-- *Startup* (read-only nudge) — count the items, identify which appear related to this project, surface both numbers, offer processing as one of the startup options. Never auto-files.
-- *Wrap-up* (Step 3 sub-step) — sweep items that belong here before the cleanup scripts, so imported tasks lint and ride the wrap commit.
-- *On demand* — "inbox zero", "empty the inbox", "process the roam inbox", "triage my roam inbox".
-
-Each project touches the roam inbox at least twice a session this way: once at startup, once at wrap-up.
-
-* The ownership rule (the coordination primitive)
-
-The inbox is shared, so the workflow must never let two projects fight over an item or let one project grab another's. Ownership is by explicit prefix:
-
-- =<project>: ...= heading → owned by that project. The current project claims only items prefixed with its own identifier.
-- Prefixed for *another* project → leave untouched (cross-project boundary, =protocols.org=).
-- *No prefix* → unowned. Never auto-claim. Surface as candidates a human can claim or prefix.
-
-The prefix partition is what makes concurrent triage across projects safe: each project only ever removes its own items, so two sessions editing the inbox touch disjoint lines.
-
-** Resolving this project's identifier (v1)
-
-Use the project root basename plus its common aliases (=.emacs.d= ↔ =emacs=, and the obvious ones: =rulesets=, =work=, =home=). A project may override the inferred set with an =:INBOX_PREFIX:= line in =notes.org='s *Workflow State* section when the basename is fragile (a dot in the name, an alias the inference misses). The explicit override is optional in v1; the durable multi-project resolution is part of the deferred domain-aware mode.
-
-* Phase A — Identify, count, and match
-
-1. Resolve the current project's identifier and aliases (above).
-2. Read =~/org/roam/inbox.org=. If absent, silent no-op (the file lives only on machines with the roam clone).
-3. Bucket every item under the inbox heading:
- - *claimed* — prefixed for this project
- - *foreign* — prefixed for another project → leave
- - *unowned* — no project prefix
-4. *Summarize the scan* (Craig's requirement, every scan): report the total item count in the inbox, then the count that appears related to this project. "Appears related" is the union of claimed items (exact prefix) and any unowned item whose topic plainly concerns this project's domain (a content judgment, surfaced as a candidate, never auto-claimed). Foreign-prefixed items are not "related" — they belong to their owner.
-5. If both claimed and related-unowned are empty, report the total and stop (the common case for most wraps).
-
-* Phase B — File each claimed item into todo.org
-
-Apply =process-inbox.org='s discipline against the project's =todo.org=; don't reinvent it:
-
-1. *Status check first.* Already done, or already a task in =todo.org=? → drop it, or fold into the existing task (dated sub-entry per =todo-format.md=). Don't duplicate.
-2. *Rewrite* to terse-heading + body per =todo-format.md=.
-3. *Priority + tags from THIS project's scheme* — the legend at the top of its =todo.org=, tags from that scheme's allowed set only. The project expresses someday-maybe with =[#D]=; there's no special someday-maybe routing.
-4. *File* under the project's Open Work section.
-
-* Phase C — Reconcile the shared inbox
-
-The roam inbox lives in a git repo (=~/org/roam=, auto-synced by the =roam-sync= timer). Edit it carefully:
-
-1. *Pull first* (=git -C ~/org/roam pull --ff-only=). If it can't fast-forward (dirty tree, divergence), surface and stop. Don't auto-stash, auto-merge, or force. Resolve before removing items.
-2. *Remove only the claimed items.* Never touch foreign or unowned items.
-3. *Commit the roam repo as its own commit* (separate from any project wrap commit): =chore(inbox): route <project> tasks to <project>/todo.org=. Push, or leave for the =roam-sync= timer. Surface a blocked push; don't force.
-
-* Phase D — Surface
-
-Report: moved (with their new priorities and tags), folded, dropped-as-done. Then the residue: foreign items (left for their owners, count only) and unowned items (count plus the headings that appear related to this project, for manual claim or prefix). Same "summarize what we kept" shape.
-
-* Skip conditions
-
-- No =~/org/roam/inbox.org= → silent no-op.
-- No claimed and no related-unowned items → report the total, stop.
-- Roam pull blocked → surface, stop before editing.
-
-* Caller integration
-
-** Startup (read-only nudge)
-
-Phase A of =startup.org= reads =~/org/roam/inbox.org= and produces the scan summary; Phase C surfaces one line: "Roam inbox: N items total, M appear related to this project — say 'inbox zero' to file them." Offered as one of the priority options. Startup never auto-files; it counts and offers.
-
-** Wrap-up (Step 3 sub-step)
-
-A sub-step at the start of wrap-up Step 3 (before the cleanup scripts, so imported tasks get linted and ride the wrap commit) delegates here for the claimed set. Skip-fast when nothing matches.
-
-* Deferred: domain-aware routing (future work, multi-project)
-
-v1 handles the single-destination case via the prefix rule. The multi-project parts are deferred until the need is real:
-
-1. *Domain-aware empty-it-all mode.* If rulesets held a description of each project's domain, one run could guess the owner of every item (prefixed or not) and empty the whole inbox at once, delivering each item to its owning project's =inbox/= via =inbox-send= (where that project's =process-inbox= gate still decides whether to file it). This turns "inbox zero" from a per-project aspiration into a single command. Open: where the domain map lives (central registry vs each project's =notes.org=), how confident a guess must be before auto-routing vs surfacing, and whether a low-confidence item stays put.
-2. *Explicit per-project =:INBOX_PREFIX:= as the durable resolver*, replacing basename inference.
-3. *Unowned-item lifecycle* once domain-aware routing exists (no item stays unrouted indefinitely).
-4. *Concurrent push contention* on the shared roam repo: the pull-before-edit + ff-only + surface-on-conflict floor may want a retry-once-after-pull.
-
-Take these up when the single-destination version is in use and the multi-project pain is concrete.
diff --git a/.ai/workflows/inbox.org b/.ai/workflows/inbox.org
new file mode 100644
index 0000000..6faa20f
--- /dev/null
+++ b/.ai/workflows/inbox.org
@@ -0,0 +1,524 @@
+#+TITLE: Inbox Workflow (Engine)
+#+AUTHOR: Craig Jennings
+#+DATE: 2026-06-23
+
+* Overview
+
+One engine for the project's inbox surfaces. Inbox items are *ideas to evaluate*, not orders to execute — each is a proposal that earns a place in =todo.org= or git history only when it passes the value gate. The engine holds the shared disposition machinery once: the three-question value gate, the skeptical review, the disposition ladder, the reply-to-sender discipline, the capture-guard before a roam write, and the priority-scheme check. Each *mode* is a thin section that names which surface it reads, how it enters and exits, and which core steps it runs.
+
+Two surfaces feed a project, and there is a recurring check over the second:
+
+1. *Project-local =inbox/= dir* — handoffs from other projects (via =inbox-send=), from scripts, and from Craig (typed directives saved as files). Handled by *process mode*; watched on a cadence by *monitor mode*.
+2. *Global roam inbox* (=~/org/roam/inbox.org=) — Craig's cross-project GTD capture, one shared file every project can see. Handled by *roam mode*, which claims only the items this project owns. *Auto inbox zero* runs roam mode on a recurring interactive loop.
+
+A *third* surface — external accounts (email / calendar / PRs) — is a different domain and stays in its own engine: =triage-intake.org= and its source plugins are *not* part of this engine. "Deal with my inbox dirs" is here; "what's new across my accounts" is there.
+
+*Two altitudes.* For the user, the trigger phrase picks the mode and the phrases are unchanged (see When to Use). For the implementer, this is one file: the core sections are written once, and each mode references them by name ("run the value gate (core §1) on each item") rather than restating them.
+
+* When to Use This Workflow
+
+The trigger phrase selects the mode. Every phrase below still works; it now routes to a mode of this engine.
+
+** Process mode — the local =inbox/= dir
+
+- "process inbox" / "process the inbox"
+- "handle the inbox"
+- "what's in inbox" / "what's in the inbox"
+- "let's clear the inbox" / "let's process the inbox items"
+
+Auto-invocation: startup Phase C delegates here when the local inbox is non-empty — don't ask, just run it.
+
+** Monitor mode — process mode on a cadence
+
+- "monitor the inbox" / "watch the inbox" — *the defined meaning:* one process pass now, then loop every 15 minutes (see the Monitor mode Cadence section). The phrase *is* the loop, not an opt-in extra.
+- "respond to the handoffs" / "handle the handoffs" — a single pass now, no loop.
+
+Ambient (always on, even with no loop running): the =inbox-status= task-boundary check (Monitor mode).
+
+** Roam mode — the global roam inbox
+
+- "inbox zero" / "empty the inbox" / "process the roam inbox" / "triage my roam inbox"
+
+Called read-only from startup (count + offer) and as a wrap-up Step 3 sub-step.
+
+** Auto inbox zero — recurring interactive roam check
+
+- "auto inbox zero"
+
+Match this before "inbox zero" — the auto phrase contains the roam phrase as a substring, so the longer match wins. Starts a recurring =/loop=-driven roam-mode pass; see the Auto inbox zero mode.
+
+** Boundary
+
+Do *not* invoke this engine for an inbox item that is clearly out-of-scope for the project — that is a cross-project routing problem, handled per the cross-project boundary rule in =protocols.org=. And do not invoke it for external-account triage ("what's new in email/cal/PRs") — that is =triage-intake.org=.
+
+* Core §1 — The value gate
+
+Every inbox item (local or roam) passes through three questions. One *yes* is enough to accept.
+
+1. *Does it advance an existing TODO?* Look up by topic in =todo.org='s open work. If the item extends a filed task, fold it in. If it implements a filed task, do the work.
+2. *Does it improve how the project works?* Architecture cleanup, workflow refinement, tooling, rule hygiene, drift detection — anything that makes the project itself more effective.
+3. *Does it serve the project's stated mission?* Read =notes.org= *Project-Specific Context* if the mission isn't obvious from the working directory and current task. The item should advance that mission, not orbit it.
+
+Three *no*s means reject. The rejection isn't lazy — an idea that doesn't help any current task, doesn't improve the system, and doesn't serve the mission is genuine noise, and accepting it inflates =todo.org= without payoff.
+
+* Core §2 — The skeptical review
+
+The value gate decides whether an item is worth taking. This review decides whether what it proposes is *right*, *complete*, and *as simple as it should be*. Run it on every task and file that arrives — not only shared-asset change proposals. Pure FYIs and replies that ask for nothing skip it.
+
+Approach the file with curiosity and skepticism. Work through, in writing — the core pass on every item:
+
+1. Is the request actually right — does it do what it claims, and is the claim correct for this project?
+2. Is it complete, or does it leave a gap — an unhandled case, a missing step, an untested path?
+3. Should it be simpler?
+4. Can it be enhanced to be more effective than as proposed?
+5. Does it conflict with any existing instruction — workflows, skills, rules, protocols, CLAUDE.md?
+
+When the item proposes a change to *shared assets* — template workflows, rules, skills, scripts, anything synced to consuming projects — or to a substantive convention, add the cross-project battery. It arrived from one project's context; you're evaluating it for all of them:
+
+6. Does this make sense for *all* consuming projects, or just the sender's situation?
+7. How does it change a common activity Craig performs — better, worse, or differently than the sender assumed?
+8. Plus at least three more questions specific to this change — what breaks for artifacts already using the old shape, what tooling interacts with it, what's underspecified, what the sender's worked example doesn't exercise.
+
+Output: a short summary of the thinking and a recommendation (do it / do it with named changes / file / reject). For shared-asset and convention changes the recommendation is surfaced to Craig for approval before applying; for ordinary tasks and files it feeds the act-vs-file and no-approvals-execute decision (Monitor mode).
+
+** In a no-approvals session: shared-asset changes defer and stage
+
+Shared-asset and convention changes still don't self-apply when Craig has put the session in no-approvals mode — they need his decision, so they fail the *solo* test in Monitor mode's executing-in-no-approvals criteria. Ordinary tasks and files that pass the review and are quick + solo execute under that criteria instead; this defer-and-stage path is for the shared-asset and convention changes that don't qualify. Run the review, prepare the edits in =working/<task-slug>/= (a patch file or the worked-out diff), file a =[#B]= VERIFY carrying the decision package, and reply to the sender that it's parked. The sender's local stopgap (per =cross-project.md='s propagation process) means the delay costs nothing — the canonical update is about durability, not speed.
+
+Wording-only fixes — no consuming project acts differently — may proceed even then, logged in the session log.
+
+The VERIFY shape (top-level, =[#B]= so startup's A/B surfacing catches it; no =SCHEDULED= unless the proposal names a real deadline):
+
+#+begin_example
+** VERIFY [#B] Parked: <proposal topic> (from <sender>)
+What arrived: <one line — what the handoff proposes>.
+Recommendation: <accept as-is / accept with changes / reject> — <2-3 line
+skeptical-review summary: what's right, what to change, what was checked>.
+Prepared diff: [[file:working/<slug>/proposed.diff]] — apply is mechanical on
+your go.
+Say "approve the parked <topic>" (or adjust / reject) and it gets applied.
+#+end_example
+
+The full question-battery answers live in the session log and the =working/= dir, not the task body — the body carries the conclusion, with the trail one link away.
+
+* Core §3 — The disposition ladder
+
+Every item that clears the value gate gets one disposition. The first six are the per-item outcomes; *park* is the no-approvals shared-asset path from core §2.
+
+** Implement now
+Small, scoped, clear, no design call required. The work is the disposition. Do the work, commit per the project's commit flow, delete the inbox file. The commit message references the inbox item by filename so the provenance lands in =git log=.
+
+** Fold into existing TODO
+The item extends a task already filed. Update the parent TODO's body with a dated reconciliation sub-entry per =todo-format.md= (=*** YYYY-MM-DD Day @ HH:MM:SS -ZZZZ <what landed>=). Move substantive content to =docs/design/<date>-<topic>.<ext>= if it's worth keeping; reference from the TODO body. Delete the inbox file.
+
+** 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
+Rename in place to =inbox/PROCESSED-<original-filename>= and add a brief comment line at the top: =# Deferred YYYY-MM-DD: <condition>=. Don't accumulate deferred items indefinitely — sweep them on a future process pass when the condition is met or the deferral has aged out.
+
+** Reject — by source
+- *From Craig* — push back honestly in chat. State why you won't implement; offer the conditions under which you would, if any. The inbox file stays until Craig confirms — override re-enters as accept, acknowledgment deletes the file. Don't theatre the pushback: if you don't genuinely think Craig is wrong, just do the work.
+- *From another project (handoff)* — write a response file at =/tmp/inbox-response-<topic>.org=: a heading naming the original handoff and date, one paragraph on the rejection rationale (*which* value-gate question failed and why), one paragraph on the condition under which you'd reconsider (or "never, this misreads the project's mission" if that's the truth). Deliver via =inbox-send <sender> --file /tmp/inbox-response-<topic>.org=. Delete the local inbox file after the response lands. Silent rejection on a handoff trains the sender to escalate around the channel — always close the loop.
+- *From a script or automated system* — just delete. No notification.
+
+** Park (skeptical review in a no-approvals session)
+Move the proposal file into =working/<task-slug>/= alongside the prepared diff, file the =[#B]= VERIFY per core §2, reply to the sender that it's parked for Craig's review, and delete the inbox file. On Craig's approval the apply is mechanical: apply the prepared edits, run the normal verify-and-publish flow, close the parked =**= VERIFY per =todo-format.md= (a top-level VERIFY resolves to =DONE= + =CLOSED:=, not a dated header), and send the acceptance reply. On rejection, the reject-from-another-project flow above runs unchanged.
+
+* Core §4 — Reply-to-sender discipline
+
+A handoff came from another project's agent (or the user). Close the loop:
+
+- *Accepted and acted on* — send a confirmation to the sender via =inbox-send <sender> --text "..."=, naming what landed and the commit, so they're not left guessing (they can't see this project's git log). =inbox-send= excludes the current project as a target, so a self-sourced item is handled in-session, not sent.
+- *Accepted and filed* — a short confirmation that it's filed and where, so the sender knows it wasn't dropped.
+- *Rejected* — always state the why (which value-gate question failed), per the reject-by-source ladder (core §3).
+
+Cross-project boundary: never act on a file under another project's =.ai/= scope from here — route it back as a handoff (see =cross-project.md=).
+
+* Core §5 — Capture-guard before a roam write
+
+Before *any* read-modify-write of =~/org/roam/inbox.org=, run the capture-guard. This runs first because the Phase D edit rewrites the file on disk, and editing underneath a live capture wedges it just as a stray hand edit would.
+
+*Wait-and-retry, not bounce.* Use the poll mode so a *transient* capture clears itself instead of immediately kicking the work back to the caller:
+
+#+begin_src bash
+.ai/scripts/capture-guard --wait "$HOME/org/roam/inbox.org"
+#+end_src
+
+An org capture is usually only a few seconds of mid-finalize state, so =--wait= (default 30s, re-checking every ~10s) returns the instant it clears and reports blocked only if it's *still* open at the deadline. The common case — nothing capturing — returns instantly without sleeping. This is the fix for the guard bouncing a caller over a capture that would have cleared on its own a moment later. (The bare single-shot form — no =--wait= — stays available for a caller that genuinely must not block.)
+
+- *Exit 0* → no live capture, or it cleared during the wait (or no reachable Emacs). Proceed with the edit.
+- *Exit 1* → an indirect org-capture buffer is *still* cloned from the roam inbox after the wait (the script prints the offending buffer name). Editing underneath it would leave the capture pointing at stale state and unable to finalize with =C-c C-c= (see =emacs.md=). Only now does the per-caller fallback fire:
+ - *On-demand / interactive run* → stop and surface: "You have a live org-capture session open against the roam inbox (=<buffer>=) — finalize it (=C-c C-c=) or abort it (=C-c C-k=) and I'll continue." Re-run the guard and resume once it returns clean.
+ - *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).
+
+- *Scheme present* — file new TODOs per the scheme. Every TODO gets a priority cookie matching the legend's rules, the mandatory type tag, and any applicable effort/autonomy tags.
+- *Scheme absent* — surface one sentence: "This project has no priority scheme. We should adopt one before filing the new TODOs from this inbox pass — want me to propose one based on the rulesets scheme?" If Craig says yes, do that first (the =/research-priority-scheme= research subagent pattern in rulesets is the reference). If Craig says no, file the TODOs without grading but flag in the commit message that they're un-prioritized pending a scheme.
+
+The point is to avoid adding ungraded =TODO= entries to a project that's never agreed on what =[#A]= means.
+
+* Mode: process
+
+Reads the project-local =inbox/= dir. Entry: a trigger phrase, or startup Phase C on a non-empty inbox. Exit: inbox empty (excluding =.gitkeep= and intentional =PROCESSED-*=), session log updated, =:LAST_INBOX_PROCESS:= stamped.
+
+** Phase A — Inventory (one parallel batch)
+
+Issue these reads in one parallel batch:
+
+1. List =inbox/= excluding =.gitkeep= and =PROCESSED-*= prefixes (use =\ls -la inbox/= per the protocols.org exa-alias note).
+2. Read =notes.org= *Project-Specific Context* if mission isn't already loaded in the session.
+3. Read =todo.org='s top-of-file priority scheme if present.
+
+For each inbox file, parse the filename for sender. Two common patterns:
+
+- =YYYY-MM-DD-HHMM-from-<sender>-<topic>.<ext>= — from another project via =inbox-send=.
+- =<topic>.org= — typically from Craig directly, or from a script.
+
+Note the file type. =.eml= files need the extract script (not raw =Read=):
+
+#+begin_src bash
+# View mode
+python3 .ai/scripts/eml-view-and-extract-attachments.py inbox/<file>.eml
+
+# Pipeline mode (extract attachments to a directory)
+python3 .ai/scripts/eml-view-and-extract-attachments.py inbox/<file>.eml --output-dir assets/<target>/
+#+end_src
+
+Everything else, read directly.
+
+** Phase B — Evaluate each item
+
+For each inbox file:
+
+1. *Read it.* Full read for substantive proposals (org files with TODO entries, design notes, multi-section docs); skim short FYIs and one-liner asks.
+2. *Identify the shape.* Instruction, question, proposal, FYI, or handoff — shapes guide disposition.
+3. *Apply the value gate* (core §1). One yes → candidate accept. Three nos → candidate reject.
+4. *Run the skeptical review* (core §2) on the item before classifying — the core pass on every accepted task and file, plus the cross-project battery when it proposes a shared-asset or convention change. Its summary + recommendation rides along to Phase C; in a no-approvals session it gates whether the item self-applies (quick + solo + agreed, per Monitor mode) or, for shared-asset and convention changes, defers and stages.
+5. *Within accept, classify* by the disposition ladder (core §3): implement now / fold into existing TODO / file as TODO.
+6. *Within reject, classify by source* (core §3): from Craig / from another project / from a script.
+
+** Phase B.1 — Priority-scheme check
+
+Run core §6. This gates Phase C filing when there are accept-and-file items.
+
+** Phase C — Surface dispositions
+
+Numbered options inline per =interaction.md= (no popup). Recommendation at item 1.
+
+Batch trivial items (one-line rejections of script noise, obvious file-as-TODO accepts where the scheme is already settled) into a single confirm-all prompt. Walk substantive items one at a time so the decision is visible.
+
+Per-item template:
+
+#+begin_example
+<filename> from <sender>: <one-line summary>
+Value-gate read: <yes/no on each of the three questions, one phrase each>
+Disposition recommendation: <implement / fold into <TODO> / file [#X] :tags: / reject>
+
+1. <recommendation as item 1>
+2. <alternative>
+3. Defer — leave in inbox under PROCESSED-<topic>.<ext> until <condition>
+4. Something else
+#+end_example
+
+For items that went through the skeptical review, the surfaced disposition includes its summary + recommendation, and approval here is what authorizes the apply. In a no-approvals session those items are reported as parked (the =[#B]= VERIFY) rather than surfaced for live approval.
+
+For pure FYIs that need no action, surface as a single line and recommend delete-with-acknowledgment.
+
+** Phase D — Apply
+
+Apply each disposition per the ladder (core §3). The flow is autonomous past Craig's Phase C approval.
+
+** Phase E — Close out
+
+Verify =inbox/= is empty (excluding =.gitkeep= and any intentional =PROCESSED-*= files). Run =\ls -la inbox/= and confirm.
+
+Update the session log per =protocols.org= with one short paragraph: count processed, count accepted (implement/fold/file split), count rejected (Craig/handoff/script split), and the commit SHA if a commit landed.
+
+Stamp =:LAST_INBOX_PROCESS:= in =notes.org='s *Workflow State* section if it exists, so future workflows that gate on freshness can read it. Same format as =:LAST_AUDIT:= (=YYYY-MM-DD=).
+
+* Mode: monitor
+
+Process mode on a cadence. This is the *when, how-often, and act-vs-file* layer; the per-item disposition mechanics are the core sections, run via process mode — not restated here. Monitor decides *that* an item gets handled and *how I respond*; the core decides *what disposition* each item gets.
+
+The gap it closes: handoffs that arrive mid-session used to sit unseen until the user asked or the next startup ran. A handoff the sender can't see being handled trains them to escalate around the inbox channel.
+
+** Preconditions — before starting
+
+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 --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.
+
+** Cadence — how often to check
+
+*"Monitor the inbox" = run now, then loop every 15 minutes.* Do one process pass over any pending handoffs immediately, then start the loop:
+
+#+begin_src
+/loop 15m check the inbox with inbox-status and run inbox.org process mode over any pending handoffs
+#+end_src
+
+Each firing runs the cheap =inbox-status= check first and only does a full process pass when items are pending. The loop is the monitoring; it runs until Craig stops it or the session ends. Honor the Preconditions gate before the first pass and the Close-out gate when the loop stops.
+
+*Ambient task-boundary check (always on, even without a loop).* After finishing a unit of work, before reporting back or asking "what's next," run the cheap status check:
+
+#+begin_src bash
+.ai/scripts/inbox-status -q
+#+end_src
+
+Exit 1 means handoffs are pending — list them (drop =-q=) and run process mode. Exit 0 means clean; say nothing. This is one =find=; it costs nothing to run often, and it's the fix for handoffs piling up unseen during long sessions.
+
+*Startup and wrap-up already cover their ends.* Startup Phase C processes a non-empty inbox; the wrap-up sanity check refuses to wrap with unprocessed handoffs. The task-boundary cadence fills the middle.
+
+*Mid-task arrivals.* If a handoff lands while you're mid-task and it's urgent (blocks the current work, or is time-sensitive), surface it right away. Otherwise batch it to the next task boundary so the current work isn't thrashed.
+
+** The act-vs-file decision
+
+Every accepted handoff (one that clears the value gate) is then either acted on now or filed as a task.
+
+*Act immediately — and just do it, no asking — when all of these hold:*
+- *Clear* — the action is unambiguous; no design decision or option-choice is needed.
+- *Bounded* — small, finishable this session, ideally a tight file set.
+- *Low-risk and verifiable now* — not a risky change to load-bearing infra (or trivially revertible), and testable/lintable this session.
+- *In-scope and safe* — within this project, not destructive or outward-facing without confirmation, not across a project boundary.
+- *Cheaper than deferring* — doing it now costs less than filing plus re-triaging later.
+
+When you decide to act, queue the work and do it. Don't ask first.
+
+*Exception:* a proposal to change a shared asset (template workflow, rule, skill, synced script) or a substantive convention never qualifies for silent act-now, however clear and bounded it looks — it routes through the skeptical review (core §2), which carries its own approval (or, in a no-approvals session, park) step.
+
+*File a task when any of these hold:*
+- It needs a judgment call, a design decision, or an option the user would pick.
+- It's large, multi-session, or sprawls across many files.
+- It's blocked (a dependency, an external thing, the user is away).
+- It's risky enough to want the user's eyes before it lands.
+- It's off the session's active goal and acting now would derail it (file and keep going, unless it's urgent).
+
+When you decide to file, *ask first* — inline numbered options per =interaction.md=, with *filing as option 1 (the recommendation)* and *"do it now" as option 2*:
+
+#+begin_example
+<handoff> wants <X>. My read: file it (needs <reason>).
+
+1. File as a TODO ([#?] :tags:) — Recommended
+2. Do it now instead
+3. Something else
+
+Pick a number.
+#+end_example
+
+*Always ask if you're unsure* which side of the line an item falls on. Decisiveness on clear act-now items is the point of the rule; the ask is for genuine ambiguity and for filing.
+
+** Executing in no-approvals mode
+
+When Craig has put the session in no-approvals mode, an accepted item may be implemented automatically — but only when all three of these hold:
+
+1. *Agreed* — you've run the value gate and the full skeptical review and concluded the change should be done, not merely that it's harmless.
+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 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
+
+Close the loop per the reply-to-sender discipline (core §4): confirm what landed (accepted-and-acted), confirm where it's filed (accepted-and-filed), or state the why (rejected).
+
+** The inbox-status script
+
+=.ai/scripts/inbox-status= lists unprocessed handoffs and exits nonzero when any are pending. Exclusions match the wrap-up sanity check (=.gitkeep=, =lint-followups.org=, =PROCESSED-*=). Exit 0 = clean, 1 = pending, 2 = no inbox/ or bad usage. Use =-q= for the count-only form the cadence check calls.
+
+** Close out — before finishing
+
+End the way it started: clean worktree, green suite. Before stopping the loop or reporting the pass done:
+
+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).
+
+* Mode: roam
+
+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.
+
+** Scope: single-destination (v1)
+
+Routes each item to its one owning project, identified by an explicit =<project>:= heading prefix. The multi-project domain-aware mode (guess the owner of every unprefixed item and empty the whole inbox in one run) is deferred — see "Deferred: domain-aware routing" at the end. v1 claims only what's prefixed for the current project, surfaces the rest, and never guesses.
+
+** Callers
+
+The steps live here so three callers reuse them:
+- *Startup* (read-only nudge) — count the items, identify which appear related to this project, surface both numbers, offer processing as one of the startup options. Never auto-files.
+- *Wrap-up* (Step 3 sub-step) — sweep items that belong here before the cleanup scripts, so imported tasks lint and ride the wrap commit.
+- *On demand* — the roam-mode trigger phrases.
+
+Each project touches the roam inbox at least twice a session this way: once at startup, once at wrap-up.
+
+** The ownership rule (the coordination primitive)
+
+The inbox is shared, so the mode must never let two projects fight over an item or let one grab another's. Ownership is by explicit prefix:
+
+- =<project>: ...= heading → owned by that project. The current project claims only items prefixed with its own identifier.
+- Prefixed for *another* project → leave untouched (cross-project boundary, =protocols.org=).
+- *No prefix* → unowned. Never auto-claim. Surface as candidates a human can claim or prefix.
+
+The prefix partition is what makes concurrent triage across projects safe: each project only ever removes its own items, so two sessions editing the inbox touch disjoint lines.
+
+*Resolving this project's identifier (v1).* Use the project root basename plus its common aliases (=.emacs.d= ↔ =emacs=, and the obvious ones: =rulesets=, =work=, =home=). A project may override the inferred set with an =:INBOX_PREFIX:= line in =notes.org='s *Workflow State* section when the basename is fragile. The explicit override is optional in v1; the durable multi-project resolution is part of the deferred domain-aware mode.
+
+** Phase A — Process the project-local inbox
+
+1. Check the project-local =inbox/= with =.ai/scripts/inbox-status -q=.
+2. If pending handoffs exist, run *process mode* before touching the roam inbox. Project handoffs are already addressed to this project, so they are higher-confidence and cheaper to clear than shared roam captures.
+3. If =inbox-status= reports no =inbox/= directory, note it and continue to the roam inbox. Some projects only participate in the shared roam capture flow.
+4. If process mode cannot finish because it needs Craig's decision, stop after surfacing that decision. Do not remove roam items in the same run; the project still does not have a clean inbox.
+
+** Phase B — Identify, count, and match roam items
+
+1. Resolve the current project's identifier and aliases (above).
+2. Read =~/org/roam/inbox.org=. If absent, silent no-op (the file lives only on machines with the roam clone).
+3. Bucket every item under the inbox heading:
+ - *claimed* — prefixed for this project
+ - *foreign* — prefixed for another project → leave
+ - *unowned* — no project prefix
+ - *empty* — a heading with no title and no body: just stars, optionally a =TODO=/keyword, and whitespace (e.g. =** =, =** TODO =, =*** TODO =). These are aborted or accidental captures, owned by nobody, and safe to delete regardless of project. A heading with any title text or any body content is never empty.
+4. *Summarize the scan* (Craig's requirement, every scan): report the total item count in the inbox, then the count that appears related to this project. "Appears related" is the union of claimed items (exact prefix) and any unowned item whose topic plainly concerns this project's domain (a content judgment, surfaced as a candidate, never auto-claimed). Foreign-prefixed items are not "related" — they belong to their owner. Note the empty count separately.
+5. If claimed, related-unowned, *and* empty are all absent, report the total and stop (the common case for most wraps). Empty entries on their own are enough to enter Phase D — the cleanup runs even when this project owns nothing else, since empties belong to nobody and removing them is what "check the inbox" should always do.
+
+** Phase C — File each claimed roam item into todo.org
+
+Apply the core disposition discipline against the project's =todo.org=; don't reinvent it:
+
+1. *Status check first.* Already done, or already a task in =todo.org=? → drop it, or fold into the existing task (dated sub-entry per =todo-format.md=). Don't duplicate.
+2. *Rewrite* to terse-heading + body per =todo-format.md=.
+3. *Priority + tags from THIS project's scheme* (core §6) — the legend at the top of its =todo.org=, tags from that scheme's allowed set only. The project expresses someday-maybe with =[#D]=; there's no special someday-maybe routing.
+4. *File* under the project's Open Work section.
+
+** Phase D — Reconcile the shared roam inbox
+
+The roam inbox lives in a git repo (=~/org/roam=, auto-synced every 15 minutes by the =roam-sync= timer). Craig captures into it constantly, so its working tree is dirty most of the time — which is exactly why this mode never runs =git pull= itself. A pull on a dirty tree fails, and that would block triage on nearly every run. Instead, edit the file and hand the git work to =roam-sync=, which already commits-first-then-rebases and so handles the dirty tree correctly.
+
+1. *Guard against a live org-capture session* — run the capture-guard in poll mode (=capture-guard --wait=, core §5) before the edit, so a transient capture clears itself rather than bouncing the run. On a still-blocked exit 1 the caller-specific fallback (interactive stop-and-surface / auto-loop defer-to-next-cycle / wrap-up skip-and-self-heal) is in core §5.
+2. *Remove the claimed items and the empty entries* from the working-tree file. Never touch foreign or unowned (titled) items. Empty entries (Phase B's =empty= bucket) are removed on every triage regardless of who would own a titled version, since an aborted capture belongs to nobody. The claimed-item removal and the empty sweep happen in the same edit.
+3. *Hand the commit + push to =roam-sync=.* Don't =git pull=, =git commit=, or =git push= here. Trigger the sync so the edit lands promptly rather than waiting up to 15 minutes for the next timer tick:
+
+ #+begin_src bash
+ systemctl --user start roam-sync.service
+ #+end_src
+
+ =roam-sync= commits the edit (under its generic auto-sync message), rebases onto the remote, and pushes. The removal is safe to land without a pull-first because only this project ever touches =<project>:=-prefixed lines (the ownership partition), so =roam-sync='s rebase can't conflict on the edit. Provenance for the routed tasks lives in the project's =todo.org= and session log, not the roam commit message. If =systemctl= isn't available, leave the edit for the next timer tick — it still lands.
+
+ Don't pull or stash the roam tree to "clean" it first: that fights =roam-sync= for ownership of the repo's git state. The edit-then-sync handoff is the whole point.
+
+** Phase E — Surface
+
+Report: local project inbox disposition first (processed count and whether it is clear), then roam disposition: moved (with their new priorities and tags), folded, dropped-as-done, and empty entries swept (count). Then the residue: foreign items (left for their owners, count only) and unowned items (count plus the headings that appear related to this project, for manual claim or prefix). Same "summarize what we kept" shape.
+
+If triaging this batch surfaced a durable, cross-project fact (a reference pointer worth keeping, a pattern worth recording), consider writing it to the agent KB as one =:agent:= node (see =knowledge-base.md=; personal projects only). Skip silently when nothing durable came up — never pad an empty run with a KB line.
+
+** Skip conditions
+
+- No project-local =inbox/= and no =~/org/roam/inbox.org= → silent no-op.
+- Project-local =inbox/= exists but has no pending handoffs → continue to roam scan.
+- No =~/org/roam/inbox.org= after the local inbox check → report the local inbox disposition and stop.
+- No claimed, no related-unowned, and no empty roam entries → report the total, stop.
+- Live org-capture against the roam inbox (capture-guard exit 1) → surface (interactive) or skip-and-self-heal (wrap-up), per core §5.
+
+** Caller integration
+
+*Startup (read-only nudge).* Startup already checks the project-local =inbox/= via =inbox-status= and processes it through process mode when needed. It also reads =~/org/roam/inbox.org= and produces the roam scan summary; one line surfaces: "Roam inbox: N items total, M appear related to this project (K empty entries to sweep) — say 'inbox zero' to file them." Offered as one of the priority options. The empty count rides along so a clean-up-only run still gets offered. Startup never auto-files or auto-sweeps roam items; it counts and offers (the read-only nudge never edits, so empties are reported, not removed, until a real triage runs).
+
+*Wrap-up (Step 3 sub-step).* A sub-step at the start of wrap-up Step 3 (before the cleanup scripts, so imported tasks get linted and ride the wrap commit) delegates here for the claimed set. Skip-fast when nothing matches.
+
+** Deferred: domain-aware routing (future work, multi-project)
+
+v1 handles the single-destination case via the prefix rule. The multi-project parts are deferred until the need is real:
+
+1. *Domain-aware empty-it-all mode.* If rulesets held a description of each project's domain, one run could guess the owner of every item (prefixed or not) and empty the whole inbox at once, delivering each item to its owning project's =inbox/= via =inbox-send= (where that project's process-mode gate still decides whether to file it). Open: where the domain map lives, how confident a guess must be before auto-routing vs surfacing, and whether a low-confidence item stays put.
+2. *Explicit per-project =:INBOX_PREFIX:= as the durable resolver*, replacing basename inference.
+3. *Unowned-item lifecycle* once domain-aware routing exists (no item stays unrouted indefinitely).
+4. *Concurrent push contention* on the shared roam repo: triage now hands its commit + push to =roam-sync=, which already aborts-and-surfaces on a rebase conflict. If multi-machine contention ever makes that abort frequent, =roam-sync= may want a retry-once-after-rebase.
+
+Take these up when the single-destination version is in use and the multi-project pain is concrete.
+
+* 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 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 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* → 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 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
+
+A fully-unattended cron pass (firing while Craig is away) is a *different contract* and is deferred. It can't wait for a yes, so it has to decide up front whether it may mutate =todo.org= and the roam inbox or stays read-only, how a find reaches Craig asynchronously, how dedup state survives across runs that don't share a session, and what session/auth context a cron run carries.
+
+The =/schedule= recipe, once that contract is designed, would look like:
+
+#+begin_src
+/schedule <cron-expression> run inbox.org roam mode read-only, and <surface-mechanism> any finds
+#+end_src
+
+v1 ships only the interactive =/loop= shape above; the unattended contract is logged to =todo.org= for its own design pass. Don't invent the unattended behavior here — route a request for it to that task.
+
+* Common Mistakes
+
+1. *Treating items as orders.* Inbox content is a proposal. The value gate is the rule. Implementing every item without evaluation inflates =todo.org= and trains senders to keep sending noise.
+2. *Filing without applying the value gate.* "File as TODO" is not a default — it's the disposition for proposals that pass the gate but wait. A reject is also a valid answer.
+3. *Filing raw TODOs when the project has a priority scheme.* Core §6 is mandatory when the scheme exists. An un-graded TODO in a project with a legend is a defect.
+4. *Silently deleting a project handoff.* Send a response naming which value-gate question failed. Silent rejection trains the sender to escalate to Craig instead of through the inbox channel.
+5. *Pushing back on a Craig directive only to immediately implement it anyway.* If you genuinely think Craig is wrong, say so and wait. If you don't, just do the work — don't theatre the pushback.
+6. *Skipping the implement-vs-fold-vs-file classification.* Defaulting every accept to "file as TODO" turns the inbox into a queue that flows into =todo.org= without filtering.
+7. *Not propagating value-gate failure to the response.* When you reject a handoff, name *which* gate question failed so the sender can recalibrate, not just resend.
+8. *Forgetting to delete the inbox file after acting.* The local inbox should be empty when process mode ends. Files left behind become noise on the next startup.
+9. *Applying a shared-asset change proposal without the skeptical review.* The value gate alone asks whether to take the change, never whether the change is right, complete, or as simple as it should be. (Worked example: the 2026-06-12 spec-decisions handoff was applied as-is and the after-the-fact review surfaced a lost state, a vacuous gate pass, and an enhancement — all catchable up front.)
+10. *Editing the roam inbox without the capture-guard.* A disk write under a live org-capture wedges the capture (core §5). Guard first, every roam write.
+11. *Auto inbox zero re-surfacing queued items.* The loop must dedup against the displayed queue, not just against what's been implemented — or every cycle re-lists the same un-run finds.
+
+* Living Document
+
+Refine the value gate's three questions if the project's mission sharpens. Tune the per-source rejection-response template if =inbox-send= response loops surface a pattern. Tune the monitor cadence if task-boundary checking proves too frequent or too sparse. Capture the auto-loop interval that worked once the pattern recurs.
+
+If a mode wants real depth — enough that it bloats the core — it can become an =inbox.<mode>.org= plugin under this engine's namespace (the pattern =triage-intake= uses) rather than swelling this file. The principle that inbox items are *ideas to evaluate* is the part that doesn't change.
diff --git a/.ai/workflows/journal-entry.org b/.ai/workflows/journal-entry.org
index 3f476a7..c70dfe8 100644
--- a/.ai/workflows/journal-entry.org
+++ b/.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/.ai/workflows/meeting-prep.org b/.ai/workflows/meeting-prep.org
index 162ae30..563328b 100644
--- a/.ai/workflows/meeting-prep.org
+++ b/.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/.ai/workflows/meeting-prep.pre-wire.org b/.ai/workflows/meeting-prep.pre-wire.org
index 6a156c0..3e27c2a 100644
--- a/.ai/workflows/meeting-prep.pre-wire.org
+++ b/.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/.ai/workflows/monitor-inbox.org b/.ai/workflows/monitor-inbox.org
deleted file mode 100644
index 4912a2b..0000000
--- a/.ai/workflows/monitor-inbox.org
+++ /dev/null
@@ -1,122 +0,0 @@
-#+TITLE: Monitor Inbox Workflow
-#+AUTHOR: Craig Jennings & Claude
-#+DATE: 2026-05-31
-
-* Overview
-
-Keep the project's =inbox/= responsive: notice handoffs on a cadence, triage each one, decide whether to act now or file it, and reply to the sender. This workflow is the /when, how-often, and act-vs-file/ layer. The per-item disposition mechanics — the value gate, the implement/fold/file classification, the per-source rejection flow — live in [[file:process-inbox.org][process-inbox.org]] and are not duplicated here. Think of it as: monitor-inbox decides /that/ an item gets handled and /how I respond/; process-inbox decides /what disposition/ each item gets.
-
-The gap this closes: handoffs that arrive mid-session used to sit unseen until the user asked or the next startup ran. A handoff the sender can't see being handled trains them to escalate around the inbox channel.
-
-* Preconditions — before starting
-
-Never begin inbox monitoring on a dirty worktree or with 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 of the workflow, check both:
-
-1. =git status --porcelain= is empty (clean worktree).
-2. A full test run is all green (=make test= here, or the project's full-suite command).
-
-If the worktree is *dirty*: offer to commit the pending changes in discrete, logical batches before starting. If the suite is *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.
-
-* When to Use This Workflow
-
-Trigger phrases:
-
-- "monitor the inbox" / "watch the inbox" — *the defined meaning:* run one process-inbox pass now, then loop process-inbox every 15 minutes (see Cadence). Not an opt-in extra — the phrase *is* the loop.
-- "respond to the handoffs" / "handle the handoffs" — a single pass now, no loop.
-
-Cadence auto-trigger (ambient, always on): check at every task boundary during a session, even when no loop is running — see Cadence below.
-
-* Cadence — how often to check
-
-*"Monitor the inbox" = run now, then loop every 15 minutes.* When Craig says monitor or watch the inbox, do one process-inbox pass over any pending handoffs immediately, then start the loop:
-
-#+begin_src
-/loop 15m check the inbox with inbox-status and run process-inbox.org over any pending handoffs
-#+end_src
-
-Each firing runs the cheap =inbox-status= check first and only does a full process-inbox pass when items are pending. The loop is the monitoring; it runs until Craig stops it or the session ends. Honor the Preconditions gate before the first pass and the Close-out gate when the loop stops.
-
-*Ambient task-boundary check (always on, even without a loop).* After finishing a unit of work, before reporting back or asking "what's next," run the cheap status check:
-
-#+begin_src bash
-.ai/scripts/inbox-status -q
-#+end_src
-
-Exit 1 means handoffs are pending — list them (drop =-q=) and process per process-inbox.org. Exit 0 means clean; say nothing. This is one =find=; it costs nothing to run often, and it's the fix for handoffs piling up unseen during long sessions.
-
-*Startup and wrap-up already cover their ends.* Startup Phase C processes a non-empty inbox; the wrap-up sanity check refuses to wrap with unprocessed handoffs. The task-boundary cadence fills the middle.
-
-*Mid-task arrivals.* If a handoff lands while you're mid-task and it's urgent (blocks the current work, or is time-sensitive), surface it right away. Otherwise batch it to the next task boundary so the current work isn't thrashed.
-
-* The act-vs-file decision
-
-Every accepted handoff (one that clears process-inbox's value gate) is then either acted on now or filed as a task. The rule, and how to surface it:
-
-*Act immediately — and just do it, no asking — when all of these hold:*
-- *Clear* — the action is unambiguous; no design decision or option-choice is needed.
-- *Bounded* — small, finishable this session, ideally a tight file set.
-- *Low-risk and verifiable now* — not a risky change to load-bearing infra (or trivially revertible), and testable/lintable this session.
-- *In-scope and safe* — within this project, not destructive or outward-facing without confirmation, not across a project boundary.
-- *Cheaper than deferring* — doing it now costs less than filing plus re-triaging later.
-
-When you decide to act, queue the work and do it. Don't ask first.
-
-*Exception:* a proposal to change a shared asset (template workflow, rule, skill, synced script) or a substantive convention never qualifies for silent act-now, however clear and bounded it looks — it routes through process-inbox's *Skeptical Review*, which carries its own approval (or, in a no-approvals session, park) step.
-
-*File a task when any of these hold:*
-- It needs a judgment call, a design decision, or an option the user would pick.
-- It's large, multi-session, or sprawls across many files.
-- It's blocked (a dependency, an external thing, the user is away).
-- It's risky enough to want the user's eyes before it lands.
-- It's off the session's active goal and acting now would derail it (file and keep going, unless it's urgent).
-
-When you decide to file, *ask first* — inline numbered options per =interaction.md=, with *filing as option 1 (the recommendation)* and *"do it now" as option 2*:
-
-#+begin_example
-<handoff> wants <X>. My read: file it (needs <reason>).
-
-1. File as a TODO ([#?] :tags:) — Recommended
-2. Do it now instead
-3. Something else
-
-Pick a number.
-#+end_example
-
-*Always ask if you're unsure* which side of the line an item falls on. Decisiveness on clear act-now items is the point of the rule; the ask is for genuine ambiguity and for filing.
-
-** Executing in no-approvals mode
-
-When Craig has put the session in no-approvals mode, an accepted item may be implemented automatically — but only when all three of these hold:
-
-1. *Agreed* — you've run the value gate and the full Skeptical Review and concluded the change should be done, not merely that it's harmless.
-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 process-inbox's defer-and-stage park; an oversized item fails *quick* and gets filed.
-
-* Replying to handoffs
-
-A handoff came from another project's agent (or the user). Close the loop:
-
-- *Accepted and acted on* — send a confirmation to the sender via =inbox-send <sender> --text "..."=, naming what landed and the commit, so they're not left guessing (they can't see this project's git log). =inbox-send= excludes the current project as a target, so a self-sourced item is handled in-session, not sent.
-- *Accepted and filed* — a short confirmation that it's filed and where, so the sender knows it wasn't dropped.
-- *Rejected* — always state the why (which value-gate question failed), per process-inbox's per-source rejection flow.
-
-Cross-project boundary: never act on a file under another project's =.ai/= scope from here — route it back as a handoff (see =cross-project.md=).
-
-* The inbox-status script
-
-=.ai/scripts/inbox-status= lists unprocessed handoffs and exits nonzero when any are pending. Exclusions match the wrap-up sanity check (=.gitkeep=, =lint-followups.org=, =PROCESSED-*=). Exit 0 = clean, 1 = pending, 2 = no inbox/ or bad usage. Use =-q= for the count-only form the cadence check calls.
-
-* Close out — before finishing
-
-End the workflow 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.
-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), so handing it a dirty tree or a red suite breaks the next run before it begins.
-
-* Living Document
-
-Tune the cadence if task-boundary checking proves too frequent or too sparse in practice. Refine the act-vs-file criteria as edge cases recur. If the background-monitor loop becomes a common pattern, capture the interval that worked. The decision rule itself — act-now is silent, filing asks with file-as-option-1, ambiguity asks — is the stable core (set by Craig, 2026-05-30).
diff --git a/.ai/workflows/no-approvals.org b/.ai/workflows/no-approvals.org
index 1efce82..6b5c7fa 100644
--- a/.ai/workflows/no-approvals.org
+++ b/.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.
@@ -44,10 +46,10 @@ The interaction gates that step the workflow back to Craig for an "OK to proceed
The engineering-discipline gates protect quality, not Craig's interaction time. They remain in force:
-- =/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.
+- =/review-code= against the staged diff before every commit, dispatched as an isolated adversarial reviewer per the =publish= skill's Step 1 — no-approvals removes *interaction* gates, never the isolation. Critical and Important findings still block, and the re-review loop still runs to approval. Minor findings still surface. No "proceed anyway" override unless Craig has given it explicitly for this batch. If the review can't reach approval — three rounds, a recurring finding, or a =Needs Discussion= verdict — that is a genuine question: park the item per step 4 and move to the next one rather than committing past a standing finding.
- =/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.
@@ -68,7 +70,7 @@ For each item:
- Do the work.
- Update the Session Log per the rules in =protocols.org=.
-- Before any commit: run =/review-code= against the staged diff. Surface Critical and Important findings inline; fix them and re-review until clean. Minor findings show but don't block.
+- Before any commit: dispatch the isolated adversarial reviewer per the =publish= skill's Step 1 — never review your own staged diff inline. Surface Critical and Important findings; fix them and send the updated diff back to the *same* reviewer until it approves. Minor findings show but don't block and never earn another round. If the review can't reach approval — three rounds, a finding that recurs after being reported fixed, or a =Needs Discussion= verdict — park the item per step 4 with the standing findings and move on; don't commit past a blocking finding.
- Draft the commit message. Run =/voice personal= (the skill, or walk the patterns inline if unavailable). Print the final message inline before committing so the log shows it.
- Commit and push.
- One-line status between items ("Task X done, on to Y.") so Craig knows what's happening when he checks back in.
diff --git a/.ai/workflows/open-tasks.org b/.ai/workflows/open-tasks.org
index fe782d6..205d95c 100644
--- a/.ai/workflows/open-tasks.org
+++ b/.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.
@@ -176,6 +177,10 @@ Next Mode answers two questions in one output: "what matters most right now?" (t
Apply the prioritization cascade in order. Stop at the first matching step. This is the importance/urgency answer.
+*Exclude blocked tasks.* A task tagged =:blocked:= has an unmet cross-project dependency (its body names the project and the work owed, per =todo-format.md=). It can't be worked until that other project delivers, so it is *never* the cascade recommendation — skip it at every cascade step below. Blocked tasks are surfaced on their own in Step 3 so the stalled dependency stays visible instead of silently dropping out of view.
+
+*Surface blocking tasks first.* The mirror of the above: a task tagged =:blocker:= is holding up work in *another* project (its body names which project and what's owed, per =todo-format.md=). Clearing it unblocks that project, so it carries borrowed urgency — surface it at the *top* of the cascade recommendation regardless of its own priority cookie, ahead of the normal In-Progress / deadline / priority order. When several =:blocker:= tasks exist, lead with the one blocking the most, or the longest. This is the "do the thing that unblocks someone else first" rule; a =:blocker:= task left at its own low priority is exactly how a cross-project dependency stalls.
+
**** 1. In-Progress Tasks
- Look for tasks marked =DOING= or partially complete.
- *If found:* Recommend that task (always finish what's started).
@@ -228,11 +233,22 @@ Within each row, pick a single task per the same-level tie-breakers above (block
The friction filter is the override path. When the cascade winner is partially blocked, hardware-dependent, or simply too large for the user's current state, one of the friction rows is what they pick instead.
+*** Step 3 — Blocked-on-other-projects surface
+
+Independently of the cascade and the friction filter, collect every open task tagged =:blocked:=. These are tasks this project can't advance until another project delivers; surfacing them keeps a cross-project dependency from rotting at low priority on the other side — the exact failure the tag exists to prevent (a blocked task whose blocker is a =[#D]= in another project sits forever otherwise).
+
+For each blocked task, read its body for the blocking project and what's owed, and present one line: the task, the blocking project, and what that project owes. Then offer — per blocked task — to nudge the blocker: an =inbox-send <project> --text= note naming what's needed and why it's blocking, so the dependency gets attention in the project that owns it. Don't send without the user's go.
+
+If no =:blocked:= tasks exist, omit this surface entirely (the common case).
+
*** Output Format
-Pair the cascade recommendation with the friction block beneath it. Recommendation-at-item-1 convention applies to the friction rows — quick+solo first, since it's the strongest low-friction pick.
+Pair the cascade recommendation with the friction block beneath it, and the blocked-on-other-projects surface (Step 3) beneath that when any blocked task exists. Recommendation-at-item-1 convention applies to the friction rows — quick+solo first, since it's the strongest low-friction pick.
#+begin_example
+Unblocks other projects (do these first):
+- ai-term wrap-teardown companion — :blocker:, unblocks rulesets (the three ai-term functions)
+
Cascade recommendation (importance/urgency):
- Fix org-noter reliability — [#A], Method 1, 8/18 complete, blocks daily reading/annotation
@@ -240,17 +256,25 @@ If you want lower friction instead:
1. Quick + solo: Bump linter config — [#C] :quick:solo:, ~15 min
2. Quick: Confirm new dirvish setup — [#B] :quick:, needs your eye
3. Solo: Refactor config-utilities — [#B] :solo:, bounded but multi-hour
+
+Blocked on other projects (can't advance until the blocker delivers):
+- Wrap-teardown feature — blocked by emacsd: ai-term companion functions — nudge?
#+end_example
+The =:blocker:= surface sits at the very top — clearing one of those is the highest-leverage thing on the list, since it frees work in another project. Omit it when no =:blocker:= task exists (the common case).
+
Include for each row:
- Task name / description.
- Priority + tag cluster.
- One-line reasoning. For the cascade row, name which cascade step matched. For friction rows, an effort hint when one is obvious.
- Progress indicator (for V2MOM-structured todos) on the cascade row only.
+- For a =:blocker:= row: the project it unblocks and what's owed (from the task body).
+- For a blocked row: the blocking project and what it owes (from the task body), plus the nudge offer.
**** Edge cases
- *Empty friction block.* If no =:quick:= or =:solo:= tagged tasks exist in the open set, omit the friction block entirely. Present only the cascade recommendation.
+- *No =:blocker:= tasks.* Omit the "Unblocks other projects" surface entirely (the common case) — show it only when a task carries the =:blocker:= tag.
- *Dedupe.* If the cascade recommendation IS the same task as one of the friction rows (e.g. it's =:quick:solo:= and also won the cascade), show it once at the top with both labels. Don't list it twice.
- *Decline behavior.* If the user declines the cascade recommendation, drop straight to the friction block as the natural next prompt. Do not fall through to lower-cascade-tier tasks; the friction filter IS the override.
diff --git a/.ai/workflows/page-me.org b/.ai/workflows/page-me.org
index 607ed51..7a3b792 100644
--- a/.ai/workflows/page-me.org
+++ b/.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/.ai/workflows/process-inbox.org b/.ai/workflows/process-inbox.org
deleted file mode 100644
index af406ee..0000000
--- a/.ai/workflows/process-inbox.org
+++ /dev/null
@@ -1,220 +0,0 @@
-#+TITLE: Process Inbox Workflow
-#+AUTHOR: Craig Jennings & Claude
-#+DATE: 2026-05-28
-
-* Overview
-
-Inbox items are *ideas to evaluate*, not orders to execute. They arrive from Craig (typed directives saved as files), from other projects (handoffs via =inbox-send=), and from scripts/automated systems. Each is a proposal. An item earns a place in =todo.org= or git history only when it passes the value gate: it advances an existing task, improves how the project works, or serves the project's stated mission.
-
-The workflow is the disposition discipline. Read each item, evaluate honestly, apply the decision, then notify the sender if it was a project handoff and you're rejecting. Silent rejection on a handoff is worse than no reply.
-
-* When to Use This Workflow
-
-User triggers:
-
-- "process inbox" / "process the inbox"
-- "handle the inbox"
-- "what's in inbox" / "what's in the inbox"
-- "let's clear the inbox" / "let's process the inbox items"
-
-Auto-invocation:
-
-- Startup =Phase C step 2= delegates here when the inbox is non-empty. Don't ask Craig — just run it.
-
-Do *not* invoke this for inbox items that are clearly out-of-scope for the project — those are cross-project routing problems, handled per the cross-project boundary rule in =protocols.org=.
-
-* The Value Gate
-
-Every inbox item passes through three questions. One *yes* is enough to accept.
-
-1. *Does it advance an existing TODO?* Look up by topic in =todo.org='s open work. If the item extends a filed task, fold it in. If it implements a filed task, do the work.
-2. *Does it improve how the project works?* Architecture cleanup, workflow refinement, tooling, rule hygiene, drift detection — anything that makes the project itself more effective.
-3. *Does it serve the project's stated mission?* Read =notes.org= *Project-Specific Context* if the mission isn't obvious from the working directory and current task. The item should advance that mission, not orbit it.
-
-Three *no*s means reject. The rejection isn't lazy — an idea that doesn't help any current task, doesn't improve the system, and doesn't serve the mission is genuine noise, and accepting it inflates =todo.org= without payoff.
-
-* The Skeptical Review (every arriving task and file)
-
-The value gate decides whether an item is worth taking. This review decides whether what it proposes is *right*, *complete*, and *as simple as it should be*. Run it on every task and file that arrives in the inbox — not only shared-asset change proposals. Pure FYIs and replies that ask for nothing skip it.
-
-Approach the file with curiosity and skepticism. Work through, in writing — the core pass on every item:
-
-1. Is the request actually right — does it do what it claims, and is the claim correct for this project?
-2. Is it complete, or does it leave a gap — an unhandled case, a missing step, an untested path?
-3. Should it be simpler?
-4. Can it be enhanced to be more effective than as proposed?
-5. Does it conflict with any existing instruction — workflows, skills, rules, protocols, CLAUDE.md?
-
-When the item proposes a change to shared assets — template workflows, rules, skills, scripts, anything synced to consuming projects — or to a substantive convention, add the cross-project battery. It arrived from one project's context; you're evaluating it for all of them:
-
-6. Does this make sense for *all* consuming projects, or just the sender's situation?
-7. How does it change a common activity Craig performs — better, worse, or differently than the sender assumed?
-8. Plus at least three more questions specific to this change — what breaks for artifacts already using the old shape, what tooling interacts with it, what's underspecified, what the sender's worked example doesn't exercise.
-
-Output: a short summary of the thinking and a recommendation (do it / do it with named changes / file / reject). For shared-asset and convention changes the recommendation is surfaced to Craig for approval before applying; for ordinary tasks and files it feeds the act-vs-file and no-approvals-execute decision (=monitor-inbox.org=).
-
-** In a no-approvals session: shared-asset changes defer and stage
-
-Shared-asset and convention changes still don't self-apply when Craig has put the session in no-approvals mode — they need his decision, so they fail the *solo* test in monitor-inbox's executing-in-no-approvals criteria. Ordinary tasks and files that pass the review and are quick + solo execute under that criteria instead; this defer-and-stage path is for the shared-asset and convention changes that don't qualify. Run the review, prepare the edits in =working/<task-slug>/= (a patch file or the worked-out diff), file a =[#B]= VERIFY carrying the decision package, and reply to the sender that it's parked. The sender's local stopgap (per =cross-project.md='s propagation process) means the delay costs nothing — the canonical update is about durability, not speed.
-
-Wording-only fixes — no consuming project acts differently — may proceed even then, logged in the session log.
-
-The VERIFY shape (top-level, =[#B]= so startup's A/B surfacing catches it; no =SCHEDULED= unless the proposal names a real deadline):
-
-#+begin_example
-** VERIFY [#B] Parked: <proposal topic> (from <sender>)
-What arrived: <one line — what the handoff proposes>.
-Recommendation: <accept as-is / accept with changes / reject> — <2-3 line
-skeptical-review summary: what's right, what to change, what was checked>.
-Prepared diff: [[file:working/<slug>/proposed.diff]] — apply is mechanical on
-your go.
-Say "approve the parked <topic>" (or adjust / reject) and it gets applied.
-#+end_example
-
-The full question-battery answers live in the session log and the =working/= dir, not the task body — the body carries the conclusion, with the trail one link away.
-
-* Phase A — Inventory (one parallel batch)
-
-Issue these reads in one parallel batch:
-
-1. List =inbox/= excluding =.gitkeep= and =PROCESSED-*= prefixes (use =\ls -la inbox/= per the protocols.org exa-alias note).
-2. Read =notes.org= *Project-Specific Context* if mission isn't already loaded in the session.
-3. Read =todo.org='s top-of-file priority scheme if present (look for a =* Priority and Tag Scheme= section or similar between the intro and the first =* <Project> Open Work= header).
-
-For each inbox file, parse the filename for sender. Two common patterns:
-
-- =YYYY-MM-DD-HHMM-from-<sender>-<topic>.<ext>= — from another project via =inbox-send=.
-- =<topic>.org= — typically from Craig directly, or from a script.
-
-Note the file type. =.eml= files need the extract script (not raw =Read=):
-
-#+begin_src bash
-# View mode
-python3 .ai/scripts/eml-view-and-extract-attachments.py inbox/<file>.eml
-
-# Pipeline mode (extract attachments to a directory)
-python3 .ai/scripts/eml-view-and-extract-attachments.py inbox/<file>.eml --output-dir assets/<target>/
-#+end_src
-
-Everything else, read directly.
-
-* Phase B — Evaluate each item
-
-For each inbox file:
-
-1. *Read it.* For substantive proposals (org files with TODO entries, design notes, multi-section docs), the full read is the right move. For short FYIs and one-liner asks, skim.
-2. *Identify the shape.* Is it an instruction, a question, a proposal, an FYI, or a handoff? Shapes guide disposition.
-3. *Apply the value gate.* Three questions above. One yes → candidate accept. Three nos → candidate reject.
-4. *Run the Skeptical Review* (section above) on the item before classifying — the core pass on every accepted task and file, plus the cross-project battery when it proposes a shared-asset or convention change. Its summary + recommendation rides along to Phase C; in a no-approvals session it gates whether the item self-applies (quick + solo + agreed, per =monitor-inbox.org=) or, for shared-asset and convention changes, defers and stages.
-5. *Within accept, classify:*
- - *Implement now* — small, scoped, clear, no design call required. The work is the disposition.
- - *Fold into existing TODO* — the item extends a task already filed; update the TODO body and link the inbox content if substantive.
- - *File as TODO* — substantive but waits, or needs design/triage before implementation.
-6. *Within reject, classify by source:*
- - *From Craig* — push back honestly in chat. State why you won't implement. Offer the conditions under which you would, if any. Wait for Craig to override or accept.
- - *From another project* — write a response file naming the rejection rationale and (optionally) the condition under which you'd reconsider. Deliver via =inbox-send <sender> --file <response>= per the cross-project handoff convention.
- - *From a script or automated system* — just delete; no notification needed.
-
-* Phase B.1 — Priority-scheme check
-
-This gates Phase C filing when 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).
-
-- *Scheme present* — file new TODOs per the scheme. Every TODO gets a priority cookie matching the legend's rules, the mandatory type tag, and any applicable effort/autonomy tags.
-- *Scheme absent* — surface one sentence: "This project has no priority scheme. We should adopt one before filing the new TODOs from this inbox pass — want me to propose one based on the rulesets scheme?" If Craig says yes, do that first (the =/research-priority-scheme= research subagent pattern in rulesets is the reference). If Craig says no, file the TODOs without grading but flag in the commit message that they're un-prioritized pending a scheme.
-
-The point is to avoid adding ungraded =TODO= entries to a project that's never agreed on what =[#A]= means.
-
-* Phase C — Surface dispositions
-
-Numbered options inline per =interaction.md= (no popup). Recommendation at item 1.
-
-Batch trivial items (one-line rejections of script noise, obvious file-as-TODO accepts where the scheme is already settled) into a single confirm-all prompt. Walk substantive items one at a time so the decision is visible.
-
-Per-item template:
-
-#+begin_example
-<filename> from <sender>: <one-line summary>
-Value-gate read: <yes/no on each of the three questions, one phrase each>
-Disposition recommendation: <implement / fold into <TODO> / file [#X] :tags: / reject>
-
-1. <recommendation as item 1>
-2. <alternative>
-3. Defer — leave in inbox under PROCESSED-<topic>.<ext> until <condition>
-4. Something else
-#+end_example
-
-For items that went through the Skeptical Review, the surfaced disposition includes its summary + recommendation, and approval here is what authorizes the apply. In a no-approvals session those items are reported as parked (the =[#B]= VERIFY) rather than surfaced for live approval.
-
-For pure FYIs that need no action, surface as a single line and recommend delete-with-acknowledgment.
-
-* Phase D — Apply
-
-Apply each disposition. The flow is autonomous past Craig's Phase C approval.
-
-** Implement-now
-
-Do the work. Commit per the project's commit flow. Delete the inbox file. The commit message references the inbox item by filename so the provenance lands in =git log=.
-
-** Fold into existing TODO
-
-Update the parent TODO's body with a dated reconciliation sub-entry per =todo-format.md= (=*** YYYY-MM-DD Day @ HH:MM:SS -ZZZZ <what landed>=). Move substantive content to =docs/design/<date>-<topic>.<ext>= if it's worth keeping; reference from the TODO body. Delete the inbox file.
-
-** File as TODO
-
-Add the TODO under =* <Project> Open Work= with priority + tags per Phase B.1. 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).
-
-** Reject from Craig
-
-State the rejection in chat clearly: what you won't implement, why, and the conditions (if any) under which you would. Wait for Craig's override or acknowledgment. The inbox file stays until Craig confirms — if he overrides, re-enter Phase D as accept; if he acknowledges the rejection, delete the file.
-
-** Reject from another project (handoff)
-
-Write the response file at =/tmp/inbox-response-<topic>.org=. Contents:
-
-- Heading naming the original handoff and date
-- One paragraph: the rejection rationale (which value-gate question failed and why)
-- One paragraph: the condition under which you'd reconsider, if such a condition exists. If the answer is "never, this misreads the project's mission," say so directly.
-
-Deliver via =inbox-send <sender> --file /tmp/inbox-response-<topic>.org=. The =inbox-send= script (per =cross-project.md=) handles the from-prefix, date stamp, and target inbox path.
-
-Delete the local inbox file after the response lands in the sender's inbox.
-
-** Reject from script or automated system
-
-Just delete. No notification.
-
-** Defer
-
-Rename in place to =inbox/PROCESSED-<original-filename>= and add a brief comment line at the top: =# Deferred YYYY-MM-DD: <condition>=. Don't accumulate deferred items indefinitely — sweep them on a future =process-inbox= run when the condition is met or the deferral has aged out.
-
-** Park (Skeptical Review in a no-approvals session)
-
-Move the proposal file into =working/<task-slug>/= alongside the prepared diff, file the =[#B]= VERIFY per the Skeptical Review section, reply to the sender that it's parked for Craig's review, and delete the inbox file. On Craig's approval the apply is mechanical: apply the prepared edits, run the normal verify-and-publish flow, rewrite the VERIFY to a dated log entry per =todo-format.md=, and send the sender the acceptance reply. On rejection, the reject-from-another-project flow above runs unchanged.
-
-* Phase E — Close out
-
-Verify =inbox/= is empty (excluding =.gitkeep= and any intentional =PROCESSED-*= files). Run =\ls -la inbox/= and confirm.
-
-Update the session log per =protocols.org= with one short paragraph summarizing this pass: count processed, count accepted (implement/fold/file split), count rejected (Craig/handoff/script split), and the commit SHA if a commit landed.
-
-Stamp =:LAST_INBOX_PROCESS:= in =notes.org='s *Workflow State* section if it exists, so future workflows that gate on freshness can read it. Same format as =:LAST_AUDIT:= (=YYYY-MM-DD=).
-
-* Common Mistakes
-
-1. *Treating items as orders.* Inbox content is a proposal. The value gate is the rule. Implementing every item without evaluation inflates =todo.org= and trains senders to keep sending noise.
-2. *Filing without applying the value gate.* "File as TODO" is not a default — it's the disposition for proposals that pass the gate but wait. A reject is also a valid file-as-TODO answer to nothing.
-3. *Filing raw TODOs when the project has a priority scheme.* Phase B.1 is mandatory when the scheme exists. An un-graded TODO in a project with a legend is a defect.
-4. *Silently deleting a project handoff.* Send a response. The sender's next session sees the response in their inbox and learns the rejection rationale. Silent rejection trains the sender to escalate to Craig instead of through the inbox channel.
-5. *Pushing back on a Craig directive only to immediately implement it anyway.* If you genuinely think Craig is wrong, say so and wait for his call. If you don't, just do the work — don't theatre the pushback.
-6. *Skipping the implement-vs-fold-vs-file classification.* Defaulting every accept to "file as TODO" turns the inbox into a queue that flows into =todo.org= without filtering. Small, scoped, clear items get implemented now; substantive proposals get filed; extensions to existing work get folded.
-7. *Not propagating value-gate failure to the response.* When you reject a handoff, the response should name *which* gate question failed (advances no current task / doesn't improve the project / doesn't serve the mission) so the sender can recalibrate, not just resend.
-8. *Forgetting to delete the inbox file after acting.* The inbox should be empty when this workflow ends. Files left behind become noise on the next startup.
-9. *Applying a shared-asset change proposal without the Skeptical Review.* The value gate alone asks whether to take the change, never whether the change is right, complete, or as simple as it should be. A proposal that's clear and bounded can still carry a design gap — the review is where that surfaces, before the change syncs to every consuming project. (Worked example: the 2026-06-12 spec-decisions handoff was applied as-is and the after-the-fact review surfaced a lost state, a vacuous gate pass, and an enhancement — all catchable up front.)
-
-* Living Document
-
-Refine the value gate's three questions if the project's mission sharpens. Tune the per-source rejection-response template if =inbox-send= response loops surface a pattern. Add new auto-classification shortcuts if certain item shapes (e.g. routine FYIs from a script) become common.
-
-The workflow is shaped by use. The principle that inbox items are *ideas to evaluate* is the part that doesn't change.
diff --git a/.ai/workflows/process-meeting-transcript.org b/.ai/workflows/process-meeting-transcript.org
index 4dd340f..d0806ad 100644
--- a/.ai/workflows/process-meeting-transcript.org
+++ b/.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/.ai/workflows/read-calendar-events.org b/.ai/workflows/read-calendar-events.org
index be66bf4..5eac529 100644
--- a/.ai/workflows/read-calendar-events.org
+++ b/.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/.ai/workflows/readability-audit.org b/.ai/workflows/readability-audit.org
new file mode 100644
index 0000000..90ad366
--- /dev/null
+++ b/.ai/workflows/readability-audit.org
@@ -0,0 +1,242 @@
+#+TITLE: Readability Audit Workflow
+#+AUTHOR: Craig Jennings
+#+DATE: 2026-06-28
+
+* Overview
+
+A pass over one file, a set of modules, or the whole tree that makes the code
+*readable to a future maintainer*. It checks four things and fixes the cheap
+ones in place: the file-top commentary, the inline comments, the names, and the
+physical organization of the code. Structural changes that need a real refactor
+(splitting a module, renaming a public symbol) are not done here — they are
+filed as =:refactor:= tasks so they get their own design and test pass.
+
+This is language-agnostic. Where a step names a language-specific tool or
+convention, it's stated as "the project's <X>, if it has one" — read the
+project's =CLAUDE.md= / =notes.org= and the language bundle to resolve the
+concrete tool.
+
+* Where it sits among the code-quality tools
+
+These tools are a pipeline, not duplicates. Knowing which to reach for:
+
+- *readability-audit* (this workflow) — prose and human-reader clarity:
+ comments, file headers, names, and physical organization. Judgment-driven
+ (does this comment lie? does this name reveal intent? can a newcomer place
+ this file in a minute?).
+- =/refactor= — structure on measurable metrics: complexity, duplication,
+ dead-code, the =simplification= lens (behavior-preserving logic/size
+ reduction), and =rename= (executes a codebase-wide symbol rename).
+- =/simplify= — behavior-preserving cleanup of the current diff, applied
+ directly.
+
+The link that keeps them from overlapping: when this audit finds a structural
+problem too big for a comment/name fix — a module to split, a *public* symbol to
+rename across call sites — it *files* a =:refactor:= task rather than doing it
+here. =/refactor= (rename, simplification) or =/start-work= then executes that
+filed task with a proper design and test plan. Readability finds and files;
+=/refactor= transforms.
+
+* Problem We're Solving
+
+Source files drift toward two opposite failure modes, and both hurt the next
+person to open the file:
+
+- *Documentation rot and noise.* Headers carry stale user-manual content
+ (quick-starts, full option matrices, setup walkthroughs) that belongs in user
+ docs; comments restate what the next line already says; comments go out of
+ date and start lying; placeholder =TODO=/=FIXME= stubs and conversational
+ asides accumulate. A blank summary or a missing file-top description leaves a
+ reader with no map.
+- *Structural fog.* Names that don't reveal intent force the reader to decode
+ them; related functions scatter; a public entry point sits far from the
+ private helpers it calls; a file grows to hold several unrelated
+ responsibilities.
+
+Left alone, opening a file costs more every month. The fix is a repeatable audit
+with a clear, checkable standard, run on demand or as files are touched.
+
+* Exit Criteria
+
+For the audited scope:
+
+1. *Every file has an accurate top section* that states what the file does and
+ how it fits the rest of the codebase — terse, no user-manual content, and
+ carrying the project's file-header convention where it has one.
+2. *Every surviving comment earns its place* — it explains a *why* the code
+ can't (a constraint, a workaround and its reason, an ordering dependency, a
+ warning), it is accurate against the current code, and it is terse. Obvious
+ "describe the next line" comments are gone.
+3. *Names reveal intent* — no cryptic abbreviations; the project's
+ public/private visibility convention is applied consistently.
+4. *Related code is co-located* — a public function's private helpers sit right
+ after it; the file reads top-to-bottom by descending abstraction; sections
+ group what belongs together.
+5. *Structural problems too big to fix in a comment pass are filed* as
+ =:refactor:= tasks, not left as a vague note and not half-done inline.
+6. *Nothing broke* — the build is clean and the test suite is green
+ (comment/name edits are behavior-preserving, so this should always hold; it
+ is the proof, not a hope). See "Graceful degradation" for projects without a
+ suite.
+
+* When to Use This Workflow
+
+- "Let's run the readability-audit workflow."
+- "Audit the comments and commentary in <file/area>."
+- "Clean up the structure/organization of <module>."
+- After landing a feature, on the files it touched, before moving on.
+- On a single file you just found hard to read.
+- As a tree-wide sweep: inventory all the source files, audit each, batch the
+ fixes.
+
+Do NOT use this to *perform* the structural refactors themselves (use
+=/refactor= or =/start-work= against a filed task) or to hunt for bugs /
+complexity / duplication (that is =/refactor=, not a readability pass).
+
+* Approach: How We Work Together
+
+** Phase 1 — Scope and inventory
+
+Pick the target: one file, a named module set, or the whole tree. For a sweep,
+list the source files (honor =.aiignore=) and decide coverage. Lean on the
+language's own doc linters as a first filter where they exist — many flag a
+missing or blank file summary and malformed headers; run the project's lint
+target first.
+
+** Phase 2 — Audit each file against the four dimensions
+
+Record findings as =file:line — issue — proposed fix=. The four dimensions:
+
+*** A. File-top commentary (the map)
+
+- Present, and *accurate* against what the file now does.
+- States purpose, the file's role/architecture, and key entry points —
+ *tersely*. A reader should learn what this is and how it connects in a few
+ lines.
+- Carries the project's file-header convention where it has one (a metadata
+ block, a module docstring, a standard header comment). If the project has no
+ header convention, skip this sub-check — don't invent one.
+- Does *not* carry user-manual content — quick-starts, full option matrices,
+ step-by-step setup. That belongs in user docs; move it, don't keep it in the
+ source header.
+- Mechanics are correct for the language: a filled summary line (not blank), the
+ expected section markers, the expected footer.
+
+*** B. Inline comments (why, not what)
+
+- Explains a *why* the code cannot: a workaround *and its reason*, an ordering
+ or load dependency, business-logic rationale, a real warning ("do not reorder
+ these — deadlock").
+- Is *accurate* — matches the current code. A wrong comment is worse than none;
+ fix or delete on sight.
+- Is *terse and useful*. Delete the obvious "describe the next line" comment
+ unless it names a non-obvious constraint. Replace a stale placeholder or a
+ rambling aside with the real one-line reason, or remove it.
+- Convert a comment that's only restating the code into a better *name* instead
+ (see C).
+
+*** C. Names (carry the what/how so comments don't have to)
+
+- Intention-revealing variable and function names; no cryptic single letters or
+ abbreviations outside tight local scopes.
+- The project's public/private convention is applied consistently and correctly:
+ a helper only called within the file is private; a user-facing or
+ intentionally-reusable symbol is public. (Resolve the concrete convention from
+ the language and the project — a naming prefix, an export list, an
+ access modifier.)
+- When a comment exists only to explain a name, rename instead.
+
+*** D. Organization (co-location and ordering)
+
+- Related functions sit together. A public function's private helpers come
+ *right after* it (stepdown / proximity / "reads like a newspaper").
+- The file reads top-to-bottom by descending abstraction.
+- Sections group what belongs together.
+- *Cohesion check:* if the file holds several unrelated responsibilities, or has
+ grown large enough that the top no longer describes one coherent thing, flag a
+ split into layered owners — but see Phase 4: that's a filed refactor, not an
+ inline fix.
+
+** Phase 3 — Apply the cheap, safe fixes inline
+
+Dimensions A, B, and C are *comment- and name-only* and *solo* (no design or
+preference call): apply them directly. After each file (or a batch), verify with
+the project's gates: parse/syntax check, a clean build (no new warnings), and a
+green test suite. Comment/name edits can't change behavior, so green is the proof
+the edit was clean, not a behavior check.
+
+For a tree-wide sweep, drive the uniform rewrites mechanically and verify the
+whole batch at once: a *mechanical applier with a boundary assertion* that
+replaces a well-defined header span is reliable and fast, then one suite run
+covers the batch. Keep the varied cases (header-line fixes, summary fixes that
+must preserve surrounding metadata, inline-comment surgery, generated-file
+headers) as careful per-file edits. (The boundary markers are language-specific;
+the principle — mechanical applier + assert + one suite run for uniform
+rewrites, per-file judgment for varied cases — is not.)
+
+** Phase 4 — File the structural refactors, don't do them here
+
+Dimension D's bigger findings — split a module, rename a *public* symbol across
+call sites, move a function to a different file — are real refactors with their
+own risk and test surface. Do *not* slip them into a readability pass. File each
+as a =:refactor:= task in =todo.org= with the specific finding, so it gets
+=/refactor= or =/start-work= with a proper design and test plan. This is the
+line between the cheap clarity win and the structural change; keeping it sharp is
+what lets the audit stay safe and fast.
+
+** Phase 5 — Verify and commit in logical batches
+
+Full suite green, build clean. Commit the doc/comment changes as =docs:= (or
+=refactor:= where a header/structure normalized) in cohesive batches — one
+commit per coherent slice (a set of condensed commentaries, the
+generated-file-header fixes, the obvious-comment prune), not one mega-commit and
+not one-per-file. Generated files are fixed *in their generator* and then
+regenerated, so the next regen stays compliant.
+
+* Graceful degradation
+
+The audit adapts to what the project provides:
+
+- *No file-header convention* → skip dimension A's metadata sub-check; still
+ check the summary/description for accuracy and terseness.
+- *No test suite* → the green-suite proof in Phases 3 and 5 is unavailable. Fall
+ back to the strongest gate the project has (compile/byte-compile, parse check,
+ linters) and *flag the weaker proof as a known limit* — a behavior-preserving
+ edit is lower-risk, but say plainly that there's no suite to confirm it.
+- *No doc linter* → do the Phase 1 first-filter by reading instead; the audit
+ still runs, just without the cheap pre-pass.
+
+* Principles to Follow
+
+- *Comments explain why; code explains what.* If a comment restates the code,
+ delete it or turn it into a better name.
+- *Accuracy beats completeness.* A wrong or stale comment is worse than no
+ comment. When in doubt, delete.
+- *Terse and useful.* Every comment and every header line earns its place. The
+ source header is not the user manual — move manuals to user docs.
+- *Readable means the next person, fast.* The test of the top-section and the
+ organization is whether a maintainer who has never seen the file can place it
+ and navigate it in under a minute.
+- *Keep the cheap pass cheap.* Comment/name fixes are solo and land inline.
+ Structural splits and public renames are not — they get filed, designed, and
+ tested separately.
+- *Preserve legal and attribution headers verbatim.* Vendored / GPL / copyright
+ notices are never condensed away by a readability pass.
+- *Manual validation is still Craig's.* Solo means no input is needed to *do*
+ the work; visual/behavior confirmation afterward is expected where relevant.
+
+* Living Document
+
+Update this with what real runs teach. Lessons worth keeping as the standard
+sharpens:
+
+- *Interpretation default for "fix blank summary":* when a rewrite shows only a
+ header + summary and omits a metadata block the file already has, keep the
+ existing metadata and replace only the header line and the summary. Its
+ absence from the rewrite means "leave it," not "delete it."
+- *Generated files:* fix the *generator*, then regenerate. Editing the generated
+ file directly is reverted on the next regen.
+- *Vendored files:* preserve the copyright/attribution; do not auto-condense a
+ licensed header.
+- *Mechanical applier + assert + one suite run* is the safe way to do a
+ many-file uniform rewrite; per-file judgment is for the varied cases.
diff --git a/.ai/workflows/rename-artifact.org b/.ai/workflows/rename-artifact.org
index 7b9f15b..a8d1246 100644
--- a/.ai/workflows/rename-artifact.org
+++ b/.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/.ai/workflows/send-email.org b/.ai/workflows/send-email.org
index 065f925..82d2286 100644
--- a/.ai/workflows/send-email.org
+++ b/.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/.ai/workflows/sentry.org b/.ai/workflows/sentry.org
new file mode 100644
index 0000000..b25fc14
--- /dev/null
+++ b/.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 cycle 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-cycle 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 cycle 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-cycle body being one sentry cycle (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 cycle acquires it at cycle start and releases it at cycle end, and refreshes it between passes (the heartbeat, so a live cycle's lock never ages past one pass). If =/loop= fires again while a previous cycle still holds it, the new cycle's acquire fails and the cycle skips with one digest line — no two cycles run at once. The bounded wait is short (a few seconds); a live cycle 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 cycle records it. A reclaim during a genuinely slow pass is possible, so it's never silent.
+
+* The Pass Runner — one contract per pass
+
+Each cycle, 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 cycle (every pass probe-skipped or no-op) are not written one by one — the cycle collapses to a single heartbeat at cycle-end (below), so an idle cycle 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 (cycle start, before the passes)
+
+After acquiring the lock, confirm the cycle 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 cycle 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 cycle — skip it with a digest line, the next cycle 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 cycle retries. Unsafe states and their scope:
+
+- *Unexpected dirty tree* (non-spine) → skip the whole cycle.
+- *Lost or un-acquirable single-runner lock* → skip the cycle (another cycle 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 cycle-end* (see below) → the commits stay on the branch, flagged in the digest for morning review; the cycle doesn't roll back.
+
+Skips are never silent and never partial. Inside a *working* cycle, a pass line means the pass fully ran and a skip line names why it didn't. An *all-quiet* cycle 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 cycle has surfaced that there was none.
+
+** Multi-day stall notification
+
+An unmerged prior =sentry/*= branch at cycle start (the morning review never happened) skips the cycle. After the *second consecutive* cycle 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 cycle: 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 cycle 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 cycle-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 cycle. 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 cycle. Rotate the area across cycles 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-cycle work here, reaching past a frozen test corpus. Expect the pass to go honestly quiet after the first few cycles 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 cycles, 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 cycles 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, the isolated adversarial review per =publish= Step 1 with its re-review loop, =/voice=), and the review 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. A task whose review never reaches approval — three rounds, a recurring finding, or a =Needs Discussion= verdict — is the same shape: no commit, and a digest line naming the standing findings, so the morning review sees what the reviewer would not pass rather than finding the task silently absent. (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.)
+
+* Cycle-end — conditional suite, then the digest commit
+
+After the passes:
+
+1. *Conditional suite run.* If any pass this cycle 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 cycle'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 cycle into minutes all night. Cycles that only touched org/spine files skip this.
+
+2. *Heartbeat or digest, then commit.* Decide quiet vs working. A *quiet* cycle — 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* cycle — 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> cycle= for a working cycle, =chore(sentry): heartbeat — <date> <time>= for a quiet one, so even a quiet cycle 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 cycle 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* cycle appends its block to the =session-context.org= Session Log (the spine the cycle already writes), so it survives a crash, rides the session archive, and is on screen in the running session. One block per working cycle: the timestamp, then one line per pass (ran + what, or skipped + why), plus any lock reclaim notes. A *quiet* cycle (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-cycle artifact; it still carries one line per pass so a real skip inside a working cycle 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 cycles 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 cycles.
+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 cycle 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 cycle 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 cycle-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 cycle, 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 cycle 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 cycles. 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/.ai/workflows/session-harvest.org b/.ai/workflows/session-harvest.org
index c48d689..54a7c09 100644
--- a/.ai/workflows/session-harvest.org
+++ b/.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/.ai/workflows/spec-create.org b/.ai/workflows/spec-create.org
index f90c511..39758a0 100644
--- a/.ai/workflows/spec-create.org
+++ b/.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
@@ -10,7 +10,7 @@ The guiding principle, drawn from how Google, Oxide, Amazon, Basecamp, and the A
It is the front of a trio:
- =spec-create.org= (this one) — author writes the spec.
-- =spec-review.org= — a reviewer gates the spec for implementation-readiness and writes =<spec-basename>-review.org=.
+- =spec-review.org= — a reviewer gates the spec for implementation-readiness and records findings in the spec's =* Review findings= section.
- =spec-response.org= — the author folds the review back in.
The spec this workflow produces has to *pass spec-review's gate* — that gate is the definition of done. So the structure below is built to answer the reviewer's questions up front. Keep it lightweight anyway: a short required spine plus a *readiness-dimensions menu* where each item is either answered or explicitly marked "N/A because…". The best spec is the shortest one that still lets an engineer build it, test it, and ship behavior that matches the user's mental model.
@@ -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=.
@@ -59,7 +61,7 @@ Capture, in this order:
This is where the spec earns a "Ready" from review: an engineer must be able to build it in steps, know when it's done, and never have to invent product behavior mid-implementation.
-1. *Implementation phases* — decompose the work into phases each small enough to finish in one focused session and each leaving the tree in a working (not half-broken) state. =spec-review= lifts this section straight into =todo.org= tasks, so a spec that can't be phased fails the gate — the absence is itself a finding.
+1. *Implementation phases* — decompose the work into phases each small enough to finish in one focused session and each leaving the tree in a working (not half-broken) state. =spec-review= checks this section decomposes cleanly and =spec-response= lifts it into =todo.org= tasks, so a spec that can't be phased fails the gate — the absence is itself a finding.
2. *Acceptance criteria* — the observable conditions that mean the feature works, written as checkable items. The review's test-surface task mirrors these.
3. *Readiness dimensions* — walk this menu and, for each, either define the behavior or write "N/A because…". The escape hatch keeps a simple spec short; the prompt keeps a hidden decision from slipping into implementation:
- *Data model & ownership* — what's user-authored / generated / cached / remote; who owns each editable region; what persists vs refreshes.
@@ -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/.ai/workflows/spec-response.org b/.ai/workflows/spec-response.org
index 2686cf8..7628e49 100644
--- a/.ai/workflows/spec-response.org
+++ b/.ai/workflows/spec-response.org
@@ -5,9 +5,9 @@
* Overview
-The spec-response workflow processes external reviews of a design spec and folds them into the spec until it is implementation-ready. A reviewer (human or another agent) leaves a review file next to the spec — typically produced by its counterpart, the *spec-review* workflow, which writes =<spec-basename>-review.org= and assigns a readiness rubric. Claude works through every recommendation, deciding accept / modify / reject for each, updates the spec for the accepted ones, documents the modified and rejected ones with reasons, then deletes the review file. Repeat for each spec under review.
+The spec-response workflow processes a review's findings and folds them into the spec until it is implementation-ready. A reviewer (human or another agent) records findings in the spec's =* Review findings= section — typically via its counterpart, the *spec-review* workflow, which writes one =TODO= task per finding (=[/]= cookie on the heading) and assigns a readiness rubric. Claude works through every finding, deciding accept / modify / reject, updates the spec body for the accepted ones, and completes each finding task in place: accept and modify finish =DONE= (the modify's change noted in the body), reject finishes =CANCELLED= with the reason. Repeat for each spec under review.
-The output is a spec a reader could implement from, plus a durable record — inside the spec — of why any recommendation was changed or declined, so the reviewer can find the reasoning later without re-litigating.
+The output is a spec a reader could implement from, plus a durable record — inside the spec — of why any finding was changed or declined, so the reviewer can find the reasoning later without re-litigating. The reasoning lives on the completed finding task, not a separate file.
This workflow was first run on 2026-05-23 against the linear-emacs =issue-query-spec.org= and =issue-representation-spec.org= reviews, and written up from that run.
@@ -27,25 +27,25 @@ A review is only useful if every point in it gets a decision. Without a defined
A spec's review is fully processed when:
-1. *Every recommendation has an explicit disposition* — accepted, modified, or rejected. None dropped.
-2. *Accepted recommendations are woven into the spec body* — the spec reads as if they were always there, not appended as a changelog.
-3. *Modified and rejected recommendations are documented in a bottom section* (e.g. "Review dispositions") with a one-paragraph reason each, so the reviewer can find the reasoning.
+1. *Every finding has an explicit disposition* — accepted, modified, or rejected, and its task completed (=DONE= or =CANCELLED=). None dropped.
+2. *Accepted findings are woven into the spec body* — the spec reads as if they were always there, not appended as a changelog.
+3. *Modified and rejected findings carry a one-paragraph reason in the completed task's body*, so the reviewer can find the reasoning.
4. *Review/response provenance is documented in the spec* — iteration count/date, contributor, role, what changed, and why.
5. *Pre-agreed decisions are flipped to =DONE=* — each settled decision's =TODO= becomes =DONE=, and the =[/]= cookie on the spec's =* Decisions= heading reflects the tally.
6. *Cross-spec tensions are reconciled in writing* when related specs were reviewed together.
-7. *The review file is deleted* once 1-6 hold.
+7. *Every finding task is completed* — the =* Review findings= =[/]= cookie reads complete (each finding =DONE= or =CANCELLED=).
8. *Tracking is updated* — the spec's VERIFY/task body notes "review incorporated" and whether it's implementation-ready.
9. *Implementation tasks exist* — once the author confirms the spec is Ready, the project's =todo.org= carries the full implementation-task breakdown (Phase 6), reviewed for completeness, with =:solo:= marked and a Manual-testing task for everything else.
-The whole run is done when no =*-review.org= files remain and each spec is judged implementation-ready (or its remaining blockers are named).
+The whole run is done when every spec's =* Review findings= cookie reads complete and each spec is judged implementation-ready (or its remaining blockers are named).
-*Measurable validation:* a reader scanning the review against the revised spec can find, for every review point, either the change in the body or its disposition at the bottom. Nothing is unaccounted for.
+*Measurable validation:* a reader scanning the spec can find, for every finding, either the change in the body or its disposition on the completed finding task. Nothing is unaccounted for.
* When to Use This Workflow
Trigger when:
-- A reviewer drops a review file alongside a spec — convention: same basename with a =-review.org= suffix (=foo.org= → =foo-review.org=).
+- A reviewer records findings in the spec's =* Review findings= section — typically via the spec-review workflow.
- Craig says "respond to the review" / "let's run the spec-response workflow" / "process the spec reviews."
- Any time a spec needs to absorb structured external feedback and converge to implementation-ready.
@@ -63,7 +63,7 @@ the file should be renamed first. Spec workflows require the -spec.org suffix as
guard against pointing the workflow at tutorial, inventory, or setup docs.
#+end_example
-The review file the response consumes follows the convention =<spec-basename>-review.org=, so a misnamed spec produces a mis-pointed review file too. Fix the spec name first.
+The findings the response consumes live in the spec's own =* Review findings= section, so the spec filename is the handle the workflow keys on — point it at the right =-spec.org= file. Fix the spec name first.
The user resolves the mismatch and re-invokes the workflow. Do not proceed with the response against a misnamed spec.
@@ -71,7 +71,7 @@ The user resolves the mismatch and re-invokes the workflow. Do not proceed with
** Phase 0: Orient
-1. List the review files (=ls docs/*-review.org= or wherever they live). Process them one at a time in whatever order; the user may name an order.
+1. Find specs with open findings — a =* Review findings= section whose =[/]= cookie isn't complete (=TODO= findings remain). Process them one at a time in whatever order; the user may name an order.
2. Re-read the *current* spec, not your memory of it — it may have changed since you wrote it (the user or a linter may have edited it, and pre-agreed decisions may already be encoded in the tracking file).
3. Note any *pre-agreed decisions* the reviewer or user has already settled — in the review's own "Agreed decisions" section, or in the spec's tracking task. These are settled inputs. Don't reopen them; bake them in.
@@ -79,15 +79,15 @@ The user resolves the mismatch and re-invokes the workflow. Do not proceed with
Read the entire review first. Recommendations interact — an early "medium" finding may be subsumed by a "high" one, or two findings may point at the same edit. Decide dispositions with the whole picture in view, not finding-by-finding as you scroll.
-** Phase 2: Decide a disposition for every recommendation
+** Phase 2: Decide a disposition for every finding
-For each recommendation, choose one:
+For each finding, choose one:
-- *Accept* — the recommendation is right as written. Plan the edit.
-- *Modify* — the recommendation is right in spirit but wrong in detail or scope. Adjust it, and record what you changed and why.
-- *Reject* — the recommendation doesn't fit. Record why.
+- *Accept* — the finding is right as written. Plan the edit.
+- *Modify* — the finding is right in spirit but wrong in detail or scope. Adjust it, and record what you changed and why.
+- *Reject* — the finding doesn't fit. Record why.
-*Engage critically.* Rubber-stamping is a failure mode. On a strong review most points are accepts, but actively look for the genuine modify/reject cases — they are where your judgment earns its place. Examples from the first run:
+*Engage critically.* Rubber-stamping is a failure mode. On a strong review most findings are accepts, but actively look for the genuine modify/reject cases — they are where your judgment earns its place. Examples from the first run:
- *Modify:* the review proposed an automatic cache-TTL defcustom. Accepted the goal (fresh data) but deferred TTL to vNext because for a single-user tool an explicit force-refresh + clear-cache command covers it without invalidation complexity.
- *Reject:* the review floated a separate =default-issue-filter= defcustom. Rejected as redundant — a fixed default command plus a default-view preference already covered it.
@@ -101,8 +101,8 @@ When related specs were reviewed together, two reviews can recommend opposite th
** Phase 4: Update the spec
-1. *Weave accepted recommendations into the body.* The spec should read naturally — a new "Selector semantics" section, a revised phase plan, an added test-strategy section — not a list of "review said X so I did Y." The body reflects the decisions; it doesn't narrate them.
-2. *Add a bottom "Review dispositions" section* listing only the *modified* and *rejected* recommendations, each with a short reason. Close it with a one-line "everything else accepted as written" so the reader knows the omissions from this section are accepts, not gaps.
+1. *Weave accepted findings into the body.* The spec should read naturally — a new "Selector semantics" section, a revised phase plan, an added test-strategy section — not a list of "review said X so I did Y." The body reflects the decisions; it doesn't narrate them.
+2. *Complete each finding task in place.* Accept → =DONE=, body noting where it was folded; modify → =DONE=, body noting what you changed and why; reject → =CANCELLED=, body giving the reason. The =[/]= cookie on =* Review findings= tracks progress. The reason on a modified or rejected finding is the durable record — accepted findings are recorded by the body change itself, so they need no separate note. The asymmetry is deliberate.
3. *Update or add a bottom "Review and iteration history" section.* Every response pass gets an entry, even when all findings are accepted. Each entry is an org subheading with a compound id followed by three body fields:
Heading format: =YYYY-MM-DD Day @ HH:MM:SS -ZZZZ — Contributor — Role=
@@ -113,26 +113,28 @@ When related specs were reviewed together, two reviews can recommend opposite th
- *What changed:* compact summary of accepted, modified, and rejected work.
- *Why:* the rationale or decision pressure behind the changes.
- - *Artifacts:* review filename, disposition section, task IDs, source checks, or commits when useful.
+ - *Artifacts:* the relevant findings, task IDs, source checks, or commits when useful.
4. *Flip settled decisions to =DONE=.* Each decision the decision-maker has agreed flips its =TODO= to =DONE=; the =[/]= cookie on the =* Decisions= heading tracks the tally. A contested decision stays =TODO= with the back-and-forth under its =*** Discussion= child header. Decisions still =TODO= should be only what genuinely still blocks, each with an owner and a by-when.
-5. *Raise the spec to implementation-ready:* consolidate decisions up front, add any implementation prerequisites the review surfaced (e.g. a schema-verification checklist), a consolidated test strategy, and a phased plan ordered so dependencies (like an output model everything depends on) come early. *Gate:* the spec Status cannot move past =draft= to implementation-ready while any decision is still =TODO= — the =[/]= cookie must read complete, or the author consciously accepts and records the risk of building with one open.
+5. *Raise the spec to implementation-ready:* consolidate decisions up front, add any implementation prerequisites the review surfaced (e.g. a schema-verification checklist), a consolidated test strategy, and a phased plan ordered so dependencies (like an output model everything depends on) come early. *Gate:* the spec Status cannot move past =draft= to implementation-ready while any decision or any =:blocking:= finding is still =TODO= — both =[/]= cookies must read complete, or the author consciously accepts and records the risk of building with one open. *If this response expanded scope* — folding a finding in added new phases, decisions, or external-dependency assumptions — re-run spec-review's readiness rubric against the *expanded* spec, and file any new gap as a finding or decision before claiming =Ready=. Disposition-completeness gates the *review*; the readiness rubric gates the *spec*. A response can resolve every finding and still be less ready than before, because the answers introduced unproven obligations — the cookies only protect you if the new obligation is actually filed.
6. *Update the status line* to note "review incorporated (<reviewer>, <date>)."
** Phase 5: Close out and iterate
-1. *Delete the review file* — only after every recommendation has a disposition. Its deletion is the signal the review is fully processed.
-2. *Update tracking* — the spec's VERIFY/task body gets a line noting review incorporated, what changed at a high level, which recommendations were modified (pointing at Review dispositions), and whether it's now implementation-ready pending final go.
+1. *Confirm every finding is completed* — the =* Review findings= =[/]= cookie reads complete (every finding =DONE= or =CANCELLED=). The complete cookie is the signal the review is fully processed; there is no file to delete.
+2. *Update tracking* — the spec's VERIFY/task body gets a line noting review incorporated, what changed at a high level, which findings were modified or rejected (pointing at the completed findings), and whether it's now implementation-ready pending final go.
3. *Update the session log* (state changed this turn).
-4. *Move to the next review file.* Repeat Phases 1-5 until none remain.
+4. *Move to the next spec with open findings.* Repeat Phases 1-5 until none remain.
5. *Report* what was accepted-wholesale, what was modified/rejected and why, any cross-spec reconciliations, and the implementation-ready verdict per spec.
** Phase 6: On Ready, build the implementation-task breakdown
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.
@@ -150,7 +152,7 @@ The workflow is complete when these tasks exist, the completeness pass confirms
Accept, modify, or reject — but never silently drop. The reviewer must be able to account for every point.
** Document the no's, not the yes's
-Accepted recommendations live in the spec body (the change *is* the record). Modified and rejected ones need an explicit written reason at the bottom, because the change is invisible and the reasoning would otherwise be lost. The asymmetry is deliberate.
+Accepted findings live in the spec body (the change *is* the record). Modified and rejected ones need an explicit written reason on the completed finding task, because the change is invisible and the reasoning would otherwise be lost. The asymmetry is deliberate.
** Critique, don't rubber-stamp
A review you accept entirely without finding a single thing to push on probably wasn't read critically. Your judgment — including a well-reasoned no — is the value you add.
@@ -164,11 +166,11 @@ When reviews conflict, find the framing where both are right. Silently honoring
** A reject goes back to the reviewer, not just into the file
Recording a reasoned reject is the floor, not the close. Communicate the rejection and its reason to the reviewer — a reject is a two-party event, not a unilateral call. If the reviewer disagrees, that's a discussion: weigh the counter, and if you still can't agree, escalate to whoever owns the decision rather than letting the author's "no" stand by default. "I'm not doing that" with no reason the reviewer can engage is the failure mode. (For a tight solo author-reviewer loop this is lightweight; for a team it's the difference between a review and a rubber-stamp-in-reverse.)
-** The spec reads forward, the dispositions read backward
-The body is written for the implementer (no review archaeology). The dispositions section is written for the reviewer (the reasoning trail). Keep the two audiences separate.
+** The spec reads forward, the findings read backward
+The body is written for the implementer (no review archaeology). A completed finding's reason is written for the reviewer (the reasoning trail). Keep the two audiences separate.
** The history explains provenance, not implementation behavior
-The spec body should still be the implementation contract. The bottom =Review and iteration history= section is for provenance: number of iterations, dates, contributors (including agents), roles, what each pass contributed, and why. Keep it short enough that future readers can understand how decisions evolved without rereading chats, deleted review files, or session logs.
+The spec body should still be the implementation contract. The bottom =Review and iteration history= section is for provenance: number of iterations, dates, contributors (including agents), roles, what each pass contributed, and why. Keep it short enough that future readers can understand how decisions evolved without rereading chats or session logs.
** Re-read before editing
The spec may have changed since you last saw it. Edit the current file, reconcile against the latest tracking state.
@@ -213,3 +215,8 @@ Update this workflow as we learn what works. Capture new disposition patterns, b
- *What:* Reconciled this workflow to spec-create's new Decisions convention (each decision is an org =TODO= task that flips to =DONE= on agreement, with a =[/]= cookie on the =* Decisions= heading and a =*** Discussion= child for disputes). Exit Criterion 5, Phase 2's pre-agreed-decisions step, and Phase 4 steps 4-5 now speak in flip-to-=DONE= terms, and the implementation-ready step gates on the all-=DONE= cookie.
- *Why:* The convention change landed in spec-create.org via an .emacs.d handoff (originated in its keymap-consolidation spec); this workflow still described the retired =State: proposed | accepted | superseded= model.
- *Artifacts:* Handoff =inbox/2026-06-12-1906-from-.emacs.d-spec-create-decisions-todo-note.org=. Paired spec-create.org and spec-review.org edits in the same commit.
+
+** 2026-06-21 Sun @ 23:16:06 -0400 — Claude Code (rulesets) — responder
+- *What:* Folded the review into the spec. Findings are now =* Review findings= =TODO= tasks the responder completes in place (accept/modify → =DONE=, reject → =CANCELLED= with the reason) instead of a "Review dispositions" section; the response is done when the =[/]= cookie reads complete, not when a review file is deleted. Phase 0 finds open work by an incomplete findings cookie; the Phase 4 implementation-ready gate now also requires the findings cookie, and rerun-the-readiness-rubric-on-expanded-scope is folded into that gate (a scope-expanding response must file new obligations as findings or decisions before claiming =Ready=).
+- *Why:* Deleting the review file left the iteration-history =Artifacts= line dangling and lost the verbatim review; keeping the file collided with this workflow's file discovery and its "no review files remain" done-condition. Craig's call: incorporate the review into the document, reusing the decisions machinery so the readiness signal is a cookie. The scope-expansion rerun closes a real gap — a response can resolve every finding and still introduce unreviewed obligations.
+- *Artifacts:* Paired spec-review.org edits in the same commit. Inbox handoffs =2026-06-20-2339-from-home-spec-response-readiness-gate-proposal.org= and =2026-06-21-0156-from-home-companion-to-tonight-s-spec-response.org=.
diff --git a/.ai/workflows/spec-review.org b/.ai/workflows/spec-review.org
index d956f00..0da8e65 100644
--- a/.ai/workflows/spec-review.org
+++ b/.ai/workflows/spec-review.org
@@ -5,9 +5,9 @@
* Overview
-The spec-review workflow evaluates a feature/specification document before implementation and decides one thing: can an engineer implement it confidently, test it thoroughly, and ship behavior that matches the user's mental model? If yes, say so and stop. If no, write a review file next to the spec naming every blocking gap and the concrete change that closes it.
+The spec-review workflow evaluates a feature/specification document before implementation and decides one thing: can an engineer implement it confidently, test it thoroughly, and ship behavior that matches the user's mental model? If yes, say so and stop. If no, record every blocking gap and the concrete change that closes it as findings in the spec's own =* Review findings= section.
-This is the *reviewer* side of a pair. Its counterpart is the spec-response workflow, which the spec's author runs to fold a review back in. The contract between them is the review file: =<spec-basename>-review.org= (e.g. =docs/issue-query-spec.org= → =docs/issue-query-spec-review.org=). spec-review produces it; spec-response consumes it.
+This is the *reviewer* side of a pair. Its counterpart is the spec-response workflow, which the spec's author runs to disposition the findings. The contract between them lives *in the spec*: a =* Review findings= section carrying one =TODO= task per finding, with a =[/]= cookie — the same shape the spec's =* Decisions= section already uses. spec-review writes the findings; spec-response completes them. No separate review file is written, so nothing dangles when a review is processed and the full review/response trail stays in the spec.
The goal is not to prove the spec is clever. It is to leave the implementer with *fewer* hidden decisions, not more prose.
@@ -28,7 +28,7 @@ A review is complete when:
1. *The implementation-readiness gate has been evaluated* and a rubric label assigned (=Ready= / =Ready with caveats= / =Not ready= / =Needs research=).
2. *If ready:* the user is told plainly ("This spec is implementation-ready. I have no further blocking review notes."), and the review stops — no churn for its own sake.
-3. *If not ready:* a =<spec>-review.org= file is written next to the spec, in the standard structure, with every finding specific and actionable (current behavior named, risk explained, change recommended, blocking-or-not stated).
+3. *If not ready:* findings are recorded in the spec's =* Review findings= section as =TODO= tasks (one per finding, =[/]= cookie on the heading), each specific and actionable (current behavior named, risk explained, change recommended, blocking-or-not stated).
4. *The spec's review history is updated* with who reviewed it, when, which iteration it was, what changed or was recommended, and why.
5. *Deferred work is logged* to =todo.org= (v1 = =[#B]=, vNext/someday = =[#D]=), not left only in chat.
6. *Implementation tasks are enumerated* — the spec's =Implementation phases= section is lifted into a drop-in =todo.org= block (one entry per phase plus a test-surface entry), or, if the spec has no phase decomposition, that gap is raised as a finding.
@@ -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
@@ -93,19 +98,19 @@ Mark the spec implementation-ready only if *all* of these hold:
- The plan can be phased without shipping broken intermediate states, and phases are small enough to reach a clean stopping point in one focused work session.
- External API assumptions are verified or explicitly listed as prerequisites.
-If all true → tell the user it's ready and stop unless they ask for more. If any false → continue and write the review file. A "ready" at this phase is provisional; confirm it at Phase 3 after the code read.
+If all true → tell the user it's ready and stop unless they ask for more. If any false → continue and record findings (Phase 5). A "ready" at this phase is provisional; confirm it at Phase 3 after the code read.
** Phase 2: Required reading order
Never review a spec in isolation.
1. *Read the existing implementation first.* The code paths the spec would touch: public commands and entry points, internal helpers/boundaries, current data representation, persistence/write-back, async/sync, caching, error handling, existing tests, naming/style. Capture current-state facts with function names and file paths. Don't recommend designs that ignore how the package works today.
-2. *Read related specs and task tracking.* Companion specs, relevant =todo.org= tasks, README/testing docs, prior review files. Record which tasks the spec absorbs, which stay separate, which decisions are already made, which are still open.
+2. *Read related specs and task tracking.* Companion specs, relevant =todo.org= tasks, README/testing docs, prior reviews (in each spec's =* Review findings= and =Review and iteration history=). Record which tasks the spec absorbs, which stay separate, which decisions are already made, which are still open.
3. *Read the target spec end to end — twice.* First for its problem/behavior/phases/assumptions; second looking only for gaps. The second read asks: "What would an implementer still have to invent?"
** Phase 3: Re-run the gate (authoritative)
-After reading code and spec, re-run the Phase 1 gate — this is the pass that counts, because now you can actually judge the items that needed the code: architecture fit, API verification, integration points. If now ready, don't manufacture churn. If not, write the review file.
+After reading code and spec, re-run the Phase 1 gate — this is the pass that counts, because now you can actually judge the items that needed the code: architecture fit, API verification, integration points. If now ready, don't manufacture churn. If not, record findings (Phase 5).
** Phase 4: Evaluate across dimensions
@@ -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.
@@ -136,66 +143,41 @@ Work the spec against these. Each is a source of concrete findings, not a box to
- *Development tooling.* Does the repo give contributors obvious commands for setup, fast tests, specific tests, compile, lint, coverage, cleanup, slow/manual tests, and release checks? Are optional/live tests gated by explicit environment variables? Is the Makefile/script surface consistent with sibling projects?
- *Small enhancement radar.* Are there low-complexity, high-value affordances already provided by the platform that should be surfaced now or explicitly deferred? Examples: archive/compress commands in file managers, built-in history, previews, diagnostics, or doctor commands. Keep the hot path simple; capture the opportunity rather than accidentally losing it.
-** Phase 5: Write the review file
+** Phase 5: Record findings in the spec
-Use this structure for =<spec-basename>-review.org= unless the spec calls for something different:
+Findings live in the spec, not a sibling file. Add (or append to) a =* Review findings= section near the spec's =* Decisions= section, with a =[/]= cookie on the heading. Each finding is a =** TODO= task: the heading is the smallest noun phrase naming the gap; the body names current behavior, the risk, and the recommended change. Tag a blocking (high-priority) finding =:blocking:= — it holds the rubric at =Not ready= until dispositioned; leave non-blocking findings untagged. Findings accumulate across review rounds the way decisions do, and the responder completes each one in place (Phase 4 of spec-response), so the section becomes the full review/response trail.
#+begin_src org
-,#+TITLE: Review: <Spec Title>
-,#+AUTHOR: <reviewer>
-,#+DATE: <date>
-,#+STARTUP: showall
-
-,* Scope reviewed
-What code, tests, docs, and specs you read.
-
-,* Implementation-readiness
-Whether the spec is ready. If not, summarize the blockers.
-
-,* Overall assessment
-The short senior-engineering read: what's right, what's risky, what must be clarified.
-
-,* High-priority findings
-Concrete headings. Each: why it matters and what to change.
-
-,* Medium-priority findings
-Important improvements that shouldn't block all progress.
-
-,* UX observations
-,* Architecture observations
-,* Robustness and performance observations
-,* Test strategy recommendations
-Specific test cases, not generic "add tests".
-,* Documentation and tooling recommendations
-README/user/developer docs, Makefile/package scripts, coverage, debug tools, and customization surface.
-
-,* Suggested spec edits
-Concrete edits to make the spec implementation-ready.
-
-,* Agreed decisions
-Answers reached during review. Omit if none.
-
-,* Open questions
-Only questions that truly block or materially affect implementation.
-
-,* vNext candidates
-Deferred features to capture in task tracking.
+,* Review findings [/]
+,** TODO Comment edit-back is undefined :blocking:
+The spec says fetched comments render as subheadings but doesn't define whether
+editing one syncs back. Linear only lets users edit their own comments. V1 should
+treat fetched comments as remote-owned display content and support only adding new
+comments; editing own comments can be vNext. (blocking)
+,** TODO Empty result and fetch error render identically
+A failed fetch and a successful-but-empty fetch produce the same buffer, so the
+user can't tell "no issues" from "the query broke." Define a distinct empty-state
+message. (non-blocking)
#+end_src
+Where the old review-file sub-sections go now: the scope-reviewed and overall-assessment narrative goes in the =Review and iteration history= entry (Phase 6); suggested spec edits are the recommended-change line in each finding's body; agreed decisions flip the spec's own =* Decisions= tasks; open questions are =:blocking:= findings or open decisions; vNext candidates are logged to =todo.org= as =[#D]= (Phase 6). The Phase 4 review dimensions are where findings come *from* — not headings to reproduce in the spec.
+
** Phase 6: Assign the rubric and update tracking
Assign one label consistently:
-- =Ready= — no blocking open questions; implementation can start. Requires no decision in the spec's =* Decisions= section to still be =TODO= (the =[/]= cookie reads complete; =SUPERSEDED= and =CANCELLED= count as resolved) — a decision still =TODO= holds the rubric at =Not ready=, or =Ready with caveats= if the author consciously accepts and records the risk.
+- =Ready= — no blocking open questions; implementation can start. Requires both cookies complete: no decision in =* Decisions= and no =:blocking:= finding in =* Review findings= still =TODO= (the =[/]= cookies read complete; =SUPERSEDED=/=CANCELLED= and a completed or rejected finding count as resolved) — a still-=TODO= decision or =:blocking:= finding holds the rubric at =Not ready=, or =Ready with caveats= if the author consciously accepts and records the risk. A non-blocking finding left =TODO= is author's discretion and does not hold the rubric.
- =Ready with caveats= — can start if the caveats are accepted and tracked.
- =Not ready= — blocking ambiguity / missing decisions would force implementers to invent product behavior.
- =Needs research= — external API/library/platform assumptions must be verified first.
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 review file is written.
+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.
Each entry is an org subheading with a compound id followed by three body fields.
@@ -207,27 +189,11 @@ Body fields:
- *What changed or was recommended:* high-signal summary, not a duplicate of the whole review.
- *Why:* the decision pressure or rationale that caused the contribution.
-- *Artifacts:* links to the review file, response/disposition section, commits, task IDs, or source checks when useful.
+- *Artifacts:* links to the relevant findings, commits, task IDs, or source checks when useful.
If the spec has no such section, add it at the bottom. Keep the history short and cumulative; it is provenance for future readers, not a session transcript.
-*Emit implementation tasks (drop-in for =todo.org=).* Read the spec's =Implementation phases= section and turn it into a paste-ready block in the review file, under a heading =Implementation tasks (drop-in for todo.org)=. One =** TODO= entry per phase, plus a final entry for the test surface. The point: the handoff to whoever implements is one paste, not a re-read of the spec, and a spec that can't be decomposed into phases fails this step, surfacing a shape problem before =Ready=.
-
-Per-phase entry, following =todo-format.md= (terse heading names the phase; body holds the one-line deliverable plus a pointer back to the spec; tags on the heading):
-
-#+begin_example
-** TODO [#B] <phase name — smallest noun phrase> :feature:
-<what this phase delivers, one line>. Spec: [[file:<spec path>]] (Implementation phases, phase N).
-#+end_example
-
-Final test-surface entry, mirroring the spec's =Acceptance criteria= when present:
-
-#+begin_example
-** TODO [#B] <feature> — test surface :test:
-Unit: <...>. Integration: <...>. E2e / manual-verify: <acceptance criteria as checkable items>. Spec: [[file:<spec path>]] (Acceptance criteria).
-#+end_example
-
-Priority and tags follow the deferred-work rule below. Emit the block in the review file; the author pastes it into =todo.org= during spec-response, or you log it directly when you're also closing the loop. If the spec has no =Implementation phases= section, don't invent one — that absence is the finding, and the step becomes the prompt to ask the author to add a phase decomposition before the spec can be =Ready=.
+*Check the spec decomposes into phases.* A =Ready= spec needs an =Implementation phases= section an implementer can turn into one task per phase plus a test surface. Confirm it's present and decomposable — each phase small enough to reach a clean stopping point in one focused session, with no broken intermediate states. If it's missing or can't be phased, file that as a =:blocking:= finding; don't invent the phases. The phase-to-task breakdown itself is spec-response's job (its Phase 6 reads =Implementation phases= directly once the author confirms =Ready=); the reviewer only verifies the section exists and is sound.
Then log deferred work to =todo.org=: v1 implementation = =[#B]= (unless urgent or speculative); vNext/someday = =[#D]=. Tag =:feature:= / =:bug:= / =:refactor:= / =:test:= / =:quick:= / =:solo:= only when accurate. Don't leave important deferred decisions only in chat.
@@ -256,8 +222,11 @@ Every material comment should be tagged by force: blocking, should-fix, or optio
** Make feedback author-usable
Review comments should be specific, neutral, and actionable: quote or name the spec behavior, explain the risk, recommend the smallest concrete change, and say how the author can verify the fix. Avoid personal language, rhetorical questions, vague "this needs work" comments, and comments that require the author to infer the desired edit.
+** Keep review and response roles explicit
+If the user asks for review plus "enhance the spec" in the same turn, produce the findings first. Make only low-risk provenance and tracking edits unless the user clearly wants the reviewer to respond too. Don't silently resolve product decisions on the author's behalf — a proposed default belongs in a finding until it's accepted, modified, or rejected.
+
** Preserve iteration provenance
-Future reviewers and implementers need to know not just the current decision, but how the spec got there: how many review/response loops happened, who contributed, what they changed or recommended, and why. Keep that record in the spec itself under =Review and iteration history= so the trail survives deleted review files, chat loss, and agent handoffs.
+Future reviewers and implementers need to know not just the current decision, but how the spec got there: how many review/response loops happened, who contributed, what they changed or recommended, and why. Keep that record in the spec itself under =Review and iteration history= so the trail survives chat loss and agent handoffs.
** Be strict about ownership
Especially for org-mode features: a user treats visible text as editable unless the representation says otherwise. Make generated-vs-editable explicit.
@@ -265,6 +234,9 @@ Especially for org-mode features: a user treats visible text as editable unless
** Never depend on an unverified API shape
If the spec assumes fields/mutations/enums, they're verified against current schema/docs/live responses, or listed as a research prerequisite. =Needs research= is a real, useful verdict.
+** Source external-dependency checks in the finding
+When a finding turns on a current external-dependency fact (release version, API capability, platform behavior, package availability, hosted-service terms), cite the checked source in the finding body. Stale dependency assumptions are common, and the next reviewer needs to tell "verified this pass" from "remembered from prior context."
+
** Favor small pure cores and thin IO layers
Push findings toward separable, unit-testable pure functions surrounded by thin command/transport layers.
@@ -354,3 +326,8 @@ Sources:
- *What:* Two refinements to the same-day decisions convention after Craig's review: the gate item and =Ready= rubric now read "no decision is still =TODO=" with =SUPERSEDED= and =CANCELLED= counting as resolved (spec-create's template defines them as done-class keywords via a =#+TODO:= header), and a spec still on the retired =State:= field model explicitly fails the gate item until converted — closing the vacuous-pass hole on old specs.
- *Why:* Review of the freshly-landed convention flagged that TODO/DONE alone lost the old model's superseded state and that the gate as written would silently pass a spec with no decision tasks at all. Craig chose the two done-class keywords and the auto-added =#+TODO:= header (the in-file header is what makes custom keywords portable).
- *Artifacts:* Paired spec-create.org edits (keyword scheme + template header) in the same commit.
+
+** 2026-06-21 Sun @ 23:16:06 -0400 — Claude Code (rulesets) — responder
+- *What:* Moved findings from a sibling =<spec>-review.org= file into the spec itself. Findings are now =** TODO= tasks under a =* Review findings= section with a =[/]= cookie, mirroring =* Decisions=; =:blocking:= marks high-priority. Phase 5 records findings in the spec instead of writing a review file; the Phase 6 =Ready= rubric gates on both the decisions and the findings cookie; the implementation-task drop-in (which lived in the review file) is gone, leaving the reviewer to verify the spec decomposes into phases and spec-response to build the breakdown. Also added two reviewer-practice principles harvested from a home spec-review: keep review and response roles explicit, and source external-dependency checks in the finding.
+- *Why:* The delete-the-review-file convention left the iteration-history =Artifacts= line dangling and dropped the verbatim review; keeping the file instead collided with spec-response's file discovery and its "no review files remain" done-condition. Craig's call: incorporate the review into the document, reusing the decisions machinery so the readiness signal is a cookie, not a file's presence or absence. The role-explicit and source-checking practices came in from the home finance-report spec via inbox handoffs.
+- *Artifacts:* Paired spec-response.org edits in the same commit. Inbox handoffs =2026-06-20-2339-from-home-spec-response-readiness-gate-proposal.org=, =2026-06-21-0156-from-home-companion-to-tonight-s-spec-response.org=, and the home-edited =2026-06-21-0156-from-home-spec-review.org=.
diff --git a/.ai/workflows/startup.org b/.ai/workflows/startup.org
index 59c9c54..2262eea 100644
--- a/.ai/workflows/startup.org
+++ b/.ai/workflows/startup.org
@@ -1,5 +1,5 @@
#+TITLE: Startup Workflow
-#+AUTHOR: Craig Jennings & Claude
+#+AUTHOR: Craig Jennings
#+DATE: 2026-04-25
* Summary
@@ -10,8 +10,8 @@ The workflow is structured into four phases. *Phase A.0* is a sequential pre-fli
Quick contract — runs / produces:
- *Phase A.0* (sequential): refresh rulesets, then the project repo.
-- *Phase A* (parallel batch): timestamp, session-context check, guarded =.ai/= sync, recent sessions, inbox-status, cross-agent status, notes.org, staleness, language-bundle freshness.
-- *Phase B* (parallel batch): read the crash-recovery anchor if present, the recent session summaries, new inbox items, pending cross-agent messages.
+- *Phase A* (parallel batch): timestamp, session-context check, guarded =.ai/= sync, recent sessions, inbox-status, notes.org, staleness, language-bundle freshness.
+- *Phase B* (parallel batch): read the crash-recovery anchor if present, the recent session summaries, new inbox items.
- *Phase C* (interactive): surface findings, process the inbox, run project startup-extras, ask priorities.
* Execution
@@ -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,26 +143,75 @@ 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
4. =\ls -t .ai/sessions/ 2>/dev/null | head -5= — list 5 most recent session files. The backslash bypasses any =ls= alias in the user's profile. Without it, bare =ls -t= silently returns no output under =exa= (a common =ls= replacement) — which makes a sessions directory full of files look empty, and the agent then skips Phase B step 2.
5. =\ls -la inbox/ 2>/dev/null= — inventory the inbox. Same reason for the backslash escape, applied uniformly across the Phase A =ls= calls.
-6. =cross-agent-status 2>/dev/null || true= — snapshot of pending cross-agent messages across local projects. This is layer A of the cold-start design from =cross-agent-comms.org=: pending messages from other agents (delivered while no session was active here) get surfaced on session start. The =|| true= keeps Phase A from failing if =cross-agent-status= isn't installed yet — older projects without the script still boot cleanly. If HALT is active, =cross-agent-status= prints a banner; surface that prominently in Phase C.
-7. Read =.ai/notes.org= — Project-Specific Context, Active Reminders, Pending Decisions sections (skip About This File).
-8. Read =.ai/project-workflows/startup-extras.org= if it exists.
-9. =[ -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.
-10. =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.
-11. =[ -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 inbox-zero 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-zero.org=). Read-only; never files at startup.
+6. Read =.ai/notes.org= — Project-Specific Context, Active Reminders, Pending Decisions sections (skip About This File).
+7. Read =.ai/project-workflows/startup-extras.org= if it exists.
+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/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: $(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:"
+ for f in $matches; do rg -m1 '^#\+title:' "$f" 2>/dev/null | sed 's/^#+title:/ -/'; done
+ 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.
@@ -159,6 +219,7 @@ Notes on the rsync commands:
- 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.
@@ -170,7 +231,6 @@ These calls depend on Phase A outputs, but are independent of each other. Issue
1. *Read =.ai/session-context.org= if Phase A reported it exists.* The file is the crash-recovery anchor — if it's there, the previous session was interrupted and the context lives only in this file.
2. *Read each of the 5 most recent session files* from Phase A's =\ls -t .ai/sessions/= output. Read just the =* Summary= section of each — not the full file. The Summary gives Active Goal / Decisions / Data Collected / Findings / Files Modified / Next Steps. That's enough to pick up where things left off. Drill into a specific =* Session Log= later only if you need the /why/ or sequence on something. *If Phase A's listing came back empty, sanity-check with =\ls -la .ai/sessions/= before treating empty as definitive — sessions/ should normally be populated, and an empty result usually means the listing got swallowed somewhere, not that the directory is genuinely empty.*
3. *Read each new inbox file* from Phase A's =\ls -la inbox/= output. For =.eml= files, defer to Phase C — those need the extract script (below) rather than a raw Read.
-4. *Process pending cross-agent messages.* For each project with a pending count >0 in Phase A's =cross-agent-status= output (typically the current project; cross-project pending is surfaced too but only acted on if the user asks), run =cross-agent-recv <message-file>= on the file path =cross-agent-status= named. The script returns a structured decision (=process= / =dedup= / =query= / =reject=) per the protocol. For =process=, read the message body to determine the action. For =query=, prepare a clarifying reply. For =reject=, surface to user with the reason. For =dedup=, no action — silent retry already handled. Surface all decisions in Phase C alongside other findings.
Rationale: Reads are independent and benign. Batching them means the whole session-history view + inbox view lands in one round-trip instead of one per file.
@@ -184,7 +244,11 @@ This phase touches the user and runs sequentially:
- Mention Pending Decisions from notes.org.
- Briefly note significant template updates noticed during sync (new workflows, protocol changes).
- *Task-review nudge.* If the Phase A staleness count (step 11) is greater than zero, surface one line: "=<N>= top-level tasks unreviewed for >7 days — say 'let's do a task review' to run a cycle." If zero, say nothing.
- - *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-zero.org=.
+ - *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.
@@ -197,8 +261,7 @@ This phase touches the user and runs sequentially:
#+end_src
If it reports a count, surface one line: wrap-up's Step 4.0 will commit it as =chore: sync .ai tooling from templates=, or offer to commit it now. If silent, say nothing. This is the crashed-session counterpart to the wrap-up commit step (the primary fix). From the 2026-05-31 jr-estate + work handoffs.
- - *Surface pending cross-agent messages.* If =cross-agent-status= reported any pending messages, list them with their =cross-agent-recv= decision (process / query / reject) per file. For =process= messages in this project's inbox, propose handling now or after the current task. For pending in other projects, mention the count so the user knows to switch projects when ready. If HALT was active, surface that prominently — cross-agent activity is paused until =cross-agent-resume= clears it.
-2. *Process inbox if non-empty.* Mandatory — don't ask, just delegate to [[file:process-inbox.org][process-inbox.org]]. That workflow owns the value gate (advances an existing TODO / improves the project / serves the mission), the per-source rejection flow (Craig / project handoff / script), the priority-scheme check before filing, and the =.eml= extraction path. Single source of truth for the discipline.
+2. *Process inbox if non-empty.* Mandatory — don't ask, just delegate to [[file:inbox.org][inbox.org]] process mode. That mode owns the value gate (advances an existing TODO / improves the project / serves the mission), the per-source rejection flow (Craig / project handoff / script), the priority-scheme check before filing, and the =.eml= extraction path. Single source of truth for the discipline.
3. *Execute project-specific startup extras* (the contents of =.ai/project-workflows/startup-extras.org= read in Phase A). If the file didn't exist, skip.
4. *Ask about priorities.* "What would you like to work on, or is there something urgent you need?"
- If urgent: proceed immediately.
diff --git a/.ai/workflows/status-check.org b/.ai/workflows/status-check.org
index efff16d..4a9972c 100644
--- a/.ai/workflows/status-check.org
+++ b/.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/.ai/workflows/summarize-emails.org b/.ai/workflows/summarize-emails.org
index 6ac5e6f..c9c7001 100644
--- a/.ai/workflows/summarize-emails.org
+++ b/.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/.ai/workflows/suspend.org b/.ai/workflows/suspend.org
new file mode 100644
index 0000000..166f9c9
--- /dev/null
+++ b/.ai/workflows/suspend.org
@@ -0,0 +1,143 @@
+#+TITLE: Session Suspend Workflow
+#+AUTHOR: Craig Jennings
+#+DATE: 2026-06-28
+
+* Overview
+
+This workflow captures the live state of a session when Craig must leave
+abruptly, so a future session resumes with nothing lost. It is the fast,
+capture-only workflow for departure: it writes down where every thread stands,
+notes any uncommitted work, then STOPS — no cleanup, no archive, no teardown.
+
+Triggered by Craig saying "suspend the session," "suspend," "I need to go,"
+"stick a pin in everything," or similar. "I need to go" is broad — if it reads
+as a conversational aside rather than a request to suspend, confirm before
+running.
+
+* Where suspend sits among its neighbors
+
+Three workflows touch the session anchor (=.ai/session-context.org=); keep them
+straight:
+
+- =flush= ([[file:../../flush/SKILL.md]] / =/flush=) — *stay and sharpen.*
+ 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, 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.
+
+Suspend and flush share one core — capture into the anchor, leave it in place.
+They differ in the exit (leave vs clear-and-continue) and the resume path
+(startup vs the =/clear= hook). Suspend reuses flush's capture discipline (its
+Phase 1 anchor-refresh) rather than restating it, and adds a richer,
+resume-weighted Session Log entry because it's written for a cold resume after a
+gap, not a same-session reset.
+
+* Suspend vs wrap-up — the one structural difference
+
+=wrap-it-up= ARCHIVES =.ai/session-context.org= (renames it into
+=.ai/sessions/=); its absence at the next startup is the signal that the last
+session ended cleanly.
+
+Suspend does the opposite: it LEAVES =.ai/session-context.org= in place. Its
+presence at startup is exactly the signal that the previous session was
+interrupted, so the startup workflow reads it and resumes. Suspend provides only
+the *capture* half — startup's existing interrupted-session path (Phase A checks
+for the anchor, Phase B reads it, Phase C offers to resume) is the *resume* half,
+already built.
+
+So: never archive, never rename the context file in a suspend. Capture into it
+and leave it.
+
+* What gets captured
+
+The point is zero lost information, weighted toward RESUME. Into the
+=* Session Log= of =.ai/session-context.org=, append one dated
+=** YYYY-MM-DD ... — SUSPENDED= entry holding:
+
+1. *Open threads — resume here.* For each active or pending thread: the topic,
+ its status (ACTIVE / PINNED / SET ASIDE / DEFERRED), the immediate next
+ step, and the pointers needed to act on it cold (files + line numbers,
+ commit SHAs, the specific finding or decision). This is the core; spend the
+ most words here. Order newest / most-active first.
+2. *Pending decisions / open questions* awaiting Craig — anything blocked on
+ his input, with enough context that the answer is actionable.
+3. *Shipped this session* — a terse list of what landed, each with its commit
+ SHA, so the resume knows what is already done and need not re-derive it.
+4. *Uncommitted work* — anything modified on disk but not committed, named
+ file by file, so the resume knows what state the tree is in.
+5. *Key findings not yet recorded elsewhere* — anything learned this session
+ that isn't already in a commit, a file, or memory, so it survives.
+6. *Background work* — any running task, agent, or job, and how to check it.
+7. *Resume hint* — the single most likely "start here" next action.
+
+Also update the top of =* Summary= (Active Goal) with a one-line SUSPENDED
+pointer to the entry, so startup reading the top sees the current state even
+when the Summary body is from an earlier thread.
+
+* Steps
+
+1. *Write the SUSPENDED entry* into the Session Log, per "What gets captured"
+ above. Timestamp with =date "+%Y-%m-%d %a @ %H:%M:%S %z"=.
+2. *Update the Active Goal pointer* at the top of =* Summary=.
+3. *Record uncommitted work, don't force-commit it.* A suspend records state, it
+ does not tidy it. Name every uncommitted change in the SUSPENDED entry and
+ leave the tree as it is — on an abrupt departure, a dirty tree (like any
+ crash) is safer than a blind commit of arbitrary mid-work state. (If a
+ project defines a standing always-commit set in its own workflow, commit only
+ 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. 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
+
+Speed over completeness. A suspend deliberately skips everything wrap-it-up
+does beyond capture:
+
+- No =* Summary= rewrite beyond the one-line Active Goal pointer.
+- No todo.org cleanup / archive-done.
+- No KB / memory promotion sweep.
+- No Linear / board reconciliation.
+- No session-record archive (the file stays live).
+- 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.
+
+If Craig later wants the clean end, he runs wrap-it-up, which picks up the
+captured state and finishes the job.
diff --git a/.ai/workflows/sync-email.org b/.ai/workflows/sync-email.org
index 52a7caf..863b400 100644
--- a/.ai/workflows/sync-email.org
+++ b/.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/.ai/workflows/task-audit.org b/.ai/workflows/task-audit.org
index 67ce496..aa50176 100644
--- a/.ai/workflows/task-audit.org
+++ b/.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,11 +81,41 @@ 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.
+** Phase C.5 — Consolidate related tasks (interactive)
+
+Phase C's *Consolidate duplicates* bullet folds tasks that track the *same* thing. This step is the broader case: tasks that aren't duplicates but are really *one effort* fragmented across the list. A spread-out effort — several tasks all circling "make the tooling agent-agnostic," say — is harder to see, plan, and finish as a whole than one task, or one parent with the pieces as children.
+
+After the Phase C edits, read the open-task set as a whole and look for *clusters*: tasks that share a goal, a subsystem, or an obvious sequence. Use judgment over the task bodies, not a keyword heuristic — adjacency is a semantic call, and a brittle title-match both misses real clusters and invents false ones.
+
+For each cluster, surface it to Craig (inline numbered options per =interaction.md=, no popup) with a recommendation, offering the two shapes:
+
+- *Merge* — fold the cluster into one task when the members are genuinely the same work split up (near-duplicates, or steps with no independent value). The merged task keeps the strongest priority, unions the type tags, and absorbs each member's body as a dated note or a short list; the absorbed tasks close per =todo-format.md= (a =**= task → =CANCELLED= + =CLOSED:= with a one-line "merged into <task>", or deletion if it carried nothing unique).
+- *Parent with children* — when the members are related but distinct (each ships independently or has its own value), promote a parent task and re-home the members beneath it as sub-tasks, so the list shows the effort as a unit without losing the individual pieces.
+
+Never merge or re-parent autonomously — which tasks belong together, and whether they're one-work or related-distinct, is a judgment only Craig ratifies. Propose, don't apply, until he picks. A cluster he declines stays as separate tasks; don't re-surface it every audit (note the decline in the session log).
+
+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).
@@ -132,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/.ai/workflows/task-review.org b/.ai/workflows/task-review.org
index 69e172d..7ea2e8e 100644
--- a/.ai/workflows/task-review.org
+++ b/.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/.ai/workflows/triage-intake.cmail.org b/.ai/workflows/triage-intake.cmail.org
index d818c72..8d8abfb 100644
--- a/.ai/workflows/triage-intake.cmail.org
+++ b/.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/.ai/workflows/triage-intake.github-prs.org b/.ai/workflows/triage-intake.github-prs.org
index c1bc796..644421c 100644
--- a/.ai/workflows/triage-intake.github-prs.org
+++ b/.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/.ai/workflows/triage-intake.org b/.ai/workflows/triage-intake.org
index b257f2d..55cc939 100644
--- a/.ai/workflows/triage-intake.org
+++ b/.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 markdown summary surfaced inline to Craig. Order:
+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.)
-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:
+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 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. *==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).
-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.
+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 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:
+*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.
+
+*** Phase D: Close — the default, not an option
+
+*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.
-**** Sub-step: write each Action item into =todo.org= as its own =:quick:= task
+**** Task-filing format (=todo.org=)
-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.
+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,113 @@ 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.
+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.
-*** Phase D: Execute actions on confirmation
+*** Exit Criteria
-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").
+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.
-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.
+*** KB capture (only if the sweep surfaced something durable)
-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).
+If this sweep surfaced a durable, cross-project fact — a recurring pattern across sources, a reference pointer worth keeping, an environment gotcha — consider writing it to the agent KB as one =:agent:= node (see the best-practices node and =knowledge-base.md=; personal projects only, work never writes). One line of judgment, not a step: an all-quiet sweep surfaces nothing and writes nothing. Never blocking, never padded onto a no-signal run.
-*Do not close the workflow yet.* See Exit Criteria below.
-*** Exit Criteria
+* Auto mode (unattended monitoring)
+
+Auto mode is a self-running variant of the engine for when Craig is away from the desk but wants tight awareness — a loop that runs the standard sweep on a short interval, *accumulates* findings rather than mutating state, and hands Craig a gated checkpoint to commit the batch. It composes two things: the *delivery* (a =/loop= in the live session) and the *behavior* (accumulate-don't-mutate sweeps with a checkpoint). The one-shot run above is unchanged; auto mode is an additional way to run the same Phase 0 / A-D engine.
+
+** Trigger and delivery
+
+- "auto triage" / "auto triage-intake" / "watch the desk" / "monitor the triage" — start auto mode.
+- Default interval *20 minutes*; Craig sets it.
+
+Auto mode runs as a =/loop= in the *live session*, not a detached cron job:
+
+#+begin_src
+/loop 20m run an auto-mode triage-intake sweep per triage-intake.org
+#+end_src
+
+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 workflow stays open until Craig has *explicitly* either:
+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.
-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").
+*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.")
-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 =agent-text= isn't on =PATH=, fall back to inline delivery and say so once.
-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.
+*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.
-This rule prevents the failure mode where the workflow self-declares done and the next exchange has to relitigate what state things are in.
+** 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.
+
+** A sweep: accumulate, don't mutate
+
+Each sweep runs Phase 0 (load *both* plugin dirs — the loud requirement still holds) and Phases A-D's scan / classify / synthesize, but performs *none* of the normal run's mutations:
+
+- Does NOT advance the sentinel. The scan window grows from the last *close* until the next close: every sweep scans from the existing sentinel up to now, so nothing between sweeps is dropped.
+- Does NOT write =todo.org= Action tasks — accumulates them for the close.
+- Does NOT take mail actions (trash / mark-read / star).
+- Does NOT commit.
+- 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, 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).
+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 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)"
+ #+end_src
+
+** The unacked list — durable state
+
+The awaiting-acknowledgment list lives in =.ai/triage-intake-unacked.org=, so it survives a session crash, a =/clear=, or a restart — the away-from-desk case auto mode exists for. It's project-local state, tracked the same way as the sentinel (=.ai/last-triage-intake=), created on first need.
+
+Shape — one =** = heading per awaiting item:
+
+#+begin_example
+#+TITLE: Triage Intake — Responses Awaiting Acknowledgment
+# Maintained by triage-intake auto mode. One heading per item; acked items are removed.
+
+** Dana — 2pm reschedule invite
+:PROPERTIES:
+:SOURCE: personal-calendar
+:LOCATOR: <event id or thread url — the dedupe key>
+:SINCE: 2026-06-15 10:42
+:END:
+She's waiting on a yes/no to the move.
+#+end_example
+
+- *Add* — a sweep appends any new directed-at-Craig response not already listed, deduped on =LOCATOR=.
+- *Carry forward* — every sweep re-renders the full list in its second section, whether or not it changed this sweep.
+- *Ack* — "ack <item>" (e.g. "ack the Dana thread") removes that heading; "ack all" clears the list.
+- *Close* — a close empties the list as part of processing (each item is either actioned or filed).
+
+** Close and stop — the checkpoint
+
+The mutations are gated behind two commands:
+
+- *"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.
+
+** Why a separate mode
+
+The standard engine is one-shot and mutating — right for an at-the-desk "what's new?" glance, wrong for unattended polling: run every 20 minutes it would advance the sentinel past unprocessed items, spray reactive todos, take mail actions, and commit noise without review. Auto mode separates the cheap, frequent *watching* from the deliberate, gated *committing*, and adds the away-user's missing primitive — the running unacked-responses list.
* Reference
@@ -185,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:
@@ -263,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>
+
+==TASKS==
+- <solo-executable items first, then the rest; priority order within each;
+ one prose bullet each: who, what, why-now, source link>
+
+==FYI==
+- <substantive work context, no action owed; priority order>
-**Top signals to act on:**
-1. <terse Action description with link>
-2. ...
+==MISC==
+- <everything outside the project — personal mail, family, household;
+ priority order; action-ness stated inline>
-<one block per loaded source, in ORDER — see each plugin's Render>
+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.
-**Suggested actions:**
-- Trash N noise items
-- Mark-read M keep items
-- Respond to <invite>
-- Merge PRs #X and #Y
-- ...
+<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
@@ -297,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).
@@ -314,6 +440,15 @@ 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.
+
**** 2026-05-01: Initial creation
Extracted from daily-prep's Phase 3 pattern as a standalone, lightweight, between-meetings sweep.
@@ -327,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/.ai/workflows/triage-intake.personal-calendar.org b/.ai/workflows/triage-intake.personal-calendar.org
index bf7d543..b5ee67a 100644
--- a/.ai/workflows/triage-intake.personal-calendar.org
+++ b/.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/.ai/workflows/triage-intake.personal-gmail.org b/.ai/workflows/triage-intake.personal-gmail.org
index aa0554d..7fb1231 100644
--- a/.ai/workflows/triage-intake.personal-gmail.org
+++ b/.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/.ai/workflows/triage-intake.telegram.org b/.ai/workflows/triage-intake.telegram.org
index 9caa4e1..1319da5 100644
--- a/.ai/workflows/triage-intake.telegram.org
+++ b/.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
@@ -30,12 +30,27 @@ Telega does not autostart with the Emacs daemon. "Down" is its normal state
unless Craig has Telegram open in Emacs. The scan therefore runs the full
lifecycle every time, never skips because the server is down:
+⚠ *DOWN / not-loaded is the TRIGGER to launch, never a reason to skip or fail.*
+This is the exact mistake two projects (work + home, 2026-07-24) made: they
+probed telega, saw =(telega-server-live-p)= nil or telega not =featurep=, and
+reported =SCAN FAILED: telegram — not loaded= or a silent SKIP — a *blind*
+sweep — instead of running Step 1 to start it. A down or unloaded telega is the
+normal entry state; =(telega t)= both LOADS the package and STARTS the docker
+server (work confirmed: down → =(telega t)= → Ready, 18 chats). So the plugin
+MUST run Step 1's launch whenever telega is down/unloaded, wait for Ready, then
+scan. =SCAN FAILED= is reserved for a launch that was actually ATTEMPTED and did
+not reach Ready (image missing, server crash on start, daemon unreachable) —
+never for the pre-launch down state itself. The =:ENABLED:= guard above tests
+whether telega is INSTALLED (=fboundp=), not whether the server is up; a down
+server never disables the source.
+
1. Record prior state: TELEGA_WAS_RUNNING via (telega-server-live-p).
2. Launch (only if not running):
emacsclient -e "(progn (setq telega-use-docker t) (telega t) 'started)"
- The setq is mandatory defense: tdlib segfaults outside docker mode
- (2026-06-09), and Craig's daemon currently has telega-use-docker nil.
- Wait ~2s for Ready, then (telega--loadChats 'main) until telega--chats
+ The setq is mandatory defense: tdlib crashed in native mode when this was
+ set up (2026-06-09) — a separate matter from the SEGFAULT gotcha, which is
+ about the loadChats argument — and Craig's daemon defaults to nil.
+ Wait ~2s for Ready, then (telega--loadChats '(:@type "chatListMain")) until telega--chats
is populated.
3. Check messages: the maphash unread scan in ** Scan Step 2 (filters the
messageContactRegistered join-notice noise).
@@ -48,10 +63,13 @@ lifecycle every time, never skips because the server is down:
Verify: telega-server-live-p → nil, no zevlg/telega-server container in
docker ps. If Craig had it running, leave it untouched.
-If any lifecycle step fails (docker image missing, server crash, daemon
-unreachable), the sweep reports it as SCAN FAILED at the top of the summary
-per the engine's failure rule — never as a silent skip. Craig gets real
-traffic here.
+If any lifecycle step fails *after the launch was attempted* (docker image
+missing, server crash on start, daemon unreachable, Ready never reached), the
+sweep reports it as SCAN FAILED at the top of the summary per the engine's
+failure rule — never as a silent skip. This does NOT cover the ordinary
+pre-launch down state: a down server means "run Step 1," not "SCAN FAILED."
+Craig gets real traffic here, so a blind sweep that skipped the launch is worse
+than a clean failure — it hides real unread messages behind a false all-clear.
** Scan
@@ -85,22 +103,58 @@ TELEGA_WAS_RUNNING=$(emacsclient -e "(and (fboundp 'telega-server-live-p) (teleg
*** Step 1 — start (docker mode) if not already running, wait for Ready
#+begin_src bash
-# `(telega t)` starts without popping the root buffer. Docker mode (the stable
-# path — see the SEGFAULT gotcha) reconnects the persisted ~/.telega session in
-# ~2s. Then load the main chat list so telega--chats populates.
+# `(telega t)` starts without popping the root buffer. Docker mode reconnects the
+# persisted ~/.telega session in ~2s. Then load the main chat list so
+# telega--chats populates.
+#
+# The `(setq telega-use-docker t)` is mandatory and must come BEFORE `(telega t)`:
+# tdlib crashed in native mode when this was first set up (2026-06-09), and the
+# daemon's default is nil unless something (e.g. an Emacs-config :custom) has
+# already forced it. It was missing here while the Quick Reference required it —
+# a session that started telega without it on a native-mode daemon would take the
+# untested path. Match the Quick Reference exactly.
+#
+# Note this is a SEPARATE concern from the SEGFAULT gotcha below: that gotcha is
+# about the `loadChats` argument, and the deaths it explains happened in docker
+# mode. Docker mode is not a defense against it, and it is not evidence for
+# docker mode. Keep both.
emacsclient -e "(progn
+ (setq telega-use-docker t)
(unless (and (fboundp 'telega-server-live-p) (telega-server-live-p)) (telega t))
'started)"
# Poll until Ready with chats synced, or a crash/timeout. Background this with an
# until-loop so the wait doesn't block; exit on Ready-with-chats OR an abnormal
# server exit. Then force a chat-list load if the hash is thin:
-emacsclient -e "(progn (ignore-errors (telega--loadChats 'main)) (ignore-errors (telega--loadChats 'main)) 'loaded)"
+# NOTE: the chat-list argument must be a TL object, not the symbol 'main.
+# `telega--loadChats' puts it straight into the request as :chat_list, and a
+# bare symbol kills the server outright (see the SEGFAULT gotcha below).
+#
+# The liveness check on the tail is the load's only failure signal. `ignore-errors'
+# catches nothing here, because a bad argument kills the server process rather than
+# signalling in elisp, so without this the call returns 'loaded either way.
+# The `fboundp' guard matches Step 0: if the launch failed outright telega is not
+# loaded, and that should read as 'server-died like any other failure rather than
+# signalling void-function.
+emacsclient -e "(progn (ignore-errors (telega--loadChats '(:@type \"chatListMain\"))) (ignore-errors (telega--loadChats '(:@type \"chatListMain\"))) (if (and (fboundp 'telega-server-live-p) (telega-server-live-p)) 'loaded 'server-died))"
#+end_src
On a persisted session telega reaches status "Ready" within ~2s; the chat list
loads over a few more. If =(hash-table-count telega--chats)= is 0 or thin,
re-issue =telega--loadChats= and poll until it stabilizes.
+⚠ *=server-died= is SCAN FAILED, never a quiet account.* A server that dies
+during the load leaves a thin =telega--chats= hash, and a thin hash reads exactly
+like an account with little unread. That is the same false all-clear the
+down/not-loaded rule exists to prevent, arriving one step later in the lifecycle.
+It also fits the SCAN FAILED definition above: the launch was attempted and did
+not hold. So on =server-died=, report SCAN FAILED rather than scanning, and never
+report a low unread count from that run.
+
+This is the independent evidence the SEGFAULT gotcha asks for when it says to
+treat a short chat list as a real short list. Without the check there is no way
+to tell the two apart, which is how the =loadChats= crash stayed invisible
+through two investigations.
+
*** Step 2 — read unread, classified by last-message type
The single most important filter: =messageContactRegistered=. Telegram counts a
@@ -157,24 +211,61 @@ stays non-nil). =telega-server-kill= is what actually stops the server. Call
left in =docker ps=. Skipping this whole branch when =TELEGA_WAS_RUNNING= is t is
the point of Step 0: never tear down a session Craig is actively using.
-⚠ *SEGFAULT GOTCHA — crashes are spontaneous; treat server death as routine.*
-The dockerized =telega-server= (=zevlg/telega-server:latest=, image built
-2026-06-04, tdlib 1.8.64) SIGSEGVs (exit 139) *on its own*, minutes-to-hours
-into a session — 11 host coredumps between 2026-06-09 and 2026-06-11, several at
-times when no triage verb was running. The 2026-06-11 investigation reproduced
-the crash-free verbs and the spontaneous deaths side by side: coredump
-backtraces show a corrupted stack (memory corruption in the musl build), and
-no newer image exists upstream. Earlier theories — "native mode is the trigger",
-"toggle-read is the trigger" — were timing coincidences; the verbs are sound.
+⚠ *SEGFAULT GOTCHA — this was our bug, not tdlib's. Root-caused 2026-07-28.*
+=telega-server= dies with =Unexpected char 'm' in plist value= followed by
+=Assertion failed: false (telega-dat.c: tdat_plist_value: 500)=. The cause was
+this workflow: Step 1 called =(telega--loadChats 'main)=.
+
+The chain. =telega--loadChats= is a raw TL wrapper — it drops its argument into
+the request as =:chat_list= with no conversion. =telega-server--send= then
+=prin1='s the whole plist, and =telega--tl-pack= passes atoms through untouched,
+so the symbol goes out on the wire bare as =main=. The C parser
+(=server/telega-dat.c=, =tdat_plist_value=) accepts only =(=, =[=, ="=, =-=, a
+digit, =t=, =:=, or =n= to start a value. It hits =m=, prints that line, and
+calls =assert(false)=, which aborts the process. The =m= in the error is
+literally the first character of =main=.
+
+The symbol shorthand is real but belongs to a different layer:
+=telega-filter.el= and =telega-folders.el= convert =(eq cl-fspec 'main)= into
+='(:@type "chatListMain")=. The raw TL layer never does. telega's own callers
+always pass the object (=telega.el:290=, =telega-tdlib-events.el:516=).
+
+Proved by experiment, not inference (2026-07-28): from a live Ready server,
+=(telega--loadChats 'main)= killed it within seconds and added one coredump,
+with that exact assertion; a restart plus =(telega--loadChats '(:@type
+"chatListMain"))= survived three consecutive calls with no new coredump and no
+assertion.
+
+*The previous entry here was wrong and cost real time.* It recorded the deaths
+as spontaneous musl memory corruption and declared "the verbs are sound", which
+sent later investigations at the docker image and tdlib versions instead of at
+this file. The corrupted stack in the backtraces is what an =assert= abort looks
+like, not independent evidence of a memory bug. If crashes are ever seen again
+with *no* triage verb running, that is a genuinely separate cause and needs its
+own investigation — do not reuse the old spontaneous-crash story to explain it.
+
+*This crash kills a scan; it does not silently shorten one.* An earlier draft of
+this section claimed the reported "19 chats of ~50" was truncation caused by the
+bad call. That was wrong, and work disproved it at the wire level on 2026-07-28:
+with the corrected call their count is 19 before the first load and 19 after five
+(four on =chatListMain=, one on =chatListArchive=). Nineteen is the real size of
+that account. The same reading here — 19 stable across three corrected loads —
+was already sitting in the evidence and should have retired the claim before it
+was written down. Treat a short chat list as a real short list unless something
+independently shows the server died mid-sync.
+
+=ignore-errors= around the call never helped — the failure is the server process
+dying, not an elisp signal, so there is nothing for it to catch. That is why the
+death is easy to miss from inside elisp, and why a caller should check
+=(process-live-p (telega-server--proc))= after a load rather than trusting a
+returned value.
Operationally: docker mode stays mandatory (=telega-use-docker= = t; the setq
before =(telega t)= is still the right defense), and *every action batch checks
the server first* — =(process-live-p (telega-server--proc))= — restarting via
-=(telega t)= when dead and re-checking Ready before firing verbs. A mid-sweep
-death is recoverable, not an abort: restart, confirm Ready, resume. Durable-fix
-candidates if the crashing gets worse: pin a pre-2026-06 image digest, build
-=telega-server= natively against tdlib, or report upstream to zevlg with the
-coredumps (=coredumpctl list /usr/bin/telega-server=).
+=(telega t)= when dead and re-checking Ready before firing verbs. Any argument
+handed to a =telega--*= TL wrapper must be a TL object or a plain
+string/number/list, never a bare symbol.
Defense in depth: even if the server does die, the scan still works because it
reads the cached =telega--chats= hash, not a live query. A dead server is
diff --git a/.ai/workflows/work-the-backlog.org b/.ai/workflows/work-the-backlog.org
new file mode 100644
index 0000000..ea3f402
--- /dev/null
+++ b/.ai/workflows/work-the-backlog.org
@@ -0,0 +1,266 @@
+#+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 the isolated adversarial review (=publish= Step 1) with its re-review loop, 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.
+- *Isolated adversarial review* before every commit, dispatched per the =publish= skill's Step 1 — never an inline self-review, however small the diff. Critical and Important findings block until fixed, and each fix goes back to the *same* reviewer until it approves. Minor findings never earn another round.
+ - *When the review can't reach approval* — three rounds without it, a finding that recurs after being reported fixed, or a =Needs Discussion= verdict — the unattended run has no one to ask. Record the task =failed= with the standing findings in its result, leave the tree working, and continue to the next task. Never commit past a blocking finding because nobody is awake to adjudicate — an unreviewed commit landing overnight is the outcome this gate exists to prevent.
+- *=/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/.ai/workflows/wrap-it-up.org b/.ai/workflows/wrap-it-up.org
index 139d612..a9a5895 100644
--- a/.ai/workflows/wrap-it-up.org
+++ b/.ai/workflows/wrap-it-up.org
@@ -1,10 +1,10 @@
#+TITLE: Session Wrap-Up Workflow
-#+AUTHOR: Craig Jennings & Claude
+#+AUTHOR: Craig Jennings
#+DATE: 2026-04-20
* Overview
-This workflow defines the process for ending a Claude Code session cleanly. It finalizes the session record, commits + pushes all work, and provides a warm handoff.
+This workflow defines the process for ending a Claude Code session cleanly. It finalizes the session record, commits + pushes all work, and provides a warm handoff. A bare wrap also tears the session down (kills the ai-term buffer + tmux session, restoring geometry); a qualified wrap keeps the buffer, and a shutdown wrap powers the machine off. The teardown variants are set by the trigger phrase (see Teardown mode below) and act only at the very end, in Step 6.
Triggered by Craig saying "wrap it up," "that's a wrap," "let's call it a wrap," or similar.
@@ -24,15 +24,53 @@ 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.
-6. *Valediction delivered.* Brief, warm closing with key accomplishments and reminders.
+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.
+* Teardown mode (set from the trigger phrase)
+
+The wrap itself — Steps 1 through 5 — is identical in every mode. The trigger phrase only decides what Step 6 does once commit + push and the valediction are done. Resolve the mode from the phrase before starting:
+
+- *Teardown* (the default) — bare "wrap it up", "that's a wrap", "let's call it a wrap". The full wrap, then Step 6 kills the ai-term buffer + the =aiv-<project>= tmux session (which takes =claude= with it) and restores the saved window geometry. This is Craig's typical end-of-day case.
+- *No-teardown* — "wrap it up with summary" or "wrap it up and summarize". The full wrap, but Step 6 leaves the buffer and session intact so the summary stays readable. The explicit qualifier is what opts out of teardown.
+- *Shutdown* — "wrap it up and shutdown". The full wrap, then Step 6 gates on this being the only live ai-term session and powers the machine off. Shutdown supersedes teardown (killing the buffer is moot if the box is going down).
+
+Why teardown waits for Step 6 and runs through a hook, never inline: teardown kills the very tmux session =claude= runs in, so an inline kill would cut the valediction off before it renders. Step 6 instead drops a sentinel after everything else is verified, and the =Stop= hook (=ai-wrap-teardown.sh=) does the actual teardown when this response ends — by which point the valediction has already been delivered.
+
+This depends on three functions in =.emacs.d/modules/ai-term.el= (=cj/ai-term-quit=, =cj/ai-term-live-count=, =cj/ai-term-shutdown-countdown=) and on the =Stop= hook being wired in =settings.json= (=hooks/settings-snippet.json=). If =emacsclient= or the daemon is unreachable, the sentinel is cleared and the session simply stays up — teardown degrades to a no-op, never a wedge.
+
* 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 cycle) 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.
+
Read through the =* Session Log= in =.ai/session-context.org=. Populate (or refine) the =* Summary= section:
- *Active Goal* — one or two sentences describing the session's focus
@@ -90,15 +128,15 @@ Replace =DESCRIPTION= with your picked slug. (=AI_AGENT_ID= should be filename-s
If the project has a =todo.org= at its root, run the cleanup script before committing. Two passes, both fast and idempotent: a hygiene pass and an archive pass.
-*** Roam inbox sweep (inbox-zero)
+*** Roam inbox sweep (inbox roam mode)
-Before the cleanup scripts, sweep the roam global inbox (=~/org/roam/inbox.org=) for items that belong to this project, so any imported tasks get linted and ride the wrap commit. Delegate to [[file:inbox-zero.org][inbox-zero.org]] for the claimed set.
+Before the cleanup scripts, sweep the roam global inbox (=~/org/roam/inbox.org=) for items that belong to this project, so any imported tasks get linted and ride the wrap commit. Delegate to [[file:inbox.org][inbox.org]] roam mode for the claimed set.
#+begin_src bash
[ -f "$HOME/org/roam/inbox.org" ] && grep -cE '^\*\* ' "$HOME/org/roam/inbox.org" || true
#+end_src
-Skip-fast when nothing matches: if the roam clone isn't on this machine, or no item is prefixed for this project, this is a silent no-op. When claimed items exist, run inbox-zero's Phase B–C (file each into =todo.org=, then remove them from the shared inbox in a separate roam commit). Report the total count and how many appeared related to this project, per inbox-zero's scan-summary rule.
+Skip-fast when nothing matches: if the roam clone isn't on this machine, or no item is prefixed for this project, this is a silent no-op. When claimed items exist, run roam mode's Phase B–D (file each into =todo.org=, then remove them from the shared inbox and let =roam-sync= commit + push the edit). Report the total count and how many appeared related to this project, per roam mode's scan-summary rule.
*** Hygiene pass
@@ -121,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
@@ -135,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
@@ -170,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
@@ -204,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 the =process-inbox.org= workflow 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 \
@@ -213,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 process-inbox.org 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' \
@@ -222,11 +291,37 @@ 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 =process-inbox.org=, 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.
+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)
@@ -406,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
@@ -424,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:
-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."
+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
+
+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)
@@ -444,6 +561,8 @@ Include:
Tone: warm but professional. No emoji unless Craig has explicitly requested. Acknowledge effort when session was long or difficult.
+End on a clear signoff: the *last* line of the valediction is always =session wrapped.= on its own line (lowercase, with the period, nothing after it). It's the unmistakable end-of-session marker, so don't trail it with another sentence. This is the last user-facing output — Step 6's teardown is silent.
+
Example:
#+begin_example
That's a wrap. Today we restructured the entire claude-templates
@@ -456,8 +575,49 @@ from earlier) and archsetup's layout-navigate tests. Both are
ratio-local uncommitted state.
Good session. Talk tomorrow.
+
+session wrapped.
#+end_example
+** Step 6: Session teardown (mode-dependent)
+
+The last action of the wrap, and only after Step 4's commit + push is verified and the Step 5 valediction is composed. The teardown itself happens when this response ends (via the =Stop= hook), so the valediction always renders first. Act by the mode resolved up front:
+
+*** No-teardown mode
+
+Do nothing. The buffer, the =aiv-<project>= tmux session, and =claude= all stay up so the summary stays readable. The wrap is complete.
+
+*** Teardown mode (default)
+
+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")"
+#+end_src
+
+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:
+
+#+begin_src bash
+emacsclient -e '(cj/ai-term-live-count)'
+#+end_src
+
+- *Count > 1* — another ai-term session is alive. ABORT the shutdown. List the other live =aiv-*= sessions, drop *no* sentinel, and tell Craig in the valediction that it fell back to a normal wrap (no poweroff, no teardown). This gate is the load-bearing safety of the whole feature.
+- *Count = 1* — this session is the only one. Drop the shutdown sentinel:
+
+ #+begin_src bash
+ touch "/tmp/ai-wrap-shutdown-$(basename "$PWD")"
+ #+end_src
+
+ The =Stop= hook fires =cj/ai-term-shutdown-countdown= when this response ends: it re-checks the gate, runs an abort-able 10→1 countdown in the Emacs echo area (=C-g= cancels), then =sudo shutdown now=. Shutdown supersedes teardown — do *not* also drop the teardown sentinel.
+
+If =emacsclient= isn't resolvable or the daemon is down, the gate can't run — abort the shutdown, fall back to a normal wrap, and say so. Don't power off on an unverifiable gate.
+
* Common Mistakes to Avoid
1. *Skipping Step 1 (Summary)* — the file becomes the record; an empty Summary makes it hard to scan at catch-up
@@ -469,7 +629,8 @@ Good session. Talk tomorrow.
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
@@ -479,19 +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)