@sabaiway/agent-workflow-kit 3.12.0 → 3.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,188 @@
1
+ ### Mode: state-block-guard
2
+
3
+ The opt-in **closing-state-block detector**, wired as a Claude Code `Stop` hook. It ships as a
4
+ **self-contained** runtime at `references/hooks/state-block-guard.mjs` (no kit imports — a placed
5
+ copy keeps working if the kit is uninstalled) and it **writes nothing**: this mode is read-only, and
6
+ the wiring is yours to paste (see *No writer yet*, below).
7
+
8
+ **Read this first — what it is and is not.** A `Stop` hook runs when a turn has already ENDED, so it
9
+ **cannot un-send the message it judges**. This is **DETECTION, not prevention**. What it buys is
10
+ exactly one thing, and the thing is worth a release: a defect that previously depended on a human
11
+ re-reading every closing message becomes an immediate, visible warning. Any surface that describes
12
+ this as preventing the defect is describing it wrongly.
13
+
14
+ **What it detects.** Two shapes, both of which are not merely *discouraged* at Stop time but
15
+ **provably false** — which is what makes them mechanically judgeable at all:
16
+
17
+ - **The «what I need from you» slot answering "nothing".** A turn that ends is by definition idle, so
18
+ a resume from the reader IS required; a slot saying otherwise states something untrue. Judged over
19
+ the slot's **first clause only** — a slot that names a real ask and then, after a `;` or a full
20
+ stop, adds «ничего другого не нужно» is honest and passes. A COMMA does not open a new clause, so
21
+ the comma-joined version of that sentence is flagged; see the residuals. Hedged forms count: «не
22
+ требуется», «ваше присутствие не требуется прямо сейчас», «nothing right now», «n/a».
23
+ - **Announce-and-stop.** The «what next» slot promising first-person imminent work («беру…»,
24
+ «начинаю…», «I'll start…») while the turn ends. A promise **gated on something named** («после
25
+ вашего да — беру…», «once CI finishes…») states a dependency instead of a false start, and passes.
26
+
27
+ **A message with no recognisable block at all is SILENT by default.** This kit does not mandate the
28
+ three-part closing block — it is a per-project dialogue contract — so warning whenever a block is
29
+ absent would fire on nearly every turn of a project that never adopted it, and a hook that runs on
30
+ every single turn must not become that noise. Pass `--require-block` in the wiring to turn the
31
+ absent-block report on; the honest residual of the default is stated plainly below.
32
+
33
+ **What it does NOT judge.** Quoted material (`>` lines) and fenced code blocks are stripped before
34
+ anything is matched, and each slot label must start its own line. Otherwise a message that *discusses*
35
+ this contract — pasting the very specimen the guard catches — would be judged on the paste instead of
36
+ its own closing block. Labels are then grouped into whole blocks — never picked one slot at a time,
37
+ which would splice a stray trailing label onto an earlier block and judge a block nobody wrote. The
38
+ **last started** group decides: if the message ends mid-block, the turn did not end on a block at
39
+ all, and `--require-block` says so rather than falling back to an earlier one the turn had moved past.
40
+
41
+ **How it reports (the channel is part of the contract).** At exit 0 a `Stop` hook's **stderr goes to
42
+ the debug log and is seen by nobody**; the one user-visible lane is JSON on stdout carrying
43
+ `systemMessage`. The guard emits exactly that:
44
+
45
+ ```json
46
+ {"systemMessage": "state-block-guard — the closing state block is defective (CONTINUATION-STALL):\n• …"}
47
+ ```
48
+
49
+ The same channel carries FAILURES, under their own headline — reporting «the closing state block is
50
+ defective» when no block was ever read would blame the writer for the guard's own blindness.
51
+ «Exit 0 on every path» and «say nothing on every path» are different promises, and only the first is
52
+ kept: if the guard cannot see the turn — the payload will not parse, is not a JSON object, or
53
+ carries a `last_assistant_message` that is present but not a string — it is blind, and it says so
54
+ instead of going quiet. An ABSENT field is different: that is a host delivering nothing, and it stays
55
+ silent.
56
+
57
+ It **never** emits `decision`, `continue`, `stopReason`, or `hookSpecificOutput`, and it **exits 0 on
58
+ every path** — including every failure path. A hook that runs on every single turn must never become
59
+ the blocker or the noise. Blocking the stop would re-enter the model on a message the reader has
60
+ already seen, which is not what a detector is for; the payload does carry `stop_hook_active`, so a
61
+ future intervening variant has the loop guard it would need, but that variant is deliberately not
62
+ this one.
63
+
64
+ **Where it reads the closing message — one source, deliberately.** The `last_assistant_message` field
65
+ of the `Stop` payload, and nothing else. A `transcript_path` fallback was built and then **removed**:
66
+ the transcript file is written asynchronously, so a lagging one can END on the previous turn's
67
+ assistant entry with nothing after it, and no check on the file can tell that apart from the current
68
+ turn. A fallback that may be confidently wrong about WHICH TURN it read is worse than no fallback,
69
+ because being confidently wrong is the single failure a detector must not have.
70
+
71
+ The consequence is stated rather than hidden: **a host that does not deliver `last_assistant_message`
72
+ gets no detection at all**, silently — not a warning on every turn, which is what a host-shaped
73
+ condition would produce. An EMPTY delivered message is different: it is text, so a turn that ended
74
+ with no prose is judged as having no block, which `--require-block` will report.
75
+
76
+ **Language.** The slot labels and both banned sets carry Russian and English twins, because the
77
+ contract this enforces was written for a Russian-dialogue deployment. A deployment in another
78
+ dialogue language gets no detection until its labels are added — that is a real limit, not a
79
+ configuration you can set today.
80
+
81
+ **No writer yet — and why, plainly.** Every other placed thing in this kit arrives through a
82
+ consent-gated writer. This one does not, deliberately:
83
+
84
+ - extending the gate-approval writer (`tools/gate-hook.mjs`) would grow a second placement path
85
+ through the family's **highest-blast-radius** component — the one that wires command
86
+ auto-approval — for the benefit of a detector;
87
+ - a second dedicated writer would duplicate that writer's hardened placement discipline (symlink
88
+ refusal, malformed-settings refusal, unknown-script refusal, merge-don't-clobber), which this
89
+ project's own DRY rule forbids.
90
+
91
+ The wiring is eight lines of JSON, and hand-editing `settings.json` from a paste-ready block is the
92
+ form this family already sanctions. When the detector has earned a writer in real use, it gets one.
93
+
94
+ **Wiring it (paste-ready, two steps).**
95
+
96
+ 1. Copy the runtime into your project (it is self-contained; the copy keeps working without the kit).
97
+ **Check before you copy** — this is a plain copy, not a guarded writer, so the safety is yours:
98
+
99
+ ```
100
+ ls -ld .claude .claude/hooks
101
+ ls -l .claude/hooks/state-block-guard.mjs
102
+ ```
103
+
104
+ Stop if `.claude` or `.claude/hooks` is a **symlink** (a copy would write through it to somewhere
105
+ you did not choose), and stop if the target already exists — including a dangling symlink. Only
106
+ then:
107
+
108
+ ```
109
+ mkdir -p .claude/hooks
110
+ cp "${CLAUDE_SKILL_DIR}/references/hooks/state-block-guard.mjs" .claude/hooks/state-block-guard.mjs
111
+ ```
112
+
113
+ To UPDATE an existing copy, `diff` it against the bundled file first and replace it deliberately.
114
+ No `cp` flag makes this safe for you: `-n` is not portable in the same way everywhere, and none of
115
+ them refuse a symlinked parent directory.
116
+
117
+ 2. Merge this into `.claude/settings.json` — **merge, do not clobber.** Three cases, and getting the
118
+ third wrong silently disables someone else's hook:
119
+ - no `hooks` key → add the whole object below;
120
+ - a `hooks` key WITHOUT `Stop` (for example only the gate-approval `PreToolUse` entry) → add the
121
+ `Stop` key beside it;
122
+ - a `hooks.Stop` array that ALREADY EXISTS → **append one element to that array**, never replace
123
+ it and never write a second `Stop` key. If `hooks.Stop` is present but is not an array, stop and
124
+ fix that by hand first — pasting into it would destroy whatever is there.
125
+
126
+ ```json
127
+ {
128
+ "hooks": {
129
+ "Stop": [
130
+ {
131
+ "hooks": [
132
+ {
133
+ "type": "command",
134
+ "command": "node \"$CLAUDE_PROJECT_DIR/.claude/hooks/state-block-guard.mjs\" --require-block",
135
+ "timeout": 10
136
+ }
137
+ ]
138
+ }
139
+ ]
140
+ }
141
+ }
142
+ ```
143
+
144
+ Drop `--require-block` if your project has not adopted the three-part closing block and you only want
145
+ the two lying-slot checks. `--require-block` is the **only** argument accepted: an unrecognised one
146
+ makes the guard refuse to judge the turn and say so, rather than quietly running in the weaker mode
147
+ you did not choose.
148
+
149
+ A `Stop` hook is read at session start, so it becomes live in the **next** session, not the current
150
+ one.
151
+
152
+ **Trust posture (state it plainly when asking consent):** the guard reads the closing assistant
153
+ message of each turn and writes one warning line. It runs a Node process on every turn end, it does
154
+ not read your repository, it makes no network call, and it can approve nothing — its blast radius is
155
+ the warning text. It is not a sandbox and not a permission control.
156
+
157
+ **Honest residuals:**
158
+
159
+ - **Detection, not prevention** — restated because it is the one thing that must not blur.
160
+ - **The judgement is LEXICAL, and that is a layer with limits, not a temporary weakness.** It matches
161
+ slot labels and phrase sets on Unicode-aware word boundaries, so an honest «мне нужно ваше
162
+ подтверждение» is not read as «не нужно» and an interrogative «не нужно ли…» is not read as a
163
+ refusal. It cannot parse a sentence, and it cannot recognise a wording it has never been told
164
+ about. Two rules that tried to close an edge here were **deleted rather than tightened a third
165
+ time**, because each next version needed a second classifier — the residuals below are what
166
+ replaced them. A named specimen of the cost: «подтвердите, что ничего не упущено» is a real ask
167
+ and is FLAGGED, because the banned word sits inside it. Phrase the ask without the word.
168
+ - **A comma-joined qualifier is flagged.** «одно да, ничего другого не нужно» warns; the same
169
+ sentence with a `;` or a full stop passes. The rule that tried to exempt the comma form kept
170
+ letting a real "nothing" through behind a harmless prefix, so it was removed and the false flag is
171
+ accepted instead. It costs one line and names its own fix.
172
+ - **A condition is bound to a promise by TOKEN ORDER inside one segment, which is an approximation.**
173
+ «после вашего да — беру класс» passes; «беру класс, а если тест упадёт, сообщу» is flagged. Two
174
+ known misreadings follow from the approximation, both accepted: an honest TRAILING gate («беру,
175
+ когда вы скажете») is flagged, and a gate belonging to an earlier comma-clause («если тест упадёт,
176
+ сообщу, а сейчас начинаю…») wrongly excuses the promise after it. Leading with the gate avoids the
177
+ first; the second is a miss this layer cannot close without parsing.
178
+ - **The English side is weaker than the Russian side, structurally.** Russian promises are action
179
+ verbs («беру», «начинаю»); English ones are pronoun+modal («I'll», «I will»), which cannot tell
180
+ starting from waiting. Waiting is explicitly excluded — «I'll wait for your approval» passes,
181
+ because waiting is what a turn that ends actually does — but the exclusion is a list, and an
182
+ unusual way of saying "I am waiting" will be flagged.
183
+ - **A host that does not deliver `last_assistant_message` gets no detection**, silently. There is no
184
+ transcript fallback, on purpose — see above.
185
+ - **Without `--require-block`, a turn that drops the block entirely is not detected.** That is the
186
+ cost of not warning every turn in projects that never adopted the block.
187
+ - **Removing the wiring silently removes the detector.** There is no rung that notices a hook that
188
+ stopped being wired.
@@ -140,6 +140,13 @@ const CATALOG = [
140
140
  kind: WRITER,
141
141
  oneLine: 'Auto-approve your own declared gate commands (docs/ai/gates.json) via a Claude Code hook — exact matches only, previews first (opt-in).',
142
142
  },
143
+ {
144
+ key: 'state-block-guard',
145
+ invocation: invocationOf('state-block-guard'),
146
+ group: 'Configure',
147
+ kind: READ_ONLY,
148
+ oneLine: 'Warn when a turn ends on a defective closing state block — a "nothing needed from you" answer or a promise of work that never started (Claude Code Stop hook; detection, never prevention; you paste the wiring).',
149
+ },
143
150
  {
144
151
  key: 'bridge-settings',
145
152
  invocation: invocationOf('bridge-settings'),
@@ -222,7 +229,7 @@ const CATALOG = [
222
229
  invocation: invocationOf('commit-guard'),
223
230
  group: 'Orchestrate',
224
231
  kind: READ_ONLY,
225
- oneLine: 'The read-only pre-commit guard: binds the LATEST completed run-gates --final receipt to the CURRENT tree refusing on any fingerprint, declaration, evidence-hash, or lcov drift, a dangling later attempt, or unsatisfied review obligations; re-runs no gate or test.',
232
+ oneLine: 'The read-only pre-commit guard: FIRST refuses an INDEX that lags the working tree — so the commit cannot ship less than was verified, which deliberately blocks a partial commit (--no-verify stays the residual) — then binds the LATEST completed run-gates --final receipt to the CURRENT tree, refusing on any fingerprint, declaration, evidence-hash, or lcov drift, a dangling later attempt, or unsatisfied review obligations; re-runs no gate or test.',
226
233
  },
227
234
  {
228
235
  key: 'doc-parity',
@@ -2,6 +2,15 @@
2
2
  // commit-guard.mjs — the read-only pre-commit guard (strip-the-kit 2.5, D10). It re-runs NO
3
3
  // gate/test subprocess: the heavy D3(b)/(c)/(d) verification lives in `run-gates --final`, whose
4
4
  // receipt this guard binds. `--check`:
5
+ // 0. refuses an INDEX that lags the verified working tree — FIRST, before the fingerprint is
6
+ // computed. The gates and the fingerprint describe the WORKING tree while `git commit` builds
7
+ // the commit from the INDEX alone, and the fingerprint domain is identical either way, so
8
+ // without this arm a lagging index ships a strict SUBSET of what was verified. Refuses on
9
+ // tracked paths differing index↔worktree or reviewable untracked-not-ignored paths (the same
10
+ // never-committable filter the fingerprint applies; ignored paths never refuse), naming them
11
+ // up to INDEX_LAG_PATH_CAP with the remainder stated. A dirty tracked SUBMODULE is named
12
+ // separately with its own recovery. Fail-closed on an undecidable probe. This BLOCKS the
13
+ // deliberate partial commit by design — `--no-verify` is the stated residual, not a flag;
5
14
  // 1. recomputes the CURRENT tree fingerprint (the review-state export — read-only git plumbing);
6
15
  // 2. reads the LATEST completed final-run record from the core-evidence store (only the latest
7
16
  // attempt at a fingerprint is authoritative — a green receipt is DEAD once a later attempt at
@@ -18,11 +27,11 @@
18
27
 
19
28
  import { readFileSync, lstatSync } from 'node:fs';
20
29
  import { resolve } from 'node:path';
21
- import { pathToFileURL } from 'node:url';
30
+ import { pathToFileURL, fileURLToPath } from 'node:url';
22
31
  import { spawnSync } from 'node:child_process';
23
32
  import { createHash } from 'node:crypto';
24
- import { computeTreeFingerprint, buildState, decideCheck } from './review-state.mjs';
25
- import { resolveEvidencePath, readEvidence, authoritativeOfKind, canonicalKindSerialization } from './core-evidence.mjs';
33
+ import { computeTreeFingerprint, buildState, decideCheck, quoteReportName, shellQuoteArg } from './review-state.mjs';
34
+ import { resolveEvidencePath, readEvidence, authoritativeOfKind, canonicalKindSerialization, computeWorkingState } from './core-evidence.mjs';
26
35
  import { resolveLcovPath } from './coverage-check.mjs';
27
36
  import { GATES_REL, loadDeclaration } from './run-gates.mjs';
28
37
 
@@ -44,10 +53,130 @@ export const resolveGitHooksPath = (projectDir) => {
44
53
  return line == null ? null : resolve(projectDir, line);
45
54
  };
46
55
 
56
+ // How many offending paths the index-lag refusal names before it states a remainder count: enough
57
+ // to act on, bounded so a wide lag cannot bury a pre-commit hook's output.
58
+ export const INDEX_LAG_PATH_CAP = 10;
59
+
60
+ // The recovery must name the run-gates the CONSUMER actually has. A repo-relative literal is only
61
+ // correct inside this monorepo; every installed deployment keeps the tool beside this file. Shell-
62
+ // quoted, because an install path carrying a space or a metacharacter would otherwise render an
63
+ // instruction that is unrunnable at best and dangerous to paste at worst.
64
+ const FINAL_RUN_TOOL = shellQuoteArg(fileURLToPath(new URL('./run-gates.mjs', import.meta.url)));
65
+
66
+ // ONE budget across every named category — a per-category cap would print 2× the stated number.
67
+ const renderBudgeted = (paths, budget) => ({
68
+ text: paths.slice(0, Math.max(budget, 0)).map(quoteReportName).join(', '),
69
+ used: Math.min(paths.length, Math.max(budget, 0)),
70
+ });
71
+
72
+ // The ONE ordered recovery plan — text and executable `argv` built from the SAME structure, so a
73
+ // test can run exactly what the operator is shown instead of reconstructing its own commands.
74
+ // Order matters: the submodule step first (staging and re-running --final before it would stale the
75
+ // fresh receipt at once), then the index bits, because `git add -A` CANNOT restage a skip-worktree
76
+ // or assume-unchanged entry — printing it alone is a recovery that silently does nothing. The two
77
+ // bits get SEPARATE commands: one `update-index` invocation carrying both flags applies only one.
78
+ // The text points at `git ls-files -v` rather than pasting names — the displayed list is capped,
79
+ // and a name safe to display is not automatically safe to paste into a shell.
80
+ export const buildIndexLagRecovery = (state) => {
81
+ const flags = state.flaggedPaths ?? [];
82
+ const steps = [];
83
+ if (state.unstagedSubmodulePaths.length > 0) {
84
+ steps.push({ text: 'commit or clean INSIDE every dirty submodule named above and stage its gitlink — a root-level git add -A cannot reach a submodule\'s own worktree' });
85
+ }
86
+ for (const [bit, key] of [['--no-skip-worktree', 'skipWorktree'], ['--no-assume-unchanged', 'assumeUnchanged']]) {
87
+ const affected = flags.filter((flag) => flag[key]);
88
+ if (affected.length === 0) continue;
89
+ // Scoped to the LAGGING paths only, never to `git ls-files -v`: that set also holds every
90
+ // de-materialised sparse-checkout entry, and clearing THEIR bit before `git add -A` would stage
91
+ // their deletions. The cap is handled by iteration, not by a wider enumeration. The executable
92
+ // form is offered ONLY when every affected name survives a byte round-trip — a lossily decoded
93
+ // name would address a DIFFERENT path, so there the text stands alone and says so.
94
+ const exact = affected.every((flag) => flag.exactName);
95
+ steps.push({
96
+ text: `clear the ${bit.slice(5)} bit on the bit-carrying path(s) named above — it is what makes git add -A a no-op on them — with git update-index ${bit} -- <path>, for those paths ONLY (never every entry git ls-files -v reports: that set includes de-materialised sparse paths whose deletions would then be staged)${exact ? '' : '; at least one of these names carries bytes that do not decode cleanly, so the name shown above is LOSSY and this refusal cannot give you a runnable command for it — its record is visible in git ls-files -v -z, and clearing that one is a by-hand step'}`,
97
+ ...(exact ? { argv: ['update-index', bit, '--', ...affected.map((flag) => flag.rel)] } : {}),
98
+ });
99
+ }
100
+ steps.push({ text: 'run git add -A from the work-tree root', argv: ['add', '-A'] });
101
+ steps.push({ text: `re-run node ${FINAL_RUN_TOOL} --final` });
102
+ steps.push({ text: 'commit the WHOLE tree' });
103
+ return steps;
104
+ };
105
+
106
+ // decideIndexLag(state) → a refusal, or null when the index already carries the verified tree.
107
+ // The gates and the fingerprint both describe the WORKING tree; `git commit` takes the INDEX, and
108
+ // the fingerprint domain cannot tell the two apart — so without this arm a lagging index ships a
109
+ // strict subset of what was verified (it did, on 2026-07-25). FAIL-CLOSED on an undecidable probe:
110
+ // the guard's whole claim is that the committed bytes ARE the verified bytes, and it cannot make
111
+ // that claim about a tree it failed to read.
112
+ export const decideIndexLag = (state) => {
113
+ if (state == null) {
114
+ return { code: 1, lines: ['commit-guard: REFUSED — the index/worktree comparison could not be decided (a git probe failed); re-run inside the work tree and inspect `git status` by hand before committing'] };
115
+ }
116
+ // THREE categories, because they take DIFFERENT recoveries. A bit-carrying path folded into the
117
+ // plain list would be un-actionable: its clause is the only one whose recovery is not `git add -A`,
118
+ // and on cap overflow the plain paths could hide every one of them.
119
+ const flaggedSet = new Set((state.flaggedPaths ?? []).map((flag) => flag.rel));
120
+ const all = [...state.unstagedPaths, ...state.untrackedPaths];
121
+ const plain = all.filter((rel) => !flaggedSet.has(rel));
122
+ const bitCarrying = all.filter((rel) => flaggedSet.has(rel));
123
+ const submodules = state.unstagedSubmodulePaths;
124
+ const total = plain.length + bitCarrying.length + submodules.length;
125
+ if (total === 0) return null;
126
+ // Every non-empty category reserves a slot before the budget is spent — a clause that names no
127
+ // path cannot deliver the recovery it exists to state.
128
+ const groups = [plain, bitCarrying, submodules];
129
+ const rendered = [];
130
+ let spent = 0;
131
+ groups.forEach((group, index) => {
132
+ if (group.length === 0) {
133
+ rendered[index] = { text: '', used: 0 };
134
+ return;
135
+ }
136
+ const stillToReserve = groups.slice(index + 1).filter((later) => later.length > 0).length;
137
+ rendered[index] = renderBudgeted(group, INDEX_LAG_PATH_CAP - spent - stillToReserve);
138
+ spent += rendered[index].used;
139
+ });
140
+ const hidden = total - spent;
141
+ const remainder = hidden > 0 ? `, plus ${hidden} further path(s) not listed` : '';
142
+ const clauses = [];
143
+ if (plain.length > 0) {
144
+ clauses.push(`paths the index does not carry: ${rendered[0].text}`);
145
+ }
146
+ if (bitCarrying.length > 0) {
147
+ clauses.push(`path(s) held back by a skip-worktree / assume-unchanged index bit: ${rendered[1].text}`);
148
+ }
149
+ if (submodules.length > 0) {
150
+ clauses.push(`tracked submodule(s) not proven current: ${rendered[2].text}`);
151
+ }
152
+ // ONE ordered recovery, and every step must actually converge. The submodule step comes FIRST:
153
+ // staging and re-running --final before it would stale the fresh receipt at once. The index-bit
154
+ // step comes next, because `git add -A` CANNOT restage a skip-worktree / assume-unchanged entry —
155
+ // printing it alone would be a recovery that silently does nothing. It deliberately points at
156
+ // `git ls-files -v` rather than pasting names: the list above is capped, and a filename safe to
157
+ // display is not automatically safe to paste into a shell.
158
+ const steps = buildIndexLagRecovery(state);
159
+ // The iterate-until-silent hint must also fire when the CAP hid work — otherwise a truncated
160
+ // list of submodules with no index bits would send the operator to --final and commit while
161
+ // unnamed ones are still unhandled.
162
+ const converge = hidden > 0 || (state.flaggedPaths ?? []).length > 0
163
+ ? ' The listed paths are capped: re-run this guard after each pass and it names the next batch, until it names none — that is the completion signal.'
164
+ : '';
165
+ return {
166
+ code: 1,
167
+ lines: [`commit-guard: REFUSED — the index does NOT carry the whole CURRENT working tree, so this commit would leave part of it behind: ${clauses.join('; ')}${remainder}. To recover, in order: ${steps.map((step, i) => `(${i + 1}) ${step.text}`).join('; ')}. An intentional partial commit stays git commit --no-verify.${converge}`],
168
+ };
169
+ };
170
+
47
171
  // runGuard({ cwd, env }) → { code, lines }. Every refusal names its recovery.
48
172
  export const runGuard = ({ cwd = process.cwd(), env = process.env } = {}) => {
49
173
  const rootTop = gitLine(['rev-parse', '--show-toplevel'], cwd);
50
174
  if (rootTop == null) return { code: 1, lines: ['commit-guard: not a git work tree — nothing to guard'] };
175
+ // FIRST: a pure tree property needing no store read. Its recovery re-stages the tree and re-mints
176
+ // the receipt, so every arm below is re-decided anyway — naming a stale fingerprint ahead of it
177
+ // would send the operator down a recovery they must redo.
178
+ const indexLag = decideIndexLag(computeWorkingState(cwd));
179
+ if (indexLag !== null) return indexLag;
51
180
  const fingerprint = computeTreeFingerprint(cwd);
52
181
  // The guard's OWN reads resolve FIXED git-dir paths — a stray AW_CORE_EVIDENCE / AW_LCOV_FILE
53
182
  // in the committing shell must never redirect the LAST line of defense to a forged artifact
@@ -128,11 +257,13 @@ const HELP = `commit-guard — the read-only pre-commit guard (agent-workflow fa
128
257
  Usage:
129
258
  node commit-guard.mjs --check [--cwd <dir>]
130
259
 
131
- Re-runs NOTHING: recomputes the current tree fingerprint and binds the LATEST completed
132
- run-gates --final receipt refusing on { no receipt for this tree · a red latest attempt ·
133
- before≠after · declaration content drift · evidence-hash drift · lcov drift · unsatisfied review
134
- obligations (the review-state decision) }. Wire it into pre-commit; \`git commit --no-verify\`
135
- stays the stated residual (self-discipline, not a security boundary).
260
+ Re-runs NOTHING: refuses an INDEX that lags the verified working tree (FIRST unstaged tracked
261
+ paths, reviewable untracked paths, or a dirty tracked submodule, each named with its recovery;
262
+ this deliberately blocks a partial commit), then recomputes the current tree fingerprint and binds
263
+ the LATEST completed run-gates --final receipt refusing on { no receipt for this tree · a red
264
+ latest attempt · before≠after · declaration content drift · evidence-hash drift · lcov drift ·
265
+ unsatisfied review obligations (the review-state decision) }. Wire it into pre-commit;
266
+ \`git commit --no-verify\` stays the stated residual (self-discipline, not a security boundary).
136
267
 
137
268
  Exit codes: 0 pass; 1 refused (reason named); 2 usage.`;
138
269