@sabaiway/agent-workflow-kit 5.10.0 → 5.11.1

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.
Files changed (74) hide show
  1. package/CHANGELOG.md +152 -0
  2. package/README.md +2 -2
  3. package/SKILL.md +1 -1
  4. package/capability.json +1 -1
  5. package/package.json +1 -1
  6. package/references/hooks/gate-approve.mjs +13 -2
  7. package/references/hooks/state-block-guard.mjs +14 -2
  8. package/references/modes/commit-guard.md +11 -8
  9. package/references/modes/core-evidence.md +1 -1
  10. package/references/modes/dispatch.md +32 -10
  11. package/references/modes/worktrees.md +47 -3
  12. package/references/scripts/archive-changelog.mjs +14 -3
  13. package/references/scripts/archive-decisions.mjs +14 -3
  14. package/references/scripts/archive-issues.mjs +14 -3
  15. package/references/scripts/check-docs-size.mjs +14 -3
  16. package/references/scripts/migrate-gates.mjs +13 -2
  17. package/tools/ack-write.mjs +3 -3
  18. package/tools/advisor-matrix.mjs +165 -0
  19. package/tools/autonomy-doctor.mjs +2 -3
  20. package/tools/bridge-settings.mjs +2 -3
  21. package/tools/cheap-agents.mjs +3 -3
  22. package/tools/commands.mjs +4 -5
  23. package/tools/commit-guard.mjs +77 -20
  24. package/tools/core-evidence.mjs +12 -3
  25. package/tools/coverage-check.mjs +2 -3
  26. package/tools/delegation.mjs +2 -3
  27. package/tools/detect-backends.mjs +2 -3
  28. package/tools/dispatch-advisor.mjs +323 -0
  29. package/tools/dispatch.mjs +174 -109
  30. package/tools/doc-parity.mjs +69 -16
  31. package/tools/family-registry.mjs +3 -3
  32. package/tools/flow-adoption-mint.mjs +70 -0
  33. package/tools/flow-append.mjs +309 -0
  34. package/tools/flow-chain-state.mjs +91 -0
  35. package/tools/flow-check-cores.mjs +35 -6
  36. package/tools/flow-check-rungs.mjs +20 -2
  37. package/tools/flow-check.mjs +22 -8
  38. package/tools/flow-delta-proof.mjs +307 -0
  39. package/tools/flow-record.mjs +1 -1
  40. package/tools/flow-store-read.mjs +3 -3
  41. package/tools/flow-store.mjs +35 -812
  42. package/tools/flow-subset-budget.mjs +81 -0
  43. package/tools/flow-writer.mjs +3 -3
  44. package/tools/gate-hook.mjs +3 -3
  45. package/tools/gates-init.mjs +3 -3
  46. package/tools/grounding.mjs +2 -3
  47. package/tools/hide-footprint.mjs +2 -3
  48. package/tools/inject-methodology.mjs +2 -3
  49. package/tools/lens-region.mjs +2 -3
  50. package/tools/manifest/validate.mjs +2 -3
  51. package/tools/migrate-adr-store.mjs +3 -3
  52. package/tools/observation-builder.mjs +123 -0
  53. package/tools/path-inventory.mjs +2 -3
  54. package/tools/procedures.mjs +3 -3
  55. package/tools/receipt-deadline.mjs +2 -3
  56. package/tools/recipes.mjs +2 -3
  57. package/tools/recommendations.mjs +3 -3
  58. package/tools/release-scan.mjs +2 -3
  59. package/tools/repo-search.mjs +2 -3
  60. package/tools/review-state.mjs +3 -3
  61. package/tools/run-gates.mjs +2 -3
  62. package/tools/sandbox-masks.mjs +3 -3
  63. package/tools/satellite-locator.mjs +179 -0
  64. package/tools/set-autonomy.mjs +2 -3
  65. package/tools/set-flow.mjs +3 -3
  66. package/tools/set-recipe.mjs +2 -3
  67. package/tools/setup-backends.mjs +3 -3
  68. package/tools/store-append.mjs +2 -2
  69. package/tools/uninstall.mjs +2 -3
  70. package/tools/velocity-profile.mjs +3 -3
  71. package/tools/worktree-handoff-return.mjs +369 -0
  72. package/tools/worktree-prompt.mjs +190 -0
  73. package/tools/worktrees-record.mjs +171 -0
  74. package/tools/worktrees.mjs +311 -300
