aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.ai/notes.org2
-rw-r--r--.ai/protocols.org7
-rw-r--r--.ai/sessions/2026-07-25-09-24-invalid-block-filter-push-task-review.org83
-rw-r--r--.ai/sessions/2026-07-25-15-34-clean-wrap-and-inbox-safe-sync.org85
-rw-r--r--.ai/workflows/startup.org27
-rw-r--r--.ai/workflows/work-the-backlog.org2
-rw-r--r--.ai/workflows/wrap-it-up.org58
-rw-r--r--.claude/settings.json9
-rw-r--r--.codex/hooks.json30
-rw-r--r--Makefile21
-rw-r--r--claude-rules/cross-project.md8
-rw-r--r--claude-rules/todo-format.md3
-rw-r--r--claude-templates/.ai/protocols.org7
-rw-r--r--claude-templates/.ai/workflows/startup.org27
-rw-r--r--claude-templates/.ai/workflows/work-the-backlog.org2
-rw-r--r--claude-templates/.ai/workflows/wrap-it-up.org58
-rwxr-xr-xclaude-templates/bin/ai40
-rwxr-xr-xclaude-templates/bin/git-worktree-gate185
-rw-r--r--hooks/README.md2
-rwxr-xr-xhooks/ai-wrap-teardown.sh43
-rwxr-xr-xhooks/rulesets-write-boundary.py94
-rw-r--r--hooks/settings-snippet.json6
-rw-r--r--hooks/tests/test_rulesets_write_boundary.py110
-rw-r--r--inbox/lint-followups.org17
-rw-r--r--scripts/tests/ai-launcher-characterization.bats41
-rw-r--r--scripts/tests/ai-wrap-teardown-hook.bats66
-rwxr-xr-xscripts/tests/git-worktree-gate.bats146
-rw-r--r--scripts/tests/install-hooks-link.bats15
-rw-r--r--todo.org71
29 files changed, 1166 insertions, 99 deletions
diff --git a/.ai/notes.org b/.ai/notes.org
index d53f8e0..447c33c 100644
--- a/.ai/notes.org
+++ b/.ai/notes.org
@@ -83,6 +83,6 @@ Format:
Markers maintained by workflows to record when they last ran. Read by other workflows that gate their behavior on freshness.
:LAST_AUDIT: 2026-07-20 (open set current — this session's shipped work (working/temp, triage-source-activation, silent-until-signal, suspend detach) closed as it went; sentry cluster consolidated (merged the /schedule tasks, added cross-host-coordination); nothing shipped-but-open per git reconcile. Live finding: the Polyglot + Subprojects scouting tasks are SCHEDULED 2026-07-20 and due.)
-:LAST_INBOX_PROCESS: 2026-07-20 (morning sentry review + build session: triage migration confirmations from home + work processed; earlier 2026-07-18 pass folded in)
+:LAST_INBOX_PROCESS: 2026-07-25 (consolidated home + work Claude-to-Codex MCP registry proposals into one [#B] parked spec decision; memory auditor split from the registry work)
Format: one =:MARKER: YYYY-MM-DD= line per workflow. Workflows overwrite their own marker on completion.
diff --git a/.ai/protocols.org b/.ai/protocols.org
index 3cd02ef..2ea041b 100644
--- a/.ai/protocols.org
+++ b/.ai/protocols.org
@@ -84,7 +84,7 @@ Do NOT estimate, guess, or rely on memory. Just run the command. It takes one se
Every session pulls rulesets first, then the local project repo. Rulesets carries the canonical behavioral rules and =.ai/= templates (the old =claude-templates= repo is folded in as a subtree at =rulesets/claude-templates/=); the project pull lands commits pushed from other machines or teammates since the last session.
-Resolve any dirty-tree or merge issue at each step before moving on. Both pulls run as =git pull --ff-only= (or =git merge --ff-only= against the fetched upstream), so anything non-trivial — non-fast-forward history, dirty working tree, diverged branches — aborts. Surface the state and stop. Never auto-stash, auto-merge, or auto-rebase; the user resolves the conflict before further work.
+Resolve any sync-blocking tree or merge issue at each step before moving on. The shared =git-worktree-gate sync-safe= policy permits untracked deliveries beneath =inbox/= so receiving a handoff never prevents another project from refreshing rulesets; every staged or tracked change, dirty submodule, Git operation in progress, or untracked path outside =inbox/= blocks. Both pulls run as =git pull --ff-only= (or =git merge --ff-only= against the fetched upstream), so non-fast-forward history and diverged branches also abort. Surface the state and stop. Never auto-stash, auto-merge, or auto-rebase; the user resolves the conflict before further work.
Mechanics live in =startup.org= Phase A.0. The rule lives here because it governs the very first action of every session: load the freshest behavioral rules and templates before anything else runs.
@@ -567,12 +567,13 @@ When monitoring a long-running process (rsync, large downloads, builds, VM tests
** "Wrap it up" / "That's a wrap" / "Let's call it a wrap"
-When Craig says any of these phrases (or variations), execute the wrap-up workflow: [[file:workflows/wrap-it-up.org][wrap-it-up.org]]. Four steps:
+When Craig says any of these phrases (or variations), execute the wrap-up workflow: [[file:workflows/wrap-it-up.org][wrap-it-up.org]]. Five load-bearing steps:
1. *Finalize the Summary* in =.ai/session-context.org= (populate the 5 subsections from the Session Log)
2. *Rename* =.ai/session-context.org= → =.ai/sessions/YYYY-MM-DD-HH-MM-description.org=
3. *Git commit + push* to all remotes (see Git Commit Requirements)
-4. *Valediction* — brief, warm, specific closing
+4. *Certify the clean tree* with =git-worktree-gate certify=. Any remaining staged, unstaged, untracked, submodule, or in-progress-operation state blocks wrap entirely; report each path and the exact decision needed. There is no dirty-file deferral.
+5. *Valediction* — brief, warm, specific closing, reachable only after certification
The absence of =.ai/session-context.org= after wrap-up is the signal that the session ended cleanly. If the file is still there at the next session start, the previous session was interrupted.
diff --git a/.ai/sessions/2026-07-25-09-24-invalid-block-filter-push-task-review.org b/.ai/sessions/2026-07-25-09-24-invalid-block-filter-push-task-review.org
new file mode 100644
index 0000000..d0814f9
--- /dev/null
+++ b/.ai/sessions/2026-07-25-09-24-invalid-block-filter-push-task-review.org
@@ -0,0 +1,83 @@
+#+TITLE: Session Context — 2026-07-24
+#+AUTHOR: Craig Jennings
+
+* Summary
+
+** Active Goal
+
+An evening of backlog clearing in the order Craig picked: push the delivery-blocking commits, fix the lint-org =invalid-block= false positive that home had just unblocked, then run a task-review cycle. All three finished.
+
+** Decisions
+
+- The =invalid-block= fix is a filter on org-lint's output, not a local checker edit. Home settled the open question and I re-verified both halves before acting: the string appears nowhere in =lint-org.el=, and =org-lint--checkers= enumerates it in batch Emacs. Same resolution as the earlier =link-to-local-file= episode.
+- Left the =,**= comma-escape in =claude-templates/.ai/notes.org= in place. The task said the fix "lets that escape be reverted," but the escape is correct org for a literal =**= inside a verbatim block, so reverting trades correctness for nothing once the finding is suppressed. Reasoning recorded in the closed task rather than acting on the permission silently.
+- Task review: all seven in the batch kept as-is, no new =:quick:= or =:solo:= tags, confirmed by Craig in one pass.
+- Work's sentry loop was left alone. Craig said "sentry stop" here, but the running loop belongs to the work project (branch =sentry/2026-07-25-ratio=, firing hourly into =aiv-work:1.1=), and the stop procedure's later steps — lock release, branch disposition, approval queue — need that session's context. Surfaced rather than half-executed.
+
+** Data Collected / Findings
+
+- =invalid-block= is org-lint's own checker. Reproduced the false positive three ways: a paired example block with a heading-shaped body line flags both delimiters, a paired src block holding a literal =#+end_example= flags three lines, and a genuinely unterminated block flags once and must keep doing so.
+- Verified the fix against home's live fixture in both directions: its =.ai/notes.org= produced exactly the two reported findings (lines 386 and 398) under the pre-change script and zero under the new one, file untouched.
+- The uppercase-delimiter path (=#+BEGIN_EXAMPLE=) was handled but untested. Confirmed it was a real trigger — 2 findings before, 0 after — before adding the boundary test.
+- Part of the task-review staleness count measures work that can't be delegated rather than work nobody read. This batch was the deliberation-heavy tail, where every task needs a decision from Craig mid-stream, which is why the speedruns kept stepping past them.
+- The KB orphan task cites a 2026-07-01 snapshot of 53 agent nodes. Startup counted 104, so the KB has doubled and the snapshot is worth even less than the task body assumed.
+- Five =[#D]= tasks carry no =:LAST_REVIEWED:= at all. The staleness script excludes =[#D]= but =lint-org= flags them, so the two tools disagree permanently. Left alone; needs a decision about which is right.
+
+** Files Modified
+
+- =claude-templates/.ai/scripts/lint-org.el= (+ mirror) — =lo--matched-block-regions= pairs blocks by line scan under org's real rule, memoized on the buffer modification tick; =lo--handle-item= drops an =invalid-block= finding inside a paired region.
+- =claude-templates/.ai/scripts/tests/test-lint-org.el= (+ mirror) — five tests: heading-in-example, literal =#+end_example= in src, uppercase delimiters, unterminated block still reports, and one file with both proving per-block scoping.
+- =todo.org= — closed the =invalid-block= task with its verification record, stamped seven review dates, inserted a missing properties drawer.
+- KB: =agents/20260725093500-parser-cannot-verify-its-own-misreading.org=.
+
+** Next Steps
+
+- Work's sentry is still running on ratio and untouched. Stopping it properly means saying "sentry stop" in the work session (pane =aiv-work:1.1=), which handles the branch disposition and the overnight approval queue.
+- Home is waiting on this push to re-run its =invalid-block= fixture.
+- Next review batch starts with the agent-source improvements and flashcard tooling tasks.
+- Seven parked VERIFYs still await Craig, the =[#A]= account-binding guard from home first.
+- The sentry spec still wants Craig's deep read before the READY flip.
+
+KB: promoted 1 / consulted no
+
+* Session Log
+
+** Startup
+
+Startup ran clean: rulesets already current, =make install= had nothing new to link, project repo up to date with origin (5 commits ahead, unpushed — carried over from the prior session by Craig's choice). =.ai/= synced from templates with no churn. No crash anchor. Task staleness reported 13 top-level tasks unreviewed for >7 days.
+
+** Inbox — home's answer on invalid-block
+
+One pending handoff: home answering the open question left in the =[#C] lint-org invalid-block false-positives= task — is =invalid-block= lint-org.el's own checker or org-lint's? Home says org-lint's, and I re-verified both halves rather than taking it: =grep invalid-block= over =claude-templates/.ai/scripts/lint-org.el= returns nothing, and =org-lint--checkers= enumerates =invalid-block= in batch Emacs alongside =link-to-local-file=. So the fix is a filter on org-lint's output, matching the =link-to-local-file= episode.
+
+Folded into the existing task as a dated sub-entry rather than filing anything new — the task was already filed and only needed its open question closed. Also recorded home's regression fixture: its own =.ai/notes.org= PENDING DECISIONS block (lines 386-398) is left unescaped on purpose and trips both delimiters, so the filter should take those two findings to zero without touching the file. Replied to home confirming, and asked them to keep the fixture unescaped pending a ping when the filter lands. Inbox back to zero.
+
+** Pushed main
+
+Craig picked the evening's order: push, then the invalid-block fix, then a task review. Pre-push reconcile showed ahead-only by 5, no divergence, so =git push origin main= went out and verified at 0/0. The secret-scan fail-open fix is now delivered to consuming projects.
+
+** invalid-block filter
+
+Built test-first. Reproduced the failure three ways before writing anything: a paired example block with a heading-shaped body line flags both delimiters; a paired src block holding a literal =#+end_example= flags three lines; a genuinely unterminated block flags once and must keep doing so. Wrote four ERT tests covering those plus a mixed file, watched three fail for the right reason, then implemented.
+
+The fix is =lo--matched-block-regions=: a line scan that pairs blocks under org's real rule (once open, only the block's own =#+end_TYPE= closes it), memoized on the buffer's modification tick. =lo--handle-item= then drops an =invalid-block= finding falling inside a matched region, delimiters included, since org-lint reports at the delimiters. Line-scanning instead of asking org is the whole point — org's parser is the thing that's confused.
+
+One test failed after the implementation on an off-by-one in my own expectation (the unterminated opener is line 7, not 8); the code was right and I corrected the test. Verified against home's live fixture both ways: two findings under the pre-change mirror copy, zero under the new canonical, home's file untouched. Synced canonical → mirror, full =make test= green at exit 0.
+
+Review caught two things I fixed rather than filed: the uppercase-delimiter path was handled but untested (confirmed it was a real trigger — 2 findings before, 0 after — then added the boundary test), and the cache-tick comparison used =eq= where =eql= is strictly correct. Verdict Approve, committed 8822b0d after the voice pass. Pinged home that the filter landed and the fixture can be re-run.
+
+Left the =,**= comma-escape in the notes template alone. The task said the fix "lets that escape be reverted," but the escape is correct org for a literal =**= in a verbatim block, so reverting buys nothing now that the finding is suppressed. Recorded the reasoning in the closed task rather than acting on the permission silently.
+
+** Task review
+
+Batch of 7 from the staleness script, oldest first. Every one came back Keep with no new =:quick:= or =:solo:= tag, and Craig confirmed the batch in one pass. Backed todo.org up to /tmp first (matching the mutator convention) and stamped by exact line number rather than a global replace, since ten other tasks carried the same 2026-07-13 date and only six were in the batch. The Sentry vNext task had no properties drawer at all, so it got one.
+
+Two observations worth keeping. The uniform Keep-with-no-tags result isn't the review going soft: this batch is the deliberation-heavy tail, where every task needs a decision from Craig somewhere in the middle, which is exactly why the speedruns kept stepping past them and why they aged. So part of the staleness count is measuring work that can't be handed off rather than work nobody read. And the KB orphan task cites a 2026-07-01 snapshot of 53 agent nodes; tonight's startup counted 104, so the KB has doubled and the snapshot is worth even less than the task body already assumed.
+
+** Inbox — home's acknowledgment
+
+Home replied confirming both messages landed, the fixture stays unescaped, and they'll pick the filter up after the rulesets push and their next clean startup sync. A pure FYI asking nothing, so it skipped the skeptical review and got no reply (acking an ack loops). Deleted; inbox back to zero. It does corroborate that the unpushed commit is the only thing standing between home and the fix.
+
+** Task review (cont.)
+
+Staleness went 13 → 6. Lint flags five more tasks missing =:LAST_REVIEWED:= entirely (lines 327, 336, 434, 593, 602) — all =[#D]=, which the staleness script excludes but the checker doesn't. Pre-existing, not touched.
diff --git a/.ai/sessions/2026-07-25-15-34-clean-wrap-and-inbox-safe-sync.org b/.ai/sessions/2026-07-25-15-34-clean-wrap-and-inbox-safe-sync.org
new file mode 100644
index 0000000..737b1c5
--- /dev/null
+++ b/.ai/sessions/2026-07-25-15-34-clean-wrap-and-inbox-safe-sync.org
@@ -0,0 +1,85 @@
+#+TITLE: Session Context — 2026-07-25
+#+AUTHOR: Craig Jennings
+
+* Summary
+
+** Active Goal
+
+Guarantee that rulesets can only report a successful wrap with a completely clean Git worktree, while allowing other projects to refresh rulesets when its only residue is untracked inbox deliveries. Implemented, reviewed, fully tested, and prepared for a strict self-hosted wrap.
+
+** Decisions
+
+- One executable, =git-worktree-gate=, owns both repository-state policies. =strict= means no staged, unstaged, untracked, dirty-submodule, or in-progress-operation state; =sync-safe= permits only untracked paths beneath =inbox/=.
+- Cleanup failure is not a degraded wrap. It leaves the session open and must report each path, its Git state, why it cannot be resolved safely, and the decision Craig needs. Dirty-file deferrals, valediction, and teardown are forbidden in that state.
+- Final wrap verification is a HEAD-bound certificate stored in the Git directory and freshly rechecked inside the existing teardown hook. Integrating the check avoids the concurrent-hook race documented by Codex.
+- Another project's structured Edit/Write call may not resolve through an installed symlink into rulesets. The runtime hook denies it and points the sender to =inbox-send rulesets=.
+- The two MCP-registry handoffs were consolidated into one parked =[#B]= specification decision. The memory auditor remains separate; no machine-owned MCP configuration was promoted into canonical rulesets.
+
+** Data Collected / Findings
+
+- The prior rulesets archive completed at 09:24; the inherited three tracked changes were written at 10:27. The repository was dirtied after wrap through a later write path, which is why wrap certification alone needed the symlink-aware boundary guard.
+- The previous wrap prose contradicted itself: clean Git state was an exit criterion, but the leftover and inbox sections allowed explicit deferral. The teardown hook checked only the sentinel.
+- Adversarial review found a fail-open process-substitution edge: a low-level =git status= failure could appear as an empty stream. The gate now captures status and its exit code in Git-directory temporary files and blocks on failure.
+- Current Codex hooks support Stop blocking with =continue=false= and run matching commands concurrently. A user-level =hooks.json= is installed; a new Codex session must complete the normal hook review/trust step.
+- Full =make test= passed twice on the implementation. The final run includes 436 core Python tests, 72 hook tests, language suites, ERT suites, and all Bats suites. Focused additions cover inbox-only pulls, staged/unstaged/untracked/submodule/operation states, status failure, certificate/HEAD drift, Claude and Codex Stop outputs, installation, and realpath-based write denial.
+- The wrap roam sweep found Craig's 120-column table question. It was already enforced by =org-tables.md=, =lint-org='s =org-table-standard= judgment, and =wrap-org-table.el=; the live lint pass flagged the existing over-wide table. Removed the duplicate capture and synced roam.
+
+** Files Modified
+
+- =claude-templates/bin/git-worktree-gate= — shared strict/sync-safe classifier plus certificate/verify modes.
+- Startup protocol/workflow mirrors and =claude-templates/bin/ai= — inbox-only state remains visible but no longer blocks fast-forward refresh.
+- Wrap protocol/workflow mirrors and =hooks/ai-wrap-teardown.sh= — no deferral escape, actionable hard blocker, final certificate, fresh teardown verification.
+- =hooks/rulesets-write-boundary.py=, Claude/Codex hook configuration, cross-project rule, Makefile, and hook documentation — prevent structured writes through installed symlinks and install the enforcement on both runtimes.
+- Bats and pytest suites — repository-state, launcher, teardown, installer, and cross-project boundary regressions.
+- =todo.org= and workflow state — parked the consolidated MCP registry spec decision and recorded inbox processing.
+- Three inherited backlog files — retain the post-09:24 definition that speedrunnable means =:solo:=.
+
+** Next Steps
+
+- Start a new Codex session and review/trust the new user-level hooks when =/hooks= prompts; Claude already reads the linked hook configuration.
+- Say "spec the MCP registry sync" when ready to design the separate host-level registry reconciler.
+- The existing =inbox/lint-followups.org= pipeline retains its 14 current judgment items, including the over-wide table and older missing review stamps/links; they do not represent uncommitted work after this wrap.
+
+KB: promoted 0 / consulted no
+
+* Session Log
+
+** Startup and clean-worktree investigation
+
+Ran the required startup workflow. The canonical rulesets pull and template sync were blocked by three tracked modifications; two untracked inbox handoffs were also pending. Read the project and global behavioral rules, recent session archive, wrap workflow, relevant hook and launcher code, and the current Codex MCP and hook documentation needed to evaluate the inbox proposals.
+
+Investigated Craig's requirement that rulesets finish with an absolutely clean worktree while still allowing downstream projects to sync when rulesets has received inbox deliveries. The current wrap workflow states a clean exit criterion but later permits explicitly deferred dirty files, and its teardown hook checks only the wrap sentinel rather than Git state. The startup shell's tracked-change check already ignores untracked inbox files, but the general launcher dirty check does not distinguish inbox deliveries from other untracked residue.
+
+The three tracked files now dirty were written at 10:27, after the latest archived rulesets session wrapped at 09:24. That establishes a post-wrap write path: a strict wrap gate can guarantee the state at completion, but preventing later contamination also needs a realpath-aware cross-project write guard because globally installed rules and workflows are symlinks into this repository.
+
+The proposed design is one shared repository-state classifier with two policies. Strict wrap requires no staged, unstaged, or untracked entries and no dirty submodules. Inbox-safe sync permits an otherwise clean tracked/index state with untracked entries only below =inbox/=; those deliveries do not block pull or template sync. The strict check should run after the final push and again inside the ordered teardown hook, tied to the checked HEAD, while the inbox-safe policy should drive both startup and the =ai= launcher. Tests should cover every Git state, unusual path names, inbox-only sync, hook behavior, and the canonical/template mirrors.
+
+Two pending inbox handoffs both propose a shared Claude-to-Codex MCP registry mirror. They pass the value gate but overlap. The recommendation is to consolidate them into one =[#B]= specification with Codex-only entries preserved, atomic and redacted updates, dependency and health checks, both-machine verification, and the Claude-memory audit split into a separate task. No inbox disposition or project implementation has been applied.
+
+Completed the investigation plan without changing product code. The worktree proposal is ready for Craig's approval; implementation, tests, and inbox filing remain deliberately pending.
+
+** Clean-wrap invariant clarified
+
+Craig confirmed that cleanup failure must prevent wrap-up entirely. The agent must keep the session open and report exactly what remains in the Git worktree, why it could not resolve each item safely, and the specific action or decision Craig needs to supply. A warning, deferred-file exception, valediction, archived-as-complete status, or teardown is not an acceptable substitute for a clean tree.
+
+** Clean-wrap enforcement implemented
+
+Craig approved implementation and asked for a full wrap when it is done. Added =git-worktree-gate= as the single policy executable: strict mode rejects every staged, unstaged, untracked, dirty-submodule, and in-progress-operation state; sync-safe mode permits only untracked =inbox/= deliveries. Certificate and verify modes bind the final clean check to HEAD inside the Git directory.
+
+Wired sync-safe behavior into both startup workflow copies and the =ai= launcher. The picker labels inbox-only state distinctly and now fast-forwards a behind repository with inbox deliveries present while refusing other untracked residue.
+
+Made wrap cleanup fail closed: removed every dirty-file and inbox deferral escape, added the post-push clean certificate as a hard prerequisite to valediction, and required an exact path/state/needed-decision report when cleanup cannot finish. The existing teardown hook now freshly verifies the certificate and HEAD before consuming either sentinel, emits the runtime-appropriate Claude or Codex Stop blocker, and leaves the sentinel/session intact on failure. Added global Codex hook configuration and installed its symlink; Codex will require its normal hook review/trust on a new session.
+
+Added =rulesets-write-boundary.py= and configured Claude and Codex Edit/Write hooks. It resolves targets through symlinks and denies another project's write when the real path lands in rulesets, directing the proposal through =inbox-send rulesets=. The cross-project rule now states the installed-symlink case explicitly.
+
+Focused verification is green: 10 state-gate Bats cases, 13 teardown-hook cases including dirty/changed-HEAD/missing-certificate and both runtime outputs, 36 launcher cases including inbox-only pull, 5 installer cases, and 5 Python write-boundary cases.
+
+** Inbox — MCP registry proposals consolidated
+
+Processed the two pending 2026-07-25 handoffs from work and home. They were duplicate evidence for a host-level Claude-to-Codex MCP registry reconciler, not project-level implementation requests. Filed one =[#B]= parked specification decision in =todo.org= with preservation, redaction, atomicity, transport, health-check, two-machine, malformed-input, and token-rotation gates; split the memory auditor into separate future work. Deleted both inbound handoffs, stamped =:LAST_INBOX_PROCESS:=, sent acknowledgements to both source projects, and verified zero pending project handoffs.
+
+** Review, full verification, and wrap cleanup
+
+The first full =make test= run passed. Adversarial self-review then found the Git-status process-substitution fail-open and a path-resolution fail-open in the cross-project hook; fixed both, added status-error and sequencer regressions, aligned =protocols.org= with the new policies, and reran the complete suite successfully.
+
+Executed wrap cleanup: no sentry lock, todo hygiene/convert/archive/priority passes made no changes, lint-org applied zero mechanical changes and refreshed the 14-item judgment pipeline, project inbox remained at zero, route-batch had no candidates, and 30-day staleness was zero. The shared roam inbox held one rulesets capture about 120-column tables; verified the enforcement already exists in three layers, removed the duplicate, and pushed the roam update with the repository's sync helper.
diff --git a/.ai/workflows/startup.org b/.ai/workflows/startup.org
index 929d482..1dff1db 100644
--- a/.ai/workflows/startup.org
+++ b/.ai/workflows/startup.org
@@ -29,10 +29,16 @@ Inside a rulesets session, the project-repo refresh below covers this — the ru
#+begin_src bash
rs="$HOME/code/rulesets"
if [ -d "$rs/.git" ]; then
- if (cd "$rs" && git diff --quiet --ignore-submodules HEAD -- 2>/dev/null); then
+ gate="$rs/claude-templates/bin/git-worktree-gate"
+ if [ -x "$gate" ] && "$gate" sync-safe "$rs"; then
+ (cd "$rs" && git pull --ff-only origin main 2>&1) | tail -3
+ elif [ ! -x "$gate" ] \
+ && (cd "$rs" && git diff --quiet --ignore-submodules HEAD -- 2>/dev/null); then
+ # Bootstrap fallback for a checkout old enough not to have the shared
+ # gate yet. The pull that follows installs it for subsequent starts.
(cd "$rs" && git pull --ff-only origin main 2>&1) | tail -3
else
- echo "rulesets: dirty working tree — using as-is, skipping pull"
+ echo "rulesets: changes beyond untracked inbox deliveries — using as-is, skipping pull"
fi
else
echo "rulesets: not a git checkout — skipping"
@@ -40,11 +46,11 @@ fi
#+end_src
Behavior:
-- *Clean working tree* → fast-forward pull. =git pull --ff-only= refuses any merge or rebase, so the operation is either a no-op (already current) or a clean advance.
-- *Dirty working tree* → skip the pull. Don't auto-stash and don't auto-merge — those would either lose work or invite conflicts at the worst possible moment (session start).
+- *Clean working tree, or untracked deliveries only beneath =inbox/=* → fast-forward pull. =git pull --ff-only= refuses any merge or rebase, so the operation is either a no-op (already current) or a clean advance. Inbox files are queue input, not source-tree work, and do not block other projects from receiving rulesets updates.
+- *Any staged or tracked change, dirty submodule, Git operation in progress, or untracked file outside =inbox/=* → skip the pull. Don't auto-stash and don't auto-merge — those would either lose work or invite conflicts at the worst possible moment (session start).
- *Non-fast-forward history* → =--ff-only= aborts with an error. Surface that to the user; the rsync still proceeds against the working tree as-is.
-*Template-freshness policy (applies to every dirty-check in the synced workflows).* "Dirty" means *tracked modifications only*. Untracked and gitignored files — an inbox drop, a file left in the tree to read, scratch output — never block a template pull, a fast-forward, or a monitoring gate. Projects were falling behind on templates because somebody sent them a task; that's the failure this policy closes. The checks here already comply (=git diff --quiet HEAD= sees only tracked changes; the ff gate uses =--untracked-files=no=), and any dirty-check added to a synced workflow follows the same rule. One deliberate exception: the rsync WIP-guard below counts untracked files *within rulesets' own synced source paths*, because an untracked half-written template is exactly the WIP it exists to hold back — that guard is about rulesets' outbound content, not the consuming project's local state.
+*Template-freshness policy (applies to every dirty-check in the synced workflows).* The shared =git-worktree-gate sync-safe= policy is the source of truth: untracked files beneath =inbox/= and gitignored files do not block a pull, fast-forward, or monitoring gate; every other staged, tracked, untracked, submodule, or in-progress-operation state does. Projects must not fall behind merely because somebody sent them a task, but an arbitrary scratch file is not silently treated as safe. One deliberate exception remains: the rsync WIP-guard below is narrower than the repository gate and counts untracked files within rulesets' own synced source paths, because an untracked half-written template is exactly the WIP it exists to hold back.
*** Install rulesets symlinks into ~/.claude (idempotent)
@@ -74,8 +80,11 @@ if [ -d .git ]; then
current=$(git symbolic-ref --short HEAD 2>/dev/null)
dirty=0
- if ! git diff --quiet --ignore-submodules HEAD -- 2>/dev/null \
- || [ -n "$(git status --porcelain --untracked-files=no)" ]; then
+ gate="$HOME/code/rulesets/claude-templates/bin/git-worktree-gate"
+ if [ -x "$gate" ]; then
+ "$gate" sync-safe "$PWD" >/dev/null 2>&1 || dirty=1
+ elif ! git diff --quiet --ignore-submodules HEAD -- 2>/dev/null \
+ || [ -n "$(git status --porcelain --untracked-files=no)" ]; then
dirty=1
fi
@@ -107,8 +116,8 @@ fi
#+end_src
Behavior, per branch:
-- *Behind only, current branch, clean tree* → =git merge --ff-only= advances HEAD.
-- *Behind only, current branch, dirty tree* → fetched but not advanced. Surface so Craig can ff manually after dealing with the dirty state.
+- *Behind only, current branch, sync-safe tree* → =git merge --ff-only= advances HEAD. An untracked =inbox/= delivery is sync-safe.
+- *Behind only, current branch, sync-blocking tree* → fetched but not advanced. Surface so Craig can ff manually after dealing with the reported state.
- *Behind only, non-checkout branch* → =git fetch . upstream:branch= advances the ref without touching the working tree.
- *Diverged* (ahead and behind) → leave alone. Surface for Craig to resolve. Don't auto-rebase or auto-merge.
- *Ahead only* or *up to date* → silent no-op.
diff --git a/.ai/workflows/work-the-backlog.org b/.ai/workflows/work-the-backlog.org
index 3933749..a0b24a8 100644
--- a/.ai/workflows/work-the-backlog.org
+++ b/.ai/workflows/work-the-backlog.org
@@ -70,6 +70,8 @@ A task is autonomous-safe when *both* hold. This layer is a lookup, not a judgme
1. *Status is =TODO=* — never =VERIFY=, =DOING=, =DONE=, or =CANCELLED=. =VERIFY= marks "awaiting Craig's input"; auto-implementing one defeats the check it represents. The do-not-implement set is safe-by-omission: anything not plainly =TODO= (plus any project-declared "hold" marker) is out.
2. *Tagged =:solo:=* — the autonomy tag, resolved against the project's priority/tag scheme header in =todo.org= (never hardcoded). =:solo:= carries the hard definition in =todo-format.md=: completable and verifiable without Craig beyond at most one or two quick decisions answerable up front, no design deliberation. A project whose scheme declares a different autonomous-safe tag set overrides the default.
+Terminology: *speedrunnable means tagged =:solo:=*. It does not mean =:quick:= or require =:quick:solo:=. The =TODO= status check above is the execution-state gate over that speedrunnable set.
+
Priority and =:next:= drive *ordering* within the eligible set, not eligibility ([#A] before [#B] before [#C], then the author's ordering). =:quick:= is an effort hint for batching and duration estimates — never a gate.
Task *size* is deliberately absent from this gate. A large but well-specified, decision-free task is in scope and gets decomposed into per-logical-commit chunks during implementation. Size never sends a task away; only *deliberation* or *risk* does (the checklist below).
diff --git a/.ai/workflows/wrap-it-up.org b/.ai/workflows/wrap-it-up.org
index 103e225..7d55a55 100644
--- a/.ai/workflows/wrap-it-up.org
+++ b/.ai/workflows/wrap-it-up.org
@@ -24,7 +24,7 @@ The wrap-up is complete when:
2. *File is archived.* =.ai/session-context.org= has been renamed to =.ai/sessions/YYYY-MM-DD-HH-MM-description.org=. The old path no longer exists.
3. *todo.org is clean.* Cleanup script ran. Any auto-fixes are staged for the wrap-up commit. Orphan planning lines surfaced for manual fix if there are any.
4. *Linear board is honest* (skip if project doesn't use Linear). Any Dev-Review ticket whose PR has merged was moved to Done or PM Acceptance per the classification rule.
-5. *Git state is clean.* All changes committed + pushed to all remotes. Working tree clean.
+5. *Git state is certified clean.* All changes are committed + pushed to all remotes, =git-worktree-gate certify= succeeded at the current HEAD, and the working tree has no staged, unstaged, untracked, submodule, or in-progress-operation state.
6. *Valediction delivered.* Brief, warm closing with key accomplishments and reminders, ending with =session wrapped.= on its own line as the signoff marker.
The absence of =.ai/session-context.org= is the signal that the last session wrapped up cleanly. Its presence at session start means the previous session was interrupted.
@@ -273,7 +273,7 @@ For an interactive walk of the judgments mid-day, run =/lint-org todo.org=.
*** Inbox sanity check (surface unprocessed handoffs)
-If the project has an =inbox/= directory, verify it holds nothing but =.gitkeep=, =lint-followups.org= (the lint-org pipeline file the next morning's daily-prep consumes), and any explicitly-deferred =PROCESSED-*= files before the wrap completes. An inbox that arrived at session start with handoffs from other projects, or that received handoffs mid-session, needs =inbox.org= process mode to run and apply its value-gate dispositions. Wrapping with a dirty inbox silently defers the work to next session and accumulates handoff debt that the sender can't see.
+If the project has an =inbox/= directory, verify it holds nothing but =.gitkeep=, =lint-followups.org= (the lint-org pipeline file the next morning's daily-prep consumes), and =PROCESSED-*= files before the wrap completes. An inbox that arrived at session start with handoffs from other projects, or that received handoffs mid-session, needs =inbox.org= process mode to run and apply its value-gate dispositions. Wrapping with an unprocessed inbox silently defers the work to next session and accumulates handoff debt that the sender can't see.
#+begin_src bash
unprocessed=$(find inbox -maxdepth 1 -type f \
@@ -282,7 +282,7 @@ unprocessed=$(find inbox -maxdepth 1 -type f \
! -name 'PROCESSED-*' \
2>/dev/null | wc -l)
if [ "$unprocessed" -gt 0 ]; then
- echo "wrap-up: inbox/ has $unprocessed unprocessed item(s). Run inbox.org process mode before wrapping, or explicitly defer each item with a one-line reason in the valediction."
+ echo "wrap-up blocked: inbox/ has $unprocessed unprocessed item(s). Run inbox.org process mode before wrapping."
find inbox -maxdepth 1 -type f \
! -name '.gitkeep' \
! -name 'lint-followups.org' \
@@ -291,7 +291,7 @@ if [ "$unprocessed" -gt 0 ]; then
fi
#+end_src
-If the count is zero or the project has no =inbox/= directory, the check is a silent no-op. If non-zero, the wrap is incomplete by default. The user resolves each item (process now, defer with reason in the valediction, or delete with rationale) before the validation checklist passes.
+If the count is zero or the project has no =inbox/= directory, the check is a silent no-op. If non-zero, the wrap is blocked. Process each item through its value-gate disposition, or delete it only when that workflow's rationale authorizes deletion, before continuing.
The check exempts =lint-followups.org= explicitly because lint-org runs earlier in the same wrap-up workflow and writes its judgment items to that file in =inbox/= by design. The file is a pipeline artifact for the next morning's =daily-prep=, not a handoff that needs the value gate.
@@ -501,17 +501,17 @@ Behavior:
git status --short
#+end_src
-*Default policy: end every session with an empty =git status=.* The wrap is incomplete while anything remains dirty. There is no "leave it alone" default — every leftover gets an active resolution. The only way for a file to stay dirty across the wrap is the user explicitly saying "defer this one, leave it dirty." Surface each leftover with a concrete recommendation; the user has to actively opt out for the dirt to persist.
+*Hard policy: end every session with an empty =git status=.* The wrap is incomplete while anything remains dirty. There is no deferral exception and no "wrapped with known changes" state: unresolved dirt means the session remains open and wrap-up does not occur.
This inverts the older "intentional carryover" default, which let pre-existing dirty state accumulate across sessions silently. Carryover that lives for days or weeks is almost always one of: a forgotten commit from a prior wrap, a stale change that should be discarded, or genuine in-flight work that needs an explicit stash/branch home. None of those should default to "leave it dirty."
**** Three kinds of leftover
-| Pattern | What it is | Recommended action (apply unless user defers) |
+| Pattern | What it is | Recommended action |
|---+---+---|
| Generated, runtime, or lock files that no human edits — e.g., =.claude/scheduled_tasks.lock=, =.pytest_cache/=, build outputs, IDE state, editor swap files | *Runtime artifact* — created by tooling or the harness, not by the user, and shouldn't be tracked | Add the matching pattern to =.gitignore= (project-level, not =~/.gitignore_global=). For tracked files, =git rm --cached <path>=. Stage =.gitignore= and any =rm --cached= changes in *one* follow-up commit (=chore: gitignore X=), push. Re-run =git status= to confirm clean. |
| Modified or created during the session but not staged into the wrap-up commit | *Forgotten change* — real session work that should have been in the wrap commit but missed it | Stage and create a follow-up commit. Don't =--amend= the wrap-up commit once pushed (diverging history without a clear win). Push the follow-up to all remotes. |
-| Was dirty at session start and still dirty at session end — work this session deliberately didn't touch | *Pre-existing dirt that needs a decision* — could be a missed commit from a prior wrap, stale abandoned work, or real in-flight work without a home | Investigate (show diff + check the originating session). Recommend one of: (a) commit now if the work is complete, (b) stash with a descriptive message if it's genuine WIP, (c) =git checkout -- <path>= / =git clean -f <path>= if stale and unwanted, (d) move to a feature branch if it's longer-running, (e) user explicitly defers and accepts the dirt. Do not silently leave dirty. |
+| Was dirty at session start and still dirty at session end — work this session deliberately didn't touch | *Pre-existing dirt that needs a decision* — could be a missed commit from a prior wrap, stale abandoned work, or real in-flight work without a home | Investigate (show diff + check the originating session). Recommend one of: (a) commit now if the work is complete, (b) stash with a descriptive message if it's genuine WIP, (c) =git checkout -- <path>= / =git clean -f <path>= if stale and unwanted, or (d) move to a feature branch if it's longer-running. Do not silently leave dirty. |
**** Per-file flow
@@ -519,18 +519,40 @@ For each leftover line in =git status --short=:
1. Identify which of the three kinds above it matches.
2. State what the file is (one line) and the recommended action.
-3. Apply the action unless the user explicitly defers.
-4. Re-run =git status --short= after each follow-up commit until empty (or until every remaining line is an explicit user-deferred entry).
+3. Apply the action when it is safe and authorized.
+4. Re-run =git status --short= after each follow-up commit until empty.
The pre-existing-dirt case (third row) is the one this rule most cares about. Treat each pre-existing-dirty file as a question that must get an answer this session, not as "carryover that's fine to inherit." A file that was dirty for a week before this session probably isn't going to get cleaner by waiting another week. Look at the diff, check the originating session's notes, and recommend a real resolution.
-**** When the user defers
+**** When cleanup cannot be completed
-If the user does say "leave this one dirty for now" after seeing the recommendation, that is fine — log the deferral in the valediction so the next session knows it was an explicit choice, not a miss. Format: "Deferred (per Craig's decision today): =path/to/file= — <one-line reason>". Without that note, the next session can't distinguish "we agreed to defer" from "we forgot again."
+Stop the wrap. Do not deliver the valediction, print =session wrapped.=, drop a teardown/shutdown sentinel, or describe the session as complete. Report:
+
+1. Every remaining path and its exact Git state.
+2. What the file is and why the agent cannot safely resolve it alone.
+3. The concrete action or decision Craig needs to provide to make the tree clean.
+
+An explicit decision to keep a file dirty changes the outcome from "wrapping" to "leaving the session interrupted." It never satisfies this workflow.
+
+*** Final clean-tree certificate — hard gate
+
+After all commits are pushed and every leftover appears resolved, run the shared gate:
+
+#+begin_src bash
+gate="$(command -v git-worktree-gate 2>/dev/null || true)"
+[ -n "$gate" ] || gate="$HOME/code/rulesets/claude-templates/bin/git-worktree-gate"
+if [ ! -x "$gate" ]; then
+ echo "wrap blocked: git-worktree-gate is unavailable; install rulesets tooling and retry"
+ exit 1
+fi
+"$gate" certify "$PWD"
+#+end_src
+
+The certificate lives inside the Git directory, so it does not dirty the worktree. It records the exact verified HEAD. A non-zero result is a hard stop governed by "When cleanup cannot be completed" above. Step 5 is unreachable until certification succeeds.
** Step 5: Valediction
-Brief, warm closing. 3-4 sentences max.
+Only after the final clean-tree certificate succeeds, deliver a brief, warm closing. 3-4 sentences max.
Include:
- What was accomplished (specific, not generic)
@@ -567,7 +589,7 @@ Do nothing. The buffer, the =aiv-<project>= tmux session, and =claude= all stay
*** Teardown mode (default)
-Confirm commit + push succeeded (Exit Criteria 5 — never tear down over unpushed work), then drop the sentinel:
+Confirm commit + push and the final clean-tree certificate succeeded (Exit Criteria 5 — never tear down over unpushed or dirty work), then drop the sentinel:
#+begin_src bash
touch "/tmp/ai-wrap-teardown-$(basename "$PWD")"
@@ -605,7 +627,8 @@ If =emacsclient= isn't resolvable or the daemon is down, the gate can't run —
7. *Leaving =.ai/session-context.org= in place* — its presence means "interrupted session", confuses next startup
8. *Long preachy valediction* — brief beats thorough
9. *Leaving runtime/generated files dirty without gitignoring them* — pollutes every future =git status= and erodes trust in "working tree clean" as a signal. Fix =.gitignore= during the wrap, not later.
-10. *Treating "was dirty at session start, still dirty now" as fine by default* — that's how a forgotten commit from two sessions ago turns into "carryover" for two weeks. Every pre-existing dirty file needs an active resolution recommendation this session. Deferral is allowed only with an explicit user choice, logged in the valediction.
+10. *Treating "was dirty at session start, still dirty now" as fine* — that's how a forgotten commit from two sessions ago turns into "carryover" for two weeks. Every pre-existing dirty file must be resolved or the wrap remains blocked.
+11. *Calling a blocked cleanup a wrap* — if the strict gate fails, report the paths and needed decisions; do not valedict, certify completion, or tear down.
* Validation Checklist
@@ -618,19 +641,20 @@ Before considering wrap-up complete:
- [ ] =todo-cleanup.el= ran — hygiene pass + =--convert-subtasks= + =--archive-done= + =--sync-child-priority= (if =todo.org= exists at project root)
- [ ] =lint-org.el= ran on =todo.org= — mechanical fixes applied, judgments appended to follow-ups file (if =todo.org= exists)
- [ ] Any orphan-planning-line warnings reviewed (fix or accept)
-- [ ] Inbox carries nothing but expected pipeline artifacts (=.gitkeep=, =lint-followups.org=, =PROCESSED-*= prefixes), OR each remaining handoff has an explicit deferral logged in the valediction
+- [ ] Inbox carries nothing but expected committed or ignored pipeline artifacts (=.gitkeep=, =lint-followups.org=, =PROCESSED-*= prefixes); any untracked inbox delivery was processed before wrap
- [ ] Linear Dev-Review sweep ran; any merged-PR tickets moved to Done or PM Acceptance (skip if project doesn't use Linear)
- [ ] Template-sync churn committed as its own =chore: sync .ai tooling from templates= (consuming projects only; skipped in rulesets), or surfaced if a synced path didn't match canonical
-- [ ] After wrap-up commit + push, =git status --short= is empty OR every remaining line has an explicit user-deferred decision logged in the valediction
+- [ ] After wrap-up commit + push, =git-worktree-gate certify "$PWD"= succeeded at the current HEAD
- [ ] Each leftover was investigated and the user saw a concrete resolution recommendation
- [ ] Runtime artifacts added to =.gitignore=, follow-up commit pushed, =git status= re-verified
- [ ] Forgotten changes committed in a follow-up and pushed
-- [ ] Pre-existing dirty files resolved (committed / stashed / discarded / moved to a feature branch) or explicitly deferred with a one-line reason in the valediction
+- [ ] Pre-existing dirty files resolved (committed / stashed / discarded / moved to a feature branch); otherwise wrap stopped with an actionable blocker report
- [ ] Current branch pushed to ALL remotes (verified with =git remote -v=)
- [ ] All other local branches with a tracking upstream pushed to their remote
- [ ] Any untracked-upstream branches surfaced for manual =git push -u=
- [ ] Step 6 teardown matches the trigger phrase: no-teardown leaves the buffer; teardown drops only =/tmp/ai-wrap-teardown-<project>=; shutdown gates on =cj/ai-term-live-count= = 1 and drops only =/tmp/ai-wrap-shutdown-<project>=
- [ ] No teardown/shutdown sentinel was dropped before commit + push was verified
+- [ ] The teardown hook can re-verify the clean-tree certificate before consuming a sentinel
- [ ] Shutdown aborted (fell back to normal wrap, logged in the valediction) when another =aiv-*= session was live or the gate couldn't run
- [ ] Commit message follows format (no =session:=, no Claude attribution)
- [ ] Valediction delivered (brief, specific, warm)
diff --git a/.claude/settings.json b/.claude/settings.json
index a486e89..3585af4 100644
--- a/.claude/settings.json
+++ b/.claude/settings.json
@@ -11,6 +11,15 @@
"hooks": {
"PreToolUse": [
{
+ "matcher": "Edit|Write",
+ "hooks": [
+ {
+ "type": "command",
+ "command": "~/.claude/hooks/rulesets-write-boundary.py"
+ }
+ ]
+ },
+ {
"matcher": "AskUserQuestion",
"hooks": [
{
diff --git a/.codex/hooks.json b/.codex/hooks.json
new file mode 100644
index 0000000..e14bed5
--- /dev/null
+++ b/.codex/hooks.json
@@ -0,0 +1,30 @@
+{
+ "description": "Rulesets lifecycle enforcement shared with Claude sessions.",
+ "hooks": {
+ "PreToolUse": [
+ {
+ "matcher": "Edit|Write",
+ "hooks": [
+ {
+ "type": "command",
+ "command": "~/.claude/hooks/rulesets-write-boundary.py",
+ "timeout": 30,
+ "statusMessage": "Checking rulesets write boundary"
+ }
+ ]
+ }
+ ],
+ "Stop": [
+ {
+ "hooks": [
+ {
+ "type": "command",
+ "command": "~/.claude/hooks/ai-wrap-teardown.sh",
+ "timeout": 30,
+ "statusMessage": "Verifying clean wrap state"
+ }
+ ]
+ }
+ ]
+ }
+}
diff --git a/Makefile b/Makefile
index 2d744a1..fdbb70b 100644
--- a/Makefile
+++ b/Makefile
@@ -6,6 +6,7 @@ RULES_DIR := $(HOME)/.claude/rules
HOOKS_DIR := $(HOME)/.claude/hooks
CLAUDE_DIR := $(HOME)/.claude
CODEX_DIR := $(HOME)/.codex
+CODEX_HOOKS := $(CURDIR)/.codex/hooks.json
LOCAL_BIN := $(HOME)/.local/bin
AI_LAUNCHER := $(CURDIR)/claude-templates/bin/ai
SKILLS := $(patsubst %/SKILL.md,%,$(wildcard */SKILL.md))
@@ -231,6 +232,19 @@ install: ## Symlink skills, rules, config, hooks, and bin scripts into place
ln -s "$(CURDIR)/claude-templates/AGENTS.md" "$(CODEX_DIR)/AGENTS.md"; \
echo " link AGENTS.md → $(CODEX_DIR)/AGENTS.md"; \
fi
+ @if [ -L "$(CODEX_DIR)/hooks.json" ]; then \
+ target=$$(readlink "$(CODEX_DIR)/hooks.json"); \
+ if [ "$$target" = "$(CODEX_HOOKS)" ]; then \
+ echo " skip hooks.json (already linked)"; \
+ else \
+ echo " WARN hooks.json links elsewhere ($$target) — skipping"; \
+ fi; \
+ elif [ -e "$(CODEX_DIR)/hooks.json" ]; then \
+ echo " WARN hooks.json exists and is not a symlink — skipping"; \
+ else \
+ ln -s "$(CODEX_HOOKS)" "$(CODEX_DIR)/hooks.json"; \
+ echo " link hooks.json → $(CODEX_DIR)/hooks.json"; \
+ fi
@echo ""
@echo "Hooks (default):"
@for hook in $(DEFAULT_HOOKS); do \
@@ -317,6 +331,13 @@ uninstall: ## Remove global symlinks from ~/.claude/
else \
echo " skip commands (not a symlink)"; \
fi
+ @if [ -L "$(CODEX_DIR)/hooks.json" ] \
+ && [ "$$(readlink "$(CODEX_DIR)/hooks.json")" = "$(CODEX_HOOKS)" ]; then \
+ rm "$(CODEX_DIR)/hooks.json"; \
+ echo " rm codex hooks.json"; \
+ else \
+ echo " skip codex hooks.json (not our symlink)"; \
+ fi
@echo ""
@echo "ai launcher:"
@if [ -L "$(LOCAL_BIN)/ai" ]; then \
diff --git a/claude-rules/cross-project.md b/claude-rules/cross-project.md
index 73c0e1b..c5de962 100644
--- a/claude-rules/cross-project.md
+++ b/claude-rules/cross-project.md
@@ -50,6 +50,14 @@ whose canonical home is `~/code/rulesets/`. When work in a downstream project
needs one of these files to change, a local edit alone is a stopgap that the
next sync reverts. The durable change happens only in the rulesets canonical.
+Installed global paths such as `~/.claude/rules/`, `~/.claude/hooks/`,
+`~/.claude/skills/`, and rulesets-owned entries in `~/.local/bin/` are
+symlinks into the canonical repository, not downstream copies. Never edit
+through those paths from another project's session: resolving the symlink
+would dirty rulesets outside its own logging and wrap discipline. The
+`rulesets-write-boundary.py` hook mechanically denies Edit/Write targets whose
+real path lands there and directs the proposal through `inbox-send rulesets`.
+
The process, every time:
1. **Make the change locally** in the downstream project so it's usable
diff --git a/claude-rules/todo-format.md b/claude-rules/todo-format.md
index e6a34e9..dc5e3b3 100644
--- a/claude-rules/todo-format.md
+++ b/claude-rules/todo-format.md
@@ -91,6 +91,9 @@ fixed definitions everywhere, because autonomous execution
eligibility gate and trusts the author's tag rather than re-deriving
autonomy at run time.
+“Speedrunnable” is shorthand for `:solo:`. The `:quick:` tag plays no part
+in that definition.
+
- **`:solo:` — autonomy.** The task can be completed *and verified* without
Craig's involvement beyond at most one or two quick decisions that can be
stated and answered before work starts. No open design question, no
diff --git a/claude-templates/.ai/protocols.org b/claude-templates/.ai/protocols.org
index 3cd02ef..2ea041b 100644
--- a/claude-templates/.ai/protocols.org
+++ b/claude-templates/.ai/protocols.org
@@ -84,7 +84,7 @@ Do NOT estimate, guess, or rely on memory. Just run the command. It takes one se
Every session pulls rulesets first, then the local project repo. Rulesets carries the canonical behavioral rules and =.ai/= templates (the old =claude-templates= repo is folded in as a subtree at =rulesets/claude-templates/=); the project pull lands commits pushed from other machines or teammates since the last session.
-Resolve any dirty-tree or merge issue at each step before moving on. Both pulls run as =git pull --ff-only= (or =git merge --ff-only= against the fetched upstream), so anything non-trivial — non-fast-forward history, dirty working tree, diverged branches — aborts. Surface the state and stop. Never auto-stash, auto-merge, or auto-rebase; the user resolves the conflict before further work.
+Resolve any sync-blocking tree or merge issue at each step before moving on. The shared =git-worktree-gate sync-safe= policy permits untracked deliveries beneath =inbox/= so receiving a handoff never prevents another project from refreshing rulesets; every staged or tracked change, dirty submodule, Git operation in progress, or untracked path outside =inbox/= blocks. Both pulls run as =git pull --ff-only= (or =git merge --ff-only= against the fetched upstream), so non-fast-forward history and diverged branches also abort. Surface the state and stop. Never auto-stash, auto-merge, or auto-rebase; the user resolves the conflict before further work.
Mechanics live in =startup.org= Phase A.0. The rule lives here because it governs the very first action of every session: load the freshest behavioral rules and templates before anything else runs.
@@ -567,12 +567,13 @@ When monitoring a long-running process (rsync, large downloads, builds, VM tests
** "Wrap it up" / "That's a wrap" / "Let's call it a wrap"
-When Craig says any of these phrases (or variations), execute the wrap-up workflow: [[file:workflows/wrap-it-up.org][wrap-it-up.org]]. Four steps:
+When Craig says any of these phrases (or variations), execute the wrap-up workflow: [[file:workflows/wrap-it-up.org][wrap-it-up.org]]. Five load-bearing steps:
1. *Finalize the Summary* in =.ai/session-context.org= (populate the 5 subsections from the Session Log)
2. *Rename* =.ai/session-context.org= → =.ai/sessions/YYYY-MM-DD-HH-MM-description.org=
3. *Git commit + push* to all remotes (see Git Commit Requirements)
-4. *Valediction* — brief, warm, specific closing
+4. *Certify the clean tree* with =git-worktree-gate certify=. Any remaining staged, unstaged, untracked, submodule, or in-progress-operation state blocks wrap entirely; report each path and the exact decision needed. There is no dirty-file deferral.
+5. *Valediction* — brief, warm, specific closing, reachable only after certification
The absence of =.ai/session-context.org= after wrap-up is the signal that the session ended cleanly. If the file is still there at the next session start, the previous session was interrupted.
diff --git a/claude-templates/.ai/workflows/startup.org b/claude-templates/.ai/workflows/startup.org
index 929d482..1dff1db 100644
--- a/claude-templates/.ai/workflows/startup.org
+++ b/claude-templates/.ai/workflows/startup.org
@@ -29,10 +29,16 @@ Inside a rulesets session, the project-repo refresh below covers this — the ru
#+begin_src bash
rs="$HOME/code/rulesets"
if [ -d "$rs/.git" ]; then
- if (cd "$rs" && git diff --quiet --ignore-submodules HEAD -- 2>/dev/null); then
+ gate="$rs/claude-templates/bin/git-worktree-gate"
+ if [ -x "$gate" ] && "$gate" sync-safe "$rs"; then
+ (cd "$rs" && git pull --ff-only origin main 2>&1) | tail -3
+ elif [ ! -x "$gate" ] \
+ && (cd "$rs" && git diff --quiet --ignore-submodules HEAD -- 2>/dev/null); then
+ # Bootstrap fallback for a checkout old enough not to have the shared
+ # gate yet. The pull that follows installs it for subsequent starts.
(cd "$rs" && git pull --ff-only origin main 2>&1) | tail -3
else
- echo "rulesets: dirty working tree — using as-is, skipping pull"
+ echo "rulesets: changes beyond untracked inbox deliveries — using as-is, skipping pull"
fi
else
echo "rulesets: not a git checkout — skipping"
@@ -40,11 +46,11 @@ fi
#+end_src
Behavior:
-- *Clean working tree* → fast-forward pull. =git pull --ff-only= refuses any merge or rebase, so the operation is either a no-op (already current) or a clean advance.
-- *Dirty working tree* → skip the pull. Don't auto-stash and don't auto-merge — those would either lose work or invite conflicts at the worst possible moment (session start).
+- *Clean working tree, or untracked deliveries only beneath =inbox/=* → fast-forward pull. =git pull --ff-only= refuses any merge or rebase, so the operation is either a no-op (already current) or a clean advance. Inbox files are queue input, not source-tree work, and do not block other projects from receiving rulesets updates.
+- *Any staged or tracked change, dirty submodule, Git operation in progress, or untracked file outside =inbox/=* → skip the pull. Don't auto-stash and don't auto-merge — those would either lose work or invite conflicts at the worst possible moment (session start).
- *Non-fast-forward history* → =--ff-only= aborts with an error. Surface that to the user; the rsync still proceeds against the working tree as-is.
-*Template-freshness policy (applies to every dirty-check in the synced workflows).* "Dirty" means *tracked modifications only*. Untracked and gitignored files — an inbox drop, a file left in the tree to read, scratch output — never block a template pull, a fast-forward, or a monitoring gate. Projects were falling behind on templates because somebody sent them a task; that's the failure this policy closes. The checks here already comply (=git diff --quiet HEAD= sees only tracked changes; the ff gate uses =--untracked-files=no=), and any dirty-check added to a synced workflow follows the same rule. One deliberate exception: the rsync WIP-guard below counts untracked files *within rulesets' own synced source paths*, because an untracked half-written template is exactly the WIP it exists to hold back — that guard is about rulesets' outbound content, not the consuming project's local state.
+*Template-freshness policy (applies to every dirty-check in the synced workflows).* The shared =git-worktree-gate sync-safe= policy is the source of truth: untracked files beneath =inbox/= and gitignored files do not block a pull, fast-forward, or monitoring gate; every other staged, tracked, untracked, submodule, or in-progress-operation state does. Projects must not fall behind merely because somebody sent them a task, but an arbitrary scratch file is not silently treated as safe. One deliberate exception remains: the rsync WIP-guard below is narrower than the repository gate and counts untracked files within rulesets' own synced source paths, because an untracked half-written template is exactly the WIP it exists to hold back.
*** Install rulesets symlinks into ~/.claude (idempotent)
@@ -74,8 +80,11 @@ if [ -d .git ]; then
current=$(git symbolic-ref --short HEAD 2>/dev/null)
dirty=0
- if ! git diff --quiet --ignore-submodules HEAD -- 2>/dev/null \
- || [ -n "$(git status --porcelain --untracked-files=no)" ]; then
+ gate="$HOME/code/rulesets/claude-templates/bin/git-worktree-gate"
+ if [ -x "$gate" ]; then
+ "$gate" sync-safe "$PWD" >/dev/null 2>&1 || dirty=1
+ elif ! git diff --quiet --ignore-submodules HEAD -- 2>/dev/null \
+ || [ -n "$(git status --porcelain --untracked-files=no)" ]; then
dirty=1
fi
@@ -107,8 +116,8 @@ fi
#+end_src
Behavior, per branch:
-- *Behind only, current branch, clean tree* → =git merge --ff-only= advances HEAD.
-- *Behind only, current branch, dirty tree* → fetched but not advanced. Surface so Craig can ff manually after dealing with the dirty state.
+- *Behind only, current branch, sync-safe tree* → =git merge --ff-only= advances HEAD. An untracked =inbox/= delivery is sync-safe.
+- *Behind only, current branch, sync-blocking tree* → fetched but not advanced. Surface so Craig can ff manually after dealing with the reported state.
- *Behind only, non-checkout branch* → =git fetch . upstream:branch= advances the ref without touching the working tree.
- *Diverged* (ahead and behind) → leave alone. Surface for Craig to resolve. Don't auto-rebase or auto-merge.
- *Ahead only* or *up to date* → silent no-op.
diff --git a/claude-templates/.ai/workflows/work-the-backlog.org b/claude-templates/.ai/workflows/work-the-backlog.org
index 3933749..a0b24a8 100644
--- a/claude-templates/.ai/workflows/work-the-backlog.org
+++ b/claude-templates/.ai/workflows/work-the-backlog.org
@@ -70,6 +70,8 @@ A task is autonomous-safe when *both* hold. This layer is a lookup, not a judgme
1. *Status is =TODO=* — never =VERIFY=, =DOING=, =DONE=, or =CANCELLED=. =VERIFY= marks "awaiting Craig's input"; auto-implementing one defeats the check it represents. The do-not-implement set is safe-by-omission: anything not plainly =TODO= (plus any project-declared "hold" marker) is out.
2. *Tagged =:solo:=* — the autonomy tag, resolved against the project's priority/tag scheme header in =todo.org= (never hardcoded). =:solo:= carries the hard definition in =todo-format.md=: completable and verifiable without Craig beyond at most one or two quick decisions answerable up front, no design deliberation. A project whose scheme declares a different autonomous-safe tag set overrides the default.
+Terminology: *speedrunnable means tagged =:solo:=*. It does not mean =:quick:= or require =:quick:solo:=. The =TODO= status check above is the execution-state gate over that speedrunnable set.
+
Priority and =:next:= drive *ordering* within the eligible set, not eligibility ([#A] before [#B] before [#C], then the author's ordering). =:quick:= is an effort hint for batching and duration estimates — never a gate.
Task *size* is deliberately absent from this gate. A large but well-specified, decision-free task is in scope and gets decomposed into per-logical-commit chunks during implementation. Size never sends a task away; only *deliberation* or *risk* does (the checklist below).
diff --git a/claude-templates/.ai/workflows/wrap-it-up.org b/claude-templates/.ai/workflows/wrap-it-up.org
index 103e225..7d55a55 100644
--- a/claude-templates/.ai/workflows/wrap-it-up.org
+++ b/claude-templates/.ai/workflows/wrap-it-up.org
@@ -24,7 +24,7 @@ The wrap-up is complete when:
2. *File is archived.* =.ai/session-context.org= has been renamed to =.ai/sessions/YYYY-MM-DD-HH-MM-description.org=. The old path no longer exists.
3. *todo.org is clean.* Cleanup script ran. Any auto-fixes are staged for the wrap-up commit. Orphan planning lines surfaced for manual fix if there are any.
4. *Linear board is honest* (skip if project doesn't use Linear). Any Dev-Review ticket whose PR has merged was moved to Done or PM Acceptance per the classification rule.
-5. *Git state is clean.* All changes committed + pushed to all remotes. Working tree clean.
+5. *Git state is certified clean.* All changes are committed + pushed to all remotes, =git-worktree-gate certify= succeeded at the current HEAD, and the working tree has no staged, unstaged, untracked, submodule, or in-progress-operation state.
6. *Valediction delivered.* Brief, warm closing with key accomplishments and reminders, ending with =session wrapped.= on its own line as the signoff marker.
The absence of =.ai/session-context.org= is the signal that the last session wrapped up cleanly. Its presence at session start means the previous session was interrupted.
@@ -273,7 +273,7 @@ For an interactive walk of the judgments mid-day, run =/lint-org todo.org=.
*** Inbox sanity check (surface unprocessed handoffs)
-If the project has an =inbox/= directory, verify it holds nothing but =.gitkeep=, =lint-followups.org= (the lint-org pipeline file the next morning's daily-prep consumes), and any explicitly-deferred =PROCESSED-*= files before the wrap completes. An inbox that arrived at session start with handoffs from other projects, or that received handoffs mid-session, needs =inbox.org= process mode to run and apply its value-gate dispositions. Wrapping with a dirty inbox silently defers the work to next session and accumulates handoff debt that the sender can't see.
+If the project has an =inbox/= directory, verify it holds nothing but =.gitkeep=, =lint-followups.org= (the lint-org pipeline file the next morning's daily-prep consumes), and =PROCESSED-*= files before the wrap completes. An inbox that arrived at session start with handoffs from other projects, or that received handoffs mid-session, needs =inbox.org= process mode to run and apply its value-gate dispositions. Wrapping with an unprocessed inbox silently defers the work to next session and accumulates handoff debt that the sender can't see.
#+begin_src bash
unprocessed=$(find inbox -maxdepth 1 -type f \
@@ -282,7 +282,7 @@ unprocessed=$(find inbox -maxdepth 1 -type f \
! -name 'PROCESSED-*' \
2>/dev/null | wc -l)
if [ "$unprocessed" -gt 0 ]; then
- echo "wrap-up: inbox/ has $unprocessed unprocessed item(s). Run inbox.org process mode before wrapping, or explicitly defer each item with a one-line reason in the valediction."
+ echo "wrap-up blocked: inbox/ has $unprocessed unprocessed item(s). Run inbox.org process mode before wrapping."
find inbox -maxdepth 1 -type f \
! -name '.gitkeep' \
! -name 'lint-followups.org' \
@@ -291,7 +291,7 @@ if [ "$unprocessed" -gt 0 ]; then
fi
#+end_src
-If the count is zero or the project has no =inbox/= directory, the check is a silent no-op. If non-zero, the wrap is incomplete by default. The user resolves each item (process now, defer with reason in the valediction, or delete with rationale) before the validation checklist passes.
+If the count is zero or the project has no =inbox/= directory, the check is a silent no-op. If non-zero, the wrap is blocked. Process each item through its value-gate disposition, or delete it only when that workflow's rationale authorizes deletion, before continuing.
The check exempts =lint-followups.org= explicitly because lint-org runs earlier in the same wrap-up workflow and writes its judgment items to that file in =inbox/= by design. The file is a pipeline artifact for the next morning's =daily-prep=, not a handoff that needs the value gate.
@@ -501,17 +501,17 @@ Behavior:
git status --short
#+end_src
-*Default policy: end every session with an empty =git status=.* The wrap is incomplete while anything remains dirty. There is no "leave it alone" default — every leftover gets an active resolution. The only way for a file to stay dirty across the wrap is the user explicitly saying "defer this one, leave it dirty." Surface each leftover with a concrete recommendation; the user has to actively opt out for the dirt to persist.
+*Hard policy: end every session with an empty =git status=.* The wrap is incomplete while anything remains dirty. There is no deferral exception and no "wrapped with known changes" state: unresolved dirt means the session remains open and wrap-up does not occur.
This inverts the older "intentional carryover" default, which let pre-existing dirty state accumulate across sessions silently. Carryover that lives for days or weeks is almost always one of: a forgotten commit from a prior wrap, a stale change that should be discarded, or genuine in-flight work that needs an explicit stash/branch home. None of those should default to "leave it dirty."
**** Three kinds of leftover
-| Pattern | What it is | Recommended action (apply unless user defers) |
+| Pattern | What it is | Recommended action |
|---+---+---|
| Generated, runtime, or lock files that no human edits — e.g., =.claude/scheduled_tasks.lock=, =.pytest_cache/=, build outputs, IDE state, editor swap files | *Runtime artifact* — created by tooling or the harness, not by the user, and shouldn't be tracked | Add the matching pattern to =.gitignore= (project-level, not =~/.gitignore_global=). For tracked files, =git rm --cached <path>=. Stage =.gitignore= and any =rm --cached= changes in *one* follow-up commit (=chore: gitignore X=), push. Re-run =git status= to confirm clean. |
| Modified or created during the session but not staged into the wrap-up commit | *Forgotten change* — real session work that should have been in the wrap commit but missed it | Stage and create a follow-up commit. Don't =--amend= the wrap-up commit once pushed (diverging history without a clear win). Push the follow-up to all remotes. |
-| Was dirty at session start and still dirty at session end — work this session deliberately didn't touch | *Pre-existing dirt that needs a decision* — could be a missed commit from a prior wrap, stale abandoned work, or real in-flight work without a home | Investigate (show diff + check the originating session). Recommend one of: (a) commit now if the work is complete, (b) stash with a descriptive message if it's genuine WIP, (c) =git checkout -- <path>= / =git clean -f <path>= if stale and unwanted, (d) move to a feature branch if it's longer-running, (e) user explicitly defers and accepts the dirt. Do not silently leave dirty. |
+| Was dirty at session start and still dirty at session end — work this session deliberately didn't touch | *Pre-existing dirt that needs a decision* — could be a missed commit from a prior wrap, stale abandoned work, or real in-flight work without a home | Investigate (show diff + check the originating session). Recommend one of: (a) commit now if the work is complete, (b) stash with a descriptive message if it's genuine WIP, (c) =git checkout -- <path>= / =git clean -f <path>= if stale and unwanted, or (d) move to a feature branch if it's longer-running. Do not silently leave dirty. |
**** Per-file flow
@@ -519,18 +519,40 @@ For each leftover line in =git status --short=:
1. Identify which of the three kinds above it matches.
2. State what the file is (one line) and the recommended action.
-3. Apply the action unless the user explicitly defers.
-4. Re-run =git status --short= after each follow-up commit until empty (or until every remaining line is an explicit user-deferred entry).
+3. Apply the action when it is safe and authorized.
+4. Re-run =git status --short= after each follow-up commit until empty.
The pre-existing-dirt case (third row) is the one this rule most cares about. Treat each pre-existing-dirty file as a question that must get an answer this session, not as "carryover that's fine to inherit." A file that was dirty for a week before this session probably isn't going to get cleaner by waiting another week. Look at the diff, check the originating session's notes, and recommend a real resolution.
-**** When the user defers
+**** When cleanup cannot be completed
-If the user does say "leave this one dirty for now" after seeing the recommendation, that is fine — log the deferral in the valediction so the next session knows it was an explicit choice, not a miss. Format: "Deferred (per Craig's decision today): =path/to/file= — <one-line reason>". Without that note, the next session can't distinguish "we agreed to defer" from "we forgot again."
+Stop the wrap. Do not deliver the valediction, print =session wrapped.=, drop a teardown/shutdown sentinel, or describe the session as complete. Report:
+
+1. Every remaining path and its exact Git state.
+2. What the file is and why the agent cannot safely resolve it alone.
+3. The concrete action or decision Craig needs to provide to make the tree clean.
+
+An explicit decision to keep a file dirty changes the outcome from "wrapping" to "leaving the session interrupted." It never satisfies this workflow.
+
+*** Final clean-tree certificate — hard gate
+
+After all commits are pushed and every leftover appears resolved, run the shared gate:
+
+#+begin_src bash
+gate="$(command -v git-worktree-gate 2>/dev/null || true)"
+[ -n "$gate" ] || gate="$HOME/code/rulesets/claude-templates/bin/git-worktree-gate"
+if [ ! -x "$gate" ]; then
+ echo "wrap blocked: git-worktree-gate is unavailable; install rulesets tooling and retry"
+ exit 1
+fi
+"$gate" certify "$PWD"
+#+end_src
+
+The certificate lives inside the Git directory, so it does not dirty the worktree. It records the exact verified HEAD. A non-zero result is a hard stop governed by "When cleanup cannot be completed" above. Step 5 is unreachable until certification succeeds.
** Step 5: Valediction
-Brief, warm closing. 3-4 sentences max.
+Only after the final clean-tree certificate succeeds, deliver a brief, warm closing. 3-4 sentences max.
Include:
- What was accomplished (specific, not generic)
@@ -567,7 +589,7 @@ Do nothing. The buffer, the =aiv-<project>= tmux session, and =claude= all stay
*** Teardown mode (default)
-Confirm commit + push succeeded (Exit Criteria 5 — never tear down over unpushed work), then drop the sentinel:
+Confirm commit + push and the final clean-tree certificate succeeded (Exit Criteria 5 — never tear down over unpushed or dirty work), then drop the sentinel:
#+begin_src bash
touch "/tmp/ai-wrap-teardown-$(basename "$PWD")"
@@ -605,7 +627,8 @@ If =emacsclient= isn't resolvable or the daemon is down, the gate can't run —
7. *Leaving =.ai/session-context.org= in place* — its presence means "interrupted session", confuses next startup
8. *Long preachy valediction* — brief beats thorough
9. *Leaving runtime/generated files dirty without gitignoring them* — pollutes every future =git status= and erodes trust in "working tree clean" as a signal. Fix =.gitignore= during the wrap, not later.
-10. *Treating "was dirty at session start, still dirty now" as fine by default* — that's how a forgotten commit from two sessions ago turns into "carryover" for two weeks. Every pre-existing dirty file needs an active resolution recommendation this session. Deferral is allowed only with an explicit user choice, logged in the valediction.
+10. *Treating "was dirty at session start, still dirty now" as fine* — that's how a forgotten commit from two sessions ago turns into "carryover" for two weeks. Every pre-existing dirty file must be resolved or the wrap remains blocked.
+11. *Calling a blocked cleanup a wrap* — if the strict gate fails, report the paths and needed decisions; do not valedict, certify completion, or tear down.
* Validation Checklist
@@ -618,19 +641,20 @@ Before considering wrap-up complete:
- [ ] =todo-cleanup.el= ran — hygiene pass + =--convert-subtasks= + =--archive-done= + =--sync-child-priority= (if =todo.org= exists at project root)
- [ ] =lint-org.el= ran on =todo.org= — mechanical fixes applied, judgments appended to follow-ups file (if =todo.org= exists)
- [ ] Any orphan-planning-line warnings reviewed (fix or accept)
-- [ ] Inbox carries nothing but expected pipeline artifacts (=.gitkeep=, =lint-followups.org=, =PROCESSED-*= prefixes), OR each remaining handoff has an explicit deferral logged in the valediction
+- [ ] Inbox carries nothing but expected committed or ignored pipeline artifacts (=.gitkeep=, =lint-followups.org=, =PROCESSED-*= prefixes); any untracked inbox delivery was processed before wrap
- [ ] Linear Dev-Review sweep ran; any merged-PR tickets moved to Done or PM Acceptance (skip if project doesn't use Linear)
- [ ] Template-sync churn committed as its own =chore: sync .ai tooling from templates= (consuming projects only; skipped in rulesets), or surfaced if a synced path didn't match canonical
-- [ ] After wrap-up commit + push, =git status --short= is empty OR every remaining line has an explicit user-deferred decision logged in the valediction
+- [ ] After wrap-up commit + push, =git-worktree-gate certify "$PWD"= succeeded at the current HEAD
- [ ] Each leftover was investigated and the user saw a concrete resolution recommendation
- [ ] Runtime artifacts added to =.gitignore=, follow-up commit pushed, =git status= re-verified
- [ ] Forgotten changes committed in a follow-up and pushed
-- [ ] Pre-existing dirty files resolved (committed / stashed / discarded / moved to a feature branch) or explicitly deferred with a one-line reason in the valediction
+- [ ] Pre-existing dirty files resolved (committed / stashed / discarded / moved to a feature branch); otherwise wrap stopped with an actionable blocker report
- [ ] Current branch pushed to ALL remotes (verified with =git remote -v=)
- [ ] All other local branches with a tracking upstream pushed to their remote
- [ ] Any untracked-upstream branches surfaced for manual =git push -u=
- [ ] Step 6 teardown matches the trigger phrase: no-teardown leaves the buffer; teardown drops only =/tmp/ai-wrap-teardown-<project>=; shutdown gates on =cj/ai-term-live-count= = 1 and drops only =/tmp/ai-wrap-shutdown-<project>=
- [ ] No teardown/shutdown sentinel was dropped before commit + push was verified
+- [ ] The teardown hook can re-verify the clean-tree certificate before consuming a sentinel
- [ ] Shutdown aborted (fell back to normal wrap, logged in the valediction) when another =aiv-*= session was live or the gate couldn't run
- [ ] Commit message follows format (no =session:=, no Claude attribution)
- [ ] Valediction delivered (brief, specific, warm)
diff --git a/claude-templates/bin/ai b/claude-templates/bin/ai
index 55c0e0d..65d0ab7 100755
--- a/claude-templates/bin/ai
+++ b/claude-templates/bin/ai
@@ -231,12 +231,28 @@ fetch_candidates() {
wait
}
-# True (exit 0) when the worktree has staged, unstaged, or untracked changes.
+# Resolve the shared state gate installed beside this launcher. Keeping the
+# policy in one executable prevents startup, the picker, and wrap-up from
+# developing different meanings of "safe to sync."
+_git_gate_path() {
+ local gate="${GIT_WORKTREE_GATE:-}"
+ [ -n "$gate" ] || gate="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/git-worktree-gate"
+ [ -x "$gate" ] && printf '%s\n' "$gate"
+}
+
+# True (exit 0) when strict wrap would reject the worktree.
_git_is_dirty() {
- local dir="$1"
- ! git -C "$dir" diff --quiet 2>/dev/null ||
- ! git -C "$dir" diff --cached --quiet 2>/dev/null ||
- [ -n "$(git -C "$dir" ls-files --others --exclude-standard 2>/dev/null)" ]
+ local dir="$1" gate
+ gate="$(_git_gate_path)" || return 0
+ ! "$gate" strict "$dir" >/dev/null 2>&1
+}
+
+# True (exit 0) when startup sync must stop. Untracked inbox deliveries are
+# safe queue input; every tracked, staged, or other untracked change blocks.
+_git_blocks_sync() {
+ local dir="$1" gate
+ gate="$(_git_gate_path)" || return 0
+ ! "$gate" sync-safe "$dir" >/dev/null 2>&1
}
# Return " (↑N ↓N dirty)" or " (✓)" if clean.
@@ -254,7 +270,13 @@ git_status_indicator() {
parts+=("no upstream")
fi
- _git_is_dirty "$dir" && parts+=("dirty")
+ if _git_is_dirty "$dir"; then
+ if _git_blocks_sync "$dir"; then
+ parts+=("dirty")
+ else
+ parts+=("inbox")
+ fi
+ fi
if [ ${#parts[@]} -gt 0 ]; then
local IFS=' '
@@ -275,11 +297,11 @@ annotate_candidates() {
candidates=("${annotated[@]}")
}
-# Pull if clean, behind, not ahead. No-op otherwise.
+# Pull if sync-safe, behind, not ahead. Inbox-only queue input is sync-safe.
auto_pull_if_clean() {
local dir="$1" upstream ahead behind
[ -d "$dir/.git" ] || return 0
- _git_is_dirty "$dir" && return 0
+ _git_blocks_sync "$dir" && return 0
upstream=$(git -C "$dir" rev-parse --abbrev-ref --symbolic-full-name "@{u}" 2>/dev/null || true)
[ -z "$upstream" ] && return 0
@@ -356,7 +378,7 @@ prep_git_single() {
ahead=$(git -C "$dir" rev-list --count "$upstream..HEAD" 2>/dev/null || echo 0)
behind=$(git -C "$dir" rev-list --count "HEAD..$upstream" 2>/dev/null || echo 0)
- _git_is_dirty "$dir" && dirty=1
+ _git_blocks_sync "$dir" && dirty=1
case "$(_git_prep_action 1 "$dirty" "${ahead:-0}" "${behind:-0}")" in
pull)
diff --git a/claude-templates/bin/git-worktree-gate b/claude-templates/bin/git-worktree-gate
new file mode 100755
index 0000000..e453fd1
--- /dev/null
+++ b/claude-templates/bin/git-worktree-gate
@@ -0,0 +1,185 @@
+#!/usr/bin/env bash
+# git-worktree-gate — one definition of safe Git state for startup and wrap.
+#
+# Modes:
+# strict [DIR] Require an entirely empty worktree.
+# sync-safe [DIR] Permit untracked inbox/ deliveries, but nothing else.
+# certify [DIR] Strict-check, then record the verified HEAD in the git dir.
+# verify [DIR] Strict-check and require the recorded HEAD to still match.
+#
+# Ignored files are deliberately outside Git's clean-worktree contract.
+
+set -u
+
+mode="${1:-}"
+repo="${2:-.}"
+
+usage() {
+ echo "usage: git-worktree-gate {strict|sync-safe|certify|verify} [DIR]" >&2
+ exit 2
+}
+
+case "$mode" in
+ strict|sync-safe|certify|verify) ;;
+ *) usage ;;
+esac
+
+root="$(git -C "$repo" rev-parse --show-toplevel 2>/dev/null)" || {
+ echo "git-worktree-gate: $repo is not inside a Git worktree" >&2
+ exit 2
+}
+gitdir="$(git -C "$root" rev-parse --absolute-git-dir 2>/dev/null)" || {
+ echo "git-worktree-gate: cannot resolve the Git directory for $root" >&2
+ exit 2
+}
+certificate="$gitdir/ai-wrap-clean"
+
+quote_path() {
+ printf '%q' "$1"
+}
+
+operation_in_progress() {
+ local marker
+ for marker in MERGE_HEAD CHERRY_PICK_HEAD REVERT_HEAD BISECT_LOG; do
+ [ -e "$gitdir/$marker" ] && {
+ printf '%s' "$marker"
+ return 0
+ }
+ done
+ for marker in rebase-merge rebase-apply sequencer; do
+ [ -d "$gitdir/$marker" ] && {
+ printf '%s' "$marker"
+ return 0
+ }
+ done
+ return 1
+}
+
+describe_entry() {
+ local xy="$1" path="$2" original="${3:-}"
+ local index="${xy:0:1}" worktree="${xy:1:1}" label=""
+
+ if [ "$xy" = "??" ]; then
+ label="untracked; add and commit it, move it outside the repository, or remove it if unwanted"
+ elif [ "$xy" = "!!" ]; then
+ label="ignored"
+ elif [[ "$xy" = *U* || "$xy" = "AA" || "$xy" = "DD" ]]; then
+ label="unmerged; resolve the conflict and commit the result"
+ elif [ "$index" != " " ] && [ "$worktree" != " " ]; then
+ label="staged and unstaged changes; review both layers, then commit or restore them"
+ elif [ "$index" != " " ]; then
+ label="staged change; commit it or unstage and restore it"
+ else
+ label="unstaged tracked change; commit it or restore it"
+ fi
+
+ printf ' %s ' "$xy"
+ quote_path "$path"
+ if [ -n "$original" ]; then
+ printf ' (from '
+ quote_path "$original"
+ printf ')'
+ fi
+ printf ' — %s\n' "$label"
+}
+
+check_state() {
+ local policy="$1" xy path original="" blocked=0 op=""
+ local status_tmp="" status_err="" status_detail=""
+ local -a report=()
+
+ if op="$(operation_in_progress)"; then
+ report+=(" Git operation in progress: $op — finish or abort it")
+ blocked=1
+ fi
+
+ status_tmp="$(mktemp "$gitdir/ai-worktree-status.tmp.XXXXXX")" || {
+ echo "wrap blocked: cannot allocate a Git-state check file" >&2
+ return 1
+ }
+ status_err="$(mktemp "$gitdir/ai-worktree-status.err.XXXXXX")" || {
+ rm -f "$status_tmp"
+ echo "wrap blocked: cannot allocate a Git-state error file" >&2
+ return 1
+ }
+
+ if ! git -C "$root" status --porcelain=v1 -z \
+ --untracked-files=all --ignore-submodules=none \
+ >"$status_tmp" 2>"$status_err"; then
+ status_detail="$(head -1 "$status_err")"
+ [ -n "$status_detail" ] || status_detail="unknown Git error"
+ report+=(" git status failed — $status_detail")
+ blocked=1
+ else
+ while IFS= read -r -d '' entry; do
+ xy="${entry:0:2}"
+ path="${entry:3}"
+ original=""
+ if [[ "${xy:0:1}" = "R" || "${xy:0:1}" = "C" ]]; then
+ IFS= read -r -d '' original || true
+ fi
+
+ if [ "$policy" = "sync-safe" ] \
+ && [ "$xy" = "??" ] \
+ && [[ "$path" = inbox/* ]]; then
+ continue
+ fi
+
+ report+=("$(describe_entry "$xy" "$path" "$original")")
+ blocked=1
+ done <"$status_tmp"
+ fi
+ rm -f "$status_tmp" "$status_err"
+
+ if [ "$blocked" -ne 0 ]; then
+ if [ "$policy" = "sync-safe" ]; then
+ echo "sync blocked: rulesets has changes other than untracked inbox deliveries" >&2
+ else
+ echo "wrap blocked: Git worktree is not completely clean" >&2
+ fi
+ printf '%s\n' "${report[@]}" >&2
+ return 1
+ fi
+ return 0
+}
+
+case "$mode" in
+ strict)
+ check_state strict
+ ;;
+ sync-safe)
+ check_state sync-safe
+ ;;
+ certify)
+ check_state strict || exit 1
+ head="$(git -C "$root" rev-parse HEAD 2>/dev/null)" || {
+ echo "wrap blocked: cannot resolve HEAD" >&2
+ exit 1
+ }
+ tmp="$(mktemp "$gitdir/ai-wrap-clean.tmp.XXXXXX")" || exit 1
+ chmod 600 "$tmp"
+ {
+ printf 'head=%s\n' "$head"
+ printf 'root=%s\n' "$root"
+ } >"$tmp"
+ mv "$tmp" "$certificate"
+ ;;
+ verify)
+ check_state strict || exit 1
+ [ -f "$certificate" ] || {
+ echo "wrap blocked: no clean-tree certificate exists; rerun the final wrap verification" >&2
+ exit 1
+ }
+ certified_head="$(sed -n 's/^head=//p' "$certificate" | head -1)"
+ certified_root="$(sed -n 's/^root=//p' "$certificate" | head -1)"
+ current_head="$(git -C "$root" rev-parse HEAD 2>/dev/null)" || exit 1
+ [ "$certified_root" = "$root" ] || {
+ echo "wrap blocked: clean-tree certificate belongs to a different worktree" >&2
+ exit 1
+ }
+ [ -n "$certified_head" ] && [ "$certified_head" = "$current_head" ] || {
+ echo "wrap blocked: HEAD changed after clean-tree certification; rerun the final wrap verification" >&2
+ exit 1
+ }
+ ;;
+esac
diff --git a/hooks/README.md b/hooks/README.md
index e83469e..9c4268d 100644
--- a/hooks/README.md
+++ b/hooks/README.md
@@ -11,6 +11,8 @@ Machine-wide Claude Code hooks that install into `~/.claude/hooks/` and apply to
| `gh-pr-create-confirm.py` | `PreToolUse(Bash)` | Gates `gh pr create` behind a confirmation modal showing title, base←head, reviewers, labels, assignees, milestone, draft flag, and body (HEREDOC or quoted). |
| `destructive-bash-confirm.py` | `PreToolUse(Bash)` | Gates destructive commands (`git push --force`, `git reset --hard`, `git clean -f`, `git branch -D`, `rm -rf`) with a modal showing the command, local context (branch, uncommitted file counts, targeted paths), and a warning banner. Elevates severity when force-pushing protected branches or targeting root/home/wildcard paths. |
| `inbox-boundary-check.sh` | `Stop` | Soft-nudges the agent to process pending `inbox/` handoffs before yielding. Blocks the stop once (injects a reason with the pending count) when `inbox-status -q` reports pending items; steps aside on the harness re-entry (`stop_hook_active`) so a mid-task pause or an unprocessable item never wedges. No-ops in any project without an `inbox/` or without `inbox-status`. |
+| `ai-wrap-teardown.sh` | `Stop` | Re-verifies the strict clean-tree certificate and its HEAD before consuming a wrap sentinel. Dirty or uncertified state blocks Stop with an actionable report; verified state tears down the matching ai-term session or starts the guarded shutdown countdown. Emits the appropriate Claude or Codex response shape. |
+| `rulesets-write-boundary.py` | `PreToolUse(Edit\|Write)` | Resolves write targets through symlinks and denies cross-project edits that land inside rulesets. Directs the sender through `inbox-send rulesets`; rulesets sessions themselves pass. Codex maps `apply_patch` to the same matcher. |
Shared library (not a hook): `_common.py` — `read_payload()`, `respond_ask()`, `scan_attribution()`. Installed as a sibling symlink so the two Python hooks can `from _common import …` at runtime.
diff --git a/hooks/ai-wrap-teardown.sh b/hooks/ai-wrap-teardown.sh
index 6133075..ca73ae6 100755
--- a/hooks/ai-wrap-teardown.sh
+++ b/hooks/ai-wrap-teardown.sh
@@ -39,8 +39,10 @@
# "command": "~/.claude/hooks/ai-wrap-teardown.sh" } ] } ]
set -u
+payload="$(cat)"
+
# Stop-hook stdin JSON carries cwd; basename it to the project / aiv- session.
-cwd="$(jq -r '.cwd // empty' 2>/dev/null)"
+cwd="$(printf '%s' "$payload" | jq -r '.cwd // empty' 2>/dev/null)"
[ -z "$cwd" ] && cwd="$PWD"
proj="$(basename "$cwd")"
@@ -53,15 +55,54 @@ fire() {
emacsclient -e "$1" >/dev/null 2>&1 || true
}
+# A sentinel means wrap-up claimed completion. Re-prove that claim immediately
+# before consuming it: the certificate binds a prior strict check to HEAD, and
+# verify also performs a fresh strict check to catch late writes.
+verify_wrap() {
+ local gate="${GIT_WORKTREE_GATE:-}" detail
+ if [ -z "$gate" ]; then
+ gate="$(command -v git-worktree-gate 2>/dev/null || true)"
+ fi
+ if [ -z "$gate" ] || [ ! -x "$gate" ]; then
+ gate="$HOME/code/rulesets/claude-templates/bin/git-worktree-gate"
+ fi
+ if [ ! -x "$gate" ]; then
+ detail="wrap blocked: git-worktree-gate is unavailable; install rulesets tooling and retry"
+ else
+ detail="$("$gate" verify "$cwd" 2>&1)" && return 0
+ fi
+
+ # Codex and Claude consume different Stop-hook response fields. Codex
+ # command-hook payloads always include model; Claude's do not.
+ if printf '%s' "$payload" | jq -e '.model? != null' >/dev/null 2>&1; then
+ jq -n --arg reason "$detail" \
+ '{continue:false, stopReason:$reason, systemMessage:$reason}'
+ else
+ jq -n --arg reason "$detail" \
+ '{decision:"block", reason:$reason}'
+ fi
+ return 1
+}
+
+consume_certificate() {
+ local dir
+ dir="$(git -C "$cwd" rev-parse --absolute-git-dir 2>/dev/null)" || return 0
+ rm -f "$dir/ai-wrap-clean"
+}
+
# Shutdown supersedes teardown when both are somehow present.
if [ -f "$shutdown_sentinel" ]; then
+ verify_wrap || exit 0
rm -f "$shutdown_sentinel" "$teardown_sentinel"
+ consume_certificate
fire '(cj/ai-term-shutdown-countdown)'
exit 0
fi
if [ -f "$teardown_sentinel" ]; then
+ verify_wrap || exit 0
rm -f "$teardown_sentinel"
+ consume_certificate
fire "(cj/ai-term-quit \"${proj}\")"
exit 0
fi
diff --git a/hooks/rulesets-write-boundary.py b/hooks/rulesets-write-boundary.py
new file mode 100755
index 0000000..35088ea
--- /dev/null
+++ b/hooks/rulesets-write-boundary.py
@@ -0,0 +1,94 @@
+#!/usr/bin/env python3
+"""Block cross-project Edit/Write calls that resolve into rulesets.
+
+Global Claude rules, hooks, skills, commands, and bin tools are symlinks into
+~/code/rulesets. Editing one from another project's session silently dirties
+rulesets and can block every later startup. The sanctioned path is inbox-send;
+the rulesets session owns the canonical edit and its wrap.
+"""
+
+from __future__ import annotations
+
+import os
+import re
+from pathlib import Path
+from typing import Any, Iterable
+
+from _common import read_payload, respond_deny
+
+
+PATCH_PATH = re.compile(
+ r"^\*\*\* (?:Add|Update|Delete) File: (.+)$|^\*\*\* Move to: (.+)$",
+ re.MULTILINE,
+)
+
+
+def inside(path: Path, parent: Path) -> bool:
+ try:
+ path.relative_to(parent)
+ return True
+ except ValueError:
+ return False
+
+
+def candidate_paths(tool_input: Any) -> Iterable[str]:
+ if isinstance(tool_input, dict):
+ for key, value in tool_input.items():
+ if key in {"file_path", "path"} and isinstance(value, str):
+ yield value
+ elif key in {"patch", "input"} and isinstance(value, str):
+ for match in PATCH_PATH.finditer(value):
+ yield match.group(1) or match.group(2)
+ elif isinstance(tool_input, str):
+ for match in PATCH_PATH.finditer(tool_input):
+ yield match.group(1) or match.group(2)
+
+
+def resolve(raw: str, cwd: Path) -> Path:
+ expanded = Path(os.path.expandvars(os.path.expanduser(raw)))
+ if not expanded.is_absolute():
+ expanded = cwd / expanded
+ return expanded.resolve(strict=False)
+
+
+def main() -> int:
+ payload = read_payload()
+ tool_name = payload.get("tool_name", "")
+ if tool_name not in {"Edit", "Write", "apply_patch"}:
+ return 0
+
+ rulesets = Path(
+ os.environ.get("RULESETS_ROOT", "~/code/rulesets")
+ ).expanduser().resolve(strict=False)
+ cwd = Path(payload.get("cwd") or os.getcwd()).resolve(strict=False)
+
+ # A rulesets session owns its own canonical files.
+ if inside(cwd, rulesets):
+ return 0
+
+ blocked: list[str] = []
+ for raw in candidate_paths(payload.get("tool_input", {})):
+ try:
+ target = resolve(raw, cwd)
+ except (OSError, RuntimeError):
+ blocked.append(f"{raw} (real path could not be verified)")
+ continue
+ if inside(target, rulesets):
+ blocked.append(str(target))
+
+ if not blocked:
+ return 0
+
+ shown = ", ".join(blocked)
+ reason = (
+ "Blocked cross-project write into rulesets: "
+ f"{shown}. This path may have been reached through an installed "
+ "symlink. Send the proposed change with inbox-send rulesets instead, "
+ "then let a rulesets session apply and wrap it."
+ )
+ respond_deny(reason, system_message=reason)
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/hooks/settings-snippet.json b/hooks/settings-snippet.json
index 26cbc18..50e3d31 100644
--- a/hooks/settings-snippet.json
+++ b/hooks/settings-snippet.json
@@ -23,6 +23,12 @@
],
"PreToolUse": [
{
+ "matcher": "Edit|Write",
+ "hooks": [
+ { "type": "command", "command": "~/.claude/hooks/rulesets-write-boundary.py" }
+ ]
+ },
+ {
"matcher": "Bash",
"hooks": [
{ "type": "command", "command": "~/.claude/hooks/git-commit-confirm.py" },
diff --git a/hooks/tests/test_rulesets_write_boundary.py b/hooks/tests/test_rulesets_write_boundary.py
new file mode 100644
index 0000000..826a941
--- /dev/null
+++ b/hooks/tests/test_rulesets_write_boundary.py
@@ -0,0 +1,110 @@
+import json
+import os
+import subprocess
+import sys
+from pathlib import Path
+
+
+SCRIPT = Path(__file__).parents[1] / "rulesets-write-boundary.py"
+
+
+def run_hook(payload: dict, rulesets: Path) -> dict | None:
+ proc = subprocess.run(
+ [sys.executable, str(SCRIPT)],
+ input=json.dumps(payload),
+ text=True,
+ capture_output=True,
+ env={**os.environ, "RULESETS_ROOT": str(rulesets)},
+ check=True,
+ )
+ return json.loads(proc.stdout) if proc.stdout else None
+
+
+def test_allows_write_from_rulesets_session(tmp_path):
+ rulesets = tmp_path / "rulesets"
+ rulesets.mkdir()
+ target = rulesets / "file"
+ result = run_hook(
+ {
+ "cwd": str(rulesets),
+ "tool_name": "Write",
+ "tool_input": {"file_path": str(target)},
+ },
+ rulesets,
+ )
+ assert result is None
+
+
+def test_blocks_absolute_cross_project_write(tmp_path):
+ rulesets = tmp_path / "rulesets"
+ other = tmp_path / "other"
+ rulesets.mkdir()
+ other.mkdir()
+ result = run_hook(
+ {
+ "cwd": str(other),
+ "tool_name": "Edit",
+ "tool_input": {"file_path": str(rulesets / "rule.md")},
+ },
+ rulesets,
+ )
+ assert result["hookSpecificOutput"]["permissionDecision"] == "deny"
+ assert "inbox-send rulesets" in result["hookSpecificOutput"][
+ "permissionDecisionReason"
+ ]
+
+
+def test_blocks_write_reached_through_symlink(tmp_path):
+ rulesets = tmp_path / "rulesets"
+ other = tmp_path / "other"
+ installed = tmp_path / "installed"
+ rulesets.mkdir()
+ other.mkdir()
+ (rulesets / "rules").mkdir()
+ installed.symlink_to(rulesets / "rules", target_is_directory=True)
+ result = run_hook(
+ {
+ "cwd": str(other),
+ "tool_name": "Write",
+ "tool_input": {"file_path": str(installed / "todo-format.md")},
+ },
+ rulesets,
+ )
+ assert result["hookSpecificOutput"]["permissionDecision"] == "deny"
+ assert str(rulesets) in result["systemMessage"]
+
+
+def test_blocks_apply_patch_target(tmp_path):
+ rulesets = tmp_path / "rulesets"
+ other = tmp_path / "other"
+ rulesets.mkdir()
+ other.mkdir()
+ patch = (
+ f"*** Begin Patch\n*** Update File: {rulesets / 'file'}\n"
+ "@@\n-old\n+new\n*** End Patch\n"
+ )
+ result = run_hook(
+ {
+ "cwd": str(other),
+ "tool_name": "apply_patch",
+ "tool_input": {"input": patch},
+ },
+ rulesets,
+ )
+ assert result["hookSpecificOutput"]["permissionDecision"] == "deny"
+
+
+def test_allows_unrelated_write(tmp_path):
+ rulesets = tmp_path / "rulesets"
+ other = tmp_path / "other"
+ rulesets.mkdir()
+ other.mkdir()
+ result = run_hook(
+ {
+ "cwd": str(other),
+ "tool_name": "Edit",
+ "tool_input": {"file_path": str(other / "file")},
+ },
+ rulesets,
+ )
+ assert result is None
diff --git a/inbox/lint-followups.org b/inbox/lint-followups.org
index 52905dd..f63470c 100644
--- a/inbox/lint-followups.org
+++ b/inbox/lint-followups.org
@@ -1,2 +1,17 @@
-
* 2026-07-20 Mon — Task-review health: 1 top-level [#A]/[#B]/[#C] tasks unreviewed for >30 days (daily review may have slipped)
+
+* lint-org follow-ups — todo.org (2026-07-25)
+** TODO link-to-local-file — Link to non-existent local file "working/hook-fail-open/validate-el.diff" (line 2066)
+** TODO misplaced-planning-info — Misplaced planning info line (line 2055)
+** TODO link-to-local-file — Link to non-existent local file "working/hook-fail-open/pre-commit.diff" (line 2050)
+** TODO misplaced-planning-info — Misplaced planning info line (line 2035)
+** TODO org-table-standard — table violates the org-table standard: no closing rule; missing rule between rows — wrap-org-table.el reflows it (line 141)
+** TODO task-missing-last-reviewed — task has no :LAST_REVIEWED: — stamp it at creation with today's date (a task you just wrote and graded is reviewed); otherwise it enters the next staleness batch as never-reviewed (line 299)
+** TODO task-missing-last-reviewed — task has no :LAST_REVIEWED: — stamp it at creation with today's date (a task you just wrote and graded is reviewed); otherwise it enters the next staleness batch as never-reviewed (line 302)
+** TODO task-missing-last-reviewed — task has no :LAST_REVIEWED: — stamp it at creation with today's date (a task you just wrote and graded is reviewed); otherwise it enters the next staleness batch as never-reviewed (line 309)
+** TODO task-missing-last-reviewed — task has no :LAST_REVIEWED: — stamp it at creation with today's date (a task you just wrote and graded is reviewed); otherwise it enters the next staleness batch as never-reviewed (line 317)
+** TODO task-missing-last-reviewed — task has no :LAST_REVIEWED: — stamp it at creation with today's date (a task you just wrote and graded is reviewed); otherwise it enters the next staleness batch as never-reviewed (line 320)
+** TODO task-missing-last-reviewed — task has no :LAST_REVIEWED: — stamp it at creation with today's date (a task you just wrote and graded is reviewed); otherwise it enters the next staleness batch as never-reviewed (line 329)
+** TODO task-missing-last-reviewed — task has no :LAST_REVIEWED: — stamp it at creation with today's date (a task you just wrote and graded is reviewed); otherwise it enters the next staleness batch as never-reviewed (line 427)
+** TODO task-missing-last-reviewed — task has no :LAST_REVIEWED: — stamp it at creation with today's date (a task you just wrote and graded is reviewed); otherwise it enters the next staleness batch as never-reviewed (line 586)
+** TODO task-missing-last-reviewed — task has no :LAST_REVIEWED: — stamp it at creation with today's date (a task you just wrote and graded is reviewed); otherwise it enters the next staleness batch as never-reviewed (line 595)
diff --git a/scripts/tests/ai-launcher-characterization.bats b/scripts/tests/ai-launcher-characterization.bats
index f53855f..5b93ff3 100644
--- a/scripts/tests/ai-launcher-characterization.bats
+++ b/scripts/tests/ai-launcher-characterization.bats
@@ -120,6 +120,14 @@ _mk_repo_upstream() {
[ "$output" = " (no upstream dirty)" ]
}
+@test "git_status_indicator: inbox-only delivery is visible but not called dirty" {
+ _mk_repo "$WORK/r"
+ mkdir -p "$WORK/r/inbox"
+ touch "$WORK/r/inbox/from-home.org"
+ run git_status_indicator "$WORK/r"
+ [ "$output" = " (no upstream inbox)" ]
+}
+
@test "git_status_indicator: in-sync tracked repo reads (✓)" {
_mk_repo_upstream "$WORK/r"
run git_status_indicator "$WORK/r"
@@ -142,6 +150,39 @@ _mk_repo_upstream() {
[ "$output" = " (↓1)" ]
}
+@test "auto_pull_if_clean fast-forwards with an untracked inbox delivery" {
+ _mk_repo_upstream "$WORK/r"
+ git clone -q "$WORK/r.remote" "$WORK/writer"
+ git -C "$WORK/writer" config user.email t@example.com
+ git -C "$WORK/writer" config user.name tester
+ git -C "$WORK/writer" commit -q --allow-empty -m remote
+ git -C "$WORK/writer" push -q
+ git -C "$WORK/r" fetch -q
+ mkdir -p "$WORK/r/inbox"
+ printf 'handoff\n' >"$WORK/r/inbox/from-home.org"
+
+ run auto_pull_if_clean "$WORK/r"
+ [ "$status" -eq 0 ]
+ [ "$(git -C "$WORK/r" rev-parse HEAD)" = "$(git -C "$WORK/r" rev-parse '@{u}')" ]
+ [ -f "$WORK/r/inbox/from-home.org" ]
+}
+
+@test "auto_pull_if_clean refuses a non-inbox untracked file" {
+ _mk_repo_upstream "$WORK/r"
+ git clone -q "$WORK/r.remote" "$WORK/writer"
+ git -C "$WORK/writer" config user.email t@example.com
+ git -C "$WORK/writer" config user.name tester
+ git -C "$WORK/writer" commit -q --allow-empty -m remote
+ git -C "$WORK/writer" push -q
+ git -C "$WORK/r" fetch -q
+ printf 'scratch\n' >"$WORK/r/scratch"
+ before="$(git -C "$WORK/r" rev-parse HEAD)"
+
+ run auto_pull_if_clean "$WORK/r"
+ [ "$status" -eq 0 ]
+ [ "$(git -C "$WORK/r" rev-parse HEAD)" = "$before" ]
+}
+
# --- annotate_candidates() ----------------------------------------------------
@test "annotate_candidates: appends each candidate's status suffix" {
diff --git a/scripts/tests/ai-wrap-teardown-hook.bats b/scripts/tests/ai-wrap-teardown-hook.bats
index 05c49f1..6e33cca 100644
--- a/scripts/tests/ai-wrap-teardown-hook.bats
+++ b/scripts/tests/ai-wrap-teardown-hook.bats
@@ -7,10 +7,18 @@
setup() {
REPO_ROOT="$(cd "$(dirname "$BATS_TEST_FILENAME")/../.." && pwd)"
SCRIPT="$REPO_ROOT/hooks/ai-wrap-teardown.sh"
+ GATE="$REPO_ROOT/claude-templates/bin/git-worktree-gate"
TMPDIR_T="$(mktemp -d)"
PROJ="proj-$$-$BATS_TEST_NUMBER" # unique so /tmp sentinels don't collide
CWD="$TMPDIR_T/$PROJ"
mkdir -p "$CWD"
+ git init -q "$CWD"
+ git -C "$CWD" config user.email test@example.com
+ git -C "$CWD" config user.name tester
+ printf 'base\n' >"$CWD/tracked"
+ git -C "$CWD" add tracked
+ git -C "$CWD" commit -qm init
+ bash "$GATE" certify "$CWD"
TEARDOWN_SENTINEL="/tmp/ai-wrap-teardown-${PROJ}"
SHUTDOWN_SENTINEL="/tmp/ai-wrap-shutdown-${PROJ}"
@@ -35,7 +43,7 @@ teardown() {
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"
+ | GIT_WORKTREE_GATE="$GATE" PATH="$BIN:$PATH" bash "$SCRIPT"
}
@test "no sentinel: silent no-op, emacsclient never called" {
@@ -81,7 +89,7 @@ run_hook() {
: > "$TEARDOWN_SENTINEL"
status=0
output="$(printf '{"cwd":"%s","hook_event_name":"Stop"}' "$CWD" \
- | PATH="/usr/bin:/bin" bash "$SCRIPT")" || status=$?
+ | GIT_WORKTREE_GATE="$GATE" PATH="/usr/bin:/bin" bash "$SCRIPT")" || status=$?
[ "$status" -eq 0 ]
[ ! -f "$TEARDOWN_SENTINEL" ]
}
@@ -89,13 +97,63 @@ run_hook() {
@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'"
+ run env "GIT_WORKTREE_GATE=$GATE" "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)"
+ | GIT_WORKTREE_GATE="$GATE" PATH="$BIN:$PATH" bash "$SCRIPT" 2>&1 >/dev/null)"
[ -z "$err" ]
}
+
+@test "dirty worktree blocks teardown, preserves sentinel, and names the path" {
+ printf 'late\n' >>"$CWD/tracked"
+ : >"$TEARDOWN_SENTINEL"
+ run run_hook
+ [ "$status" -eq 0 ]
+ echo "$output" | jq -e '.decision == "block"'
+ echo "$output" | jq -e '.reason | test("tracked")'
+ [ -f "$TEARDOWN_SENTINEL" ]
+ [ ! -f "$EC_LOG" ]
+}
+
+@test "changed HEAD after certification blocks teardown" {
+ git -C "$CWD" commit -q --allow-empty -m later
+ : >"$TEARDOWN_SENTINEL"
+ run run_hook
+ echo "$output" | jq -e '.reason | test("HEAD changed")'
+ [ -f "$TEARDOWN_SENTINEL" ]
+ [ ! -f "$EC_LOG" ]
+}
+
+@test "missing certificate blocks teardown" {
+ rm -f "$(git -C "$CWD" rev-parse --absolute-git-dir)/ai-wrap-clean"
+ : >"$TEARDOWN_SENTINEL"
+ run run_hook
+ echo "$output" | jq -e '.reason | test("no clean-tree certificate")'
+ [ -f "$TEARDOWN_SENTINEL" ]
+}
+
+@test "Codex payload receives continue false and a stop reason" {
+ printf 'late\n' >>"$CWD/tracked"
+ : >"$TEARDOWN_SENTINEL"
+ run bash -c \
+ "printf '{\"cwd\":\"$CWD\",\"hook_event_name\":\"Stop\",\"model\":\"gpt-test\"}' \
+ | GIT_WORKTREE_GATE='$GATE' PATH='$BIN:$PATH' bash '$SCRIPT'"
+ [ "$status" -eq 0 ]
+ echo "$output" | jq -e '.continue == false'
+ echo "$output" | jq -e '.stopReason | test("tracked")'
+ [ -f "$TEARDOWN_SENTINEL" ]
+}
+
+@test "successful teardown consumes the clean-tree certificate" {
+ : >"$TEARDOWN_SENTINEL"
+ cert="$(git -C "$CWD" rev-parse --absolute-git-dir)/ai-wrap-clean"
+ [ -f "$cert" ]
+ run run_hook
+ [ "$status" -eq 0 ]
+ [ ! -f "$cert" ]
+}
diff --git a/scripts/tests/git-worktree-gate.bats b/scripts/tests/git-worktree-gate.bats
new file mode 100755
index 0000000..7d0d8a1
--- /dev/null
+++ b/scripts/tests/git-worktree-gate.bats
@@ -0,0 +1,146 @@
+#!/usr/bin/env bats
+
+setup() {
+ REPO_ROOT="$(cd "$(dirname "$BATS_TEST_FILENAME")/../.." && pwd)"
+ GATE="$REPO_ROOT/claude-templates/bin/git-worktree-gate"
+ WORK="$(mktemp -d)"
+ REPO="$WORK/repo"
+ git init -q "$REPO"
+ git -C "$REPO" config user.email test@example.com
+ git -C "$REPO" config user.name tester
+ printf 'base\n' >"$REPO/tracked"
+ git -C "$REPO" add tracked
+ git -C "$REPO" commit -qm init
+}
+
+teardown() {
+ rm -rf "$WORK"
+}
+
+@test "strict accepts a completely clean worktree" {
+ run bash "$GATE" strict "$REPO"
+ [ "$status" -eq 0 ]
+ [ -z "$output" ]
+}
+
+@test "strict rejects unstaged, staged, and untracked changes with paths" {
+ printf 'changed\n' >>"$REPO/tracked"
+ printf 'new\n' >"$REPO/staged"
+ git -C "$REPO" add staged
+ printf 'loose\n' >"$REPO/loose"
+
+ run bash "$GATE" strict "$REPO"
+ [ "$status" -eq 1 ]
+ [[ "$output" == *"wrap blocked"* ]]
+ [[ "$output" == *"tracked"* ]]
+ [[ "$output" == *"staged"* ]]
+ [[ "$output" == *"loose"* ]]
+}
+
+@test "sync-safe permits untracked inbox deliveries and strict still rejects them" {
+ mkdir -p "$REPO/inbox/nested"
+ printf 'handoff\n' >"$REPO/inbox/nested/from-home.org"
+
+ run bash "$GATE" sync-safe "$REPO"
+ [ "$status" -eq 0 ]
+
+ run bash "$GATE" strict "$REPO"
+ [ "$status" -eq 1 ]
+ [[ "$output" == *"inbox/nested/from-home.org"* ]]
+}
+
+@test "sync-safe rejects tracked changes even inside inbox" {
+ mkdir -p "$REPO/inbox"
+ printf 'tracked\n' >"$REPO/inbox/tracked.org"
+ git -C "$REPO" add inbox/tracked.org
+ git -C "$REPO" commit -qm inbox
+ printf 'changed\n' >>"$REPO/inbox/tracked.org"
+
+ run bash "$GATE" sync-safe "$REPO"
+ [ "$status" -eq 1 ]
+ [[ "$output" == *"inbox/tracked.org"* ]]
+}
+
+@test "sync-safe rejects untracked files outside inbox" {
+ printf 'scratch\n' >"$REPO/scratch"
+ run bash "$GATE" sync-safe "$REPO"
+ [ "$status" -eq 1 ]
+ [[ "$output" == *"scratch"* ]]
+}
+
+@test "ignored files do not block either policy" {
+ printf 'cache/\n' >"$REPO/.gitignore"
+ git -C "$REPO" add .gitignore
+ git -C "$REPO" commit -qm ignore
+ mkdir -p "$REPO/cache"
+ printf 'generated\n' >"$REPO/cache/output"
+
+ run bash "$GATE" strict "$REPO"
+ [ "$status" -eq 0 ]
+ run bash "$GATE" sync-safe "$REPO"
+ [ "$status" -eq 0 ]
+}
+
+@test "reports unusual filenames without losing the entry" {
+ odd=$'line break\nname'
+ printf 'odd\n' >"$REPO/$odd"
+ run bash "$GATE" strict "$REPO"
+ [ "$status" -eq 1 ]
+ [[ "$output" == *"line"* ]]
+ [[ "$output" == *"name"* ]]
+}
+
+@test "certify and verify bind a clean worktree to its current HEAD" {
+ run bash "$GATE" certify "$REPO"
+ [ "$status" -eq 0 ]
+ run bash "$GATE" verify "$REPO"
+ [ "$status" -eq 0 ]
+
+ git -C "$REPO" commit -q --allow-empty -m later
+ run bash "$GATE" verify "$REPO"
+ [ "$status" -eq 1 ]
+ [[ "$output" == *"HEAD changed"* ]]
+}
+
+@test "verify rejects changes made after certification" {
+ run bash "$GATE" certify "$REPO"
+ [ "$status" -eq 0 ]
+ printf 'late\n' >>"$REPO/tracked"
+ run bash "$GATE" verify "$REPO"
+ [ "$status" -eq 1 ]
+ [[ "$output" == *"tracked"* ]]
+}
+
+@test "dirty submodule blocks strict and sync-safe" {
+ CHILD="$WORK/child"
+ git init -q "$CHILD"
+ git -C "$CHILD" config user.email test@example.com
+ git -C "$CHILD" config user.name tester
+ printf 'child\n' >"$CHILD/file"
+ git -C "$CHILD" add file
+ git -C "$CHILD" commit -qm init
+ git -C "$REPO" -c protocol.file.allow=always submodule add -q "$CHILD" sub
+ git -C "$REPO" commit -qam submodule
+ printf 'dirty\n' >>"$REPO/sub/file"
+
+ run bash "$GATE" strict "$REPO"
+ [ "$status" -eq 1 ]
+ [[ "$output" == *"sub"* ]]
+ run bash "$GATE" sync-safe "$REPO"
+ [ "$status" -eq 1 ]
+}
+
+@test "a low-level git status failure blocks instead of looking clean" {
+ printf 'not an index\n' >"$WORK/bad-index"
+ run env GIT_INDEX_FILE="$WORK/bad-index" bash "$GATE" strict "$REPO"
+ [ "$status" -eq 1 ]
+ [[ "$output" == *"git status failed"* ]]
+}
+
+@test "an in-progress sequencer operation blocks a clean-looking tree" {
+ gitdir="$(git -C "$REPO" rev-parse --absolute-git-dir)"
+ mkdir -p "$gitdir/sequencer"
+ run bash "$GATE" strict "$REPO"
+ [ "$status" -eq 1 ]
+ [[ "$output" == *"sequencer"* ]]
+}
diff --git a/scripts/tests/install-hooks-link.bats b/scripts/tests/install-hooks-link.bats
index 80ac8dd..5368781 100644
--- a/scripts/tests/install-hooks-link.bats
+++ b/scripts/tests/install-hooks-link.bats
@@ -20,6 +20,7 @@ run_install() {
RULES_DIR="$TMPHOME/rules" \
HOOKS_DIR="$TMPHOME/hooks" \
CLAUDE_DIR="$TMPHOME/claude" \
+ CODEX_DIR="$TMPHOME/codex" \
LOCAL_BIN="$TMPHOME/bin"
}
@@ -28,6 +29,20 @@ run_install() {
[ "$status" -eq 0 ]
[ -L "$TMPHOME/hooks/session-clear-resume.sh" ]
[ -L "$TMPHOME/hooks/precompact-priorities.sh" ]
+ [ -L "$TMPHOME/hooks/rulesets-write-boundary.py" ]
+}
+
+@test "install links Codex Stop-hook configuration" {
+ run run_install
+ [ "$status" -eq 0 ]
+ [ -L "$TMPHOME/codex/hooks.json" ]
+ grep -q "ai-wrap-teardown.sh" "$TMPHOME/codex/hooks.json"
+}
+
+@test "install links the shared Git worktree gate beside the launcher" {
+ run run_install
+ [ "$status" -eq 0 ]
+ [ -L "$TMPHOME/bin/git-worktree-gate" ]
}
@test "install does not link opt-in hooks" {
diff --git a/todo.org b/todo.org
index f13ebbc..7d2168c 100644
--- a/todo.org
+++ b/todo.org
@@ -39,31 +39,24 @@ Tags are assigned and refreshed by =task-audit=; =task-review= keeps them honest
* Rulesets Open Work
-** DONE [#C] lint-org invalid-block false-positives inside example/src blocks :bug:solo:
-CLOSED: [2026-07-24 Fri]
+** VERIFY [#B] Parked: reusable Claude-to-Codex MCP registry sync :spec:
:PROPERTIES:
-:LAST_REVIEWED: 2026-07-24
+:LAST_REVIEWED: 2026-07-25
:END:
-Fixed 2026-07-24, test-first. =lo--matched-block-regions= scans lines for correctly paired blocks under org's real rule — once a block is open, only its own =#+end_TYPE= closes it — and =lo--handle-item= drops an =invalid-block= finding whose line falls in one, delimiters included (org-lint reports at the delimiters themselves). Line-scanning rather than asking org is the point: org's parser is what mis-reads these blocks. 4 ERT tests: the heading-in-example case, a src block holding a literal =#+end_example=, a genuinely unterminated block that must still report, and a file with one of each proving the suppression is per-block not per-file.
-
-Verified against home's fixture end to end: =/home/cjennings/projects/home/.ai/notes.org= produced exactly the two reported findings (lines 386, 398) under the pre-change script and zero under the new one, file untouched. Full suite green.
-
-Left alone deliberately: the =,**= comma-escape in =claude-templates/.ai/notes.org= line 53. The task noted the fix "lets that escape be reverted," but the escape is the documented org convention for a literal =**= inside a verbatim block, so reverting it would trade correct org for no gain now that the finding is suppressed either way.
-
-home reported it, and I verified it: an =#+begin_example= block whose body contains a line beginning =** = (or any heading-shaped line) makes =invalid-block= flag *both* delimiters as "Possible incomplete block", even though the block is correctly paired. The checker reads the heading line inside the verbatim body as a structural break and loses the open block.
-
-Reproduced 2026-07-24 on a three-line example block: 2 judgment findings, both false. This is a docs-file-common shape — any org file documenting org syntax inside an example block hits it (home's notes.org PENDING DECISIONS section does).
-
-*This is the root cause behind a workaround I already shipped.* During fire 1 last night I comma-escaped exactly this =** Feature Name= line in =claude-templates/.ai/notes.org= to silence the two findings. That fixed the symptom in one file; the checker bug it worked around is still live and recurs everywhere. Fixing the checker lets that escape be reverted.
+Two independent 2026-07-25 handoffs (work and home) report the same successful local migration: expose Claude's global =~/.claude.json= MCP registrations to Codex without duplicating secrets. Stdio definitions launch through a mode-0700 =claude-mcp-for-codex SERVER_NAME= wrapper that reads the mode-0600 Claude config at process start; Streamable HTTP entries remain native Codex entries; the loopback-only legacy Slack SSE endpoint bridges through =mcp-remote=. Both senders verified initialize + tools/list, not merely =codex mcp list=.
-Grading: Minor severity (judgment output, nothing mutates; pure noise that trains the reader to skim) x most users, frequently (every org file documenting org syntax in a verbatim block) = P3 = [#C].
+Recommendation: accept the need, but spec it before adding an installer. "Every project" is misleading because these are machine-global registries; the reusable unit is a host-level, idempotent reconciler. It must:
-Fix direction (per home): while inside a =begin_example= / =begin_src= block, skip structural parsing of the body until the matching =#+end_= line — verbatim blocks contain no headings, timestamps, or delimiters by definition. The same class hits a src block containing =#+end_example= as literal text. Tagged :solo: because the fix and its test are mechanical.
+1. Preserve Codex-only and plugin-provided entries and distinguish active global registrations from inactive marketplace templates and broken project-local definitions.
+2. Inventory and report without printing environment values, secret-bearing arguments, tokens, or auth material.
+3. Back up and update atomically; preserve secure file modes; handle malformed input, name collisions, upstream removals, missing dependencies, and repeated runs.
+4. Classify stdio, Streamable HTTP, and legacy SSE explicitly. Never register SSE as HTTP, and allow cleartext only for loopback or an equivalently trusted private endpoint.
+5. Health-check each locally executable server with initialize + tools/list and report only redacted names/counts. Verify both machines and document restart/new-session plus OAuth reauthentication behavior.
+6. Keep the Figma token finding separate: rotate it and move it out of command-line arguments without ever recording its value.
-*** 2026-07-24 Fri @ 20:10:00 -0500 Open question settled — invalid-block is org-lint's, so the fix is a filter
-Home answered it and I re-verified both halves here: =grep invalid-block= over =lint-org.el= returns nothing, and =org-lint--checkers= enumerates =invalid-block= in batch Emacs alongside =link-to-local-file=, =invalid-babel-call-block=, and =missing-language-in-src-block=. So this is the same shape as the =link-to-local-file= episode: suppress an =invalid-block= finding whose line falls inside a verbatim block, on our side of org-lint's output. No local checker to edit.
+The proposed Claude-memory migration auditor is related only by runtime portability and should be a separate task/spec. It should classify guidance, useful context, duplicates, stale/ephemeral material, and secret-bearing denylist entries; it must not bulk-copy generated memory files.
-Regression fixture, ready to use: home's own =.ai/notes.org= PENDING DECISIONS example block (lines 386-398) holds an unescaped =** Feature Name or Topic= and trips both delimiters — findings at lines 386 and 398. Home is deliberately leaving its copy unescaped so it stays a fixture, and asked to be pinged when the filter lands so it can re-run. The filter should take those two findings to zero without touching the file.
+No prepared diff: this is a shared configuration design decision, and the current local wrappers/configs remain machine-owned evidence rather than canonical source. Say "spec the MCP registry sync" to start the decisions walk.
** VERIFY [#B] Parked: telegram source treats "down" as launch, not SCAN FAILED (from .emacs.d)
:PROPERTIES:
@@ -337,6 +330,9 @@ Design the one contract; both features consume it. Merged 2026-07-20 from the se
From .emacs.d (2026-07-20, rulesets-owned research). Is there a Google Maps MCP (or similar) that reports the locations of devices sharing their location with you? If none exists, research how hard it would be to build one. (Google's location-sharing has no official public API; likely needs investigation of unofficial routes or a different provider.)
** TODO [#C] Sentry vNext passes — from live-trial design input :feature:spec:
+:PROPERTIES:
+:LAST_REVIEWED: 2026-07-25
+:END:
Three considerations captured by .emacs.d's own hand-run sentry trial (routed via the shared roam inbox, 2026-07-20), for folding into the sentry workflow. Design input, needs deliberation — not applied to the shared workflow unattended.
1. Bug/enhancement logging pass. Only where the project owns a codebase: file bugs by default, enhancements only if asked; review-and-accept/decline during the morning reconciliation. Live-tested — .emacs.d ran exactly this by hand and logged findings to its session anchor. Strongest candidate of the three; overlaps the deferred KB lesson-detection pass ([[file:todo.org::*KB lesson-detection heuristic][KB lesson-detection heuristic]]).
@@ -348,35 +344,35 @@ Take up with the sentry Living Document refinements once the trial has quiet nig
** TODO [#B] Extend ui-prototyping rule with build-to-prototype :feature:
:PROPERTIES:
:CREATED: [2026-07-11 Sat]
-:LAST_REVIEWED: 2026-07-13
+:LAST_REVIEWED: 2026-07-25
:END:
.emacs.d proposal (2026-07-11, Craig-approved promotion), extending the ui-prototyping rule shipped tonight (=claude-rules/ui-prototyping.md=, 53f6ce6). The base rule covers research → prototypes → iterate → decisions-backed-by-a-prototype, but not the "build to the prototype" half. Add: after prototyping, fold what settled back into the spec and make the *build target* the prototype rather than the original spec text — the built feature should match the prototype, and any deviation is documented in a "Prototype & deviations" addendum section the build keeps current. Wire into four touch points: =brainstorm= Phase 3 (a UI design isn't "accepted" until it's been through the prototype loop; Next Steps say "build to the prototype"), =spec-create= (emit the deviations-addendum section for UI specs), =spec-response= (a UI spec decomposes into a prototype loop first, then build-to-prototype tasks), =start-work= (its verify phase drives the UI end-to-end; the bar becomes "matches the prototype," deviations logged). Worked example: the takuzu Emacs game — colored tiles read as all-black in the real terminal frame (dark faces + GUI-only box cursor), caught by a prototype loop on the first screenshot where build-to-spec would have shipped an unusable board. Multi-asset synced-rule change, so review-gated and needs a focused session; decide brainstorm-vs-lifecycle placement with Craig. Source: =inbox/PROCESSED-2026-07-11-0222-from-.emacs.d-ui-prototype-rule-proposal.org=.
** TODO [#C] Roam-only startup for .ai projects — investigate :spec:
:PROPERTIES:
:CREATED: [2026-07-11 Sat]
-:LAST_REVIEWED: 2026-07-13
+:LAST_REVIEWED: 2026-07-25
:END:
Open question from the roam inbox (2026-07-11): could the startup sequence for .ai projects use org-roam as the single store instead of local files? Potential gain is near-guaranteed shared information across projects — lessons and proven techniques on a common thread, scannable across similar projects. It would need a way to isolate a project from the rest. Weigh the pros/cons, the risk, and whether it's worth it before any build. Exploratory, no commitment yet.
** TODO [#C] Keep WIP from blocking the template sync gate :feature:
:PROPERTIES:
:CREATED: [2026-07-11 Sat]
-:LAST_REVIEWED: 2026-07-13
+:LAST_REVIEWED: 2026-07-25
:END:
From the roam inbox (2026-07-11): work in progress in one project shouldn't stop the sync gate. Idea: keep all diffs/changes in a =working/= directory and exclude it (and its subdirectories) from the sync gate. Many projects run at once, so their WIP files need to be grouped. Also add a per-project count of when the gate tripped, tracked as a metric to investigate. Distinct from the 2026-07-02 policy (untracked/gitignored changes already pass — this is about *tracked* WIP under =working/=). Verify how the gate detects dirtiness today before designing.
** TODO [#C] KB orphan-node review pass :chore:
:PROPERTIES:
:CREATED: [2026-07-01 Wed]
-:LAST_REVIEWED: 2026-07-13
+:LAST_REVIEWED: 2026-07-25
:END:
The 2026-07-01 kb-hygiene report listed 42 agent KB nodes with no inbound id: links (of 53 agent nodes; 0 conflicts, no duplicate titles). Orphan-ness alone isn't a defect — agent nodes are found by rg, not only by links — but a periodic pass is worth doing: prune nodes that aged out, merge near-duplicates, add id: links where clusters exist. Regenerate the list with the kb-hygiene script rather than trusting the snapshot. Propose deletions/merges to Craig before applying (auto-cleanup allowed only for :agent:-tagged nodes after approval, per knowledge-base.md).
** TODO [#B] Helper-agent instance support — concurrent same-project Claude :feature:spec:
:PROPERTIES:
:CREATED: [2026-06-11 Thu]
-:LAST_REVIEWED: 2026-07-13
+:LAST_REVIEWED: 2026-07-25
: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.
@@ -419,7 +415,7 @@ Craig's call (2026-06-24): helper-instance is independent of the generic-runtime
** TODO [#B] Wrap-up routing — manual end-to-end validation :test:
:PROPERTIES:
-:LAST_REVIEWED: 2026-07-13
+:LAST_REVIEWED: 2026-07-25
:END:
What we're verifying: a real keeper routes through a live wrap and the destination actually files it. The task-routing build shipped IMPLEMENTED 2026-07-04 (spec [[id:00b47414-2213-4a99-be35-48ceb266fc08][wrapup-routing]]); this confirms it works end to end across a real cross-project wrap. A failed check promotes to a bug.
- In a project session, let process-inbox file a handoff whose home is a different project; confirm the local task carries =:ROUTE_CANDIDATE: <dest>=.
@@ -2068,3 +2064,28 @@ Verified the superseding file drops the cap entirely (zero references, gate is n
Since Craig already made this call, the remaining decision is only adoption scope: removing the cap means other consuming projects run every stem-matched test file per edit, and one may go red on first use by surfacing pollution that per-file runs hid. The sender flags that as intended.
Prepared: [[file:working/hook-fail-open/validate-el.diff]], plus =test-validate-el-hook.bats= (6 tests, two of which stage a deliberately failing test so a quiet pass proves the run happened).
+** DONE [#C] lint-org invalid-block false-positives inside example/src blocks :bug:solo:
+CLOSED: [2026-07-24 Fri]
+:PROPERTIES:
+:LAST_REVIEWED: 2026-07-24
+:END:
+Fixed 2026-07-24, test-first. =lo--matched-block-regions= scans lines for correctly paired blocks under org's real rule — once a block is open, only its own =#+end_TYPE= closes it — and =lo--handle-item= drops an =invalid-block= finding whose line falls in one, delimiters included (org-lint reports at the delimiters themselves). Line-scanning rather than asking org is the point: org's parser is what mis-reads these blocks. 4 ERT tests: the heading-in-example case, a src block holding a literal =#+end_example=, a genuinely unterminated block that must still report, and a file with one of each proving the suppression is per-block not per-file.
+
+Verified against home's fixture end to end: =/home/cjennings/projects/home/.ai/notes.org= produced exactly the two reported findings (lines 386, 398) under the pre-change script and zero under the new one, file untouched. Full suite green.
+
+Left alone deliberately: the =,**= comma-escape in =claude-templates/.ai/notes.org= line 53. The task noted the fix "lets that escape be reverted," but the escape is the documented org convention for a literal =**= inside a verbatim block, so reverting it would trade correct org for no gain now that the finding is suppressed either way.
+
+home reported it, and I verified it: an =#+begin_example= block whose body contains a line beginning =** = (or any heading-shaped line) makes =invalid-block= flag *both* delimiters as "Possible incomplete block", even though the block is correctly paired. The checker reads the heading line inside the verbatim body as a structural break and loses the open block.
+
+Reproduced 2026-07-24 on a three-line example block: 2 judgment findings, both false. This is a docs-file-common shape — any org file documenting org syntax inside an example block hits it (home's notes.org PENDING DECISIONS section does).
+
+*This is the root cause behind a workaround I already shipped.* During fire 1 last night I comma-escaped exactly this =** Feature Name= line in =claude-templates/.ai/notes.org= to silence the two findings. That fixed the symptom in one file; the checker bug it worked around is still live and recurs everywhere. Fixing the checker lets that escape be reverted.
+
+Grading: Minor severity (judgment output, nothing mutates; pure noise that trains the reader to skim) x most users, frequently (every org file documenting org syntax in a verbatim block) = P3 = [#C].
+
+Fix direction (per home): while inside a =begin_example= / =begin_src= block, skip structural parsing of the body until the matching =#+end_= line — verbatim blocks contain no headings, timestamps, or delimiters by definition. The same class hits a src block containing =#+end_example= as literal text. Tagged :solo: because the fix and its test are mechanical.
+
+*** 2026-07-24 Fri @ 20:10:00 -0500 Open question settled — invalid-block is org-lint's, so the fix is a filter
+Home answered it and I re-verified both halves here: =grep invalid-block= over =lint-org.el= returns nothing, and =org-lint--checkers= enumerates =invalid-block= in batch Emacs alongside =link-to-local-file=, =invalid-babel-call-block=, and =missing-language-in-src-block=. So this is the same shape as the =link-to-local-file= episode: suppress an =invalid-block= finding whose line falls inside a verbatim block, on our side of org-lint's output. No local checker to edit.
+
+Regression fixture, ready to use: home's own =.ai/notes.org= PENDING DECISIONS example block (lines 386-398) holds an unescaped =** Feature Name or Topic= and trips both delimiters — findings at lines 386 and 398. Home is deliberately leaving its copy unescaped so it stays a fixture, and asked to be pinged when the filter lands so it can re-run. The filter should take those two findings to zero without touching the file.