aboutsummaryrefslogtreecommitdiff
path: root/todo.org
diff options
context:
space:
mode:
Diffstat (limited to 'todo.org')
-rw-r--r--todo.org1219
1 files changed, 1032 insertions, 187 deletions
diff --git a/todo.org b/todo.org
index f4b3a55..9d47566 100644
--- a/todo.org
+++ b/todo.org
@@ -39,94 +39,445 @@ Tags are assigned and refreshed by =task-audit=; =task-review= keeps them honest
* Rulesets Open Work
-** TODO [#B] Sentry workflow — build from spec :feature:
-Build the sentry supervisor workflow from the READY-gated spec:
-[[file:docs/specs/2026-07-14-sentry-workflow-spec.org][sentry workflow spec]] (DRAFT as of 2026-07-14; needs spec-review first).
-Four phases: agent-lock helper + bats, sentry.org engine, companion
-reconciliations (knowledge-base.md, roam-sync.sh header, inbox.org,
-triage-intake.org), live trial night on rulesets. Origin: work project's
-proposal, [[file:docs/design/2026-07-14-sentry-workflow-proposal.org][docs/design/2026-07-14-sentry-workflow-proposal.org]]. All nine
-design decisions resolved with Craig 2026-07-14 (recorded in the spec).
-
-** TODO [#C] Polyglot projects — supported, or refused? :spec:
-SCHEDULED: <2026-07-20 Mon>
-Do we support more than one language bundle per project? The honest answer today
-is "partly, by accident." The collision guard added 2026-07-16 refuses a
-*colliding* second bundle rather than silently replacing the first's config, but
-a non-overlapping pair still installs fine: bash ships =settings.json= +
-githooks and no coverage fragment, python ships only a coverage fragment, so
-=bash= + =python= composes cleanly today and yields a real polyglot project with
-both rule sets. So the line isn't polyglot-vs-not, it's overlap-vs-not — and
-nobody chose that line, it fell out of which bundle happens to ship what. Origin:
-home's report after scaffolding clock-panel with python + typescript,
-[[file:docs/design/2026-07-16-polyglot-bundle-collision.txt][docs/design/2026-07-16-polyglot-bundle-collision.txt]].
+** DOING [#B] Hostile subagent review before every agent commit :feature:
+:PROPERTIES:
+:LAST_REVIEWED: 2026-07-28
+:END:
+From the roam inbox, 2026-07-28: "code reviews must occur before every commit an agent does, and they should be hostile reviews from a subagent without the agent's context."
-Pair this with the subproject scouting below — it's the same question in a
-different costume ("which projects would actually be polyglot, and why"), so
-they should be one conversation.
+Two asks, and only the second is new. The publish flow already mandates a review before every commit (Step 1). What changes is *who reviews*: today the reviewing agent is the one that wrote the change, so it inherits the author's mental model, and =review-code= only *suggests* subagent dispatch, and only "for substantive reviews on large diffs".
-The three options, in the order they'd be weighed:
+Decisions settled with Craig, 2026-07-28, and shipped:
+- *Scope* — every commit. The reviewer's own Phase 0 rules a diff trivial and returns Skipped, which satisfies the gate; the author never rules on their own diff.
+- *Stance* — "adversarial", not "hostile" (Craig's call). An agent told to attack manufactures findings, so the stance carries a substantiation floor: a finding not substantiated against the diff is dropped.
+- *What the reviewer gets* — the diff, a one-line claim of what it does, and the requirement source (ticket, plan, task body) where one exists. Withheld: the conversation, the exploration, the author's rationale. The requirement source stays *in* because it is the only artifact that can contradict the author's claim; withholding it makes the claim self-certifying.
+- *Loop* — re-review until the reviewer approves, turning on blocking findings rather than the verdict token. Bounded at three rounds, and stopped early on a finding that recurs after being reported fixed. Both bounds hand the decision to Craig; the unattended callers park instead.
+- *Adjudication* — Craig, never the author overruling the reviewer.
+- *Home* — the =publish= skill Step 1, with =review-code= carrying the adversarial contract and re-review mode.
+- *The =subagents.md= tension* — resolved with an Isolation Override section: the size heuristics assume the main thread could do the task equally well, and they lapse when its own context is what makes its answer untrustworthy.
-1. *Unsupported, explicitly.* Keep the guard as the answer. Cheapest, and
- matches how little polyglot exists (one project, clock-panel).
-2. *Supported.* Needs per-bundle filenames, a merged =settings.json= (the hooks
- arrays compose rather than clobber), composed githooks, and namespaced
- Makefile targets with a =coverage= aggregate. This is the real work.
-3. *Case-by-case.* Support the pairs that come up, refuse the rest.
+Remaining: nothing on the design. The change shipped in this session.
-What the decision needs to know:
+** TODO [#B] wrap-org-table splits logical rows, and lint drives it :bug:
+:PROPERTIES:
+:LAST_REVIEWED: 2026-07-29
+:END:
+Reported by work 2026-07-28 against =arch-00-deepsat-platform-spec-draft.org=. Reproduced here.
-- *The target-name collision is the deeper half* (home's point, and it's right).
- Every bundle's fragment defines =coverage:= and =coverage-summary:=, so even
- with both files present a polyglot project can't paste both into one Makefile.
- Renaming files doesn't fix it.
-- *Only three of five shared filenames actually collide.* =gitignore-add.txt=
- (5 bundles) appends deduped and composes. =CLAUDE.md= (3) is seed-only, and
- its fallback comment shows multi-bundle was already considered there.
- =claude/settings.json= (3), =githooks/*= (3), and =coverage-makefile.txt= (4)
- are the real ones.
-- *=FORCE=1= is a poor escape hatch* (home's catch): it also re-seeds
- =CLAUDE.md=, which is destructive on a customized project. If polyglot
- becomes supported, the override wants to be its own flag.
+*** Verified
-** TODO [#C] Subproject pattern — promote to claude-rules? :spec:
-SCHEDULED: <2026-07-20 Mon>
-home proposes promoting its subproject pattern (a former standalone project
-folded into a parent, living as a self-contained subdir sharing the parent's
-=.ai/= scope) into the rules layer: vocabulary, the read-first
-=<subproject>/<subproject>-brief.org= convention, the parent-vs-subproject
-content criterion ("one fact, one home"), and create/archive criteria.
-Proposal + home's full instance:
-[[file:docs/design/2026-07-15-subproject-pattern-proposal.org][proposal]],
-[[file:docs/design/2026-07-15-subprojects-convention-home-instance.org][home's convention doc]].
+Each of these is a measurement, re-run under adversarial review. The analysis I built on top of them was wrong three times, so this section is deliberately separated from the open questions below.
-Deferred 2026-07-16 rather than promoted. *Craig's framing:* he wants to scout
-which projects would actually get subprojects, and why, before we shape a rule.
-If he hasn't done that scouting by the time this comes up, offer to do it
-together — brainstorm the candidates, then explore the reasons behind each. That
-evidence decides it: either we drop the pattern, or we know enough to adjust it
-so it's effective. Don't shape the rule before the scouting.
+- *The defect.* =wrap-org-table.el= turns one logical table row into two or more, by writing a rule between its continuation lines. Content survives; structure does not.
+- *Root cause.* =wot--continuation-group-p= (=wrap-org-table.el:168=) requires every line past the first to carry at least one empty cell. When a row overflows in every column its continuation line is fully populated, the predicate rejects the group, and =wot--logical-rows= appends each physical line as its own row (=:197=).
+- *Controlled A/B.* Rules present in both, continuation line's middle cell the only variable: blank merges, populated splits.
+- *Idempotence is broken.* Running the tool twice on its own correct output corrupts it. Pass 1 emits a properly rule-delimited three-line row; pass 2 splits it into three rows. The docstring at =:203-204= asserts the opposite, and =wot-reformat-is-idempotent= passes because its fixture overflows only one column.
+- *lint doesn't just miss it, it causes it.* =lint-org.el:424= calls the same predicate. Given the tool's own correct output — a rule after every logical row — lint reports "missing rule between rows — wrap-org-table.el reflows it". Nothing is missing. Follow that advice and the tool splits the row; lint then reports the result 0 mechanical, 0 judgment. Control: a conformant table whose continuation keeps an empty cell returns 0 and 0. So the loop is not two independent green lights, it is the linter manufacturing a false violation and certifying the damage it caused.
+- *A second path.* With no hlines at all, =wot--logical-rows= short-circuits (=:184=) before the predicate is reached, so every physical line becomes a row.
+- *Nothing invokes the tool unattended.* =todo-cleanup.el= names it in comments only; the entry-script guard from the 2026-07-09 incident holds.
+- *rulesets is exposed*: =todo.org= carries a four-row attachment-sanitization table with no rules between rows. Don't reflow it until the tool is fixed — reflowing is the trigger.
-*Review findings from the 2026-07-16 pass* (the inputs the decision needs):
+*** Two fixes that were verified to work
-- *N=1.* home is the only project with subprojects, across all 27 =.ai= scopes;
- its nine all came from the single 2026-06-11 fold. This is the fact the
- scouting tests.
-- *Placement contradicts the proposal's own principle.* =claude-rules/*.md=
- loads into every session of every project. home's doc argues the always-on
- layer is "a tax paid whether or not it's relevant today" and depth belongs
- "one open away". At 282 lines the doc would be the third-largest rule and add
- ~11% to the always-on layer, so every .emacs.d / takuzu / chime session would
- carry a one-project convention.
-- *Precedent for the shape:* =patterns.md= (29 lines, explicit "don't carry the
- catalog in context") and =docs-lifecycle.md= (75 lines, depth in a spec).
- Thin rule + on-demand depth is the established answer.
-- *Dangling reference:* the doc cites =claude-rules/git-hosting-privacy-model=
- as authority for its shared-scope-safety criterion. No such file exists — the
- real content is the gitignore-vs-track and public-reachability decision in
- =protocols.org=. Fix before any promotion.
-- *Instance vs rule:* the metrics, self-improvement log, kill criteria, rollout
- dates, and adoption table are home's instance, not rule content.
+- *Conformant round-trip.* Replacing the predicate body with =(> (length group) 1)= — pure rule-delimited grouping, no emitter marker, no new field — makes the corrupting pass-1 output round-trip cleanly.
+- *Telling a continuation group from two real rows at runtime.* Provenance isn't available (=wot-reformat-table-string= receives a string), but the test is cheap: merge the group, re-wrap at the allocated widths, compare to the group as given. A continuation group reproduces itself under merge-then-wrap; two genuinely distinct short rows don't, because merged they fit on one line.
+
+Start with a red test from the double-run repro — it needs no unusual input and falsifies the docstring and the passing idempotence test together. Emission is already correct (=:222-224= emits one rule per element of =rows=), so the repair is entirely in how =rows= is computed.
+
+*Prefer the round-trip check to any detector.* work implemented the predicate-based detection I recommended and demonstrated it cannot discriminate at any threshold (2026-07-29, worked examples from their repo). Run bare it flags 144 files — essentially every table — because an ordinary header-plus-body table is one multi-line fully-populated group and rejecting it is the predicate working correctly. Add a per-row-ruled precondition and it cuts to 9, but at 9 it still mixes a genuinely wrapped row (=arch-09:40=, a header row whose second line continues the sentence) with two distinct rows legitimately sharing a rule (their =todo.org:117=, where splitting is the *right* answer). Same structural signature, opposite correct verdicts. The difference is whether one line continues the other as prose, which is semantics and not in the parse.
+
+So =lint-org.el:424= cannot simply inherit the repair, and a checker keyed on the predicate would train people to ignore it. The idempotence property is the checkable one: reflow twice and diff. It sidesteps detection entirely, because round-tripping the tool's own output correctly is true by construction and needs nobody to decide what a group means.
+
+*** Open questions
+
+- *What should no-hline input do?* My "refuse to reflow" instruction was wrong: adding rules to a ruleless table is the tool's main job, and =test-wrap-org-table.el:147= asserts exactly that. The real danger is narrower — a no-hline table where some line carries an empty cell, so a physical line might be a continuation. Needs a decision on refuse, ask, or heuristic.
+- *Which path bit work?* One question settles it: did the =arch-00= Document Status table have rules between its rows before the reflow? I inferred "probably secondary" from a file-level scan, which can't resolve a table-level incident.
+- *How exposed is work?* Partly resolved by their own implementation, 2026-07-29. *Four secondary-path files are exact.* The primary-path number is 9 under a per-row-ruled precondition, which they correctly label a floor on a population they cannot cleanly define rather than a measurement — the precondition also drops the minimal =a_full= fixture, which has only two groups. My earlier "24 secondary-path files" certification was not sound: their original signature also matches every correctly-reflowed table, so it never partitioned.
+- *Does the grade go up?* Currently Major x most users frequently = P2 = [#B] (any table you reflow that overflows every column, which is what a width violation sends you to the tool to fix). The lint-drives-it finding arrived after that grading and may lift it.
+
+*** Provenance
+
+work reverted their table and left it over-budget at 134 columns, correctly: an over-wide table is cosmetic, a table that says something else is not. I hit this same failure on 2026-07-27, wrote "reflowed a table into a worse shape and lint-org then passed it" into a session summary, and never filed it — which is why work met it three weeks later. Their framing beats the apology: a correct observation recorded and then read as fine is the same failure as a green check on a corrupted table.
+
+Process note for whoever picks this up. This task bounded out of its review loop at three rounds. Every finding across all three landed on inference, never on a measurement — the reproductions held throughout while the reasoning built on them was refuted repeatedly. Trust the Verified section; re-derive anything else.
+
+
+** TODO [#B] Synced workflows link outside the synced set with ../../ :bug:
+:PROPERTIES:
+:LAST_REVIEWED: 2026-07-28
+:END:
+Seven link sites across four synced workflows, three distinct targets, all escaping the =.ai/= boundary into rulesets repo-root paths. From a consuming project's =.ai/workflows/=, =../../= is that project's root, where none of these exist:
+
+- → =../../claude-rules/todo-format.md= (five sites): =open-tasks.org:163=, =task-audit.org:84=, =task-review.org:60=, =task-review.org:64=, =task-review.org:99=
+- → =../../docs/design/task-review.org= (one site): =task-review.org:11=
+- → =../../flush/SKILL.md= (one site): =suspend.org:22=
+
+Verified dead in both home and =.emacs.d=; they resolve only in rulesets, which is why nobody noticed.
+
+Grading: Minor severity (a documented reference an agent can't follow, workaround is to search) x every user every time (every consuming project, every sync) = P2 = [#B]. Same grade as the =references/= link this came from, and the same mechanism — a synced file linking a path the sync doesn't deliver — with seven sites instead of one.
+
+Fix direction, two halves. Rewrite the seven as prose references naming the file, the same move taken for the credential paths: a link that resolves in one repo shouldn't be a link in a file that ships to two dozen. Then close the gate, or the eighth arrives unnoticed.
+
+The gate already exists and is nearly right. =scripts/lint.sh='s =check_md_links= was written for this exact class — its comment says so ("Validate cross-references to =claude-rules/= — the install-layout problem"). It misses these for two concrete reasons: it matches only markdown link syntax (=grep -oE '\[[^]]*\]\([^)]+\)'=), so org =[[file:...]]= links are invisible to it, and its driver loop only feeds it =claude-rules/*.md= and the language rule files, never =.ai/workflows/*.org=. Extending it on both axes is a smaller and more durable fix than a manual sweep.
+
+Found by the adversarial reviewer on the =references/= fix, 2026-07-28, as the sibling class the original report missed.
+
+** TODO [#C] start-work Phase 7 still summarizes the old publish flow :chore:solo:
+:PROPERTIES:
+:LAST_REVIEWED: 2026-07-28
+:END:
+=.claude/commands/start-work.md:336-339= hands off with "Follow =commits.md= exactly" and "Run =/review-code --staged= before each commit" — no isolated reviewer, no re-review loop. It was already stale before the 2026-07-28 review change, because =commits.md= moved the publish flow into the =publish= skill in an earlier commit, so the pointer names a file that no longer holds the flow.
+
+Grading: Cosmetic severity (a stale summary beside a correct canonical, and start-work is attended so the escalation target exists) x most users frequently = P3 = [#C].
+
+Fix is to point Phase 7 at the =publish= skill rather than restate the flow, which is what let it drift in the first place. Worth a sweep for other files that restate the flow instead of pointing at it.
+
+Found by the adversarial reviewer during the 2026-07-28 review-flow change, and correctly filed rather than fixed there: the line was untouched by that diff.
+
+** TODO [#C] Two lint defects at the template source :chore:
+:PROPERTIES:
+:LAST_REVIEWED: 2026-07-28
+:END:
+Both verified at the rulesets source, so every project seeded from them inherits the defect.
+
+=retrospectives/PRINCIPLES.org:38= violates the org-table standard (no closing rule). =lint-org= flags it as =org-table-standard=, and =wrap-org-table.el= reflows it mechanically. This half is purely tool-driven.
+
+=protocols.org= lints at 8 mechanical + 19 judgment =misplaced-heading= hits, all from Markdown =**bold**= in an org file, which org reads as a level-2 heading when it starts a line. 48 bold spans total, 14 of them line-initial. This half is not mechanical: converting to org =*bold*= is 48 edits, and =lint-org --fix= would rewrite the line-initial ones without knowing they are emphasis rather than headings.
+
+Grading: Cosmetic severity x every user every time = P3 = [#C]. It is most of the linter's noise on protocols.org, which is the real cost — noise that trains the reader to skip the report.
+
+Not =:solo:= — both files are synced templates.
+
+Source: winvm link-integrity pass, 2026-07-28.
+
+** DOING [#B] Finish context-engineering rightsizing :refactor:
+:PROPERTIES:
+:LAST_REVIEWED: 2026-07-27
+:END:
+Started 2026-07-27 from three Anthropic posts Craig supplied. Always-loaded rules went from ~57,800 tokens to ~28,949, plus 13,461 path-scoped. Shipped: =paths:= frontmatter on the three file-type rules, the per-project generic-rule de-duplication, =commits.md= split into a 2,804-token invariant core plus the =publish= skill, =testing.md= split into a 347-word directive plus the =testing-standards= skill, the approval-gate signal fixed from =.ai/=-tracking to remote host, and the first-person directive.
+
+*What remains is Craig's decisions, not execution.* Each of these needs him:
+
+1. *C1 — =verification.md= (3,388 tok).* The Opus 5 guide says explicit verification instructions cause over-verification and should be removed. His standing direction is never guess, always check. My read: the honesty core (don't claim a green suite you didn't run) stays and shortens, the process injection (green baseline, suite-as-its-own-step) moves to the publish skill. His call — and it's the rule closest to a preference he's stated outright.
+2. *=interaction.md= (3,828 tok, now the largest).* Carries genuinely universal output constraints (no popup menus, no reverse-video markup) plus the new peer-reasoning contract. Splitting it means deciding which parts must fire on every turn.
+3. *The TDD rationalization table.* Moved to =testing-standards= rather than cut. The posts argue that kind of over-argument is counterproductive on current models. Deleting his defense against me skipping TDD is his call.
+4. *D3 — the gate separation.* Which approval gates are preference (he wants to see what goes out under his name) versus guardrail (written when the worst case was worse). They read identically in the files; only he can tell them apart. Highest-leverage input remaining.
+
+*Do first:* the three docs in =working/context-engineering-rightsizing/= are one commit behind — they were corrected at d74d98d, before the two splits and the gate fix. Reconcile before deciding anything from them.
+
+Risk on the record: =testing.md='s margin is thinner than =commits.md='s. If =testing-standards= fails to trigger mid-test-writing the mocking-boundary rules are lost — a quality regression, visible in review, but a real bet where =commits.md='s moved half was purely procedural.
+
+** TODO [#B] Recurring-loop mechanics as a shared rule :feature:
+:PROPERTIES:
+:LAST_REVIEWED: 2026-07-27
+:END:
+Work proposes promoting the pattern behind its 2026-07-27 auto-mode triage-intake into a standing rule covering all recurring agent tasks: fixed interval via =CronCreate= rather than dynamic self-pacing, a subagent per firing so raw scan output never reaches the orchestrator, silence with no heartbeat when subagent-backed, and accumulate-don't-mutate between closes with explicit "close the X" / "stop the X" verbs. Likely touches =triage-intake.org= auto mode, =inbox.org= monitor mode, and a new short rule in =claude-rules/= so individual workflows point at one definition of cadence, isolation, and silence.
+
+Points 1, 2, and 4 mostly promote existing practice. Point 3 changes documented behavior and needs a real decision. Three findings from the skeptical review, all to resolve before this ships:
+
+1. *Point 1 is too absolute.* Fixed interval is the right default, but not the right universal. A loop waiting on unpredictable external state (a CI run, a deploy queue) should pace to how fast that state actually changes, which is what dynamic scheduling exists for. Write it as "default to a fixed interval; use dynamic pacing only when polling external state whose timing you can't predict."
+2. *Point 2 collides with a standing instruction.* Craig's harness prompt says not to spawn subagents unless he requested it. Making subagent-per-firing the standing pattern needs that reconciled explicitly — the honest reading is that asking for the loop *is* the request, and the rule should say so rather than leaving two instructions to fight. The isolation argument itself is sound and matches the Opus 5 guidance (delegate for genuinely independent, sizeable work).
+3. *Point 3 has a silent-failure hole.* Removing the heartbeat means a loop that died looks exactly like a loop quietly finding nothing. "The subagent completing is proof it ran" only holds if a *failed* subagent still surfaces. Either keep a rare heartbeat (hourly, not per-fire) or specify that failure always breaks silence. Suppressing success is fine; suppressing failure is not.
+
+Also underspecified: what counts as "signal" for a subagent-backed loop. And the silent-until-signal spec (=docs/specs/2026-07-20-silent-until-signal-monitors-spec.org=, IMPLEMENTED) documents the per-fire heartbeat, so it needs a superseding history line rather than a silent contradiction.
+
+*** 2026-07-27 Mon @ 16:55 Work accepted all three findings and sharpened point 3
+
+Work agreed without pushback and corrected my either/or on point 3, which was too weak. Both mechanisms are needed, not one: a failed or hung subagent breaking silence covers a *scan-level* failure, but only a periodic heartbeat catches the *scheduler* dying — because in that case nothing spawns at all and there is no failure to report. Heartbeat rare (hourly), not per-fire.
+
+The live consequence makes it urgent rather than theoretical: =CronCreate= auto-expires recurring jobs after seven days, so any fully-silent loop is *guaranteed* to die quietly. Work's auto-triage loop is running under exactly that contract right now.
+
+Work also added the reason that makes point 2's carve-out principled and should go in the rule text: the subagent exists to keep N sources' worth of raw scan output out of the orchestrator across a multi-hour loop, not to parallelize.
+
+Craig's call on point 3 is pending; work is surfacing it and will send the answer.
+
+Source: work handoff 2026-07-27 14:51, reply 16:55.
+
+** TODO [#B] Sentry triage split — work mail and messengers in, personal mail out :bug:
+:PROPERTIES:
+:LAST_REVIEWED: 2026-07-27
+:END:
+Craig's 2026-07-27 correction supersedes his 2026-07-21 ruling: sentry should scan DeepSat work mail and every active messenger source, excluding only personal email. =sentry.org= currently excludes all mail and all messengers (overview line and pass 3), so work's first 2026-07-27 fire reported a quiet pass while a Hayk DM and a Kostya PR-review request sat unread. Work's live at-prompt carries the override in the meantime; the canonical workflow, the source-activation probe, and the tests still encode the old policy.
+
+Grading: Major severity (the pass reports "nothing" while real work items sit — the failure is silent, which is what makes it worse than a visible miss) × most users, frequently (every fire in any project declaring a work-mail or messenger source) = P2 = =[#B]=.
+
+The blocker is not wording. The current rule excludes by *category* — "mail", "messenger" — and the new rule is a *work-vs-personal* split that category cannot express. Shipping general plugins are =cmail=, =personal-gmail=, =personal-calendar=, =telegram=, =github-prs=; there is no general work-mail plugin, so work's source is project-specific. Naming the personal plugins in a denylist fails open the moment a new personal source is added. Decide the classification mechanism first — the durable shape is a per-plugin eligibility declaration inside each plugin file, so a new plugin classifies itself and the probe reads the declaration rather than pattern-matching a name.
+
+Scope once decided: the =sentry.org= overview line and pass 3, the activation probe's "survives that exclusion" test, and the pass-3 tests.
+
+Source: work handoff 2026-07-27 06:21.
+
+** TODO [#B] Repository publish-lock for two sessions sharing one clone :feature:
+:PROPERTIES:
+:LAST_REVIEWED: 2026-07-27
+:END:
+Craig approved this in archsetup on 2026-07-26 after weighing the competing approaches recorded there. Two agent sessions in one clone share the working tree *and* =.git/index=, so one can sweep the other's hunks into a commit; a pathspec commit doesn't help, because =git commit -- <path>= reads the current working-tree state for that path. The approved design: one repository-scoped publish lock keyed on the real Git common-directory path (so worktrees sharing a clone contend, and unrelated clones with the same basename don't), acquired before any publish-flow operation that can mutate the shared index and held across reconcile, staging, =/review-code --staged=, message approval, and =git commit=. Ownership tracks =AI_AGENT_ID=, then a stable harness id. The reviewed staged-tree fingerprint (=git write-tree= after the review) is re-compared immediately before commit; a lost lock, a vanished lock, or a changed fingerprint forces reacquire and a fresh staged review. Ordinary edits stay concurrent — the lock serializes only the publish mutation window.
+
+Skeptical review — the design is sound and the acceptance checks are testable. Three gaps to close during the build:
+
+1. *TTL sizing across a human wait.* The lock is held across the commit-message approval gate, which is an unbounded human pause. "Refresh on conversational re-entry" covers an agent that keeps working; it does not cover Craig stepping away for an hour mid-review. Size the staleness horizon for that, or refresh on a timer while the gate is open — sentry's TTL is nowhere near long enough.
+2. *Blocked-session behavior is unspecified.* The proposal says a second session cannot stage or commit through the guarded flow, but not what it should then do — wait on =--wait=, defer and report, or stop and surface. Pick one and test it.
+3. *Size.* This is a real build (key derivation, owner support in =agent-lock=, the fingerprint gate, the review-restart path, and the acceptance battery), not a wording change.
+
+Canonical touchpoints: =claude-rules/commits.md= (add the lock to the pre-commit flow; state explicitly that an approval waiver never waives the staged review, since the review is the gate that reads the hunks entering the commit), =claude-templates/.ai/scripts/agent-lock= (backward-compatible caller ownership — existing sentry and roam-write callers must stay green), and =claude-templates/.ai/scripts/tests/agent-lock.bats=.
+
+Non-goals from the approval: no per-session =GIT_INDEX_FILE=, no worktree requirement for live stow-managed dotfiles, no locking of ordinary edits, no replacement for remote pre-push reconciliation.
+
+Source: archsetup handoff 2026-07-26 10:55.
+
+** VERIFY [#B] Parked: reusable Claude-to-Codex MCP registry sync :spec:
+:PROPERTIES:
+:LAST_REVIEWED: 2026-07-25
+:END:
+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=.
+
+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:
+
+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.
+
+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.
+
+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] A SCAN FAILED source must not advance its sentinel (engine, not the plugin)
+:PROPERTIES:
+:LAST_REVIEWED: 2026-07-24
+:END:
+Promoted to top-level 2026-07-28 when its parent closed — it is a separate engine question and would have been buried under a DONE parent.
+
+Secondary finding from the 2026-07-24 handoff, flagged for your judgment because it's *engine* behavior in =triage-intake.org=, not the telegram plugin. work reported that after a SCAN FAILED, "the marker still advanced" — and telegram is =:ANCHOR: none=, so something advanced a cursor it shouldn't have. The compounding harm: a source that reports SCAN FAILED but advances its window means the next sweep believes it already covered that window, so the blind-sweep hole persists across sweeps rather than self-healing. Not touched .emacs.d-side; needs a look at the engine's per-source last-run/anchor advance.
+
+** TODO [#B] cj-remove-block still can delete the WRONG cj block :bug:
+:PROPERTIES:
+:LAST_REVIEWED: 2026-07-24
+:END:
+The 2026-07-24 range fix (17f5d48) closed the *over-deletion* case: a range spanning two blocks is now refused. It did NOT close the underlying class. The validator proves the range is a well-formed single block; it never proves it is the block that was *scanned*.
+
+An adversarial reviewer demonstrated this on the fixed code: passing the second block's range while intending the first deletes the second, exit 0, no warning. Drift by exactly one whole block still slips through — which is the failure the validator exists to prevent.
+
+Grading: Major severity (silent deletion of the wrong annotation in Craig's org files, no warning, success exit) x rare edge case (needs drift landing exactly on another well-formed block) = P3 = [#C]... graded up to [#B] because it is the *same* failure the fix was believed to have closed, so the current state carries false confidence.
+
+Fix direction needs a decision, which is why this is not :solo:. The range alone cannot identify the intended block, so the caller must assert something about content — a hash of the expected block, or the expected first body line, passed alongside the range and verified before deletion. That changes the CLI contract and the calling skill, so it is a design call.
+
+** TODO [#D] Atomic-write residuals in cj-remove-block :bug:
+:PROPERTIES:
+:LAST_REVIEWED: 2026-07-24
+:END:
+Three narrower gaps a reviewer found in the atomic write, all verified, none fixed in the 2026-07-24 repair round:
+
+1. =shutil.copymode= runs before =write_text=, and writing clears setuid/setgid — a 2755 file comes back 755.
+2. ACLs are not carried across, and copying the ACL mask into the group bits can *widen* group permission on a file carrying a named ACL entry.
+3. Hard links are broken the same way symlinks were: =os.replace= gives the path a new inode, so a second hard link keeps stale content. Introduced by the atomic write itself (17f5d48), which did not exist on main.
+4. No =fsync= before =os.replace=, so the "never a partial" guarantee holds against process failure but not a system crash. Two lines to close.
+
+Grading: Minor severity (each needs an unusual file mode, an ACL, a hard link, or a crash mid-write) x rare edge case = P4 = [#D].
+
+** TODO [#D] Test suites leak backup files into shared /tmp :test:
+:PROPERTIES:
+:LAST_REVIEWED: 2026-07-24
+:END:
+The elisp todo-cleanup suite writes roughly 81 backup files into the shared temp dir per =make test= run, named after randomized fixtures (=tc-test-XXXXXX.org.before-todo-cleanup.*=). 1598 had accumulated by 2026-07-24. Not data loss and not production-named, so nothing masquerades as a real backup and nothing is deleted — this is clutter that grows every run.
+
+The python sibling was fixed with an autouse fixture (f850ad6) that sets =TMPDIR= and overrides =tempfile.tempdir=. ERT has no autouse equivalent, so the elisp fix wants a load-time rebind of =temporary-file-directory= plus a =kill-emacs-hook= cleanup.
+
+Also unaddressed: =test-lint-org.el= scans a hardcoded =/tmp=, and =lint-org.el= hardcodes =/tmp/= in =lo--backup=, so that pair cannot be isolated the same way without touching the script. And =lint-org.el= carries the same second-resolution backup-overwrite flaw that todo-cleanup and cj-remove-block were fixed for.
+
+Grading: Minor severity (clutter in a directory cleared on reboot) x every user, every time (every suite run) = P2... graded [#D] on the no-harm read.
+
+** VERIFY [#B] Should rulesets run shellcheck on its own shell?
+:PROPERTIES:
+:LAST_REVIEWED: 2026-07-24
+:END:
+Surfaced by the lint-coverage finding above, and a bigger question than that task should decide.
+
+rulesets ships shellcheck enforcement to *other* projects — the bash bundle's =githooks/pre-commit= scans staged shell, and =validate-bash.sh= blocks an edit that fails it. rulesets runs neither on itself. Its own =githooks/pre-commit= calls =sync-check.sh= and nothing else; =lint.sh='s =check_hook= validates only shebang and exec bit; =make test= has no shellcheck step. So the repo asks consumers for a standard it does not apply to its own shell.
+
+Why this needs your call rather than an overnight fix: turning shellcheck on today would surface the findings I dispositioned as false positives during this session's sweeps — =SC2094= on =install-ai.sh= and =sweep-gitignore-tooling.sh= (append-with-stat, not a read-write race), =SC2088= on =doctor.sh= and =audit.sh= (tilde in a *display* string, not a path), =SC2164= on =lint.sh= and =status.sh=. Enabling the gate means either fixing those or adding =# shellcheck disable== directives with justifications, and which of those you want is a preference, not a fact.
+
+Options: (1) shellcheck in =make lint= as a warning, (2) in =githooks/pre-commit= as a hard gate matching what consumers get, (3) leave it, on the grounds that the repo's shell is small and reviewed. My lean is 2, since the asymmetry is the odd part — but it is your repo's bar to set.
+
+** TODO [#C] Attachment filenames from email are only partly sanitized :bug:
+:PROPERTIES:
+:LAST_REVIEWED: 2026-07-24
+:END:
+Both attachment writers derive on-disk filenames from attacker-controlled input (the =filename= an email declares for its attachment), and both sanitize incompletely — in *different* ways, so neither covers the other's gaps.
+
+=eml-view-and-extract-attachments.py= runs the name through =_clean_for_filename= but interpolates the *extension* raw: =name, ext = os.path.splitext(...)= then =f"{basename}-ATTACH-{clean_name}{ext}"=. =gmail-fetch-attachments.py='s =safe_filename= handles path separators and leading =..= and nothing else.
+
+Verified 2026-07-24 by probing both with the same adversarial set:
+
+| input | eml-view result | gmail-fetch result |
+|------------------------+------------------------------------+--------------------|
+| =report.pdf; rm -rf ~= | keeps =; rm -rf ~= in the filename | keeps it |
+| =x.p\ndf= | keeps a literal newline | keeps it |
+| =a.= + 300 chars | 314-char filename | unbounded |
+| =../../../etc/passwd= | neutralized | neutralized |
+
+What this is NOT, checked so it isn't over-graded later: not remote code execution (files are written through Python =open=, never a shell) and not path traversal (=os.path.splitext= only returns an extension when the last dot follows the last separator, so =ext= can never contain a slash — the traversal case above is neutralized in both). The real harms are narrower: a newline in a filename breaks any downstream tool that reads the output directory as a newline-delimited list, and an unbounded extension exceeds the 255-byte filesystem limit so a crafted attachment aborts the extraction.
+
+Grading: Major severity (untrusted input reaches a filesystem name unsanitized, and the newline case breaks real tooling today) x rare edge case (needs an attachment name with unusual characters *after* the last dot, which ordinary mail doesn't produce) = P3 = [#C].
+
+Fix direction: sanitize the extension with the same rules as the name, cap the *whole* filename rather than just the stem, and reject or replace control characters in both scripts. The open question is where the sanitizer lives — see the VERIFY below.
+
+** VERIFY [#B] One sanitizer or two for the attachment filename fix?
+:PROPERTIES:
+:LAST_REVIEWED: 2026-07-24
+:END:
+Deferred from pass 12 on 2026-07-24: the bug above is well-specified, but *where* the shared sanitizer lives is a design call with real tradeoffs, and the unattended loop has no one to ask.
+
+The two scripts are standalone stdlib-only CLI tools with kebab-case names, so neither is importable as a normal module. Three options, none obviously right:
+
+1. *A shared helper module* in =.ai/scripts/=. Cleanest single source of truth, but it becomes another synced template file, and the kebab-named callers need =importlib= gymnastics to reach it — the same trick =route_recommend.py= already uses to load =inbox-send.py=, so there is precedent.
+2. *Duplicate a small sanitizer in each script.* Zero import machinery and each tool stays self-contained, which is the current house style for these scripts. Costs drift, and sibling drift is exactly the defect class that produced three separate findings this session.
+3. *Fix only the demonstrated gaps in place* (extension sanitizing in one, length and control chars in both) without unifying. Smallest diff, leaves the two sanitizers permanently different.
+
+I lean 1 given how much drift has bitten tonight, but it changes the shape of =.ai/scripts/= and that is Craig's call, not an overnight one.
+
+** VERIFY [#B] Parked: question-capture pattern — ask async, answer live (from archsetup, Craig's idea)
+:PROPERTIES:
+:LAST_REVIEWED: 2026-07-24
+:END:
+What arrived: your own idea, relayed via archsetup's roam inbox. Drop a question (not a build task) into the roam inbox as a capture; the agent retrieves it during a sentry/inbox pass but does NOT auto-answer; it holds the question and answers it at the next live conversation, closing on your acknowledgement. Distinct from VERIFY: a VERIFY blocks the agent on your input, here the agent owes you the answer and you owe only the ack. The instance that spawned it: "why does the cursor not appear over the desktop when the world-clock wallpaper is on?", answered live in archsetup.
+
+Recommendation: adopt, as a small spec rather than a direct build. The value is real — a captured question has no home today (not a task, not quite a VERIFY), and decoupling async-capture from sync-answer stops the agent burning cycles guessing at something you'd rather discuss. It clears the value gate on your authorship. But the shape carries decisions I shouldn't guess, which is why it parks rather than lands:
+
+1. How is a question item identified — an explicit marker (a =:question:= filetag, a =Q:= prefix) or phrasing (ends in "?")? Phrasing is fuzzy (a real task can contain a question); a marker is reliable. My lean: explicit marker.
+2. Where does the "answer owed" state live between capture and the live session? Options: the roam item stays put with an answered-pending tag, or it routes to the owning project as an "answer owed" item that startup surfaces. My lean: surface at startup, since that's the next live moment.
+3. Does it warrant a new keyword (the inverse of VERIFY — agent-owes-answer) or a tagged VERIFY variant? The proposal draws the VERIFY distinction sharply enough that a distinct marker may be cleaner.
+
+Prepared artifact: none — this is a rule-design decision, not a mechanical edit, so there's no diff to stage. The proposal and the concrete instance are in [[file:working/question-capture-pattern/proposal-from-archsetup.org]].
+Say "spec the question-capture pattern" (answering 1-3, or leaving them to the spec's decisions walk) and it becomes a spec-create.
+
+** VERIFY [#A] Parked: account-binding guard for the personal-gmail triage plugin (from home)
+:PROPERTIES:
+:LAST_REVIEWED: 2026-07-23
+:END:
+What arrived: home hit a real cross-account hazard on 2026-07-23 — a sentry triage fire used =mcp__claude_ai_Gmail= (bound to the DeepSat work account, name gives no hint) instead of =google-docs-personal=, pulled 201 unread work messages, and would have run every hygiene action against the wrong inbox. The fix adds one guard block to the =triage-intake.personal-gmail.org= Scan phase: confirm a sample result's =to:= is =craigmartinjennings@gmail.com= before classifying, and on mismatch or unavailability fall back to the local mu mirror rather than another MCP.
+
+Recommendation: accept as-is. The diff is a single clean addition between the promotions-filter warning and the maxResults-cap warning — nothing else in the plugin changes. The bug is reproduced and dated, the account→tool mapping was verified by home 2026-07-23 (google-docs-personal=personal, google-docs-work + claude_ai_Gmail=work-bound), and the mu-mirror fallback is the right escape (it's account-fixed by maildir, not by an ambiguous MCP name). I agree with home's judgment that cmail needs no analogous note — it's a bridge script fixed to c@cjennings.net by construction, no tool-binding ambiguity.
+
+Graded [#A] on severity alone, per the todo-format security/privacy carve-out: acting on the wrong person's mailbox is a privacy exposure, and one occurrence with work mail misrouted through a personal-hygiene close is a showstopper regardless of frequency.
+
+Prepared diff: [[file:working/triage-account-guard/proposed.diff]] — apply is mechanical (home's file becomes canonical) on your go. Companion note: [[file:working/triage-account-guard/companion-note-from-home.org]].
+Say "approve the parked account guard" (or adjust / reject) and it gets applied.
+
+** VERIFY [#B] Parked: term-translation-density voice pattern #48 (from work)
+:PROPERTIES:
+:LAST_REVIEWED: 2026-07-23
+:END:
+What arrived: you proposed a new voice pattern in the work session — a sentence forcing the reader to translate more than one specialized term is too dense and gets rewritten. You supplied the rule text and a worked before/after (the ViT/VLM/detect-then-contextualize email sentence), and delegated number, placement, and mode-tag to me.
+
+Recommendation: accept, as #48, general mode. Your reasoning holds — it's a universal clarity rule in the Orwell/Plain English family, distinct from #7 (which words) and #30 (fragments), so it reads for anyone's prose, not just Craig's. The diff is prepared and verified: both files consistent, #47 intact, all mode enumerations and counts updated.
+
+The one wrinkle I resolved, worth a glance before you approve: general mode was defined as exactly "patterns #1-31," a contiguous block. A general pattern at #48 breaks that, so the prepared diff updates every "general walks #1-31" line to "#1-31 and #48" and notes the number is an artifact of when it was added, not a scope signal. The alternative was to scope it [prose · personal] to keep general clean, but that costs the pattern its reach over third-party prose, which is exactly where jargon density bites. I went with your general-mode call.
+
+Prepared diffs: [[file:working/voice-term-density/skill.diff]] and [[file:working/voice-term-density/profile.diff]] — apply is mechanical on your go.
+Say "approve the parked term-density pattern" (or adjust / reject) and it gets applied.
+
+** VERIFY [#B] Parked: auto-empty working/ when a task closes (from work)
+:PROPERTIES:
+:LAST_REVIEWED: 2026-07-23
+:END:
+What arrived: your roam capture relayed via work — every project carries working/ and temp/, and when a task completes its working/ files are automatically ("via a soft hook if possible") archived or moved to temp/. The sender flagged that most of this may already exist and asked for a check before treating it as net-new.
+
+Recommendation: accept the intent, reject the mechanism. Three of the four asks already shipped — working/ tracked from creation and temp/ gitignored (2026-07-20), and temp/ cleared at wrap (today, 10ea44b, from this same capture). Only the auto-empty is new, and I'd not build it as described.
+
+Two reasons. Moving a closed task's artifacts to temp/ destroys them, because temp/ is cleared at the next wrap, while working-files.md says those artifacts get renamed individually and filed flat into assets/ with meaningful names. And filing is deliberately a judgment step: it forces a review of each artifact's permanent value, which is what keeps assets/ searchable and catches the ones not worth keeping. Automating it removes exactly that review. Tonight is the live case — I closed four parked VERIFY tasks and filed their working/ dirs by hand, checking for inbound links and confirming the content stayed recoverable in git first. An automatic sweep would have destroyed four prepared diffs at the next wrap.
+
+The real gap is narrower: orphan detection exists only inside a sentry fire (pass 6), so a project that never runs sentry never gets the nudge. The prepared diff adds that detection to wrap-up as a report-only step, which serves the intent without the destruction. Verified against this repo's live tree.
+
+Prepared diff: [[file:working/working-dir-orphan-check/proposed.diff]] — apply is mechanical on your go.
+Say "approve the parked orphan check" (or adjust / reject) and it gets applied.
+
+** TODO [#B] No two language bundles compose any more — polyglot is now refused :feature:spec:
+:PROPERTIES:
+:LAST_REVIEWED: 2026-07-23
+:END:
+Completing the python and typescript bundles (the [#A] above, fixed 2026-07-23) had a consequence worth deciding on deliberately: all five bundles now ship =claude/settings.json= and =githooks/pre-commit=, so *every* pair collides and =install-lang= refuses the second without =FORCE=1=.
+
+This reopens a decision that was closed on a premise that turned out to be a bug. On 2026-07-17 the call was "polyglot: case-by-case, no option-2 machinery — bundles already compose; only coverage-makefile.txt collides, and it's a manual paste." Bundles appeared to compose only because python and typescript were missing the two files everything else collides on. The composition was the defect, not a design property.
+
+Live impact: =clock-panel= is a python + typescript project. It can install one bundle's hooks or the other's, not both. Today it has neither, so nothing regressed underneath it, but the polyglot path it would have wanted is now closed. =work= is python-only and unaffected.
+
+The real question is what a polyglot project should get. Both files are single-owner by construction: =settings.json= would need its =PostToolUse= arrays merged, and =pre-commit= would need each language's checks concatenated behind one secret scan (which is already identical across all five). Neither merge is hard; the reason it was never built is that nobody had a project that needed it. Now one does.
+
+Options, roughly: (1) build the merge — settings arrays union, pre-commit composes per-language phases; (2) split the shared half out, so the secret scan is one file every bundle sources and only the language phase is per-bundle; (3) leave =FORCE=1= as the polyglot answer and document that the last install wins; (4) do nothing, since only one project is affected. Option 2 is the one that would also have prevented the [#A] — a shared secret scan can't go missing from a bundle that never had its own copy.
+
+Not [#A] because nothing is currently broken: no project is running with a bundle it lost. Not [#D] because a live project wants it and the decision is now forced rather than hypothetical.
+
+Pinned by =scripts/tests/install-lang-collision.bats= so the trade can't drift back unnoticed.
+
+** TODO [#C] Two Signal-channel gaps: unbounded send, second account never received :bug:
+:PROPERTIES:
+:LAST_REVIEWED: 2026-07-23
+:END:
+Both found 2026-07-23 reading =claude-templates/bin/agent-text= and =scripts/signal-receive.sh=. Shellcheck is clean on all four =claude-templates/bin/= scripts; these are logic gaps, not style.
+
+1. *The direct send has no timeout.* =agent-text='s relay path passes =-o ConnectTimeout=10= to ssh, but the local-account path calls =signal-cli send= with no bound at all. A stalled network or a lock contended by the receive timer hangs the call indefinitely. That matters more than it looks: agent-text is invoked *by agents*, so an unbounded call blocks the calling turn with no output, and the caller can't distinguish a hang from a slow send. The receive path already takes ~16s of wall clock every cadence, so the two contending on the same account is not hypothetical. Fix: wrap the direct send in =timeout=, matching the relay's bound, and let the existing non-zero path print the desktop-fallback message.
+
+2. *ratio's second Signal account is never received.* =signal-cli listAccounts= on ratio warns "Messages have been last received 8 days ago". Established by elimination, not inference: ratio holds two accounts (=+15045173983= pager, =+15103169357= Craig's personal), =signal-receive.sh= hardcodes the pager as its default and takes no others, and the timer's journal shows it draining the pager cleanly every 15 minutes (last run 2 minutes before the warning appeared). So the 8-day-stale account is the personal one, and nothing on the machine keeps it warm.
+
+ Honest limit on item 2: the staleness is verified, the *harm* is not. That registration is described in the session history as note-to-self with no push, so it may be fine to leave cold, and Signal's own tolerance for a quiet linked account isn't something I established. Filed so the question gets answered rather than rediscovered. The script already accepts an account argument, so if it does matter the fix is a second timer instance rather than a code change — which makes it a dotfiles handoff (that repo owns the unit) with rulesets owning only the script.
+
+Grading: Minor severity for both (one is a latent hang in a tool with a documented fallback, the other a warning on an account nothing currently depends on) x most users, frequently (the stale warning is a standing condition on this host; the hang needs a stall) = P3 = [#C].
+
+** TODO Manual testing and validation
+*** Sentry — entry gates fire with Craig present
+What we're verifying: the interactive entry gates stop for the right states and start the loop only on a clean, green baseline.
+- On rulesets (ratio), with a clean tree and green suite, say "start sentry hourly".
+- Confirm it reads =:COMMIT_AUTONOMY: yes= and doesn't decline.
+#+begin_src sh :results output
+# Dirty the tree on purpose, then re-arm — the dirty-tree gate should stop and offer finish/stash/rollback.
+echo "# scratch" >> README.org
+git diff --stat
+#+end_src
+- Re-run "start sentry"; confirm it surfaces the dirty README and the three numbered options rather than starting.
+- Discard the scratch edit (=git checkout -- README.org=), re-arm, and confirm it reconciles, creates =sentry/$(date +%F)-$(uname -n)=, and arms the loop.
+Expected: sentry declines on the dirty tree with numbered options, and on a clean+green tree it creates the host-suffixed branch and starts the hourly loop.
+*** 2026-07-20 Mon @ 11:22:56 -0500 Sentry — one fire runs end to end, verified in live trial
+Verified across the 8-fire live trial on =sentry/2026-07-19-ratio= (ratio, 2026-07-20). Each fire wrote one digest block with a ran-or-skipped line per pass (no silent gaps), parked judgment/destructive items under the =* Sentry approval queue= heading with their exact commands (never executed unattended), committed writing passes to the branch, left the tracked tree clean, and freed the single-runner lock between fires. Fires 1-2 productive (2 handoffs processed + 1 parked, 3 design considerations filed); fires 3-8 clean idle. Digest lives in the session anchor.
+*** Sentry — wrap-up guard and stop-sentry
+What we're verifying: wrap-up refuses while sentry is live, and stop-sentry cleanly ends the loop.
+- With sentry live, say "wrap it up"; confirm it refuses with "sentry is active — say 'stop sentry' first."
+- Say "stop sentry"; confirm the loop cancels and it offers the branch disposition (merge now / leave named) and the approval-queue walk.
+Expected: wrap-up blocks on the held single-runner lock; stop-sentry cancels the loop and walks branch + queue disposition.
+*** Sentry — morning branch review
+What we're verifying: the morning teardown reviews and merges cleanly, nothing reached main unpushed.
+- =git log main..sentry/<date>-<host>= and read the diff.
+- Squash-merge what's wanted, delete the branch, revert any stale Emacs buffers.
+- Confirm main was never pushed to during the night and the approval queue was walked.
+Expected: the night's work is reviewable as one branch, merges by choice, and main stayed clean throughout.
+*** Silent-until-signal — empty fire heartbeats, real item full turn
+What we're verifying: an in-session monitor fire collapses to a one-line heartbeat when nothing changed, and still does the full turn on a real item. Covers sentry, auto triage-intake, and auto inbox-zero (spec: docs/specs/2026-07-20-silent-until-signal-monitors-spec.org).
+- Run a sentry fire with nothing pending. Expected: one line, =sentry at HH:MM: nothing=, no per-pass digest block, tree clean.
+- Run a sentry fire after planting an inbox handoff. Expected: the full per-pass digest block, the handoff processed or queued.
+- Start "auto triage-intake" and let an empty sweep run. Expected: one line, =triage intake at HH:MM: nothing=, no three-section output.
+- Plant a real change in a triage source, let the next sweep run. Expected: the full three-section output (deltas / unacked / timestamp).
+- Start "auto inbox zero" with an empty roam inbox, let a cycle run. Expected: one line, =inbox zero at HH:MM: nothing=.
+- Add a roam inbox item, let the next cycle run. Expected: the item summarized, filed, and queued.
+Expected: every empty fire is a single labelled heartbeat; every fire with real work does its full turn unchanged.
+*** Triage source activation — declared sources pulled, undeclared dormant
+What we're verifying: triage-intake pulls only the sources a project declares (spec: docs/specs/2026-07-20-triage-source-activation-spec.org).
+- In rulesets (no =:TRIAGE_SOURCES:=, no project plugin), run "triage intake". Expected: Phase 0 announces every general plugin as "inactive (undeclared)", loads zero sources, and the sweep no-ops.
+- In a project with =:TRIAGE_SOURCES: personal-gmail cmail=, run "triage intake". Expected: Phase 0 loads personal-gmail and cmail as active general sources, names the rest inactive, and scans only the two.
+- In a project with a project-specific plugin (=.ai/project-workflows/triage-intake.*.org=) and no declaration, run "triage intake". Expected: the project plugin is active by presence; general plugins stay inactive.
+- Under sentry, confirm pass 3 (triage) probe-skips in a project with no active source, and runs where one is declared.
+Expected: undeclared general plugins never scan; declared ones and project-specific ones do; sentry's triage pass activates only where a source is active.
+
+** TODO [#D] Document polyglot coverage-makefile namespacing :chore:
+The one manual step the polyglot decision (2026-07-20) left: when a project installs two bundles that both ship =coverage-makefile.txt= (elisp/go/python/typescript), their =coverage:= / =coverage-summary:= Makefile targets duplicate. Document the fix — rename to per-language =coverage-<lang>:= targets and add a =coverage:= aggregate that depends on them — where a polyglot user would meet it (install-lang docs, or a short note in the bundle README / languages/ overview). Low urgency: clock-panel is polyglot today and unaffected because it has no assembled Makefile. Do it when the coverage-makefile fragments are next actually pasted, or opportunistically.
+
+** TODO [#D] Cross-host agent coordination — a lock across ratio and velox :feature:
+Foundational primitive several deferred features wait on. =agent-lock= serializes agents on ONE host (its locks live on tmpfs, =$XDG_RUNTIME_DIR/agent-locks/=), so nothing coordinates the two daily drivers. When ratio and velox both act on shared state at once, there's no lock to stop them — the roam-write lock and sentry's single-runner lock are all host-local. Design a cross-machine lock (a lock node committed to a shared repo, a tailnet lock service, or a designated-primary election) that the host-local locks escalate to when the operation touches cross-machine-shared state (roam, a sibling-freshness push). Take up when cross-host contention proves real in practice, not speculatively.
+
+Blocks (the shared-prerequisite half of the sentry cluster):
+- [[file:todo.org::*Cross-host roam conflict surfacing][Cross-host roam conflict surfacing]].
+- [[file:todo.org::*Sentry vNext passes][Sentry vNext passes]] items #2 (system-health) and #3 (sibling-freshness) — both need ratio/velox not to run the pass simultaneously.
** TODO [#D] Cross-host roam conflict surfacing :feature:
Deferred from the sentry spec (Decision: host-local lock only). The
@@ -134,87 +485,67 @@ roam-write lock serializes same-host agents; a true cross-host race still
forks a sync-conflict file, surfaced only by roam-sync's rebase abort.
Investigate making the conflict loud (roam-sync retry-once, or a
cross-host lock node in the repo) when it proves frequent in practice.
+Shared prerequisite: [[file:todo.org::*Cross-host agent coordination][Cross-host agent coordination]] (the missing cross-machine lock).
** TODO [#D] KB lesson-detection heuristic :spec:
Deferred from the sentry spec review (2026-07-14): sentry's KB promotion pass was cut from v1 because "promote recent durable lessons" had no defined lesson source, and an unattended judgment pass writing to the shared KB needs one that doesn't spam or go silent. Design the detection heuristic — candidate source: Session Log entries and memory-dir changes since the last fire, judged against knowledge-base.md's inclusion bar — and define how precision gets verified before the pass ships in a sentry vNext. Blocks re-adding the pass.
-** TODO [#D] Sentry unattended /schedule variant :feature:
-Deferred from the sentry spec (Non-Goal). The v1 contract is interactive:
-a live session Craig starts, /loop-driven. A cron variant firing with no
-session needs its own contract (mutation rights, async surfacing, dedup
-state across runs) — same open questions inbox.org logs for unattended
-auto inbox zero. Take up only if the interactive shape proves too narrow.
+** TODO [#D] Unattended /schedule cron contract — no-session variant :feature:
+The design problem shared by two deferred features: a =/schedule= cron pass that fires with *no live session* needs its own contract, distinct from the interactive =/loop= shape. The open questions are the same for both consumers — mutation rights (read-only vs may-mutate =todo.org= / =~/org/roam/inbox.org=), how a find surfaces asynchronously when Craig isn't at the session, how dedup state persists across runs that don't share a session, and what session/auth context a cron run carries (the MCP-auth wall: a detached run can't reach Gmail/Slack/Linear, the same constraint the silent-until-signal spec turned on).
+
+Two consumers, one contract:
+- *Sentry* (deferred from the sentry spec Non-Goal): v1 is interactive, a live session Craig starts, /loop-driven. A cron variant firing with no session is out of v1 scope.
+- *Auto inbox zero* (vNext from the inbox-consolidation spec, Codex finding 1; [[id:a7fe2a10-dfa8-4ba3-a11a-e7b1288b7573][spec]]): v1 =auto inbox zero= is the interactive /loop check that waits for Craig's yes. Its "design after v1 consolidation lands" precondition cleared 2026-06-28 (the inbox engine consolidation 24ca58d and monitor-inbox loop edb545d shipped), so this is actionable backlog.
-** TODO [#C] ai launcher hardening — bug hunt + refactor pass :refactor:solo:
+Design the one contract; both features consume it. Merged 2026-07-20 from the separate "Sentry unattended /schedule variant" and "Fully-unattended scheduled inbox check" tasks — same no-session design problem.
+
+** TODO [#D] Research: MCP for device locations shared with you :feature:
+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:
-:CREATED: [2026-07-13 Mon]
-:LAST_REVIEWED: 2026-07-13
+:LAST_REVIEWED: 2026-07-25
:END:
-From the roam inbox (2026-07-13): find bugs in =bin/ai= until no more are visible, then refactor until no worthwhile opportunities remain. The launcher gained =--runtime= + =--print-launch= + 6 bats tests today (04c3b29); this pass extends coverage over the legacy paths (multi_mode, sort_windows, git prep, window sorting) and cleans as it goes. The runtime-selection asks from the same capture are tracked on the generic-agent-runtime parent (claude/codex shipped; ollama/qwen pending the model-floor eval); an interactive runtime picker is fair game here if it stays lightweight.
+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]]).
+2. System-health pass. Decide which checks are safe unattended, and how to keep ratio and velox from running it simultaneously (a cross-driver lock/coordination question — agent-lock is host-local tmpfs, so it doesn't coordinate across machines).
+3. Sibling-machine freshness pass. Keep the other daily driver (velox/ratio) up to date during sentry (see daily-drivers.md; same cross-driver coordination question as #2).
+
+Take up with the sentry Living Document refinements once the trial has quiet nights behind it. Passes #2 and #3 are blocked on [[file:todo.org::*Cross-host agent coordination][Cross-host agent coordination]] (the missing ratio↔velox lock). Related: [[file:todo.org::*Unattended /schedule cron contract][Unattended /schedule cron contract]] (the no-session variant).
** 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.
-** DONE [#C] Put install-ai on PATH, launchable as =install-ai= :chore:quick:solo:
-CLOSED: [2026-07-18 Sat]
-:PROPERTIES:
-:CREATED: [2026-07-11 Sat]
-:LAST_REVIEWED: 2026-07-13
-:END:
-From the roam inbox (2026-07-11): make install-ai launchable as =install-ai= (no =.sh=) from PATH. dotfiles needs a copy that stays in sync with the rulesets canonical — decide whether the startup script-sync already covers it or a dedicated mechanism is needed.
-
-Resolved: added =claude-templates/bin/install-ai=, a thin launcher that resolves its own path through the symlink chain and execs =scripts/install-ai.sh=. =make install='s existing bin loop symlinks it into =~/.local/bin/install-ai= (same mechanism as =ai= and =agent-page=), so no dedicated sync and no dotfiles copy — the symlink always points at the canonical. 3 launcher bats added (incl. symlink-invocation resolution). Verified live: =install-ai --help= runs from PATH.
-
-** TODO [#B] Document (and own) the Signal pager :feature:spec:
-:PROPERTIES:
-:CREATED: [2026-07-11 Sat]
-:LAST_REVIEWED: 2026-07-13
-:END:
-home retired the ntfy phone-notification channel (phone-notify/phone-recv, self-hosted ntfy on ratio) on 2026-07-04 in favor of paging over Signal, and tore ntfy down. The Signal pager it replaced ntfy with is undocumented: no pager script in =~/.local/bin=, and =notify= doesn't reference Signal. What exists on ratio: signal-cli 0.14.5, account 404211. Deliverable: a documented Signal pager (send + read-replies), the signal-cli setup/account notes, and the sync path — the Signal equivalent of the retired ntfy runbook. Cross-machine tooling, so canonical home + docs belong in rulesets.
-
-RECONCILE FIRST: =protocols.org= "Paging Craig" already documents an agent-paging path via the *signal-mcp* tool (=send_message_to_user=, pager account +15045173983, Craig's UUID =b1b5601e-…=, verified 2026-06-30). home's handoff is about a *different* mechanism (signal-cli / account 404211, home's own paging). Decide whether these are one channel or two, and whether the signal-cli side needs its own runbook or should route through signal-mcp. Source: home handoff 2026-07-04 (=inbox/2026-07-04-1302-from-home-task-for-rulesets-document-and-decide.org=). Successor to the 2026-06-17 two-way-comms proposal.
-
-*** 2026-07-13 Mon @ 05:16:37 -0500 Folded home's ownership ack + current-state report into the reconcile scope
-home confirmed (2026-07-11 reply) rulesets owns this task and the two-paths reconciliation is the right first step. New facts for the reconcile: from a home session on 2026-07-09, signal-mcp was NOT connected, and the local signal-cli is registered as Craig's own number — =send --note-to-self= returns a message id but produces no phone push. So home currently has no working ad-hoc page channel at all; whatever the runbook lands on must give home a live path.
-
-*** 2026-07-13 Mon @ 14:40:00 -0500 Added runtime-portability as a second motivation (Craig approved)
-The MCP portability inventory ([[file:docs/design/2026-07-13-runtime-portability-inventories.org]]) found signal-mcp exists only claude.ai-side — no local config anywhere — so a non-Claude agent (Codex-style or local LLM) has no paging path at all. The signal-cli runbook this task produces is therefore also the runtime-neutral page channel, not just home's replacement for ntfy.
-
-*** 2026-07-13 Mon @ 18:30:19 -0500 agent-page shipped — every project now knows both channels
-Craig's call: make paging universal and rename it the agent pager. NEW =claude-templates/bin/agent-page= (runs signal-cli directly on velox, ssh-relays from anywhere else, desktop-fallback hint on failure; 4 bats tests; live-verified from ratio through the real script). protocols.org "Paging Craig" rewritten around the two channels (notify desktop + agent-page phone; signal-mcp demoted to a velox-local nicety); page-me.org gained the phone section + fire-both guidance; work-the-backlog's end-of-set page names both surfaces; INDEX updated. Every project inherits via the startup sync + make install. Remaining here: the runbook proper, the receive timer, ssh-only vs linked-device.
-
-*** 2026-07-13 Mon @ 18:13:07 -0500 RECONCILED — one channel, on velox; live page verified end to end
-The two-paths question is answered: there is ONE pager identity, +15045173983, registered in velox's signal-cli (account file 465310) — and signal-mcp is a locally-configured MCP server in velox's global ~/.claude.json (not claude.ai-side as the 14:40 entry inferred; it's just invisible from ratio, which is why home and this session couldn't find it). ratio's signal-cli holds only Craig's personal number (note-to-self, no push). Verified live today: =ssh velox 'signal-cli -a +15045173983 send -m … b1b5601e-6126-47f8-afaa-0a59f5188fde'= buzzed Craig's phone — his remembered CLI page was this same account on velox. Reliability findings for the runbook: both accounts throw receive-staleness warnings (velox 40 days, ratio 26; the signal protocol wants regular receives — a systemd receive timer on velox is the roam-sync-shaped fix), and the channel requires velox to be up. Remaining deliverables sharpened: the runbook (send + read-replies + receive timer + account notes); decide ssh-over-tailnet-only vs registering ratio as a linked device of the pager account; update protocols.org "Paging Craig" (it names signal-mcp as the only supported path — true only on velox; the ssh recipe is the cross-machine path) — shared-asset edit, own review pass. Interim recipe sent to home so it's unblocked today.
-
** 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.
@@ -257,7 +588,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>=.
@@ -289,12 +620,8 @@ The Codex entry-file pointer now exists: =claude-templates/AGENTS.md= (thin poin
:END:
Three flashcard-tooling tasks that all edit =flashcard-to-anki.py= and/or =flashcard-stats.py=, grouped so they get built together instead of colliding on the same files (prior sessions flagged the conflict risk). The Anki =#+TITLE= deck-name fix already landed (commit 060a938), so any preserved pre-fix script copy gets re-derived against the current canonical, never copied wholesale. The three children each ship independently.
-*** TODO [#C] apkg → org-drill converter :feature:solo:
-:PROPERTIES:
-:CREATED: [2026-06-22 Mon]
-:LAST_REVIEWED: 2026-06-24
-:END:
-Inverse of =flashcard-to-anki.py=: read an Anki =.apkg= (zip → =collection.anki2=/=.anki21= sqlite) and emit an org-drill =.org= in the house canonical shape. Recovers orphaned decks (=deepsat-fundamentals.apkg= has no saved =.org= source) and enables phone→org round-trip. Mapping: deck name → =#+TITLE=; each note → =** <Front> :drill:= with Back as body; card tag → =* Section= grouping (best-effort); Back HTML → org (=<br>= → newlines, unescape entities, strip =<hr id="answer">=); fresh =:ID:= UUID per card. Edge cases for tests: multiple decks per apkg, non-basic note types (skip/warn), HTML entities, empty back, media refs, =.anki2= vs =.anki21= schema. Lives beside the flashcard-* family in =claude-templates/.ai/scripts/= (a new file must be built in canonical — downstream =.ai/scripts/= is wiped by startup =--delete=). PEP 723 uv-run, stdlib =zipfile= + =sqlite3= (no genanki for reading). Acceptance: round-trip a known org-drill source through =flashcard-to-anki.py= then back, assert cards match. Build request: [[file:docs/design/2026-06-21-apkg-to-orgdrill-buildreq.org][buildreq]]. Backlog, not urgent. From home 2026-06-21.
+*** 2026-07-19 Sun @ 18:40:36 -0500 Built apkg-to-orgdrill.py, the inverse converter
+Commit a143679. Reads an Anki =.apkg= (stdlib =zipfile= + =sqlite3=, no genanki) and emits org-drill =.org= in the house shape: deck → =#+TITLE=, note Front → =** heading :drill:=, Back HTML → body (=<br>=→newlines, entities unescaped amp-last, answer =<hr>= stripped), tag → =* section=, fresh =:ID:= per card. 17 tests incl. the round-trip through =flashcard-to-anki.py='s own parse(); verified end-to-end against a real genanki apkg. Grounded the schema by generating + inspecting a real apkg first. Speedrun task 1 of 2.
*** TODO [#C] flashcard-stats refutation / claim-prompt mode :feature:
:PROPERTIES:
@@ -305,24 +632,8 @@ A refutation card (heading is a bare false claim, body is the rebuttal) is valid
Design (Craig, 2026-06-28, supersedes the two proposed options): make the exemption *generic*, not refutation-specific — more card kinds like this will come. When the org header declares the relevant info, the gate honors it rather than blocking. So a general file-level header keyword (a card-kind / check-exemption declaration) tells =flashcard-stats.py= which checks not to apply, instead of a hardcoded =#+DECK_KIND: refutation= keyword or a per-card =:claim:= tag. Document the mechanism in =flashcard-review.org= and add tests (a header-declared exemption file passes despite declarative headings + claim/answer overlap). Edits =flashcard-stats.py= — coordinate with the multi-tag reconcile, same file. Proposal: [[file:docs/design/2026-06-21-flashcard-stats-refutation-proposal.org][proposal]] (its two-option fix is superseded by this generic header approach). Backlog. From home 2026-06-21.
-*** TODO [#C] Reconcile flashcard multi-tag tooling into canonical :chore:quick:solo:
-:PROPERTIES:
-:CREATED: [2026-06-20 Sat]
-:LAST_REVIEWED: 2026-06-24
-:END:
-The work project edited two synced scripts locally as a stopgap (2026-06-17) and asked rulesets to fold them into the canonical so the next sync doesn't revert them. Preserved bundle: [[file:docs/design/2026-06-17-flashcard-multitag-note.md][note]], [[file:docs/design/2026-06-17-flashcard-multitag-to-anki.py][to-anki.py]], [[file:docs/design/2026-06-17-flashcard-multitag-stats.py][stats.py]]. Change: support a second org tag on drill headings (=:fundamental:drill:=) for curated subset decks. =flashcard-to-anki.py= — broaden =CARD_RE= to match a trailing tag block (a heading is a card when =drill= is among its tags), bound the card body by any L1/L2 heading, add =--tag-filter <tag>= (emit only cards carrying that tag) and =--guid-salt <s>= (separate GUID space so a subset deck imports non-empty without disturbing the full deck's SRS state). =flashcard-stats.py= — same =CARD_RE=/=HEADING_RE= broadening plus a drill-membership guard. Use the preserved to-anki.py (the 0953 version: dropped an unused =heading_tags()= helper, tightened =CARD_RE= =(.*?)=→=(.+?)= for parity with stats). Apply to both =.ai/scripts/= and =claude-templates/.ai/scripts/=, add a multi-tag bats case to =flashcard-sync.bats= (a =:foo:drill:= heading parses; =--tag-filter foo= returns only those), verify the full deck still parses to 465 and =--tag-filter fundamental= returns 100, then sync-check + make test. Shared-asset change, so review-gated.
-
-Note (2026-06-24): the Anki =#+TITLE= deck-name fix landed (commit 060a938) — =default_deck_name= is now =default_deck_name(input_path, org_text)= with a new docstring. The preserved 2026-06-17 =to-anki.py= predates that, so *don't* copy it wholesale (it would revert the title-fix). Re-derive the multi-tag changes against the current canonical =flashcard-to-anki.py= and keep the =#+TITLE= behavior.
-
-** DONE [#C] coverage-summary.el documented as a local-only helper :chore:quick:solo:
-CLOSED: [2026-07-18 Sat]
-:PROPERTIES:
-:CREATED: [2026-06-22 Mon]
-:LAST_REVIEWED: 2026-07-13
-:END:
-The elisp bundle installs =coverage-summary.el= into =.claude/scripts/=, gitignored in code projects, so CI can't run =make coverage-summary= against it. Decision (Craig, 2026-06-28): keep it in =.claude/scripts/= and document it as a local-only helper — don't ship it to a tracked =scripts/= dir, don't expect CI to run it. Remaining work (docs only, no move): state the local-only status in the script's header comment and wherever =make coverage-summary= is described, so the gitignored install reads as intentional rather than a gap. Note: emacs-wttrin rewrote its copy's header to claim a tracked =scripts/= home, which now contradicts this decision and should be reverted on their side. Surfaced 2026-06-21 during the coverage-summary autoloads bugfix (commit fb86736).
-
-Resolved: documented the local-only status in the =coverage-summary.el= commentary header and in =elisp-testing.md='s "Measuring it" section — the gitignored install now reads as intentional, not a coverage gap. Sent emacs-wttrin a handoff to revert its contradicting header claim.
+*** 2026-07-19 Sun @ 18:41:00 -0500 Reconciled the flashcard multi-tag tooling into canonical
+Commit a14e43b. Broadened =CARD_RE= in both =flashcard-to-anki.py= and =flashcard-stats.py= so a heading is a card when =drill= is among its tag block (=:fundamental:drill:=), bounded card bodies by any L1/L2 heading (=HEADING_RE=), and added =--tag-filter= (subset emit) + =--guid-salt= (distinct GUID space) to to-anki, plus a drill-membership guard to stats. Re-derived against the current canonical rather than copying the preserved 2026-06-17 files, which predated the =#+TITLE= fix (060a938) and would have reverted it. parse()'s 3rd element is now the Anki-tag list; existing parse tests moved to that contract, new tests cover multi-tag / tag-filter / guid-salt / stats count, and a =flashcard-sync.bats= case guards the count. The real 465/100 check needs the work deck; verified end-to-end on a synthetic deck (2 cards → 1 with =--tag-filter fundamental=) through real genanki. Speedrun task 2 of 2.
** TODO [#C] Agent-KB / memory-sync — work + unknown-project write refusal :test:
:PROPERTIES:
@@ -454,22 +765,6 @@ Once specs carry lifecycle TODO keywords under =docs/specs/=, add a custom org-a
:END:
From Craig via the roam inbox (2026-07-02, routed by archsetup). Teardown-by-default already shipped (bare "wrap it up" closes the window; "with summary" keeps it). Craig's follow-on: "maybe we cut the summary altogether. help me think through when I'd want a summary and how I would recognize it before confirming and then having it close." Run that think-through with him (brainstorm-shaped, not solo), then adjust wrap-it-up.org's Step 6 + trigger phrases to the outcome.
-** TODO [#C] triage-intake.org auto mode — push each sweep to phone (ntfy) :feature:solo:
-:PROPERTIES:
-:CREATED: [2026-06-20 Sat]
-:LAST_REVIEWED: 2026-07-13
-:END:
-The work project (2026-06-18) added a "Push each sweep to Craig's phone (ntfy) — the primary delivery" subsection under "Trigger and delivery" in triage-intake.org auto mode, and asks to fold it into the canonical engine plus re-sync. Preserved bundle: [[file:docs/design/2026-06-18-triage-intake-phone-push-note.org][note]] + [[file:docs/design/2026-06-18-triage-intake-phone-push-workflow.org][edited workflow]]. Auto mode is the away-from-desk / vacation mode, so phone-notify becomes the primary delivery each sweep (fuller end-of-sweep output: per-source deltas, open-PR/Linear state, awaiting-ack list, one-line verdict, timestamp; SCAN FAILED banner on any source failure), plus phone-recv polling each sweep for Craig's replies. Falls back to inline when phone-notify is absent. Transport re-pointed 2026-07-13: ntfy is retired; agent-page (shipped today) is the send channel, so the push half is buildable now — substitute agent-page for phone-notify throughout. The reply-polling half (phone-recv) waits on the Signal-pager runbook's read-replies deliverable. Shared template-workflow change, so review-gated.
-
-** TODO [#D] Fully-unattended scheduled inbox check (/schedule cron pass) :feature:
-:PROPERTIES:
-:CREATED: [2026-06-23 Tue]
-:LAST_REVIEWED: 2026-06-28
-:END:
-vNext from the inbox-consolidation spec. =auto inbox zero= (v1) is the interactive =/loop= recurring check that waits for Craig's yes before executing. A fully-unattended =/schedule= cron pass that fires while Craig is away needs its own contract before it can ship: read-only vs may-mutate =todo.org= / =~/org/roam/inbox.org=, how a find surfaces asynchronously when Craig isn't at the session, how dedup state persists across runs that don't share a session, and what session/auth context a cron run carries. From the inbox-consolidation spec-review (Codex finding 1). See [[id:a7fe2a10-dfa8-4ba3-a11a-e7b1288b7573][spec]].
-
-Update 2026-06-28: the "design after v1 consolidation lands" precondition is cleared — the inbox engine consolidation (24ca58d) and the monitor-inbox 15-min loop (edb545d) both shipped. Now actionable backlog rather than blocked; design the unattended contract when prioritized.
-
** TODO [#D] Warn-only pre-commit hook for tooling-path enumeration :feature:
:PROPERTIES:
:CREATED: [2026-06-22 Mon]
@@ -1316,7 +1611,99 @@ having a skill to generate or check OV-1-shaped artifacts. Don't build
speculatively — defense-specific notations are narrow enough that each
skill should be driven by a concrete contract need, not aspiration.
-** TODO [#B] todo-cleanup.el dated-seal archiving :feature:solo:
+* Rulesets Resolved
+** CANCELLED [#C] ntfy phone channel as general two-way agent-comms :feature:spec:
+CLOSED: [2026-07-13 Mon]
+:PROPERTIES:
+:CREATED: [2026-06-20 Sat]
+:LAST_REVIEWED: 2026-06-24
+:END:
+Killed at the 2026-07-13 task review: home retired and tore down the ntfy channel on 2026-07-04, so this proposal's transport no longer exists. Its living successors are the Signal pager ([#B] task above — one identity on velox, runbook pending) and agent-page (shipped 2026-07-13), which cover the send half; two-way (read-replies) rides the Signal runbook.
+Proposal from the home project (2026-06-17): promote the self-hosted ntfy-over-Tailscale phone channel it built and verified on ratio into a general two-way agent-comms tool rulesets owns. Full proposal: [[file:docs/design/2026-06-17-ntfy-agent-comms-proposal.org]] (as-built runbook stays in the home project at =working/phone-notifications/spec.org=). What rulesets would decide: canonicalize =phone-notify= (send) plus a new =phone-recv= (check-since) as synced bin scripts; the per-machine config/secret convention (token in =~/.config/phone-notify/config= chmod 600 today, vs GPG-encrypted in dotfiles); a reference =ntfy-inbound-handler= plus systemd user-unit for event-driven delivery (Tier A subscriber routes inbound to inbox/notify, Tier B inbound spawns an agent session, Tier C notify a live session — harness research); approval-button workflows for the commits.md gates when Craig is away from the desk (tap-to-approve, the high-value concrete use); and the relationship to the retired cross-agent-comms scripts (ntfy may be the transport they lacked). Worked via =spec-create=. Blocks the triage-intake phone-push task below.
+** DONE [#B] Org-table helpers corrupt example blocks :bug:solo:
+CLOSED: [2026-07-14 Tue]
+:PROPERTIES:
+:CREATED: [2026-07-11 Sat]
+:LAST_REVIEWED: 2026-07-13
+:END:
+Fixed in 951b6fc, test-first. Both defects landed as filed (block-type-aware scanning in both helpers; lint-org CLI report-only by default, writes behind --fix), plus the true corruption path found during the work: wrap-org-table's load-time CLI dispatch fired on lint-org's require and reformatted lint-org's file arguments. Entry-script guard added. The named regression test (example block byte-identical) is in the suite.
+=wrap-org-table.el= and =lint-org.el= both scan for =/^\s*|/= lines and rewrite them as org tables without skipping =#+begin_example=/=src=/=quote= regions, so ASCII art using pipe characters gets mangled into bordered tables. Reproduced 2026-07-09 in the work project against an architecture doc with a pipe/=v= flow diagram; a plain indented block became a table with =|---|= rules between every line. Two separable defects: (1) table detection is line-based — both helpers should use =org-element-at-point= / =org-in-block-p= to skip example/src/quote/verse blocks; (2) =lint-org.el= mutates its input on disk with no confirmation — passing five files reformatted all five (one by 1949 lines). A linter must report, not write; put the reformat behind an explicit =--fix= flag.
+
+Grading (severity × frequency, per todo-format.md): Critical severity (silent org data loss; recoverable here only because the content was git-staged) × rare-edge-case frequency (fires only when a mixed table+example file is passed to the helper) = P2 = [#B].
+
+Regression test: run =wrap-org-table.el= against a file containing a =#+begin_example= block whose lines start with =|= and assert the block is byte-identical afterward. Source: work handoff 2026-07-09 (=inbox/2026-07-09-1341-from-work-bug-data-loss-wrap-org-table-el-and.org=).
+** DONE [#B] Sentry workflow — build from spec :feature:
+CLOSED: [2026-07-19 Sun]
+:PROPERTIES:
+:SPEC_ID: f6c51f27-d7a2-4b63-9ff9-5ba005a66dfb
+:END:
+Built the sentry supervisor workflow from the spec
+([[file:docs/specs/2026-07-14-sentry-workflow-spec.org][sentry workflow spec]], now IMPLEMENTED). Four phases, each committed and
+pushed in no-approvals + auto-flush mode; full suite green throughout. The overnight
+live trial is handed to Craig as a manual-testing task (below); its findings file as
+follow-ups.
+*** 2026-07-19 Sun @ 04:52:00 -0500 Built the agent-lock helper + 18 bats tests
+=.ai/scripts/agent-lock= (canonical =claude-templates/.ai/scripts/=, mirror synced):
+mkdir-atomic acquire, PID/host/ISO-timestamp metadata, mtime-based staleness reclaim
+(atomic-rename claim so two acquirers can't double-acquire — caught by the pre-commit
+review), heartbeat refresh, acquire/release/status/path subcommands, XDG_RUNTIME_DIR
+home with =~/.cache= fallback. Commit a8b6cf4.
+*** 2026-07-19 Sun @ 04:56:00 -0500 Built the sentry.org engine + INDEX entry
+=.ai/workflows/sentry.org= (mirror synced): :COMMIT_AUTONOMY: entry ticket, the
+interactive entry gates, ff-only reconcile, =sentry/<date>-<host>= branch mechanics,
+the ten-pass probe→work→session-context→commit runner, digest + morning-approval
+queue, skip-not-degrade safety, spine-excluded dirty checks + fire-end digest commit,
+multi-day stall notify, the stop-sentry operation. All 10 decisions and 12 findings
+reflected. Commit ccc9c26.
+*** 2026-07-19 Sun @ 05:00:00 -0500 Wired the roam writers + wrap-up guard
+knowledge-base.md and inbox.org core §5 acquire the roam-write lock and edit-plus-
+trigger (roam-sync stays sole committer); roam-sync.sh header updated to match;
+wrap-it-up.org gained a Step 0 active-sentry guard; triage-intake.org notes its
+sentry-pass role. Graceful degradation when agent-lock is absent. Commit c6383e9.
+*** 2026-07-19 Sun @ 05:04:00 -0500 Verified suite green + flipped spec to IMPLEMENTED
+=make test= green at HEAD (pytest 393, ERT + bats all pass, exit 0). Flipped the spec
+keyword DOING → IMPLEMENTED with a dated history line and mirrored the Metadata Status.
+Filed the overnight live trial as a structured manual-testing task.
+** DONE [#C] ai launcher hardening — bug hunt + refactor pass :refactor:solo:
+CLOSED: [2026-07-19 Sun]
+:PROPERTIES:
+:CREATED: [2026-07-13 Mon]
+:LAST_REVIEWED: 2026-07-19
+:END:
+Resolved across two commits (113e8d8 net, 2b619f1 refactor). Brought the 17 uncovered functions under characterization tests (launcher tests 9 → 42), extracted the git/tmux decision logic into four pure cores (_git_prep_action, _order_windows, _match_window_id, _git_is_dirty) each with a Normal/Boundary/Error set, and dispositioned the footgun audit + /refactor pass. Objective floor met: shellcheck clean, shfmt -i 2 -ci consistent, make test green before and after, live black-box smoke correct for claude and codex. Honest limit: attach_session and the full end-to-end of single/multi/fetch stay partially covered (their terminal step attaches to tmux or blocks on fzf, can't run headless). Their decision logic was extracted into the netted cores. The interactive runtime picker stayed out of scope (a design call, filed on the generic-agent-runtime parent).
+Harden =claude-templates/bin/ai= (the agent-session launcher, 540 lines, 22 functions). Origin: roam inbox 2026-07-13, phrased open-endedly ("find bugs until none visible, refactor until nothing worthwhile remains"). Rescoped 2026-07-19 with measurable acceptance criteria per =todo-format.md='s "Making an open-ended task measurable," which is what makes it =:solo:=. The four moves:
+
+1. *Bound the surface.* The 22 functions are the done-set. *Covered* (behaviorally, via =scripts/tests/ai-launcher-runtime.bats=, 9 tests over the runtime path): =resolve_agent_cmd=, =build_runtime_choices=, =pick_runtime=, =build_instructions=, the print modes. *Uncovered* (the 17 to bring under test): =usage=, =check_deps=, =attach_session=, =create_window=, =maybe_add_candidate=, =build_candidates=, =fetch_candidates=, =git_status_indicator=, =annotate_candidates=, =auto_pull_if_clean=, =read_selections=, =sort_windows=, =find_window_id=, =prep_git_single=, =attach_mode=, =single_mode=, =multi_mode=, =print_launch_mode=.
+
+2. *Net the behavior.* Characterization tests (Normal/Boundary/Error per unit, per =testing.md=) over the uncovered surface. The pure/near-pure ones take the category set directly: =git_status_indicator=, =maybe_add_candidate= (dedup), =annotate_candidates= (formatting), =read_selections= (selection parse), =usage=. The =tmux=/=git=-coupled ones (=sort_windows='s ordering, =create_window=, =attach_session=, =find_window_id=, =prep_git_single=, =auto_pull_if_clean=) get their pure decision logic extracted into helpers that take plain inputs and return plain results — that extraction *is* the hardening — with the I/O calls left as thin wrappers.
+
+3. *Disposition every finding.* (a) A bash-footgun audit per function, each cell fixed / n-a / filed: unquoted expansions + word-splitting, =set -euo pipefail= gaps and where errexit is intentionally off, subshell state loss, exit-code propagation, ordering/races in =sort_windows= + window creation, and the git-prep error paths (=prep_git_single= / =auto_pull_if_clean= on a dirty tree, detached HEAD, no upstream). (b) A =/refactor= pass, each finding applied (tests green) or declined with a one-line reason.
+
+4. *Objective floor.* =shellcheck= clean, =shfmt=-consistent, =make test= green before and after, and every uncovered function above has its characterization set (a per-function checklist — =kcov= isn't installed; install it if a single coverage number is wanted). Plus ~3 functional tests over the launch pipelines (=single_mode=, =multi_mode=, =attach_mode=) against a throwaway =tmux= session, for the composition bugs no per-function unit can see.
+
+*Qualifying answer:* a dispositioned report — surface split covered/uncovered, tests before → after, =shellcheck=/=shfmt= result, the footgun matrix fully dispositioned, the =/refactor= findings fully dispositioned, all green. Not "no bugs remain" (unprovable) — "every enumerated path passes its characterization set and clears the audit."
+
+*Out of this task's =:solo:= scope:* an interactive runtime picker. It's a feature carrying a design/preference call (does Craig want it, what shape), which is deliberation, not hardening — file it separately if wanted. The runtime-selection arc (claude/codex shipped; ollama/qwen pending the model-floor eval) stays on the generic-agent-runtime parent.
+** DONE [#C] Put install-ai on PATH, launchable as =install-ai= :chore:quick:solo:
+CLOSED: [2026-07-18 Sat]
+:PROPERTIES:
+:CREATED: [2026-07-11 Sat]
+:LAST_REVIEWED: 2026-07-13
+:END:
+From the roam inbox (2026-07-11): make install-ai launchable as =install-ai= (no =.sh=) from PATH. dotfiles needs a copy that stays in sync with the rulesets canonical — decide whether the startup script-sync already covers it or a dedicated mechanism is needed.
+
+Resolved: added =claude-templates/bin/install-ai=, a thin launcher that resolves its own path through the symlink chain and execs =scripts/install-ai.sh=. =make install='s existing bin loop symlinks it into =~/.local/bin/install-ai= (same mechanism as =ai= and =agent-page=), so no dedicated sync and no dotfiles copy — the symlink always points at the canonical. 3 launcher bats added (incl. symlink-invocation resolution). Verified live: =install-ai --help= runs from PATH.
+** DONE [#C] coverage-summary.el documented as a local-only helper :chore:quick:solo:
+CLOSED: [2026-07-18 Sat]
+:PROPERTIES:
+:CREATED: [2026-06-22 Mon]
+:LAST_REVIEWED: 2026-07-13
+:END:
+The elisp bundle installs =coverage-summary.el= into =.claude/scripts/=, gitignored in code projects, so CI can't run =make coverage-summary= against it. Decision (Craig, 2026-06-28): keep it in =.claude/scripts/= and document it as a local-only helper — don't ship it to a tracked =scripts/= dir, don't expect CI to run it. Remaining work (docs only, no move): state the local-only status in the script's header comment and wherever =make coverage-summary= is described, so the gitignored install reads as intentional rather than a gap. Note: emacs-wttrin rewrote its copy's header to claim a tracked =scripts/= home, which now contradicts this decision and should be reverted on their side. Surfaced 2026-06-21 during the coverage-summary autoloads bugfix (commit fb86736).
+
+Resolved: documented the local-only status in the =coverage-summary.el= commentary header and in =elisp-testing.md='s "Measuring it" section — the gitignored install now reads as intentional, not a coverage gap. Sent emacs-wttrin a handoff to revert its contradicting header claim.
+** DONE [#B] todo-cleanup.el dated-seal archiving :feature:solo:
+CLOSED: [2026-07-18 Sat]
Redefine =--archive-done= aging from a 7-day roll-into-one-file model to a
one-month retention with dated seals. Craig ratified the design in the work
project 2026-07-17; origin handoff preserved at
@@ -1340,8 +1727,8 @@ Changes to =.ai/scripts/todo-cleanup.el= (canonical in
TDD, canonical-then-mirror per the sync-check invariant. Home's planning-line
strip proposal (filed alongside) also touches todo-cleanup.el =--convert-subtasks=
— the two can be built as one batch.
-
-** TODO [#B] Strip stale planning lines on dated completion + lint backstop :feature:solo:
+** DONE [#B] Strip stale planning lines on dated completion + lint backstop :feature:solo:
+CLOSED: [2026-07-18 Sat]
Two linked fixes so a closed sub-task can't keep polluting the org agenda. Origin:
home handoff 2026-07-17; design preserved at
[[file:docs/design/2026-07-17-dated-log-planning-line-strip-proposal.md]]. A dated-log
@@ -1366,8 +1753,9 @@ but nothing strips the planning line, and an interactive org close only stamps
Build notes: the checker's regex must key on "no TODO keyword" so it never flags a
live TODO that legitimately carries a SCHEDULED. TDD, canonical-then-mirror. Batches
with the dated-seal task above (both touch todo-cleanup.el).
-
-** TODO [#B] Enforce the task-boundary inbox check via a hook :feature:
+** DONE [#B] Enforce the task-boundary inbox check via a hook :feature:
+CLOSED: [2026-07-19 Sun]
+Resolved with the soft-nudge design (94e54f6). New =hooks/inbox-boundary-check.sh= Stop hook blocks the yield once + injects the pending count when =inbox-status -q= exits 1, steps aside on the harness re-entry (=stop_hook_active=) so a mid-task pause never wedges, self-skips on no-inbox/no-inbox-status/clean. Wired ahead of =ai-wrap-teardown= in =.claude/settings.json= (which the live =~/.claude/settings.json= symlinks to) + the snippet; glob-installed by =make install-hooks=. 6 bats (pending/clean/re-entry/no-inbox/absent-status/project-name). protocols.org Inbox Monitoring Cadence now notes the enforcement. Live-verified on ratio (clean inbox no-ops, pending fixture blocks); velox synced + hook linked. The UserPromptSubmit visibility complement stayed unbuilt (optional in the design) — file separately if wanted.
Today the "check =inbox/= at every task boundary" rule (protocols.org, "Inbox
Monitoring Cadence") is prose-only — present in all 27 projects' synced
protocols.org, but nothing mechanically enforces it, so it holds only as well as
@@ -1432,8 +1820,9 @@ No hook can perfectly tell "a unit of work finished" from "the agent paused for
other reason" — the harness exposes only "the turn is ending," not "a task is
ending." The design leans on those two being usually the same in Craig's workflow.
That's the tradeoff hard-block vs soft-nudge is really about.
-
-** TODO [#B] "Colloquialisms and Expansions" + "the list" before-close-queue convention :feature:
+** DONE [#B] "Colloquialisms and Expansions" + "the list" before-close-queue convention :feature:
+CLOSED: [2026-07-19 Sun]
+Resolved (approved in the speedrun). New =* Colloquialisms and Expansions= section in =protocols.org= documents both shorthands: "put X on the list" → append to a session-scoped Before-Close Queue (=* Before-Close Queue= heading in the session anchor, resets on archive, todo.org for must-outlive items); "tell <project> <msg>" → =inbox-send=. =wrap-it-up.org= Step 1 gained a "Work the Before-Close Queue (before the Summary)" sub-step so queued work rides the wrap commit, unfinished items surfaced in the valediction. Design calls: reference in protocols.org not per-project notes.org (synced = shared norm); queue in the session anchor as home did; wrap step at the front of Step 1, not a new half-step (keeps the "Steps 1-5" framing). 4 documentation-integrity bats (=before-close-queue.bats=). Canonical + mirror synced. The UserPromptSubmit-style variant wasn't in scope.
Home proposes two linked cross-project norms; Craig recommends rolling them out.
Origin: home handoff 2026-07-18, design preserved at
[[file:docs/design/2026-07-18-colloquialisms-and-the-list-proposal.md]]. Needs Craig's
@@ -1464,25 +1853,481 @@ section vs a new shipped reference file); where the queue itself lives (home use
insertion point (before the teardown/valediction, alongside the roam-inbox
sub-step). Then it's a synced-file change (canonical-then-mirror) + a test that the
wrap step drains the queue.
+** DONE [#B] working/ tracked-from-creation + gitignored temp/ :feature:
+CLOSED: [2026-07-20 Mon]
+Craig's ruling relayed from .emacs.d (2026-07-19): working/ is version-controlled from creation (not excluded until graduation); ephemeral artifacts go in a gitignored temp/ or /tmp; graduation reorganizes durable artifacts into permanent homes rather than marking when they become durable.
+
+Implemented (Shape A) during the morning sentry review, 2026-07-20:
+- =claude-rules/working-files.md=: added "working/ Is Version-Controlled From Creation" section (tracked-from-creation, graduation-is-a-move, temp/ for ephemeral).
+- =.ai/protocols.org= (canonical + mirror): one-paragraph mirror in the Working-Files Convention section.
+- =scripts/install-ai.sh=: emits a =temp/= ignore block in BOTH track and gitignore modes; working/ never ignored. Idempotent.
+- =scripts/sweep-gitignore-tooling.sh=: separate mode-independent temp/ backfill pass (a distinct loop, not an IGNORE_SET member — that would skip track-mode projects, the set that most needs it).
+- Tests: 3 new install-ai bats + 4 new sweep bats (temp/ in both modes, never working/, idempotent). Suite green, 384 bats ok.
+
+Finding confirmed at implementation: the canonical machinery already never ignored working/ (tooling set is only =.ai/ .claude/ CLAUDE.md AGENTS.md=), so .emacs.d's =/working/= ignore was a purely local deviation. The staging proposal dir was removed after shipping; its content lives in the feat commit and this body.
+** DONE [#C] Polyglot projects — supported, or refused? :spec:
+CLOSED: [2026-07-20 Mon]
+DECISION (2026-07-20, scouting with Craig): *case-by-case, and it already composes — no option-2 machinery.* The evidence: bundle contents split into namespaced/additive files (rules =<lang>.md=, =validate-<lang>.sh= hooks, =coverage-summary.<ext>= scripts, appended =gitignore-add.txt=) that compose cleanly, and exactly three colliding files — =claude/settings.json= and =githooks/pre-commit= (full bundles only: bash/elisp/go) and =coverage-makefile.txt= (elisp/go/python/typescript). Of the three, only =coverage-makefile.txt= occurs in the fleet, and clock-panel (the one real polyglot, python+typescript) proves it's benign: both bundles installed, no breakage, because that fragment is a hand-pasted Makefile block nobody pasted twice. So: keep the install-lang collision guard (it blocks the destructive full+full settings/githooks clobber, which no project actually hits), and document the =coverage-<lang>:= + =coverage:= aggregate namespacing as the one manual step when going polyglot (filed below). No two-full-co-equal-bundle project exists, so the settings.json/githooks merge is unwarranted. Follow-up doc: [[file:todo.org::*Document polyglot coverage-makefile namespacing][Document polyglot coverage-makefile namespacing]].
-* Rulesets Resolved
-** CANCELLED [#C] ntfy phone channel as general two-way agent-comms :feature:spec:
-CLOSED: [2026-07-13 Mon]
+Do we support more than one language bundle per project? The honest answer today
+is "partly, by accident." The collision guard added 2026-07-16 refuses a
+*colliding* second bundle rather than silently replacing the first's config, but
+a non-overlapping pair still installs fine: bash ships =settings.json= +
+githooks and no coverage fragment, python ships only a coverage fragment, so
+=bash= + =python= composes cleanly today and yields a real polyglot project with
+both rule sets. So the line isn't polyglot-vs-not, it's overlap-vs-not — and
+nobody chose that line, it fell out of which bundle happens to ship what. Origin:
+home's report after scaffolding clock-panel with python + typescript,
+[[file:docs/design/2026-07-16-polyglot-bundle-collision.txt][docs/design/2026-07-16-polyglot-bundle-collision.txt]].
+
+Pair this with the subproject scouting below — it's the same question in a
+different costume ("which projects would actually be polyglot, and why"), so
+they should be one conversation.
+
+The three options, in the order they'd be weighed:
+
+1. *Unsupported, explicitly.* Keep the guard as the answer. Cheapest, and
+ matches how little polyglot exists (one project, clock-panel).
+2. *Supported.* Needs per-bundle filenames, a merged =settings.json= (the hooks
+ arrays compose rather than clobber), composed githooks, and namespaced
+ Makefile targets with a =coverage= aggregate. This is the real work.
+3. *Case-by-case.* Support the pairs that come up, refuse the rest.
+
+What the decision needs to know:
+
+- *The target-name collision is the deeper half* (home's point, and it's right).
+ Every bundle's fragment defines =coverage:= and =coverage-summary:=, so even
+ with both files present a polyglot project can't paste both into one Makefile.
+ Renaming files doesn't fix it.
+- *Only three of five shared filenames actually collide.* =gitignore-add.txt=
+ (5 bundles) appends deduped and composes. =CLAUDE.md= (3) is seed-only, and
+ its fallback comment shows multi-bundle was already considered there.
+ =claude/settings.json= (3), =githooks/*= (3), and =coverage-makefile.txt= (4)
+ are the real ones.
+- *=FORCE=1= is a poor escape hatch* (home's catch): it also re-seeds
+ =CLAUDE.md=, which is destructive on a customized project. If polyglot
+ becomes supported, the override wants to be its own flag.
+** DONE [#C] Subproject pattern — promote to claude-rules? :spec:
+CLOSED: [2026-07-20 Mon]
+DECISION (2026-07-20, scouting with Craig): *don't promote — keep it local at home.* The scouting confirmed N=1: across all 27 =.ai= scopes, home (9 subprojects, all from the single 2026-06-11 fold) is the only real user. The nearest neighbors aren't the pattern — rulesets folds claude-templates in as a git *subtree* (different mechanism, not shared-=.ai/=-scope), archsetup's dotfiles/ likewise; every other project is a focused single package with no subproject structure or need. Promoting a 282-line convention into the always-on claude-rules layer for one project fails the thin-always-on principle (the precedent is patterns.md at 29 lines + docs-lifecycle.md's depth-in-a-spec). Keep the convention as home's local instance. Revisit only if a genuine second case appears, and then as a thin pointer + a spec, never 282 lines always-on.
+
+home proposes promoting its subproject pattern (a former standalone project
+folded into a parent, living as a self-contained subdir sharing the parent's
+=.ai/= scope) into the rules layer: vocabulary, the read-first
+=<subproject>/<subproject>-brief.org= convention, the parent-vs-subproject
+content criterion ("one fact, one home"), and create/archive criteria.
+Proposal + home's full instance:
+[[file:docs/design/2026-07-15-subproject-pattern-proposal.org][proposal]],
+[[file:docs/design/2026-07-15-subprojects-convention-home-instance.org][home's convention doc]].
+
+Deferred 2026-07-16 rather than promoted. *Craig's framing:* he wants to scout
+which projects would actually get subprojects, and why, before we shape a rule.
+If he hasn't done that scouting by the time this comes up, offer to do it
+together — brainstorm the candidates, then explore the reasons behind each. That
+evidence decides it: either we drop the pattern, or we know enough to adjust it
+so it's effective. Don't shape the rule before the scouting.
+
+*Review findings from the 2026-07-16 pass* (the inputs the decision needs):
+
+- *N=1.* home is the only project with subprojects, across all 27 =.ai= scopes;
+ its nine all came from the single 2026-06-11 fold. This is the fact the
+ scouting tests.
+- *Placement contradicts the proposal's own principle.* =claude-rules/*.md=
+ loads into every session of every project. home's doc argues the always-on
+ layer is "a tax paid whether or not it's relevant today" and depth belongs
+ "one open away". At 282 lines the doc would be the third-largest rule and add
+ ~11% to the always-on layer, so every .emacs.d / takuzu / chime session would
+ carry a one-project convention.
+- *Precedent for the shape:* =patterns.md= (29 lines, explicit "don't carry the
+ catalog in context") and =docs-lifecycle.md= (75 lines, depth in a spec).
+ Thin rule + on-demand depth is the established answer.
+- *Dangling reference:* the doc cites =claude-rules/git-hosting-privacy-model=
+ as authority for its shared-scope-safety criterion. No such file exists — the
+ real content is the gitignore-vs-track and public-reachability decision in
+ =protocols.org=. Fix before any promotion.
+- *Instance vs rule:* the metrics, self-improvement log, kill criteria, rollout
+ dates, and adoption table are home's instance, not rule content.
+** DONE [#B] Triage source activation — per-project source declaration :feature:spec:
+CLOSED: [2026-07-20 Mon]
+Spec: [[file:docs/specs/2026-07-20-triage-source-activation-spec.org][docs/specs/2026-07-20-triage-source-activation-spec.org]] (IMPLEMENTED, ID af73ef0b-cd1d-46f1-9e1d-62695733a4de). Craig approved after his read, both open decisions resolved via cj comments; built same session.
+
+From the sentry live trial (2026-07-20): triage-intake self-activated in every project because the general (personal-account) plugins are template-synced everywhere, so sentry's pass-3 probe misfired and would pull Craig's personal inboxes into whatever project the fire ran in. Fixed with an activation gate in triage-intake Phase 0 — general plugins gate on a per-project =:TRIAGE_SOURCES:= declaration; project-specific plugins stay active by presence. Applies to interactive and unattended alike. sentry pass-3 probe reads the same signal. Migration handoffs sent to home + work. Supersedes Fire 1's narrower probe-only approval-queue item.
+** DONE [#B] Silent-until-signal for in-session monitor loops :feature:spec:
+CLOSED: [2026-07-20 Mon]
+Spec: [[file:docs/specs/2026-07-20-silent-until-signal-monitors-spec.org][docs/specs/2026-07-20-silent-until-signal-monitors-spec.org]] (IMPLEMENTED, ID af592bd6-d3e6-47e2-8804-2a287b4d9303). Craig approved without changes 2026-07-20; all 5 phases shipped that day. Sentry, auto triage-intake, and auto inbox-zero all collapse an empty fire to =<workflow> at HH:MM: nothing=; a manual-testing entry covers the live-loop verification.
+
+Craig-approved proposal from .emacs.d (2026-07-20), demonstrated by the sentry live trial (fires 3-8 were walls of no-op lines). Reframed live from a watcher *mechanism* to a *policy*: an in-session monitor fire detects first, and on an empty check collapses to one labelled heartbeat line (=<workflow> at HH:MM: nothing=) instead of a full turn; only a real item earns the full surface-and-judge turn. Keeping detection in-session dissolves the MCP-auth split (triage's Gmail/Slack/Linear need session auth), so it applies uniformly to sentry, auto triage-intake, and auto inbox-zero. No external watcher, no new seen-list. Five build phases in the spec. Phase 1 (sentry quiet-fire heartbeat) shipped 2026-07-20 ahead of the full READY gate at Craig's direction — a quiet fire now collapses to =sentry at HH:MM: nothing=. Phases 2-4 (auto triage-intake, auto inbox-zero, shared-policy home) + Phase 5 verification pending Craig's spec read. Overlaps the sentry cluster ([[file:todo.org::*Sentry vNext passes][Sentry vNext passes]], [[file:todo.org::*Triage source activation][Triage source activation]]).
+** DONE [#C] Apply suspend.org detach-on-suspend change to canonical :feature:
+CLOSED: [2026-07-20 Mon]
+Craig's change relayed from archsetup (2026-07-20): add Step 6 to suspend.org — detach the tmux client (=tmux detach-client -s "$sess"=) as the final action of every suspend, so a suspended session parks in the re-attachable set instead of cluttering the alt-space rotation. Also reworded the neighbors bullet and the "does NOT do" teardown bullet to draw the detach-vs-teardown line.
+
+Applied to canonical 2026-07-20 (verified the diff was exactly the intended change, canonical + mirror synced, lint clean bar one pre-existing flush/SKILL.md link). Replied to archsetup that its local stopgap is now canonical.
+** DONE [#B] Document (and own) the Signal pager :feature:spec:
+CLOSED: [2026-07-20 Mon]
:PROPERTIES:
-:CREATED: [2026-06-20 Sat]
-:LAST_REVIEWED: 2026-06-24
+:CREATED: [2026-07-11 Sat]
+:LAST_REVIEWED: 2026-07-13
:END:
-Killed at the 2026-07-13 task review: home retired and tore down the ntfy channel on 2026-07-04, so this proposal's transport no longer exists. Its living successors are the Signal pager ([#B] task above — one identity on velox, runbook pending) and agent-page (shipped 2026-07-13), which cover the send half; two-way (read-replies) rides the Signal runbook.
-Proposal from the home project (2026-06-17): promote the self-hosted ntfy-over-Tailscale phone channel it built and verified on ratio into a general two-way agent-comms tool rulesets owns. Full proposal: [[file:docs/design/2026-06-17-ntfy-agent-comms-proposal.org]] (as-built runbook stays in the home project at =working/phone-notifications/spec.org=). What rulesets would decide: canonicalize =phone-notify= (send) plus a new =phone-recv= (check-since) as synced bin scripts; the per-machine config/secret convention (token in =~/.config/phone-notify/config= chmod 600 today, vs GPG-encrypted in dotfiles); a reference =ntfy-inbound-handler= plus systemd user-unit for event-driven delivery (Tier A subscriber routes inbound to inbox/notify, Tier B inbound spawns an agent session, Tier C notify a live session — harness research); approval-button workflows for the commits.md gates when Craig is away from the desk (tap-to-approve, the high-value concrete use); and the relationship to the retired cross-agent-comms scripts (ntfy may be the transport they lacked). Worked via =spec-create=. Blocks the triage-intake phone-push task below.
-** DONE [#B] Org-table helpers corrupt example blocks :bug:solo:
-CLOSED: [2026-07-14 Tue]
+home retired the ntfy phone-notification channel (phone-notify/phone-recv, self-hosted ntfy on ratio) on 2026-07-04 in favor of paging over Signal, and tore ntfy down. The Signal pager it replaced ntfy with is undocumented: no pager script in =~/.local/bin=, and =notify= doesn't reference Signal. What exists on ratio: signal-cli 0.14.5, account 404211. Deliverable: a documented Signal pager (send + read-replies), the signal-cli setup/account notes, and the sync path — the Signal equivalent of the retired ntfy runbook. Cross-machine tooling, so canonical home + docs belong in rulesets.
+
+RECONCILE FIRST: =protocols.org= "Paging Craig" already documents an agent-paging path via the *signal-mcp* tool (=send_message_to_user=, pager account +15045173983, Craig's UUID =b1b5601e-…=, verified 2026-06-30). home's handoff is about a *different* mechanism (signal-cli / account 404211, home's own paging). Decide whether these are one channel or two, and whether the signal-cli side needs its own runbook or should route through signal-mcp. Source: home handoff 2026-07-04 (=inbox/2026-07-04-1302-from-home-task-for-rulesets-document-and-decide.org=). Successor to the 2026-06-17 two-way-comms proposal.
+
+*** 2026-07-13 Mon @ 05:16:37 -0500 Folded home's ownership ack + current-state report into the reconcile scope
+home confirmed (2026-07-11 reply) rulesets owns this task and the two-paths reconciliation is the right first step. New facts for the reconcile: from a home session on 2026-07-09, signal-mcp was NOT connected, and the local signal-cli is registered as Craig's own number — =send --note-to-self= returns a message id but produces no phone push. So home currently has no working ad-hoc page channel at all; whatever the runbook lands on must give home a live path.
+
+*** 2026-07-13 Mon @ 14:40:00 -0500 Added runtime-portability as a second motivation (Craig approved)
+The MCP portability inventory ([[file:docs/design/2026-07-13-runtime-portability-inventories.org]]) found signal-mcp exists only claude.ai-side — no local config anywhere — so a non-Claude agent (Codex-style or local LLM) has no paging path at all. The signal-cli runbook this task produces is therefore also the runtime-neutral page channel, not just home's replacement for ntfy.
+
+*** 2026-07-13 Mon @ 18:30:19 -0500 agent-page shipped — every project now knows both channels
+Craig's call: make paging universal and rename it the agent pager. NEW =claude-templates/bin/agent-page= (runs signal-cli directly on velox, ssh-relays from anywhere else, desktop-fallback hint on failure; 4 bats tests; live-verified from ratio through the real script). protocols.org "Paging Craig" rewritten around the two channels (notify desktop + agent-page phone; signal-mcp demoted to a velox-local nicety); page-me.org gained the phone section + fire-both guidance; work-the-backlog's end-of-set page names both surfaces; INDEX updated. Every project inherits via the startup sync + make install. Remaining here: the runbook proper, the receive timer, ssh-only vs linked-device.
+
+*** 2026-07-13 Mon @ 18:13:07 -0500 RECONCILED — one channel, on velox; live page verified end to end
+The two-paths question is answered: there is ONE pager identity, +15045173983, registered in velox's signal-cli (account file 465310) — and signal-mcp is a locally-configured MCP server in velox's global ~/.claude.json (not claude.ai-side as the 14:40 entry inferred; it's just invisible from ratio, which is why home and this session couldn't find it). ratio's signal-cli holds only Craig's personal number (note-to-self, no push). Verified live today: =ssh velox 'signal-cli -a +15045173983 send -m … b1b5601e-6126-47f8-afaa-0a59f5188fde'= buzzed Craig's phone — his remembered CLI page was this same account on velox. Reliability findings for the runbook: both accounts throw receive-staleness warnings (velox 40 days, ratio 26; the signal protocol wants regular receives — a systemd receive timer on velox is the roam-sync-shaped fix), and the channel requires velox to be up. Remaining deliverables sharpened: the runbook (send + read-replies + receive timer + account notes); decide ssh-over-tailnet-only vs registering ratio as a linked device of the pager account; update protocols.org "Paging Craig" (it names signal-mcp as the only supported path — true only on velox; the ssh recipe is the cross-machine path) — shared-asset edit, own review pass. Interim recipe sent to home so it's unblocked today.
+
+*** 2026-07-20 Mon @ 15:56:56 -0500 Runbook shipped; ratio linked as a device; receive timer on both machines
+All four remaining deliverables landed. (1) Runbook: [[file:docs/design/2026-07-20-signal-pager-runbook.org]] — send, read-replies, receive timer, signal-cli account/setup notes, the resolved topology decision. (2) Receive timer: =scripts/signal-receive.sh= + =scripts/systemd/signal-receive.{service,timer}= (roam-sync-shaped, 15-min cadence, 3 bats), stowed via dotfiles =common=, enabled + verified on ratio; a manual drain also cleared the 47-day staleness live. (3) Topology decision (Craig, option 2): register daily drivers as linked devices rather than ssh-relay-only — ratio linked as Device 2 "ratio-pager", direct send verified, =agent-page= generalized from a velox-only check to "any machine holding the account sends directly, else relay" (bats updated). (4) protocols.org "Paging Craig": verified accurate, no edit — already describes both channels and the caveats, and its generic runbook pointer is correct to leave un-pathed since it syncs into every project. Remaining one-time step: enable the timer on velox after it pulls dotfiles.
+** DONE [#C] triage-intake auto mode — push signal sweeps to phone via agent-text :feature:solo:
+CLOSED: [2026-07-20 Mon]
:PROPERTIES:
-:CREATED: [2026-07-11 Sat]
+:CREATED: [2026-06-20 Sat]
:LAST_REVIEWED: 2026-07-13
:END:
-Fixed in 951b6fc, test-first. Both defects landed as filed (block-type-aware scanning in both helpers; lint-org CLI report-only by default, writes behind --fix), plus the true corruption path found during the work: wrap-org-table's load-time CLI dispatch fired on lint-org's require and reformatted lint-org's file arguments. Entry-script guard added. The named regression test (example block byte-identical) is in the suite.
-=wrap-org-table.el= and =lint-org.el= both scan for =/^\s*|/= lines and rewrite them as org tables without skipping =#+begin_example=/=src=/=quote= regions, so ASCII art using pipe characters gets mangled into bordered tables. Reproduced 2026-07-09 in the work project against an architecture doc with a pipe/=v= flow diagram; a plain indented block became a table with =|---|= rules between every line. Two separable defects: (1) table detection is line-based — both helpers should use =org-element-at-point= / =org-in-block-p= to skip example/src/quote/verse blocks; (2) =lint-org.el= mutates its input on disk with no confirmation — passing five files reformatted all five (one by 1949 lines). A linter must report, not write; put the reformat behind an explicit =--fix= flag.
+Send half shipped 2026-07-20. Folded a "Phone delivery" subsection into canonical =triage-intake.org= auto mode: a full-three-section sweep pushes to Craig's phone over Signal via =agent-text=, with a pointer from "End-of-sweep output" and a Living Document note. Signal-only by Craig's 2026-07-20 ruling — a quiet sweep's =nothing= heartbeat never reaches the phone, so silent-until-signal governs the phone channel too and the in-session heartbeat stays as proof-of-life. Falls back to inline when =agent-text= is absent.
-Grading (severity × frequency, per todo-format.md): Critical severity (silent org data loss; recoverable here only because the content was git-staged) × rare-edge-case frequency (fires only when a mixed table+example file is passed to the helper) = P2 = [#B].
+Reply-polling half (the old =phone-recv=) deferred to the reply-correlation follow-up: with the Signal account linked on more than one device a reply fans out to every device and neither knows which page it answers, so auto mode pushes but does not poll until that's resolved. The recv wiring is owned by that spec, not this task.
-Regression test: run =wrap-org-table.el= against a file containing a =#+begin_example= block whose lines start with =|= and assert the block is byte-identical afterward. Source: work handoff 2026-07-09 (=inbox/2026-07-09-1341-from-work-bug-data-loss-wrap-org-table-el-and.org=).
+Origin: the work project's 2026-06-18 fold-in request; preserved bundle [[file:docs/design/2026-06-18-triage-intake-phone-push-note.org][note]] + [[file:docs/design/2026-06-18-triage-intake-phone-push-workflow.org][edited workflow]]. Transport re-pointed off retired ntfy → agent-text (renamed from agent-page 2026-07-20).
+** CANCELLED [#D] Fully-unattended scheduled inbox check (/schedule cron pass) :feature:
+CLOSED: [2026-07-20 Mon]
+Merged 2026-07-20 into [[file:todo.org::*Unattended /schedule cron contract][Unattended /schedule cron contract — no-session variant]]. Same no-session design problem as the sentry /schedule variant (mutation rights, async surfacing, cross-run dedup, cron auth context); its inbox-specific context was absorbed there.
+** CANCELLED [#B] lint-org resolves file: links against cwd, not the linted file :bug:solo:
+CLOSED: [2026-07-23 Thu]
+Not a defect. I filed this on a bad comparison and retracted it the same night.
+
+The claimed evidence was that linting =claude-templates/.ai/notes.org= from the repo root reports =protocols.org= and =workflows/first-session.org= missing, while linting it from its own directory reports neither. The first half of that was never run. The findings came from a =/tmp= copy of the template made while preparing the smoke diff, and in =/tmp= those two siblings genuinely are missing, so org-lint was right. I compared two different files and read the difference as a cwd bug.
+
+Retested three ways: the real file from the repo root gives zero link findings, the real file from its own directory gives zero, and only the =/tmp= copy gives two. A direct probe confirms =find-file-noselect= already sets =default-directory= to the linted file's directory, so the mechanism I proposed could not have been the cause either.
+
+Worth keeping from the episode: =link-to-local-file= is org-lint's own checker, not one lint-org.el implements, so a real fix here would mean pre- or post-filtering upstream output rather than editing a local checker.
+** DONE [#A] Python and TypeScript bundles ship no secret-scan hook :bug:
+CLOSED: [2026-07-23 Thu]
+:PROPERTIES:
+:LAST_REVIEWED: 2026-07-23
+:END:
+Fixed 2026-07-23. Both bundles now ship all four components: =githooks/pre-commit= (the secret scan, shared verbatim with the other bundles, plus a language-appropriate syntax gate), =claude/hooks/validate-python.sh= / =validate-typescript.sh=, =claude/settings.json= wiring the PostToolUse hook, and a seed =CLAUDE.md=. 53 new tests (13 + 13 hook, 13 + 14 pre-commit), suite green.
+
+Two findings from the build worth keeping. First, =node --check= must never be used on TypeScript: it ignores =--experimental-strip-types=, so it rejects valid TS (an =interface= reads as a syntax error) and accepts broken TS. Measured on node v26.4.0. The hook uses =tsc= filtered to TS1xxx (syntactic) diagnostics instead, which also keeps type errors out of scope — those need the whole project graph. Second, the install now warns when a bundle lacks a documented component, which is the half that stops this recurring: nobody will remember the seventh bundle either, so the installer says so.
+
+Left undone deliberately: re-running =make install-lang= on =work= and =clock-panel=. Both are other projects, so that's a cross-project action for Craig. See the polyglot task below — =clock-panel= is python + typescript and can no longer install both.
+The =python= and =typescript= language bundles carry rules, a coverage script, and a gitignore fragment. They carry no =githooks/pre-commit=, no =claude/hooks/=, no =claude/settings.json=, and no =CLAUDE.md=. The =bash=, =elisp=, and =go= bundles carry all four.
+
+The pre-commit hook is the secret scanner. So a project installing the Python or TypeScript bundle gets no credential scan on commit, no validate-on-edit hook, and no settings — while README's "Bundle structure" section documents all four as what each bundle follows, and notes.org describes the bundles as "rules + hooks + settings".
+
+Live as of 2026-07-23, verified by scanning every project carrying =.claude/rules/=: =work= (python) and =clock-panel= (python + typescript) both have no =githooks/= and no =.claude/settings.json=. The four elisp projects all have both. =work= is the one that matters — a work repo is where a leaked credential is most costly and most likely to reach a company remote.
+
+Not a design choice. Both bundles were added 2026-05-31; =go='s githooks landed 2026-06-02 and =bash='s 2026-06-23, so the hook rollout swept the two bundles added *after* these and skipped these. =install-lang.sh= guards its copy with =[ -d "$SRC/githooks" ]=, so the install succeeds silently and reports nothing missing — which is why this stayed invisible for nearly two months.
+
+Grading: Major severity (a documented security control absent, silently, with the install reporting success) x every user, every time (every install of either bundle, standing on 2 of the 6 bundle-using projects) = P1 = [#A].
+
+Fix direction: port =githooks/pre-commit= to both bundles, adapting the language-specific half (the secret scan is common; =gofmt=/=shellcheck= becomes a formatter/linter check per language), add =claude/hooks/validate-*.sh= and =claude/settings.json=, and seed =CLAUDE.md=. Then make the gap loud: =install-lang= should warn when a bundle lacks a component the README documents, so the next partial bundle announces itself instead of installing quietly. Re-run =make install-lang= on =work= and =clock-panel= afterward.
+** DONE [#C] Two language bundles' pre-commit hooks lost the cd guard :bug:quick:solo:
+CLOSED: [2026-07-23 Thu]
+:PROPERTIES:
+:LAST_REVIEWED: 2026-07-23
+:END:
+Fixed 2026-07-23. =go= and =elisp= now read =cd "$REPO_ROOT" || exit 1=, matching =bash=; the two new bundles were written with the guard. All five siblings agree, all five shellcheck clean.
+=languages/bash/githooks/pre-commit= line 8 reads =cd "$REPO_ROOT" || exit 1=. The =go= and =elisp= copies of the same line read a bare =cd "$REPO_ROOT"=. Three siblings of one file, one hardened and two not — the guard landed in bash and never propagated.
+
+Low impact, stated honestly: git chdirs to the working-tree root before running a hook, so if =cd= fails the cwd is already correct and the checks still run against the right tree. Neither script sets =-e=, so a failure wouldn't abort them either. It takes a =GIT_WORK_TREE= or bare-repo edge case for =rev-parse --show-toplevel= to disagree with the cwd at all.
+
+Grading: Minor severity (belt-and-braces guard, no silent-pass path found — the checks run regardless) x rare edge case (needs a git configuration that makes toplevel differ from the hook's cwd) = P4... graded [#C] rather than [#D] because it's a two-character fix in a security-relevant gate and the drift pattern is the real signal: a fix landed in one bundle copy and stopped there, which is the same shape as the [#A] above.
+** DONE [#B] Parked: zero markup in chat output, fences included (from org-drill)
+CLOSED: [2026-07-23 Thu]
+Craig approved 2026-07-23. Applied to =claude-rules/interaction.md=: the fenced-code-block carve-out is gone, replaced with a zero-markup-always statement citing his 2026-05-30 direction. The rule's factual claim stays honest — fences don't invert the way inline spans do, so the text says they read as markup he didn't ask for rather than inventing a rendering problem, and points at plain indented text or a named file path as the way to hand over something copyable. It was the only copy of the carve-out.
+** DONE [#B] Parked: sentry Living Document updates from two dogfood runs (from takuzu + archangel)
+CLOSED: [2026-07-23 Thu]
+Craig approved 2026-07-23. Four folds applied to =sentry.org=: =--archive-done= touches =.gitignore= on its first run so an "org-only" pass can still produce a commit, a mirror-only project's quiet fires leave no commits so the anchor's heartbeat list is the only record, randomized property sweeps are good quiet-fire work, and the task-audit pass splits into a mechanical hourly subset plus a nightly judgment half. The fifth finding (make the bug hunt official) had already landed as pass 11, so it became a corroboration note. Takuzu's lint-org finding stayed a filed bug task rather than a workflow edit, since it's a defect not a design change.
+** DONE [#B] Parked: clear four lint flags in the notes.org template (from smoke)
+CLOSED: [2026-07-23 Thu]
+Craig approved 2026-07-23. Applied to =claude-templates/.ai/notes.org= and synced to the mirror. The template now lints with zero mechanical and zero judgment findings, down from four. Two fixes beyond what smoke reported: two more column-0 bold lines, and the real cause of the block flags — a literal =** Feature Name= inside the example block that org parses as a heading, cleared by comma-escaping that one line. Worth remembering: those heading flags were mechanical, not judgment, so =lint-org --fix= in any project would have rewritten the template locally and drifted it from canonical.
+** DONE [#B] Parked: clear temp/ during wrap-up teardown (from your roam capture)
+CLOSED: [2026-07-23 Thu]
+Craig approved 2026-07-23. Added =*** Clear temp/= to =wrap-it-up.org= Step 3, after the archive pass, and synced to the mirror. Two guards: confirm before deleting anything that reads as in-progress rather than throwaway (that belongs in =working/=, so move it there), and skip entirely where =temp/= isn't gitignored, since that means the project uses the directory for something else. This closes the last open clause of his 2026-07-20 working/temp capture.
+** DONE [#B] inbox-send leaves a phantom empty handoff in the target inbox :bug:solo:
+CLOSED: [2026-07-23 Thu]
+:PROPERTIES:
+:LAST_REVIEWED: 2026-07-23
+:END:
+Fixed 2026-07-23 (speedrun, 0f91a8e). Both send paths write to a .inbox-send-* temp sibling and os.replace it into place, so a mid-write failure leaves no phantom; utf-8 pinned on the write and the roots read; inbox-status skips the in-flight temp. 5 new tests (atomic write, no-partial-on-failure, no-temp-on-success, both paths) + 1 inbox-status bats. Review found and fixed one Important: the temp was discoverable by inbox-status during the write window.
+=inbox-send.py= writes straight to the destination path in the target project's =inbox/=. =Path.write_text= opens with mode =w=, which creates and truncates before any content is written, so *any* failure between opening and finishing leaves a zero-byte =.org= file sitting in another project's inbox.
+
+That file is not inert. =inbox-status= counts it as a pending handoff, so it trips the receiving project's =inbox-boundary-check= Stop hook and blocks a turn there. The receiving agent then has to resolve a handoff with no content and no sender context, which it cannot do from the file. Meanwhile the sender saw an error and will most likely retry, so the target gets a second file too.
+
+Reproduced 2026-07-23 end to end: a send whose text contains non-ASCII under =LC_ALL=C PYTHONUTF8=0 PYTHONCOERCECLOCALE=0= fails on the ASCII codec, leaves a zero-byte file in the destination inbox, and =inbox-status= in that project then reports it as pending.
+
+The encoding case is one trigger, not the defect. =write_text= and =read_text= are both called with no =encoding= argument, so they follow the locale; passing =encoding="utf-8"= closes that trigger. The defect underneath is the non-atomic write, which a full disk, a revoked permission, or an interrupted process reaches just as easily.
+
+Grading: Major severity (a phantom handoff in a *different* project's inbox, unresolvable from its own content, that blocks a turn there via the boundary hook) x some users, sometimes (any mid-write failure, of which the locale case is only the one reproduced) = P2 = [#B].
+
+Fix direction: write to a temp file in the destination directory and =os.replace= into place, so the inbox only ever sees a complete file. Pin =encoding="utf-8"= on both =write_text= and the =read_text= in =resolve_roots=. Same treatment for =send_file=, whose =shutil.copy2= has the same shape. Test by forcing a mid-write failure and asserting the destination directory is unchanged.
+** DONE [#C] Two smaller inbox-send defects: uncaught traceback, duplicate roots :bug:quick:solo:
+CLOSED: [2026-07-23 Thu]
+:PROPERTIES:
+:LAST_REVIEWED: 2026-07-23
+:END:
+Fixed 2026-07-23 (speedrun, a053e9d). main now catches OSError, so an unreadable source gives the clean inbox-send: error rather than a traceback. discover_projects dedupes on the resolved path. 2 tests.
+Both verified 2026-07-23, both in =.ai/scripts/inbox-send.py=, both low-harm. Grouped because they're the same file and the same fix session.
+
+1. *Uncaught =PermissionError=.* =main= catches =(ValueError, FileNotFoundError)= around the send, but =send_file= reaches =shutil.copy2=, which raises =PermissionError= on an unreadable source. That is an =OSError=, not caught, so the user gets a raw Python traceback instead of the clean =inbox-send: <message>= error every other failure path produces. Reproduced with a =chmod 000= source. No partial file is left in this case. Fix: catch =OSError= alongside the other two.
+
+2. *Duplicate roots list a project twice.* =discover_projects= appends without deduping, so a roots config naming both a parent and one of its children (=/x= and =/x/proj=) lists =proj= at two different numeric indices. Reproduced via =INBOX_SEND_ROOTS=. Harmless today — both indices resolve to the same project, so no message goes to the wrong place, and Craig's current =~/.claude/inbox-roots.txt= has no overlap. Fix: dedupe on =resolve()= before returning.
+
+Grading: Minor severity (one is cosmetic output noise, the other an ugly but accurate failure message; neither loses or misroutes a message) x rare edge case (an unreadable source file, or a roots config with an overlap) = P4... graded up to [#C] rather than [#D] because both fixes are one line each and sit in a script every project depends on for cross-project messaging.
+** DONE [#C] lint-org todo-format checkers fire on spec files :bug:solo:
+CLOSED: [2026-07-24 Fri]
+:PROPERTIES:
+:LAST_REVIEWED: 2026-07-23
+:END:
+Fixed 2026-07-24 (speedrun, c38bab9). A lo--spec-file-p path guard skips all five todo-format-family checkers on files under docs/specs/. Verified: the nine repo specs go from 100 todo-format findings to zero, todo.org keeps its full checker set. 7 ERT tests.
+=level2-done-without-closed= and =level-2-dated-header= encode =todo.org= completion conventions, but they run against every org file, including =docs/specs/=. A spec's Decisions section legitimately uses =** DONE <decision>= with no =CLOSED:= cookie, and its Review-and-iteration-history section legitimately uses =** <dated> — <who> — <role>= headings. Neither is a completion defect there.
+
+Reproduced 2026-07-23 across rulesets' own specs: 100 findings over 7 of the 9 files in =docs/specs/= (docs-lifecycle 27, sentry-workflow 25, autonomous-batch 11, wrapup-routing 11, inbox-consolidation 10, agent-kb 8, encourage-kb 8; the two 2026-07-20 specs are clean). Every project carrying =docs/specs/= inherits the same noise on every sweep. Reported independently by takuzu's first sentry dogfood run.
+
+Grading: Minor severity (judgment-kind output, so nothing mutates; the cost is noise that trains the reader to skim) x every user, every time (fires on every sweep in every project with specs) = P2... graded down to [#C] because the two inputs disagree: the frequency row is genuinely universal, but Minor severity with zero mutation risk and a known cause reads as P3. Recorded so the read can be argued.
+
+Fix direction: scope the todo-format-family checkers away from =docs/specs/= by path. Correction (2026-07-23 speedrun): the earlier note that these are org-lint's own checkers was wrong — =lo--check-level2-dated-headers= (line 413) and =lo--check-level2-done-without-closed= (line 507) are lint-org.el's own functions, so the fix is a direct local guard, not upstream filtering.
+
+Decision (Craig, 2026-07-23 speedrun pre-flight): scope *all four* todo-format-family checkers, not just the two that fired — the two named plus =subtask-done-not-dated= and =dated-log-heading-active-timestamp=, which would misfire the same way on a spec's dated review-history headings. Path-based (any file under a =docs/specs/= segment), matching the docs-lifecycle canon that specs live there. Takuzu's spec-create alternative fixes new specs only and leaves the nine existing ones noisy, so path-scoping is the general fix.
+** DONE [#C] notes.org template trips four lint-org flags in every project :bug:quick:solo:
+CLOSED: [2026-07-24 Fri]
+:PROPERTIES:
+:LAST_REVIEWED: 2026-07-23
+:END:
+Already satisfied. The canonical claude-templates/.ai/notes.org was fixed in 10ea44b (2026-07-23) when Craig approved the smoke proposal — the two column-0 bold lines rephrased, the example block's marker comma-escaped. Verified 2026-07-24: the template lints with zero mechanical and zero judgment findings, down from four. This TODO and the applied smoke VERIFY were duplicate work items for the same fix; closing DONE citing the commit rather than filing a no-op VERIFY, since the end-state is verifiable now with nothing to ask.
+The synced =claude-templates/.ai/notes.org= carries two boilerplate lines that open with markdown-style bold at column 0 (=**Session history is NOT in this file.**=, =**For protocols and conventions, see:**=). Org parses a line-initial =**= as a level-2 heading, so =misplaced-heading= flags both, and the =#+begin_example= block in the Pending Decisions instructions trips =invalid-block= twice. Every project inherits all four on every sweep.
+
+Reproduced 2026-07-23. Confirms smoke's proposal. Additional finding from the same run: these two are =mechanical-fixed=, not judgment, so =lint-org --fix= silently rewrites the template's =**bold**= to org =*bold*=. The rewrite is correct org, but it lands on a synced template, so whichever project runs =--fix= first creates drift against canonical.
+
+Grading: Minor severity (cosmetic noise; the mechanical rewrite is correct org and harmless in isolation) x every user, every time (every project, every sweep) = P2... same disagreement as the checker task above; graded [#C] on the no-real-harm read.
+
+Fix direction: rephrase both lines in the canonical template so no line starts with =**= (a list dash, or move the bold off column 0), and comma-escape the example block's own markers. One canonical fix clears it everywhere.
+** DONE [#B] cj-remove-block silently deletes content between two cj blocks :bug:solo:
+CLOSED: [2026-07-24 Fri]
+:PROPERTIES:
+:LAST_REVIEWED: 2026-07-24
+:END:
+Fixed 2026-07-24 (sentry fire 2, 17f5d48). The range validation now looks inside the range and refuses when a second fence appears before the end. Verified it doesn't over-tighten: indented blocks, interior blank lines, legacy single-line annotations, and prose mentioning a fence mid-line all still validate. Second defect fixed in the same commit: remove_range now backs up to /tmp (matching lint-org.el) and writes atomically through a temp sibling, utf-8 pinned. 6 new tests.
+=looks_like_cj_range= validates only that the *first* line of the range opens a =#+begin_src cj:= fence and the *last* line closes with =#+end_src=. It never checks that the range holds exactly one block. A range spanning one block's opening fence to a *later* block's closing fence passes validation, and =remove_range= then deletes everything between — real prose, headings, whole tasks — silently, exit 0.
+
+That is precisely the failure the validation exists to prevent. Its docstring says it "protects against accidentally trimming the wrong block when line numbers drift between a cj-scan call and a remove call", and drift is the normal operating mode: =respond-to-cj-comments= edits the file as it processes each item, and a file being processed for cj comments generally holds several of them.
+
+Reproduced 2026-07-24 on a fixture with two cj blocks separated by real content. =looks_like_cj_range(lines, 2, 10)= returned =ok=True=, and the removal reduced a 10-line file to its first line. A heading and two content lines were destroyed with no warning and a zero exit.
+
+Grading: Major severity (silent destruction of real content in the file that holds Craig's tasks and notes, with no warning and a success exit; git recovers only to the last commit, so intra-session work is lost) x some users, sometimes (needs drift plus multiple cj blocks, which together are the skill's normal operating mode) = P2 = [#B].
+
+Fix direction: =looks_like_cj_range= must confirm the range contains exactly one block — scan lines start..end and reject when any =#+end_src= appears before the final line, or when any second =#+begin_src cj:= appears after the first. Test with the two-block fixture above asserting the validation refuses.
+
+Second defect, same file, same fix session: =remove_range= writes the mutated org file with a bare =path.write_text=, which truncates the target on open, and it takes no backup first. A mid-write failure leaves Craig's =todo.org= truncated. =lint-org.el= — the other tool that mutates these files — copies a backup to =/tmp/<basename>.before-lint-pass.<timestamp>= before touching anything. cj-remove-block should match that: back up first, then write atomically via a temp file and =os.replace=, the same shape shipped for =inbox-send= in 0f91a8e. Pin =encoding="utf-8"= on the read and write while there.
+** DONE [#D] route_recommend downgrades strong to weak on a duplicate basename :bug:quick:solo:
+CLOSED: [2026-07-24 Fri]
+:PROPERTIES:
+:LAST_REVIEWED: 2026-07-24
+:END:
+Fixed 2026-07-24 (sentry fire 2, 1b0f284). The dedupe went into recommend rather than discover_destination_names as the task proposed, so every caller of the pure core is protected, not just the CLI path. Identical names collapse; genuine ambiguity between two different projects still downgrades to weak, pinned by a test. 3 new tests.
+=discover_destination_names= collapses discovered projects to bare basenames (=[p.name for p in ...]=). Two projects sharing a basename across roots (=~/code/notes= and =~/projects/notes=) therefore appear twice in the candidate list, both literal-match the same item, and =recommend= reads =len(strong) > 1= as an ambiguous tie — downgrading a correct strong match to weak.
+
+Reproduced 2026-07-24 by direct probe: =recommend("fix the notes thing", ["notes", "other"])= returns =('notes', 'strong')=, while the same call with =["notes", "notes", "other"]= returns =('notes', 'weak')=.
+
+Latent, not live: the current project set is 27 projects with 27 distinct basenames, so no collision exists today. The destination stays correct either way; only the confidence tier is wrong, which costs an unnecessary routing prompt rather than a misroute.
+
+Grading: Minor severity (right destination, wrong tier, cost is one extra prompt) x rare edge case (needs a basename collision across roots, which doesn't currently exist) = P4 = [#D].
+
+Fix direction: dedupe names in =discover_destination_names= (=list(dict.fromkeys(names))=, order-preserving). A duplicate basename is one addressable name as far as routing goes, since =inbox-send='s =find_target= resolves a name to its first match anyway. Add a test with a duplicated candidate asserting the tier stays strong.
+** DONE [#C] audit.bats has a flaky teardown that produces false suite reds :bug:test:solo:
+CLOSED: [2026-07-24 Fri]
+:PROPERTIES:
+:LAST_REVIEWED: 2026-07-24
+:END:
+Fixed 2026-07-24 (sentry fire 3, 7f45d4b). The fixture now sets =maintenance.auto false= and =gc.auto 0= before staging.
+
+Cause, traced not guessed: =git commit= spawns =git maintenance run --auto --quiet --detach= on git 2.55. The commit returns while that detached process is still writing a pack, and teardown's =rm -rf= races it. Every failed run left a =tmp_pack_*= behind, which is what pointed at it.
+
+*My original lead in this task was wrong* and worth recording as such. It blamed =gc.auto='s loose-object threshold; the object counts kill that outright (a fixture holds five objects against a default threshold of 6700). The right knob on modern git is =maintenance.auto=, and the mechanism is the =--detach=, not the threshold. Filing the theory as an explicitly-labelled lead rather than a finding is what kept it from being implemented as fact.
+
+Validation: 20 consecutive runs clean with no leftover fixture directories, against a baseline of roughly one failure in eight. Stated honestly, 20 clean runs alone would be about 7% likely by luck at that rate, so the statistics confirm rather than prove — the trace is the evidence. Disabled the background writer rather than retrying the delete, since a retry loop hides a live process instead of removing it.
+=scripts/tests/audit.bats= test 4 ("tracked project with dirty .ai/ is skipped") intermittently fails in its =teardown=, not its assertions: =rm -rf "$TEST_HOME"= exits non-zero with =rm: cannot remove '/tmp/audit-bats.XXXX/code/alpha/.git/objects': Directory not empty=. The test body passes; only the cleanup fails, and bats reports the whole test as failed.
+
+Verified intermittent 2026-07-24: it failed twice during a sentry fire (once in a full =make test=, once running the file alone), then passed three consecutive runs immediately afterward with an identical tree. That intermittency is the defect — it makes =make test= return a false red.
+
+This matters more than a normal flaky test because the green suite is load-bearing for sentry: it gates entry, and the fire-end conditional run gates whether a night's commits are trusted. A spurious red there either blocks a fire or flags a clean night for morning review.
+
+Grading: Minor severity (a teardown-only failure, no production code implicated, and the assertions themselves pass) x some users, sometimes (fired twice in roughly six runs tonight) = P3 = [#C].
+
+Lead, not a verified cause: =audit.bats= line 42 runs =git init -q= in each fixture project and sets no =gc.auto=, while =audit.sh= runs five git commands against them. Git can fork background maintenance (=gc --auto=) that keeps writing into =.git/objects= after the foreground command returns, which would race the teardown's =rm -rf=. That fits the symptom exactly but is untested. Confirm before fixing.
+
+Fix direction (once the cause is confirmed): set =git config gc.auto 0= (and =maintenance.auto false=) in the fixture setup so no background writer exists. Failing that, make teardown resilient rather than papering over it — a retry loop hides a real writer instead of removing it, so prefer killing the writer.
+** DONE [#C] todo-cleanup rewrites todo.org with no backup, unlike its siblings :bug:solo:
+CLOSED: [2026-07-24 Fri]
+:PROPERTIES:
+:LAST_REVIEWED: 2026-07-24
+:END:
+Fixed 2026-07-24 (sentry fire 4, 0686784). Copies to =/tmp/<basename>.before-todo-cleanup.<stamp>= before the first mutation, matching lint-org.el's convention, once per invocation and skipped under =--check=. 2 ERT tests. The two non-findings recorded in this task's original body stand: the archive move was already fail-safe, and missing-file behavior is unchanged (exit 255, nothing created) — both re-verified against the pre-change version.
+=todo-cleanup.el= rewrites =todo.org= in place (hygiene fixes, =--convert-subtasks=, =--archive-done=, =--sync-child-priority=) and leaves no copy behind. The two sibling tools that mutate the same files both do: =lint-org.el= copies to =/tmp/<basename>.before-lint-pass.<stamp>= and =wrap-org-table.el= does the equivalent. =cj-remove-block= joined them in 17f5d48. todo-cleanup is the outlier, and it is the one that runs most often — wrap-up calls it, and every sentry fire's pass 4 calls it three times (four runs tonight alone).
+
+Verified 2026-07-24: after a real =--convert-subtasks= mutation on a fixture, the directory holds only =todo.org=. Emacs's own backup mechanism does not fire under =--batch -q=, so there is genuinely no undo short of git, which recovers only to the last commit and loses intra-session work.
+
+Two things this is NOT, both checked so they don't get re-investigated:
+
+- The archive move is *fail-safe*, not lossy. It deletes subtrees from the buffer, writes the archive file, and only saves =todo.org= at the very end, so an archive-write failure aborts before the save. Verified by making the archive directory unwritable: exit 255, =todo.org= byte-identical, content intact. My initial hypothesis that a mid-move failure could lose a subtree from both files was wrong.
+- No error swallowing on the mutation path. The only =ignore-errors= in the file wrap =call-process "git"=, not any write.
+
+So this is a hardening gap rather than an active bug: a future defect in a mechanical rewriter would have no undo.
+
+Grading: Minor severity (no known active defect; the exposure is that any future one is unrecoverable within a session) x every user, every time (it runs on every wrap and every sentry fire) = P2 = [#C].
+
+Fix direction: back up before the first mutation, matching =lint-org.el='s convention exactly — =/tmp/<basename>.before-todo-cleanup.<YYYYMMDD-HHMMSS>=. One copy per invocation, not per pass, and skip it under =--check= (which writes nothing). Test by asserting the backup exists and holds the pre-edit content after a real mutation.
+** DONE [#C] claude-templates/bin/ gets no lint coverage at all :bug:quick:solo:
+CLOSED: [2026-07-24 Fri]
+:PROPERTIES:
+:LAST_REVIEWED: 2026-07-24
+:END:
+Fixed 2026-07-24 (sentry fire 8, f91feef). lint.sh now sweeps =claude-templates/bin/*= through =check_hook=, matching the extensionless-file shape =languages/*/githooks/*= already uses. Added =scripts/tests/lint-coverage.bats=, which pins the *coverage* rather than current cleanliness: it plants a broken file in each swept location and asserts lint.sh complains, so a location that silently stops being swept fails the suite. The broader question this surfaced — whether rulesets should run shellcheck on its own shell at all — is the VERIFY below and stays Craig's call.
+=scripts/lint.sh= sweeps =scripts/*.sh=, =languages/*/claude/hooks/*.sh=, and =languages/*/githooks/*= through =check_hook= (shebang present, executable bit set). It never touches =claude-templates/bin/=. Verified: zero references to that path in the file.
+
+Those four scripts — =ai=, =agent-text=, =agent-page=, =install-ai= — are the ones =make install= symlinks into =~/.local/bin=, so they run on Craig's PATH on every machine. They are the *most* exposed shell in the repo and the only shell with no gate over it.
+
+All four are clean today (shebangs present, mode 755, shellcheck-clean when run by hand), so nothing is broken. This is a missing gate, not an active defect: the =ai= launcher was hardened to 42 tests recently and that cleanliness is not enforced going forward.
+
+Grading: Minor severity (nothing broken now; the exposure is a future regression in a PATH-installed script going uncaught) x every user, every time (every =make lint= silently skips them) = P2 = [#C].
+
+Fix: add =claude-templates/bin/*= to the =check_hook= loop, the same shape =languages/*/githooks/*= already uses for extensionless files. A no-op today by design — it passes immediately — which is exactly what a guard should do.
+** DONE [#A] Applied: the secret-scan pre-commit fails open in ALL FIVE bundles (from .emacs.d)
+:PROPERTIES:
+:LAST_REVIEWED: 2026-07-24
+:END:
+CLOSED: [2026-07-24 Fri]
+Applied 2026-07-24 across all five bundles (11 sites: the secret-scan input in each, plus each bundle's staged-file list, typescript having two). Verified on all five: refuses when the diff can't be read, still blocks a real staged secret, still passes a clean commit. Adopted .emacs.d's elisp bats suite (8 tests) and extended the repo-level cross-bundle suite with two fail-closed assertions.
+
+Separate defect found while wiring that up: the cross-bundle suite's VARIANTS list read "elisp bash go" while python and typescript also shipped hooks, so every "in every variant" assertion had silently skipped two bundles since I added them. VARIANTS is now discovered from the tree rather than enumerated — the same failure class the tests exist to catch.
+
+What arrived: .emacs.d found that =languages/elisp/githooks/pre-commit= builds its scan input as =added_lines="$(git diff --cached -U0 ... | grep '^+' | grep -v '^+++' || true)"=. With no pipefail and =|| true= swallowing everything, any git failure yields an empty string, so the scan searches nothing, finds nothing, reports clean, and the commit proceeds with the secret in it. The staged-file list feeding the paren check has the identical hole.
+
+*Verified independently, and it is worse than reported.* I reproduced the fail-open with a stub git that fails only the staged-diff call: exit 0 with an AWS-shaped key staged. Then I checked the other bundles, which the sender did not: *bash, go, python, and typescript all carry the same pattern and all fail open the same way.* Confirmed live on all five. Two of those (python, typescript) are hooks I wrote on 2026-07-24 by copying the bash one, so I propagated the defect while closing a different gap.
+
+Graded [#A] on severity alone, per the todo-format security carve-out: a credential-scanning gate that reports clean without having looked is a showstopper regardless of how rarely git fails.
+
+The sender's fix is correct and I verified all three axes on it: refuses to proceed when the diff cannot be read (exit 1), still blocks a real staged secret (exit 1), still passes a clean commit (exit 0). It splits the git read from the greps so a git failure aborts while "grep matched nothing" stays the ordinary case.
+
+Decision needed: the fix as sent covers elisp only. It should be applied to all five bundles, which is my scope expansion rather than the sender's proposal — hence a VERIFY rather than a silent apply. Also unresolved: where the two attached bats suites live, since the hooks are rulesets-owned and the tests currently sit in the consuming project.
+
+Prepared: [[file:working/hook-fail-open/pre-commit.diff]], plus =test-pre-commit-hook.bats= (8 tests) in the same dir.
+** DONE [#B] Applied: remove the validate-el auto-test cap (from .emacs.d, Craig's call)
+:PROPERTIES:
+:LAST_REVIEWED: 2026-07-24
+:END:
+CLOSED: [2026-07-24 Fri]
+Applied 2026-07-24. Cap and the unreachable notice helper removed; the gate is now count -ge 1. Adopted the rewritten bats suite (6 tests), with its hook path corrected from the installed .claude/hooks/ layout to the bundle's claude/hooks/ — the re-homing mismatch the sender flagged.
+
+What arrived: a superseding handoff. The first proposed a loud notice when the test count exceeds =MAX_AUTO_TEST_FILES=20= (above the cap the block was skipped, nothing printed, exit 0 — indistinguishable from a pass). Craig chose in the .emacs.d session to remove the cap instead.
+
+The reasoning is the valuable part. Measured, a whole family runs in under two seconds (calendar-sync 63 files / 633 tests / 0.9s), so the cap bought nothing. Worse, it was *concealing* a real cross-test pollution bug: calendar-sync exits 1 when its 63 files run in one process, because one test marks a calendar as syncing and never resets it, and a sibling file's test then hits the stale guard. Invisible from both directions — =make test= runs each file in its own Emacs, and the hook skipped the family for being over the cap.
+
+Verified the superseding file drops the cap entirely (zero references, gate is now =count -ge 1=) and removes the now-unreachable notice helper.
+
+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.
+** DONE [#C] sentry.org calls one loop cycle a "fire" :chore:solo:
+CLOSED: [2026-07-28 Tue]
+:PROPERTIES:
+:LAST_REVIEWED: 2026-07-28
+:END:
+Done 2026-07-28. 72 noun-sense instances in =sentry.org= became "cycle"; the four verb-sense uses stayed. Three more lived outside the file — =wrap-it-up.org= ("a crashed fire"), =todo-cleanup.el= and its test ("every sentry fire") — all naming a sentry cycle, so they moved too. home's proposal was scoped to =sentry.org= alone, so the leak would have split the vocabulary across files.
+
+Craig read home's "nine fires" as nine emergencies and went looking for what was burning (2026-07-28): "I assume you mean nine crises, not nine loop cycles and I begin to get scared." The term reaches him directly — digest headings render as =** Fire 11 — 08:32 CDT= in the anchor he reads every morning. 35 instances in =sentry.org=.
+
+Grading: Minor severity (a user-facing artifact that miscommunicates, no data loss) x most users frequently (every digest, on every project running sentry) = P3 = [#C].
+
+*Take the problem, not home's proposed term.* home proposed "pass", reasoning that it already lives in the file's vocabulary. That is exactly what disqualifies it. =sentry.org= already uses "pass" as a precise numbered noun: "the pass list", "the Pass Runner", "eleven finding/hygiene passes", "pass 12" for the implementation pass. There are exactly eleven hygiene passes, so home's proposed digest heading =** Pass 11= collides with an existing real referent. Renaming would trade a term Craig misreads as urgent for one that is genuinely ambiguous.
+
+Counter-proposal: *cycle*. It appears zero times in =sentry.org=, so there is nothing to collide with. It is also Craig's own word from the very quote that surfaced this ("not nine loop cycles"). =** Cycle 11 — 08:32 CDT= reads cleanly. Checked and rejected: "sweep" (already used for hygiene and property sweeps) and "run" (already used as a noun, "first live run").
+
+Keep the verb sense of fire throughout ("the notify fires", "the path never fires") — only the noun meaning one loop cycle changes. Past session anchors are historical records and stay as written.
+
+Craig approved "cycle" on 2026-07-28. That settles the only judgment the task carried, so it is =:solo:= now: the surface is the 35 noun-sense instances in =sentry.org=, and the completion check is objective — zero noun-sense "fire" left, verb sense untouched, lint clean, suite green, canonical and mirror in sync.
+
+Source: home handoff, 2026-07-28.
+** DONE [#B] references/ is linked from protocols.org but never synced :bug:
+CLOSED: [2026-07-28 Tue]
+:PROPERTIES:
+:LAST_REVIEWED: 2026-07-28
+:END:
+Craig picked option 1 on 2026-07-28: drop the link, point at the calendar workflows instead. The four of them sync already and carry the MCP tool names, both account ids, the gcalcli fallback, and the conflict-check discipline — everything the reference was cited for.
+
+The adversarial review then returned =Needs Discussion= and widened the fix twice, both correctly:
+
+- My first replacement said credentials "live in the rulesets repo" without naming a file. The only calendar-named document there was =calendar-reference.org=, whose three credential paths have all been dead since the OAuth keys moved into the encrypted MCP bundle in May. So the prose sent a reader to a stale file, which fails more quietly than the dead link it replaced. Now names =mcp/README.org=, the real authority, verified to document =gcp-oauth.keys.json= as gitignored and regenerated at install.
+- =calendar-reference.org= was left orphaned by the link removal: zero live inbound references, two copies, and =references/= is not in =sync-check.sh='s gate (=paths=(protocols.org workflows scripts)=), so the copies could drift silently. Deleted both, and the empty =references/= directories with them. Its operational content is fully covered by the four workflows and its credential paths were all stale, so nothing live was lost.
+
+The review also found the same defect class at seven other sites, filed separately.
+
+One fact died with the file, dropped deliberately rather than by accident: that the Google Cloud app runs in production mode, so tokens don't expire after seven days. It's checkable in the console, and it was the last live line in a document whose other credential facts had all gone stale.
+
+=protocols.org:273= links to =references/calendar-reference.org=, but startup.org's rsync copies only =protocols.org=, =workflows/=, and =scripts/=. So the link is dead in every consuming project. Confirmed: the file exists at =claude-templates/.ai/references/=, and neither home nor =.emacs.d= has a =.ai/references/= at all.
+
+Grading: Minor severity (a documented reference an agent can't follow, workaround is to search or ask) x every user every time (every consuming project, every sync) = P2 = [#B].
+
+Pinned 2026-07-28 for Craig's decision. Analysis below is complete; only the choice is open.
+
+*Revised recommendation: drop the link, don't add the sync.* My first read was add =references/= to the rsync. Looking at what the directory actually holds reversed it:
+
+- =references/= holds exactly one file, and =protocols.org:273= is its only citation anywhere in the tree.
+- The four calendar workflows (=add-=, =edit-=, =delete-=, =read-calendar-event(s).org=) already sync to every project, and already carry the operational detail: the MCP server name, both account IDs, the gcalcli fallback, conflict-checking. None of them cites =calendar-reference.org=; they are self-sufficient.
+- What the reference uniquely adds is credential *file locations* (an OAuth keys path, a GPG-encrypted gcalcli secret) — no secret values. Re-auth is a rare operation Craig performs himself, not something an agent in another project needs a pointer to.
+
+So adding a synced directory carrying =--delete= semantics, to deliver one mostly-redundant file, is a poor trade. It also cuts against the rightsizing work: =protocols.org= is read into context every session, and a dead link is noise in a file being slimmed.
+
+Preferred fix: replace the link with a one-line pointer to the calendar workflows, which travel and are current. The reference file stays rulesets-only for the credential locations.
+
+Alternatives if Craig prefers: add =references/= to the rsync (the =--delete= hazard is not present today — work is the only project with a =.ai/references/= and its copy is byte-identical), or fold the credential locations into the calendar workflows and then drop the link (most complete, but spreads local absolute paths across four more synced files).
+
+Not =:solo:= — it changes a synced template, which needs Craig's approval by the inbox rule, and the sync-vs-drop choice is his.
+
+Source: winvm link-integrity pass, 2026-07-28.
+** DONE [#B] Parked: telegram source treats "down" as launch, not SCAN FAILED (from .emacs.d)
+CLOSED: [2026-07-28 Tue]
+:PROPERTIES:
+:LAST_REVIEWED: 2026-07-24
+:END:
+Applied 2026-07-28, merged with the segfault root-cause fix that arrived the same morning rather than applied alone.
+
+Merging was necessary, not tidiness. The parked proposed file still carried the bad =(telega--loadChats 'main)= call at its own lines 52 and 122, so applying it as-is would have shipped a file that fixed the wording defect while preserving the call that kills the server. Its third hunk also added prose citing "tdlib segfaults in native mode (SEGFAULT gotcha below)" — pointing at the section the new handoff rewrites to say those deaths were our own bad argument, not tdlib memory corruption.
+
+What landed: all three parked hunks (the down-is-launch directive, the SCAN-FAILED-only-after-launch-attempted rewording, the =(setq telega-use-docker t)= restored to the Step 1 code block), plus both corrected =loadChats= call sites and the rewritten gotcha. The native-mode prose was reconciled in two places so it no longer leans on the refuted story: the Step 1 comment now states plainly that docker mode and the loadChats bug are separate concerns (the deaths happened *in* docker mode, so docker mode is neither a defense against it nor evidence for it), and the Quick Reference line says "crashed in native mode (2026-06-09)" instead of "segfaults", with the same disambiguation.
+
+Verified: both live call sites use the TL object; the two remaining ='main= occurrences are inside the gotcha prose describing the bug. lint-org clean, mirror synced, suite green.