@@ -0,0 +1,70 @@
1
+ // flow-adoption-mint.mjs — the adoption mint (#58): the frontmatter planId reader and mintAdoption,
2
+ // which binds a plan's chain identity to {frontmatter planId, plan content digest} and lands the
3
+ // chain's FIRST record. Split out of flow-store.mjs unchanged (baseline-practices tranche 2); the
4
+ // facade re-exports both names.
5
+ //
6
+ // The plan file is READ, never written. Imports run ONE way: this leaf mints through the store's
7
+ // ONE append door (flow-append.mjs) and never reaches the flow-store.mjs facade or its sibling mint
8
+ // leaf — the one-line sha256Hex below is a deliberate copy rather than a sideways import.
9
+
10
+ import { createHash } from 'node:crypto';
11
+ import { readFileSync } from 'node:fs';
12
+ import { resolve } from 'node:path';
13
+ import { FLOW_SCHEMA_VERSION, CHAIN_KIND, canonicalFlowDigest } from './flow-record.mjs';
14
+ import { resolveBase, computeTreeFingerprint } from './core-evidence.mjs';
15
+ import { flowStoreStop, resolveFlowStorePath, readFlowStore, deriveFlowOwner } from './flow-store-read.mjs';
16
+ import { appendFlowRecord } from './flow-append.mjs';
17
+
18
+ const stop = flowStoreStop;
19
+ const sha256Hex = (bytes) => createHash('sha256').update(bytes).digest('hex');
20
+
21
+ const PLAN_ID_FRONTMATTER_HINT = 'planId: <your-stable-plan-id>';
22
+
23
+ // Identity binds only a CLOSED leading frontmatter block — an unterminated block never yields an
24
+ // id; CRLF is normalized per line so line endings never fork chain identity.
25
+ export const readPlanFrontmatterId = (text) => {
26
+ const lines = text.split('\n').map((line) => line.replace(/\r$/, ''));
27
+ if (lines[0]?.trim() !== '---') return null;
28
+ const close = lines.findIndex((line, i) => i > 0 && line.trim() === '---');
29
+ if (close === -1) return null;
30
+ for (const line of lines.slice(1, close)) {
31
+ const m = /^planId:[ \t]*(\S+)[ \t]*$/.exec(line);
32
+ if (m) return m[1];
33
+ }
34
+ return null;
35
+ };
36
+
37
+ export const mintAdoption = ({ cwd = process.cwd(), env = process.env, deps = {}, planPath, planLabel, cycle = 1, commitEpoch = 0, timestamp = new Date().toISOString() } = {}) => {
38
+ const owner = deriveFlowOwner(cwd);
39
+ if (owner == null) throw stop('not inside a git work tree — the adoption mint derives the owning worktree and the tree fingerprint from git (fail closed)');
40
+ let planBytes;
41
+ try {
42
+ planBytes = readFileSync(resolve(cwd, planPath));
43
+ } catch (err) {
44
+ throw stop(`cannot read the plan file ${planPath} (${(err && err.code) || (err && err.message) || err}) — the adoption mint READS an existing plan file (fail closed)`);
45
+ }
46
+ const planId = readPlanFrontmatterId(planBytes.toString('utf8'));
47
+ if (planId == null) {
48
+ throw stop(`the plan file ${planPath} carries no frontmatter planId — plan filenames are never chain identity. Add this line inside a leading "---" frontmatter block:\n${PLAN_ID_FRONTMATTER_HINT}\nand re-run; the plan file is never written by this mint (fail closed)`);
49
+ }
50
+ const planDigest = sha256Hex(planBytes);
51
+ // A pre-append read purely for the NAMED refusal: the locked append would refuse a second
52
+ // adoption anyway, but only this comparison can surface whether the plan content still matches.
53
+ const resolved = resolveFlowStorePath(cwd, env);
54
+ const adopted = resolved == null ? undefined : readFlowStore(resolved).records
55
+ .find((r) => r.kind === CHAIN_KIND && r.purpose === 'adoption' && r.planId === planId);
56
+ if (adopted !== undefined) {
57
+ throw stop(adopted.planDigest === planDigest
58
+ ? `plan "${planId}" is already adopted (content digest unchanged — a rename never resets chain identity); adoption is only ever the chain's first record`
59
+ : `plan "${planId}" is already adopted and the plan file content no longer matches its adoption record (recorded ${adopted.planDigest.slice(0, 12)}…, current ${planDigest.slice(0, 12)}…) — re-adopting edited plan content is refused; the digest mismatch is surfaced, never silent`);
60
+ }
61
+ const fingerprint = computeTreeFingerprint(cwd);
62
+ if (fingerprint == null) throw stop('cannot compute the tree fingerprint — the adoption record binds {base, fingerprint} (fail closed)');
63
+ const record = {
64
+ schema: FLOW_SCHEMA_VERSION, kind: CHAIN_KIND, purpose: 'adoption', planId, cycle, round: 0,
65
+ commitEpoch, owner, base: resolveBase(cwd), timestamp, stepId: null, fingerprint,
66
+ planLabel: planLabel ?? planId, createdAt: timestamp, planDigest,
67
+ };
68
+ const { writtenPath } = appendFlowRecord({ cwd, record, env, deps });
69
+ return { writtenPath, record, digest: canonicalFlowDigest(record) };
70
+ };
@@ -0,0 +1,309 @@
1
+ // flow-append.mjs — the flow store's ONE write door (flow-orchestration Phase 2, extracted from
2
+ // flow-store.mjs unchanged by baseline-practices tranche 2): the flow lane over the parameterized
3
+ // createStoreAppendLane, the SEMANTIC append preflight, the two generic append entries, the
4
+ // subset-run serializer + the pre-gate lock probe, and the Decision-7 locked subset-attempt factory.
5
+ //
6
+ // The lock/CAS discipline itself lives one module further down in store-append.mjs (delegation
7
+ // Plan 1 D12); this leaf injects the flow store's nouns, env seams, knob names, validator, parser
8
+ // and the per-kind legality the lane runs inside the critical section.
9
+ //
10
+ // Imports run ONE way: this module composes the two PURE leaves (flow-chain-state.mjs,
11
+ // flow-subset-budget.mjs) and the read half; the two mint leaves compose THIS one. Nothing here
12
+ // reaches back up to the flow-store.mjs facade — that edge would be the cycle
13
+ // test/read-graph-purity.test.mjs reds.
14
+ //
15
+ // flowSemanticPreflight, deepFreezeClone and the lane's own captureRecordSnapshot/
16
+ // appendResolvedRecord stay PRIVATE: the factory-only rule for subset-attempt records is kept by
17
+ // NOT publishing them, so a hand-built record can never forge a fresh counting context.
18
+
19
+ import {
20
+ FLOW_SCHEMA_VERSION, CHAIN_KIND, validateFlowRecord, validateChainSequence, validateSupersessions,
21
+ canonicalFlowDigest, subsetFoldBatchDigest, subsetGateIdsDigest, SUBSET_ATTEMPT_DIAGNOSIS_FROM,
22
+ } from './flow-record.mjs';
23
+ import { derivePregateSubsetIds, GATES_REL } from './gates-declaration.mjs';
24
+ import { CONFIG_REL } from './orchestration-config.mjs';
25
+ import { createStoreAppendLane } from './store-append.mjs';
26
+ import {
27
+ flowStoreStop, resolveFlowStorePath, resolveFlowLockPath, parseFlowStoreText, deriveFlowOwner,
28
+ } from './flow-store-read.mjs';
29
+ import {
30
+ walkChainState, resolveRecordReference, isAuthoritativeReferenceTarget, validateOpenerReference,
31
+ } from './flow-chain-state.mjs';
32
+ import {
33
+ SUBSET_ATTEMPT_MAX_REDS, SUBSET_ATTEMPT_DIAGNOSIS_REDS, subsetAttemptState, subsetAttemptGate,
34
+ } from './flow-subset-budget.mjs';
35
+
36
+ const stop = flowStoreStop;
37
+
38
+ // Wait bound + poll cadence; the env knobs keep hermetic tests off wall-clock.
39
+ export const FLOW_LOCK_WAIT_MS = 10_000;
40
+ export const FLOW_LOCK_POLL_MS = 100;
41
+
42
+ // ── the shared append lane (D12) ──────────────────────────────────────────────────────────────────
43
+
44
+ // The lock/CAS discipline, the fd-custody rules and the serialized append are the EXTRACTION of
45
+ // exactly this module's former code into store-append.mjs, so behavior is unchanged by
46
+ // construction: this store injects its nouns (every refusal still names the flow store), its env
47
+ // seam and knob names, its typed-STOP factory, its record validator, its store-text parser, and
48
+ // the SEMANTIC preflight below. The flow suites are the characterization bar for that claim.
49
+ const flowAppendLane = createStoreAppendLane({
50
+ nouns: { store: 'flow store', adj: 'flow-store', record: 'flow record' },
51
+ envNames: { store: 'AW_FLOW_STORE', waitKnob: 'AW_FLOW_LOCK_WAIT_MS', pollKnob: 'AW_FLOW_LOCK_POLL_MS' },
52
+ stop,
53
+ resolveStorePath: resolveFlowStorePath,
54
+ resolveLockPath: resolveFlowLockPath,
55
+ validateRecord: validateFlowRecord,
56
+ parseStoreText: parseFlowStoreText,
57
+ lockWaitMs: FLOW_LOCK_WAIT_MS,
58
+ lockPollMs: FLOW_LOCK_POLL_MS,
59
+ });
60
+
61
+ const captureRecordSnapshot = flowAppendLane.captureRecordSnapshot;
62
+
63
+ // ── the ONE append (validated, semantic-preflighted, lock-serialized, atomic) ─────────────────────
64
+
65
+ // The store path is always RESOLVED (cwd/env), never caller-supplied — a raw path param would
66
+ // bypass the absolute-normalization door the AW_FLOW_STORE seam enforces. Read, write, and unlock
67
+ // all use the CANONICAL pair acquire returned — nothing is re-derived mid-append.
68
+ export const appendFlowRecord = ({ cwd = process.cwd(), record, env = process.env, deps = {} } = {}) => {
69
+ const { line, snapshot } = captureRecordSnapshot(record);
70
+ // Round-9 fold: subset-attempt records are minted ONLY by the locked factory — foldBatch,
71
+ // subsetDigest, and attemptIndex are DERIVED inside its critical section, and a hand-built
72
+ // record could forge a fresh counting context and bypass the hard-stop budget.
73
+ if (snapshot.kind === 'subset-attempt') {
74
+ throw stop('subset-attempt records are minted ONLY by the locked append factory (appendSubsetAttempt) — a hand-built record could forge a fresh counting context and bypass the hard-stop budget (fail closed)');
75
+ }
76
+ return flowAppendLane.appendResolvedRecord({ cwd, env, deps, preflight: flowSemanticPreflight, makeRecord: () => ({ line, snapshot }) });
77
+ };
78
+
79
+ // appendFlowRecordWithPreflight — the generic lane plus a caller `preflight(records)` hook that
80
+ // runs INSIDE the critical section on the locked store snapshot (Plan 4 Phase 3 / round-1 fold
81
+ // F6): a writer's lock-free cap/completeness walk is advisory — the locked snapshot decides, so
82
+ // a concurrent append can never slip a stale terminal (or a stranding round) through. The hook
83
+ // receives a DEEP-FROZEN CLONE (round-1 fold M5): a buggy preflight throws on any mutation
84
+ // attempt and can never skew the bytes the semantic validation and the write see. A throwing
85
+ // preflight refuses the append with nothing written. The subset-attempt factory-only rule holds
86
+ // on this lane too.
87
+ export const appendFlowRecordWithPreflight = ({ cwd = process.cwd(), record, env = process.env, deps = {}, preflight = null } = {}) => {
88
+ const { line, snapshot } = captureRecordSnapshot(record);
89
+ if (snapshot.kind === 'subset-attempt') {
90
+ throw stop('subset-attempt records are minted ONLY by the locked append factory (appendSubsetAttempt) — a hand-built record could forge a fresh counting context and bypass the hard-stop budget (fail closed)');
91
+ }
92
+ return flowAppendLane.appendResolvedRecord({ cwd, env, deps, preflight: flowSemanticPreflight, makeRecord: (records) => {
93
+ if (preflight != null) preflight(deepFreezeClone(records));
94
+ return { line, snapshot };
95
+ } });
96
+ };
97
+
98
+ const deepFreezeClone = (value) => {
99
+ const freeze = (v) => {
100
+ if (v !== null && typeof v === 'object') {
101
+ Object.values(v).forEach(freeze);
102
+ Object.freeze(v);
103
+ }
104
+ return v;
105
+ };
106
+ return freeze(structuredClone(value));
107
+ };
108
+
109
+ // The SEMANTIC half of the append, handed to the shared lane and run by it INSIDE the critical
110
+ // section on the LOCKED store snapshot (a writer's lock-free walk is advisory — only the locked
111
+ // snapshot decides): per-kind chain legality, reference resolution, the closure rules, the
112
+ // counting-context gate, and supersession legality. An illegal record never lands. Throws a typed
113
+ // STOP; the lane releases the lock and re-throws.
114
+ const flowSemanticPreflight = ({ records, snapshot, storePath }) => {
115
+ if (snapshot.kind === CHAIN_KIND) {
116
+ const chain = records.filter((r) => r.kind === CHAIN_KIND && r.planId === snapshot.planId);
117
+ const existingSeq = validateChainSequence(chain);
118
+ if (!existingSeq.ok) {
119
+ throw stop(`refusing to append to a flow store whose existing chain for plan "${snapshot.planId}" is already illegal (${existingSeq.reason}) — inspect ${storePath}; nothing was written (fail closed)`);
120
+ }
121
+ const candidateSeq = validateChainSequence([...chain, snapshot]);
122
+ if (!candidateSeq.ok) {
123
+ throw stop(`refusing an illegal chain record: ${candidateSeq.reason} — the append-only store never absorbs a record that permanently reddens the checker; nothing was written`);
124
+ }
125
+ // Reference RESOLUTION (#63) on top of the structural half above: a step-OPENING round must
126
+ // digest-reference the chain's prior terminal; a round REVISION re-states its reference
127
+ // byte-bound (validateRoundRevision), so it is never re-classified against a moved terminal.
128
+ if (snapshot.purpose === 'round' && snapshot.opensFrom !== null && walkChainState(chain).mode === 'boundary') {
129
+ const ref = validateOpenerReference(records, snapshot);
130
+ if (!ref.ok) throw stop(`refusing a step-opening round: ${ref.reason} — nothing was written`);
131
+ }
132
+ if (snapshot.purpose === 'refresh') {
133
+ if (resolveRecordReference(records, snapshot.refreshedRecord) === undefined) {
134
+ throw stop(`refusing a refresh whose refreshedRecord does not match the store (no record digests to ${snapshot.refreshedRecord.slice(0, 12)}…) — a re-attestation binds an existing record; nothing was written`);
135
+ }
136
+ if (!isAuthoritativeReferenceTarget(records, snapshot.refreshedRecord)) {
137
+ throw stop('refusing a refresh whose refreshedRecord targets a superseded record — a re-attestation binds the authoritative latest record of its key; nothing was written');
138
+ }
139
+ }
140
+ }
141
+ // The closure rule runs UNDER the lock on the captured snapshot — a writer's lock-free
142
+ // usability pre-check can race a concurrent up/clear, and a justification minted after its
143
+ // mark closed can never satisfy the decide layer (#25), so the store refuses to strand it.
144
+ if (snapshot.kind === 'degrade-justification') {
145
+ const closed = records.some((r) => (r.kind === 'down-mark-up' || r.kind === 'down-mark-clear') && r.target === snapshot.downMark);
146
+ if (closed) {
147
+ throw stop('refusing a degrade-justification whose down-mark is already closed by up/clear — minted-after-close can never satisfy (#25); nothing was written');
148
+ }
149
+ }
150
+ // The same P3-26 discipline for the consult-attestation (Phase-4): the writer derives
151
+ // {cycle, stepId, round} lock-free, so a concurrent converged/park/complete can close or move
152
+ // the step first — under the lock the named plan's chain must be LEGAL and hold an OPEN step
153
+ // (in-step, not parked, not completed) whose {cycle, stepId, round} EQUALS the record's; a
154
+ // stale consult context can never satisfy the decide layer, so the store refuses to strand it.
155
+ if (snapshot.kind === 'consult-attestation') {
156
+ const chain = records.filter((r) => r.kind === CHAIN_KIND && r.planId === snapshot.planId);
157
+ const seq = chain.length === 0 ? { ok: false, reason: 'no chain exists for that plan' } : validateChainSequence(chain);
158
+ if (!seq.ok) {
159
+ throw stop(`refusing a consult-attestation: the plan "${snapshot.planId}" chain is not a legal open carrier under the lock (${seq.reason}); nothing was written`);
160
+ }
161
+ const state = walkChainState(chain);
162
+ const open = state.mode === 'in-step' && !state.parked && !state.completed;
163
+ if (!open || state.stepId !== snapshot.stepId || state.cycle !== snapshot.cycle || state.round !== snapshot.round) {
164
+ const shown = !open
165
+ ? (state.completed ? 'the plan is completed' : state.parked ? 'the plan is parked' : 'no step is open')
166
+ : `the open step is "${state.stepId}" (cycle ${state.cycle}, round ${state.round})`;
167
+ throw stop(`refusing a consult-attestation whose {cycle, stepId, round} does not match the OPEN step under the lock — ${shown}; a consult binds the open step's round, and a stale context can never satisfy; nothing was written`);
168
+ }
169
+ }
170
+ // The Decision-7/8 counting-context gate runs UNDER the lock for BOTH append lanes (the
171
+ // factory computes a passing record; a hand-built one must satisfy the same rules).
172
+ if (snapshot.kind === 'subset-attempt') {
173
+ const gate = subsetAttemptGate(records, snapshot);
174
+ if (!gate.ok) throw stop(`refusing a subset-attempt: ${gate.reason} — nothing was written`);
175
+ }
176
+ const existingSup = validateSupersessions(records);
177
+ if (!existingSup.ok) {
178
+ throw stop(`refusing to append to a flow store whose existing records already violate supersession legality (${existingSup.reason}) — inspect ${storePath}; nothing was written (fail closed)`);
179
+ }
180
+ const candidateSup = validateSupersessions([...records, snapshot]);
181
+ if (!candidateSup.ok) {
182
+ throw stop(`refusing an illegal supersession: ${candidateSup.reason} — the append-only store never absorbs a record that permanently reddens the checker; nothing was written`);
183
+ }
184
+ };
185
+
186
+ // ── the Decision-7 subset-run serializer (round-6 fold) ──────────────────────────────────────────
187
+
188
+ // --pre-review's WHOLE armed cycle (budget preflight → gates → append) holds this lock: without
189
+ // it a parallel run executes gates whose red can no longer be recorded once the winner lands,
190
+ // and an unrecorded red undercounts the budget ("EVERY subset-run red counts"). A SEPARATE lock
191
+ // file beside the store — never the store lock itself, so appends from other lanes never block
192
+ // behind a minutes-long gate run — riding the same CAS/fd-custody/holder-liveness discipline: a
193
+ // crashed holder surfaces as the named DEAD refusal with its rm recovery; a live holder is a
194
+ // bounded loud wait (the queued run then re-reads the budget and re-decides).
195
+ export const SUBSET_RUN_LOCK_INFIX = '.subset-run';
196
+
197
+ export const acquireSubsetRunLock = ({ cwd = process.cwd(), env = process.env, deps = {} } = {}) => {
198
+ const resolved = flowAppendLane.resolveOrStop(cwd, env, 'serialize a subset run against');
199
+ const { lockPath, lockFd, lockIdentity } = flowAppendLane.acquireLock(`${resolved}${SUBSET_RUN_LOCK_INFIX}`, env, deps);
200
+ return { lockPath, release: () => flowAppendLane.releaseLock(lockPath, lockFd, lockIdentity, deps) };
201
+ };
202
+
203
+ // The pre-gate append-lock readiness probe (round-8 fold): acquire and immediately release the
204
+ // ORDINARY append lock through the full acquire discipline — a DEAD/foreign/malformed lock or
205
+ // an unwritable parent surfaces BEFORE any gate spends, with the acquire's own named refusal.
206
+ // Stated residual: a lock landing between this probe and the post-run append still refuses at
207
+ // append time — closing that would mean holding the append lock across the whole gate run.
208
+ export const probeFlowAppendLock = ({ cwd = process.cwd(), env = process.env, deps = {} } = {}) => {
209
+ const resolved = flowAppendLane.resolveOrStop(cwd, env, 'probe');
210
+ const { lockPath, lockFd, lockIdentity } = flowAppendLane.acquireLock(resolved, env, deps);
211
+ const issue = flowAppendLane.releaseLock(lockPath, lockFd, lockIdentity, deps);
212
+ if (issue != null) throw issue;
213
+ };
214
+
215
+ // appendSubsetAttempt — the Decision-7 locked append factory: the chain identity is captured
216
+ // BEFORE the gates run (the caller's `expected` {planId, cycle, stepId, round}) and re-checked
217
+ // under the append lock against the OPEN owning chain; attemptIndex, foldBatch/subsetDigest
218
+ // derivation, and the hard-stop state are computed from the captured store snapshot INSIDE the
219
+ // critical section — a concurrent appender never duplicates an index, and a round/park/complete
220
+ // landing mid-run refuses the append (never a silent misfile). subsetGateIds states what the
221
+ // caller RAN — only the caller knows that — but it never DECIDES the counting context: the
222
+ // factory re-derives the subset from the declaration + config itself (the R10 rider, via the
223
+ // gates-declaration leaf) and refuses a mismatch, so a caller-chosen id list can never forge a
224
+ // fresh subsetDigest and bypass the hard-stop budget.
225
+ export const appendSubsetAttempt = ({ cwd = process.cwd(), env = process.env, deps = {}, expected, subsetGateIds, status, diagnosis = null, base, fingerprint, timestamp = new Date().toISOString() } = {}) => {
226
+ const owner = deriveFlowOwner(cwd);
227
+ if (owner == null) throw stop('not inside a git work tree — the subset-attempt mint derives the owning worktree from git (fail closed)');
228
+ if (expected == null || typeof expected.planId !== 'string' || expected.planId.length === 0
229
+ || !Number.isInteger(expected.cycle) || !Number.isInteger(expected.round)
230
+ || (expected.stepId !== null && typeof expected.stepId !== 'string')) {
231
+ throw stop('the captured chain identity must be {planId, cycle, stepId|null, round} — the factory re-checks exactly this projection under the lock (fail closed)');
232
+ }
233
+ if (!Array.isArray(subsetGateIds)) throw stop("subsetGateIds must be the derived subset's ordered gate-id array (fail closed)");
234
+ if (status !== 'green' && status !== 'red') throw stop(`status must be green | red (got ${JSON.stringify(status)}) — an unrunnable subset refuses with NO attempt record (fail closed)`);
235
+ if (diagnosis !== null && (typeof diagnosis !== 'string' || diagnosis.length === 0)) {
236
+ throw stop(`diagnosis must be null or a non-empty string (got ${JSON.stringify(diagnosis)}) — a mistyped input would otherwise record diagnosis-less silently (round-11 fold; fail closed)`);
237
+ }
238
+ let derived;
239
+ try {
240
+ derived = derivePregateSubsetIds(cwd);
241
+ } catch (err) {
242
+ throw stop(`the pregate subset cannot be re-derived (${(err && err.message) || err}) — an attempt records only a subset the declaration derives (R10; fail closed)`);
243
+ }
244
+ if (derived.length !== subsetGateIds.length || derived.some((id, i) => id !== subsetGateIds[i])) {
245
+ throw stop(`subsetGateIds [${subsetGateIds.join(', ')}] does not match the subset derived from ${GATES_REL} + ${CONFIG_REL} flow.pregateExclude [${derived.join(', ')}] — the factory re-derives the subset itself (R10), so a caller-chosen id list never binds a counting context (fail closed)`);
246
+ }
247
+ // Everything downstream binds the factory-owned DERIVED ids — the caller array stays mutable in
248
+ // the caller's hands (a deps lock-hook could rewrite it after the check above) and must never
249
+ // reach the digest domain.
250
+ const subsetIds = Object.freeze([...derived]);
251
+ let minted = null;
252
+ const value = flowAppendLane.appendResolvedRecord({ cwd, env, deps, preflight: flowSemanticPreflight, makeRecord: (records) => {
253
+ const chain = records.filter((r) => r.kind === CHAIN_KIND && r.planId === expected.planId);
254
+ if (chain.length === 0) throw stop(`no chain exists for plan "${expected.planId}" under the lock — the captured identity is stale; re-run the subset under the current context (fail closed)`);
255
+ const seq = validateChainSequence(chain);
256
+ if (!seq.ok) throw stop(`the plan "${expected.planId}" chain is illegal under the lock (${seq.reason}) — refusing to bind an attempt to it (fail closed)`);
257
+ if (chain[0].owner !== owner) throw stop(`the plan "${expected.planId}" chain is owned by "${chain[0].owner}", not this worktree ("${owner}") — a foreign chain never records this tree's attempts (fail closed)`);
258
+ const state = walkChainState(chain);
259
+ const open = !state.completed && !state.parked;
260
+ const held = open && state.cycle === expected.cycle && state.stepId === expected.stepId && (state.round ?? 0) === expected.round;
261
+ if (!held) {
262
+ const shown = state.completed ? 'the plan completed' : state.parked ? 'the plan parked' : `the open context is {cycle ${state.cycle}, step ${JSON.stringify(state.stepId)}, round ${state.round ?? 0}}`;
263
+ throw stop(`the chain identity moved under the run — captured {cycle ${expected.cycle}, step ${JSON.stringify(expected.stepId)}, round ${expected.round}}, but ${shown} under the lock (a round/park/complete landed mid-run); re-run the subset under the current context (fail closed)`);
264
+ }
265
+ if (expected.stepId === null && state.openers.length > 0) {
266
+ throw stop(`the plan "${expected.planId}" chain sits at a post-convergence boundary — the stepId-null context is legal only before the FIRST round (the adoption context, round-6 fold); open the next step round first (fail closed)`);
267
+ }
268
+ // Round-9 fold: the EXACTLY-ONE-open-owning-chain rule is re-derived UNDER the lock — an
269
+ // adoption/resume landing after the caller's preflight would otherwise record the attempt
270
+ // into an already-ambiguous context. (After the specific refusals above, so a parked or
271
+ // moved TARGET chain keeps its own named diagnosis.)
272
+ const openOwn = [...new Set(records.filter((r) => r.kind === CHAIN_KIND && r.owner === owner).map((r) => r.planId))].filter((planId) => {
273
+ const c = records.filter((r) => r.kind === CHAIN_KIND && r.planId === planId);
274
+ if (c[0].purpose !== 'adoption' || c[0].owner !== owner || !validateChainSequence(c).ok) return false;
275
+ const s = walkChainState(c);
276
+ return !s.completed && !s.parked;
277
+ });
278
+ if (openOwn.length !== 1 || openOwn[0] !== expected.planId) {
279
+ throw stop(`this worktree ("${owner}") owns ${openOwn.length} open chains under the lock (${openOwn.join(', ') || 'none'}) — an attempt records only when exactly ONE open owning chain exists and it is the captured one ("${expected.planId}"); a chain landed mid-run — re-run the subset under the current context (fail closed)`);
280
+ }
281
+ const probe = { planId: expected.planId, cycle: expected.cycle, stepId: expected.stepId, foldBatch: subsetFoldBatchDigest(expected), subsetDigest: subsetGateIdsDigest(subsetIds) };
282
+ const budget = subsetAttemptState(records, probe);
283
+ const attemptIndex = budget.nextIndex;
284
+ if (budget.reds >= SUBSET_ATTEMPT_DIAGNOSIS_REDS && (typeof diagnosis !== 'string' || diagnosis.length === 0)) {
285
+ throw stop(`attempt ${attemptIndex} follows ${budget.reds} reds at this counting context and requires a recorded diagnosis (Decision 8 — the blind budget is spent): investigate, then re-run with a non-empty diagnosis byte-distinct from the prior attempt's; never a wait-for-maintainer`);
286
+ }
287
+ if (attemptIndex < SUBSET_ATTEMPT_DIAGNOSIS_FROM && diagnosis != null) {
288
+ throw stop(`attempt ${attemptIndex} is inside the blind budget (attempts 1-2) — a diagnosis rides only attempt ${SUBSET_ATTEMPT_DIAGNOSIS_FROM} and later (Decision 8); drop the diagnosis input (the captured context may be stale — fail closed, never silently dropped)`);
289
+ }
290
+ const { line, snapshot } = captureRecordSnapshot({
291
+ schema: FLOW_SCHEMA_VERSION, kind: 'subset-attempt', planId: expected.planId, cycle: expected.cycle,
292
+ stepId: expected.stepId, foldBatch: probe.foldBatch, subsetDigest: probe.subsetDigest, attemptIndex,
293
+ ...(typeof diagnosis === 'string' ? { diagnosis } : {}), status, base, fingerprint, timestamp,
294
+ });
295
+ // Computed UNDER the lock from the captured snapshot — a lock-free preflight state could
296
+ // pick the wrong message under a concurrent append.
297
+ const consumedPermit = budget.reds >= SUBSET_ATTEMPT_MAX_REDS;
298
+ const redsAfter = budget.reds + (status === 'red' ? 1 : 0);
299
+ const creditsAfter = budget.credits - (consumedPermit ? 1 : 0);
300
+ minted = {
301
+ attemptIndex,
302
+ redsAtKey: redsAfter,
303
+ reopened: consumedPermit,
304
+ exhaustedAfter: redsAfter >= SUBSET_ATTEMPT_MAX_REDS && creditsAfter <= 0,
305
+ };
306
+ return { line, snapshot };
307
+ } });
308
+ return { ...value, ...minted, digest: canonicalFlowDigest(value.record) };
309
+ };
@@ -0,0 +1,91 @@
1
+ // flow-chain-state.mjs — the chain-state walk and the generic prior-terminal reference validator
2
+ // (#63): priorChainTerminal, walkChainState, resolveRecordReference, isAuthoritativeReferenceTarget
3
+ // and validateOpenerReference. Split out of flow-store.mjs unchanged (baseline-practices tranche 2),
4
+ // which now re-exports every name here.
5
+ //
6
+ // PURE over read results and the LOWEST of the store's five leaves: no store IO, no git, no fs, and
7
+ // the record vocabulary is its only tools sibling. Imports run ONE way — flow-append.mjs composes
8
+ // this module; nothing here reaches back up to the facade.
9
+
10
+ import { CHAIN_KIND, canonicalFlowDigest, authoritativeFlowRecords } from './flow-record.mjs';
11
+
12
+ const TERMINAL_PURPOSES = ['adoption', 'converged', 'complete'];
13
+
14
+ // The record a step-opening round must reference: the latest converged/complete, else the adoption
15
+ // record itself (the plan's first step — the exemption is explicit, never inferred).
16
+ export const priorChainTerminal = (chain) => {
17
+ let terminal = null;
18
+ for (const r of chain) {
19
+ if (r.purpose === 'adoption' && terminal === null) terminal = r;
20
+ else if (r.purpose === 'converged' || r.purpose === 'complete') terminal = r;
21
+ }
22
+ return terminal;
23
+ };
24
+
25
+ // walkChainState(chain) → { mode, parked, completed, cycle, round, stepId, openers, lastTerminal }
26
+ // over ONE plan's raw-order chain. Legality lives in validateChainSequence — callers run it first;
27
+ // this walk only derives state, including each opener with its at-that-point prior terminal.
28
+ export const walkChainState = (chain) => {
29
+ const state = {
30
+ mode: 'boundary', parked: false, completed: false,
31
+ cycle: chain[0]?.cycle ?? null, round: chain[0]?.round ?? null, stepId: null,
32
+ openers: [], lastTerminal: null,
33
+ };
34
+ for (const r of chain) {
35
+ state.cycle = r.cycle;
36
+ if (r.purpose === 'adoption') { state.lastTerminal = r; state.round = r.round; continue; }
37
+ if (r.purpose === 'park') { state.parked = true; continue; }
38
+ if (r.purpose === 'resume') { state.parked = false; continue; }
39
+ if (r.purpose === 'complete') { state.completed = true; state.lastTerminal = r; continue; }
40
+ if (r.purpose === 'converged') { state.mode = 'boundary'; state.lastTerminal = r; state.stepId = null; continue; }
41
+ if (r.purpose === 'unfreeze' && state.mode === 'boundary') { state.mode = 'in-step'; state.stepId = r.stepId; state.round = r.round; continue; }
42
+ if (r.purpose === 'round') {
43
+ if (state.mode === 'boundary') {
44
+ state.openers.push({ record: r, priorTerminal: state.lastTerminal });
45
+ state.mode = 'in-step';
46
+ state.stepId = r.stepId;
47
+ state.round = r.round;
48
+ } else if (r.round > state.round) state.round = r.round;
49
+ }
50
+ }
51
+ return state;
52
+ };
53
+
54
+ // Reference checks live ENTIRELY in the digest domain — two byte-different records with one
55
+ // canonical serialization are ONE identity, so object identity never decides resolution or
56
+ // authority. resolveRecordReference returns the LAST matching record (consistent with the
57
+ // latest-per-key authoritative selection); the prefix (records BEFORE the referencing one) is the
58
+ // resolution domain, so an out-of-order reference never resolves.
59
+ export const resolveRecordReference = (prefixRecords, digest) =>
60
+ prefixRecords.findLast((r) => canonicalFlowDigest(r) === digest);
61
+
62
+ export const isAuthoritativeReferenceTarget = (scopeRecords, digest) =>
63
+ authoritativeFlowRecords(scopeRecords).some((r) => canonicalFlowDigest(r) === digest);
64
+
65
+ // validateOpenerReference(prefixRecords, candidate) → { ok } | { ok: false, reason }. The named
66
+ // classification of a step-opening round's prior-terminal reference: unresolved · non-chain ·
67
+ // another plan · non-terminal · superseded · not-the-prior-terminal.
68
+ export const validateOpenerReference = (prefixRecords, candidate) => {
69
+ const target = resolveRecordReference(prefixRecords, candidate.opensFrom);
70
+ if (target === undefined) {
71
+ return { ok: false, reason: `the prior-terminal reference does not match the store — no record digests to ${candidate.opensFrom.slice(0, 12)}…` };
72
+ }
73
+ if (target.kind !== CHAIN_KIND) {
74
+ return { ok: false, reason: `the prior-terminal reference targets a ${target.kind} record, not a chain terminal` };
75
+ }
76
+ if (target.planId !== candidate.planId) {
77
+ return { ok: false, reason: `the prior-terminal reference targets another plan's record ("${target.planId}") — a step never opens from a foreign chain` };
78
+ }
79
+ if (!TERMINAL_PURPOSES.includes(target.purpose)) {
80
+ return { ok: false, reason: `the prior-terminal reference targets a non-terminal record (purpose "${target.purpose}") — an opener references adoption, converged, or complete only` };
81
+ }
82
+ const chain = prefixRecords.filter((r) => r.kind === CHAIN_KIND && r.planId === candidate.planId);
83
+ if (!isAuthoritativeReferenceTarget(chain, candidate.opensFrom)) {
84
+ return { ok: false, reason: 'the prior-terminal reference targets a superseded record — reference the latest record of that key' };
85
+ }
86
+ const prior = priorChainTerminal(chain);
87
+ if (prior == null || canonicalFlowDigest(prior) !== candidate.opensFrom) {
88
+ return { ok: false, reason: `the prior-terminal reference must target the chain's PRIOR terminal (${prior == null ? 'none' : `${canonicalFlowDigest(prior).slice(0, 12)}…`}), not another step's or an earlier terminal — step minting cannot manufacture fresh budgets` };
89
+ }
90
+ return { ok: true };
91
+ };
@@ -20,6 +20,7 @@ import {
20
20
  short, shellQuote, writerCommand,
21
21
  collectUnansweredRedRefusals, collectDegradeCoverageRefusals, collectReceiptCoverageRefusals,
22
22
  } from './flow-check-rungs.mjs';
