aboutsummaryrefslogtreecommitdiff
path: root/claude-templates
diff options
context:
space:
mode:
Diffstat (limited to 'claude-templates')
-rw-r--r--claude-templates/.ai/protocols.org7
-rw-r--r--claude-templates/.ai/workflows/startup.org27
-rw-r--r--claude-templates/.ai/workflows/work-the-backlog.org2
-rw-r--r--claude-templates/.ai/workflows/wrap-it-up.org58
-rwxr-xr-xclaude-templates/bin/ai40
-rwxr-xr-xclaude-templates/bin/git-worktree-gate185
6 files changed, 281 insertions, 38 deletions
diff --git a/claude-templates/.ai/protocols.org b/claude-templates/.ai/protocols.org
index 3cd02ef..2ea041b 100644
--- a/claude-templates/.ai/protocols.org
+++ b/claude-templates/.ai/protocols.org
@@ -84,7 +84,7 @@ Do NOT estimate, guess, or rely on memory. Just run the command. It takes one se
Every session pulls rulesets first, then the local project repo. Rulesets carries the canonical behavioral rules and =.ai/= templates (the old =claude-templates= repo is folded in as a subtree at =rulesets/claude-templates/=); the project pull lands commits pushed from other machines or teammates since the last session.
-Resolve any dirty-tree or merge issue at each step before moving on. Both pulls run as =git pull --ff-only= (or =git merge --ff-only= against the fetched upstream), so anything non-trivial — non-fast-forward history, dirty working tree, diverged branches — aborts. Surface the state and stop. Never auto-stash, auto-merge, or auto-rebase; the user resolves the conflict before further work.
+Resolve any sync-blocking tree or merge issue at each step before moving on. The shared =git-worktree-gate sync-safe= policy permits untracked deliveries beneath =inbox/= so receiving a handoff never prevents another project from refreshing rulesets; every staged or tracked change, dirty submodule, Git operation in progress, or untracked path outside =inbox/= blocks. Both pulls run as =git pull --ff-only= (or =git merge --ff-only= against the fetched upstream), so non-fast-forward history and diverged branches also abort. Surface the state and stop. Never auto-stash, auto-merge, or auto-rebase; the user resolves the conflict before further work.
Mechanics live in =startup.org= Phase A.0. The rule lives here because it governs the very first action of every session: load the freshest behavioral rules and templates before anything else runs.
@@ -567,12 +567,13 @@ When monitoring a long-running process (rsync, large downloads, builds, VM tests
** "Wrap it up" / "That's a wrap" / "Let's call it a wrap"
-When Craig says any of these phrases (or variations), execute the wrap-up workflow: [[file:workflows/wrap-it-up.org][wrap-it-up.org]]. Four steps:
+When Craig says any of these phrases (or variations), execute the wrap-up workflow: [[file:workflows/wrap-it-up.org][wrap-it-up.org]]. Five load-bearing steps:
1. *Finalize the Summary* in =.ai/session-context.org= (populate the 5 subsections from the Session Log)
2. *Rename* =.ai/session-context.org= → =.ai/sessions/YYYY-MM-DD-HH-MM-description.org=
3. *Git commit + push* to all remotes (see Git Commit Requirements)
-4. *Valediction* — brief, warm, specific closing
+4. *Certify the clean tree* with =git-worktree-gate certify=. Any remaining staged, unstaged, untracked, submodule, or in-progress-operation state blocks wrap entirely; report each path and the exact decision needed. There is no dirty-file deferral.
+5. *Valediction* — brief, warm, specific closing, reachable only after certification
The absence of =.ai/session-context.org= after wrap-up is the signal that the session ended cleanly. If the file is still there at the next session start, the previous session was interrupted.
diff --git a/claude-templates/.ai/workflows/startup.org b/claude-templates/.ai/workflows/startup.org
index 929d482..1dff1db 100644
--- a/claude-templates/.ai/workflows/startup.org
+++ b/claude-templates/.ai/workflows/startup.org
@@ -29,10 +29,16 @@ Inside a rulesets session, the project-repo refresh below covers this — the ru
#+begin_src bash
rs="$HOME/code/rulesets"
if [ -d "$rs/.git" ]; then
- if (cd "$rs" && git diff --quiet --ignore-submodules HEAD -- 2>/dev/null); then
+ gate="$rs/claude-templates/bin/git-worktree-gate"
+ if [ -x "$gate" ] && "$gate" sync-safe "$rs"; then
+ (cd "$rs" && git pull --ff-only origin main 2>&1) | tail -3
+ elif [ ! -x "$gate" ] \
+ && (cd "$rs" && git diff --quiet --ignore-submodules HEAD -- 2>/dev/null); then
+ # Bootstrap fallback for a checkout old enough not to have the shared
+ # gate yet. The pull that follows installs it for subsequent starts.
(cd "$rs" && git pull --ff-only origin main 2>&1) | tail -3
else
- echo "rulesets: dirty working tree — using as-is, skipping pull"
+ echo "rulesets: changes beyond untracked inbox deliveries — using as-is, skipping pull"
fi
else
echo "rulesets: not a git checkout — skipping"
@@ -40,11 +46,11 @@ fi
#+end_src
Behavior:
-- *Clean working tree* → fast-forward pull. =git pull --ff-only= refuses any merge or rebase, so the operation is either a no-op (already current) or a clean advance.
-- *Dirty working tree* → skip the pull. Don't auto-stash and don't auto-merge — those would either lose work or invite conflicts at the worst possible moment (session start).
+- *Clean working tree, or untracked deliveries only beneath =inbox/=* → fast-forward pull. =git pull --ff-only= refuses any merge or rebase, so the operation is either a no-op (already current) or a clean advance. Inbox files are queue input, not source-tree work, and do not block other projects from receiving rulesets updates.
+- *Any staged or tracked change, dirty submodule, Git operation in progress, or untracked file outside =inbox/=* → skip the pull. Don't auto-stash and don't auto-merge — those would either lose work or invite conflicts at the worst possible moment (session start).
- *Non-fast-forward history* → =--ff-only= aborts with an error. Surface that to the user; the rsync still proceeds against the working tree as-is.
-*Template-freshness policy (applies to every dirty-check in the synced workflows).* "Dirty" means *tracked modifications only*. Untracked and gitignored files — an inbox drop, a file left in the tree to read, scratch output — never block a template pull, a fast-forward, or a monitoring gate. Projects were falling behind on templates because somebody sent them a task; that's the failure this policy closes. The checks here already comply (=git diff --quiet HEAD= sees only tracked changes; the ff gate uses =--untracked-files=no=), and any dirty-check added to a synced workflow follows the same rule. One deliberate exception: the rsync WIP-guard below counts untracked files *within rulesets' own synced source paths*, because an untracked half-written template is exactly the WIP it exists to hold back — that guard is about rulesets' outbound content, not the consuming project's local state.
+*Template-freshness policy (applies to every dirty-check in the synced workflows).* The shared =git-worktree-gate sync-safe= policy is the source of truth: untracked files beneath =inbox/= and gitignored files do not block a pull, fast-forward, or monitoring gate; every other staged, tracked, untracked, submodule, or in-progress-operation state does. Projects must not fall behind merely because somebody sent them a task, but an arbitrary scratch file is not silently treated as safe. One deliberate exception remains: the rsync WIP-guard below is narrower than the repository gate and counts untracked files within rulesets' own synced source paths, because an untracked half-written template is exactly the WIP it exists to hold back.
*** Install rulesets symlinks into ~/.claude (idempotent)
@@ -74,8 +80,11 @@ if [ -d .git ]; then
current=$(git symbolic-ref --short HEAD 2>/dev/null)
dirty=0
- if ! git diff --quiet --ignore-submodules HEAD -- 2>/dev/null \
- || [ -n "$(git status --porcelain --untracked-files=no)" ]; then
+ gate="$HOME/code/rulesets/claude-templates/bin/git-worktree-gate"
+ if [ -x "$gate" ]; then
+ "$gate" sync-safe "$PWD" >/dev/null 2>&1 || dirty=1
+ elif ! git diff --quiet --ignore-submodules HEAD -- 2>/dev/null \
+ || [ -n "$(git status --porcelain --untracked-files=no)" ]; then
dirty=1
fi
@@ -107,8 +116,8 @@ fi
#+end_src
Behavior, per branch:
-- *Behind only, current branch, clean tree* → =git merge --ff-only= advances HEAD.
-- *Behind only, current branch, dirty tree* → fetched but not advanced. Surface so Craig can ff manually after dealing with the dirty state.
+- *Behind only, current branch, sync-safe tree* → =git merge --ff-only= advances HEAD. An untracked =inbox/= delivery is sync-safe.
+- *Behind only, current branch, sync-blocking tree* → fetched but not advanced. Surface so Craig can ff manually after dealing with the reported state.
- *Behind only, non-checkout branch* → =git fetch . upstream:branch= advances the ref without touching the working tree.
- *Diverged* (ahead and behind) → leave alone. Surface for Craig to resolve. Don't auto-rebase or auto-merge.
- *Ahead only* or *up to date* → silent no-op.
diff --git a/claude-templates/.ai/workflows/work-the-backlog.org b/claude-templates/.ai/workflows/work-the-backlog.org
index 3933749..a0b24a8 100644
--- a/claude-templates/.ai/workflows/work-the-backlog.org
+++ b/claude-templates/.ai/workflows/work-the-backlog.org
@@ -70,6 +70,8 @@ A task is autonomous-safe when *both* hold. This layer is a lookup, not a judgme
1. *Status is =TODO=* — never =VERIFY=, =DOING=, =DONE=, or =CANCELLED=. =VERIFY= marks "awaiting Craig's input"; auto-implementing one defeats the check it represents. The do-not-implement set is safe-by-omission: anything not plainly =TODO= (plus any project-declared "hold" marker) is out.
2. *Tagged =:solo:=* — the autonomy tag, resolved against the project's priority/tag scheme header in =todo.org= (never hardcoded). =:solo:= carries the hard definition in =todo-format.md=: completable and verifiable without Craig beyond at most one or two quick decisions answerable up front, no design deliberation. A project whose scheme declares a different autonomous-safe tag set overrides the default.
+Terminology: *speedrunnable means tagged =:solo:=*. It does not mean =:quick:= or require =:quick:solo:=. The =TODO= status check above is the execution-state gate over that speedrunnable set.
+
Priority and =:next:= drive *ordering* within the eligible set, not eligibility ([#A] before [#B] before [#C], then the author's ordering). =:quick:= is an effort hint for batching and duration estimates — never a gate.
Task *size* is deliberately absent from this gate. A large but well-specified, decision-free task is in scope and gets decomposed into per-logical-commit chunks during implementation. Size never sends a task away; only *deliberation* or *risk* does (the checklist below).
diff --git a/claude-templates/.ai/workflows/wrap-it-up.org b/claude-templates/.ai/workflows/wrap-it-up.org
index 103e225..7d55a55 100644
--- a/claude-templates/.ai/workflows/wrap-it-up.org
+++ b/claude-templates/.ai/workflows/wrap-it-up.org
@@ -24,7 +24,7 @@ The wrap-up is complete when:
2. *File is archived.* =.ai/session-context.org= has been renamed to =.ai/sessions/YYYY-MM-DD-HH-MM-description.org=. The old path no longer exists.
3. *todo.org is clean.* Cleanup script ran. Any auto-fixes are staged for the wrap-up commit. Orphan planning lines surfaced for manual fix if there are any.
4. *Linear board is honest* (skip if project doesn't use Linear). Any Dev-Review ticket whose PR has merged was moved to Done or PM Acceptance per the classification rule.
-5. *Git state is clean.* All changes committed + pushed to all remotes. Working tree clean.
+5. *Git state is certified clean.* All changes are committed + pushed to all remotes, =git-worktree-gate certify= succeeded at the current HEAD, and the working tree has no staged, unstaged, untracked, submodule, or in-progress-operation state.
6. *Valediction delivered.* Brief, warm closing with key accomplishments and reminders, ending with =session wrapped.= on its own line as the signoff marker.
The absence of =.ai/session-context.org= is the signal that the last session wrapped up cleanly. Its presence at session start means the previous session was interrupted.
@@ -273,7 +273,7 @@ For an interactive walk of the judgments mid-day, run =/lint-org todo.org=.
*** Inbox sanity check (surface unprocessed handoffs)
-If the project has an =inbox/= directory, verify it holds nothing but =.gitkeep=, =lint-followups.org= (the lint-org pipeline file the next morning's daily-prep consumes), and any explicitly-deferred =PROCESSED-*= files before the wrap completes. An inbox that arrived at session start with handoffs from other projects, or that received handoffs mid-session, needs =inbox.org= process mode to run and apply its value-gate dispositions. Wrapping with a dirty inbox silently defers the work to next session and accumulates handoff debt that the sender can't see.
+If the project has an =inbox/= directory, verify it holds nothing but =.gitkeep=, =lint-followups.org= (the lint-org pipeline file the next morning's daily-prep consumes), and =PROCESSED-*= files before the wrap completes. An inbox that arrived at session start with handoffs from other projects, or that received handoffs mid-session, needs =inbox.org= process mode to run and apply its value-gate dispositions. Wrapping with an unprocessed inbox silently defers the work to next session and accumulates handoff debt that the sender can't see.
#+begin_src bash
unprocessed=$(find inbox -maxdepth 1 -type f \
@@ -282,7 +282,7 @@ unprocessed=$(find inbox -maxdepth 1 -type f \
! -name 'PROCESSED-*' \
2>/dev/null | wc -l)
if [ "$unprocessed" -gt 0 ]; then
- echo "wrap-up: inbox/ has $unprocessed unprocessed item(s). Run inbox.org process mode before wrapping, or explicitly defer each item with a one-line reason in the valediction."
+ echo "wrap-up blocked: inbox/ has $unprocessed unprocessed item(s). Run inbox.org process mode before wrapping."
find inbox -maxdepth 1 -type f \
! -name '.gitkeep' \
! -name 'lint-followups.org' \
@@ -291,7 +291,7 @@ if [ "$unprocessed" -gt 0 ]; then
fi
#+end_src
-If the count is zero or the project has no =inbox/= directory, the check is a silent no-op. If non-zero, the wrap is incomplete by default. The user resolves each item (process now, defer with reason in the valediction, or delete with rationale) before the validation checklist passes.
+If the count is zero or the project has no =inbox/= directory, the check is a silent no-op. If non-zero, the wrap is blocked. Process each item through its value-gate disposition, or delete it only when that workflow's rationale authorizes deletion, before continuing.
The check exempts =lint-followups.org= explicitly because lint-org runs earlier in the same wrap-up workflow and writes its judgment items to that file in =inbox/= by design. The file is a pipeline artifact for the next morning's =daily-prep=, not a handoff that needs the value gate.
@@ -501,17 +501,17 @@ Behavior:
git status --short
#+end_src
-*Default policy: end every session with an empty =git status=.* The wrap is incomplete while anything remains dirty. There is no "leave it alone" default — every leftover gets an active resolution. The only way for a file to stay dirty across the wrap is the user explicitly saying "defer this one, leave it dirty." Surface each leftover with a concrete recommendation; the user has to actively opt out for the dirt to persist.
+*Hard policy: end every session with an empty =git status=.* The wrap is incomplete while anything remains dirty. There is no deferral exception and no "wrapped with known changes" state: unresolved dirt means the session remains open and wrap-up does not occur.
This inverts the older "intentional carryover" default, which let pre-existing dirty state accumulate across sessions silently. Carryover that lives for days or weeks is almost always one of: a forgotten commit from a prior wrap, a stale change that should be discarded, or genuine in-flight work that needs an explicit stash/branch home. None of those should default to "leave it dirty."
**** Three kinds of leftover
-| Pattern | What it is | Recommended action (apply unless user defers) |
+| Pattern | What it is | Recommended action |
|---+---+---|
| Generated, runtime, or lock files that no human edits — e.g., =.claude/scheduled_tasks.lock=, =.pytest_cache/=, build outputs, IDE state, editor swap files | *Runtime artifact* — created by tooling or the harness, not by the user, and shouldn't be tracked | Add the matching pattern to =.gitignore= (project-level, not =~/.gitignore_global=). For tracked files, =git rm --cached <path>=. Stage =.gitignore= and any =rm --cached= changes in *one* follow-up commit (=chore: gitignore X=), push. Re-run =git status= to confirm clean. |
| Modified or created during the session but not staged into the wrap-up commit | *Forgotten change* — real session work that should have been in the wrap commit but missed it | Stage and create a follow-up commit. Don't =--amend= the wrap-up commit once pushed (diverging history without a clear win). Push the follow-up to all remotes. |
-| Was dirty at session start and still dirty at session end — work this session deliberately didn't touch | *Pre-existing dirt that needs a decision* — could be a missed commit from a prior wrap, stale abandoned work, or real in-flight work without a home | Investigate (show diff + check the originating session). Recommend one of: (a) commit now if the work is complete, (b) stash with a descriptive message if it's genuine WIP, (c) =git checkout -- <path>= / =git clean -f <path>= if stale and unwanted, (d) move to a feature branch if it's longer-running, (e) user explicitly defers and accepts the dirt. Do not silently leave dirty. |
+| Was dirty at session start and still dirty at session end — work this session deliberately didn't touch | *Pre-existing dirt that needs a decision* — could be a missed commit from a prior wrap, stale abandoned work, or real in-flight work without a home | Investigate (show diff + check the originating session). Recommend one of: (a) commit now if the work is complete, (b) stash with a descriptive message if it's genuine WIP, (c) =git checkout -- <path>= / =git clean -f <path>= if stale and unwanted, or (d) move to a feature branch if it's longer-running. Do not silently leave dirty. |
**** Per-file flow
@@ -519,18 +519,40 @@ For each leftover line in =git status --short=:
1. Identify which of the three kinds above it matches.
2. State what the file is (one line) and the recommended action.
-3. Apply the action unless the user explicitly defers.
-4. Re-run =git status --short= after each follow-up commit until empty (or until every remaining line is an explicit user-deferred entry).
+3. Apply the action when it is safe and authorized.
+4. Re-run =git status --short= after each follow-up commit until empty.
The pre-existing-dirt case (third row) is the one this rule most cares about. Treat each pre-existing-dirty file as a question that must get an answer this session, not as "carryover that's fine to inherit." A file that was dirty for a week before this session probably isn't going to get cleaner by waiting another week. Look at the diff, check the originating session's notes, and recommend a real resolution.
-**** When the user defers
+**** When cleanup cannot be completed
-If the user does say "leave this one dirty for now" after seeing the recommendation, that is fine — log the deferral in the valediction so the next session knows it was an explicit choice, not a miss. Format: "Deferred (per Craig's decision today): =path/to/file= — <one-line reason>". Without that note, the next session can't distinguish "we agreed to defer" from "we forgot again."
+Stop the wrap. Do not deliver the valediction, print =session wrapped.=, drop a teardown/shutdown sentinel, or describe the session as complete. Report:
+
+1. Every remaining path and its exact Git state.
+2. What the file is and why the agent cannot safely resolve it alone.
+3. The concrete action or decision Craig needs to provide to make the tree clean.
+
+An explicit decision to keep a file dirty changes the outcome from "wrapping" to "leaving the session interrupted." It never satisfies this workflow.
+
+*** Final clean-tree certificate — hard gate
+
+After all commits are pushed and every leftover appears resolved, run the shared gate:
+
+#+begin_src bash
+gate="$(command -v git-worktree-gate 2>/dev/null || true)"
+[ -n "$gate" ] || gate="$HOME/code/rulesets/claude-templates/bin/git-worktree-gate"
+if [ ! -x "$gate" ]; then
+ echo "wrap blocked: git-worktree-gate is unavailable; install rulesets tooling and retry"
+ exit 1
+fi
+"$gate" certify "$PWD"
+#+end_src
+
+The certificate lives inside the Git directory, so it does not dirty the worktree. It records the exact verified HEAD. A non-zero result is a hard stop governed by "When cleanup cannot be completed" above. Step 5 is unreachable until certification succeeds.
** Step 5: Valediction
-Brief, warm closing. 3-4 sentences max.
+Only after the final clean-tree certificate succeeds, deliver a brief, warm closing. 3-4 sentences max.
Include:
- What was accomplished (specific, not generic)
@@ -567,7 +589,7 @@ Do nothing. The buffer, the =aiv-<project>= tmux session, and =claude= all stay
*** Teardown mode (default)
-Confirm commit + push succeeded (Exit Criteria 5 — never tear down over unpushed work), then drop the sentinel:
+Confirm commit + push and the final clean-tree certificate succeeded (Exit Criteria 5 — never tear down over unpushed or dirty work), then drop the sentinel:
#+begin_src bash
touch "/tmp/ai-wrap-teardown-$(basename "$PWD")"
@@ -605,7 +627,8 @@ If =emacsclient= isn't resolvable or the daemon is down, the gate can't run —
7. *Leaving =.ai/session-context.org= in place* — its presence means "interrupted session", confuses next startup
8. *Long preachy valediction* — brief beats thorough
9. *Leaving runtime/generated files dirty without gitignoring them* — pollutes every future =git status= and erodes trust in "working tree clean" as a signal. Fix =.gitignore= during the wrap, not later.
-10. *Treating "was dirty at session start, still dirty now" as fine by default* — that's how a forgotten commit from two sessions ago turns into "carryover" for two weeks. Every pre-existing dirty file needs an active resolution recommendation this session. Deferral is allowed only with an explicit user choice, logged in the valediction.
+10. *Treating "was dirty at session start, still dirty now" as fine* — that's how a forgotten commit from two sessions ago turns into "carryover" for two weeks. Every pre-existing dirty file must be resolved or the wrap remains blocked.
+11. *Calling a blocked cleanup a wrap* — if the strict gate fails, report the paths and needed decisions; do not valedict, certify completion, or tear down.
* Validation Checklist
@@ -618,19 +641,20 @@ Before considering wrap-up complete:
- [ ] =todo-cleanup.el= ran — hygiene pass + =--convert-subtasks= + =--archive-done= + =--sync-child-priority= (if =todo.org= exists at project root)
- [ ] =lint-org.el= ran on =todo.org= — mechanical fixes applied, judgments appended to follow-ups file (if =todo.org= exists)
- [ ] Any orphan-planning-line warnings reviewed (fix or accept)
-- [ ] Inbox carries nothing but expected pipeline artifacts (=.gitkeep=, =lint-followups.org=, =PROCESSED-*= prefixes), OR each remaining handoff has an explicit deferral logged in the valediction
+- [ ] Inbox carries nothing but expected committed or ignored pipeline artifacts (=.gitkeep=, =lint-followups.org=, =PROCESSED-*= prefixes); any untracked inbox delivery was processed before wrap
- [ ] Linear Dev-Review sweep ran; any merged-PR tickets moved to Done or PM Acceptance (skip if project doesn't use Linear)
- [ ] Template-sync churn committed as its own =chore: sync .ai tooling from templates= (consuming projects only; skipped in rulesets), or surfaced if a synced path didn't match canonical
-- [ ] After wrap-up commit + push, =git status --short= is empty OR every remaining line has an explicit user-deferred decision logged in the valediction
+- [ ] After wrap-up commit + push, =git-worktree-gate certify "$PWD"= succeeded at the current HEAD
- [ ] Each leftover was investigated and the user saw a concrete resolution recommendation
- [ ] Runtime artifacts added to =.gitignore=, follow-up commit pushed, =git status= re-verified
- [ ] Forgotten changes committed in a follow-up and pushed
-- [ ] Pre-existing dirty files resolved (committed / stashed / discarded / moved to a feature branch) or explicitly deferred with a one-line reason in the valediction
+- [ ] Pre-existing dirty files resolved (committed / stashed / discarded / moved to a feature branch); otherwise wrap stopped with an actionable blocker report
- [ ] Current branch pushed to ALL remotes (verified with =git remote -v=)
- [ ] All other local branches with a tracking upstream pushed to their remote
- [ ] Any untracked-upstream branches surfaced for manual =git push -u=
- [ ] Step 6 teardown matches the trigger phrase: no-teardown leaves the buffer; teardown drops only =/tmp/ai-wrap-teardown-<project>=; shutdown gates on =cj/ai-term-live-count= = 1 and drops only =/tmp/ai-wrap-shutdown-<project>=
- [ ] No teardown/shutdown sentinel was dropped before commit + push was verified
+- [ ] The teardown hook can re-verify the clean-tree certificate before consuming a sentinel
- [ ] Shutdown aborted (fell back to normal wrap, logged in the valediction) when another =aiv-*= session was live or the gate couldn't run
- [ ] Commit message follows format (no =session:=, no Claude attribution)
- [ ] Valediction delivered (brief, specific, warm)
diff --git a/claude-templates/bin/ai b/claude-templates/bin/ai
index 55c0e0d..65d0ab7 100755
--- a/claude-templates/bin/ai
+++ b/claude-templates/bin/ai
@@ -231,12 +231,28 @@ fetch_candidates() {
wait
}
-# True (exit 0) when the worktree has staged, unstaged, or untracked changes.
+# Resolve the shared state gate installed beside this launcher. Keeping the
+# policy in one executable prevents startup, the picker, and wrap-up from
+# developing different meanings of "safe to sync."
+_git_gate_path() {
+ local gate="${GIT_WORKTREE_GATE:-}"
+ [ -n "$gate" ] || gate="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/git-worktree-gate"
+ [ -x "$gate" ] && printf '%s\n' "$gate"
+}
+
+# True (exit 0) when strict wrap would reject the worktree.
_git_is_dirty() {
- local dir="$1"
- ! git -C "$dir" diff --quiet 2>/dev/null ||
- ! git -C "$dir" diff --cached --quiet 2>/dev/null ||
- [ -n "$(git -C "$dir" ls-files --others --exclude-standard 2>/dev/null)" ]
+ local dir="$1" gate
+ gate="$(_git_gate_path)" || return 0
+ ! "$gate" strict "$dir" >/dev/null 2>&1
+}
+
+# True (exit 0) when startup sync must stop. Untracked inbox deliveries are
+# safe queue input; every tracked, staged, or other untracked change blocks.
+_git_blocks_sync() {
+ local dir="$1" gate
+ gate="$(_git_gate_path)" || return 0
+ ! "$gate" sync-safe "$dir" >/dev/null 2>&1
}
# Return " (↑N ↓N dirty)" or " (✓)" if clean.
@@ -254,7 +270,13 @@ git_status_indicator() {
parts+=("no upstream")
fi
- _git_is_dirty "$dir" && parts+=("dirty")
+ if _git_is_dirty "$dir"; then
+ if _git_blocks_sync "$dir"; then
+ parts+=("dirty")
+ else
+ parts+=("inbox")
+ fi
+ fi
if [ ${#parts[@]} -gt 0 ]; then
local IFS=' '
@@ -275,11 +297,11 @@ annotate_candidates() {
candidates=("${annotated[@]}")
}
-# Pull if clean, behind, not ahead. No-op otherwise.
+# Pull if sync-safe, behind, not ahead. Inbox-only queue input is sync-safe.
auto_pull_if_clean() {
local dir="$1" upstream ahead behind
[ -d "$dir/.git" ] || return 0
- _git_is_dirty "$dir" && return 0
+ _git_blocks_sync "$dir" && return 0
upstream=$(git -C "$dir" rev-parse --abbrev-ref --symbolic-full-name "@{u}" 2>/dev/null || true)
[ -z "$upstream" ] && return 0
@@ -356,7 +378,7 @@ prep_git_single() {
ahead=$(git -C "$dir" rev-list --count "$upstream..HEAD" 2>/dev/null || echo 0)
behind=$(git -C "$dir" rev-list --count "HEAD..$upstream" 2>/dev/null || echo 0)
- _git_is_dirty "$dir" && dirty=1
+ _git_blocks_sync "$dir" && dirty=1
case "$(_git_prep_action 1 "$dirty" "${ahead:-0}" "${behind:-0}")" in
pull)
diff --git a/claude-templates/bin/git-worktree-gate b/claude-templates/bin/git-worktree-gate
new file mode 100755
index 0000000..e453fd1
--- /dev/null
+++ b/claude-templates/bin/git-worktree-gate
@@ -0,0 +1,185 @@
+#!/usr/bin/env bash
+# git-worktree-gate — one definition of safe Git state for startup and wrap.
+#
+# Modes:
+# strict [DIR] Require an entirely empty worktree.
+# sync-safe [DIR] Permit untracked inbox/ deliveries, but nothing else.
+# certify [DIR] Strict-check, then record the verified HEAD in the git dir.
+# verify [DIR] Strict-check and require the recorded HEAD to still match.
+#
+# Ignored files are deliberately outside Git's clean-worktree contract.
+
+set -u
+
+mode="${1:-}"
+repo="${2:-.}"
+
+usage() {
+ echo "usage: git-worktree-gate {strict|sync-safe|certify|verify} [DIR]" >&2
+ exit 2
+}
+
+case "$mode" in
+ strict|sync-safe|certify|verify) ;;
+ *) usage ;;
+esac
+
+root="$(git -C "$repo" rev-parse --show-toplevel 2>/dev/null)" || {
+ echo "git-worktree-gate: $repo is not inside a Git worktree" >&2
+ exit 2
+}
+gitdir="$(git -C "$root" rev-parse --absolute-git-dir 2>/dev/null)" || {
+ echo "git-worktree-gate: cannot resolve the Git directory for $root" >&2
+ exit 2
+}
+certificate="$gitdir/ai-wrap-clean"
+
+quote_path() {
+ printf '%q' "$1"
+}
+
+operation_in_progress() {
+ local marker
+ for marker in MERGE_HEAD CHERRY_PICK_HEAD REVERT_HEAD BISECT_LOG; do
+ [ -e "$gitdir/$marker" ] && {
+ printf '%s' "$marker"
+ return 0
+ }
+ done
+ for marker in rebase-merge rebase-apply sequencer; do
+ [ -d "$gitdir/$marker" ] && {
+ printf '%s' "$marker"
+ return 0
+ }
+ done
+ return 1
+}
+
+describe_entry() {
+ local xy="$1" path="$2" original="${3:-}"
+ local index="${xy:0:1}" worktree="${xy:1:1}" label=""
+
+ if [ "$xy" = "??" ]; then
+ label="untracked; add and commit it, move it outside the repository, or remove it if unwanted"
+ elif [ "$xy" = "!!" ]; then
+ label="ignored"
+ elif [[ "$xy" = *U* || "$xy" = "AA" || "$xy" = "DD" ]]; then
+ label="unmerged; resolve the conflict and commit the result"
+ elif [ "$index" != " " ] && [ "$worktree" != " " ]; then
+ label="staged and unstaged changes; review both layers, then commit or restore them"
+ elif [ "$index" != " " ]; then
+ label="staged change; commit it or unstage and restore it"
+ else
+ label="unstaged tracked change; commit it or restore it"
+ fi
+
+ printf ' %s ' "$xy"
+ quote_path "$path"
+ if [ -n "$original" ]; then
+ printf ' (from '
+ quote_path "$original"
+ printf ')'
+ fi
+ printf ' — %s\n' "$label"
+}
+
+check_state() {
+ local policy="$1" xy path original="" blocked=0 op=""
+ local status_tmp="" status_err="" status_detail=""
+ local -a report=()
+
+ if op="$(operation_in_progress)"; then
+ report+=(" Git operation in progress: $op — finish or abort it")
+ blocked=1
+ fi
+
+ status_tmp="$(mktemp "$gitdir/ai-worktree-status.tmp.XXXXXX")" || {
+ echo "wrap blocked: cannot allocate a Git-state check file" >&2
+ return 1
+ }
+ status_err="$(mktemp "$gitdir/ai-worktree-status.err.XXXXXX")" || {
+ rm -f "$status_tmp"
+ echo "wrap blocked: cannot allocate a Git-state error file" >&2
+ return 1
+ }
+
+ if ! git -C "$root" status --porcelain=v1 -z \
+ --untracked-files=all --ignore-submodules=none \
+ >"$status_tmp" 2>"$status_err"; then
+ status_detail="$(head -1 "$status_err")"
+ [ -n "$status_detail" ] || status_detail="unknown Git error"
+ report+=(" git status failed — $status_detail")
+ blocked=1
+ else
+ while IFS= read -r -d '' entry; do
+ xy="${entry:0:2}"
+ path="${entry:3}"
+ original=""
+ if [[ "${xy:0:1}" = "R" || "${xy:0:1}" = "C" ]]; then
+ IFS= read -r -d '' original || true
+ fi
+
+ if [ "$policy" = "sync-safe" ] \
+ && [ "$xy" = "??" ] \
+ && [[ "$path" = inbox/* ]]; then
+ continue
+ fi
+
+ report+=("$(describe_entry "$xy" "$path" "$original")")
+ blocked=1
+ done <"$status_tmp"
+ fi
+ rm -f "$status_tmp" "$status_err"
+
+ if [ "$blocked" -ne 0 ]; then
+ if [ "$policy" = "sync-safe" ]; then
+ echo "sync blocked: rulesets has changes other than untracked inbox deliveries" >&2
+ else
+ echo "wrap blocked: Git worktree is not completely clean" >&2
+ fi
+ printf '%s\n' "${report[@]}" >&2
+ return 1
+ fi
+ return 0
+}
+
+case "$mode" in
+ strict)
+ check_state strict
+ ;;
+ sync-safe)
+ check_state sync-safe
+ ;;
+ certify)
+ check_state strict || exit 1
+ head="$(git -C "$root" rev-parse HEAD 2>/dev/null)" || {
+ echo "wrap blocked: cannot resolve HEAD" >&2
+ exit 1
+ }
+ tmp="$(mktemp "$gitdir/ai-wrap-clean.tmp.XXXXXX")" || exit 1
+ chmod 600 "$tmp"
+ {
+ printf 'head=%s\n' "$head"
+ printf 'root=%s\n' "$root"
+ } >"$tmp"
+ mv "$tmp" "$certificate"
+ ;;
+ verify)
+ check_state strict || exit 1
+ [ -f "$certificate" ] || {
+ echo "wrap blocked: no clean-tree certificate exists; rerun the final wrap verification" >&2
+ exit 1
+ }
+ certified_head="$(sed -n 's/^head=//p' "$certificate" | head -1)"
+ certified_root="$(sed -n 's/^root=//p' "$certificate" | head -1)"
+ current_head="$(git -C "$root" rev-parse HEAD 2>/dev/null)" || exit 1
+ [ "$certified_root" = "$root" ] || {
+ echo "wrap blocked: clean-tree certificate belongs to a different worktree" >&2
+ exit 1
+ }
+ [ -n "$certified_head" ] && [ "$certified_head" = "$current_head" ] || {
+ echo "wrap blocked: HEAD changed after clean-tree certification; rerun the final wrap verification" >&2
+ exit 1
+ }
+ ;;
+esac