aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.ai/notes.org6
-rw-r--r--.ai/protocols.org4
-rwxr-xr-x.ai/scripts/capture-guard91
-rwxr-xr-x.ai/scripts/flashcard-to-anki.py26
-rw-r--r--.ai/scripts/tests/capture-guard.bats130
-rw-r--r--.ai/scripts/tests/test_flashcard_to_anki.py31
-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/workflows/INDEX.org15
-rw-r--r--.ai/workflows/broadcast.org4
-rw-r--r--.ai/workflows/create-workflow.org8
-rw-r--r--.ai/workflows/inbox-zero.org111
-rw-r--r--.ai/workflows/inbox.org499
-rw-r--r--.ai/workflows/monitor-inbox.org122
-rw-r--r--.ai/workflows/open-tasks.org25
-rw-r--r--.ai/workflows/process-inbox.org220
-rw-r--r--.ai/workflows/startup.org6
-rw-r--r--.ai/workflows/task-audit.org15
-rw-r--r--.ai/workflows/triage-intake.org2
-rw-r--r--.ai/workflows/wrap-it-up.org72
-rw-r--r--.claude/settings.json10
-rw-r--r--Makefile4
-rw-r--r--README.org15
-rw-r--r--claude-rules/cross-project.md2
-rw-r--r--claude-rules/daily-drivers.md49
-rw-r--r--claude-rules/emacs.md6
-rw-r--r--claude-rules/interaction.md4
-rw-r--r--claude-rules/todo-format.md55
-rw-r--r--claude-rules/triggers.md6
-rw-r--r--claude-rules/working-files.md2
-rw-r--r--claude-templates/.ai/protocols.org4
-rwxr-xr-xclaude-templates/.ai/scripts/capture-guard91
-rwxr-xr-xclaude-templates/.ai/scripts/flashcard-to-anki.py26
-rw-r--r--claude-templates/.ai/scripts/tests/capture-guard.bats130
-rw-r--r--claude-templates/.ai/scripts/tests/test_flashcard_to_anki.py31
-rw-r--r--claude-templates/.ai/workflows/INDEX.org15
-rw-r--r--claude-templates/.ai/workflows/broadcast.org4
-rw-r--r--claude-templates/.ai/workflows/create-workflow.org8
-rw-r--r--claude-templates/.ai/workflows/inbox-zero.org111
-rw-r--r--claude-templates/.ai/workflows/inbox.org499
-rw-r--r--claude-templates/.ai/workflows/monitor-inbox.org122
-rw-r--r--claude-templates/.ai/workflows/open-tasks.org25
-rw-r--r--claude-templates/.ai/workflows/process-inbox.org220
-rw-r--r--claude-templates/.ai/workflows/startup.org6
-rw-r--r--claude-templates/.ai/workflows/task-audit.org15
-rw-r--r--claude-templates/.ai/workflows/triage-intake.org2
-rw-r--r--claude-templates/.ai/workflows/wrap-it-up.org72
-rw-r--r--docs/design/2026-06-21-anki-titlefix-proposal.org57
-rw-r--r--docs/design/2026-06-21-apkg-to-orgdrill-buildreq.org68
-rw-r--r--docs/design/2026-06-21-flashcard-stats-refutation-proposal.org57
-rw-r--r--docs/design/2026-06-21-host-identity-guard-proposal.org54
-rw-r--r--docs/design/2026-06-22-inbox-zero-capture-hardening.org39
-rw-r--r--docs/design/2026-06-23-install-lang-claude-md-gap.org31
-rw-r--r--docs/design/2026-06-23-wrap-teardown-shutdown-proposal.org124
-rw-r--r--docs/inbox-workflow-consolidation-spec.org192
-rwxr-xr-xhooks/ai-wrap-teardown.sh69
-rw-r--r--hooks/settings-snippet.json7
-rw-r--r--languages/bash/CLAUDE.md71
-rwxr-xr-xlanguages/bash/claude/hooks/validate-bash.sh66
-rw-r--r--languages/bash/claude/rules/bash-testing.md71
-rw-r--r--languages/bash/claude/rules/bash.md83
-rw-r--r--languages/bash/claude/settings.json68
-rwxr-xr-xlanguages/bash/githooks/pre-commit48
-rw-r--r--languages/bash/gitignore-add.txt4
-rw-r--r--languages/bash/tests/validate-bash.bats96
-rw-r--r--languages/default-CLAUDE.md64
-rwxr-xr-xlanguages/elisp/claude/hooks/validate-el.sh1
-rwxr-xr-xscripts/install-lang.sh19
-rwxr-xr-xscripts/lint.sh3
-rw-r--r--scripts/tests/ai-wrap-teardown-hook.bats101
-rw-r--r--scripts/tests/install-lang.bats47
-rw-r--r--todo.org261
74 files changed, 3950 insertions, 1053 deletions
diff --git a/.ai/notes.org b/.ai/notes.org
index af1d3c5..62eee64 100644
--- a/.ai/notes.org
+++ b/.ai/notes.org
@@ -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.
@@ -78,7 +78,7 @@ Format:
Markers maintained by workflows to record when they last ran. Read by other workflows that gate their behavior on freshness.
-:LAST_AUDIT: 2026-06-15
-:LAST_INBOX_PROCESS: 2026-06-15 (3 items: fix-speedrun proposal filed [#C], 2 pearl acks cleared)
+:LAST_AUDIT: 2026-06-24
+:LAST_INBOX_PROCESS: 2026-06-23 (chime validate-el.sh Phase 2 cd-to-tests fix applied + pushed e5aab19, reply sent; earlier same day: inbox-zero capture-guard, install-lang neutral-default CLAUDE.md, bash bundle filed [#C])
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 da6928f..3048df2 100644
--- a/.ai/protocols.org
+++ b/.ai/protocols.org
@@ -203,7 +203,7 @@ 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.
** Recursive Reads — Honor =.aiignore=
@@ -460,7 +460,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
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/flashcard-to-anki.py b/.ai/scripts/flashcard-to-anki.py
index 7227683..ca4c70b 100755
--- a/.ai/scripts/flashcard-to-anki.py
+++ b/.ai/scripts/flashcard-to-anki.py
@@ -13,9 +13,11 @@ Parses org-drill structure:
text (sans :drill: tag). 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.
+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
@@ -177,7 +179,19 @@ def build(cards: list[tuple[str, str, str]], deck_name: str) -> genanki.Deck:
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 +211,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",
@@ -213,7 +227,7 @@ 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)
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/test_flashcard_to_anki.py b/.ai/scripts/tests/test_flashcard_to_anki.py
index 058b0cd..87008a8 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) ---
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/workflows/INDEX.org b/.ai/workflows/INDEX.org
index 5ae8480..eef81df 100644
--- a/.ai/workflows/INDEX.org
+++ b/.ai/workflows/INDEX.org
@@ -18,8 +18,10 @@ 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"
- =retrospective.org= — post-mortem after a tough session.
- Triggers: "let's do a retrospective", "retrospective time"
@@ -44,12 +46,11 @@ 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")
** Calendar
diff --git a/.ai/workflows/broadcast.org b/.ai/workflows/broadcast.org
index 1be07d2..60e9ed1 100644
--- a/.ai/workflows/broadcast.org
+++ b/.ai/workflows/broadcast.org
@@ -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/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/inbox-zero.org b/.ai/workflows/inbox-zero.org
deleted file mode 100644
index 4da27bd..0000000
--- a/.ai/workflows/inbox-zero.org
+++ /dev/null
@@ -1,111 +0,0 @@
-#+TITLE: Inbox Zero Workflow
-#+AUTHOR: Craig Jennings & Claude
-#+DATE: 2026-06-13
-
-* Overview
-
-Inbox zero means both inboxes that can feed the current project are checked:
-
-1. The project-local =inbox/= directory, which receives handoffs from other projects, scripts, and Craig. This workflow delegates those items to =process-inbox.org=; it does not duplicate that workflow's value gate or disposition mechanics.
-2. The roam global inbox (=~/org/roam/inbox.org=), Craig's cross-project GTD capture: one shared file every project can see. This workflow routes each roam 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: after this workflow runs, the current project's local handoff inbox has been processed and the shared roam inbox no longer contains items explicitly owned by this project.
-
-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 — Process the project-local inbox
-
-1. Check the project-local =inbox/= with =.ai/scripts/inbox-status -q=.
-2. If pending handoffs exist, run [[file:process-inbox.org][process-inbox.org]] 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-inbox.org= 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
-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 C — File each claimed roam 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 D — Reconcile the shared roam 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 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. 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 and no related-unowned roam items → report the total, stop.
-- Roam pull blocked → surface, stop before editing.
-
-* Caller integration
-
-** Startup (read-only nudge)
-
-Startup already checks the project-local =inbox/= via =inbox-status= and processes it through =process-inbox.org= when needed. It also reads =~/org/roam/inbox.org= and produces the roam 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 roam items; 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..5fc855f
--- /dev/null
+++ b/.ai/workflows/inbox.org
@@ -0,0 +1,499 @@
+#+TITLE: Inbox Workflow (Engine)
+#+AUTHOR: Craig Jennings & Claude
+#+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).
+
+*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.
+
+* 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= is empty (clean worktree).
+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 =commits.md= still run). Miss any one and it doesn't self-apply: a shared-asset or convention change needs Craig's decision, so it fails *solo* and routes to the defer-and-stage park (core §2 / core §3); an oversized item fails *quick* and gets filed.
+
+** 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 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).
+
+* 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.
+
+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, and a find waits for Craig's go before any work happens.
+
+** Per cycle
+
+1. Run roam mode's scan (Phase A local check + Phase B roam scan), read-only — no =git pull=. The capture-guard still gates any write: use =capture-guard --wait= (core §5) so a transient capture clears itself; if it's still open after the wait, *defer this cycle's roam reconcile to the next cycle* rather than surfacing — the loop cadence is the retry, and the filed items get swept next time. The rare write hands its git to =roam-sync= (roam Phase D).
+2. *Nothing found* → no inbox summary. One acknowledgement line: =ran at HH:MM, nothing found=. Nothing else. The acknowledge-only-on-empty rule keeps a quiet inbox quiet.
+3. *Items found* → summarize the found items, file them as tasks (roam Phase C), and *append them to a displayed queue* — the harness task list, via =TaskCreate= — so the queue accumulates across cycles. Then ask: "run this batch next?"
+ - *Yes* → launch into implementing the found items, each through the normal disposition ladder (core §3) + verify flow.
+ - *No* → they stay queued for a later go.
+4. *Cross-cycle dedup.* Subsequent cycles add only *newly-found* items to the same displayed queue, never re-surfacing what's already there. Dedup against the queue (the =TaskCreate= list), not against what's already been implemented — a find that was queued-but-not-yet-run must not reappear, and one already filed into =todo.org= is dropped by roam Phase C's status check.
+
+A find is always surfaced and gated on Craig's yes; a quiet inbox produces only the timestamped acknowledgement. =auto inbox zero= is inherently in-session because its execute step waits for a yes.
+
+** 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/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/open-tasks.org b/.ai/workflows/open-tasks.org
index fe782d6..4ba29dd 100644
--- a/.ai/workflows/open-tasks.org
+++ b/.ai/workflows/open-tasks.org
@@ -176,6 +176,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 +232,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 +255,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/process-inbox.org b/.ai/workflows/process-inbox.org
deleted file mode 100644
index 687767e..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, close the parked =**= VERIFY per =todo-format.md= (a top-level VERIFY resolves to =DONE= + =CLOSED:=, not a dated header), 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/startup.org b/.ai/workflows/startup.org
index 7540787..5e8f61e 100644
--- a/.ai/workflows/startup.org
+++ b/.ai/workflows/startup.org
@@ -150,7 +150,7 @@ These calls have no dependencies on each other. Issue them all together in one m
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 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.
+10. =[ -f "$HOME/org/roam/inbox.org" ] && grep -cE '^\*\* ' "$HOME/org/roam/inbox.org" || true= — count items in the roam global inbox (=~/org/roam/inbox.org=), the roam-mode startup nudge. Silent if the roam clone isn't on this machine. Phase C reads the file when the count is non-zero, splits total vs items related to this project, and surfaces the offer (see =inbox.org= roam mode). Read-only; never files at startup.
11. KB surface prep (the read + contribute startup nudges; see =docs/design/2026-06-16-encourage-kb-contribution-spec.org=). Gated on the agent KB clone. Counts =:agent:= nodes, lists up to 5 whose content matches the current project basename (titles only; a few most-recent nodes as a fallback when nothing matches), and resolves the best-practices node path. Read-only; silent when the clone is absent. Phase C surfaces the relevant titles (consult) and the best-practices link (contribute).
#+begin_src bash
@@ -196,7 +196,7 @@ 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.
- *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.
@@ -211,7 +211,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.
-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/task-audit.org b/.ai/workflows/task-audit.org
index 67ce496..94b99da 100644
--- a/.ai/workflows/task-audit.org
+++ b/.ai/workflows/task-audit.org
@@ -84,6 +84,21 @@ For every STALE task, edit it in the main thread:
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 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).
diff --git a/.ai/workflows/triage-intake.org b/.ai/workflows/triage-intake.org
index 7241017..a5a3bda 100644
--- a/.ai/workflows/triage-intake.org
+++ b/.ai/workflows/triage-intake.org
@@ -191,7 +191,7 @@ Running in the live session means MCP auth (Slack, Gmail, Linear) is inherited f
** Preconditions and Close-out
-Auto mode borrows the inbox-monitor gates (=monitor-inbox.org=): 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.
+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
diff --git a/.ai/workflows/wrap-it-up.org b/.ai/workflows/wrap-it-up.org
index b1560eb..5d2cdd2 100644
--- a/.ai/workflows/wrap-it-up.org
+++ b/.ai/workflows/wrap-it-up.org
@@ -4,7 +4,7 @@
* 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.
@@ -25,10 +25,22 @@ The wrap-up is complete when:
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.
+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 1: Finalize the Summary
@@ -94,15 +106,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
@@ -208,7 +220,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 any explicitly-deferred =PROCESSED-*= files before the wrap completes. An inbox that arrived at session start with handoffs from other projects, or that received handoffs mid-session, needs =inbox.org= process mode to run and apply its value-gate dispositions. Wrapping with a dirty inbox silently defers the work to next session and accumulates handoff debt that the sender can't see.
#+begin_src bash
unprocessed=$(find inbox -maxdepth 1 -type f \
@@ -217,7 +229,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: inbox/ has $unprocessed unprocessed item(s). Run inbox.org process mode before wrapping, or explicitly defer each item with a one-line reason in the valediction."
find inbox -maxdepth 1 -type f \
! -name '.gitkeep' \
! -name 'lint-followups.org' \
@@ -230,7 +242,7 @@ If the count is zero or the project has no =inbox/= directory, the check is a si
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.
*** Review-habit health check (surface a slipped daily task-review)
@@ -448,6 +460,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
@@ -460,8 +474,47 @@ 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 succeeded (Exit Criteria 5 — never tear down over unpushed 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.
+
+*** 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
@@ -497,5 +550,8 @@ Before considering wrap-up complete:
- [ ] 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
+- [ ] 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)
diff --git a/.claude/settings.json b/.claude/settings.json
index 5ed33e9..33ed7e6 100644
--- a/.claude/settings.json
+++ b/.claude/settings.json
@@ -48,6 +48,16 @@
}
]
}
+ ],
+ "Stop": [
+ {
+ "hooks": [
+ {
+ "type": "command",
+ "command": "~/.claude/hooks/ai-wrap-teardown.sh"
+ }
+ ]
+ }
]
},
"statusLine": {
diff --git a/Makefile b/Makefile
index 450dc1a..ca73612 100644
--- a/Makefile
+++ b/Makefile
@@ -15,7 +15,7 @@ HOOKS := $(wildcard hooks/*.sh hooks/*.py)
OPTIN_HOOKS := hooks/destructive-bash-confirm.py
DEFAULT_HOOKS := $(filter-out $(OPTIN_HOOKS),$(HOOKS))
CLAUDE_CONFIG := $(wildcard .claude/*.json) $(wildcard .claude/.*.json) $(wildcard .claude/*.sh)
-LANGUAGES := $(notdir $(wildcard languages/*))
+LANGUAGES := $(notdir $(patsubst %/,%,$(wildcard languages/*/)))
TEAMS := $(notdir $(wildcard teams/*))
PDFTOOLS_VENV ?= $(HOME)/.local/venvs/pdftools
@@ -509,7 +509,7 @@ test: ## Run all test suites (pytest + ERT + bats)
echo "ert: $$(basename "$$f")"; \
emacs --batch -q -l ert -l "$$f" -f ert-run-tests-batch-and-exit; \
done
- @set -e; for f in scripts/tests/*.bats .ai/scripts/tests/*.bats; do \
+ @set -e; for f in scripts/tests/*.bats .ai/scripts/tests/*.bats languages/*/tests/*.bats; do \
[ -e "$$f" ] || continue; \
echo "bats: $$(basename "$$f")"; \
bats "$$f"; \
diff --git a/README.org b/README.org
index 067a2a1..f445c26 100644
--- a/README.org
+++ b/README.org
@@ -80,9 +80,18 @@ re-encrypt. See [[file:mcp/README.org][mcp/README.org]] for the full pipeline.
* Available languages
-| Language | Path | Notes |
-|----------+------------------+----------------------------------------------|
-| elisp | =languages/elisp/= | Emacs Lisp — ERT, check-parens, byte-compile |
+| Language | Path | Notes |
+|------------+-------------------------+-----------------------------------------------|
+| bash | =languages/bash/= | Shell, shellcheck validate hook, bats tests |
+|------------+-------------------------+-----------------------------------------------|
+| elisp | =languages/elisp/= | Emacs Lisp, ERT, check-parens, byte-compile |
+|------------+-------------------------+-----------------------------------------------|
+| go | =languages/go/= | Go, gofmt + go vet hook, table-driven tests |
+|------------+-------------------------+-----------------------------------------------|
+| python | =languages/python/= | Python, pytest, coverage-summary |
+|------------+-------------------------+-----------------------------------------------|
+| typescript | =languages/typescript/= | TypeScript, coverage-summary |
+|------------+-------------------------+-----------------------------------------------|
Add more by creating =languages/<name>/= with the same structure.
diff --git a/claude-rules/cross-project.md b/claude-rules/cross-project.md
index ed4a19c..caceec9 100644
--- a/claude-rules/cross-project.md
+++ b/claude-rules/cross-project.md
@@ -35,7 +35,7 @@ Two acceptable outcomes:
```
Output filenames follow `YYYY-MM-DD-HHMM-from-<this-project>-<slug>.<ext>` automatically, so the target's next session sees the source + timestamp at a glance without you having to construct the name. Fall back to `Write`/`Edit` only when the script isn't available (e.g. a freshly-cloned project before the first startup-rsync).
-2. **"Switch projects"** — stop. Let the user reopen Claude in the right cwd.
+2. **"Switch projects"** — stop. Let the user reopen the agent session in the right cwd.
Don't assume which one was meant. Either guess is wrong half the time and the cost of asking once is one short turn.
diff --git a/claude-rules/daily-drivers.md b/claude-rules/daily-drivers.md
new file mode 100644
index 0000000..eeda33f
--- /dev/null
+++ b/claude-rules/daily-drivers.md
@@ -0,0 +1,49 @@
+# Daily-Driver Machines
+
+Applies to: `**/*`
+
+Craig runs exactly two daily-driver machines: **ratio** and **velox**. They are
+kept in sync, and an important change made on one usually needs to reach the
+other.
+
+## The Rule
+
+When you make or notice a change that is **machine-level and important** —
+dotfiles, installed tooling, a synced repo's clone or timer setup, a global
+config, a systemd unit, a credential, a one-time bootstrap step — consider
+whether the *other* daily driver needs the same change, and flag it. Don't
+assume a change made on the current machine is live everywhere.
+
+This is a prompt to think, not a script to run. The agent can't reach the other
+machine; the point is to surface "the other daily driver may need this too" at
+the moment the change lands, so it doesn't silently drift to one box.
+
+## How the sync actually happens
+
+The mechanism depends on what changed:
+
+- **A tracked repo** (rulesets, dotfiles, a project) — the other machine just
+ needs a `git pull` (and, for rulesets, a `make install` to relink anything
+ new). Most changes are this.
+- **Dotfiles** — ride the dotfiles repo; the other machine picks them up on its
+ next stow/pull.
+- **A one-time setup** — a new repo clone, a new systemd timer, a freshly
+ installed tool, a credential — has to be done by hand on each machine. These
+ are the ones that silently drift, because nothing carries them automatically.
+
+When the change is the one-time kind, say so explicitly: name the manual step
+the other machine still needs.
+
+## Knowing which machine you're on
+
+`uname -n` returns the hostname (`ratio` or `velox`). Use it when a reminder is
+machine-specific ("on ratio, you still need to …") so the note is actionable
+rather than abstract.
+
+## Current open instance
+
+The org-roam knowledge-base clone — `git@cjennings.net:roam.git` — plus its
+`roam-sync` systemd timer is confirmed set up on **velox**. It still needs
+verifying (clone + timer) on **ratio**. This is the last piece before the
+"memory sync across machines" work closes (tracked in the rulesets `todo.org`).
+Clear this line once ratio is confirmed.
diff --git a/claude-rules/emacs.md b/claude-rules/emacs.md
index 702b40e..ae4f7cb 100644
--- a/claude-rules/emacs.md
+++ b/claude-rules/emacs.md
@@ -27,3 +27,9 @@ This re-evaluates the file and redefines its `defun`s live. For straight functio
3. Verify: for visual changes, screenshot and read it (the `screenshot.py` tool under `.ai/scripts/` can capture an app off-screen on a headless output); for behavior, eval or exercise it.
This replaces the quit → relaunch → re-find-and-load-files cycle for most edits. A real restart stays the gold standard for a guaranteed-clean state — anything touching `:config`, load order, or when in doubt.
+
+## Don't edit on disk a file the daemon is capturing into
+
+The reload caveats above are about pushing changes *into* the daemon. The inverse hazard: a tool that edits a file *on disk* while the daemon has an indirect buffer cloned from it. org-capture works through such a buffer, and a disk write (a hand edit, a `git pull` that fast-forwards the file, a `sed`/Write) reverts the base buffer underneath the capture. The capture is left on stale state, can no longer finalize with `C-c C-c`, and a freshly-typed item can be lost or written back against post-edit content. Orphaned `CAPTURE-*` buffers piling up as Craig retries is the visible symptom.
+
+The roam inbox (`~/org/roam/inbox.org`) is the live case — Craig captures into it constantly, and the inbox workflow's roam mode (Phase D) edits it. Before a disk write to a file the daemon may be capturing into, check first: `.ai/scripts/capture-guard <file>` exits non-zero (and names the buffer) when a live capture is cloned from `<file>`, and exits 0 — safe — when there's no capture or no reachable Emacs. Same principle as the reload rule, one layer out: leave the daemon's live buffers authoritative rather than yanking the file from under them.
diff --git a/claude-rules/interaction.md b/claude-rules/interaction.md
index 1fd0334..9148b4f 100644
--- a/claude-rules/interaction.md
+++ b/claude-rules/interaction.md
@@ -2,11 +2,11 @@
Applies to: `**/*`
-How Claude communicates with the user during a session — choice prompts, status updates, decision points.
+How the agent communicates with the user during a session — choice prompts, status updates, decision points.
## No Popup Menus for Choices
-When Claude needs the user to pick between options, **do not** use the AskUserQuestion popup. Present the options inline in chat as a numbered list and ask the user to reply with a number.
+When the agent needs the user to pick between options, **do not** use the AskUserQuestion popup. Present the options inline in chat as a numbered list and ask the user to reply with a number.
**Why:** The popup menu UI sits at the bottom of the chat window and obscures the chat content directly above it — exactly the area the user needs to read to make the choice. Inline numbered options keep the question, the surrounding context, and the proposed text all visible in the same scrollback.
diff --git a/claude-rules/todo-format.md b/claude-rules/todo-format.md
index b9e93bb..5c34966 100644
--- a/claude-rules/todo-format.md
+++ b/claude-rules/todo-format.md
@@ -24,8 +24,8 @@ guessing:
The section is mandatory. A `todo.org` without it leaves `[#A]` and the tags
undefined, so task-audit can't enforce a vocabulary, task-review can't grade
-against agreed semantics, and process-inbox can't file new tasks correctly
-(its Phase B.1 already checks for this scheme). Each project defines the
+against agreed semantics, and the inbox workflow can't file new tasks correctly
+(its priority-scheme check already gates on this scheme). Each project defines the
scheme its own way; the floor is that priorities and tags are both spelled
out under the header.
@@ -263,3 +263,54 @@ are noise that pollute his `cj:` greps.
** DOING [#A] Kostya's contract :admin:kostya:
*** 2026-05-15 Fri @ 14:00:00 -0500 Kostya basis — part-time, 20 hr/week
Nerses confirmed 5/15 13:30 CDT: Kostya runs at 20 hr/week part-time, mirroring Vrezh's structure. Plugged into Exhibit A § 2 of the contract draft.
+
+## Cross-Project Dependency Tags
+
+A task can be blocked by work that has to happen in a *different project* — a rulesets task that can't finish until `.emacs.d` ships a companion function, say. Left unmarked, two things go wrong: the what's-next workflow keeps recommending the blocked task even though it can't move, and the blocker sits at low priority in the other project, so the dependency stalls silently.
+
+Two plain org tags track it, one on each side, so neither the waiter nor the blocker loses sight of the dependency: `:blocked:` on the task that's waiting, `:blocker:` on the task that owes the work. The cross-project detail — which project, what work — goes in the task *body*, not a property. This applies to *any* project pair; the convention here and the surfacing in `open-tasks.org` live in the shared rule + workflow layer, not in one project.
+
+### `:blocked:` — the waiting side
+
+The task that can't proceed carries `:blocked:`. Its body names the project it's waiting on and what that project owes:
+
+```
+** DOING [#B] Wrap-teardown feature :feature:blocked:
+Blocked on emacsd: needs the ai-term companion functions
+(cj/ai-term-quit, -live-count) before the manual validation can run.
+```
+
+`open-tasks.org` reads the `:blocked:` tag to pull the task out of the "do this next" cascade (it can't be worked) and surface it in a dedicated "Blocked on other projects" section, reading the body for which project to name and nudge.
+
+### Registering with the blocker — the reciprocal handoff (required)
+
+Setting `:blocked:` is not complete until the blocking project knows it's blocking. The moment you mark a task `:blocked:` on another project's work, send that project a dependency handoff:
+
+```
+inbox-send <project> --text "Blocking dependency: <this-project>'s task \"<task>\" is blocked on you — it needs <what>. It stays blocked until this lands. Tag the owning task :blocker: on your side so it surfaces as priority work."
+```
+
+This is what closes the gap: without it, the blocker only learns it's blocking by accident. The handoff lands in `<project>`'s `inbox/` and its normal inbox processing tags the work (below). A `:blocked:` task with no matching reciprocal handoff is half-done — the dependency is invisible to the one project that can clear it. Skip the send only when the blocker demonstrably already tracks the work (e.g. it's the same handoff that spawned the dependency); it dedups against an existing task either way.
+
+### `:blocker:` — the blocking side
+
+When a project processes a blocking-dependency handoff (inbox process mode), it tags the owning task `:blocker:` and names the requesting project in the body:
+
+```
+** TODO [#B] ai-term wrap-teardown companion :feature:blocker:
+Rulesets' wrap-teardown feature is blocked on this — it needs the three
+ai-term functions. Surface first so rulesets unblocks.
+```
+
+The blocking task does *not* carry `:blocked:` — it isn't blocked, it's the blocker. `:blocker:` is a priority signal: `open-tasks.org` surfaces a `:blocker:` task *first*, since clearing it unblocks work in another project, so a dependency that would otherwise stall at low priority gets pulled forward. This is the "surface dependencies first" half of the design.
+
+### Resolving the dependency
+
+When the blocker delivers:
+
+1. The blocking project completes its `:blocker:` task, drops the `:blocker:` tag, and notifies the waiter (`inbox-send <waiter> --text "Delivered: <what> — you're unblocked."`).
+2. The waiting project drops the `:blocked:` tag; the task is workable again. Either side noticing the delivery can lift its own tag — the notification just makes it prompt.
+
+### Not the same as VERIFY
+
+`:blocked:` marks "waiting on another *project's* work"; `VERIFY` marks "waiting on Craig's input." If Craig's input is what's needed, it's a VERIFY, not `:blocked:`. And `:blocker:` only ever sits on the project that *owes* the work, never the one waiting.
diff --git a/claude-rules/triggers.md b/claude-rules/triggers.md
index e45e660..a8d5e77 100644
--- a/claude-rules/triggers.md
+++ b/claude-rules/triggers.md
@@ -8,13 +8,13 @@ Trigger phrases the user can say from any session to invoke a cross-project acti
Synonyms: "Launch X", "Open project X", "Switch to project X".
-**Action:** run the `ai` script (the Claude Code session launcher, installed at `~/.local/bin/ai`) in single-project mode targeting the named project.
+**Action:** run the `ai` script (the agent session launcher, installed at `~/.local/bin/ai`) in single-project mode targeting the named project.
```
ai <project-path>
```
-The `ai` script handles tmux session creation, window placement, and the per-project Claude opening line — see `~/code/rulesets/claude-templates/bin/ai` for the canonical source.
+The `ai` script handles tmux session creation, window placement, and the per-project agent opening line — see `~/code/rulesets/claude-templates/bin/ai` for the canonical source.
**Resolving X.** Match against project basenames discoverable by `ai` — directories under `~/code/`, `~/projects/`, and `~/.emacs.d` that contain `.ai/protocols.org`.
@@ -22,7 +22,7 @@ The `ai` script handles tmux session creation, window placement, and the per-pro
- No match → list all available basenames, ask which to launch.
- Multiple partial matches (X is a substring of two or more candidates) → list the matching basenames, ask which.
-Do not guess. The cost of asking once is one short turn; launching the wrong project is a wrong-context Claude session that has to be killed and restarted.
+Do not guess. The cost of asking once is one short turn; launching the wrong project is a wrong-context agent session that has to be killed and restarted.
## Why a separate file
diff --git a/claude-rules/working-files.md b/claude-rules/working-files.md
index 9a72702..2432268 100644
--- a/claude-rules/working-files.md
+++ b/claude-rules/working-files.md
@@ -120,7 +120,7 @@ When the task is marked done:
- *Inbox content* — `inbox/` and `daily-prep/` follow their own
conventions (dated filenames, processed and moved on cadence).
-## Implementation Note for Claude Sessions
+## Implementation Note for Agent Sessions
When the user starts a new task that's going to produce file artifacts:
diff --git a/claude-templates/.ai/protocols.org b/claude-templates/.ai/protocols.org
index da6928f..3048df2 100644
--- a/claude-templates/.ai/protocols.org
+++ b/claude-templates/.ai/protocols.org
@@ -203,7 +203,7 @@ 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.
** Recursive Reads — Honor =.aiignore=
@@ -460,7 +460,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
diff --git a/claude-templates/.ai/scripts/capture-guard b/claude-templates/.ai/scripts/capture-guard
new file mode 100755
index 0000000..6c01f2f
--- /dev/null
+++ b/claude-templates/.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/claude-templates/.ai/scripts/flashcard-to-anki.py b/claude-templates/.ai/scripts/flashcard-to-anki.py
index 7227683..ca4c70b 100755
--- a/claude-templates/.ai/scripts/flashcard-to-anki.py
+++ b/claude-templates/.ai/scripts/flashcard-to-anki.py
@@ -13,9 +13,11 @@ Parses org-drill structure:
text (sans :drill: tag). 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.
+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
@@ -177,7 +179,19 @@ def build(cards: list[tuple[str, str, str]], deck_name: str) -> genanki.Deck:
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 +211,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",
@@ -213,7 +227,7 @@ 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)
diff --git a/claude-templates/.ai/scripts/tests/capture-guard.bats b/claude-templates/.ai/scripts/tests/capture-guard.bats
new file mode 100644
index 0000000..31632a4
--- /dev/null
+++ b/claude-templates/.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/claude-templates/.ai/scripts/tests/test_flashcard_to_anki.py b/claude-templates/.ai/scripts/tests/test_flashcard_to_anki.py
index 058b0cd..87008a8 100644
--- a/claude-templates/.ai/scripts/tests/test_flashcard_to_anki.py
+++ b/claude-templates/.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) ---
diff --git a/claude-templates/.ai/workflows/INDEX.org b/claude-templates/.ai/workflows/INDEX.org
index 5ae8480..eef81df 100644
--- a/claude-templates/.ai/workflows/INDEX.org
+++ b/claude-templates/.ai/workflows/INDEX.org
@@ -18,8 +18,10 @@ 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"
- =retrospective.org= — post-mortem after a tough session.
- Triggers: "let's do a retrospective", "retrospective time"
@@ -44,12 +46,11 @@ 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")
** Calendar
diff --git a/claude-templates/.ai/workflows/broadcast.org b/claude-templates/.ai/workflows/broadcast.org
index 1be07d2..60e9ed1 100644
--- a/claude-templates/.ai/workflows/broadcast.org
+++ b/claude-templates/.ai/workflows/broadcast.org
@@ -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/claude-templates/.ai/workflows/create-workflow.org b/claude-templates/.ai/workflows/create-workflow.org
index 6060df1..393fce5 100644
--- a/claude-templates/.ai/workflows/create-workflow.org
+++ b/claude-templates/.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/claude-templates/.ai/workflows/inbox-zero.org b/claude-templates/.ai/workflows/inbox-zero.org
deleted file mode 100644
index 4da27bd..0000000
--- a/claude-templates/.ai/workflows/inbox-zero.org
+++ /dev/null
@@ -1,111 +0,0 @@
-#+TITLE: Inbox Zero Workflow
-#+AUTHOR: Craig Jennings & Claude
-#+DATE: 2026-06-13
-
-* Overview
-
-Inbox zero means both inboxes that can feed the current project are checked:
-
-1. The project-local =inbox/= directory, which receives handoffs from other projects, scripts, and Craig. This workflow delegates those items to =process-inbox.org=; it does not duplicate that workflow's value gate or disposition mechanics.
-2. The roam global inbox (=~/org/roam/inbox.org=), Craig's cross-project GTD capture: one shared file every project can see. This workflow routes each roam 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: after this workflow runs, the current project's local handoff inbox has been processed and the shared roam inbox no longer contains items explicitly owned by this project.
-
-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 — Process the project-local inbox
-
-1. Check the project-local =inbox/= with =.ai/scripts/inbox-status -q=.
-2. If pending handoffs exist, run [[file:process-inbox.org][process-inbox.org]] 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-inbox.org= 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
-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 C — File each claimed roam 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 D — Reconcile the shared roam 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 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. 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 and no related-unowned roam items → report the total, stop.
-- Roam pull blocked → surface, stop before editing.
-
-* Caller integration
-
-** Startup (read-only nudge)
-
-Startup already checks the project-local =inbox/= via =inbox-status= and processes it through =process-inbox.org= when needed. It also reads =~/org/roam/inbox.org= and produces the roam 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 roam items; 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/claude-templates/.ai/workflows/inbox.org b/claude-templates/.ai/workflows/inbox.org
new file mode 100644
index 0000000..5fc855f
--- /dev/null
+++ b/claude-templates/.ai/workflows/inbox.org
@@ -0,0 +1,499 @@
+#+TITLE: Inbox Workflow (Engine)
+#+AUTHOR: Craig Jennings & Claude
+#+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).
+
+*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.
+
+* 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= is empty (clean worktree).
+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 =commits.md= still run). Miss any one and it doesn't self-apply: a shared-asset or convention change needs Craig's decision, so it fails *solo* and routes to the defer-and-stage park (core §2 / core §3); an oversized item fails *quick* and gets filed.
+
+** 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 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).
+
+* 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.
+
+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, and a find waits for Craig's go before any work happens.
+
+** Per cycle
+
+1. Run roam mode's scan (Phase A local check + Phase B roam scan), read-only — no =git pull=. The capture-guard still gates any write: use =capture-guard --wait= (core §5) so a transient capture clears itself; if it's still open after the wait, *defer this cycle's roam reconcile to the next cycle* rather than surfacing — the loop cadence is the retry, and the filed items get swept next time. The rare write hands its git to =roam-sync= (roam Phase D).
+2. *Nothing found* → no inbox summary. One acknowledgement line: =ran at HH:MM, nothing found=. Nothing else. The acknowledge-only-on-empty rule keeps a quiet inbox quiet.
+3. *Items found* → summarize the found items, file them as tasks (roam Phase C), and *append them to a displayed queue* — the harness task list, via =TaskCreate= — so the queue accumulates across cycles. Then ask: "run this batch next?"
+ - *Yes* → launch into implementing the found items, each through the normal disposition ladder (core §3) + verify flow.
+ - *No* → they stay queued for a later go.
+4. *Cross-cycle dedup.* Subsequent cycles add only *newly-found* items to the same displayed queue, never re-surfacing what's already there. Dedup against the queue (the =TaskCreate= list), not against what's already been implemented — a find that was queued-but-not-yet-run must not reappear, and one already filed into =todo.org= is dropped by roam Phase C's status check.
+
+A find is always surfaced and gated on Craig's yes; a quiet inbox produces only the timestamped acknowledgement. =auto inbox zero= is inherently in-session because its execute step waits for a yes.
+
+** 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/claude-templates/.ai/workflows/monitor-inbox.org b/claude-templates/.ai/workflows/monitor-inbox.org
deleted file mode 100644
index 4912a2b..0000000
--- a/claude-templates/.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/claude-templates/.ai/workflows/open-tasks.org b/claude-templates/.ai/workflows/open-tasks.org
index fe782d6..4ba29dd 100644
--- a/claude-templates/.ai/workflows/open-tasks.org
+++ b/claude-templates/.ai/workflows/open-tasks.org
@@ -176,6 +176,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 +232,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 +255,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/claude-templates/.ai/workflows/process-inbox.org b/claude-templates/.ai/workflows/process-inbox.org
deleted file mode 100644
index 687767e..0000000
--- a/claude-templates/.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, close the parked =**= VERIFY per =todo-format.md= (a top-level VERIFY resolves to =DONE= + =CLOSED:=, not a dated header), 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/claude-templates/.ai/workflows/startup.org b/claude-templates/.ai/workflows/startup.org
index 7540787..5e8f61e 100644
--- a/claude-templates/.ai/workflows/startup.org
+++ b/claude-templates/.ai/workflows/startup.org
@@ -150,7 +150,7 @@ These calls have no dependencies on each other. Issue them all together in one m
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 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.
+10. =[ -f "$HOME/org/roam/inbox.org" ] && grep -cE '^\*\* ' "$HOME/org/roam/inbox.org" || true= — count items in the roam global inbox (=~/org/roam/inbox.org=), the roam-mode startup nudge. Silent if the roam clone isn't on this machine. Phase C reads the file when the count is non-zero, splits total vs items related to this project, and surfaces the offer (see =inbox.org= roam mode). Read-only; never files at startup.
11. KB surface prep (the read + contribute startup nudges; see =docs/design/2026-06-16-encourage-kb-contribution-spec.org=). Gated on the agent KB clone. Counts =:agent:= nodes, lists up to 5 whose content matches the current project basename (titles only; a few most-recent nodes as a fallback when nothing matches), and resolves the best-practices node path. Read-only; silent when the clone is absent. Phase C surfaces the relevant titles (consult) and the best-practices link (contribute).
#+begin_src bash
@@ -196,7 +196,7 @@ 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.
- *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.
@@ -211,7 +211,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.
-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/claude-templates/.ai/workflows/task-audit.org b/claude-templates/.ai/workflows/task-audit.org
index 67ce496..94b99da 100644
--- a/claude-templates/.ai/workflows/task-audit.org
+++ b/claude-templates/.ai/workflows/task-audit.org
@@ -84,6 +84,21 @@ For every STALE task, edit it in the main thread:
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 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).
diff --git a/claude-templates/.ai/workflows/triage-intake.org b/claude-templates/.ai/workflows/triage-intake.org
index 7241017..a5a3bda 100644
--- a/claude-templates/.ai/workflows/triage-intake.org
+++ b/claude-templates/.ai/workflows/triage-intake.org
@@ -191,7 +191,7 @@ Running in the live session means MCP auth (Slack, Gmail, Linear) is inherited f
** Preconditions and Close-out
-Auto mode borrows the inbox-monitor gates (=monitor-inbox.org=): 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.
+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
diff --git a/claude-templates/.ai/workflows/wrap-it-up.org b/claude-templates/.ai/workflows/wrap-it-up.org
index b1560eb..5d2cdd2 100644
--- a/claude-templates/.ai/workflows/wrap-it-up.org
+++ b/claude-templates/.ai/workflows/wrap-it-up.org
@@ -4,7 +4,7 @@
* 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.
@@ -25,10 +25,22 @@ The wrap-up is complete when:
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.
+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 1: Finalize the Summary
@@ -94,15 +106,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
@@ -208,7 +220,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 any explicitly-deferred =PROCESSED-*= files before the wrap completes. An inbox that arrived at session start with handoffs from other projects, or that received handoffs mid-session, needs =inbox.org= process mode to run and apply its value-gate dispositions. Wrapping with a dirty inbox silently defers the work to next session and accumulates handoff debt that the sender can't see.
#+begin_src bash
unprocessed=$(find inbox -maxdepth 1 -type f \
@@ -217,7 +229,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: inbox/ has $unprocessed unprocessed item(s). Run inbox.org process mode before wrapping, or explicitly defer each item with a one-line reason in the valediction."
find inbox -maxdepth 1 -type f \
! -name '.gitkeep' \
! -name 'lint-followups.org' \
@@ -230,7 +242,7 @@ If the count is zero or the project has no =inbox/= directory, the check is a si
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.
*** Review-habit health check (surface a slipped daily task-review)
@@ -448,6 +460,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
@@ -460,8 +474,47 @@ 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 succeeded (Exit Criteria 5 — never tear down over unpushed 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.
+
+*** 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
@@ -497,5 +550,8 @@ Before considering wrap-up complete:
- [ ] 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
+- [ ] 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)
diff --git a/docs/design/2026-06-21-anki-titlefix-proposal.org b/docs/design/2026-06-21-anki-titlefix-proposal.org
new file mode 100644
index 0000000..08b8c13
--- /dev/null
+++ b/docs/design/2026-06-21-anki-titlefix-proposal.org
@@ -0,0 +1,57 @@
+#+TITLE: Proposal — flashcard-to-anki.py deck name should come from #+TITLE
+
+From: home session, 2026-06-21. Two attached files are the edited
+canonical scripts (flashcard-to-anki.py + its test). Applied locally in
+home as a stopgap; this is the durable proposal for the rulesets
+canonical. Please reconcile and re-sync.
+
+* The bug (longstanding)
+
+flashcard-to-anki.py's default_deck_name returned input_path.stem (the
+filename), so every deck generated through flashcard-sync (which passes no
+--deck) was named after the file, e.g. "personal-drill" / "health-drill"
+/ "kit", not the curated #+TITLE.
+
+flashcard-review.org already documents the intended behavior: "The
+#+TITLE line drives ... the Anki deck name on the phone" and "derives the
+Anki deck ID from the deck name." The script never matched the doc.
+deepsat only looked correct because its first run used an explicit
+--deck "DeepSat Flashcards".
+
+* The fix
+
+default_deck_name(input_path, org_text) now scans for a #+TITLE: line
+(case-insensitive, surrounding whitespace trimmed) and returns it; falls
+back to input_path.stem when there's no non-empty #+TITLE. main() passes
+the already-read org_text. Help text + module docstring updated.
+
+TDD: the two old deck-name tests asserted the buggy basename behavior —
+rewrote them. New tests cover title-driven naming, trimming,
+case-insensitive #+title, basename fallback (no title), and basename
+fallback (blank title). Full file: 29 pass.
+
+No companion script changes needed: flashcard-sync passes no --deck so it
+picks up the new default automatically, and flashcard-stats.py already
+reads #+TITLE. flashcard-review.org needs no change (the script now
+matches what it already says).
+
+* Migration caveat (worth a line in the doc if you want)
+
+Deck ID derives from the deck name, so this fix changes the ID for any
+deck previously generated without --deck. On next import those land as
+new decks; the old basename-named decks keep their review history and
+must be deleted by hand. The workflow's existing "Stable-ID caveat"
+already covers the mechanics. In home this affected personal-drill,
+health-drill, kit (regenerated this session as Personal / Health / KIT,
+with titles also stripped of "Flashcards"/"Drill" per Craig). deepsat is
+unaffected (already title-named).
+
+* Related idea (separate, not in these files) — apkg → org-drill converter
+
+deepsat-fundamentals.apkg (100-card DeepSat subset, made once with
+--deck "DeepSat Fundamentals") has no saved .org source anywhere. Craig
+wants an apkg → org-drill converter — the inverse of flashcard-to-anki.py
+— to recover orphaned decks and pull phone-authored cards back into the
+org source-of-truth. Flagging as a candidate rulesets tool alongside the
+flashcard-* family; deepsat-fundamentals is the concrete first use case.
+Not built yet; raising for the backlog.
diff --git a/docs/design/2026-06-21-apkg-to-orgdrill-buildreq.org b/docs/design/2026-06-21-apkg-to-orgdrill-buildreq.org
new file mode 100644
index 0000000..37a866f
--- /dev/null
+++ b/docs/design/2026-06-21-apkg-to-orgdrill-buildreq.org
@@ -0,0 +1,68 @@
+#+TITLE: Build request — apkg → org-drill converter (inverse of flashcard-to-anki.py)
+
+From: home session, 2026-06-21. Craig wants this built (backlogged, not
+urgent). Standalone build request — the earlier anki-title-fix-proposal
+only mentioned it in passing; this is the real ask.
+
+* Why
+
+The flashcard pipeline is one-directional (org-drill → apkg). Decks
+authored or curated on the phone, and orphaned apkgs whose .org source
+was never saved, can't get back into the org source-of-truth. Concrete
+case: deepsat-fundamentals.apkg — a 100-card DeepSat subset generated
+once with --deck "DeepSat Fundamentals" — has no .org source anywhere on
+ratio, velox, or in work git history. The converter recovers it and makes
+phone → org round-tripping possible.
+
+* What — contract (inverse of flashcard-to-anki.py)
+
+Input: an Anki =.apkg= (a zip containing collection.anki2 / .anki21
+sqlite, plus a media blob).
+Output: an org-drill =.org= file in the house canonical shape that
+flashcard-stats.py / flashcard-to-anki.py already agree on.
+
+Mapping (mirror flashcard-to-anki.py's parse/build):
+- Deck name (from the apkg) → =#+TITLE:=.
+- Each note → =** <Front> :drill:= with the Back as the body.
+- Card tag → top-level =* Section= grouping (inverse of section_to_tag;
+ cards sharing a tag collect under one section; the slug won't round-trip
+ to the exact original section title, so this is best-effort — emit the
+ tag as the section heading and let a human retitle).
+- Back HTML → org: convert =<br>= back to newlines; unescape
+ =&amp;/&lt;/&gt;=; strip the =<hr id="answer">= the card template adds
+ (the Back field itself shouldn't contain it, but guard anyway).
+- Generate a fresh =:ID:= UUID per card in a =:PROPERTIES:= drawer so the
+ output is immediately org-drill-valid and round-trips back through
+ flashcard-to-anki.py. (Note: GUIDs in flashcard-to-anki.py are derived
+ from the front text, not the :ID:, so a regenerated apkg still matches
+ existing phone cards by front — call that out in the docstring.)
+
+Edge cases to cover in tests (Normal/Boundary/Error):
+- Multiple decks in one apkg (emit one file per deck, or error asking for
+ a deck filter — pick one and document it).
+- Notes with multiple fields / non-basic note types (the pipeline only
+ models Front/Back — skip or warn on others, don't silently drop).
+- HTML entities, embedded =<br>=, and any =Source:= footer surviving
+ round-trip.
+- Empty back; media references (flag, since org side has no media path).
+- collection.anki2 vs .anki21 schema differences.
+
+* Where it lives
+
+Rulesets-owned, beside the flashcard-* family
+(=claude-templates/.ai/scripts/=): suggest =anki-to-flashcard.py= (or
+=apkg-to-orgdrill.py= — your naming call). Add tests under
+=scripts/tests/=. A new file can't be built downstream — home/.ai/scripts/
+is wiped to match the template by the startup =--delete= rsync — so this
+has to be built in the rulesets canonical. PEP 723 uv-run script like its
+sibling; genanki isn't needed for reading (stdlib =zipfile= + =sqlite3=
+suffice), so it has no runtime deps.
+
+* Acceptance
+
+Round-trip test: take a known org-drill source, run it through
+flashcard-to-anki.py, run the result back through this converter, and
+assert the cards (front/back/section) match the original (modulo
+regenerated :ID:s and best-effort section titles). Plus: run it on the
+real deepsat-fundamentals.apkg and hand the recovered .org back so its
+source can be filed (work project).
diff --git a/docs/design/2026-06-21-flashcard-stats-refutation-proposal.org b/docs/design/2026-06-21-flashcard-stats-refutation-proposal.org
new file mode 100644
index 0000000..bbbe175
--- /dev/null
+++ b/docs/design/2026-06-21-flashcard-stats-refutation-proposal.org
@@ -0,0 +1,57 @@
+#+TITLE: Proposal — flashcard-stats.py refutation / claim-prompt mode
+
+From: home session, 2026-06-21. Backlog, not urgent. Relates to the
+refutation-drill deck being built in the home project.
+
+* Problem
+
+A new card family doesn't fit the linter: the *refutation / claim-prompt*
+card. Its heading is a bare false claim ("The earth is flat.") and its
+body is the rebuttal. This is a legit org-drill simple card (org-drill is
+happy), but flashcard-stats.py — built for Q&A decks — trips two BLOCKING
+checks on every such card, both false positives:
+
+- *non-prompt heading*: a declarative claim has no '?' and no
+ imperative verb, so it reads as "topic-as-heading not yet rewritten".
+ But for this family the declarative claim IS the intended prompt.
+- *answer leakage*: the claim's words necessarily reappear in the
+ refutation, so front/back overlap is high. But the answer (the rebuttal)
+ is not given away by the claim — there's no actual leakage.
+
+Concrete: the home refutation-drill.org (6 cards) reports 6 non-prompt
+headings + 1 leakage WARN, so flashcard-sync's gate blocks it entirely.
+The deck currently has to be generated with the flashcard-to-anki.py
+override, losing the safety net.
+
+* Proposed fix
+
+A per-deck opt-in marker that switches the two checks off for that file
+only. Two options (your call):
+
+1. A file-level keyword: =#+DECK_KIND: refutation= near the top. When
+ present, flashcard-stats skips the non-prompt-heading check and the
+ answer-leakage check for the whole file (keeps the others:
+ missing-:ID:, *** Answer sub-headers, duplicate fronts, the
+ non-blocking NOTEs).
+2. A per-card tag: cards tagged =:claim:= (alongside =:drill:=) are
+ exempted from those two checks individually.
+
+Option 1 is simpler and matches how this deck works (the whole file is
+one family). Option 2 is finer-grained if a deck ever mixes families.
+
+Either way: document the new card family in flashcard-review.org (a
+"Refutation / claim-prompt cards" subsection under Canonical Card Shape —
+heading is the bare claim, body is snap-response + backups + named-fallacy
++ restate, Source footer), and note that flashcard-sync then works
+normally on these decks.
+
+* Affected files
+- =flashcard-stats.py= — the check skip + (option 1) keyword parse / (option 2) tag check.
+- =flashcard-review.org= — document the family + the marker.
+- =flashcard-to-anki.py= / =flashcard-sync= — no change needed (they don't gate on heading form).
+- Tests: add cases for a refutation-marked file passing despite declarative headings + claim/answer overlap.
+
+* Companion context
+The home deck's card format and the org-drill-fine / Anki-linter-fights
+finding are written up in home:refutation-drill-sources.org (Tooling
+note). The override command is documented there too.
diff --git a/docs/design/2026-06-21-host-identity-guard-proposal.org b/docs/design/2026-06-21-host-identity-guard-proposal.org
new file mode 100644
index 0000000..f389825
--- /dev/null
+++ b/docs/design/2026-06-21-host-identity-guard-proposal.org
@@ -0,0 +1,54 @@
+#+TITLE: From archsetup — hardcoded machine identity in CLAUDE.md (consider fleet-wide)
+#+DATE: 2026-06-21
+
+* What we did
+
+Built a Super+F Dirvish popup in the archsetup/dotfiles + .emacs.d projects,
+modeled on the existing Super+Shift+N org-capture popup (launcher script names an
+emacsclient frame, Hyprland window rules float it, an Emacs command runs in the
+frame and q closes it). Cross-project: dotfiles half committed from archsetup,
+Emacs half handed off to .emacs.d's inbox.
+
+* The bug it surfaced
+
+While stowing on this machine, =make stow hyprland= pulled the *velox* host tier,
+and =uname -n= returned =velox=. But archsetup's CLAUDE.md asserted, as a fixed
+fact, "This machine is **ratio**." It was simply wrong on velox — a stale
+identity baked into a per-project doc that travels to every machine via git.
+
+I'd been reasoning from that line all session (e.g. "the touchpad-auto reminder
+is velox-only, and we're on ratio, so skip it") — exactly backwards. A hardcoded
+"this machine is X" in a synced/tracked project file is a latent trap on any
+multi-machine setup: the file is identical on every host, so the claim is false
+on every host but one.
+
+* The fix (this project)
+
+Replaced the fixed identity with a runtime instruction. The attached CLAUDE.md
+now reads, in the Notes section:
+
+ Never assume which machine this is — always run =uname -n= to find the hostname
+ (the =hostname= binary is absent, so =uname -n= is the source of truth;
+ =uname -r= is the kernel release, not the host). The fleet is ratio
+ (workstation) and velox (laptop), both Hyprland (Wayland)...
+
+(Craig initially said =uname -r=; that's the kernel release. =uname -n= is the
+nodename/hostname, which is what the stow host-tier logic already keys on.)
+
+* Why this is a rulesets concern
+
+This isn't an archsetup-only quirk. Any project whose CLAUDE.md / notes get
+synced or cloned across machines can hardcode environment identity — current
+host, current OS, "the laptop", an IP, a display name — and be wrong everywhere
+the doc lands but the origin. rulesets governs how every project's CLAUDE.md and
+rules are shaped, so it's the right layer to consider a general guard:
+
+- A rule (claude-rules) along the lines of: don't assert mutable
+ environment/host identity as a fixed fact in a tracked/synced project file;
+ derive it at runtime (=uname -n= for host, etc.) and name the command.
+- Possibly a startup or codify-time lint that flags "this machine is <name>" /
+ "the current host is" style claims in CLAUDE.md.
+
+Sending the edited CLAUDE.md (attached separately) plus this note so the rulesets
+session can decide whether to codify the broader pattern. Proposal, not a
+directive — your value gate applies.
diff --git a/docs/design/2026-06-22-inbox-zero-capture-hardening.org b/docs/design/2026-06-22-inbox-zero-capture-hardening.org
new file mode 100644
index 0000000..69acf94
--- /dev/null
+++ b/docs/design/2026-06-22-inbox-zero-capture-hardening.org
@@ -0,0 +1,39 @@
+#+TITLE: inbox-zero Phase D wedges live org-capture sessions on the roam inbox
+
+* The bug
+
+Phase D of =inbox-zero.org= removes claimed items from =~/org/roam/inbox.org= by editing the file on disk (Edit / sed / Write). That collides with any live org-capture session Craig has open against the same file.
+
+org-capture works through an *indirect buffer* cloned from the target file. When the inbox-zero disk write lands and Emacs reverts the main =inbox.org= buffer underneath, the indirect capture buffers are left pointing at stale state and wedge — they can no longer finalize cleanly with =C-c C-c=. The visible symptom is org-capture failing, and one or more orphaned =CAPTURE-*inbox.org= / =CAPTURE-N-inbox.org= buffers piling up as Craig retries.
+
+Hit live on 2026-06-22 during a home-session inbox-zero: I filed three home items, wrote =inbox.org= on disk, and Craig's open capture wedged, leaving two orphaned =CAPTURE-inbox.org= buffers. No data was lost that time (the orphaned buffers held only existing file content, not a freshly-typed item), but that was luck — had he typed an item into the capture before it wedged, finalizing the stale buffer afterward would have written it back against the post-edit file and could have clobbered the routing or a foreign item.
+
+* Why it's worth fixing in the canonical
+
+=inbox-zero.org= is a rulesets-owned synced workflow that runs in every project (startup nudge, wrap-up sub-step, on demand), and the roam inbox is the single shared file all of them edit. Craig edits that same file live in Emacs and captures into it constantly. So the collision window recurs in every project, every session — not a home-only quirk. A local fix in home gets reverted by the next template sync, so the durable fix has to land in rulesets.
+
+* Proposed fix (recommended: guard before the disk write)
+
+Add a pre-edit guard to Phase D, before removing any claimed items:
+
+1. If Emacs is reachable (=emacsclient -e t= succeeds), check for live capture buffers targeting the roam inbox:
+
+ #+begin_src bash
+ emacsclient -e '(mapcar #(quote buffer-name)
+ (seq-filter (lambda (b) (string-match-p "CAPTURE.*inbox" (buffer-name b)))
+ (buffer-list)))' 2>/dev/null
+ #+end_src
+
+2. If any =CAPTURE-*inbox*= buffer exists, *stop before editing* and surface it: "You have a live org-capture session open against the roam inbox — finalize (=C-c C-c=) or abort (=C-c C-k=) it before I route items, otherwise the edit will wedge the capture." Resume Phase D only once it's clear. This mirrors the existing pull-before-edit / surface-and-stop discipline already in Phase D.
+
+3. Independently, when Emacs has =inbox.org= open and *unmodified* (the common case, no live capture), the disk edit is benign — Emacs reverts a clean buffer without complaint. Optionally trigger an explicit =revert-buffer= via emacsclient afterward so the buffer is immediately consistent rather than lazily on next focus.
+
+* Alternative (heavier): do the removal through Emacs when it's running
+
+Instead of editing on disk, when Emacs is reachable, perform the claimed-item removal inside the running daemon (find the buffer, delete the items, save), and fall back to the disk edit only when Emacs isn't running. This keeps Emacs's buffer authoritative and sidesteps the disk/buffer divergence entirely. It's more code and more failure surface for arbitrary item removal, so I'd lean on the guard above unless you want the stronger guarantee.
+
+* Note for whoever builds it
+
+The =emacs.md= rule already covers "don't make Craig restart Emacs; push changes into the running daemon." This is the same principle one layer out: don't edit a file *on disk* that the running daemon is actively editing/capturing into. Worth a line in =emacs.md= too, or at least a cross-reference from inbox-zero Phase D.
+
+Origin: home, 2026-06-22.
diff --git a/docs/design/2026-06-23-install-lang-claude-md-gap.org b/docs/design/2026-06-23-install-lang-claude-md-gap.org
new file mode 100644
index 0000000..cf16256
--- /dev/null
+++ b/docs/design/2026-06-23-install-lang-claude-md-gap.org
@@ -0,0 +1,31 @@
+#+TITLE: install-lang CLAUDE.md gap — non-elisp projects get a wrong or missing CLAUDE.md
+#+DATE: 2026-06-23
+
+Surfaced while running the archangel .ai/ conversion you sent (the 2026-06-20 handoff). archangel is a bash project — 437 =.sh= files; the only =.el=/=.py= in the tree are under =work/x86_64/airootfs/= (archiso staging, not source). Per your handoff I installed both elisp and python bundles. The result exposed two coupled issues that block CLAUDE.md consistency across projects.
+
+* The two findings
+
+1. *Only the elisp bundle ships a CLAUDE.md template.* =languages/elisp/CLAUDE.md= exists; =languages/python/=, =go/=, =typescript/= ship none. =install-lang.sh= guards on =[ -f "$SRC/CLAUDE.md" ]=, so a bundle without a template silently contributes nothing — no line printed, no file seeded.
+
+2. *No shell/bash bundle exists* (only elisp, go, python, typescript). archangel and archsetup are bash projects with no bundle that fits.
+
+* The consequence
+
+- Install python (or go/ts) alone → project gets *no* CLAUDE.md.
+- Install elisp + anything → project gets the elisp stub, whose first line is "Elisp project." Because install-lang seeds CLAUDE.md only on first install and never overwrites without FORCE=1, install order doesn't matter — the elisp template is the only one available, so it always wins.
+- Net: archangel, a bash project, ended up with a CLAUDE.md headed "Elisp project." An inaccurate CLAUDE.md is worse than none — it mislabels the project for every future session.
+
+* Proposals (rulesets' call)
+
+1. *Add a shell/bash language bundle.* This is the real gap for archangel/archsetup and any other shell-heavy project.
+2. *Give every bundle its own CLAUDE.md template*, or ship a language-neutral default so install-lang always seeds an accurate (or at least non-misleading) header. A stub that says "<LANG> project — customize this" is only safe when the bundle actually matches the language.
+3. *Consider the multi-bundle case* — when a project installs more than one bundle, the CLAUDE.md "Project" line shouldn't hardcode a single language picked by which template happened to exist.
+
+* Companion files to reconcile
+
+- =scripts/install-lang.sh= — the seed-on-first-install / no-overwrite logic (sections 3 and 3b) is correct; the gap is the missing templates and missing bash bundle, not this logic.
+- =languages/elisp/CLAUDE.md= — the only template today; pattern to replicate per language.
+
+* What archangel did locally (stopgap)
+
+Installed both bundles as you asked; the generic =.claude/rules/= and gitignore hygiene are the real gain there. I flagged the elisp-stub mismatch to Craig and offered to hand-write archangel's CLAUDE.md as a bash ISO-build project. That local fix doesn't address the cross-project pattern — hence this note.
diff --git a/docs/design/2026-06-23-wrap-teardown-shutdown-proposal.org b/docs/design/2026-06-23-wrap-teardown-shutdown-proposal.org
new file mode 100644
index 0000000..a47aa2d
--- /dev/null
+++ b/docs/design/2026-06-23-wrap-teardown-shutdown-proposal.org
@@ -0,0 +1,124 @@
+#+TITLE: Proposal — wrap-it-up teardown + "wrap it up and shutdown" variant
+
+* Source
+
+Raised by Craig in a home-project session, 2026-06-23, after talking the
+design through. Two related additions to =wrap-it-up.org=. Both touch the
+Claude-session lifecycle (workflow + hook + the =ai-term= buffer/tmux pair),
+so they're rulesets — with one companion piece that has to live in
+=.emacs.d/modules/ai-term.el= (flagged below). Originally floated as an
+archsetup task; archsetup owns the Hyprland/waybar layer, not the
+Claude-session lifecycle, so it was re-routed here.
+
+* Architecture this depends on (so the design is grounded)
+
+- =ai-term.el= (=.emacs.d=) is the in-Emacs launcher: a vertical-split vterm
+ buffer running a tmux session named =aiv-<project-basename>= (prefix
+ =aiv-=). Layering: =claude= process → tmux session =aiv-<proj>= → Emacs
+ vterm buffer.
+- Killing the tmux session takes the =claude= process with it, so "quit
+ Claude Code" is a *consequence* of killing =aiv-<proj>=, not a separate
+ step.
+- Hooks already exist under =~/.claude/hooks/= (e.g. =session-clear-resume.sh=,
+ =precompact-priorities.sh=) — the teardown trigger fits that pattern.
+- =sudo= is =NOPASSWD: ALL= on Craig's machines, so =sudo shutdown now= runs
+ unattended.
+
+* Item 1 — wrap-up also removes the buffer, quits Claude, removes the tmux session
+
+Recommend: yes, with one structural rule — the wrap-up runs *inside* the
+things it tears down, so teardown is self-terminating and must be the last,
+decoupled action, or the valediction may not flush before the session dies.
+
+Design:
+1. *Teardown lives in =ai-term.el=* (companion, see below): one function
+ =cj/ai-term-quit= that kills the =aiv-<proj>= tmux session (takes =claude=
+ with it), kills the vterm buffer, and restores the saved window geometry —
+ =ai-term.el= already owns the buffer↔session pair and the geometry logic.
+2. *Trigger from a Stop / SessionEnd hook, not inline.* Wrap-up does all its
+ git/archive work, delivers the valediction, then drops a sentinel (flag
+ file, e.g. =/tmp/ai-wrap-teardown-<session>=). The hook fires when Claude
+ finishes, sees the sentinel, and runs =cj/ai-term-quit= via =emacsclient=.
+ Decoupling guarantees the valediction lands before the session dies.
+3. *Gate on commit+push verified* — never tear down before the session record
+ is pushed (wrap-up's existing Step 4 / validation checklist already
+ enforces push; teardown is strictly after it).
+4. *Phrase split — teardown IS the default* (Craig's decision 2026-06-23).
+ Bare "wrap it up" does the full wrap AND removes the buffer/session/quits —
+ that's his typical case. The non-destructive variant gets the explicit
+ qualifier: "wrap it up with summary" summarizes + commits + pushes +
+ archives but keeps the buffer (no teardown), so the summary stays readable.
+ So: "wrap it up" → teardown; "wrap it up with summary" → no teardown;
+ "wrap it up and shutdown" → wrap + poweroff (supersedes teardown, Item 2).
+
+* Item 2 — "wrap it up and shutdown": 10-count then =sudo shutdown now=
+
+Recommend: yes, but the safety gate is load-bearing and the countdown has a
+rendering gotcha.
+
+Design:
+1. *"Only ai-term left" = hard blocking precondition*, evaluated BEFORE the
+ countdown. Count live sessions (=tmux ls | grep '^aiv-'= or
+ =pgrep -fc claude=). If more than this one is alive, ABORT the shutdown,
+ list what's running, and fall back to a normal wrap. Never power the box
+ off out from under another active Claude session. This is the most
+ important part of the item.
+2. *The live countdown can't run through Claude's tool output.* The Bash tool
+ buffers stdout until the command returns, so a =for i in $(seq 10 -1 1);
+ sleep 1= prints all ten at once at the end, not one per second. It has to
+ run detached or in Emacs:
+ - tty writer: =for i in $(seq 10 -1 1); do printf '\rShutting down in %2d…'
+ "$i" > /dev/tty; sleep 1; done; sudo shutdown now= (backgrounded), or
+ - an Emacs =run-at-time= timer printing 10→1 in the echo area, then
+ =(shell-command "sudo shutdown now")=.
+3. *Make it abort-able* (Ctrl-C / keypress cancels). A 10-second countdown's
+ whole purpose is a last-chance window; a non-cancellable one is just a
+ delay.
+4. *Sequencing.* "...and shutdown" supersedes Item 1's teardown — if the box
+ is powering off, killing the buffer/session first is moot. Wrap (commit +
+ push + archive) → session-count gate → countdown → =shutdown=.
+
+Packaging: a small rulesets bin script (e.g. =ai-wrap-shutdown=) doing the
+gate → abort-able countdown → shutdown, invoked by the workflow after the wrap
+commit/push. Countdown either in that script (tty) or handed to Emacs.
+
+* Companion — required change in =.emacs.d/modules/ai-term.el=
+
+Item 1's teardown function =cj/ai-term-quit= must live in =ai-term.el= (it
+owns =aiv-<proj>= session naming, the vterm buffer, and geometry restore).
+rulesets owns the workflow + hook + bin script that *call* it; =.emacs.d= owns
+the function itself. Spec for the =.emacs.d= side:
+
+- =cj/ai-term-quit (&optional project)= — resolve the =aiv-<basename>= session
+ for the current/!named project, =tmux kill-session= it, =kill-buffer= the
+ associated vterm buffer, restore saved geometry. Idempotent / no-op if the
+ session or buffer is already gone. Callable from =emacsclient -e= so the
+ Stop hook can invoke it headlessly.
+- (Optional) a count helper =cj/ai-term-live-count= so the Item-2 gate can ask
+ Emacs how many ai-term sessions are live, as an alternative to =tmux ls= /
+ =pgrep=.
+
+When rulesets builds the workflow/hook side, route this companion to
+=.emacs.d= (inbox-send) so the two land together.
+
+* Open decisions for Craig
+
+- Phrase set: DECIDED (2026-06-23) — "wrap it up" tears down (default);
+ "wrap it up with summary" wraps without teardown; "wrap it up and shutdown"
+ is the poweroff variant. Remaining nuance: confirm the exact non-destructive
+ qualifier wording is "with summary" (vs e.g. "and summarize").
+- Countdown home: tty-writer bin script vs Emacs timer. (Emacs timer reads
+ cleaner inside the vterm and is trivially abort-able.)
+- Session-count mechanism for the gate: =tmux ls=, =pgrep claude=, or
+ =cj/ai-term-live-count=.
+
+* Verify
+
+- Item 1: bare "wrap it up" → valediction renders fully, THEN buffer +
+ =aiv-<proj>= session + claude all gone, geometry restored; "wrap it up with
+ summary" → wrap completes but the buffer stays intact (no teardown).
+- Item 2 gate: with a second =aiv-*= session alive, "wrap it up and shutdown"
+ refuses, lists the other session, and does a normal wrap (no poweroff).
+- Item 2 happy path: sole session → 10→1 renders one-per-second, is
+ cancellable, then =shutdown= fires.
+- Teardown never runs before commit+push is verified.
diff --git a/docs/inbox-workflow-consolidation-spec.org b/docs/inbox-workflow-consolidation-spec.org
new file mode 100644
index 0000000..2e158b6
--- /dev/null
+++ b/docs/inbox-workflow-consolidation-spec.org
@@ -0,0 +1,192 @@
+#+TITLE: Inbox Workflow Consolidation — Spec
+#+AUTHOR: Craig Jennings & Claude
+#+DATE: 2026-06-23
+#+TODO: TODO | DONE SUPERSEDED CANCELLED
+
+* Metadata
+| Status | Ready — review incorporated (Codex, 2026-06-23) |
+|----------+-------------------------------------------------------------|
+| Owner | Craig |
+|----------+-------------------------------------------------------------|
+| Reviewer | Craig |
+|----------+-------------------------------------------------------------|
+| Related | [[file:../todo.org][Consolidate inbox/triage workflows + scheduled inbox check]] |
+|----------+-------------------------------------------------------------|
+
+* Summary
+
+Four inbox-named workflows (=inbox-zero=, =process-inbox=, =monitor-inbox=, plus the startup/wrap-up nudges) circle the same disposition logic across three different surfaces. This spec consolidates them into one =inbox= engine with explicit modes, keeps =triage-intake= (external accounts) and =no-approvals= (session mode) separate, and adds an interactive recurring roam check (=auto inbox zero=). The fully-unattended cron pass is named but deferred to vNext.
+
+* Problem / Context
+
+"Too many inbox related workflows" (Craig, roam capture 2026-06-23). The word "inbox" is overloaded onto three genuinely different surfaces, and the workflows that serve them have grown to circle the same logic:
+
+- *Project-local =inbox/= dir* (handoffs from other projects/scripts/Craig) → =process-inbox.org= owns the value gate and disposition; =monitor-inbox.org= is a thin cadence layer on top of it ("loop process-inbox every 15 min + act-vs-file + reply discipline").
+- *Global roam inbox* (=~/org/roam/inbox.org=, GTD capture) → =inbox-zero.org=, whose Phase A already *calls* =process-inbox= for the local dir before doing the roam-routing part.
+- *External accounts* (email / calendar / PRs) → =triage-intake.org= + six source plugins.
+
+So a reader (or a non-Claude agent) facing "deal with my inbox" has to know which of four files to invoke, and the shared concepts — the three-question value gate, the skeptical review, the implement/fold/file/defer/reject disposition, the reply-to-sender discipline, the capture-guard before a roam write, the priority-scheme check before filing — are spread across and cross-referenced between them. The duplication is real (=monitor-inbox= and =inbox-zero= both lean on =process-inbox='s machinery) and the count is the symptom Craig named.
+
+A second gap surfaced in the same capture: there's no documented way to run a *recurring* inbox check, and Craig wants a keyword trigger for it. v1 answers this with an interactive in-session loop (=auto inbox zero=); a fully-unattended cron pass that fires while Craig is away is a larger contract (mutation safety, surfacing-when-away, cross-run state) and is deferred.
+
+* Goals and Non-Goals
+
+** Goals
+- One engine is the single entry point for the inbox surfaces, with the shared value-gate / disposition / reply / capture-guard / priority-scheme logic living in exactly one place.
+- Mode selection is unambiguous from the trigger phrase and the caller (startup, wrap-up, on-demand).
+- Every existing trigger phrase still works, routing to the right mode — no relearning.
+- A documented interactive recurring check (=auto inbox zero=, =/loop=-based). The fully-unattended cron pass (=/schedule=) is vNext, not v1.
+- INDEX.org, protocols.org, and the startup/wrap-up callers reconciled to the new shape with no dangling references.
+- No behavior regression: the value gate, disposition rules, capture-guard, and reply discipline behave exactly as today.
+
+** Non-Goals
+- *Not* merging =triage-intake.org=. External-account triage ("what's new across my email/cal/PRs") is a different domain from "my inbox dirs"; keeping it distinct is correct, not redundancy.
+- *Not* merging =no-approvals.org=. It's a session mode, not an inbox workflow (it's referenced by the monitor cadence, not part of it).
+- *Not* changing value-gate semantics or disposition rules. This is a structural merge, behavior-preserving.
+- *Not* the domain-aware whole-roam-inbox routing (still deferred, unchanged).
+- *Not* the agent-neutral language sweep over these files — that is the parked half of the agent-source task and runs *after* this merge, over fewer files.
+- *Not* renaming =CLAUDE.md=, =.claude/=, or other structural paths.
+
+** Scope tiers
+- v1: merge =process-inbox= + =monitor-inbox= + =inbox-zero= into one =inbox.org= engine with =process= / =monitor= / =roam= modes; preserve all trigger phrases; reconcile INDEX + protocols + startup + wrap-up; add the interactive =auto inbox zero= recurring check (=/loop=).
+- Out of scope: =triage-intake= merge, =no-approvals= merge, domain-aware roam routing, the agent-neutrality sweep.
+- vNext (log to todo.org): the fully-unattended =/schedule= cron pass — needs its own contract (read-only vs may-mutate =todo.org= / =~/org/roam/inbox.org=, how a find surfaces when Craig is away, how dedup state survives across runs, auth/session constraints); a later umbrella unifying =triage-intake='s "what's new" with the inbox engine; the agent-neutrality pass over the consolidated =inbox.org=.
+
+* Design
+
+The consolidation produces one engine file, =inbox.org=, structured as a shared core plus three thin modes. The core holds every concept that today is duplicated or cross-referenced: the three-question value gate, the skeptical review (with the cross-project battery for shared-asset proposals), the disposition ladder (implement-now / fold / file / defer / reject-by-source / park), the reply-to-sender discipline, the capture-guard before any roam-inbox disk write, and the priority-scheme check before filing. A mode is a short front section that says which surface it reads, how it enters and exits, and which core steps it runs.
+
+*Two altitudes.*
+
+For the *user*: the trigger phrase picks the mode, and the phrases are unchanged. "process inbox" / "handle the inbox" → process mode (the local =inbox/= dir). "monitor the inbox" / "watch the inbox" → monitor mode (process mode on a loop, with the act-vs-file and reply discipline and the clean-tree/green-suite gates). "inbox zero" / "process the roam inbox" → roam mode (route the global roam inbox by =<project>:= prefix, sweep empties, capture-guard the write). Startup calls process mode for the local dir and the read-only roam nudge; wrap-up calls process mode then the roam sweep.
+
+For the *implementer*: =inbox.org= is one file. The core sections are written once. Each mode is a section that references core steps by name rather than restating them ("run the value gate (core §X) on each item", "guard and reconcile the roam write (core §Y)"). The old three files are deleted; their content is absorbed, not copied. The =triage-intake= engine and its plugins are untouched and keep their own namespace.
+
+*Routing and callers.* protocols.org's terminology section and the startup workflow's INDEX-driven routing both key off trigger phrases, so the phrase→mode map is the contract. Each caller that today names =process-inbox.org= / =monitor-inbox.org= / =inbox-zero.org= (startup Phase C, wrap-up Step 3, protocols, INDEX) is repointed at =inbox.org= and the relevant mode. INDEX gets one entry for =inbox.org= listing every trigger phrase, grouped by mode.
+
+*Auto inbox zero (the scheduled mode).* The trigger phrase =auto inbox zero= starts a recurring roam-mode pass. On invocation the engine *asks Craig for the interval* (e.g. 30 min, 2 hours), then drives the loop with =/loop <interval>= running roam mode. It's in-session and interactive by design — each cycle reports, and a find waits for Craig's go before any work happens. Per cycle:
+
+- *Nothing found* → no inbox summary. A single acknowledgement line: ran at =HH:MM=, nothing found. Nothing else.
+- *Items found* → summarize the found items, file them as tasks, and *append them to a displayed queue* (the harness task list, =TaskCreate=) so the queue accumulates across cycles. Then ask: "run this batch next?" If Craig says yes, the engine launches into implementing the found items (each through the normal disposition + verify flow); if no, they stay queued for a later go. Subsequent cycles add only newly-found items to the same displayed queue, never re-surfacing what's already there.
+
+The acknowledge-only-on-empty rule keeps a quiet inbox quiet — no noise when there's nothing to do — while a find is always surfaced and gated on Craig's yes. =auto inbox zero= is the interactive =/loop= shape because its execute step waits for a yes, so it is inherently in-session.
+
+A fully-unattended =/schedule= cron pass (firing while Craig is away) is a different contract and is *vNext, not v1*: 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 persists between runs that don't share a session, and what session/auth context a cron run carries. v1 ships only the interactive loop; the unattended contract is logged to =todo.org= for its own design pass.
+
+* Alternatives Considered
+
+** Option A — One engine with modes (chosen)
+- Good, because it cuts four inbox-named files to one and puts the shared logic in a single authoritative place, which is exactly the "too many" complaint.
+- Good, because every trigger phrase can re-home to a mode with no user relearning.
+- Bad, because =inbox.org= becomes a larger file with internal mode branching.
+- Neutral, because =triage-intake= and =no-approvals= stay separate either way.
+
+** Option B — Keep three files, extract a shared include
+- Good, because the diffs are smaller and the per-surface entry points stay familiar.
+- Bad, because it does not reduce the file count — Craig's actual complaint is the number of files, and this keeps three plus adds an include.
+- Neutral, because the dedup of logic happens, just without the count reduction.
+
+** Option C — Merge only process-inbox + monitor-inbox, leave inbox-zero
+- Good, because it fixes the tightest, least-ambiguous redundancy (monitor is literally a loop over process) at the lowest risk.
+- Bad, because roam vs local stays two files; the consolidation is partial (4→3, not 4→2).
+- Neutral, because it could be a first phase of Option A rather than a competing end state.
+
+** Option D — Do nothing, just document which file is which
+- Good, because zero risk to load-bearing synced workflows.
+- Bad, because it doesn't reduce the count at all; the complaint stands.
+
+* Decisions [4/4]
+
+** DONE Engine shape — one file with modes vs partial merge
+- Context: Option A (one =inbox.org=, 4→1) maximally addresses "too many" but is the biggest single change to load-bearing synced files. Option C (merge the process/monitor pair only, 4→3) is lower-risk and could be A's first phase.
+- Decision: We will build Option A — one =inbox.org= engine with =process= / =monitor= / =roam= modes. (Craig, 2026-06-23.)
+- Consequences: easier discovery and one home for the logic; harder single-file size and a bigger, higher-blast-radius diff, mitigated by the shared-core + thin-mode structure and the plugin-namespace escape hatch for a mode that wants depth.
+
+** DONE Trigger-phrase routing — preserve all existing phrases
+- Context: protocols + startup route by phrase; users have these in muscle memory.
+- Decision: We will keep every existing trigger phrase, re-homing each to its mode on the one engine, adding only the new =auto inbox zero= phrase. (Craig, 2026-06-23 — accepted as recommended.)
+- Consequences: easier — no relearning, no broken muscle memory; harder — the engine must document a longer phrase→mode table and guard against collisions.
+
+** DONE triage-intake stays separate
+- Context: external-account triage is a different surface; folding it in would re-bloat the engine.
+- Decision: We will leave =triage-intake.org= and its plugins untouched, out of this consolidation. (Craig, 2026-06-23 — accepted as recommended.)
+- Consequences: easier — smaller, coherent inbox engine; harder — two "what's arriving" entry points remain (inbox engine vs triage-intake), documented so the boundary is clear.
+
+** DONE Scheduled-check mechanism + behavior + keyword
+- Context: Craig wants a recurring inbox check with a keyword, an interactive find-then-execute flow, and a running queue.
+- Decision: The trigger phrase is =auto inbox zero=. On invocation it asks Craig for the interval, then runs roam-mode on =/loop <interval>=. Empty cycle → one acknowledgement line (ran at HH:MM, nothing found), no inbox summary. Find → summarize, file as tasks, append to the displayed task queue (=TaskCreate=), and ask "run this batch next?"; on yes, implement the found items; subsequent cycles append only new finds to the same queue. =/schedule= stays available for a fully-unattended pass. (Craig, 2026-06-23.)
+- Consequences: easier — a quiet inbox stays quiet, a find is always gated on a yes, and the queue is one accumulating view; harder — the loop must dedup against already-queued items so it doesn't re-surface them, and the in-session =/loop= shape means the unattended case still needs =/schedule=.
+
+* Review findings [2/2]
+
+** DONE Fully unattended scheduled behavior is not specified :blocking:
+Disposition: accepted via the narrow option. v1 ships only the interactive =auto inbox zero= (=/loop=); the fully-unattended =/schedule= pass is deferred to vNext with its open contract questions named (read-only vs may-mutate, surface-when-away, cross-run dedup state, auth/session). Folded into Summary, Goals, Problem/Context, Scope tiers (vNext), and the Design "Auto inbox zero" subsection; the vNext contract is logged to todo.org. This sequences the unattended pass rather than dropping it, preserving Decision 4's intent.
+The Summary and Goals promise a scheduled unattended inbox check with trigger keywords, but the concrete =auto inbox zero= design is intentionally interactive: it asks for an interval, runs =/loop <interval>= in the live session, and waits for Craig before executing found work. The only unattended behavior is the sentence that =/schedule= remains available for a fully-unattended cloud-cron pass. That leaves an implementer to invent the actual scheduled contract: trigger phrase(s), whether the pass is read-only or may mutate =todo.org= / =~/org/roam/inbox.org=, how findings are surfaced when Craig is away, how dedup state survives across runs, and what auth/session constraints apply. Add a distinct =/schedule= subsection and acceptance criteria for the fully unattended mode, or narrow the Summary/Goals to say v1 ships only the interactive =/loop= mode and log the unattended cron shape as vNext. (blocking)
+
+** DONE Stale-reference verification relies on a checker that does not check workflow links
+Phase 2 and the Risks section rely on the workflow-integrity / INDEX-drift check as the backstop for missed references to =process-inbox.org=, =monitor-inbox.org=, and =inbox-zero.org=. Current =scripts/workflow-integrity.py= checks INDEX coverage, script references, plugin parentage, orientation sections, and duplicate trigger phrases; it does not validate arbitrary =[[file:...org]]= workflow links or prose references in workflows/protocols/rules. That means a deleted-workflow link in =startup.org=, =wrap-it-up.org=, or =protocols.org= can survive the named checker. Keep the grep requirement, but make it an explicit acceptance item with the exact scope: at minimum =rg 'process-inbox|monitor-inbox|inbox-zero' claude-templates/.ai .ai claude-rules= after caller rewrites, allowing only intentional historical/spec/todo mentions. Optionally extend =workflow-integrity.py= to validate local workflow links, but do not imply it already catches this class. (non-blocking)
+
+Disposition: accepted. Added the exact grep as an acceptance item and a Phase 2 step, reworded the Risks "missed caller reference" dodge and the Dev-tooling readiness line so the integrity checker is no longer implied to validate workflow links, and noted the optional =workflow-integrity.py= extension as not-required-for-v1.
+
+* Implementation phases
+
+** Phase 1 — Author the inbox engine
+Write =inbox.org= (canonical =claude-templates/.ai/workflows/=): the shared core (value gate, skeptical review, disposition ladder, reply discipline, capture-guard, priority-scheme check) plus the three mode sections, absorbing the content of the three source files. No caller changes and no deletions yet — the tree still works with the old files in place, the new engine sits alongside for review.
+
+** Phase 2 — Reconcile callers and retire the old files
+Repoint INDEX.org (one =inbox.org= entry, phrases grouped by mode), protocols.org terminology, startup.org Phase C, and wrap-it-up.org Step 3 at =inbox.org= + mode. Delete =process-inbox.org=, =monitor-inbox.org=, =inbox-zero.org=. Grep for stale references — =rg 'process-inbox|monitor-inbox|inbox-zero' claude-templates/.ai .ai claude-rules= — and clear every live caller (the integrity checker covers INDEX coverage and trigger duplication, not workflow links). Run the workflow-integrity / INDEX-drift check. Sync the mirror.
+
+** Phase 3 — Auto inbox zero + scheduled check
+Add the =auto inbox zero= mode to =inbox.org=: ask-for-interval, =/loop <interval>= over roam mode, the empty-cycle acknowledgement, and the find → summarize → file → queue → ask-to-execute flow with cross-cycle dedup against the displayed queue. Document the =/schedule= recipe for the fully-unattended pass alongside it.
+
+** Phase 4 — Verify
+Trigger-phrase coverage (every old phrase resolves to a mode), startup + wrap-up dry-run against the new engine, capture-guard still gates the roam write, INDEX drift clean, mirror in sync.
+
+* Acceptance criteria
+- [ ] Every trigger phrase that today routes to =process-inbox= / =monitor-inbox= / =inbox-zero= resolves to a mode of =inbox.org=.
+- [ ] The three old workflow files are deleted and absent from INDEX; the integrity check reports no orphan or stale entry.
+- [ ] After caller rewrites, =rg 'process-inbox|monitor-inbox|inbox-zero' claude-templates/.ai .ai claude-rules= returns only intentional historical / spec / todo mentions — no live caller reference to a deleted file. (The integrity checker validates INDEX coverage, not arbitrary workflow links, so this grep is the real backstop.)
+- [ ] Startup still processes the local inbox and produces the read-only roam nudge; wrap-up still sweeps the project's roam items.
+- [ ] The capture-guard runs before any roam-inbox disk write in the consolidated engine.
+- [ ] The value gate, disposition ladder, and reply-to-sender discipline are present once and unchanged in behavior.
+- [ ] =auto inbox zero= asks for an interval, then runs roam mode on =/loop <interval>=.
+- [ ] An empty auto cycle emits only a timestamped acknowledgement (ran at HH:MM, nothing found) — no inbox summary.
+- [ ] A find summarizes the items, files them as tasks, appends them to the displayed queue, and asks before executing; "yes" runs the batch; later cycles append only newly-found items, never re-surfacing queued ones.
+- [ ] Canonical and mirror copies are in sync (=sync-check.sh=).
+
+* Readiness dimensions
+- Data model & ownership: the engine reads two files it doesn't own (project =inbox/= dir contents, =~/org/roam/inbox.org=) and writes =todo.org= + the roam file. Ownership unchanged from today; the merge moves no data.
+- Errors, empty states & failure: empty inbox → report and stop (preserved per surface); roam pull blocked or dirty → surface and stop, never auto-stash (preserved); live org-capture on the roam file → capture-guard blocks the write (preserved).
+- Security & privacy: N/A because no credentials or sensitive data; the engine moves task text between local files.
+- Observability: the user sees which mode ran and its disposition summary; INDEX drift check surfaces a mis-wired routing.
+- Performance & scale: N/A because the inputs are small text files triaged by hand-scale counts.
+- Reuse & lost opportunities: the whole point — the shared core is written once instead of three times; =triage-intake='s plugin pattern is intentionally not reused here (different surface).
+- Architecture fit & weak points: integration points are INDEX.org, protocols.org terminology, startup Phase C, wrap-up Step 3. Weak point: a missed caller reference to an old filename breaks routing — mitigated by the explicit stale-reference grep (acceptance item), since the integrity check covers INDEX coverage, not workflow links.
+- Config surface: trigger phrases (the phrase→mode table) and the scheduled-check keyword set + cron expression.
+- Documentation plan: the engine file is the doc; INDEX entry updated; protocols terminology updated. No separate user doc needed.
+- Dev tooling: the workflow-integrity check + startup INDEX-drift check cover INDEX coverage and trigger-phrase duplication; they do *not* validate workflow file links, so the stale-reference grep (acceptance item) is a manual step. Optionally extend =workflow-integrity.py= to validate local =[[file:...org]]= workflow links — not required for v1.
+- Rollout, compatibility & rollback: the merge lands via the template sync; =rsync --delete= removes the three retired files from every consuming project on its next startup, and the new =inbox.org= arrives the same pass. Rollback = git revert of the rulesets commit, then the next sync restores the old files. Trigger-phrase preservation is the compatibility guarantee.
+- External APIs & deps: N/A because no external API; =/schedule= and =/loop= are harness features, not deps.
+
+* Risks, Rabbit Holes, and Drawbacks
+- *Missed caller reference.* A lingering mention of =process-inbox.org= / =monitor-inbox.org= / =inbox-zero.org= in a workflow, protocol, skill, or the INDEX would break routing after the files are deleted. Dodge: =rg 'process-inbox|monitor-inbox|inbox-zero' claude-templates/.ai .ai claude-rules= and clear every live caller before deleting. The workflow-integrity checker validates INDEX coverage and trigger-phrase duplication, *not* arbitrary =[[file:...]]= links, so the grep — not the checker — is the real backstop here.
+- *Single-file sprawl.* One engine with three modes risks becoming the wall-of-text the workflows were split to avoid. Dodge: the shared-core + thin-mode structure and the terseness pass; if a mode wants real depth, it can become a =inbox.<mode>.org= plugin under the engine namespace (the same pattern =triage-intake= uses) rather than bloating the core.
+- *Sequencing with the agent-neutrality sweep.* If the neutrality sweep runs first, it edits three files about to be deleted. Dodge: this consolidation lands first by construction (it's why the sweep was parked).
+
+* Review and iteration history
+** 2026-06-23 Tue @ 21:51:51 -0400 — Claude — author
+- What: initial draft.
+- Why: Craig chose to spec the inbox-workflow consolidation before building a load-bearing 3-to-1 merge of synced workflows.
+- Artifacts: docs/inbox-workflow-consolidation-spec.org; todo.org "Consolidate inbox/triage workflows + scheduled inbox check".
+** 2026-06-23 Tue @ 22:05:00 -0400 — Craig — decision-maker
+- What: resolved all four decisions; added the =auto inbox zero= scheduled mode (ask-for-interval, empty-cycle acknowledgement only, find → summarize → file → displayed queue → ask-to-execute, cross-cycle dedup). Status → ready for review.
+- Why: chose Option A (4→1 engine) and specified the recurring-check behavior in full.
+- Artifacts: Decisions [4/4]; Design "Auto inbox zero" subsection.
+** 2026-06-23 Tue @ 22:15:58 -0400 — Codex — reviewer
+- What: spec-review pass rated the spec =Not ready= and added two findings: the fully unattended =/schedule= behavior is not specified, and stale-reference verification leans on a checker that does not validate workflow links.
+- Why: the current design is strong enough for the consolidation and interactive =/loop= mode, but the stated scheduled/unattended goal would force implementers to invent behavior before shipping.
+- Artifacts: Review findings [0/2].
+** 2026-06-23 Tue @ 22:28:00 -0400 — Claude — responder
+- What: both findings accepted and folded. Finding 1 (blocking) resolved by narrowing v1 to the interactive =auto inbox zero= (=/loop=) and deferring the fully-unattended =/schedule= contract to vNext with its open questions named — Summary, Goals, Problem/Context, Scope tiers, and Design updated. Finding 2 resolved by adding the exact stale-reference grep as an acceptance item + Phase 2 step and dropping the over-claim that the integrity checker validates workflow links. Findings [2/2], Decisions [4/4]; scope narrowed (not expanded), so no readiness-rubric rerun needed. Status → Ready.
+- Why: the scheduled/unattended promise outran what Craig actually specced (he detailed the interactive loop); sequencing the cron pass to vNext keeps v1 honest. The checker genuinely doesn't catch stale workflow links.
+- Artifacts: Decisions [4/4]; Review findings [2/2]; vNext task to be logged for the unattended cron contract.
diff --git a/hooks/ai-wrap-teardown.sh b/hooks/ai-wrap-teardown.sh
new file mode 100755
index 0000000..6133075
--- /dev/null
+++ b/hooks/ai-wrap-teardown.sh
@@ -0,0 +1,69 @@
+#!/usr/bin/env bash
+# Stop hook: tear down the ai-term session (or power off) AFTER a wrap-up.
+#
+# wrap-it-up.org drops a sentinel only at the very end of a teardown- or
+# shutdown-mode wrap, once commit+push is verified and the valediction is
+# delivered. This hook fires when Claude stops responding; on every NORMAL
+# stop there is no sentinel, so it is a silent no-op. Only after a wrap that
+# requested teardown does the matching sentinel exist, and only then does this
+# hook act — which is what keeps a routine end-of-turn from killing the
+# session.
+#
+# Decoupling via the Stop hook (rather than an inline workflow step) is what
+# lets the valediction flush before the session dies: teardown kills the very
+# tmux session Claude runs in, so it cannot happen inline.
+#
+# Two sentinels, keyed by the project basename (one ai-term session per
+# project, named aiv-<basename>):
+# /tmp/ai-wrap-teardown-<basename> -> cj/ai-term-quit "<basename>"
+# kill the aiv-<basename> tmux session (takes claude with it), kill the
+# vterm buffer, restore geometry. Defined in .emacs.d/modules/ai-term.el.
+# /tmp/ai-wrap-shutdown-<basename> -> cj/ai-term-shutdown-countdown
+# abort-able 10->1 echo-area countdown, then sudo shutdown now. Shutdown
+# supersedes teardown (killing the buffer is moot if powering off).
+#
+# The sentinel is removed BEFORE the emacsclient call: cj/ai-term-quit kills
+# the tmux session this hook runs inside, so the hook process may not survive
+# the call. Clearing first guarantees the sentinel never lingers to re-fire on
+# a later session in the same project. The emacs daemon is a separate process,
+# so it completes the teardown even when the hook is cut off mid-call.
+#
+# emacsclient absent or daemon unreachable: clear the sentinel and exit 0. The
+# session simply stays up — graceful degradation, never a wedge.
+#
+# Wire in ~/.claude/settings.json (see hooks/settings-snippet.json):
+#
+# "Stop": [
+# { "hooks": [
+# { "type": "command",
+# "command": "~/.claude/hooks/ai-wrap-teardown.sh" } ] } ]
+set -u
+
+# Stop-hook stdin JSON carries cwd; basename it to the project / aiv- session.
+cwd="$(jq -r '.cwd // empty' 2>/dev/null)"
+[ -z "$cwd" ] && cwd="$PWD"
+proj="$(basename "$cwd")"
+
+teardown_sentinel="/tmp/ai-wrap-teardown-${proj}"
+shutdown_sentinel="/tmp/ai-wrap-shutdown-${proj}"
+
+fire() {
+ # $1 = elisp form. Best-effort: only when emacsclient resolves.
+ command -v emacsclient >/dev/null 2>&1 || return 0
+ emacsclient -e "$1" >/dev/null 2>&1 || true
+}
+
+# Shutdown supersedes teardown when both are somehow present.
+if [ -f "$shutdown_sentinel" ]; then
+ rm -f "$shutdown_sentinel" "$teardown_sentinel"
+ fire '(cj/ai-term-shutdown-countdown)'
+ exit 0
+fi
+
+if [ -f "$teardown_sentinel" ]; then
+ rm -f "$teardown_sentinel"
+ fire "(cj/ai-term-quit \"${proj}\")"
+ exit 0
+fi
+
+exit 0
diff --git a/hooks/settings-snippet.json b/hooks/settings-snippet.json
index a5f9d9c..0f0e784 100644
--- a/hooks/settings-snippet.json
+++ b/hooks/settings-snippet.json
@@ -30,6 +30,13 @@
{ "type": "command", "command": "~/.claude/hooks/destructive-bash-confirm.py" }
]
}
+ ],
+ "Stop": [
+ {
+ "hooks": [
+ { "type": "command", "command": "~/.claude/hooks/ai-wrap-teardown.sh" }
+ ]
+ }
]
}
}
diff --git a/languages/bash/CLAUDE.md b/languages/bash/CLAUDE.md
new file mode 100644
index 0000000..2511c47
--- /dev/null
+++ b/languages/bash/CLAUDE.md
@@ -0,0 +1,71 @@
+# CLAUDE.md
+
+## Project
+
+Bash/shell project. Customize this section with your own description, layout,
+and conventions.
+
+**Typical layout:**
+- `bin/` or top-level `*.sh` — entry-point scripts
+- `lib/*.sh` — sourced function libraries (no `set -e`; the caller owns the shell)
+- `tests/*.bats` — bats-core tests beside the scripts they exercise
+
+## Build & Test Commands
+
+If the project has a Makefile, document targets here. Common pattern:
+
+```bash
+make test # run the bats suite
+make test FILE=tests/x.bats # one file
+make lint # shellcheck across the tree
+make fmt # shfmt -w (if the project adopts shfmt)
+```
+
+Direct equivalents: `bats -r tests/`, `shellcheck script.sh`,
+`shfmt -d script.sh` (diff), `shfmt -w script.sh` (write).
+
+## Language Rules
+
+See rule files in `.claude/rules/`:
+- `bash.md` — code style and patterns (strict mode, quoting, `[[ ]]`, traps)
+- `bash-testing.md` — bats conventions
+- `verification.md` — verify-before-claim-done discipline
+
+## Git Workflow
+
+Commit conventions: see `.claude/rules/commits.md` (author identity,
+no AI attribution, message format).
+
+Pre-commit hook in `githooks/` scans for secrets and runs `shellcheck` on staged
+shell files. Activate on a fresh clone with `git config core.hooksPath githooks`.
+
+## Problem-Solving Approach
+
+Investigate before fixing. When diagnosing a bug:
+1. Read the relevant script and trace what actually happens
+2. Identify the root cause, not a surface symptom
+3. Write a failing bats test that captures the correct behavior
+4. Fix, then re-run tests
+
+## Testing Discipline
+
+TDD is the default: write a failing test before any implementation. If you can't
+write the test, you don't yet understand the change. Details in
+`.claude/rules/bash-testing.md`.
+
+## Editing Discipline
+
+A PostToolUse hook runs `shellcheck` on every shell file after Edit/Write/
+MultiEdit and blocks on a violation — read the SCxxxx code and fix it (each has a
+wiki page). The hook covers `.sh`, `.bash`, and extensionless files with a shell
+shebang. Formatting (`shfmt`) is recommended but not enforced by the hook, since
+shell has no single canonical style; adopt one per project via `.editorconfig`.
+
+## What Not to Do
+
+- Don't add features beyond what was asked
+- Don't refactor surrounding code when fixing a bug
+- Don't leave expansions unquoted or use `[ ]` where `[[ ]]` fits
+- Don't add comments to code you didn't change
+- Don't commit `.env` files, credentials, or API keys — the pre-commit hook
+ catches common patterns but isn't a substitute for care
diff --git a/languages/bash/claude/hooks/validate-bash.sh b/languages/bash/claude/hooks/validate-bash.sh
new file mode 100755
index 0000000..4e75f40
--- /dev/null
+++ b/languages/bash/claude/hooks/validate-bash.sh
@@ -0,0 +1,66 @@
+#!/usr/bin/env bash
+# Validate shell files after Edit/Write/MultiEdit.
+# PostToolUse hook: receives tool-call JSON on stdin.
+#
+# On success: exit 0 silent.
+# On failure: emit JSON with hookSpecificOutput.additionalContext so Claude
+# sees a structured error in its context, THEN exit 2 to block the tool
+# pipeline. stderr still echoes the error for terminal visibility.
+#
+# Gate: shellcheck. It catches the bugs that define shell — unquoted
+# expansions, unset variables, masked exit codes — and is the high-value,
+# universally-agreed check. Formatting (shfmt) is deliberately NOT enforced
+# here: shell has no single canonical style (tabs vs spaces), so blocking on
+# it would impose a contested choice. bash.md recommends shfmt; this hook
+# enforces correctness.
+#
+# Scope: .sh and .bash files, plus extensionless files whose first line is a
+# sh/bash shebang (the CLI tools that fill a shell-heavy repo carry no
+# extension).
+
+set -u
+
+# Emit a JSON failure payload and exit 2. Arguments:
+# $1 — short failure type (e.g. "SHELLCHECK FAILED")
+# $2 — file path
+# $3 — tool output (error body)
+fail_json() {
+ local ctx
+ ctx="$(printf '%s: %s\n\n%s\n\nFix before proceeding.' "$1" "$2" "$3" \
+ | jq -Rs .)"
+ cat <<EOF
+{"hookSpecificOutput": {"hookEventName": "PostToolUse", "additionalContext": $ctx}}
+EOF
+ printf '%s: %s\n%s\n' "$1" "$2" "$3" >&2
+ exit 2
+}
+
+f="$(jq -r '.tool_input.file_path // .tool_response.filePath // empty')"
+[ -z "$f" ] && exit 0
+[ -f "$f" ] || exit 0
+
+# Is this a shell file? By extension, or by shebang when it has no extension.
+# Match on the basename, not the full path — a temp/parent dir can carry a dot
+# (e.g. validate-bash-bats.XXXX/) and misfire the "*.*" extension test.
+is_shell=0
+base="${f##*/}"
+case "$base" in
+ *.sh | *.bash) is_shell=1 ;;
+ *.*) is_shell=0 ;; # some other extension — not ours
+ *)
+ # No extension: sniff the shebang.
+ if head -1 "$f" 2>/dev/null | grep -qE '^#!.*\b(bash|sh)\b'; then
+ is_shell=1
+ fi
+ ;;
+esac
+[ "$is_shell" -eq 1 ] || exit 0
+
+# No shellcheck on this machine — nothing to validate, don't block the edit.
+command -v shellcheck >/dev/null 2>&1 || exit 0
+
+if ! out="$(shellcheck "$f" 2>&1)"; then
+ fail_json "SHELLCHECK FAILED" "$f" "$out"
+fi
+
+exit 0
diff --git a/languages/bash/claude/rules/bash-testing.md b/languages/bash/claude/rules/bash-testing.md
new file mode 100644
index 0000000..c904927
--- /dev/null
+++ b/languages/bash/claude/rules/bash-testing.md
@@ -0,0 +1,71 @@
+# Bash Testing Rules
+
+Applies to: `**/*.bats`
+
+Implements the core principles from `testing.md`. All rules there apply here —
+this file covers shell-specific patterns.
+
+## Framework: bats-core
+
+Use [bats-core](https://bats-core.readthedocs.io/) for shell tests. A test file
+is `<thing>.bats`; each test is a `@test "description" { ... }` block; a non-zero
+exit inside the block fails the test. Run a file with `bats path/to/file.bats`,
+or a tree with `bats -r tests/`.
+
+Drive the script under test with `run`: it captures `$status` (exit code),
+`$output` (combined stdout+stderr), and `$lines[]` (output split by line)
+without the failure aborting the test. Assert on those.
+
+```bash
+@test "greet: prints the name passed in" {
+ run bash "$SCRIPT" --name Ada
+ [ "$status" -eq 0 ]
+ [[ "$output" == *"Hello, Ada"* ]]
+}
+```
+
+## Test the Real Script, Through Its Interface
+
+Run the actual script file — never copy its logic into the test. Invoke it the
+way a caller does (`run bash "$SCRIPT" <args>`, or `run "$SCRIPT"` when it's
+executable) and assert on exit status and output. A test that re-implements the
+script's logic passes even when the script breaks.
+
+For a script that sources a library of functions, source the library in `setup`
+and call the functions directly — that's the unit level; the `run` invocation is
+the integration level.
+
+## Normal, Boundary, Error — the Three Categories
+
+Cover all three from `testing.md` per script:
+
+- Normal: the expected arguments and inputs produce the expected output and a
+ zero exit.
+- Boundary: empty argument, missing optional flag, single-item vs many,
+ whitespace and unicode in inputs, a path with a space.
+- Error: missing required argument, nonexistent input file, a dependency
+ absent. Assert the exit code and that the error names the problem — not the
+ exact wording (`testing.md`'s error-behavior rule).
+
+## Isolation and Determinism
+
+- `setup()` makes a fresh `mktemp -d` per test; `teardown()` removes it. No test
+ leans on another's leftovers, and tests pass in any order.
+- Mock an external command by putting a stub earlier on `PATH`: write a small
+ script named like the command into a temp dir, `chmod +x`, and prepend that
+ dir to `PATH` for the `run`. This is how you simulate a tool being absent,
+ returning an error, or emitting canned output — without touching the network
+ or the real tool.
+- Never hardcode dates; generate them relative to `date` (see the
+ `task-review-staleness.bats` pattern in this repo for relative-date fixtures).
+- Mock at the boundary (network, the external CLI, the clock). Don't mock the
+ script's own functions — those are the work.
+
+## What Not to Do
+
+- Don't assert exact error-message prose; assert the exit code plus a value the
+ message must contain.
+- Don't share mutable state between tests through a fixed temp path.
+- Don't test that `shellcheck` or `bats` themselves work — trust the tools.
+- Don't skip the error cases because the happy path passes; the error paths are
+ where shell scripts actually break.
diff --git a/languages/bash/claude/rules/bash.md b/languages/bash/claude/rules/bash.md
new file mode 100644
index 0000000..042138a
--- /dev/null
+++ b/languages/bash/claude/rules/bash.md
@@ -0,0 +1,83 @@
+# Bash Code Rules
+
+Applies to: `**/*.sh`, `**/*.bash`, and extensionless files with a `sh`/`bash` shebang
+
+Shell-specific style and structure. Pairs with `bash-testing.md` for tests and
+the generic `verification.md` / `commits.md` rules. When in doubt, defer to
+[ShellCheck](https://www.shellcheck.net/wiki/) (every SCxxxx code has a wiki
+page explaining the fix) and Google's
+[Shell Style Guide](https://google.github.io/styleguide/shellguide.html).
+
+## ShellCheck Is the Gate, Not a Suggestion
+
+The bundle's PostToolUse hook runs `shellcheck` on every edited shell file and
+blocks on a violation; the pre-commit hook re-checks staged files. ShellCheck
+catches the bugs that define shell: unquoted expansions that word-split, unset
+variables, `[ ]` pitfalls, masked exit codes. Fix the finding rather than
+silence it. When a warning is a genuine false positive, disable it narrowly with
+a `# shellcheck disable=SCxxxx` directive on the line above and a comment saying
+why, never a file-wide blanket disable.
+
+## The Header: Strict Mode
+
+Every script starts with `#!/usr/bin/env bash` and `set -euo pipefail`:
+
+- `-e` exits on an unhandled non-zero command. Handle the expected-failure cases
+ explicitly (`cmd || true`, an `if`, a `case`) so the exit is a real error.
+- `-u` treats an unset variable as an error. Use `"${VAR:-default}"` for the
+ ones that are legitimately optional.
+- `-o pipefail` makes a pipeline fail if any stage fails, not just the last.
+
+A script meant to be *sourced* (a library) skips `set -e` — it would change the
+caller's shell. Libraries guard their own commands instead.
+
+## Quote Everything
+
+- Double-quote every expansion: `"$var"`, `"$@"`, `"${arr[@]}"`,
+ `"$(command)"`. Unquoted is the single largest source of shell bugs — a path
+ with a space becomes two arguments.
+- `"$@"` (quoted) passes arguments through untouched; `$*` and unquoted `$@`
+ word-split. Use `"$@"` unless you specifically want the joined string.
+- Loop over arrays and `find -print0 | while IFS= read -r -d ''`, never over
+ unquoted command substitution or `ls` output.
+
+## Test, Compare, Branch
+
+- Use `[[ ]]` for tests, not `[ ]` / `test`. `[[ ]]` doesn't word-split its
+ operands, supports `&&`/`||`/`=~`, and has fewer quoting traps.
+- Arithmetic goes in `(( ))` or `$(( ))`, not `[ ]` with `-eq`.
+- `$(command)`, never backticks — nests cleanly and reads better.
+- Prefer `printf` over `echo` for anything but a fixed literal string;
+ `echo` mangles values that start with `-` or contain backslashes.
+
+## Functions and Scope
+
+- Declare function-local variables with `local`. A bare assignment in a
+ function writes a global and leaks across calls.
+- `local var; var="$(cmd)"` on two lines when you need the command's exit
+ status: `local var="$(cmd)"` masks `cmd`'s exit code behind `local`'s.
+- Keep functions focused. A function that fetches, parses, and writes is three
+ functions; the test difficulty in `bash-testing.md` is the tell.
+- Put `main "$@"` at the bottom for a script with more than a couple of
+ functions, so definition order doesn't dictate execution order.
+
+## Robustness
+
+- `trap 'rm -rf "$tmpdir"' EXIT` right after creating a temp resource, so
+ cleanup runs on every exit path including errors.
+- Make a temp file or dir with `mktemp` / `mktemp -d`, never a fixed
+ `/tmp/name` (race + collision).
+- Check that a required command exists before the work: `command -v jq
+ >/dev/null || { echo "jq required" >&2; exit 1; }`.
+- Never parse `ls` output and don't `cat` a file into a pipe you could read
+ directly. Glob, or use `find`, or read the file in place.
+
+## What Not to Do
+
+- Don't leave an expansion unquoted to "save a quote" — quote it.
+- Don't use `[ ]` when `[[ ]]` is available, or backticks when `$()` is.
+- Don't silence a ShellCheck warning file-wide to clear it; fix it or disable
+ the one code with a reason.
+- Don't refactor surrounding code while fixing a bug — keep the diff scoped.
+- Don't commit credentials or API keys — the pre-commit hook catches common
+ patterns but isn't a substitute for care.
diff --git a/languages/bash/claude/settings.json b/languages/bash/claude/settings.json
new file mode 100644
index 0000000..b725603
--- /dev/null
+++ b/languages/bash/claude/settings.json
@@ -0,0 +1,68 @@
+{
+ "attribution": {
+ "commit": "",
+ "pr": ""
+ },
+ "permissions": {
+ "allow": [
+ "Bash(make)",
+ "Bash(make help)",
+ "Bash(make targets)",
+ "Bash(make test)",
+ "Bash(make test *)",
+ "Bash(make lint)",
+ "Bash(make fmt)",
+ "Bash(shellcheck *)",
+ "Bash(shfmt *)",
+ "Bash(bats)",
+ "Bash(bats *)",
+ "Bash(git status)",
+ "Bash(git status *)",
+ "Bash(git diff)",
+ "Bash(git diff *)",
+ "Bash(git log)",
+ "Bash(git log *)",
+ "Bash(git show)",
+ "Bash(git show *)",
+ "Bash(git blame *)",
+ "Bash(git branch)",
+ "Bash(git branch -v)",
+ "Bash(git branch -a)",
+ "Bash(git branch --list *)",
+ "Bash(git remote)",
+ "Bash(git remote -v)",
+ "Bash(git remote show *)",
+ "Bash(git ls-files *)",
+ "Bash(git rev-parse *)",
+ "Bash(git cat-file *)",
+ "Bash(git stash list)",
+ "Bash(git stash show *)",
+ "Bash(jq *)",
+ "Bash(date)",
+ "Bash(date *)",
+ "Bash(which *)",
+ "Bash(file *)",
+ "Bash(ls)",
+ "Bash(ls *)",
+ "Bash(wc *)",
+ "Bash(du *)",
+ "Bash(readlink *)",
+ "Bash(realpath *)",
+ "Bash(basename *)",
+ "Bash(dirname *)"
+ ]
+ },
+ "hooks": {
+ "PostToolUse": [
+ {
+ "matcher": "Edit|Write|MultiEdit",
+ "hooks": [
+ {
+ "type": "command",
+ "command": "$CLAUDE_PROJECT_DIR/.claude/hooks/validate-bash.sh"
+ }
+ ]
+ }
+ ]
+ }
+}
diff --git a/languages/bash/githooks/pre-commit b/languages/bash/githooks/pre-commit
new file mode 100755
index 0000000..e41c41c
--- /dev/null
+++ b/languages/bash/githooks/pre-commit
@@ -0,0 +1,48 @@
+#!/usr/bin/env bash
+# Pre-commit hook: secret scan + shellcheck on staged shell files.
+# Use `git commit --no-verify` to bypass for confirmed false positives.
+
+set -u
+
+REPO_ROOT="$(git rev-parse --show-toplevel)"
+cd "$REPO_ROOT" || exit 1
+
+# --- 1. Secret scan ---
+# Patterns for common credentials. Scans only added lines in the staged diff.
+SECRET_PATTERNS='(AKIA[0-9A-Z]{16}|sk-[a-zA-Z0-9_-]{20,}|-----BEGIN (RSA|DSA|EC|OPENSSH|PGP)( PRIVATE)?( KEY| KEY BLOCK)?-----|(api[_-]?key|api[_-]?secret|auth[_-]?token|secret[_-]?key|bearer[_-]?token|access[_-]?token|password)[[:space:]]*[:=][[:space:]]*["'"'"'][^"'"'"']{16,}["'"'"'])'
+
+secret_hits="$(git diff --cached -U0 --diff-filter=AM \
+ | grep '^+' | grep -v '^+++' \
+ | grep -iEn "$SECRET_PATTERNS" || true)"
+
+if [ -n "$secret_hits" ]; then
+ echo "pre-commit: potential secret in staged changes:" >&2
+ echo "$secret_hits" >&2
+ echo "" >&2
+ echo "Review the lines above. If this is a false positive (test fixture, documentation)," >&2
+ echo "bypass with: git commit --no-verify" >&2
+ exit 1
+fi
+
+# --- 2. shellcheck on staged .sh / .bash files ---
+staged_sh="$(git diff --cached --name-only --diff-filter=AM \
+ | grep -E '\.(sh|bash)$' || true)"
+
+if [ -n "$staged_sh" ] && command -v shellcheck >/dev/null 2>&1; then
+ failed=""
+ while IFS= read -r f; do
+ [ -z "$f" ] && continue
+ [ -f "$f" ] || continue
+ if ! shellcheck "$f" >/dev/null 2>&1; then
+ failed="${failed}${f}"$'\n'
+ fi
+ done <<< "$staged_sh"
+
+ if [ -n "$failed" ]; then
+ printf 'pre-commit: shellcheck failed on staged files:\n\n%s\n' "$failed" >&2
+ echo "Run: shellcheck <file> and fix the findings, then re-stage." >&2
+ exit 1
+ fi
+fi
+
+exit 0
diff --git a/languages/bash/gitignore-add.txt b/languages/bash/gitignore-add.txt
new file mode 100644
index 0000000..899f5ba
--- /dev/null
+++ b/languages/bash/gitignore-add.txt
@@ -0,0 +1,4 @@
+# Claude Code — local tooling, delivered by install/sync, not committed
+.claude/
+CLAUDE.md
+githooks/
diff --git a/languages/bash/tests/validate-bash.bats b/languages/bash/tests/validate-bash.bats
new file mode 100644
index 0000000..9f268a1
--- /dev/null
+++ b/languages/bash/tests/validate-bash.bats
@@ -0,0 +1,96 @@
+#!/usr/bin/env bats
+#
+# Tests for languages/bash/claude/hooks/validate-bash.sh — the PostToolUse hook
+# that runs shellcheck on edited shell files and blocks on a violation.
+#
+# The hook reads tool-call JSON on stdin and extracts the file path, so each
+# test pipes a JSON payload naming a real file it wrote into a temp dir. The
+# shellcheck dependency is real (integration): clean files pass, genuinely
+# broken ones fail. Tests needing shellcheck skip when it's absent so the suite
+# stays portable.
+
+HOOK="$(cd "$(dirname "$BATS_TEST_FILENAME")/.." && pwd)/claude/hooks/validate-bash.sh"
+
+setup() {
+ TEST_DIR="$(mktemp -d -t validate-bash-bats.XXXXXX)"
+}
+
+teardown() {
+ rm -rf "$TEST_DIR"
+}
+
+# Build a tool-call JSON payload naming a file_path.
+payload() {
+ printf '{"tool_input": {"file_path": "%s"}}' "$1"
+}
+
+# ---- Normal ----------------------------------------------------------
+
+@test "validate-bash: a clean .sh file passes silently (exit 0)" {
+ command -v shellcheck >/dev/null 2>&1 || skip "shellcheck not installed"
+ printf '#!/usr/bin/env bash\nset -euo pipefail\necho "ok"\n' > "$TEST_DIR/clean.sh"
+ run bash "$HOOK" <<< "$(payload "$TEST_DIR/clean.sh")"
+ [ "$status" -eq 0 ]
+ [ -z "$output" ]
+}
+
+# ---- Error -----------------------------------------------------------
+
+@test "validate-bash: a shellcheck violation blocks (exit 2, names shellcheck)" {
+ command -v shellcheck >/dev/null 2>&1 || skip "shellcheck not installed"
+ # SC2086: unquoted expansion that word-splits — a real shellcheck warning.
+ printf '#!/usr/bin/env bash\nf=$1\nrm $f\n' > "$TEST_DIR/bad.sh"
+ run bash "$HOOK" <<< "$(payload "$TEST_DIR/bad.sh")"
+ [ "$status" -eq 2 ]
+ [[ "$output" == *"SHELLCHECK"* ]]
+}
+
+# ---- Boundary --------------------------------------------------------
+
+@test "validate-bash: a non-shell file is ignored (exit 0)" {
+ printf 'print("hello")\n' > "$TEST_DIR/script.py"
+ run bash "$HOOK" <<< "$(payload "$TEST_DIR/script.py")"
+ [ "$status" -eq 0 ]
+ [ -z "$output" ]
+}
+
+@test "validate-bash: an extensionless file with a bash shebang is validated" {
+ command -v shellcheck >/dev/null 2>&1 || skip "shellcheck not installed"
+ printf '#!/usr/bin/env bash\nf=$1\nrm $f\n' > "$TEST_DIR/cli-tool"
+ run bash "$HOOK" <<< "$(payload "$TEST_DIR/cli-tool")"
+ [ "$status" -eq 2 ]
+ [[ "$output" == *"SHELLCHECK"* ]]
+}
+
+@test "validate-bash: an extensionless non-shell file is ignored (exit 0)" {
+ printf 'just some text\nno shebang here\n' > "$TEST_DIR/notes"
+ run bash "$HOOK" <<< "$(payload "$TEST_DIR/notes")"
+ [ "$status" -eq 0 ]
+ [ -z "$output" ]
+}
+
+@test "validate-bash: empty file_path is a no-op (exit 0)" {
+ run bash "$HOOK" <<< '{"tool_input": {}}'
+ [ "$status" -eq 0 ]
+ [ -z "$output" ]
+}
+
+@test "validate-bash: a missing file is a no-op (exit 0)" {
+ run bash "$HOOK" <<< "$(payload "$TEST_DIR/does-not-exist.sh")"
+ [ "$status" -eq 0 ]
+ [ -z "$output" ]
+}
+
+@test "validate-bash: shellcheck absent does not block the edit (exit 0)" {
+ # PATH with jq + coreutils symlinked but no shellcheck → hook can't validate,
+ # must not block.
+ STUB="$TEST_DIR/bin"
+ mkdir -p "$STUB"
+ for b in bash jq head cat printf grep sed; do
+ src="$(command -v "$b" 2>/dev/null)" && ln -sf "$src" "$STUB/$b"
+ done
+ printf '#!/usr/bin/env bash\nf=$1\nrm $f\n' > "$TEST_DIR/bad.sh"
+ run env PATH="$STUB" bash "$HOOK" <<< "$(payload "$TEST_DIR/bad.sh")"
+ [ "$status" -eq 0 ]
+ [ -z "$output" ]
+}
diff --git a/languages/default-CLAUDE.md b/languages/default-CLAUDE.md
new file mode 100644
index 0000000..a5b6925
--- /dev/null
+++ b/languages/default-CLAUDE.md
@@ -0,0 +1,64 @@
+# CLAUDE.md
+
+## Project
+
+Describe this project: what it is, its layout, and its conventions. This
+default was seeded by `install-lang` because the installed bundle ships no
+language-specific CLAUDE.md — it deliberately names no language, so replace
+this section with an accurate description rather than inheriting a wrong one.
+
+**Typical layout (edit to match):**
+- entry points — the file(s) that run first
+- source directories — where the real code lives
+- tests — beside the code, or under a `tests/` tree
+
+## Build & Test Commands
+
+If the project has a Makefile, document its targets here. A common shape:
+
+```bash
+make test # run the test suite
+make lint # run the linter / formatter check
+make build # build the project
+```
+
+Otherwise, document the direct commands a contributor runs to test and build.
+
+## Language Rules
+
+Shared rules live in `.claude/rules/` (installed from `claude-rules/`):
+- `commits.md` — author identity, no AI attribution, message format
+- `testing.md` — TDD discipline and test-quality standards
+- `verification.md` — verify-before-claim-done discipline
+
+If a language bundle was installed, its own rule files (code style, testing
+conventions) sit alongside these in `.claude/rules/`.
+
+## Git Workflow
+
+Commit conventions: see `.claude/rules/commits.md`.
+
+If a `githooks/` pre-commit hook was installed, activate it on a fresh clone
+with `git config core.hooksPath githooks`.
+
+## Problem-Solving Approach
+
+Investigate before fixing. When diagnosing a bug:
+1. Read the relevant code and trace what actually happens
+2. Identify the root cause, not a surface symptom
+3. Write a failing test that captures the correct behavior
+4. Fix, then re-run tests
+
+## Testing Discipline
+
+TDD is the default: write a failing test before any implementation. If you
+can't write the test, you don't yet understand the change. Details in
+`.claude/rules/testing.md`.
+
+## What Not to Do
+
+- Don't add features beyond what was asked
+- Don't refactor surrounding code when fixing a bug
+- Don't add comments to code you didn't change
+- Don't create abstractions for one-time operations
+- Don't commit credentials or API keys
diff --git a/languages/elisp/claude/hooks/validate-el.sh b/languages/elisp/claude/hooks/validate-el.sh
index 2529fcc..8e46457 100755
--- a/languages/elisp/claude/hooks/validate-el.sh
+++ b/languages/elisp/claude/hooks/validate-el.sh
@@ -104,6 +104,7 @@ if [ "$count" -ge 1 ] && [ "$count" -le "$MAX_AUTO_TEST_FILES" ]; then
-L "$PROJECT_ROOT/tests" \
-L "$PROJECT_ROOT/themes" \
--eval '(package-initialize)' \
+ --eval "(cd \"$PROJECT_ROOT/tests\")" \
-l ert "${load_args[@]}" \
--eval "(ert-run-tests-batch-and-exit '(not (tag :slow)))" 2>&1)"; then
# Terminal gets a compact summary (the run tally + the failing test names);
diff --git a/scripts/install-lang.sh b/scripts/install-lang.sh
index 0fc9ea8..2f38fcd 100755
--- a/scripts/install-lang.sh
+++ b/scripts/install-lang.sh
@@ -66,13 +66,26 @@ if [ -d "$SRC/githooks" ]; then
fi
fi
-# 3. CLAUDE.md — seed on first install, don't overwrite unless FORCE=1
+# 3. CLAUDE.md — seed on first install, don't overwrite unless FORCE=1.
+# Prefer the bundle's own template; fall back to the language-neutral
+# default so a bundle that ships none still seeds an accurate, non-
+# mislabeling header instead of nothing. The default names no language,
+# so multi-bundle and wrong-bundle installs don't inherit a false header.
+CLAUDE_SRC=""
+CLAUDE_KIND=""
if [ -f "$SRC/CLAUDE.md" ]; then
+ CLAUDE_SRC="$SRC/CLAUDE.md"
+ CLAUDE_KIND="$LANG"
+elif [ -f "$REPO_ROOT/languages/default-CLAUDE.md" ]; then
+ CLAUDE_SRC="$REPO_ROOT/languages/default-CLAUDE.md"
+ CLAUDE_KIND="language-neutral default"
+fi
+if [ -n "$CLAUDE_SRC" ]; then
if [ -f "$PROJECT/CLAUDE.md" ] && [ "$FORCE" != "1" ]; then
echo " [skip] CLAUDE.md already exists (use FORCE=1 to overwrite)"
else
- cp "$SRC/CLAUDE.md" "$PROJECT/CLAUDE.md"
- echo " [ok] CLAUDE.md installed"
+ cp "$CLAUDE_SRC" "$PROJECT/CLAUDE.md"
+ echo " [ok] CLAUDE.md installed ($CLAUDE_KIND)"
fi
fi
diff --git a/scripts/lint.sh b/scripts/lint.sh
index ae30aa5..61a27a1 100755
--- a/scripts/lint.sh
+++ b/scripts/lint.sh
@@ -99,6 +99,9 @@ for claude_md in languages/*/CLAUDE.md; do
check_md_heading "$claude_md"
done
+# Language-neutral default CLAUDE.md (install-lang's fallback when a bundle ships none)
+[ -f languages/default-CLAUDE.md ] && check_md_heading languages/default-CLAUDE.md
+
# Hook scripts
for h in languages/*/claude/hooks/*.sh languages/*/githooks/*; do
[ -f "$h" ] || continue
diff --git a/scripts/tests/ai-wrap-teardown-hook.bats b/scripts/tests/ai-wrap-teardown-hook.bats
new file mode 100644
index 0000000..05c49f1
--- /dev/null
+++ b/scripts/tests/ai-wrap-teardown-hook.bats
@@ -0,0 +1,101 @@
+#!/usr/bin/env bats
+# hooks/ai-wrap-teardown.sh — Stop hook that tears down the ai-term session
+# (or powers off) after a wrap-up, gated on a sentinel wrap-it-up drops. On a
+# normal stop (no sentinel) it is a silent no-op. The emacsclient call is
+# stubbed here so the test records the elisp form without a live daemon.
+
+setup() {
+ REPO_ROOT="$(cd "$(dirname "$BATS_TEST_FILENAME")/../.." && pwd)"
+ SCRIPT="$REPO_ROOT/hooks/ai-wrap-teardown.sh"
+ TMPDIR_T="$(mktemp -d)"
+ PROJ="proj-$$-$BATS_TEST_NUMBER" # unique so /tmp sentinels don't collide
+ CWD="$TMPDIR_T/$PROJ"
+ mkdir -p "$CWD"
+ TEARDOWN_SENTINEL="/tmp/ai-wrap-teardown-${PROJ}"
+ SHUTDOWN_SENTINEL="/tmp/ai-wrap-shutdown-${PROJ}"
+
+ # Stub emacsclient on PATH: record the elisp form it was called with.
+ BIN="$TMPDIR_T/bin"
+ mkdir -p "$BIN"
+ EC_LOG="$TMPDIR_T/emacsclient.log"
+ cat >"$BIN/emacsclient" <<EOF
+#!/usr/bin/env bash
+# args: -e <form>
+shift # drop -e
+printf '%s\n' "\$1" >> "$EC_LOG"
+EOF
+ chmod +x "$BIN/emacsclient"
+}
+
+teardown() {
+ rm -rf "$TMPDIR_T"
+ rm -f "$TEARDOWN_SENTINEL" "$SHUTDOWN_SENTINEL"
+}
+
+run_hook() {
+ # invoke with the stubbed emacsclient on PATH, feeding Stop-hook JSON
+ printf '{"cwd":"%s","hook_event_name":"Stop"}' "$CWD" \
+ | PATH="$BIN:$PATH" bash "$SCRIPT"
+}
+
+@test "no sentinel: silent no-op, emacsclient never called" {
+ run run_hook
+ [ "$status" -eq 0 ]
+ [ -z "$output" ]
+ [ ! -f "$EC_LOG" ]
+}
+
+@test "teardown sentinel: calls cj/ai-term-quit with the project basename" {
+ : > "$TEARDOWN_SENTINEL"
+ run run_hook
+ [ "$status" -eq 0 ]
+ grep -q "cj/ai-term-quit \"$PROJ\"" "$EC_LOG"
+}
+
+@test "teardown sentinel is removed after firing" {
+ : > "$TEARDOWN_SENTINEL"
+ run run_hook
+ [ "$status" -eq 0 ]
+ [ ! -f "$TEARDOWN_SENTINEL" ]
+}
+
+@test "shutdown sentinel: calls cj/ai-term-shutdown-countdown" {
+ : > "$SHUTDOWN_SENTINEL"
+ run run_hook
+ [ "$status" -eq 0 ]
+ grep -q "cj/ai-term-shutdown-countdown" "$EC_LOG"
+}
+
+@test "shutdown supersedes teardown when both sentinels exist" {
+ : > "$TEARDOWN_SENTINEL"
+ : > "$SHUTDOWN_SENTINEL"
+ run run_hook
+ [ "$status" -eq 0 ]
+ grep -q "cj/ai-term-shutdown-countdown" "$EC_LOG"
+ ! grep -q "cj/ai-term-quit" "$EC_LOG"
+ [ ! -f "$TEARDOWN_SENTINEL" ]
+ [ ! -f "$SHUTDOWN_SENTINEL" ]
+}
+
+@test "emacsclient absent: clears the sentinel and exits 0 (graceful)" {
+ : > "$TEARDOWN_SENTINEL"
+ status=0
+ output="$(printf '{"cwd":"%s","hook_event_name":"Stop"}' "$CWD" \
+ | PATH="/usr/bin:/bin" bash "$SCRIPT")" || status=$?
+ [ "$status" -eq 0 ]
+ [ ! -f "$TEARDOWN_SENTINEL" ]
+}
+
+@test "falls back to PWD basename when cwd is absent from JSON" {
+ # No cwd key: hook uses $PWD. Run from CWD so basename resolves to PROJ.
+ : > "$TEARDOWN_SENTINEL"
+ run env "PATH=$BIN:$PATH" bash -c "cd '$CWD' && printf '{}' | bash '$SCRIPT'"
+ [ "$status" -eq 0 ]
+ grep -q "cj/ai-term-quit \"$PROJ\"" "$EC_LOG"
+}
+
+@test "emits no stderr noise on a normal stop" {
+ err="$(printf '{"cwd":"%s","hook_event_name":"Stop"}' "$CWD" \
+ | PATH="$BIN:$PATH" bash "$SCRIPT" 2>&1 >/dev/null)"
+ [ -z "$err" ]
+}
diff --git a/scripts/tests/install-lang.bats b/scripts/tests/install-lang.bats
index ecfbe01..8518852 100644
--- a/scripts/tests/install-lang.bats
+++ b/scripts/tests/install-lang.bats
@@ -79,6 +79,53 @@ teardown() {
grep -qxF "coverage/" "$PROJECT/.gitignore"
}
+@test "install-lang python: seeds the language-neutral default CLAUDE.md when the bundle ships none" {
+ run bash "$INSTALL_LANG" python "$PROJECT"
+
+ [ "$status" -eq 0 ]
+ [ -f "$PROJECT/CLAUDE.md" ]
+ # The default names no language, so it can't mislabel a python (or bash, or
+ # multi-bundle) project the way inheriting elisp's "Elisp project" header did.
+ ! grep -qi "Python project" "$PROJECT/CLAUDE.md"
+ ! grep -qi "Elisp project" "$PROJECT/CLAUDE.md"
+ grep -qF "names no language" "$PROJECT/CLAUDE.md"
+ [[ "$output" == *"language-neutral default"* ]]
+}
+
+@test "install-lang elisp: seeds the bundle's own CLAUDE.md, not the default" {
+ run bash "$INSTALL_LANG" elisp "$PROJECT"
+
+ [ "$status" -eq 0 ]
+ grep -qF "Elisp project." "$PROJECT/CLAUDE.md"
+ [[ "$output" == *"CLAUDE.md installed (elisp)"* ]]
+}
+
+@test "install-lang python: does not overwrite an existing CLAUDE.md without FORCE" {
+ echo "MY OWN CLAUDE" > "$PROJECT/CLAUDE.md"
+ run bash "$INSTALL_LANG" python "$PROJECT"
+
+ [ "$status" -eq 0 ]
+ grep -qxF "MY OWN CLAUDE" "$PROJECT/CLAUDE.md"
+}
+
+@test "install-lang bash: full bundle lands (rules, hook, settings, githook, CLAUDE.md)" {
+ run bash "$INSTALL_LANG" bash "$PROJECT"
+
+ [ "$status" -eq 0 ]
+ # Language + testing rules — the bundle's sync fingerprint
+ [ -f "$PROJECT/.claude/rules/bash.md" ]
+ [ -f "$PROJECT/.claude/rules/bash-testing.md" ]
+ # PostToolUse validate hook, executable and wired into settings
+ [ -x "$PROJECT/.claude/hooks/validate-bash.sh" ]
+ grep -qF "validate-bash.sh" "$PROJECT/.claude/settings.json"
+ # Pre-commit githook
+ [ -x "$PROJECT/githooks/pre-commit" ]
+ # The bundle ships its own CLAUDE.md, so it wins over the neutral default
+ grep -qF "Bash/shell project" "$PROJECT/CLAUDE.md"
+ # Gitignore footprint
+ grep -qxF ".claude/" "$PROJECT/.gitignore"
+}
+
@test "install-lang go: full bundle lands (rules, hook, settings, githook, CLAUDE.md, coverage)" {
run bash "$INSTALL_LANG" go "$PROJECT"
diff --git a/todo.org b/todo.org
index d7101bb..2665459 100644
--- a/todo.org
+++ b/todo.org
@@ -30,14 +30,66 @@ Optional *effort and autonomy tags* — orthogonal to type, both can apply on th
- =:quick:= — likely to take ≤30 minutes from start through verification.
- =:solo:= — Claude can complete the work end to end, including verification, without input from Craig.
+Optional *dependency tags* — cross-project, both plain tags with the which-project detail in the task body (per =todo-format.md=):
+
+- =:blocked:= — the task can't advance until another project delivers the work named in its body. =open-tasks.org= pulls =:blocked:= tasks out of the cascade and surfaces them on their own. Distinct from =VERIFY= (which waits on Craig).
+- =:blocker:= — this task owes work that's blocking another project (named in its body). =open-tasks.org= surfaces =:blocker:= tasks first, since clearing one unblocks the other project.
+
Tags are assigned and refreshed by =task-audit=; =task-review= keeps them honest in passing.
* Rulesets Open Work
-** VERIFY [#B] Helper-instance support — concurrent same-project Claude :feature:spec:
+** DOING [#B] wrap-it-up teardown + "wrap it up and shutdown" :feature:
+:PROPERTIES:
+:CREATED: [2026-06-23 Tue]
+:LAST_REVIEWED: 2026-06-24
+:END:
+Two additions to =wrap-it-up.org=, designed by Craig (home, 2026-06-23). Item 1: bare "wrap it up" also tears down the session — kill the =aiv-<proj>= tmux session (takes =claude= with it), kill the vterm buffer, restore geometry. Teardown is the default; "wrap it up with summary" wraps without teardown (keeps the buffer readable). Must fire from a Stop/SessionEnd hook via a sentinel file, decoupled and last, so the valediction flushes before the session dies, and strictly after commit+push is verified. Item 2: "wrap it up and shutdown" → wrap, then a hard blocking gate (abort unless this is the only live =aiv-*= session), then an abort-able 10→1 countdown, then =sudo shutdown now=. Countdown can't run through the Bash tool (stdout buffers — prints all ten at once); needs a detached tty writer or an Emacs =run-at-time= timer. Companion: =cj/ai-term-quit= (and optional =cj/ai-term-live-count=) must live in =.emacs.d/modules/ai-term.el= — route there via inbox-send when building so both sides land together. Open decisions for Craig first: qualifier wording ("with summary" vs "and summarize"), countdown home (tty script vs Emacs timer), session-count mechanism (=tmux ls= / =pgrep claude= / helper). Shared-asset, review-gated. Proposal: [[file:docs/design/2026-06-23-wrap-teardown-shutdown-proposal.org][proposal]]. From home 2026-06-23.
+
+*** 2026-06-23 Tue @ 23:31:59 -0400 Built the rulesets side; companion routed to .emacs.d
+Craig's three decisions (2026-06-23): non-destructive qualifier is *both* "with summary" and "and summarize"; countdown is an Emacs =run-at-time= timer; the gate uses =cj/ai-term-live-count=. Built and pushed: =hooks/ai-wrap-teardown.sh= (Stop hook, sentinel-gated, 8 bats tests green), =hooks/settings-snippet.json= Stop wiring, =wrap-it-up.org= Teardown-mode section + Step 6 + checklist, INDEX trigger update. Architecture: both teardown and shutdown fire from the Stop hook via a basename-keyed sentinel (=/tmp/ai-wrap-teardown-<proj>=, =/tmp/ai-wrap-shutdown-<proj>=) dropped only at the end of Step 6 after commit+push, so the valediction flushes first. No bin script — the gate/countdown/teardown are all =emacsclient= calls into the companion. Companion spec (=cj/ai-term-quit=, =cj/ai-term-live-count=, =cj/ai-term-shutdown-countdown=) routed to .emacs.d via inbox-send. Remaining: .emacs.d lands the three functions, then the manual end-to-end validation below. Task stays DOING until both sides verify.
+
+*** 2026-06-23 Tue @ 23:40 .emacs.d received + filed the companion spec
+Per a roam-inbox FYI from .emacs.d (2026-06-23 23:38): both ai-term handoffs (multi-LLM support + this wrap-teardown companion spec) landed and are filed as .emacs.d tasks. The teardown one is flagged for its own focused session to land alongside the rulesets half. Part (c) is now in progress on the .emacs.d side.
+
+*** 2026-06-24 Wed @ 06:51:13 -0400 Unblocked — .emacs.d companion landed; feature now live
+The three companion functions are in =.emacs.d/modules/ai-term.el= (=cj/ai-term-quit= 1068, =cj/ai-term-live-count= 1087, =cj/ai-term-shutdown-countdown= 1109), matching the contract — double-checked the bodies: quit kills session+buffer+restores layout idempotently, live-count returns the gate integer, shutdown-countdown re-checks the gate (TOCTOU guard), runs an abort-able =run-at-time= countdown (C-g cancels), then a configurable =cj/ai-term-shutdown-command=. 13 ERT tests, headless-verified live (.emacs.d FYI 2026-06-24 06:44). Dropped =:blocked:= / =:BLOCKED_BY:= — the build dependency is resolved; only the manual end-to-end validation below remains. NOTE: with the Stop hook wired and the companion present, the feature is now functional — the next bare "wrap it up" will actually tear the session down. Run the validation below before relying on it.
+
+*** TODO Manual testing and validation :test:
+What we're verifying: the wrap-teardown + shutdown feature end to end, once =.emacs.d/modules/ai-term.el= has the three companion functions and the =Stop= hook is installed (=make install-hooks= + the =settings-snippet.json= Stop block in =~/.claude/settings.json=). These need a live Emacs daemon + tmux + an =aiv-<proj>= ai-term session; they can't be driven from a script.
+
+**** Bare "wrap it up" tears down after the valediction
+What we're verifying: teardown is the default and fires only after the valediction renders.
+- In an ai-term =aiv-<proj>= session, say "wrap it up".
+- Watch the wrap run (summary, archive, commit, push, valediction).
+Expected: the valediction renders in full first, THEN the vterm buffer + =aiv-<proj>= tmux session + =claude= all disappear and the saved window geometry is restored. =/tmp/ai-wrap-teardown-<proj>= does not linger afterward.
+
+**** "wrap it up with summary" / "and summarize" keeps the buffer
+What we're verifying: the explicit qualifier opts out of teardown.
+- Say "wrap it up with summary" (then, separately, "wrap it up and summarize").
+Expected: the wrap completes (commit + push + archive), but the buffer and session stay up and readable. No teardown.
+
+**** "wrap it up and shutdown" aborts when another session is live
+What we're verifying: the safety gate refuses to power off out from under another session.
+- Start a second =aiv-*= ai-term session in another project.
+- In the first, say "wrap it up and shutdown".
+Expected: the wrap completes but the shutdown is refused; the other live =aiv-*= session is listed, and the valediction says it fell back to a normal wrap. No poweroff, no teardown, no sentinel dropped.
+
+**** "wrap it up and shutdown" as the sole session powers off (cancellable)
+What we're verifying: the happy path and the abort window.
+- With this as the only =aiv-*= session, say "wrap it up and shutdown".
+- During the countdown, first run it once and cancel with =C-g=; then run it again and let it complete. (Stub =sudo shutdown now= to an echo while validating so the box doesn't actually power off.)
+Expected: after commit + push, a 10→1 countdown renders one-per-second in the Emacs echo area; =C-g= cancels it cleanly (no shutdown); letting it finish fires =shutdown=.
+
+**** Teardown never precedes a verified push
+What we're verifying: no sentinel is dropped before commit + push succeeds.
+- Trigger a teardown wrap in a state where the push would fail (e.g. temporarily point the remote somewhere unreachable).
+Expected: the wrap surfaces the push failure and stops; no =/tmp/ai-wrap-*-<proj>= sentinel is created, so no teardown fires.
+
+** TODO [#B] Helper-agent instance support — concurrent same-project Claude :feature:spec:
:PROPERTIES:
:CREATED: [2026-06-11 Thu]
-:LAST_REVIEWED: 2026-06-15
+:LAST_REVIEWED: 2026-06-24
:END:
SPEC REVIEWED 2026-06-12: [[file:docs/design/2026-05-28-generic-agent-runtime-spec-review.org][Codex review]] now rates Phase 1.5 =Ready with caveats=. Before any build, keep the Emacs integration as a cross-project handoff to =~/.emacs.d=, preserve the three-ring gate (bats → sandbox drills → pilot project), and do not let startup/helper changes reach synced template paths until the live drills pass.
@@ -73,7 +125,91 @@ What's next — the WIRING, all behind the spec's three-ring gate (bats → sand
Stand up a drill rig before the gated work; build against it, don't touch synced paths until the live drill passes.
-OPEN QUESTION to answer first (Craig, 2026-06-15): doesn't helper-instance support depend on generic agent runtime support? Resolve before treating the wiring as unblocked. Starting point: the spec frames this work as Phase 1.5, "Independent of the spec's phases 2-6 (runtime-neutral refactor), which stay gated on their own go/no-go," and the body claims it sits only on the already-shipped session-context split. The separate =Generic agent runtime support — Codex spec v0= task (#C, below) is that phases-2-6 arc. So the spec's stated answer is "no, 1.5 is independent" — but confirm that's actually true for every wiring slice (does ai --helper, the roster branch, or helper-mode routing secretly assume any runtime-manifest / multi-runtime machinery from 2-6?), or whether helper-instance should be sequenced after, or merged into, the generic-runtime task. Don't build the gated wiring until this is settled.
+DEPENDENCY QUESTION (Craig, 2026-06-15, resolved 2026-06-24 — see below): doesn't helper-instance support depend on generic agent runtime support? Starting point: the spec frames this work as Phase 1.5, "Independent of the spec's phases 2-6 (runtime-neutral refactor), which stay gated on their own go/no-go," and the body claims it sits only on the already-shipped session-context split. The separate =Generic agent runtime support — Codex spec v0= task (#C, below) is that phases-2-6 arc. So the spec's stated answer is "no, 1.5 is independent" — but confirm that's actually true for every wiring slice (does ai --helper, the roster branch, or helper-mode routing secretly assume any runtime-manifest / multi-runtime machinery from 2-6?), or whether helper-instance should be sequenced after, or merged into, the generic-runtime task.
+
+*** 2026-06-24 Wed @ 00:30:32 -0400 RESOLVED — independent, unblocked (keyword VERIFY → TODO)
+Craig's call (2026-06-24): helper-instance is independent of the generic-runtime refactor and builds on its own. The shipped pieces and the remaining wiring are all shared-file concurrency-safety (two Edit writers, one file), orthogonal to which LLM runtime launches — none of it assumes the runtime-manifest / multi-runtime machinery of phases 2-6. One caveat: =ai --helper= overlaps the launcher refactor the generic-runtime arc plans, so keep that launcher change small and contained so the later refactor doesn't fight it. Now a buildable [#B] task behind its own three-ring gate (bats → sandbox drills → live pilot).
+
+** DOING [#B] Wrap-up inbox/transcript routing to destination projects :feature:spec:
+:PROPERTIES:
+:CREATED: [2026-06-13 Sat]
+:LAST_REVIEWED: 2026-06-24
+:END:
+Optional wrap-up step that surfaces filed keepers belonging to another project, recommends a destination, and routes each to that project's =inbox/= via =inbox-send= (the destination's own =process-inbox= files it; transcript filing deferred to vNext). Spec: [[file:docs/design/wrapup-routing-spec.org]] — Ready, [9/9] decisions. Source proposal: [[file:docs/design/2026-06-13-wrapup-inbox-transcript-routing-proposal.org]].
+
+*** 2026-06-21 Sun @ 02:06:37 -0400 Spec-review + spec-response complete — Ready
+Craig's review challenge reshaped the design from a direct cross-repo =todo.org= move to =inbox-send= delivery into the destination's inbox (safer: reuses the sanctioned cross-project path, gets provenance + per-project filing for free, degrades gracefully where a destination has an =inbox/= but no =todo.org=). D2/D3 superseded; D7 (inbox-send delivery), D8 (=:ROUTE_CANDIDATE:= marker at file time), D9 (local source removal + reject-flow recovery) added. Spec-review file consumed and deleted. Implementation-task breakdown filed below (spec-response Phase 6).
+
+*** 2026-06-24 Wed @ 00:21:20 -0400 Reconcile — marker sub-task repointed at inbox.org
+The 2026-06-23 inbox consolidation (24ca58d) merged =process-inbox= + =monitor-inbox= + =inbox-zero= into one =inbox.org= engine (process/monitor/roam modes) and deleted the three old files. The =:ROUTE_CANDIDATE:= marker sub-task targeted =process-inbox.org='s Phase D — repointed it to =inbox.org= process mode (core §3 "File as TODO"). No build has started, so this is a target-rename only; the spec design is unaffected.
+
+*** TODO [#B] Recommendation engine + destination discovery :feature:solo:
+Pure function =(item, project-list) → (destination, confidence)= reusing =inbox-send.py='s =discover_projects= for the project list. Confidence tiers: strong (destination name/path literal in the item), weak (topic-word overlap only — still routed, visibly labeled), none (stays put, never surfaced). Unit-tested directly: strong/weak/none, two-project tie, empty project list. Covers spec Phases 1 + 3. Spec: [[file:docs/design/wrapup-routing-spec.org]].
+
+*** TODO [#B] =:ROUTE_CANDIDATE:= marker in inbox process mode :feature:solo:
+Extend =inbox.org= process mode's "File as TODO" disposition (core §3 / Phase D) to stamp =:ROUTE_CANDIDATE: <inferred-project>= on any keeper whose inferred home differs from the current project (uses the engine above). Edit the canonical, sync the =.ai/= mirror, verify sync-check clean. Spec Phase 2 / D8. Spec: [[file:docs/design/wrapup-routing-spec.org]]. (Originally targeted =process-inbox.org=, merged into =inbox.org= by the 2026-06-23 consolidation.)
+
+*** TODO [#B] Wrap-up router sub-step in wrap-it-up.org :feature:solo:
+Add the optional router to =wrap-it-up.org= Step 3 after the inbox sanity check: surface the =:ROUTE_CANDIDATE:= batch (task / destination / delivery mode / confidence), go/skip; on go, per candidate =inbox-send= a one-task handoff to the destination's =inbox/= and remove the keeper from the local =todo.org=; empty set = silent. Name the gate-vs-optional split in the prose. Edit canonical + sync mirror. Spec Phase 4 / D7 / D9. Spec: [[file:docs/design/wrapup-routing-spec.org]].
+
+*** TODO [#B] Wrap-up routing — test surface :test:solo:
+Unit: recommendation engine (strong/weak/none, two-project tie, empty list); marker stamping (cross-project keeper tagged, local keeper not, standing backlog never). Integration (bats, fixture projects + temp =todo.org=): go issues N =inbox-send= calls to the right inboxes with sources removed; skip leaves all in place; empty set = zero interaction; a candidate whose destination has =inbox/= but no =todo.org= still delivers. Spec: [[file:docs/design/wrapup-routing-spec.org]] (Acceptance criteria).
+
+*** TODO [#B] Wrap-up routing — manual end-to-end validation :test:
+What we're verifying: a real keeper routes through a live wrap and the destination actually files it.
+- In a project session, let process-inbox file a handoff whose home is a different project; confirm the local task carries =:ROUTE_CANDIDATE: <dest>=.
+- Run wrap-it-up; at the router sub-step, confirm the candidate is surfaced with the right destination + confidence, then choose "go".
+- Confirm a =from-<thisproject>= handoff landed in the destination's =inbox/= and the keeper was removed from the local =todo.org=.
+- Open the destination project; confirm its startup/process-inbox files the handoff into its =todo.org= per its own conventions.
+Expected: the task ends up in the destination's =todo.org=, gone from the source, with no foreign =todo.org= written directly. Not =:solo:= — needs a real cross-project wrap and the destination's next session.
+
+*** TODO [#D] Wrap-up routing — transcript filing (vNext) :feature:no-sync:
+File a meeting recording into the destination =assets/= per =working-files.md=, batch go/skip mirroring the task router. Gated on the source-location decision (spec D4). Spec: [[file:docs/design/wrapup-routing-spec.org]] (Phase 5).
+
+** TODO [#C] Multiple agent-source improvements :spec:
+:PROPERTIES:
+:CREATED: [2026-06-23 Tue]
+:LAST_REVIEWED: 2026-06-24
+:END:
+Make the tooling agent-agnostic instead of Claude-specific. Three threads from Craig (roam 2026-06-23): (1) give the agent a name so workflows don't say "Claude" everywhere — a non-Claude agent (Codex) reading "you are Claude" gets confused; evaluate whether naming resolves the confusion or whether other spots also leak Claude-specificity. (2) Pull agent-neutral content out of Anthropic-specific files (=CLAUDE.md=) into a shared source that each agent's own entry file points to, so Codex (which runs more literal) reads the same rules; or link =CLAUDE.md= and the Codex equivalent to one source. Have Codex review the workflows for literal-reading wording gaps. (3) Send =.emacs.d= a note (inbox-send) to let =ai-term= launch Claude / Codex / a local ollama LLM, switchable seamlessly at session start. Spec-shaped — needs design before build. From the roam inbox 2026-06-23 (deferred from the 2026-06-21 session).
+
+*** 2026-06-24 Wed @ 00:21:20 -0400 Partial — agent-neutral wording sweep + thread-3 note landed
+Thread 2's wording half shipped in 6ad0442 (=refactor(rules): use agent-neutral language in shared rules=): agent-as-actor phrasing replaced with "the agent" across interaction.md, cross-project.md, triggers.md, working-files.md. Thread 3's note reached =.emacs.d=, whose 2026-06-23 inbox FYI confirms it received and filed the "multi-LLM support" ai-term handoff. Remaining and still TODO: thread 1 (give the agent a name), and thread 2's structural half (extract agent-neutral content into a shared source with a Codex entry-file pointer, then have Codex review the workflows for literal-reading gaps).
+
+** TODO [#C] apkg → org-drill converter :feature:solo:
+:PROPERTIES:
+:CREATED: [2026-06-22 Mon]
+:LAST_REVIEWED: 2026-06-24
+:END:
+Inverse of =flashcard-to-anki.py=: read an Anki =.apkg= (zip → =collection.anki2=/=.anki21= sqlite) and emit an org-drill =.org= in the house canonical shape. Recovers orphaned decks (=deepsat-fundamentals.apkg= has no saved =.org= source) and enables phone→org round-trip. Mapping: deck name → =#+TITLE=; each note → =** <Front> :drill:= with Back as body; card tag → =* Section= grouping (best-effort); Back HTML → org (=<br>= → newlines, unescape entities, strip =<hr id="answer">=); fresh =:ID:= UUID per card. Edge cases for tests: multiple decks per apkg, non-basic note types (skip/warn), HTML entities, empty back, media refs, =.anki2= vs =.anki21= schema. Lives beside the flashcard-* family in =claude-templates/.ai/scripts/= (a new file must be built in canonical — downstream =.ai/scripts/= is wiped by startup =--delete=). PEP 723 uv-run, stdlib =zipfile= + =sqlite3= (no genanki for reading). Acceptance: round-trip a known org-drill source through =flashcard-to-anki.py= then back, assert cards match. Build request: [[file:docs/design/2026-06-21-apkg-to-orgdrill-buildreq.org][buildreq]]. Backlog, not urgent. From home 2026-06-21.
+
+** TODO [#C] flashcard-stats refutation / claim-prompt mode :feature:
+:PROPERTIES:
+:CREATED: [2026-06-22 Mon]
+:LAST_REVIEWED: 2026-06-24
+:END:
+A refutation card (heading is a bare false claim, body is the rebuttal) is valid org-drill but trips two BLOCKING =flashcard-stats.py= checks as false positives: non-prompt-heading (a declarative claim has no =?= or imperative verb) and answer-leakage (claim words reappear in the rebuttal). =flashcard-sync='s gate then blocks the whole deck. Fix (pick one): a file-level =#+DECK_KIND: refutation= keyword that skips those two checks for the file, or a per-card =:claim:= tag exempting individual cards. Option 1 is simpler and matches how the deck works (the whole file is one family). Also document the family in =flashcard-review.org= and add tests (refutation-marked file passes despite declarative headings + claim/answer overlap). Edits =flashcard-stats.py= — coordinate with the multi-tag reconcile, same file. Proposal: [[file:docs/design/2026-06-21-flashcard-stats-refutation-proposal.org][proposal]]. Backlog. From home 2026-06-21.
+#+begin_src cj: comment
+ we need to make it more generic than this. there will be other cards like this in the future. let's not block against the information when it exists in the org header.
+#+end_src
+
+** TODO [#C] Guard against hardcoded host identity in synced files :feature:solo:
+:PROPERTIES:
+:CREATED: [2026-06-22 Mon]
+:LAST_REVIEWED: 2026-06-24
+:END:
+A =CLAUDE.md= / notes file that asserts mutable environment identity as a fixed fact ("This machine is ratio", a current OS, an IP, "the laptop") is false on every machine the synced/tracked file lands on but one. It bit a real archsetup session: a stale "this machine is ratio" line made the agent reason backwards all session while on velox. Proposal: a claude-rule — don't assert mutable host/env identity as a fixed fact in a tracked/synced project file; derive it at runtime and name the command (=uname -n= for host; the =hostname= binary is often absent). Optionally a codify- or startup-time lint flagging "this machine is <name>" / "the current host is" style claims. Decide rule-only vs rule+lint. Proposal: [[file:docs/design/2026-06-21-host-identity-guard-proposal.org][proposal]]. From archsetup 2026-06-21.
+
+** TODO [#C] coverage-summary.el install location vs CI reachability :bug:
+:PROPERTIES:
+:CREATED: [2026-06-22 Mon]
+:LAST_REVIEWED: 2026-06-24
+:END:
+The elisp bundle installs =coverage-summary.el= into =.claude/scripts/=, which is gitignored in code projects, so CI can't run =make coverage-summary= against it. emacs-wttrin flagged this (its copy's header was rewritten to claim a tracked =scripts/= home). Decide: ship =coverage-summary.el= to a tracked =scripts/= dir so CI reaches it, or keep =.claude/scripts/= and document it as a local-only helper. If moved, reconcile the bundle install path + the =make coverage-summary= fragment + the script's header comment. Surfaced 2026-06-21 during the coverage-summary autoloads bugfix (commit fb86736).
+
+#+begin_src cj: comment
+we can document it as a local-only helper.
+#+end_src
** VERIFY [#C] Check that memories are sync'd across machines via git :spec:
:PROPERTIES:
@@ -170,48 +306,6 @@ Expected: all four behave per the spec; any miss promotes to a bug task. (Agent-
*** 2026-06-10 Wed @ 18:21:33 -0500 Phase 4 done — monthly hygiene automation live
=scripts/kb-hygiene.sh= (6 bats green, shellcheck clean, read-only by design) inventories =:agent:= nodes, flags orphans / duplicate titles / conflict files, and writes an org report into the rulesets inbox; =roam-hygiene.timer= (monthly, Persistent) installed + enabled. Live run against the real KB verified (4 agent nodes, 428 files, 0 conflicts). Conditional vNext stays in the spec's scope tiers: a =/promote= command if the wrap-up prompt proves insufficient, an =:agent:inbox:= staging tag if free writes prove too noisy. Commit b014095.
-** DOING [#B] Wrap-up inbox/transcript routing to destination projects :feature:spec:
-:PROPERTIES:
-:CREATED: [2026-06-13 Sat]
-:LAST_REVIEWED: 2026-06-15
-:END:
-Optional wrap-up step that surfaces filed keepers belonging to another project, recommends a destination, and routes each to that project's =inbox/= via =inbox-send= (the destination's own =process-inbox= files it; transcript filing deferred to vNext). Spec: [[file:docs/design/wrapup-routing-spec.org]] — Ready, [9/9] decisions. Source proposal: [[file:docs/design/2026-06-13-wrapup-inbox-transcript-routing-proposal.org]].
-
-*** 2026-06-21 Sun @ 02:06:37 -0400 Spec-review + spec-response complete — Ready
-Craig's review challenge reshaped the design from a direct cross-repo =todo.org= move to =inbox-send= delivery into the destination's inbox (safer: reuses the sanctioned cross-project path, gets provenance + per-project filing for free, degrades gracefully where a destination has an =inbox/= but no =todo.org=). D2/D3 superseded; D7 (inbox-send delivery), D8 (=:ROUTE_CANDIDATE:= marker at file time), D9 (local source removal + reject-flow recovery) added. Spec-review file consumed and deleted. Implementation-task breakdown filed below (spec-response Phase 6).
-
-*** TODO [#B] Recommendation engine + destination discovery :feature:solo:
-Pure function =(item, project-list) → (destination, confidence)= reusing =inbox-send.py='s =discover_projects= for the project list. Confidence tiers: strong (destination name/path literal in the item), weak (topic-word overlap only — still routed, visibly labeled), none (stays put, never surfaced). Unit-tested directly: strong/weak/none, two-project tie, empty project list. Covers spec Phases 1 + 3. Spec: [[file:docs/design/wrapup-routing-spec.org]].
-
-*** TODO [#B] =:ROUTE_CANDIDATE:= marker in process-inbox :feature:solo:
-Extend =process-inbox.org='s "file as TODO" step (Phase D) to stamp =:ROUTE_CANDIDATE: <inferred-project>= on any keeper whose inferred home differs from the current project (uses the engine above). Edit the canonical, sync the =.ai/= mirror, verify sync-check clean. Spec Phase 2 / D8. Spec: [[file:docs/design/wrapup-routing-spec.org]].
-
-*** TODO [#B] Wrap-up router sub-step in wrap-it-up.org :feature:solo:
-Add the optional router to =wrap-it-up.org= Step 3 after the inbox sanity check: surface the =:ROUTE_CANDIDATE:= batch (task / destination / delivery mode / confidence), go/skip; on go, per candidate =inbox-send= a one-task handoff to the destination's =inbox/= and remove the keeper from the local =todo.org=; empty set = silent. Name the gate-vs-optional split in the prose. Edit canonical + sync mirror. Spec Phase 4 / D7 / D9. Spec: [[file:docs/design/wrapup-routing-spec.org]].
-
-*** TODO [#B] Wrap-up routing — test surface :test:solo:
-Unit: recommendation engine (strong/weak/none, two-project tie, empty list); marker stamping (cross-project keeper tagged, local keeper not, standing backlog never). Integration (bats, fixture projects + temp =todo.org=): go issues N =inbox-send= calls to the right inboxes with sources removed; skip leaves all in place; empty set = zero interaction; a candidate whose destination has =inbox/= but no =todo.org= still delivers. Spec: [[file:docs/design/wrapup-routing-spec.org]] (Acceptance criteria).
-
-*** TODO [#B] Wrap-up routing — manual end-to-end validation :test:
-What we're verifying: a real keeper routes through a live wrap and the destination actually files it.
-- In a project session, let process-inbox file a handoff whose home is a different project; confirm the local task carries =:ROUTE_CANDIDATE: <dest>=.
-- Run wrap-it-up; at the router sub-step, confirm the candidate is surfaced with the right destination + confidence, then choose "go".
-- Confirm a =from-<thisproject>= handoff landed in the destination's =inbox/= and the keeper was removed from the local =todo.org=.
-- Open the destination project; confirm its startup/process-inbox files the handoff into its =todo.org= per its own conventions.
-Expected: the task ends up in the destination's =todo.org=, gone from the source, with no foreign =todo.org= written directly. Not =:solo:= — needs a real cross-project wrap and the destination's next session.
-
-*** TODO [#D] Wrap-up routing — transcript filing (vNext) :feature:no-sync:
-File a meeting recording into the destination =assets/= per =working-files.md=, batch go/skip mirroring the task router. Gated on the source-location decision (spec D4). Spec: [[file:docs/design/wrapup-routing-spec.org]] (Phase 5).
-
-** TODO [#C] Morning ops orchestrator pilot — read-only :feature:
-:PROPERTIES:
-:CREATED: [2026-06-11 Thu]
-:LAST_REVIEWED: 2026-06-15
-:END:
-A scheduled headless morning run chaining the existing pieces: startup checks, the triage-intake scan, a system health check — producing the prep doc plus a report and a notify ping, with all remediation propose-only. Staged adoption from the 2026-06-11 insights report's "Self-Healing Daily Ops Orchestrator": read-only first; promote individual routine remediations to auto only after each has a track record. Known blockers to design around: headless MCP auth (interactively-authenticated servers are absent in cron runs) and the consent boundary (triage Phase D, anything destructive).
-
-The triage limb can reuse triage-intake's *auto mode* (added 2026-06-15, see [[file:.ai/workflows/triage-intake.org]]) — its accumulate-don't-mutate sweep is the propose-only behavior this orchestrator wants. Auto mode itself runs in-session (inherited MCP auth); the orchestrator is the durable headless schedule, so the headless-auth blocker above is the part still on this task to solve.
-
** TODO [#C] Token-rotation helper for =@a-bonus/google-docs-mcp= OAuth refresh :feature:quick:
:PROPERTIES:
:LAST_REVIEWED: 2026-06-15
@@ -268,6 +362,7 @@ Codex re-ran spec-review after the dispositions were folded in. Outcome by arc:
** TODO [#C] Spec storage location + lifecycle-status convention :spec:
:PROPERTIES:
:CREATED: [2026-06-15 Mon]
+:LAST_REVIEWED: 2026-06-24
:END:
Two coupled documentation conventions for rulesets to adopt, surfaced by .emacs.d while triaging ~28 design docs. Both land in =spec-create= ([[file:.ai/workflows/spec-create.org]]) and likely a new =docs-lifecycle= rule under =claude-rules/=. Source proposal: [[file:docs/design/2026-06-15-spec-storage-lifecycle-proposal.org]] (.emacs.d handoff 2026-06-15).
@@ -288,6 +383,7 @@ Follow-up once decided: update spec-create to emit into =docs/specs/= with the c
** DOING [#C] "fix speedrun" cross-project autonomous-batch mode :feature:spec:
:PROPERTIES:
:CREATED: [2026-06-15 Mon]
+:LAST_REVIEWED: 2026-06-24
:END:
A named mode for coding projects: Craig names an ordered task set and says "fix speedrun"; the set is worked autonomously, each task held to the full quality bar (TDD red→green, =/review-code=, =/voice= on the commit) and committed + pushed as its own logical commit, with a VERIFY filed instead of guessing on anything underspecified, and an end-of-set page listing completed + remaining tasks. Surfaced by .emacs.d from a 2026-06-15 theme-studio session where the shape worked. Source proposal: [[file:docs/design/2026-06-15-fix-speedrun-workflow-proposal.org]] (.emacs.d handoff 2026-06-15). Build via =spec-create= when worked; we handle the task in priority order.
@@ -309,21 +405,38 @@ Review [[file:docs/design/2026-06-16-autonomous-batch-execution-spec.org]] (cove
** TODO [#C] ntfy phone channel as general two-way agent-comms :feature:spec:
:PROPERTIES:
:CREATED: [2026-06-20 Sat]
+:LAST_REVIEWED: 2026-06-24
:END:
Proposal from the home project (2026-06-17): promote the self-hosted ntfy-over-Tailscale phone channel it built and verified on ratio into a general two-way agent-comms tool rulesets owns. Full proposal: [[file:docs/design/2026-06-17-ntfy-agent-comms-proposal.org]] (as-built runbook stays in the home project at =working/phone-notifications/spec.org=). What rulesets would decide: canonicalize =phone-notify= (send) plus a new =phone-recv= (check-since) as synced bin scripts; the per-machine config/secret convention (token in =~/.config/phone-notify/config= chmod 600 today, vs GPG-encrypted in dotfiles); a reference =ntfy-inbound-handler= plus systemd user-unit for event-driven delivery (Tier A subscriber routes inbound to inbox/notify, Tier B inbound spawns an agent session, Tier C notify a live session — harness research); approval-button workflows for the commits.md gates when Craig is away from the desk (tap-to-approve, the high-value concrete use); and the relationship to the retired cross-agent-comms scripts (ntfy may be the transport they lacked). Worked via =spec-create=. Blocks the triage-intake phone-push task below.
-** TODO [#C] Reconcile flashcard multi-tag tooling into canonical :chore:
+** TODO [#C] Reconcile flashcard multi-tag tooling into canonical :chore:quick:solo:
:PROPERTIES:
:CREATED: [2026-06-20 Sat]
+:LAST_REVIEWED: 2026-06-24
:END:
The work project edited two synced scripts locally as a stopgap (2026-06-17) and asked rulesets to fold them into the canonical so the next sync doesn't revert them. Preserved bundle: [[file:docs/design/2026-06-17-flashcard-multitag-note.md][note]], [[file:docs/design/2026-06-17-flashcard-multitag-to-anki.py][to-anki.py]], [[file:docs/design/2026-06-17-flashcard-multitag-stats.py][stats.py]]. Change: support a second org tag on drill headings (=:fundamental:drill:=) for curated subset decks. =flashcard-to-anki.py= — broaden =CARD_RE= to match a trailing tag block (a heading is a card when =drill= is among its tags), bound the card body by any L1/L2 heading, add =--tag-filter <tag>= (emit only cards carrying that tag) and =--guid-salt <s>= (separate GUID space so a subset deck imports non-empty without disturbing the full deck's SRS state). =flashcard-stats.py= — same =CARD_RE=/=HEADING_RE= broadening plus a drill-membership guard. Use the preserved to-anki.py (the 0953 version: dropped an unused =heading_tags()= helper, tightened =CARD_RE= =(.*?)=→=(.+?)= for parity with stats). Apply to both =.ai/scripts/= and =claude-templates/.ai/scripts/=, add a multi-tag bats case to =flashcard-sync.bats= (a =:foo:drill:= heading parses; =--tag-filter foo= returns only those), verify the full deck still parses to 465 and =--tag-filter fundamental= returns 100, then sync-check + make test. Shared-asset change, so review-gated.
-** TODO [#C] triage-intake.org auto mode — push each sweep to phone (ntfy) :feature:
+Note (2026-06-24): the Anki =#+TITLE= deck-name fix landed (commit 060a938) — =default_deck_name= is now =default_deck_name(input_path, org_text)= with a new docstring. The preserved 2026-06-17 =to-anki.py= predates that, so *don't* copy it wholesale (it would revert the title-fix). Re-derive the multi-tag changes against the current canonical =flashcard-to-anki.py= and keep the =#+TITLE= behavior.
+
+** TODO [#C] triage-intake.org auto mode — push each sweep to phone (ntfy) :feature:solo:
:PROPERTIES:
:CREATED: [2026-06-20 Sat]
+:LAST_REVIEWED: 2026-06-24
:END:
The work project (2026-06-18) added a "Push each sweep to Craig's phone (ntfy) — the primary delivery" subsection under "Trigger and delivery" in triage-intake.org auto mode, and asks to fold it into the canonical engine plus re-sync. Preserved bundle: [[file:docs/design/2026-06-18-triage-intake-phone-push-note.org][note]] + [[file:docs/design/2026-06-18-triage-intake-phone-push-workflow.org][edited workflow]]. Auto mode is the away-from-desk / vacation mode, so phone-notify becomes the primary delivery each sweep (fuller end-of-sweep output: per-source deltas, open-PR/Linear state, awaiting-ack list, one-line verdict, timestamp; SCAN FAILED banner on any source failure), plus phone-recv polling each sweep for Craig's replies. Falls back to inline when phone-notify is absent. Depends on the ntfy agent-comms task above (phone-notify/phone-recv must be canonicalized first). Shared template-workflow change, so review-gated.
+** TODO [#D] Fully-unattended scheduled inbox check (/schedule cron pass) :feature:
+:PROPERTIES:
+:CREATED: [2026-06-23 Tue]
+:END:
+vNext from the inbox-consolidation spec. =auto inbox zero= (v1) is the interactive =/loop= recurring check that waits for Craig's yes before executing. A fully-unattended =/schedule= cron pass that fires while Craig is away needs its own contract before it can ship: read-only vs may-mutate =todo.org= / =~/org/roam/inbox.org=, how a find surfaces asynchronously when Craig isn't at the session, how dedup state persists across runs that don't share a session, and what session/auth context a cron run carries. Design it after v1 consolidation lands. From the inbox-consolidation spec-review (Codex finding 1). See [[file:docs/inbox-workflow-consolidation-spec.org][spec]].
+
+** TODO [#D] Warn-only pre-commit hook for tooling-path enumeration :feature:
+:PROPERTIES:
+:CREATED: [2026-06-22 Mon]
+:END:
+Optional enforcement teeth for the no-attribution / no-tooling-artifacts tightening landed 2026-06-22 (commit 91217d9), which is documentation-only. A warn-only (not blocking) pre-commit hook could scan the commit subject + body for tooling-path enumeration (=CLAUDE.md=, =.claude/=, =.ai/=, =todo.org=, =notes.org=, =session-context=) and AI-attribution language, with the two exemptions baked in: a commit whose change IS one of those files, and private single-user repos. Must warn, not block — a rigid grep false-positives on legit subject mentions. Deferred: Craig chose docs-only for now.
+
** TODO [#D] Build =create-documentation= skill for high-quality project/product docs :feature:
:PROPERTIES:
:LAST_REVIEWED: 2026-06-15
@@ -2835,3 +2948,57 @@ Touches four synced template workflows and needs a curation pass on the best-pra
Drafted [[file:docs/design/2026-06-16-encourage-kb-contribution-spec.org][the KB-contribution spec]]: four light workflow prompts (startup nudge, triage-intake + inbox-zero end-of-flow reminders, an early wrap-up reflection feeding the existing KB receipt) plus one Craig-authored best-practices node curated from Ahrens / Matuschak / org-roam guidance. Five open sub-decisions filed as decisions-as-TODO in the spec.
*** 2026-06-20 Sat @ 23:29:10 -0400 Spec ratified + built
Craig ratified all five decisions (2026-06-20) and added D6 — a read-side startup consult-nudge surfacing project-relevant KB node titles, the counterpart the original write-only design lacked. Built all of it: the best-practices node (=~/org/roam/agents/20260620232112-agent-kb-best-practices.org=), startup's two Phase C nudges (consult + contribute, gated on the roam clone), the conditional capture reminders in triage-intake + inbox-zero, and the early wrap-up reflection feeding the existing receipt. Commits 76e5559 (workflows + spec) and the related lint checker f6dde4e. Trigger for the build: receipt data showed "promoted 0 / consulted no" across recent sessions.
+** DONE [#C] Bash/shell language bundle :feature:
+CLOSED: [2026-06-23 Tue]
+:PROPERTIES:
+:CREATED: [2026-06-23 Tue]
+:END:
+Built =languages/bash/= the same session it was filed: bash.md + bash-testing.md rules, a shellcheck PostToolUse validate hook (covers =.sh=, =.bash=, and extensionless shell scripts by shebang; 8 bats tests), a shellcheck pre-commit githook, settings.json wiring, gitignore-add.txt, and a "Bash/shell project" CLAUDE.md. shfmt left out of the blocking path on purpose (shell has no canonical style). Makefile test target now discovers =languages/*/tests/*.bats=.
+
+No =languages/= bundle fits a shell-heavy project. archangel (437 =.sh= files) and archsetup are bash projects with nothing that matches; installing elisp/python gives them the wrong language rules. Build a =languages/bash/= bundle on the elisp/go pattern: =claude/rules/bash.md= (style — =set -euo pipefail=, quoting, =[[ ]]=, trap/cleanup) + =bash-testing.md= (bats conventions), a PostToolUse validate hook (=shellcheck= on edited =.sh=), a =githooks/pre-commit= running shellcheck on staged shell files, =settings.json= wiring, =gitignore-add.txt=, and its own =CLAUDE.md= headed "Bash/shell project." Urgency dropped 2026-06-23: install-lang now seeds the language-neutral default CLAUDE.md when a bundle ships none, so a bash project no longer gets a mislabeled "Elisp project" header — the bundle is now the accurate-rules win, not a mislabel fix. From archangel 2026-06-23 ([[file:docs/design/2026-06-23-install-lang-claude-md-gap.org][handoff]]).
+** DONE [#B] Consolidate inbox/triage workflows + scheduled inbox check :chore:
+CLOSED: [2026-06-23 Tue]
+:PROPERTIES:
+:CREATED: [2026-06-23 Tue]
+:END:
+Built per the Ready spec: =process-inbox= + =monitor-inbox= + =inbox-zero= merged into one =inbox.org= engine (shared core + process/monitor/roam modes + the interactive =auto inbox zero= =/loop= mode); =triage-intake= and =no-approvals= stay separate. Callers repointed (INDEX, protocols, startup Phase C, wrap-up Step 3), old files deleted, stale-ref grep clean, workflow-integrity + sync-check + full suite green. The fully-unattended =/schedule= cron pass is vNext (see the =[#D]= task above). [[file:docs/inbox-workflow-consolidation-spec.org][spec]].
+** DONE [#C] inbox-zero: delete empty roam entries on triage :feature:
+CLOSED: [2026-06-23 Tue]
+:PROPERTIES:
+:CREATED: [2026-06-23 Tue]
+:END:
+Done in commit 3da2725 (empty-entry sweep folded into Phase D's reconcile, after capture-guard + pull, with the claimed-item removal) and carried into the consolidated =inbox.org= roam mode (Phase B =empty= bucket + Phase D sweep). From the roam inbox 2026-06-23.
+** DONE [#C] Surface cross-project dependencies first in what's-next :feature:spec:
+CLOSED: [2026-06-24 Wed]
+:PROPERTIES:
+:CREATED: [2026-06-24 Wed]
+:END:
+Tasks that depend on another project can sit for ages when the dependency is low-priority or needs its own spec process — e.g. wrap-teardown depends on =.emacs.d= for the =ai-term= companion. Craig's proposal (roam 2026-06-24): (1) an org-tag marking a task as blocked-by / depends-on another project (pick a short tag name); (2) several ways to bind dependencies into the what's-next (=open-tasks.org=) decision tree so blocked-by-dependency tasks surface first; (3) review the what's-next workflow as a whole, since many projects use it.
+
+Built 2026-06-24 (tag name =:blocked:=, Craig's pick): the =:blocked:= tag + =:BLOCKED_BY: <project>: <what>= property convention in =todo-format.md=, and =open-tasks.org= Next Mode now excludes =:blocked:= tasks from the cascade and surfaces them in a dedicated "Blocked on other projects" section with an =inbox-send= nudge offer. Applied live to the wrap-teardown task above. Commits feat(tasks) cross-project-dependency.
+** DONE [#C] Task-audit: consolidate adjacent / related tasks :feature:
+CLOSED: [2026-06-24 Wed]
+:PROPERTIES:
+:CREATED: [2026-06-24 Wed]
+:END:
+The task-audit workflow should also consider combining related tasks when they're adjacent, so a spread-out effort reads as one whole. Craig's example (roam 2026-06-24): the agent-agnostic / agent-source work could collapse into one item, or at least a parent task with the related ones as children.
+
+Built 2026-06-24: =task-audit.org= Phase C.5 reads the open-task set, spots semantic clusters by judgment, and proposes per cluster either a merge (same-work members fold into one) or a parent-with-children grouping (related-but-distinct), applied only on Craig's confirm — broader than Phase C's exact-duplicate fold. Commit feat(task-audit) consolidate.
+** DONE [#B] Anki deck name from #+TITLE :bug:quick:solo:
+CLOSED: [2026-06-24 Wed]
+:PROPERTIES:
+:CREATED: [2026-06-22 Mon]
+:LAST_REVIEWED: 2026-06-24
+:END:
+flashcard-to-anki.py's =default_deck_name= returns =input_path.stem= (the filename), so every deck built through =flashcard-sync= (which passes no =--deck=) is named after the file, not the curated =#+TITLE=. =flashcard-review.org= already documents the intended behavior ("the #+TITLE line drives the Anki deck name"); the script never matched it. Fix: =default_deck_name(input_path, org_text)= scans for a =#+TITLE:= line (case-insensitive, trimmed) and returns it, basename fallback when absent; =main()= passes the already-read =org_text=. Edited script + test ready (validated, 29 pass): [[file:docs/design/2026-06-21-anki-titlefix-flashcard-to-anki.py][script]], [[file:docs/design/2026-06-21-anki-titlefix-test.py][test]], rationale [[file:docs/design/2026-06-21-anki-titlefix-proposal.org][proposal]]. Apply to both =.ai/scripts/= and =claude-templates/.ai/scripts/=, sync-check + make test. Migration caveat: deck ID derives from the name, so decks previously built without =--deck= land as new decks on next import (old basename-named decks keep history, delete by hand). Coordinate with "Reconcile flashcard multi-tag tooling into canonical" below — both edit =flashcard-to-anki.py=, build together to avoid conflicting edits. Shared-asset, review-gated. From home 2026-06-21.
+
+Done 2026-06-24 (commit 060a938): applied the pre-staged script + test red-to-green (5 new =#+TITLE= tests, 29 pass total), synced both script dirs, full suite green. The two redundant staging =.py= files removed, the rationale proposal kept.
+** CANCELLED [#C] Morning ops orchestrator pilot — read-only :feature:
+CLOSED: [2026-06-24 Wed 05:46]
+:PROPERTIES:
+:CREATED: [2026-06-11 Thu]
+:LAST_REVIEWED: 2026-06-15
+:END:
+A scheduled headless morning run chaining the existing pieces: startup checks, the triage-intake scan, a system health check — producing the prep doc plus a report and a notify ping, with all remediation propose-only. Staged adoption from the 2026-06-11 insights report's "Self-Healing Daily Ops Orchestrator": read-only first; promote individual routine remediations to auto only after each has a track record. Known blockers to design around: headless MCP auth (interactively-authenticated servers are absent in cron runs) and the consent boundary (triage Phase D, anything destructive).
+
+The triage limb can reuse triage-intake's *auto mode* (added 2026-06-15, see [[file:.ai/workflows/triage-intake.org]]) — its accumulate-don't-mutate sweep is the propose-only behavior this orchestrator wants. Auto mode itself runs in-session (inherited MCP auth); the orchestrator is the durable headless schedule, so the headless-auth blocker above is the part still on this task to solve.