23
+ import { CONTENT_FREE_FINGERPRINT } from './core-evidence.mjs';
23
24
 
24
25
  // The checker only refuses — park/resume/complete are explicit writer actions (#59). Printed
25
26
  // operand shapes: flag values ride the inline --flag='value' form and positionals follow a
@@ -128,10 +129,21 @@ const deltaRefusals = (records, owner) => {
128
129
  // degrade after a final-start at the same fingerprint refuses unless a LATER final-start at that
129
130
  // fingerprint completed (its `final` record landed after it). The checker reads raw records,
130
131
  // never the authoritative selection (#65).
131
- const degradeOrderingRefusals = (coreRecords) => {
132
+ const degradeOrderingRefusals = (coreRecords, advisories) => {
132
133
  const refusals = [];
134
+ let contentFree = 0;
133
135
  coreRecords.forEach((r, i) => {
134
136
  if (r.kind !== 'degrade') return;
137
+ // A degrade carries no base and no attempt, so on the content-free fingerprint two records
138
+ // from unrelated clean moments pair up and this rung refuses EVERY commit, whatever tree is
139
+ // being judged. Their order is not a fact about any tree, so it decides nothing here. Stated
140
+ // residual: #64 is therefore unenforceable for a degrade minted on a clean tree until the
141
+ // record carries a base — the queued store migration owns that, and a clean tree gates nothing
142
+ // meanwhile.
143
+ if (r.fingerprint === CONTENT_FREE_FINGERPRINT) {
144
+ contentFree += 1;
145
+ return;
146
+ }
135
147
  const startedBefore = coreRecords.some((s, j) => j < i && s.kind === 'final-start' && s.fingerprint === r.fingerprint);
136
148
  if (!startedBefore) return;
137
149
  const cured = coreRecords.some((s, j) => j > i && s.kind === 'final-start' && s.fingerprint === r.fingerprint
@@ -140,6 +152,9 @@ const degradeOrderingRefusals = (coreRecords) => {
140
152
  refusals.push(`a core degrade (backend "${r.backend}") landed AFTER a final-start at its fingerprint (${short(r.fingerprint)}) with no later completed re-run at it — degrades mint strictly BEFORE the final run (#64); re-run run-gates.mjs --final on this tree`);
141
153
  }
142
154
  });
155
+ if (contentFree > 0) {
156
+ advisories.push(`${contentFree} core degrade(s) minted on a CONTENT-FREE tree are outside the ordering rung (#64): the record carries no base, so two clean moments cannot be shown to be one and their order states nothing about any tree`);
157
+ }
143
158
  return refusals;
144
159
  };
145
160
 
@@ -225,7 +240,7 @@ const baseMotionRefusals = (chain, planId, owner, motion) => {
225
240
  // Phase-1 rungs (#65/#25/#42 — each self-gates on an OWN adoption). Absent inputs keep the decision
226
241
  // byte-identical to the Plan-2 checker. `consumer` rides through to the #65 lane split and defaults
227
242
  // to the STRICT lane, so a caller that forgets to thread it inherits strictness.
228
- export const decideFlowCheck = ({ flowRead, coreRead, owner, flowPath = 'the flow store', corePath = 'the core evidence store', motion = null, evidence = null, consumer = 'commit-guard' }) => {
243
+ export const decideFlowCheck = ({ flowRead, coreRead, owner, flowPath = 'the flow store', corePath = 'the core evidence store', motion = null, evidence = null, consumer = 'commit-guard', treeCarriesBytes = true }) => {
229
244
  const refusals = [];
230
245
  const advisories = [];
231
246
  if (flowRead.readError) refusals.push(`the flow store is unreadable (${flowRead.readError}) — the checker consumes the FULL read-result; inspect ${flowPath} (fail closed)`);
@@ -243,11 +258,25 @@ export const decideFlowCheck = ({ flowRead, coreRead, owner, flowPath = 'the flo
243
258
  if (motion != null && plan.integrityClean) refusals.push(...baseMotionRefusals(chain, planId, owner, motion));
244
259
  }
245
260
  refusals.push(...deltaRefusals(records, owner));
246
- refusals.push(...degradeOrderingRefusals(coreRead.records));
261
+ refusals.push(...degradeOrderingRefusals(coreRead.records, advisories));
247
262
  if (evidence != null) {
248
- refusals.push(...collectUnansweredRedRefusals({ flowRecords: records, coreRecords: coreRead.records, currentBase: evidence.tree.base, owner, consumer, currentFingerprint: evidence.tree.fingerprint }));
249
- refusals.push(...collectDegradeCoverageRefusals({ flowRecords: records, coreRecords: coreRead.records, tree: evidence.tree, owner, backends: evidence.degradeBackends }));
250
- refusals.push(...collectReceiptCoverageRefusals({ flowRecords: records, receipts: evidence.receipts, tree: evidence.tree, owner, backends: evidence.receiptBackends, declaredPaths: evidence.declaredPaths, refreshCap: evidence.refreshCap }));
263
+ // The base-keyed rung always runs it asks about this BASE's gate history, which a tree with
264
+ // no content does not change. The two FINGERPRINT-keyed rungs do not, when the CALLER states
265
+ // that the tree carries no bytes: their coverage would then be demanded of the one fingerprint
266
+ // every clean moment shares, so whatever they found there was minted by another moment and
267
+ // possibly another base (the symmetry the #65 content-free arm and commit-guard's content-free
268
+ // lanes state — such evidence must decide nothing, in either direction). The fact is DECLARED
269
+ // by the caller rather than derived here: only the caller knows whether it is judging a commit
270
+ // at all, and a checker that keyed it off the fingerprint alone would also silence the rungs
271
+ // for every routine clean-tree check, where they are exactly what the operator asked for. The
272
+ // skip is RECORDED, never silent.
273
+ refusals.push(...collectUnansweredRedRefusals({ flowRecords: records, coreRecords: coreRead.records, currentBase: evidence.tree.base, owner, consumer, currentFingerprint: evidence.tree.fingerprint, advisories }));
274
+ if (!treeCarriesBytes) {
275
+ advisories.push('the caller states this tree carries NO bytes, so the fingerprint-keyed correlations are skipped: degrade coverage (#25) and receipt coverage (#42) here, and the D10 flow-to-final binding at the guard — evidence at a content-free fingerprint belongs to some other clean moment');
276
+ } else {
277
+ refusals.push(...collectDegradeCoverageRefusals({ flowRecords: records, coreRecords: coreRead.records, tree: evidence.tree, owner, backends: evidence.degradeBackends }));
278
+ refusals.push(...collectReceiptCoverageRefusals({ flowRecords: records, receipts: evidence.receipts, tree: evidence.tree, owner, backends: evidence.receiptBackends, declaredPaths: evidence.declaredPaths, refreshCap: evidence.refreshCap }));
279
+ }
251
280
  }
252
281
  return { refusals, advisories };
253
282
  };
@@ -13,7 +13,7 @@ import {
13
13
  CHAIN_KIND, canonicalFlowDigest, authoritativeFlowRecords, flowTreeIdentity,
14
14
  } from './flow-record.mjs';
15
15
  import { resolveRecordReference } from './flow-store.mjs';
16
- import { authoritativeOfKind, summarizeReviewReceiptsForTree } from './core-evidence.mjs';
16
+ import { authoritativeOfKind, summarizeReviewReceiptsForTree, CONTENT_FREE_FINGERPRINT } from './core-evidence.mjs';
17
17
  import { FALLBACK_LENS_ADDITIONAL_ONLY } from './cheap-agents.mjs';
18
18
 
19
19
  export const short = (digest) => `${digest.slice(0, 12)}…`;
@@ -161,7 +161,21 @@ export const evaluateVetoOverride = ({ records, vetoReceipt, tree }) => {
161
161
  // runner-attested capability (a one-time unpublished nonce over stdin or an inherited FD, verified
162
162
  // against a one-way commitment recorded in the final-start); it needs its own IPC contract and is
163
163
  // QUEUED, never pretended here.
164
- export const collectUnansweredRedRefusals = ({ flowRecords, coreRecords, currentBase, owner, consumer = 'commit-guard', currentFingerprint = null }) => {
164
+ //
165
+ // The CONTENT-FREE arm (FINGERPRINT-BASE-BINDING, second face): a red final minted while the work
166
+ // tree was CLEAN hashes an empty payload, and that one value is shared by every clean moment of
167
+ // every repository — so the base correlation resolves to as many bases as the store has clean
168
+ // moments and refuses fail-closed forever, on a record that describes no working state at all.
169
+ // The arm is keyed on the FINGERPRINT, not on how many bases happen to correlate: resolving to
170
+ // exactly one base is an accident of store history, never a statement about the tree. It is one
171
+ // half of a symmetry, and it is only sound WITH the other: content-free evidence must decide
172
+ // nothing in either direction, so commit-guard equally refuses to ATTEST from a content-free
173
+ // receipt (commit-guard.mjs, the two content-free lanes) — without that half, stepping over a red
174
+ // here would leave a stale green free to acquit. The skip is RECORDED in the `advisories` sink —
175
+ // a rung that steps over evidence says so. That sink is the ONLY place the skip is observable:
176
+ // decideFlowCheck always passes one, and a caller that omits it (a test, a future consumer) gets
177
+ // the same refusals and no record of the step.
178
+ export const collectUnansweredRedRefusals = ({ flowRecords, coreRecords, currentBase, owner, consumer = 'commit-guard', currentFingerprint = null, advisories = [] }) => {
165
179
  if (!hasOwnAdoption(flowRecords, owner)) return [];
166
180
  const adoptionInstants = flowRecords
167
181
  .filter((r) => r.kind === CHAIN_KIND && r.purpose === 'adoption' && r.owner === owner)
@@ -192,6 +206,10 @@ export const collectUnansweredRedRefusals = ({ flowRecords, coreRecords, current
192
206
  for (const { r, i } of finals) {
193
207
  if (r.status !== 'red') continue;
194
208
  if (armingInstant !== null && isCanonicalInstant(r.timestamp) && Date.parse(r.timestamp) < armingInstant) continue;
209
+ if (r.fingerprintBefore === CONTENT_FREE_FINGERPRINT) {
210
+ advisories.push(`a red final (attempt "${r.attempt}") is OUTSIDE the rung: its tree fingerprint ${short(r.fingerprintBefore)} is CONTENT-FREE — a clean work tree emits an empty payload, so the value identifies no working state, correlates to no base, and gates no commit (#65 content-free arm)`);
211
+ continue;
212
+ }
195
213
  const bases = basesAt(r.fingerprintBefore);
196
214
  if (bases.length === 0) {
197
215
  refusals.push(`a red final (attempt "${r.attempt}") cannot be base-correlated: no flow record carries its tree fingerprint ${short(r.fingerprintBefore)} — the zero-base lane is a fail-closed ambiguity (#65); the rung demands exactly ONE base through the flow store`);