@sabaiway/agent-workflow-kit 5.10.0 → 5.11.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,165 @@
1
+ // advisor-matrix.mjs — the advisor-matrix STRUCTURE check (delegation Plan 3, Phase 1), as a leaf.
2
+ //
3
+ // A doc-parity BINDING proves a token is somewhere in a file. Correspondence is a different claim,
4
+ // and it is the one this table needs: the dispatch mode doc's routing matrix must carry one row per
5
+ // registry row, in registry order, with every CELL equal. A reorder, a duplicate, a dropped row, a
6
+ // mis-bound vehicle and a drifted availability or returns cell all leave every token present — so a
7
+ // token check passes every one of them, which is exactly why this exists beside the bindings rather
8
+ // than as more of them.
9
+ //
10
+ // Its own module rather than more lines in doc-parity.mjs: the lint's identity is "a closed registry
11
+ // of value bindings plus the runner over them", and a table parser with its own refusal vocabulary is
12
+ // a second thing. Split, each is a file you can hold whole — and the parser gets its own test file.
13
+ //
14
+ // Read-only: never writes, never commits, spawns nothing. Node built-ins plus the advisor registry
15
+ // only. No side effects on import; no CLI (it is reached through doc-parity).
16
+
17
+ import { readFileSync } from 'node:fs';
18
+ import { dirname, resolve } from 'node:path';
19
+ import { fileURLToPath } from 'node:url';
20
+ import { ADVISOR_ROWS, ADVISOR_MATRIX_HEADER, ADVISOR_MATRIX_COLUMNS, renderAdvisorMatrix } from './dispatch-advisor.mjs';
21
+
22
+ const KIT_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
23
+
24
+ export const ADVISOR_MATRIX_DOC = 'references/modes/dispatch.md';
25
+
26
+ // The table is found through an ANCHORED surface, not by "the first header line anywhere". Header
27
+ // search alone is maskable in a way even an exactly-one rule does not close: a faithful copy of the
28
+ // table plus a canonical one whose HEADER drifted leaves exactly one matching header — the decoy's —
29
+ // and the check then reads the decoy and passes. The markers make the checked surface a property of
30
+ // the DOC, so a copy outside them can neither stand in for the table nor hide its drift, and a
31
+ // drifted header INSIDE them leaves the surface with zero headers and fails closed.
32
+ export const ADVISOR_MATRIX_BEGIN = '<!-- advisor-matrix:begin -->';
33
+ export const ADVISOR_MATRIX_END = '<!-- advisor-matrix:end -->';
34
+
35
+ export const readKitDoc = (rel) => readFileSync(resolve(KIT_ROOT, rel), 'utf8');
36
+
37
+ // Both line endings are ordinary here. Splitting on '\n' alone leaves a trailing '\r' on every line
38
+ // of a CRLF-authored doc, and the marker match survives it (it trims) while the exact header match
39
+ // does not — so the check would fail a CORRECT document while naming a drifted header. One split,
40
+ // before anything compares.
41
+ const splitLines = (text) => String(text).split(/\r?\n/);
42
+
43
+ const linesMatching = (lines, marker) => lines.flatMap((line, i) => (line.trim() === marker ? [i] : []));
44
+
45
+ // Only leading and trailing BLANK lines are dropped. Trimming the joined block with String.trim()
46
+ // would also eat significant edge whitespace INSIDE the first and last lines, which is drift the
47
+ // comparison is supposed to see.
48
+ const trimBlankEdges = (lines) => {
49
+ let start = 0;
50
+ let end = lines.length;
51
+ while (start < end && lines[start].trim() === '') start += 1;
52
+ while (end > start && lines[end - 1].trim() === '') end -= 1;
53
+ return lines.slice(start, end);
54
+ };
55
+
56
+ // parseAdvisorMatrix(text) → { ok: true, lines, rows } | { ok: false, reason }. `lines` is the whole
57
+ // anchored block; `rows` are its class rows, parsed for the DIAGNOSIS only — the verdict is the
58
+ // whole-block comparison in checkMatrixStructure, so a deleted alignment rule, a rewritten harness
59
+ // lane and an extra row are all caught, none of which a class-row walk would ever see.
60
+ export const parseAdvisorMatrix = (text) => {
61
+ const lines = splitLines(text);
62
+ const begins = linesMatching(lines, ADVISOR_MATRIX_BEGIN);
63
+ const ends = linesMatching(lines, ADVISOR_MATRIX_END);
64
+ if (begins.length !== 1 || ends.length !== 1) {
65
+ return { ok: false, reason: `the anchored matrix surface is not unique — found ${begins.length} "${ADVISOR_MATRIX_BEGIN}" and ${ends.length} "${ADVISOR_MATRIX_END}" (exactly one of each is required)` };
66
+ }
67
+ if (ends[0] < begins[0]) {
68
+ return { ok: false, reason: 'the matrix end marker precedes its begin marker — the anchored surface is inverted' };
69
+ }
70
+ const surface = trimBlankEdges(lines.slice(begins[0] + 1, ends[0]));
71
+ const headers = surface.filter((line) => line === ADVISOR_MATRIX_HEADER);
72
+ if (headers.length !== 1) {
73
+ return { ok: false, reason: `the anchored matrix surface carries ${headers.length} header line(s) equal to "${ADVISOR_MATRIX_HEADER}" — exactly one is required` };
74
+ }
75
+ const rows = [];
76
+ for (const line of surface.slice(surface.indexOf(ADVISOR_MATRIX_HEADER) + 1)) {
77
+ if (!line.startsWith('|')) continue;
78
+ const cells = line.split('|').slice(1, -1).map((c) => c.trim());
79
+ // Arity refuses OUTRIGHT rather than skipping the row: a row whose cell count disagrees with the
80
+ // header's is malformed whatever it says, and skipping it would report the drift as a MISSING
81
+ // class row — a true verdict reached through a misleading sentence.
82
+ if (cells.length !== ADVISOR_MATRIX_COLUMNS.length) {
83
+ return { ok: false, reason: `a matrix row carries ${cells.length} cell(s), the table has ${ADVISOR_MATRIX_COLUMNS.length} columns: ${line.trim()}` };
84
+ }
85
+ const classCell = /^`(.+)`$/.exec(cells[0]);
86
+ // A row whose first cell is not a backticked class joins no CLASS comparison — the harness lane,
87
+ // or an interloper. Neither escapes: the whole-block equality below sees every line.
88
+ if (classCell === null) continue;
89
+ rows.push(Object.fromEntries(ADVISOR_MATRIX_COLUMNS.map(({ key }, i) => [key, i === 0 ? classCell[1] : cells[i]])));
90
+ }
91
+ return { ok: true, lines: surface, rows };
92
+ };
93
+
94
+ const quoted = (classes) => classes.map((c) => `\`${c}\``).join(', ');
95
+
96
+ // The DIAGNOSIS over the class rows, and the ORDER of its questions is the point. A POSITIONAL walk
97
+ // reads a deleted middle row as a corrupted step-class cell in the row that slid up behind it —
98
+ // technically a difference at that index, and a useless pointer for whoever has to fix the doc. So
99
+ // membership is settled first (duplicated / missing / unregistered), then ORDER, and only over rows
100
+ // that agree on both does a cell comparison run — where the first differing CELL is named, because
101
+ // "row 3 disagrees" leaves the reader to diff four columns by eye.
102
+ const rowDrift = (actual, expected) => {
103
+ const actualClasses = actual.map((r) => r.stepClass);
104
+ const expectedClasses = expected.map((r) => r.stepClass);
105
+
106
+ const duplicated = actualClasses.filter((c, i) => actualClasses.indexOf(c) !== i);
107
+ if (duplicated.length > 0) return `the advisor matrix names ${quoted([...new Set(duplicated)])} more than once — the registry has exactly one row per step class`;
108
+
109
+ const missing = expectedClasses.filter((c) => !actualClasses.includes(c));
110
+ if (missing.length > 0) return `the advisor matrix is missing ${missing.length} registry row(s): ${quoted(missing)}`;
111
+
112
+ const unregistered = actualClasses.filter((c) => !expectedClasses.includes(c));
113
+ if (unregistered.length > 0) return `the advisor matrix names ${unregistered.length} row(s) the registry does not: ${quoted(unregistered)}`;
114
+
115
+ const outOfOrder = actualClasses.findIndex((c, i) => c !== expectedClasses[i]);
116
+ if (outOfOrder !== -1) return `the advisor matrix is out of registry order — row ${outOfOrder + 1} is \`${actualClasses[outOfOrder]}\`, the registry has \`${expectedClasses[outOfOrder]}\``;
117
+
118
+ for (const [i, row] of actual.entries()) {
119
+ const e = expected[i];
120
+ const differing = ADVISOR_MATRIX_COLUMNS.find(({ key }) => row[key] !== e[key]);
121
+ if (differing !== undefined) {
122
+ return `matrix row ${i + 1} (\`${row.stepClass}\`): the ${differing.label} cell reads "${row[differing.key]}", the registry has "${e[differing.key]}"`;
123
+ }
124
+ }
125
+ return null;
126
+ };
127
+
128
+ // blockDrift(actual, expected) → null when the two blocks are IDENTICAL, else the first line that
129
+ // disagrees. It is the verdict and the fallback diagnosis in one: "the blocks are equal" is exactly
130
+ // "no line disagrees", so there is no second comparison to keep in step with this one — and the
131
+ // null return is the path every green run takes, not an unreachable defensive branch.
132
+ const blockDrift = (actual, expected) => {
133
+ for (let i = 0; i < Math.max(actual.length, expected.length); i += 1) {
134
+ if (actual[i] === expected[i]) continue;
135
+ if (actual[i] === undefined) return `the anchored matrix is missing line ${i + 1}, which the canonical table renders as ${JSON.stringify(expected[i])}`;
136
+ if (expected[i] === undefined) return `the anchored matrix carries an extra line ${i + 1}: ${JSON.stringify(actual[i])}`;
137
+ return `matrix line ${i + 1} reads ${JSON.stringify(actual[i])}, the canonical table renders ${JSON.stringify(expected[i])}`;
138
+ }
139
+ return null;
140
+ };
141
+
142
+ // checkMatrixStructure(readText) → the same shape a doc-parity binding result carries, so the report,
143
+ // the --check line and the --json payload all render it through their existing paths. The VERDICT is
144
+ // whole-block equality against the canonical render: every way the doc's table can stop being the
145
+ // registry's table is one comparison, and the enumeration of those ways never has to be maintained.
146
+ export const checkMatrixStructure = (readText = readKitDoc) => {
147
+ const rel = ADVISOR_MATRIX_DOC;
148
+ const expected = ADVISOR_ROWS.map(({ stepClass, vehicle, availabilityNote, returns }) => ({ stepClass, vehicle, availabilityNote, returns }));
149
+ let text;
150
+ try {
151
+ text = readText(rel);
152
+ } catch (err) {
153
+ return { constant: 'advisor-matrix-structure', files: [{ rel, ok: false, reason: `unreadable (${(err && err.code) || (err && err.message) || 'read failed'})` }], ok: false };
154
+ }
155
+ const parsed = parseAdvisorMatrix(text);
156
+ if (parsed.ok === false) {
157
+ return { constant: 'advisor-matrix-structure', files: [{ rel, ok: false, reason: parsed.reason }], ok: false };
158
+ }
159
+ // The VERDICT is the block comparison; the row walk only refines the MESSAGE when it can point at a
160
+ // class row. A block difference the row walk cannot explain (the alignment rule, the harness lane,
161
+ // an interloping row, whitespace) keeps the line-level pointer.
162
+ const drift = blockDrift(parsed.lines, splitLines(renderAdvisorMatrix()));
163
+ const reason = drift === null ? null : (rowDrift(parsed.rows, expected) ?? drift);
164
+ return { constant: 'advisor-matrix-structure', files: [{ rel, ok: reason === null, reason }], ok: reason === null };
165
+ };
@@ -264,7 +264,7 @@ const CATALOG = [
264
264
  invocation: invocationOf('dispatch'),
265
265
  group: 'Orchestrate',
266
266
  kind: WRITER,
267
- oneLine: 'Measure delegation: check a sub-task brief’s contract block (form only — never whether the task is genuinely bounded), pre-register an acceptance wave with its thresholds, record one observation, open a delegated thread from that brief, wait for that one dispatch to answer — a wait that ends without an answer says so and authorizes nothing — absorb the wrapper’s receipt back into the ledger, fold the returned work or close the thread with a recorded degrade, and print the per-class report of how much a delegated sub-task actually bought, derived from what was dispatched, returned and folded. Writes only its own ledger file beside the repo; never commits.',
267
+ oneLine: 'Measure delegation: check a sub-task brief’s contract block (form only — never whether the task is genuinely bounded), ask which vehicle should carry that kind of sub-task on THIS machine and what past threads of that kind actually did — advice you may ignore, never a gate, printed on its own and again under a valid contract check, pre-register an acceptance wave with its thresholds, record one observation, open a delegated thread from that brief, wait for that one dispatch to answer — a wait that ends without an answer says so and authorizes nothing — absorb the wrapper’s receipt back into the ledger, fold the returned work or close the thread with a recorded degrade, and print the per-class report of how much a delegated sub-task actually bought, derived from what was dispatched, returned and folded. After a landing, deliver the handoff verbatim and count only what is fully measurable. Writes only its own ledger file beside the repo; never commits.',
268
268
  },
