aboutsummaryrefslogtreecommitdiff
path: root/.ai
diff options
context:
space:
mode:
authorCraig Jennings <c@cjennings.net>2026-07-25 15:35:14 -0500
committerCraig Jennings <c@cjennings.net>2026-07-25 15:35:14 -0500
commitf2609d9f9ad33486bef43211d753ba53e1e24181 (patch)
tree561f0514866570d47c446816daa131ce13064639 /.ai
parent3203bd803b6a05d10781634e7e18742879c1046a (diff)
downloadrulesets-f2609d9f9ad33486bef43211d753ba53e1e24181.tar.gz
rulesets-f2609d9f9ad33486bef43211d753ba53e1e24181.zip
feat: enforce clean wraps and inbox-safe syncHEADmain
Centralize repository-state checks, bind teardown to a certified clean HEAD, and allow inbox-only refreshes. Guard installed symlinks from cross-project writes and add regression coverage.
Diffstat (limited to '.ai')
-rw-r--r--.ai/notes.org2
-rw-r--r--.ai/protocols.org7
-rw-r--r--.ai/sessions/2026-07-25-15-34-clean-wrap-and-inbox-safe-sync.org85
-rw-r--r--.ai/workflows/startup.org27
-rw-r--r--.ai/workflows/wrap-it-up.org58
5 files changed, 149 insertions, 30 deletions
diff --git a/.ai/notes.org b/.ai/notes.org
index d53f8e0..447c33c 100644
--- a/.ai/notes.org
+++ b/.ai/notes.org
@@ -83,6 +83,6 @@ Format:
Markers maintained by workflows to record when they last ran. Read by other workflows that gate their behavior on freshness.
:LAST_AUDIT: 2026-07-20 (open set current — this session's shipped work (working/temp, triage-source-activation, silent-until-signal, suspend detach) closed as it went; sentry cluster consolidated (merged the /schedule tasks, added cross-host-coordination); nothing shipped-but-open per git reconcile. Live finding: the Polyglot + Subprojects scouting tasks are SCHEDULED 2026-07-20 and due.)
-:LAST_INBOX_PROCESS: 2026-07-20 (morning sentry review + build session: triage migration confirmations from home + work processed; earlier 2026-07-18 pass folded in)
+:LAST_INBOX_PROCESS: 2026-07-25 (consolidated home + work Claude-to-Codex MCP registry proposals into one [#B] parked spec decision; memory auditor split from the registry work)
Format: one =:MARKER: YYYY-MM-DD= line per workflow. Workflows overwrite their own marker on completion.
diff --git a/.ai/protocols.org b/.ai/protocols.org
index 3cd02ef..2ea041b 100644
--- a/.ai/protocols.org
+++ b/.ai/protocols.org
@@ -84,7 +84,7 @@ Do NOT estimate, guess, or rely on memory. Just run the command. It takes one se
Every session pulls rulesets first, then the local project repo. Rulesets carries the canonical behavioral rules and =.ai/= templates (the old =claude-templates= repo is folded in as a subtree at =rulesets/claude-templates/=); the project pull lands commits pushed from other machines or teammates since the last session.
-Resolve any dirty-tree or merge issue at each step before moving on. Both pulls run as =git pull --ff-only= (or =git merge --ff-only= against the fetched upstream), so anything non-trivial — non-fast-forward history, dirty working tree, diverged branches — aborts. Surface the state and stop. Never auto-stash, auto-merge, or auto-rebase; the user resolves the conflict before further work.
+Resolve any sync-blocking tree or merge issue at each step before moving on. The shared =git-worktree-gate sync-safe= policy permits untracked deliveries beneath =inbox/= so receiving a handoff never prevents another project from refreshing rulesets; every staged or tracked change, dirty submodule, Git operation in progress, or untracked path outside =inbox/= blocks. Both pulls run as =git pull --ff-only= (or =git merge --ff-only= against the fetched upstream), so non-fast-forward history and diverged branches also abort. Surface the state and stop. Never auto-stash, auto-merge, or auto-rebase; the user resolves the conflict before further work.
Mechanics live in =startup.org= Phase A.0. The rule lives here because it governs the very first action of every session: load the freshest behavioral rules and templates before anything else runs.
@@ -567,12 +567,13 @@ When monitoring a long-running process (rsync, large downloads, builds, VM tests
** "Wrap it up" / "That's a wrap" / "Let's call it a wrap"
-When Craig says any of these phrases (or variations), execute the wrap-up workflow: [[file:workflows/wrap-it-up.org][wrap-it-up.org]]. Four steps:
+When Craig says any of these phrases (or variations), execute the wrap-up workflow: [[file:workflows/wrap-it-up.org][wrap-it-up.org]]. Five load-bearing steps:
1. *Finalize the Summary* in =.ai/session-context.org= (populate the 5 subsections from the Session Log)
2. *Rename* =.ai/session-context.org= → =.ai/sessions/YYYY-MM-DD-HH-MM-description.org=
3. *Git commit + push* to all remotes (see Git Commit Requirements)
-4. *Valediction* — brief, warm, specific closing
+4. *Certify the clean tree* with =git-worktree-gate certify=. Any remaining staged, unstaged, untracked, submodule, or in-progress-operation state blocks wrap entirely; report each path and the exact decision needed. There is no dirty-file deferral.
+5. *Valediction* — brief, warm, specific closing, reachable only after certification
The absence of =.ai/session-context.org= after wrap-up is the signal that the session ended cleanly. If the file is still there at the next session start, the previous session was interrupted.
diff --git a/.ai/sessions/2026-07-25-15-34-clean-wrap-and-inbox-safe-sync.org b/.ai/sessions/2026-07-25-15-34-clean-wrap-and-inbox-safe-sync.org
new file mode 100644
index 0000000..737b1c5
--- /dev/null
+++ b/.ai/sessions/2026-07-25-15-34-clean-wrap-and-inbox-safe-sync.org
@@ -0,0 +1,85 @@
+#+TITLE: Session Context — 2026-07-25
+#+AUTHOR: Craig Jennings
+
+* Summary
+
+** Active Goal
+
+Guarantee that rulesets can only report a successful wrap with a completely clean Git worktree, while allowing other projects to refresh rulesets when its only residue is untracked inbox deliveries. Implemented, reviewed, fully tested, and prepared for a strict self-hosted wrap.
+
+** Decisions
+
+- One executable, =git-worktree-gate=, owns both repository-state policies. =strict= means no staged, unstaged, untracked, dirty-submodule, or in-progress-operation state; =sync-safe= permits only untracked paths beneath =inbox/=.
+- Cleanup failure is not a degraded wrap. It leaves the session open and must report each path, its Git state, why it cannot be resolved safely, and the decision Craig needs. Dirty-file deferrals, valediction, and teardown are forbidden in that state.
+- Final wrap verification is a HEAD-bound certificate stored in the Git directory and freshly rechecked inside the existing teardown hook. Integrating the check avoids the concurrent-hook race documented by Codex.
+- Another project's structured Edit/Write call may not resolve through an installed symlink into rulesets. The runtime hook denies it and points the sender to =inbox-send rulesets=.
+- The two MCP-registry handoffs were consolidated into one parked =[#B]= specification decision. The memory auditor remains separate; no machine-owned MCP configuration was promoted into canonical rulesets.
+
+** Data Collected / Findings
+
+- The prior rulesets archive completed at 09:24; the inherited three tracked changes were written at 10:27. The repository was dirtied after wrap through a later write path, which is why wrap certification alone needed the symlink-aware boundary guard.
+- The previous wrap prose contradicted itself: clean Git state was an exit criterion, but the leftover and inbox sections allowed explicit deferral. The teardown hook checked only the sentinel.
+- Adversarial review found a fail-open process-substitution edge: a low-level =git status= failure could appear as an empty stream. The gate now captures status and its exit code in Git-directory temporary files and blocks on failure.
+- Current Codex hooks support Stop blocking with =continue=false= and run matching commands concurrently. A user-level =hooks.json= is installed; a new Codex session must complete the normal hook review/trust step.
+- Full =make test= passed twice on the implementation. The final run includes 436 core Python tests, 72 hook tests, language suites, ERT suites, and all Bats suites. Focused additions cover inbox-only pulls, staged/unstaged/untracked/submodule/operation states, status failure, certificate/HEAD drift, Claude and Codex Stop outputs, installation, and realpath-based write denial.
+- The wrap roam sweep found Craig's 120-column table question. It was already enforced by =org-tables.md=, =lint-org='s =org-table-standard= judgment, and =wrap-org-table.el=; the live lint pass flagged the existing over-wide table. Removed the duplicate capture and synced roam.
+
+** Files Modified
+
+- =claude-templates/bin/git-worktree-gate= — shared strict/sync-safe classifier plus certificate/verify modes.
+- Startup protocol/workflow mirrors and =claude-templates/bin/ai= — inbox-only state remains visible but no longer blocks fast-forward refresh.
+- Wrap protocol/workflow mirrors and =hooks/ai-wrap-teardown.sh= — no deferral escape, actionable hard blocker, final certificate, fresh teardown verification.
+- =hooks/rulesets-write-boundary.py=, Claude/Codex hook configuration, cross-project rule, Makefile, and hook documentation — prevent structured writes through installed symlinks and install the enforcement on both runtimes.
+- Bats and pytest suites — repository-state, launcher, teardown, installer, and cross-project boundary regressions.
+- =todo.org= and workflow state — parked the consolidated MCP registry spec decision and recorded inbox processing.
+- Three inherited backlog files — retain the post-09:24 definition that speedrunnable means =:solo:=.
+
+** Next Steps
+
+- Start a new Codex session and review/trust the new user-level hooks when =/hooks= prompts; Claude already reads the linked hook configuration.
+- Say "spec the MCP registry sync" when ready to design the separate host-level registry reconciler.
+- The existing =inbox/lint-followups.org= pipeline retains its 14 current judgment items, including the over-wide table and older missing review stamps/links; they do not represent uncommitted work after this wrap.
+
+KB: promoted 0 / consulted no
+
+* Session Log
+
+** Startup and clean-worktree investigation
+
+Ran the required startup workflow. The canonical rulesets pull and template sync were blocked by three tracked modifications; two untracked inbox handoffs were also pending. Read the project and global behavioral rules, recent session archive, wrap workflow, relevant hook and launcher code, and the current Codex MCP and hook documentation needed to evaluate the inbox proposals.
+
+Investigated Craig's requirement that rulesets finish with an absolutely clean worktree while still allowing downstream projects to sync when rulesets has received inbox deliveries. The current wrap workflow states a clean exit criterion but later permits explicitly deferred dirty files, and its teardown hook checks only the wrap sentinel rather than Git state. The startup shell's tracked-change check already ignores untracked inbox files, but the general launcher dirty check does not distinguish inbox deliveries from other untracked residue.
+
+The three tracked files now dirty were written at 10:27, after the latest archived rulesets session wrapped at 09:24. That establishes a post-wrap write path: a strict wrap gate can guarantee the state at completion, but preventing later contamination also needs a realpath-aware cross-project write guard because globally installed rules and workflows are symlinks into this repository.
+
+The proposed design is one shared repository-state classifier with two policies. Strict wrap requires no staged, unstaged, or untracked entries and no dirty submodules. Inbox-safe sync permits an otherwise clean tracked/index state with untracked entries only below =inbox/=; those deliveries do not block pull or template sync. The strict check should run after the final push and again inside the ordered teardown hook, tied to the checked HEAD, while the inbox-safe policy should drive both startup and the =ai= launcher. Tests should cover every Git state, unusual path names, inbox-only sync, hook behavior, and the canonical/template mirrors.
+
+Two pending inbox handoffs both propose a shared Claude-to-Codex MCP registry mirror. They pass the value gate but overlap. The recommendation is to consolidate them into one =[#B]= specification with Codex-only entries preserved, atomic and redacted updates, dependency and health checks, both-machine verification, and the Claude-memory audit split into a separate task. No inbox disposition or project implementation has been applied.
+
+Completed the investigation plan without changing product code. The worktree proposal is ready for Craig's approval; implementation, tests, and inbox filing remain deliberately pending.
+
+** Clean-wrap invariant clarified
+
+Craig confirmed that cleanup failure must prevent wrap-up entirely. The agent must keep the session open and report exactly what remains in the Git worktree, why it could not resolve each item safely, and the specific action or decision Craig needs to supply. A warning, deferred-file exception, valediction, archived-as-complete status, or teardown is not an acceptable substitute for a clean tree.
+
+** Clean-wrap enforcement implemented
+
+Craig approved implementation and asked for a full wrap when it is done. Added =git-worktree-gate= as the single policy executable: strict mode rejects every staged, unstaged, untracked, dirty-submodule, and in-progress-operation state; sync-safe mode permits only untracked =inbox/= deliveries. Certificate and verify modes bind the final clean check to HEAD inside the Git directory.
+
+Wired sync-safe behavior into both startup workflow copies and the =ai= launcher. The picker labels inbox-only state distinctly and now fast-forwards a behind repository with inbox deliveries present while refusing other untracked residue.
+
+Made wrap cleanup fail closed: removed every dirty-file and inbox deferral escape, added the post-push clean certificate as a hard prerequisite to valediction, and required an exact path/state/needed-decision report when cleanup cannot finish. The existing teardown hook now freshly verifies the certificate and HEAD before consuming either sentinel, emits the runtime-appropriate Claude or Codex Stop blocker, and leaves the sentinel/session intact on failure. Added global Codex hook configuration and installed its symlink; Codex will require its normal hook review/trust on a new session.
+
+Added =rulesets-write-boundary.py= and configured Claude and Codex Edit/Write hooks. It resolves targets through symlinks and denies another project's write when the real path lands in rulesets, directing the proposal through =inbox-send rulesets=. The cross-project rule now states the installed-symlink case explicitly.
+
+Focused verification is green: 10 state-gate Bats cases, 13 teardown-hook cases including dirty/changed-HEAD/missing-certificate and both runtime outputs, 36 launcher cases including inbox-only pull, 5 installer cases, and 5 Python write-boundary cases.
+
+** Inbox — MCP registry proposals consolidated
+
+Processed the two pending 2026-07-25 handoffs from work and home. They were duplicate evidence for a host-level Claude-to-Codex MCP registry reconciler, not project-level implementation requests. Filed one =[#B]= parked specification decision in =todo.org= with preservation, redaction, atomicity, transport, health-check, two-machine, malformed-input, and token-rotation gates; split the memory auditor into separate future work. Deleted both inbound handoffs, stamped =:LAST_INBOX_PROCESS:=, sent acknowledgements to both source projects, and verified zero pending project handoffs.
+
+** Review, full verification, and wrap cleanup
+
+The first full =make test= run passed. Adversarial self-review then found the Git-status process-substitution fail-open and a path-resolution fail-open in the cross-project hook; fixed both, added status-error and sequencer regressions, aligned =protocols.org= with the new policies, and reran the complete suite successfully.
+
+Executed wrap cleanup: no sentry lock, todo hygiene/convert/archive/priority passes made no changes, lint-org applied zero mechanical changes and refreshed the 14-item judgment pipeline, project inbox remained at zero, route-batch had no candidates, and 30-day staleness was zero. The shared roam inbox held one rulesets capture about 120-column tables; verified the enforcement already exists in three layers, removed the duplicate, and pushed the roam update with the repository's sync helper.
diff --git a/.ai/workflows/startup.org b/.ai/workflows/startup.org
index 929d482..1dff1db 100644
--- a/.ai/workflows/startup.org
+++ b/.ai/workflows/startup.org
@@ -29,10 +29,16 @@ Inside a rulesets session, the project-repo refresh below covers this — the ru
#+begin_src bash
rs="$HOME/code/rulesets"
if [ -d "$rs/.git" ]; then
- if (cd "$rs" && git diff --quiet --ignore-submodules HEAD -- 2>/dev/null); then
+ gate="$rs/claude-templates/bin/git-worktree-gate"
+ if [ -x "$gate" ] && "$gate" sync-safe "$rs"; then
+ (cd "$rs" && git pull --ff-only origin main 2>&1) | tail -3
+ elif [ ! -x "$gate" ] \
+ && (cd "$rs" && git diff --quiet --ignore-submodules HEAD -- 2>/dev/null); then
+ # Bootstrap fallback for a checkout old enough not to have the shared
+ # gate yet. The pull that follows installs it for subsequent starts.
(cd "$rs" && git pull --ff-only origin main 2>&1) | tail -3
else
- echo "rulesets: dirty working tree — using as-is, skipping pull"
+ echo "rulesets: changes beyond untracked inbox deliveries — using as-is, skipping pull"
fi
else
echo "rulesets: not a git checkout — skipping"
@@ -40,11 +46,11 @@ fi
#+end_src
Behavior:
-- *Clean working tree* → fast-forward pull. =git pull --ff-only= refuses any merge or rebase, so the operation is either a no-op (already current) or a clean advance.
-- *Dirty working tree* → skip the pull. Don't auto-stash and don't auto-merge — those would either lose work or invite conflicts at the worst possible moment (session start).
+- *Clean working tree, or untracked deliveries only beneath =inbox/=* → fast-forward pull. =git pull --ff-only= refuses any merge or rebase, so the operation is either a no-op (already current) or a clean advance. Inbox files are queue input, not source-tree work, and do not block other projects from receiving rulesets updates.
+- *Any staged or tracked change, dirty submodule, Git operation in progress, or untracked file outside =inbox/=* → skip the pull. Don't auto-stash and don't auto-merge — those would either lose work or invite conflicts at the worst possible moment (session start).
- *Non-fast-forward history* → =--ff-only= aborts with an error. Surface that to the user; the rsync still proceeds against the working tree as-is.
-*Template-freshness policy (applies to every dirty-check in the synced workflows).* "Dirty" means *tracked modifications only*. Untracked and gitignored files — an inbox drop, a file left in the tree to read, scratch output — never block a template pull, a fast-forward, or a monitoring gate. Projects were falling behind on templates because somebody sent them a task; that's the failure this policy closes. The checks here already comply (=git diff --quiet HEAD= sees only tracked changes; the ff gate uses =--untracked-files=no=), and any dirty-check added to a synced workflow follows the same rule. One deliberate exception: the rsync WIP-guard below counts untracked files *within rulesets' own synced source paths*, because an untracked half-written template is exactly the WIP it exists to hold back — that guard is about rulesets' outbound content, not the consuming project's local state.
+*Template-freshness policy (applies to every dirty-check in the synced workflows).* The shared =git-worktree-gate sync-safe= policy is the source of truth: untracked files beneath =inbox/= and gitignored files do not block a pull, fast-forward, or monitoring gate; every other staged, tracked, untracked, submodule, or in-progress-operation state does. Projects must not fall behind merely because somebody sent them a task, but an arbitrary scratch file is not silently treated as safe. One deliberate exception remains: the rsync WIP-guard below is narrower than the repository gate and counts untracked files within rulesets' own synced source paths, because an untracked half-written template is exactly the WIP it exists to hold back.
*** Install rulesets symlinks into ~/.claude (idempotent)
@@ -74,8 +80,11 @@ if [ -d .git ]; then
current=$(git symbolic-ref --short HEAD 2>/dev/null)
dirty=0
- if ! git diff --quiet --ignore-submodules HEAD -- 2>/dev/null \
- || [ -n "$(git status --porcelain --untracked-files=no)" ]; then
+ gate="$HOME/code/rulesets/claude-templates/bin/git-worktree-gate"
+ if [ -x "$gate" ]; then
+ "$gate" sync-safe "$PWD" >/dev/null 2>&1 || dirty=1
+ elif ! git diff --quiet --ignore-submodules HEAD -- 2>/dev/null \
+ || [ -n "$(git status --porcelain --untracked-files=no)" ]; then
dirty=1
fi
@@ -107,8 +116,8 @@ fi
#+end_src
Behavior, per branch:
-- *Behind only, current branch, clean tree* → =git merge --ff-only= advances HEAD.
-- *Behind only, current branch, dirty tree* → fetched but not advanced. Surface so Craig can ff manually after dealing with the dirty state.
+- *Behind only, current branch, sync-safe tree* → =git merge --ff-only= advances HEAD. An untracked =inbox/= delivery is sync-safe.
+- *Behind only, current branch, sync-blocking tree* → fetched but not advanced. Surface so Craig can ff manually after dealing with the reported state.
- *Behind only, non-checkout branch* → =git fetch . upstream:branch= advances the ref without touching the working tree.
- *Diverged* (ahead and behind) → leave alone. Surface for Craig to resolve. Don't auto-rebase or auto-merge.
- *Ahead only* or *up to date* → silent no-op.
diff --git a/.ai/workflows/wrap-it-up.org b/.ai/workflows/wrap-it-up.org
index 103e225..7d55a55 100644
--- a/.ai/workflows/wrap-it-up.org
+++ b/.ai/workflows/wrap-it-up.org
@@ -24,7 +24,7 @@ The wrap-up is complete when:
2. *File is archived.* =.ai/session-context.org= has been renamed to =.ai/sessions/YYYY-MM-DD-HH-MM-description.org=. The old path no longer exists.
3. *todo.org is clean.* Cleanup script ran. Any auto-fixes are staged for the wrap-up commit. Orphan planning lines surfaced for manual fix if there are any.
4. *Linear board is honest* (skip if project doesn't use Linear). Any Dev-Review ticket whose PR has merged was moved to Done or PM Acceptance per the classification rule.
-5. *Git state is clean.* All changes committed + pushed to all remotes. Working tree clean.
+5. *Git state is certified clean.* All changes are committed + pushed to all remotes, =git-worktree-gate certify= succeeded at the current HEAD, and the working tree has no staged, unstaged, untracked, submodule, or in-progress-operation state.
6. *Valediction delivered.* Brief, warm closing with key accomplishments and reminders, ending with =session wrapped.= on its own line as the signoff marker.
The absence of =.ai/session-context.org= is the signal that the last session wrapped up cleanly. Its presence at session start means the previous session was interrupted.
@@ -273,7 +273,7 @@ For an interactive walk of the judgments mid-day, run =/lint-org todo.org=.
*** Inbox sanity check (surface unprocessed handoffs)
-If the project has an =inbox/= directory, verify it holds nothing but =.gitkeep=, =lint-followups.org= (the lint-org pipeline file the next morning's daily-prep consumes), and any explicitly-deferred =PROCESSED-*= files before the wrap completes. An inbox that arrived at session start with handoffs from other projects, or that received handoffs mid-session, needs =inbox.org= process mode to run and apply its value-gate dispositions. Wrapping with a dirty inbox silently defers the work to next session and accumulates handoff debt that the sender can't see.
+If the project has an =inbox/= directory, verify it holds nothing but =.gitkeep=, =lint-followups.org= (the lint-org pipeline file the next morning's daily-prep consumes), and =PROCESSED-*= files before the wrap completes. An inbox that arrived at session start with handoffs from other projects, or that received handoffs mid-session, needs =inbox.org= process mode to run and apply its value-gate dispositions. Wrapping with an unprocessed inbox silently defers the work to next session and accumulates handoff debt that the sender can't see.
#+begin_src bash
unprocessed=$(find inbox -maxdepth 1 -type f \
@@ -282,7 +282,7 @@ unprocessed=$(find inbox -maxdepth 1 -type f \
! -name 'PROCESSED-*' \
2>/dev/null | wc -l)
if [ "$unprocessed" -gt 0 ]; then
- echo "wrap-up: inbox/ has $unprocessed unprocessed item(s). Run inbox.org process mode before wrapping, or explicitly defer each item with a one-line reason in the valediction."
+ echo "wrap-up blocked: inbox/ has $unprocessed unprocessed item(s). Run inbox.org process mode before wrapping."
find inbox -maxdepth 1 -type f \
! -name '.gitkeep' \
! -name 'lint-followups.org' \
@@ -291,7 +291,7 @@ if [ "$unprocessed" -gt 0 ]; then
fi
#+end_src
-If the count is zero or the project has no =inbox/= directory, the check is a silent no-op. If non-zero, the wrap is incomplete by default. The user resolves each item (process now, defer with reason in the valediction, or delete with rationale) before the validation checklist passes.
+If the count is zero or the project has no =inbox/= directory, the check is a silent no-op. If non-zero, the wrap is blocked. Process each item through its value-gate disposition, or delete it only when that workflow's rationale authorizes deletion, before continuing.
The check exempts =lint-followups.org= explicitly because lint-org runs earlier in the same wrap-up workflow and writes its judgment items to that file in =inbox/= by design. The file is a pipeline artifact for the next morning's =daily-prep=, not a handoff that needs the value gate.
@@ -501,17 +501,17 @@ Behavior:
git status --short
#+end_src
-*Default policy: end every session with an empty =git status=.* The wrap is incomplete while anything remains dirty. There is no "leave it alone" default — every leftover gets an active resolution. The only way for a file to stay dirty across the wrap is the user explicitly saying "defer this one, leave it dirty." Surface each leftover with a concrete recommendation; the user has to actively opt out for the dirt to persist.
+*Hard policy: end every session with an empty =git status=.* The wrap is incomplete while anything remains dirty. There is no deferral exception and no "wrapped with known changes" state: unresolved dirt means the session remains open and wrap-up does not occur.
This inverts the older "intentional carryover" default, which let pre-existing dirty state accumulate across sessions silently. Carryover that lives for days or weeks is almost always one of: a forgotten commit from a prior wrap, a stale change that should be discarded, or genuine in-flight work that needs an explicit stash/branch home. None of those should default to "leave it dirty."
**** Three kinds of leftover
-| Pattern | What it is | Recommended action (apply unless user defers) |
+| Pattern | What it is | Recommended action |
|---+---+---|
| Generated, runtime, or lock files that no human edits — e.g., =.claude/scheduled_tasks.lock=, =.pytest_cache/=, build outputs, IDE state, editor swap files | *Runtime artifact* — created by tooling or the harness, not by the user, and shouldn't be tracked | Add the matching pattern to =.gitignore= (project-level, not =~/.gitignore_global=). For tracked files, =git rm --cached <path>=. Stage =.gitignore= and any =rm --cached= changes in *one* follow-up commit (=chore: gitignore X=), push. Re-run =git status= to confirm clean. |
| Modified or created during the session but not staged into the wrap-up commit | *Forgotten change* — real session work that should have been in the wrap commit but missed it | Stage and create a follow-up commit. Don't =--amend= the wrap-up commit once pushed (diverging history without a clear win). Push the follow-up to all remotes. |
-| Was dirty at session start and still dirty at session end — work this session deliberately didn't touch | *Pre-existing dirt that needs a decision* — could be a missed commit from a prior wrap, stale abandoned work, or real in-flight work without a home | Investigate (show diff + check the originating session). Recommend one of: (a) commit now if the work is complete, (b) stash with a descriptive message if it's genuine WIP, (c) =git checkout -- <path>= / =git clean -f <path>= if stale and unwanted, (d) move to a feature branch if it's longer-running, (e) user explicitly defers and accepts the dirt. Do not silently leave dirty. |
+| Was dirty at session start and still dirty at session end — work this session deliberately didn't touch | *Pre-existing dirt that needs a decision* — could be a missed commit from a prior wrap, stale abandoned work, or real in-flight work without a home | Investigate (show diff + check the originating session). Recommend one of: (a) commit now if the work is complete, (b) stash with a descriptive message if it's genuine WIP, (c) =git checkout -- <path>= / =git clean -f <path>= if stale and unwanted, or (d) move to a feature branch if it's longer-running. Do not silently leave dirty. |
**** Per-file flow
@@ -519,18 +519,40 @@ For each leftover line in =git status --short=:
1. Identify which of the three kinds above it matches.
2. State what the file is (one line) and the recommended action.
-3. Apply the action unless the user explicitly defers.
-4. Re-run =git status --short= after each follow-up commit until empty (or until every remaining line is an explicit user-deferred entry).
+3. Apply the action when it is safe and authorized.
+4. Re-run =git status --short= after each follow-up commit until empty.
The pre-existing-dirt case (third row) is the one this rule most cares about. Treat each pre-existing-dirty file as a question that must get an answer this session, not as "carryover that's fine to inherit." A file that was dirty for a week before this session probably isn't going to get cleaner by waiting another week. Look at the diff, check the originating session's notes, and recommend a real resolution.
-**** When the user defers
+**** When cleanup cannot be completed
-If the user does say "leave this one dirty for now" after seeing the recommendation, that is fine — log the deferral in the valediction so the next session knows it was an explicit choice, not a miss. Format: "Deferred (per Craig's decision today): =path/to/file= — <one-line reason>". Without that note, the next session can't distinguish "we agreed to defer" from "we forgot again."
+Stop the wrap. Do not deliver the valediction, print =session wrapped.=, drop a teardown/shutdown sentinel, or describe the session as complete. Report:
+
+1. Every remaining path and its exact Git state.
+2. What the file is and why the agent cannot safely resolve it alone.
+3. The concrete action or decision Craig needs to provide to make the tree clean.
+
+An explicit decision to keep a file dirty changes the outcome from "wrapping" to "leaving the session interrupted." It never satisfies this workflow.
+
+*** Final clean-tree certificate — hard gate
+
+After all commits are pushed and every leftover appears resolved, run the shared gate:
+
+#+begin_src bash
+gate="$(command -v git-worktree-gate 2>/dev/null || true)"
+[ -n "$gate" ] || gate="$HOME/code/rulesets/claude-templates/bin/git-worktree-gate"
+if [ ! -x "$gate" ]; then
+ echo "wrap blocked: git-worktree-gate is unavailable; install rulesets tooling and retry"
+ exit 1
+fi
+"$gate" certify "$PWD"
+#+end_src
+
+The certificate lives inside the Git directory, so it does not dirty the worktree. It records the exact verified HEAD. A non-zero result is a hard stop governed by "When cleanup cannot be completed" above. Step 5 is unreachable until certification succeeds.
** Step 5: Valediction
-Brief, warm closing. 3-4 sentences max.
+Only after the final clean-tree certificate succeeds, deliver a brief, warm closing. 3-4 sentences max.
Include:
- What was accomplished (specific, not generic)
@@ -567,7 +589,7 @@ Do nothing. The buffer, the =aiv-<project>= tmux session, and =claude= all stay
*** Teardown mode (default)
-Confirm commit + push succeeded (Exit Criteria 5 — never tear down over unpushed work), then drop the sentinel:
+Confirm commit + push and the final clean-tree certificate succeeded (Exit Criteria 5 — never tear down over unpushed or dirty work), then drop the sentinel:
#+begin_src bash
touch "/tmp/ai-wrap-teardown-$(basename "$PWD")"
@@ -605,7 +627,8 @@ If =emacsclient= isn't resolvable or the daemon is down, the gate can't run —
7. *Leaving =.ai/session-context.org= in place* — its presence means "interrupted session", confuses next startup
8. *Long preachy valediction* — brief beats thorough
9. *Leaving runtime/generated files dirty without gitignoring them* — pollutes every future =git status= and erodes trust in "working tree clean" as a signal. Fix =.gitignore= during the wrap, not later.
-10. *Treating "was dirty at session start, still dirty now" as fine by default* — that's how a forgotten commit from two sessions ago turns into "carryover" for two weeks. Every pre-existing dirty file needs an active resolution recommendation this session. Deferral is allowed only with an explicit user choice, logged in the valediction.
+10. *Treating "was dirty at session start, still dirty now" as fine* — that's how a forgotten commit from two sessions ago turns into "carryover" for two weeks. Every pre-existing dirty file must be resolved or the wrap remains blocked.
+11. *Calling a blocked cleanup a wrap* — if the strict gate fails, report the paths and needed decisions; do not valedict, certify completion, or tear down.
* Validation Checklist
@@ -618,19 +641,20 @@ Before considering wrap-up complete:
- [ ] =todo-cleanup.el= ran — hygiene pass + =--convert-subtasks= + =--archive-done= + =--sync-child-priority= (if =todo.org= exists at project root)
- [ ] =lint-org.el= ran on =todo.org= — mechanical fixes applied, judgments appended to follow-ups file (if =todo.org= exists)
- [ ] Any orphan-planning-line warnings reviewed (fix or accept)
-- [ ] Inbox carries nothing but expected pipeline artifacts (=.gitkeep=, =lint-followups.org=, =PROCESSED-*= prefixes), OR each remaining handoff has an explicit deferral logged in the valediction
+- [ ] Inbox carries nothing but expected committed or ignored pipeline artifacts (=.gitkeep=, =lint-followups.org=, =PROCESSED-*= prefixes); any untracked inbox delivery was processed before wrap
- [ ] Linear Dev-Review sweep ran; any merged-PR tickets moved to Done or PM Acceptance (skip if project doesn't use Linear)
- [ ] Template-sync churn committed as its own =chore: sync .ai tooling from templates= (consuming projects only; skipped in rulesets), or surfaced if a synced path didn't match canonical
-- [ ] After wrap-up commit + push, =git status --short= is empty OR every remaining line has an explicit user-deferred decision logged in the valediction
+- [ ] After wrap-up commit + push, =git-worktree-gate certify "$PWD"= succeeded at the current HEAD
- [ ] Each leftover was investigated and the user saw a concrete resolution recommendation
- [ ] Runtime artifacts added to =.gitignore=, follow-up commit pushed, =git status= re-verified
- [ ] Forgotten changes committed in a follow-up and pushed
-- [ ] Pre-existing dirty files resolved (committed / stashed / discarded / moved to a feature branch) or explicitly deferred with a one-line reason in the valediction
+- [ ] Pre-existing dirty files resolved (committed / stashed / discarded / moved to a feature branch); otherwise wrap stopped with an actionable blocker report
- [ ] Current branch pushed to ALL remotes (verified with =git remote -v=)
- [ ] All other local branches with a tracking upstream pushed to their remote
- [ ] Any untracked-upstream branches surfaced for manual =git push -u=
- [ ] Step 6 teardown matches the trigger phrase: no-teardown leaves the buffer; teardown drops only =/tmp/ai-wrap-teardown-<project>=; shutdown gates on =cj/ai-term-live-count= = 1 and drops only =/tmp/ai-wrap-shutdown-<project>=
- [ ] No teardown/shutdown sentinel was dropped before commit + push was verified
+- [ ] The teardown hook can re-verify the clean-tree certificate before consuming a sentinel
- [ ] Shutdown aborted (fell back to normal wrap, logged in the valediction) when another =aiv-*= session was live or the gate couldn't run
- [ ] Commit message follows format (no =session:=, no Claude attribution)
- [ ] Valediction delivered (brief, specific, warm)