@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.
- package/CHANGELOG.md +92 -0
- package/README.md +2 -1
- package/SKILL.md +5 -1
- package/capability.json +1 -1
- package/package.json +1 -1
- package/references/hooks/state-block-guard.mjs +363 -0
- package/references/modes/commit-guard.md +14 -7
- package/references/modes/hook.md +3 -1
- package/references/modes/state-block-guard.md +188 -0
- package/tools/commands.mjs +8 -1
- package/tools/commit-guard.mjs +139 -8
- package/tools/core-evidence.mjs +229 -18
|
@@ -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.
|
package/tools/commands.mjs
CHANGED
|
@@ -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
|
|
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',
|
package/tools/commit-guard.mjs
CHANGED
|
@@ -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:
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
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
|
|