269
269
  {
270
270
  // NEVER `guarded` — that kind promises dry-run-first, which these writers do not have; the
@@ -273,7 +273,7 @@ const CATALOG = [
273
273
  invocation: invocationOf('worktrees'),
274
274
  group: 'Orchestrate',
275
275
  kind: WRITER,
276
- oneLine: 'Run features in parallel git worktrees: provision an isolated sibling copy, list them, stage a finished one back onto clean main (the commit still asks in dialogue), and remove a live-verified landed one. No preview step; list is read-only; cleanup --abandon destroys unlanded work.',
276
+ oneLine: 'Run features in parallel git worktrees: provision an isolated sibling copy, list them, print the cold-start prompt a fresh session in one of them needs — where it is, what MAIN answers NOW rather than what the record froze, the handoff as the one way back, and the bars nothing enforces — stage a finished one back onto clean main (the commit still asks in dialogue), and remove a live-verified landed one. No preview step; list is read-only and so is prompt; cleanup --abandon destroys unlanded work.',
277
277
  },
278
278
  ];
279
279
 
@@ -11,7 +11,15 @@
11
11
  // up to INDEX_LAG_PATH_CAP with the remainder stated. A dirty tracked SUBMODULE is named
12
12
  // separately with its own recovery. Fail-closed on an undecidable probe. This BLOCKS the
