with one click
with one click
[HINT] Download the complete skill directory including SKILL.md and all related files
| name | commit |
| description | Use when committing code changes after a work session. |
| user-invocable | true |
| argument-hint | ["--all | --amend | --force-concurrent | --quick"] |
| allowed-tools | Bash, Read, Grep, Glob, Edit, Task |
| model | opus |
PURPOSE: Create quality commits with session-scoped staging, lattice-gated validation, and conventional commit formatting.
git branch --show-current 2>/dev/nullgit status --porcelain 2>/dev/null | head -40git diff --cached --stat 2>/dev/null | head -40git log --oneline -10 2>/dev/null"${CLAUDE_PROJECT_DIR:-.}/.claude/hooks/pyrun" -c "import os, sys; sys.path.insert(0, os.path.join(os.environ.get('CLAUDE_PROJECT_DIR', '.'), '.claude/hooks')); from _lib import count_live_peers; n = count_live_peers(os.environ.get('CLAUDE_SESSION_ID', '')); print(max(0, n))" 2>/dev/null || echo 1"${CLAUDE_PROJECT_DIR:-.}/.claude/hooks/pyrun" -c "import os, sys; sys.path.insert(0, os.path.join(os.environ.get('CLAUDE_PROJECT_DIR', '.'), '.claude/hooks')); from _lib import repo_uses_stashing_framework; print('yes' if repo_uses_stashing_framework('.') else 'no')" 2>/dev/null || echo "yes"NEVER stash, restore, reset, or clean peer-WIP paths. R2 of pre-tool-use-stash-guard.py enforces this permanently in every repo, every session โ no --force-concurrent bypass, only worktree-isolated sessions skip. Step 0 below additionally blocks /commit itself (R1, transitional belt-and-suspenders) when peers are present AND the repo still carries a pre-commit stash trap โ i.e., a stash-using framework (Python pre-commit, default husky+lint-staged) whose repo-global git stash --keep-index would race against peer commits and corrupt their WIP. The mitigation is migration, not workaround: complete docs/runbooks/precommit-removal.md (or lefthook-migration.md). Do not recommend tools/wcc / git worktrees. R1 retires once the last managed repo migrates (WORKFLOW.md phase 8b). See reference.md โ "Commit Pipeline Policy".
A PostToolUse hook logs every file path touched by Write/Edit to ${CLAUDE_PROJECT_DIR:-.}/.claude/sessions/<session_id>/files.txt. The tracker is the authoritative stage set โ peer-session WIP and pre-existing dirty state in the same checkout are NOT this session's to commit. Use --all only when you intentionally want working-tree-wide staging (e.g., regenerated lockfiles the tracker missed).
R2 (peer-WIP destruction) is unconditional and enforced by pre-tool-use-stash-guard.py regardless of this gate; Step 0 covers R1 only โ the commit-time stash race specific to repos that still carry a stash trap. Detect trap + peers + isolation:
# --quick flag (Step 0.5): exported here so subsequent steps can branch.
# Detection mirrors the existing flag-parse pattern (case โฆ in *" --foo "*).
QUICK=""
case " $* " in *" --quick "*) QUICK="1";; esac
# Pre-commit stash trap presence (transitional R1 gate). Short-circuits to
# allow when the trap is gone โ no race to prevent. Single source of truth
# lives in base/hooks/_lib.py::repo_uses_stashing_framework so /commit and
# the PreToolUse stash-guard hook can never disagree. Fail-closed (assume
# trap present, run the gate) on any helper error.
STASHES=$("${CLAUDE_PROJECT_DIR:-.}/.claude/hooks/pyrun" -c "import os, sys; sys.path.insert(0, os.path.join(os.environ.get('CLAUDE_PROJECT_DIR', '.'), '.claude/hooks')); from _lib import repo_uses_stashing_framework; print(1 if repo_uses_stashing_framework('.') else 0)" 2>/dev/null) || STASHES=1
case "$STASHES" in 0|1) ;; *) STASHES=1 ;; esac
# Live peer count: delegated to base/hooks/_lib.py::count_live_peers, which
# wraps the same `iter_active_session_dirs` that pre-tool-use-stash-guard.py
# uses. Single source of truth โ the skill and the hook always agree on what
# counts as live (turn-state.json / files.txt / dir mtime, 30-min window =
# `_DEFAULT_ACTIVE_WINDOW_S`). Fail-closed to PEERS=1 on any helper error so
# the gate stays armed when in doubt.
PEERS=$("${CLAUDE_PROJECT_DIR:-.}/.claude/hooks/pyrun" -c "import os, sys; sys.path.insert(0, os.path.join(os.environ.get('CLAUDE_PROJECT_DIR', '.'), '.claude/hooks')); from _lib import count_live_peers; n = count_live_peers(os.environ.get('CLAUDE_SESSION_ID', '')); print(n if n >= 0 else 1)" 2>/dev/null) || PEERS=1
# Numeric guard: helper returns -1 on invalid sid (mapped to 1 above for
# fail-closed); pyrun stdout could also be empty / non-numeric on transport
# error. Anything that's not a non-negative integer falls closed to 1.
case "$PEERS" in ''|*[!0-9]*) PEERS=1 ;; esac
ISOLATED=$(test "$(git rev-parse --git-common-dir 2>/dev/null)" \
!= "$(git rev-parse --git-dir 2>/dev/null)" && echo 1 || echo 0)
SENTINEL="${CLAUDE_PROJECT_DIR:-.}/.claude/.force-concurrent-$CLAUDE_SESSION_ID"
Block when STASHES == 1 AND PEERS > 0 AND ISOLATED == 0 AND no sentinel:
[BLOCK] /commit blocked โ peer-WIP-destroying stash trap present.
This repo's pre-commit framework (Python `pre-commit` or default
`husky+lint-staged`) runs `git stash --keep-index` repo-globally during hook
execution. With N peer Claude session(s) sharing this working tree:
- sessions/<peer-sid-1>
- sessions/<peer-sid-2>
a commit from here would race the stash window and silently corrupt their
WIP. The trap is structural โ narrowed mitigations have all failed (see
WORKFLOW.md "Why earlier 'stash race' mitigations are gone").
Required: complete the migration that removes the trap.
1. `docs/runbooks/precommit-removal.md` โ remove the framework, lean on
/validate-change + CI (recommended when CI is robust).
2. `docs/runbooks/lefthook-migration.md` โ switch to lefthook (which
does NOT stash) for repos that need local fast-feedback at commit.
Workarounds (not fixes):
- Wait for peer sessions to finish, then re-run /commit.
- `/commit --force-concurrent` โ bypass R1 for THIS commit only. You
accept the corruption risk for any peer with uncommitted WIP. Does
NOT bypass R2 (you still cannot stash/restore/reset/clean peer-WIP
paths in any repo).
Worktrees (`tools/wcc`) are NOT the mitigation โ the trap survives every
worktree of the same checkout that hasn't migrated. The same R1 block
fires from `pre-tool-use-stash-guard.py` if you bypass this skill via raw
`git commit` / `git stash push`.
Bypass with --force-concurrent:
touch "${CLAUDE_PROJECT_DIR:-.}/.claude/.force-concurrent-$CLAUDE_SESSION_ID"
No
EXITtrap. EachBashtool call runs in a fresh shell, so atrap โฆ EXITregistered in this Step 0 call would fire and delete the sentinel before Step 5's separategit commitcall ever runs โ the bypass would be a no-op. Cleanup is handled instead by Step 6 (post-commit) and the 1-hour stale-sentinel prune at nextsession-start.py.
The hook (pre-tool-use-stash-guard.py) accepts any .claude/.force-concurrent-*
regular file with mtime within DEFAULT_FORCE_CONCURRENT_FRESH_S (60s, defined
in base/hooks/_lib.py) โ the suffix is just a unique tag to avoid clashes
between concurrent skill invocations. The freshness window exists because the
shell-side $CLAUDE_SESSION_ID and the hook-side stdin session_id don't
always agree, so a strict per-session match is unreachable; freshness narrows
the cross-session bypass surface instead. The sentinel is removed in Step 6
(post-commit). Stale sentinels older than 1h are pruned at next session-start.
Trust-model caveat.
--force-concurrentis a commit-level bypass for R1 only: it disables the stash-race gate tree-wide for 60s so the calling commit can run despite peer presence. It does not bypass R2 โ every destructive working-tree git op on peer-WIP paths stays blocked, sentinel or not. The R1 bypass is also not mutual exclusion: two simultaneously-bypassed commits can still race on the framework's repo-global stash and clobber each other's WIP. The structural fix is migration, not bypass โ seedocs/runbooks/precommit-removal.md. Sessions otherwise isolate via the.claude/sessions/{sid}/tracker; worktrees are not the canonical mitigation.
Step 0 (R1) exemptions: worktree-isolated sessions skip the gate entirely.
Repos where the trap is gone (lefthook-only, husky+lint-staged with --no-stash,
plain .git/hooks/, or no framework) also skip โ STASHES==0, no race to
prevent. Docs-only or session-tracking-only changes do NOT exempt when the
trap is present โ peer WIP is still at risk. R2 has no exemptions of this
kind: even when /commit is allowed, you cannot stash, restore, reset, or
clean peer-WIP paths in any repo.
When invoked as /commit --quick, the skill skips Steps 2 (docs check), 2.5
(lattice HARD GATE), 2.6 (Plan-Closes reconcile), 2.7 (task completion check),
and 3 (code-review subagent). Steps 0, 1, 2.4 (plan-trailer fold), 4
(format/analyze), 5 (stage + commit), 6, and 7 always run.
Detection โ parse argv for the flag (alongside the existing --all /
--amend / --force-concurrent parsing in Step 0):
QUICK=""
case " $* " in *" --quick "*) QUICK="1";; esac
# --quick: write a sentinel so the Bash-level pre-tool-use-validate-gate.py
# (and stash-guard R1) both honor the same 60-second bypass. The skill's
# Step 2.5 SKIP-if-quick annotation handles the prose-level skip; this
# sentinel handles the unbypassable hook. Cleanup in Step 6 (same glob
# already reaps --force-concurrent sentinels).
if [[ -n "$QUICK" ]]; then
touch "${CLAUDE_PROJECT_DIR:-.}/.claude/.force-concurrent-quick-$CLAUDE_SESSION_ID"
fi
Cleanup-on-abort. If any step between 0.5 and 5 aborts (Step 2.4 trailer-fold error, Step 4 format/analyze fail, user Ctrl-C, agent rolls back), the sentinel persists for 60s and would silently bypass pre-tool-use-validate-gate.py AND stash-guard R1 for any subsequent git commit โ including a non---quick retry of substantive work, which is the exact case the gate exists to catch. Before exiting on any abort path, run rm -f "${CLAUDE_PROJECT_DIR:-.}/.claude/.force-concurrent-quick-$CLAUDE_SESSION_ID" to close the window. The Step 6 normal-flow cleanup glob handles the success path; this manual cleanup handles failures.
Hard prohibition โ --quick with a Plan-Closes: trailer in the drafted
message is rejected at parse time before any work runs. Closing a plan demands
the Step 2.6 reconcile gate to verify step coverage, file coverage, and
closure consistency; skipping it produces silent drift. Read the drafted
message via the same logic Step 2.4 uses (heredoc / -m / -F /
--amend โ HEAD); if git interpret-trailers --parse finds Plan-Closes:
when QUICK=1, abort:
[ERROR] /commit --quick: Plan-Closes requires the Step 2.6 reconcile gate; rerun without --quick.
Heuristic for when to use --quick โ apply at least one of:
docs:, style:, chore:, test: (no behavior change), ORAND no Plan-Closes: trailer AND no new test files (new tests should ride
the lattice โ they're the lattice's payoff).
When in doubt, drop --quick. The full pipeline always works; the
lightweight one is the optimization for cases where the lattice would be
disproportionate. The lattice gate's block message (Step 2.5) surfaces
--quick as a recovery option when an agent hits the gate without
/validate-change having run.
The session tracker is the authoritative stage set. Files outside it (peer-session WIP, unrelated dirty state) MUST NOT be staged unless --all was passed or the user explicitly added them via git add before invoking the skill.
The intersection (files.txt โฉ working-tree changes) is computed by _lib.compute_session_scope, written to .claude/sessions/$CLAUDE_SESSION_ID/stage.txt, and the helper's exit code drives the decision tree below โ the bash recipe cannot drift from the prose.
STAGE_FILE="${CLAUDE_PROJECT_DIR:-.}/.claude/sessions/${CLAUDE_SESSION_ID}/stage.txt"
git status --porcelain
"${CLAUDE_PROJECT_DIR:-.}/.claude/hooks/pyrun" -c "
import os, sys
sys.path.insert(0, os.path.join(os.environ.get('CLAUDE_PROJECT_DIR', '.'), '.claude/hooks'))
from _lib import compute_session_scope
# .get with empty fallback (see validate-change SKILL.md for the rationale โ
# missing env routes to SCOPE_NO_TRACKER, never to a silent SCOPE_EMPTY).
code, files, msg = compute_session_scope(os.environ.get('CLAUDE_SESSION_ID', ''), output_name='stage.txt')
print(msg, file=sys.stderr)
sys.exit(code)
"
RC=$?
echo "stage file: $STAGE_FILE"; cat "$STAGE_FILE"
Default โ RC == 0 (tracker non-empty, intersection non-empty): stage only the files in $STAGE_FILE (Step 5). Do not offer "Stage all?" โ anything else is out of scope.
RC == 1 (tracker has entries but nothing is currently changed โ already committed/reverted): stop with "no session changes to commit." Do not silently widen scope.
RC == 2 (tracker missing or empty โ fresh /clear or no Edit/Write activity yet): pause and ask the user before staging from the working tree. Peer sessions may own those changes.
--all (CLI flag): stage every changed file in the working tree (legacy behavior). Use only when intentional โ and warn the user that this risks staging peer-session WIP.
Pre-staged files: if the user already ran git add before invoking /commit, respect that โ keep already-staged paths even if they're outside the tracker, but flag them in the commit summary so the user can see what's going in.
SKIP if --quick.
Invoke /ai-guardrails-audit in diff mode. Auto-skip for test-only or formatting-only changes.
If the drafted commit message carries any Plan-Start:, Plan-Park:, or Plan-Step: trailer, run the plan tool's pre-commit fold for each so the trailer's filesystem effects (mv between stage dirs, meta-block update, progress re-render) land in the same commit as the trailer that triggered them:
PARENT_SHA="$(git rev-parse HEAD)"
# For each Plan-Start: <topic> trailer in the drafted message:
"${CLAUDE_PROJECT_DIR:-.}/.claude/hooks/pyrun" "${CLAUDE_PROJECT_DIR:-.}/.claude/tools/plan" apply-trailer <topic> start --head-sha "$PARENT_SHA"
# For each Plan-Park: <topic>:
"${CLAUDE_PROJECT_DIR:-.}/.claude/hooks/pyrun" "${CLAUDE_PROJECT_DIR:-.}/.claude/tools/plan" apply-trailer <topic> park
# For each Plan-Step: <topic>/<n> (extract the topic before the slash):
"${CLAUDE_PROJECT_DIR:-.}/.claude/hooks/pyrun" "${CLAUDE_PROJECT_DIR:-.}/.claude/tools/plan" apply-trailer <topic> step
Plan-Closes: is NOT folded here โ its fold runs in Step 2.6 after the reconcile gate passes (see below). Plan-Out-Of-Scope: is recordkeeping only and has no fold step.
apply-trailer mutates the plan pair (via git mv + plan_md.write_meta), self-tracks the changes into the session's files.txt, and runs git add so the resulting diff is staged for the commit.
Why pre-commit fold. Before v1.20.0, post-commit-plan.py performed these mutations after the commit landed โ the trailer commit was clean, but the working tree carried an orphan-diff (mv + meta) that the next /commit had to pick up, and that pickup commit bypassed /validate-change because the tracker had no record of the changes. Folding pre-commit closes that bypass: the trailer-emitted state-machine effects flow through the same /validate-change โ /commit insurance lattice as any other code change. Trailer-fold mutations are mechanical (deterministic functions of trailer kind + current plan state), so they do not require a re-run of /validate-change; the Step 2.5 gate covers operator-authored changes only.
post-commit-plan.py is now verification-only. It reads the just-landed commit's trailers and asserts the pair is in the expected stage with consistent meta โ emitting [STEER] on drift. Drift signals "Step 2.4 was skipped" (e.g. operator did git commit directly bypassing /commit); recovery is plan apply-trailer <topic> <kind> then git commit --amend.
SKIP if --quick. The lattice gate is the protection /commit's full pipeline gives substantive feature work; --quick is the operator's explicit declaration that this commit doesn't justify it (typo, comment, โค5 LOC, pure docs/style/chore/test). See Step 0.5 for the heuristic.
Detection (marker-based) โ /validate-change writes .claude/sessions/${CLAUDE_SESSION_ID}/validate-change.pass on PASS verdict (see validate-change SKILL.md Iron Rule 8). Both this skill check AND the Bash-level pre-tool-use-validate-gate.py call the SAME helper (_marker_state.validate_marker_state) so the two enforcement sites can never drift on what "fresh" means. The freshness signal is max(mtime(files.txt), max(mtime(p) for p in scope)) โ the scope-files term catches re-edits of already-tracked files (which files.txt mtime alone misses, because the tracker writers de-dup on already-present paths).
"${CLAUDE_PROJECT_DIR:-.}/.claude/hooks/pyrun" -c "
import os, sys
sys.path.insert(0, os.path.join(os.environ.get('CLAUDE_PROJECT_DIR', '.'), '.claude/hooks'))
from _marker_state import validate_marker_state, MARKER_FRESH
sid = os.environ.get('CLAUDE_SESSION_ID', '')
if not sid: sys.exit(0)
state, info = validate_marker_state(sid) # reads scope.txt for scope_files
if state == MARKER_FRESH:
print('FRESH'); sys.exit(0)
print('STALE: ' + state + ' ' + str(info)); sys.exit(2)
"
RC=$?
RC == 0 โ marker fresh. Continue. RC == 2 โ block:
[BLOCK] /validate-change has not been run for these files (or marker is stale).
Either:
1. Run `/validate-change` first (lattice protection โ recommended for feature work), OR
2. Rerun `/commit --quick` if this commit is trivial (typo, comment, โค5 LOC,
docs/style/chore/test) and doesn't justify the lattice (see Step 0.5 heuristic).
Exception: Docs-only changes (*.md) outside docs/plans/**, or session-tracking files (.claude/*). Plan files (docs/plans/**/*.md) are NOT exempt โ design/progress drift can mask spec/code mismatches that the lattice catches.
Defense-in-depth: this skill check is the friendly path. The Bash-level pre-tool-use-validate-gate.py re-runs the same logic at admission time when the agent invokes git commit directly, bypassing the skill โ it is the unbypassable backstop. Both gates honor the same .force-concurrent-* sentinel and worktree-isolation exemption.
SKIP if --quick โ but the Step 0.5 hard prohibition rejects --quick with Plan-Closes: at parse time, so this skip is unreachable in practice (closing a plan demands the reconcile gate; the abort message tells the agent to rerun without --quick).
If the drafted commit message carries a Plan-Closes: <topic> trailer, run reconcile in check-mode for each closed topic before committing:
"${CLAUDE_PROJECT_DIR:-.}/.claude/hooks/pyrun" "${CLAUDE_PROJECT_DIR:-.}/.claude/tools/plan" reconcile --check <topic>
Block when reconcile exits non-zero:
[BLOCK] /commit blocked โ plan reconcile reports drift on topic <topic>:
<step coverage / file coverage summary>
Resolve drift before claiming Plan-Closes:
- Add missing Plan-Step trailers (or list step numbers in the design's <!-- plan:dropped --> block)
- Cover phantom files (touch + Plan-Step) or remove them from the design's File Structure table
- Annotate surprise files with Plan-Out-Of-Scope on the appropriate commit
When reconcile passes, fold the close (mv to 4-done/ + Status=DONE) into this commit:
"${CLAUDE_PROJECT_DIR:-.}/.claude/hooks/pyrun" "${CLAUDE_PROJECT_DIR:-.}/.claude/tools/plan" apply-trailer <topic> closes
Plan-Step, Plan-Park, and Plan-Start trailers are NOT gated here โ they're mechanical and folded in Step 2.4. Closure drift is normal mid-plan (the closure check requires every expected step to be covered), and Plan-Start is the trailer that sets Started-At, so reconcile has nothing to walk yet. Only Plan-Closes is a closure event whose fold must wait until reconcile confirms the plan is actually done.
The Closes fold is split from Step 2.4 specifically so reconcile runs against the still-in-progress pair (step coverage walks <Started-At>..HEAD); folding the close before reconcile would mv the pair to 4-done/ while reconcile expects to find it in 3-in-progress/. The post-commit hook (post-commit-plan.py, verify-only since v1.20.0) asserts the post-fold state matches expectations and emits [STEER] on drift.
SKIP if --quick.
If a progress file exists (docs/plans/3-in-progress/*-progress.md), warn about unchecked steps. Skip for --all or docs-only.
SKIP if --quick. Trivial fixes don't justify a code-review subagent invocation; the format/analyze pass in Step 4 still runs.
Use code-reviewer subagent. Skip if /validate-change already ran this session (Layer 4 covers it).
{{FORMAT_COMMAND}}
{{ANALYZE_COMMAND}}
Stage the session-scoped set computed in Step 1, then commit. Generate a conventional commit with Co-Authored-By trailer. Types: feat, fix, refactor, style, test, docs, chore, perf.
STAGE_FILE="${CLAUDE_PROJECT_DIR:-.}/.claude/sessions/${CLAUDE_SESSION_ID}/stage.txt"
# Stage only the session-scoped files (or already-staged paths the user added).
# `--all` mode skips this and stages the full working tree explicitly.
# `-d '\n'` is newline-delimited, matching the helper's output format.
xargs -r -a "$STAGE_FILE" -d '\n' git add --
Then:
git commit {{COMMIT_NO_VERIFY}} -m "$(cat <<'EOF'
<conventional commit message here>
Plan-Step: <topic>/<n>
Co-Authored-By: Claude <noreply@anthropic.com>
EOF
)"
(Drop the Plan-Step: line for commits that don't advance a plan. When present, all trailers MUST be in the same paragraph โ see ยง 5.5 "Trailer block formatting" below.)
Peer-staged files in the index โ scope the commit, do not unstage. If git status shows files staged that are NOT in $STAGE_FILE (peer-session WIP a peer staged before /commit ran), do not try to clear them with git restore --staged <path> โ R2 of pre-tool-use-stash-guard.py blocks that, and the block is correct (peer WIP is not yours). Instead, scope the commit by content with --only -- <paths>:
xargs -r -a "$STAGE_FILE" -d '\n' git commit -o {{COMMIT_NO_VERIFY}} -m "$(cat <<'EOF'
<conventional commit message here>
Plan-Step: <topic>/<n>
Co-Authored-By: Claude <noreply@anthropic.com>
EOF
)" --
git commit --only -- <paths> (-o is the short form) builds the commit from exactly those paths and ignores the rest of the index, so peer's staged WIP stays staged but is excluded from your commit. Communicate the trade-off plainly: "peer's staged file stays staged, my commit excludes it" โ that's the intended outcome.
{{COMMIT_NO_VERIFY}} resolves per stack via commands.yaml. It is empty during the pre-commit-framework removal transition (Step 4 + /validate-change already covers what pre-commit hooks would catch โ see base/WORKFLOW.md โ "Commit pipeline policy"); it flips to --no-verify once a stack's repos have all dropped their .pre-commit-config.yaml / .husky/ / lefthook.yml. Do not hand-add --no-verify to bypass a hook. If a hook is failing under the legacy config, fix the underlying issue or accelerate that repo's phase-5 migration.
If this commit advances a plan in docs/plans/.../3-in-progress/, include exactly one Plan-*: trailer (paragraph break before it). The actual stage transition + meta update is folded into this same commit by Step 2.4 (pre-commit fold) for Plan-Start/Plan-Park/Plan-Step and Step 2.6 for Plan-Closes โ post-commit-plan.py is verify-only since v1.20.0 and emits [STEER] drift if the fold is missing. Do not git mv plan files by hand. Bare git commit (bypassing this skill) skips Step 2.4 and produces the drift; recovery is pyrun .claude/tools/plan apply-trailer <topic> <kind> followed by git commit --amend.
| Trailer | Effect |
|---|---|
Plan-Start: <topic> | mv pair to 3-in-progress/; set Started-At=<HEAD>, Status=IN_PROGRESS |
Plan-Park: <topic> | mv pair to 2-approved/; set Status=APPROVED |
Plan-Step: <topic>/<n> | render the rendered region of progress.md from <n>-numbered steps in git log |
Plan-Out-Of-Scope: <path> | mark <path> as expected non-coverage in this commit (silences "surprise file" drift) |
Plan-Closes: <topic> | run plan reconcile; mv to 4-done/ + Status=DONE on pass; [STEER] on drift |
Plan-Start and Plan-Park are mutually exclusive on the same commit. Run .claude/hooks/pyrun .claude/tools/plan reconcile <topic> locally first when closing a plan to preview the drift report.
Trailer block formatting (load-bearing): git interpret-trailers --parse โ which the post-commit hook uses to read Plan-*: values โ only treats the last contiguous paragraph of the commit message as trailers. Any blank line splits the trailer block; everything before that blank line is body text and is ignored.
โ Right โ single contiguous trailer paragraph at message-end:
feat(x): subject line
Body paragraph explaining the change.
Plan-Step: my-topic/3
Co-Authored-By: Claude <noreply@anthropic.com>
โ Wrong โ blank line between trailers; only Co-Authored-By: parses, Plan-Step: is silently dropped:
feat(x): subject line
Body paragraph explaining the change.
Plan-Step: my-topic/3
Co-Authored-By: Claude <noreply@anthropic.com>
This gotcha bit the plan-tooling implementation's own Step 7-13 commits โ every Plan-Step: trailer in those commits sat above a blank-line-separated Co-Authored-By: and was therefore invisible to the post-commit hook. Pinned in tests/plan/test_git_io.py::test_parse_trailers_blank_line_between_trailers_drops_earlier. When in doubt, run git log -1 --format=%B | git interpret-trailers --parse on a draft commit before pushing.
Best-effort cleanup: remove .claude/.force-concurrent-*$CLAUDE_SESSION_ID* sentinels (the glob reaps both --force-concurrent and --quick sentinels for this session โ both bypass pre-tool-use-validate-gate.py and stash-guard R1, both must auto-expire), write session progress, append decisions. Plan stage transitions and rendered-region refresh are folded into the trailer commit itself by Steps 2.4 / 2.6 (v1.20.0+ pre-commit fold) โ do not move plan files or edit progress.md meta blocks by hand. The post-commit post-commit-plan.py hook verifies the fold landed and emits [STEER] on drift; a clean commit shows nothing on its stderr. See reference.md for the full post-commit checklist.
git log -1 --stat
See reference.md for common mistakes and error handling.
/validate-change โ MUST run before commit (hard gate)/tdd for test-driven implementation before committing/brainstorm for design decisions before implementation/security for standalone security checks| Scenario | Pressure Type | Skill Defense |
|---|---|---|
| "Just commit everything, I'll review later" | time + sunk cost | Lattice check (Step 2.5) is a HARD GATE โ blocks commit without /validate-change |
| "Commit but skip validate-change, it passed yesterday" | authority | No manual skip flag; exception only for non-plan docs (*.md outside docs/plans/**) or session-tracking files |
| "It's just a plan-doc edit, validate-change is overkill" | docs-only loophole | Step 2.5 carve-out explicitly excludes docs/plans/** โ plan files run validate-change like any other code change |
| "Plan-Closes is fine, the post-commit hook will catch any drift" | trust-the-fallback | Step 2.6 HARD GATE runs plan reconcile --check pre-commit; the post-commit [STEER] is advisory only โ if ignored, the plan is stranded in 3-in-progress while the commit lands |
| "The plan tool's writes never go through validate-change anyway" | invisible-side-channel | v1.20.0+: plan-CLI verbs self-track into files.txt (via _lib.register_session_files), and Step 2.4 folds Plan-* trailer effects into the trailer's own commit โ every plan-emitted change flows through /validate-change โ /commit like any other change |
"Just git commit with the trailer, the hook will move the files" | bypass-the-skill | post-commit-plan.py (v1.20.0+) is verify-only โ bare git commit with Plan-Start: produces a [STEER] drift warning AND leaves the pair where it was; recovery is plan apply-trailer <topic> <kind> + git commit --amend |
| "Amend the last commit with these unrelated changes" | scope creep | Session file tracking isolates changes; code review catches unrelated additions |
| "Other sessions are running, just commit anyway" | time + impatience | Step 0 HARD GATE blocks; backed by pre-tool-use-stash-guard.py PreToolUse hook so raw git commit/git stash push is also denied (defense in depth) |