13
13
  // deliberate partial commit by design — `--no-verify` is the stated residual, not a flag;
14
- // 1. recomputes the CURRENT tree fingerprint (the review-state export — read-only git plumbing);
14
+ // 1. recomputes the CURRENT tree fingerprint (the review-state export — read-only git plumbing),
15
+ // and decides the two CONTENT-FREE lanes here, because no store read can answer them: a
16
+ // payload with no bytes yields the ONE fingerprint every clean moment of every repository
17
+ // shares, so any receipt at it was minted elsewhere and may attest nothing. With a DIRTY
18
+ // index that means staged content the payload cannot see (a gitlink hidden by
19
+ // `submodule.<name>.ignore` / `diff.ignoreSubmodules`) and the guard REFUSES, naming the
20
+ // configuration rather than re-staging; with a clean index the commit introduces no bytes
21
+ // (`--allow-empty`, a message-only `--amend`, an empty merge) and the guard PASSES while
22
+ // stating that it attests NOTHING — the receipt arms are skipped, never satisfied;
15
23
  // 2. reads the LATEST completed final-run record from the core-evidence store (only the latest
16
24
  // attempt at a fingerprint is authoritative — a green receipt is DEAD once a later attempt at
17
25
  // the same fingerprint went red) and refuses on: no record for the current fingerprint · a
@@ -38,7 +46,10 @@ import { pathToFileURL, fileURLToPath } from 'node:url';
38
46
  import { spawnSync } from 'node:child_process';
39
47
  import { createHash } from 'node:crypto';
40
48
  import { computeTreeFingerprint, buildState, decideCheck, quoteReportName, shellQuoteArg } from './review-state.mjs';
41
- import { resolveEvidencePath, readEvidence, authoritativeOfKind, canonicalKindSerialization, computeWorkingState } from './core-evidence.mjs';
49
+ import {
50
+ resolveEvidencePath, readEvidence, authoritativeOfKind, canonicalKindSerialization,
51
+ computeWorkingState, CONTENT_FREE_FINGERPRINT,
52
+ } from './core-evidence.mjs';
42
53
  import { resolveLcovPath } from './coverage-check.mjs';
43
54
  import { GATES_REL, loadDeclaration } from './run-gates.mjs';
44
55
  import { computeFlowDecision } from './flow-check.mjs';
@@ -177,15 +188,42 @@ export const decideIndexLag = (state) => {
177
188
  };
178
189
 
179
190
  // runGuard({ cwd, env }) → { code, lines }. Every refusal names its recovery.
191
+ // The flow decision's two renders, shared by every lane that consults it — the empty-commit lane
192
+ // reaches the same store through the same consumer mode, so its wording can never drift from the
193
+ // byte-carrying one.
194
+ const flowRefusalLines = (flow) => [
195
+ `commit-guard: REFUSED — the flow store refuses this commit: ${flow.refusals[0]}`,
196
+ ...flow.refusals.slice(1).map((r) => `commit-guard: flow refusal — ${r}`),
197
+ ];
198
+ const flowAdvisoryLines = (flow) => (flow.present && flow.armed
199
+ ? flow.advisories.map((a) => `commit-guard: flow advisory — ${a}`)
200
+ : []);
201
+
180
202
  export const runGuard = ({ cwd = process.cwd(), env = process.env } = {}) => {
181
203
  const rootTop = gitLine(['rev-parse', '--show-toplevel'], cwd);
182
204
  if (rootTop == null) return { code: 1, lines: ['commit-guard: not a git work tree — nothing to guard'] };
183
205
  // FIRST: a pure tree property needing no store read. Its recovery re-stages the tree and re-mints
184
206
  // the receipt, so every arm below is re-decided anyway — naming a stale fingerprint ahead of it
185
207
  // would send the operator down a recovery they must redo.
186
- const indexLag = decideIndexLag(computeWorkingState(cwd));
208
+ const working = computeWorkingState(cwd);
209
+ const indexLag = decideIndexLag(working);
187
210
  if (indexLag !== null) return indexLag;
188
211
  const fingerprint = computeTreeFingerprint(cwd);
212
+ // The CONTENT-FREE lanes — the second pure tree property, decided here for the same reason the
213
+ // index lag is: no store read can answer it. A payload with no bytes states nothing about what
214
+ // this commit will carry, and its fingerprint is the ONE value every clean moment of every
215
+ // repository shares, so a receipt found at it was minted by some other moment, possibly at
216
+ // another base. Such evidence must therefore decide NOTHING here — neither refuse nor attest
217
+ // (the same fact flow-check-rungs.mjs applies to a red final). The index tells the two lanes
218
+ // apart, and `computeWorkingState` probes it with --ignore-submodules=none precisely so a
219
+ // config-hidden gitlink cannot pass for a clean one.
220
+ const contentFree = fingerprint === CONTENT_FREE_FINGERPRINT;
221
+ if (contentFree && working.stagedDirty) {
222
+ return {
223
+ code: 1,
224
+ lines: [`commit-guard: REFUSED — the index carries staged content the fingerprint domain cannot see (a submodule gitlink hidden from \`git diff\` by \`submodule.<name>.ignore\` or \`diff.ignoreSubmodules\`), so no final receipt can describe what this commit will carry. Recovery: clear that ignore setting (or set it to \`none\`) until \`git diff --cached --no-ext-diff\` shows the change, then re-run node ${FINAL_RUN_TOOL} --final`],
225
+ };
226
+ }
189
227
  // The guard's OWN reads resolve FIXED git-dir paths — a stray AW_CORE_EVIDENCE / AW_LCOV_FILE
190
228
  // in the committing shell must never redirect the LAST line of defense to a forged artifact
191
229
  // (the env stays a test seam for the producers, never for this consumer).
@@ -194,6 +232,27 @@ export const runGuard = ({ cwd = process.cwd(), env = process.env } = {}) => {
194
232
  if ((read.malformed ?? 0) > 0 || read.readError) {
195
233
  return { code: 1, lines: [`commit-guard: REFUSED — evidence store unavailable (${read.malformed} malformed line(s)${read.readError ? `, read error: ${read.readError}` : ''}); inspect ${storePath}`] };
196
234
  }
235
+ // The empty-commit lane: the index equals HEAD, so this commit introduces no bytes at all
236
+ // (`git commit --allow-empty`, a message- or signature-only `--amend`, an empty merge). The
237
+ // guard's whole claim is about bytes, so here it has none to make and says so. The receipt arms
238
+ // are SKIPPED rather than satisfied — consulting a content-free receipt would make the outcome
239
+ // depend on which stray clean moment happened to be recorded last. The flow arm still runs: an
240
+ // empty commit still moves HEAD, and the chain bookkeeping is about that, not about bytes; its
241
+ // own fingerprint-keyed correlations (the D10 flow→final binding, receipt and degrade coverage)
242
+ // drop out inside flow-check on the same fact, so no stray content-free record decides here
243
+ // either. Store HEALTH is deliberately NOT waived above: an unreadable store is not a
244
+ // correlation, and a store that cannot be read cannot answer the chain questions either.
245
+ if (contentFree) {
246
+ const emptyFlow = computeFlowDecision({ cwd, consumer: 'commit-guard', treeCarriesBytes: false });
247
+ if (emptyFlow.refusals.length > 0) return { code: 1, lines: flowRefusalLines(emptyFlow) };
248
+ return {
249
+ code: 0,
250
+ lines: [
251
+ 'commit-guard: PASS — this commit changes no tree content (the index contributes no tree-content delta and the work tree adds nothing), so the guard attests NOTHING about it: a receipt found at the shared content-free fingerprint cannot be correlated to THIS moment or base',
252
+ ...flowAdvisoryLines(emptyFlow),
253
+ ],
254
+ };
255
+ }
197
256
  const finals = authoritativeOfKind(read.records, 'final');
198
257
  const current = finals.find((r) => r.fingerprintBefore === fingerprint) ?? null;
199
258
  if (!current) {
@@ -255,15 +314,7 @@ export const runGuard = ({ cwd = process.cwd(), env = process.env } = {}) => {
255
314
  // evidenceHashes.flow and the store has since VANISHED (present=false) — a deletion must
256
315
  // never un-arm the binding. A no-store repo with no flow-bearing receipt still yields zero
257
316
  // refusals (byte-exact pre-flow behavior).
258
- if (flow.refusals.length > 0) {
259
- return {
260
- code: 1,
261
- lines: [
262
- `commit-guard: REFUSED — the flow store refuses this commit: ${flow.refusals[0]}`,
263
- ...flow.refusals.slice(1).map((r) => `commit-guard: flow refusal — ${r}`),
264
- ],
265
- };
266
- }
317
+ if (flow.refusals.length > 0) return { code: 1, lines: flowRefusalLines(flow) };
267
318
  // The ship-receipt arm: the SAME normative decision review-state --check computes, over a
268
319
  // SANITIZED env — the receipts/evidence/flow-store overrides are producer test seams, and
269
320
  // honoring them HERE would let a forged store bypass the fixed-path reads above.
@@ -278,10 +329,7 @@ export const runGuard = ({ cwd = process.cwd(), env = process.env } = {}) => {
278
329
  const flowSuffix = flow.present && flow.armed
279
330
  ? ` — flow: armed${review.flowLabels?.length ? ` (${review.flowLabels.join('; ')})` : ''}`
280
331
  : '';
281
- const flowAdvisoryLines = flow.present && flow.armed
282
- ? flow.advisories.map((a) => `commit-guard: flow advisory — ${a}`)
283
- : [];
284
- return { code: 0, lines: [`commit-guard: PASS — a green final receipt binds this exact tree (${fingerprint.slice(0, 12)}…), the declaration and evidence hashes match, and the review obligations are satisfied${flowSuffix}`, ...flowAdvisoryLines] };
332
+ return { code: 0, lines: [`commit-guard: PASS — a green final receipt binds this exact tree (${fingerprint.slice(0, 12)}…), the declaration and evidence hashes match, and the review obligations are satisfied${flowSuffix}`, ...flowAdvisoryLines(flow)] };
285
333
  };
286
334
 
287
335
  const HELP = `commit-guard — the read-only pre-commit guard (agent-workflow family, D10).
@@ -291,7 +339,16 @@ Usage:
291
339
 
292
340
  Re-runs NOTHING: refuses an INDEX that lags the verified working tree (FIRST — unstaged tracked
293
341
  paths, reviewable untracked paths, or a dirty tracked submodule, each named with its recovery;
294
- this deliberately blocks a partial commit), then recomputes the current tree fingerprint and binds
342
+ this deliberately blocks a partial commit), then recomputes the current tree fingerprint.
343
+
344
+ A CONTENT-FREE fingerprint (a payload with no bytes — the value every clean work tree shares)
345
+ decides WITHOUT a receipt, because one found there was minted by another clean moment: with a dirty
346
+ index it REFUSES (staged content the payload cannot see — a gitlink hidden by
347
+ \`submodule.<name>.ignore\` / \`diff.ignoreSubmodules\`; the recovery is that configuration, not
348
+ \`git add\`), and with a clean index it PASSES stating it attests NOTHING (the commit carries no
349
+ bytes: \`--allow-empty\`, a message-only \`--amend\`, an empty merge).
350
+
351
+ Otherwise it binds
295
352
  the LATEST completed run-gates --final receipt — refusing on { no receipt for this tree · a red
296
353
  latest attempt · before≠after · declaration content drift · evidence-hash drift · lcov drift ·
297
354
  a flow-store refusal (a PRESENT store's open own chain / base motion / coverage — verbatim; no
@@ -160,6 +160,16 @@ export const computeTreeFingerprint = (cwd, fsx) => {
160
160
  return payload == null ? null : createHash('sha256').update(payload).digest('hex');
161
161
  };
162
162
 
163
+ // The fingerprint of a CONTENT-FREE payload — a clean work tree emits no bytes at all, so this ONE
164
+ // value is shared by every clean moment of every repository. It therefore identifies no working
165
+ // state and correlates to no base: evidence found at it was minted by some other clean moment,
166
+ // possibly at another base, and can decide nothing in either direction. Two situations reach it,
167
+ // and only the INDEX tells them apart (never the payload): an empty commit, where the index equals
168
+ // HEAD and no byte enters the repository, and staged content the payload cannot see — a gitlink
169
+ // hidden from `git diff` by an ignore configuration. Read by the consumers that correlate a
170
+ // fingerprint to a base (flow-check-rungs.mjs #65) and by commit-guard's two content-free lanes.
171
+ export const CONTENT_FREE_FINGERPRINT = createHash('sha256').update(Buffer.alloc(0)).digest('hex');
172
+
163
173
  // The index↔worktree split the fingerprint deliberately CANNOT see: the payload above concatenates
164
174
  // the staged and unstaged diffs, so against an otherwise-empty index a hunk moving into the index
165
175
  // leaves it byte-identical — while `git commit` builds the commit from the INDEX alone. This is the