@sabaiway/agent-workflow-kit 5.5.0 → 5.6.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.
@@ -4,636 +4,48 @@
4
4
  // silent empty; every refusal names its recovery as a VERBATIM pasteable flow-writer command
5
5
  // (Decision 3/8 — the writer CLI ships beside this checker, so a refusal is never a dead end).
6
6
  //
7
- // Phase 1 adds the pure decision cores (#61/#56/#65/#62/#42/#25); each keys on an ARMED flow, so
8
- // an unarmed tree sees byte-identical behavior.
9
- //
10
7
  // COMPOSED (Plan 3 Phase 2): review-state's decideCheck consumes the decision cores as gated
11
8
  // arms, commit-guard consults computeFlowDecision as its flow arm, and gates-init offers this
12
9
  // CLI as a declarable gate whenever the orchestration config carries a flow block.
13
10
  //
11
+ // LAYOUT (baseline-practices tranche 1): this module is the CLI entry, the store-reading
12
+ // composition computeFlowDecision, the report render and the public surface every consumer
13
+ // imports. The pure halves live one module down — flow-check-cores.mjs (the decision cores +
14
+ // decideFlowCheck), flow-check-rungs.mjs (the evidence rungs + the shared refusal vocabulary) and
15
+ // flow-check-git-lane.mjs (the all-path git lane). Imports run ONE way, facade → cores → rungs.
16
+ //
14
17
  // Consumer env discipline: the checker resolves FIXED git-derived store paths; AW_FLOW_STORE /
15
18
  // AW_CORE_EVIDENCE stay PRODUCER test seams this consumer ignores (the commit-guard sanitization
16
19
  // discipline) — a poisoned override can neither redirect nor mask the real stores.
17
20
 
18
21
  import { lstatSync } from 'node:fs';
19
- import { join, dirname } from 'node:path';
20
- import { pathToFileURL, fileURLToPath } from 'node:url';
21
- import { spawnSync } from 'node:child_process';
22
+ import { pathToFileURL } from 'node:url';
22
23
  import {
23
- CHAIN_KIND, validateChainSequence, validateSupersessions, canonicalFlowDigest,
24
- authoritativeFlowRecords, flowTreeIdentity, ownerScopedFlowProjection, flowProjectionHash,
24
+ CHAIN_KIND, canonicalFlowDigest, ownerScopedFlowProjection, flowProjectionHash,
25
25
  } from './flow-record.mjs';
26
+ import { resolveFlowStorePath, readFlowStore, deriveFlowOwner } from './flow-store.mjs';
26
27
  import {
27
- resolveFlowStorePath, readFlowStore, deriveFlowOwner,
28
- walkChainState, validateOpenerReference, resolveRecordReference, isAuthoritativeReferenceTarget,
29
- } from './flow-store.mjs';
30
- import {
31
- resolveEvidencePath, readEvidence, resolveBase, authoritativeOfKind, summarizeReviewReceiptsForTree,
28
+ resolveEvidencePath, readEvidence, resolveBase, authoritativeOfKind,
32
29
  resolveReceiptsPath, readReceipts, computeTreeFingerprint,
33
30
  } from './core-evidence.mjs';
34
31
  import { loadConfig } from './orchestration-config.mjs';
35
32
  import { requiredBackendsForConfiguredRecipe, DISPLAY_ALIASES } from './recipes.mjs';
36
33
  import { detectBackends } from './detect-backends.mjs';
37
- import { FALLBACK_LENS_ADDITIONAL_ONLY } from './cheap-agents.mjs';
38
-
39
- const usageFail = (message) => Object.assign(new Error(message), { exitCode: 2 });
40
-
41
- const short = (digest) => `${digest.slice(0, 12)}…`;
42
-
43
- // The verbatim pasteable recovery lane (Decision 3/8): every refusal that names a mintable record
44
- // class prints the exact flow-writer command that mints it. The tool path is absolute (pasteable
45
- // from any cwd) and POSIX single-quoted — raw path/id bytes must never execute on paste.
46
- const FLOW_WRITER_TOOL = join(dirname(fileURLToPath(import.meta.url)), 'flow-writer.mjs');
47
- const shellQuote = (v) => `'${String(v).replaceAll("'", "'\\''")}'`;
48
- const writerCommand = (args) => `node ${shellQuote(FLOW_WRITER_TOOL)} ${args}`;
49
-
50
- // The checker only refuses — park/resume/complete are explicit writer actions (#59). Printed
51
- // operand shapes: flag values ride the inline --flag='value' form and positionals follow a
52
- // literal ` -- ` — one shape for EVERY id, so a leading-dash operand stays recoverable.
53
- const parkRecovery = (planId) =>
54
- `recovery (pasteable): ${writerCommand(`park -- ${shellQuote(planId)}`)}`;
55
-
56
- // Arms in dependency order; the first failing arm reports, and integrityClean gates the caller's
57
- // dependent arms (base motion) off a broken chain.
58
- const planRefusals = (records, chain, planId, owner, advisories) => {
59
- if (chain[0].purpose !== 'adoption') {
60
- return { integrityClean: false, refusals: [`plan "${planId}": the chain has no content-digest-bound adoption record — a chain starts at adoption binding the plan content digest (#58); the store is append-only, so inspect how this chain was written`] };
61
- }
62
- const seq = validateChainSequence(chain);
63
- if (!seq.ok) return { integrityClean: false, refusals: [`plan "${planId}": illegal transition — ${seq.reason}`] };
64
- const state = walkChainState(chain);
65
- const referenceIssues = [];
66
- for (const { record } of state.openers) {
67
- const check = validateOpenerReference(records.slice(0, records.indexOf(record)), record);
68
- if (!check.ok) referenceIssues.push(`plan "${planId}": step-opening round (step "${record.stepId}") — ${check.reason}`);
69
- }
70
- for (const r of chain) {
71
- if (r.purpose !== 'refresh') continue;
72
- const prefix = records.slice(0, records.indexOf(r));
73
- if (resolveRecordReference(prefix, r.refreshedRecord) === undefined) {
74
- referenceIssues.push(`plan "${planId}": a refresh's refreshedRecord does not match the store (no earlier record digests to ${short(r.refreshedRecord)}) — a re-attestation binds an existing record`);
75
- } else if (!isAuthoritativeReferenceTarget(prefix, r.refreshedRecord)) {
76
- referenceIssues.push(`plan "${planId}": a refresh's refreshedRecord targets a superseded record — a re-attestation binds the authoritative latest record of its key (as of the refresh's own raw position)`);
77
- }
78
- }
79
- if (referenceIssues.length > 0) return { integrityClean: false, refusals: referenceIssues };
80
- const open = !state.completed && !state.parked && state.mode === 'in-step';
81
- if (!open) return { integrityClean: true, refusals: [] };
82
- if (chain[0].owner !== owner) {
83
- advisories.push(`plan "${planId}": an OPEN chain owned by "${chain[0].owner}" (a foreign worktree) — advisory visibility only, never this tree's refusal (#57)`);
84
- return { integrityClean: true, refusals: [] };
85
- }
86
- return { integrityClean: true, refusals: [`plan "${planId}" has an OPEN chain owned by this worktree ("${owner}"): step "${state.stepId}" is not converged — a commit closes only at a terminal. ${parkRecovery(planId)}`] };
87
- };
88
-
89
- // The custody arm verifies the PERSISTED proof against a bare declaration (#60): the masked
90
- // recompute must equal fingerprintBefore, and every delta must be re-attested by a SUBSEQUENT
91
- // chain refresh binding {refreshedRecord, fingerprintBefore = the delta's fingerprintAfter} (#45)
92
- // — an earlier or fingerprint-mismatched record never satisfies (raw order decides). Satisfaction
93
- // is STORE-GLOBAL: the locked delta shape carries no chain field, so WHICH chain's refresh cap
94
- // the re-attestation consumes is the Plan-3 decideCheck arm (#61), not a Plan-2 refusal.
95
- // The recovery lane needs the invoker's OWN OPEN chains: a refresh is a within-step record, so
96
- // only such a chain can carry the re-attestation (and its refresh cap is what the mint consumes,
97
- // #61). A command under a "pasteable" label is always CONCRETE — with no own open chain the
98
- // recovery states the precondition instead of printing a placeholder command.
99
- const ownOpenChainPlanIds = (records, owner) =>
100
- [...new Set(records.filter((r) => r.kind === CHAIN_KIND).map((r) => r.planId))].filter((planId) => {
101
- const chain = records.filter((r) => r.kind === CHAIN_KIND && r.planId === planId);
102
- if (chain[0].owner !== owner || chain[0].purpose !== 'adoption' || !validateChainSequence(chain).ok) return false;
103
- const state = walkChainState(chain);
104
- return !state.completed && !state.parked && state.mode === 'in-step';
105
- });
106
-
107
- // The ONE per-record custody predicate (Plan 4 Phase 3, round-2 fold): the confinement equality
108
- // + the mint-only invariants the record-level shape validation cannot see — shared by the
109
- // gate-time walk below and the writer's terminal move validation, so a forged proof can neither
110
- // pass the gates nor carry a terminal. → issue string | null.
111
- export const deltaCustodyIssue = (r) => {
112
- if (r.custodyProof.maskedFingerprint !== r.fingerprintBefore) {
113
- return `the persisted custody proof does not prove confinement (maskedFingerprint ${short(r.custodyProof.maskedFingerprint)} ≠ fingerprintBefore ${short(r.fingerprintBefore)}) — a bare or tampered declaration never passes; re-mint through mintBookkeepingDelta`;
114
- }
115
- const proof = r.custodyProof;
116
- const mintInvariant = !proof.tracked ? null
117
- : proof.preClass !== 'present' ? 'a tracked path with an absent pre-state never mints'
118
- : proof.indexDigest === null ? 'a staged deletion (a HEAD entry without an index entry) never mints'
119
- : proof.worktreeDigest !== proof.indexDigest ? 'the clean-at-path rule (pre-change worktree bytes = the index entry) never minted this'
120
- : null;
121
- return mintInvariant === null ? null : `the persisted custody proof violates a mint invariant — ${mintInvariant}; an unmintable proof never passes (fail closed)`;
122
- };
123
-
124
- const deltaRefusals = (records, owner) => {
125
- const refusals = [];
126
- const openPlanIds = ownOpenChainPlanIds(records, owner);
127
- // The re-attestation OBLIGATION binds only AUTHORITATIVE deltas: a superseded same-key delta
128
- // never enters classifyDeltaChain and the refresh preflight refuses to reference it, so
129
- // demanding its refresh would be exactly the unrecoverable red the plan bans — supersession is
130
- // the store's own recovery valve. Custody and mint checks stay RAW-wide (tamper detection).
131
- const authoritative = new Set(authoritativeFlowRecords(records));
132
- records.forEach((r, i) => {
133
- if (r.kind !== 'bookkeeping-delta') return;
134
- const custody = deltaCustodyIssue(r);
135
- if (custody !== null) {
136
- refusals.push(`bookkeeping-delta at ${r.path}: ${custody}`);
137
- return;
138
- }
139
- if (!authoritative.has(r)) return;
140
- const digest = canonicalFlowDigest(r);
141
- const satisfied = records.some((s, j) => j > i && s.kind === CHAIN_KIND && s.purpose === 'refresh'
142
- && s.refreshedRecord === digest && s.fingerprintBefore === r.fingerprintAfter);
143
- if (!satisfied) {
144
- const recovery = openPlanIds.length > 0
145
- ? `recovery (pasteable; choose the chain whose refresh cap this consumes, #61): ${openPlanIds.map((planId) => writerCommand(`refresh --cause='bookkeeping delta re-attestation' --refreshed-record=${digest} -- ${shellQuote(planId)}`)).join(' OR ')}`
146
- : `recovery: no own OPEN chain can carry the re-attestation yet — open the owning plan's step round, then mint the refresh binding --refreshed-record=${digest}`;
147
- refusals.push(`bookkeeping-delta at ${r.path}: no satisfying re-attestation — a SUBSEQUENT chain refresh must bind {refreshedRecord: ${short(digest)}, fingerprintBefore: ${short(r.fingerprintAfter)}}; an earlier or fingerprint-mismatched record never satisfies. ${recovery}`);
148
- }
149
- });
150
- return refusals;
151
- };
152
-
153
- // Degrade-before-final (#64), decidable from RAW core-store order and grouped BY FINGERPRINT: a
154
- // degrade after a final-start at the same fingerprint refuses unless a LATER final-start at that
155
- // fingerprint completed (its `final` record landed after it). The checker reads raw records,
156
- // never the authoritative selection (#65).
157
- const degradeOrderingRefusals = (coreRecords) => {
158
- const refusals = [];
159
- coreRecords.forEach((r, i) => {
160
- if (r.kind !== 'degrade') return;
161
- const startedBefore = coreRecords.some((s, j) => j < i && s.kind === 'final-start' && s.fingerprint === r.fingerprint);
162
- if (!startedBefore) return;
163
- const cured = coreRecords.some((s, j) => j > i && s.kind === 'final-start' && s.fingerprint === r.fingerprint
164
- && coreRecords.some((c, k) => k > j && c.kind === 'final' && c.attempt === s.attempt && c.fingerprintBefore === s.fingerprint));
165
- if (!cured) {
166
- 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`);
167
- }
168
- });
169
- return refusals;
170
- };
171
-
172
- // ── Plan-3 Phase-1 decision cores — pure over read-results + explicit inputs ─────────────────────
173
-
174
- const isCanonicalInstant = (v) => typeof v === 'string' && Number.isFinite(Date.parse(v)) && new Date(v).toISOString() === v;
175
-
176
- const hasOwnAdoption = (records, owner) =>
177
- records.some((r) => r.kind === CHAIN_KIND && r.purpose === 'adoption' && r.owner === owner);
178
-
179
- // #61: an unbroken declared-path delta chain lifts a stale receipt; each link consumes the
180
- // refresh cap of the chain that minted its re-attestation.
181
- export const classifyDeltaChain = ({ records, fromFingerprint, toFingerprint, declaredPaths, refreshCap }) => {
182
- if (!Number.isInteger(refreshCap) || refreshCap < 1) {
183
- return { classification: 'refused', reason: `the refresh cap must arrive as a positive-integer input (#45) — got ${JSON.stringify(refreshCap)} (fail closed)` };
184
- }
185
- const declared = Array.isArray(declaredPaths) ? declaredPaths : [];
186
- const deltas = authoritativeFlowRecords(records).filter((r) => r.kind === 'bookkeeping-delta');
187
- const links = [];
188
- const consumers = new Map();
189
- const visited = new Set([fromFingerprint]);
190
- let tip = fromFingerprint;
191
- while (tip !== toFingerprint) {
192
- const candidates = deltas.filter((d) => d.fingerprintBefore === tip);
193
- if (candidates.length === 0) {
194
- return { classification: 'refused', reason: `no bookkeeping-delta continues the chain at ${short(tip)} — a gap never classifies CURRENT (fail closed)` };
195
- }
196
- if (candidates.length > 1) {
197
- // FLOW-DELTA-FORK-NAMES-UNDECLARED: the declaredPaths restriction outranks the fork wording
198
- // — a mixed pair's actionable fact is the undeclared path, not the fork (both lanes refuse).
199
- const undeclared = candidates.filter((d) => !declared.includes(d.path));
200
- if (undeclared.length > 0) {
201
- return { classification: 'refused', reason: `bookkeeping-delta at ${undeclared.map((d) => d.path).join(', ')}: not a DECLARED bookkeeping path (declared: ${declared.join(', ') || 'none'}) — an undeclared-path delta never enters a classification chain, however valid its custody proof (fail closed)` };
202
- }
203
- return { classification: 'refused', reason: `${candidates.length} authoritative deltas fork the chain at ${short(tip)} — a fork never classifies CURRENT (fail closed)` };
204
- }
205
- const d = candidates[0];
206
- if (!declared.includes(d.path)) {
207
- return { classification: 'refused', reason: `bookkeeping-delta at ${d.path}: not a DECLARED bookkeeping path (declared: ${declared.join(', ') || 'none'}) — an undeclared-path delta never enters a classification chain, however valid its custody proof (fail closed)` };
208
- }
209
- const digest = canonicalFlowDigest(d);
210
- const at = records.indexOf(d);
211
- const attesting = records.find((s, j) => j > at && s.kind === CHAIN_KIND && s.purpose === 'refresh'
212
- && s.refreshedRecord === digest && s.fingerprintBefore === d.fingerprintAfter);
213
- if (attesting === undefined) {
214
- return { classification: 'refused', reason: `bookkeeping-delta at ${d.path}: no satisfying re-attestation — a mismatched or missing refresh link never carries the chain (fail closed)` };
215
- }
216
- const key = JSON.stringify([attesting.planId, attesting.cycle]);
217
- consumers.set(key, (consumers.get(key) ?? 0) + 1);
218
- links.push(d);
219
- tip = d.fingerprintAfter;
220
- if (visited.has(tip)) {
221
- return { classification: 'refused', reason: `the delta chain revisits ${short(tip)} — a cycle never classifies CURRENT (fail closed)` };
222
- }
223
- visited.add(tip);
224
- }
225
- const attribution = [...consumers.entries()].map(([key, count]) => {
226
- const [planId, cycle] = JSON.parse(key);
227
- const refreshes = records.filter((r) => r.kind === CHAIN_KIND && r.purpose === 'refresh' && r.planId === planId && r.cycle === cycle).length;
228
- return { planId, cycle, links: count, refreshes };
229
- });
230
- const exhausted = attribution.find((a) => a.refreshes > refreshCap);
231
- if (exhausted !== undefined) {
232
- return { classification: 'escalation', reason: `refresh cap exhausted for chain "${exhausted.planId}" (cycle ${exhausted.cycle}): ${exhausted.refreshes} refreshes exceed the cap ${refreshCap} (#45) — cap exhaustion escalates to a real round, never a silent pass` };
233
- }
234
- return { classification: 'current', links, attribution };
235
- };
236
-
237
- // #56: only the authoritative head of the veto instance lifts, exact-matching the full bound set.
238
- export const evaluateVetoOverride = ({ records, vetoReceipt, tree }) => {
239
- const stands = (reason) => ({ lifted: false, reason });
240
- const vetoDigest = canonicalFlowDigest(vetoReceipt);
241
- const instance = records.filter((r) => r.kind === 'maintainer-override' && r.vetoReceiptDigest === vetoDigest);
242
- if (instance.length === 0) {
243
- if (!records.some((r) => r.kind === CHAIN_KIND && r.purpose === 'adoption')) return stands('the flow is unarmed (no adoption record) — the override arm is inert and the veto stands');
244
- return stands(`a standing veto (backend "${vetoReceipt.backend}", verdict ${JSON.stringify(vetoReceipt.verdict)}) has no maintainer-override for its instance — degradation never lifts a veto (#48); only a checkpoint-approved override does`);
245
- }
246
- const head = instance[instance.length - 1];
247
- const prefix = records.slice(0, records.indexOf(head));
248
- if (!prefix.some((r) => r.kind === CHAIN_KIND && r.purpose === 'adoption')) {
249
- return stands('the flow is unarmed at the override head (no adoption record precedes it) — a forward-referencing override never lifts and the veto stands');
250
- }
251
- const mismatch = (field, got, want) => stands(`the override head does not lift: bound-set mismatch on ${field} (override ${JSON.stringify(got)} ≠ ${JSON.stringify(want)}) — the evaluation exact-matches the full #56 bound set`);
252
- if (head.backend !== vetoReceipt.backend) return mismatch('backend', head.backend, vetoReceipt.backend);
253
- if (head.verdict !== vetoReceipt.verdict) return mismatch('verdict', head.verdict, vetoReceipt.verdict);
254
- if (head.base !== tree.base) return mismatch('base', head.base, tree.base);
255
- if (head.fingerprint !== tree.fingerprint) return mismatch('fingerprint', head.fingerprint, tree.fingerprint);
256
- const target = resolveRecordReference(prefix, head.chainRecord);
257
- if (target === undefined || target.kind !== CHAIN_KIND) {
258
- return stands('the override head does not lift: bound-set mismatch on chainRecord — the digest does not resolve to a chain record PRECEDING the override (mint-time order decides)');
259
- }
260
- const chain = prefix.filter((r) => r.kind === CHAIN_KIND && r.planId === target.planId);
261
- if (chain[0].purpose !== 'adoption') {
262
- return stands('the override head does not lift: bound-set mismatch on chainRecord — the bound chain is not adopted');
263
- }
264
- return {
265
- lifted: true,
266
- label: `veto lifted by maintainer-override ${short(canonicalFlowDigest(head))} — backend "${head.backend}" verdict "${head.verdict}" (checkpoint-approved, #38/#56)`,
267
- };
268
- };
269
-
270
- // #65: a current-base red passes only through a rerun-cause naming ITS attempt, matched to a
271
- // later first-of-its-attempt completed retry; base correlation is flow-side and must be unambiguous.
272
- // Scope (the Phase-4 veteran-store dogfood catch): a red final minted strictly BEFORE this
273
- // worktree's EARLIEST adoption instant is OUTSIDE the rung — no flow record could carry its tree
274
- // BY CONSTRUCTION, so demanding the correlation retroactively would brick arming over any
275
- // pre-flow evidence history. Both instants are RECORDED record fields in the CANONICAL
276
- // UTC ISO form (#39 — check-time wall-clock never enters, and Date.parse's tolerance for
277
- // non-canonical spellings never widens the boundary): ANY own-adoption instant that is not
278
- // canonical disables the exemption ENTIRELY (a silently dropped broken instant would move the
279
- // boundary to a LATER adoption — fail open), and a red final exempts only on its own canonical
280
- // strictly-earlier instant. Stated residual: the two stores are deliberately not lock-coupled
281
- // and their records remain forgeable (each store's own header says so) — a backdated or
282
- // backwards-clock instant can move a red across this boundary; the cross-store arming-fence
283
- // hardening is queued (FLOW-ARMING-FENCE-CROSS-STORE), never pretended here.
284
- //
285
- // The lane split (FLOW-FINAL-RED-DEADLOCK) — the same reasoning the D10 arm already applies one arm
286
- // away (see the `consumer` paragraph in computeFlowDecision's own header; a line reference there
287
- // would rot on the next insertion). The strict rule demands a receipt the gate's OWN run has not
288
- // written yet: run-gates appends the final receipt only AFTER every gate has run, so an in-matrix
289
- // flow-check can never see the completed retry that would answer the newest red, and each --final
290
- // mints red N+1 — no number of rerun-causes converges. On the 'gate' lane a current-base red is
291
- // therefore ALSO answered by a provable IN-PROGRESS retry: an authoritative rerun-cause naming its
292
- // attempt AND binding the CURRENT fingerprint, plus a final-start at that fingerprint ordered
293
- // STRICTLY AFTER that red whose attempt carries no completed final — the shape run-gates creates
294
- // before any gate runs (run-gates.mjs:636-658), so inside a real final run the conjunction holds by
295
- // construction while a standalone check on a quiet tree still refuses. The relaxation carries the
296
- // SAME base correlation the strict rule demands of the retry tree: a fingerprint is not unique to a
297
- // base (a clean tree hashes identically under every HEAD), so without it a cause minted at another
298
- // base would answer the gate and leave a green final the commit boundary is guaranteed to reject —
299
- // the relaxation must only ever admit a state a completed retry could actually clear. It is opt-in
300
- // by EXACT match: any other consumer — unknown, misspelled, absent — evaluates the strict rule, and
301
- // a tree whose fingerprint is unresolvable or ambiguously correlated never relaxes. The
302
- // 'commit-guard' lane is unchanged; the commit boundary still demands a real completed retry.
303
- // Stated residual: an INTERRUPTED final run leaves exactly that record shape with no live run
304
- // behind it, so a standalone check reads PASS in that window. It authorizes nothing — commit-guard
305
- // refuses the dangling start independently (commit-guard.mjs:213-223) and consults the STRICT lane
306
- // (commit-guard.mjs:253) — and the window closes on the next completed final run. The real fix is a
307
- // runner-attested capability (a one-time unpublished nonce over stdin or an inherited FD, verified
308
- // against a one-way commitment recorded in the final-start); it needs its own IPC contract and is
309
- // QUEUED, never pretended here.
310
- export const collectUnansweredRedRefusals = ({ flowRecords, coreRecords, currentBase, owner, consumer = 'commit-guard', currentFingerprint = null }) => {
311
- if (!hasOwnAdoption(flowRecords, owner)) return [];
312
- const adoptionInstants = flowRecords
313
- .filter((r) => r.kind === CHAIN_KIND && r.purpose === 'adoption' && r.owner === owner)
314
- .map((r) => (isCanonicalInstant(r.timestamp) ? Date.parse(r.timestamp) : null));
315
- const armingInstant = adoptionInstants.length > 0 && adoptionInstants.every((t) => t !== null)
316
- ? Math.min(...adoptionInstants) : null;
317
- const refusals = [];
318
- const identities = flowRecords.map(flowTreeIdentity);
319
- const basesAt = (fp) => [...new Set(identities.filter((t) => t.fingerprint === fp).map((t) => t.base))];
320
- const rerunCauses = authoritativeFlowRecords(flowRecords).filter((r) => r.kind === 'rerun-cause');
321
- const finals = coreRecords.map((r, i) => ({ r, i })).filter(({ r }) => r.kind === 'final');
322
- const firstFinalByAttempt = new Map();
323
- for (const { r, i } of finals) {
324
- if (!firstFinalByAttempt.has(r.attempt)) firstFinalByAttempt.set(r.attempt, i);
325
- }
326
- const answeredBy = (red, redAt) => rerunCauses.some((c) => c.attempt === red.attempt
327
- && finals.some(({ r: g, i: gi }) => gi > redAt
328
- && basesAt(g.fingerprintBefore).length === 1 && basesAt(g.fingerprintBefore)[0] === currentBase
329
- && firstFinalByAttempt.get(g.attempt) === gi
330
- && c.fingerprint === g.fingerprintBefore));
331
- const completedAttempts = new Set(finals.map(({ r }) => r.attempt));
332
- const relaxes = consumer === 'gate' && typeof currentFingerprint === 'string' && currentFingerprint !== ''
333
- && basesAt(currentFingerprint).length === 1 && basesAt(currentFingerprint)[0] === currentBase;
334
- const inProgressRetryFor = (red, redAt) => rerunCauses.some((c) => c.attempt === red.attempt
335
- && c.fingerprint === currentFingerprint
336
- && coreRecords.some((s, si) => si > redAt && s.kind === 'final-start'
337
- && s.fingerprint === currentFingerprint && !completedAttempts.has(s.attempt)));
338
- for (const { r, i } of finals) {
339
- if (r.status !== 'red') continue;
340
- if (armingInstant !== null && isCanonicalInstant(r.timestamp) && Date.parse(r.timestamp) < armingInstant) continue;
341
- const bases = basesAt(r.fingerprintBefore);
342
- if (bases.length === 0) {
343
- 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`);
344
- continue;
345
- }
346
- if (bases.length > 1) {
347
- refusals.push(`a red final (attempt "${r.attempt}") resolves ${bases.length} distinct bases through the flow store — a multi-base correlation is a fail-closed ambiguity (#65); one tree fingerprint must carry exactly ONE base`);
348
- continue;
349
- }
350
- if (bases[0] !== currentBase) continue;
351
- if (!answeredBy(r, i) && !(relaxes && inProgressRetryFor(r, i))) {
352
- refusals.push(`a red final (attempt "${r.attempt}") on the CURRENT base (${bases[0] == null ? 'null' : short(bases[0])}) has no later completed retry cleared by a rerun-cause — an unanswered red never passes an armed flow (#65). recovery (edit the quoted cause, then paste; mint on the RETRY tree): ${writerCommand(`rerun-cause --attempt=${shellQuote(r.attempt)} --cause='<the stated cause>'`)}`);
353
- }
354
- }
355
- return refusals;
356
- };
357
-
358
- // #62: base delta ∩ plan surface — disjoint ⇒ re-baseline, intersecting/undecidable ⇒ refresh.
359
- export const classifyBaseMotion = ({ baseDelta, changedSurface }) => {
360
- if (!baseDelta?.ok) {
361
- return { motion: 'undecidable', requires: 'refresh', reason: `the base delta is undecidable (${baseDelta?.reason ?? 'no delta supplied'}) — fail closed: a refresh dispatch is REQUIRED (#62)` };
362
- }
363
- if (!changedSurface?.ok) {
364
- return { motion: 'undecidable', requires: 'refresh', reason: `the changed surface is undecidable (${changedSurface?.reason ?? 'no surface supplied'}) — fail closed: a refresh dispatch is REQUIRED (#62)` };
365
- }
366
- const surface = new Set(changedSurface.paths);
367
- const witness = baseDelta.paths.find((p) => surface.has(p));
368
- if (witness !== undefined) return { motion: 'intersecting', requires: 'refresh', witness };
369
- return { motion: 'disjoint', requires: 're-baseline' };
370
- };
371
-
372
- // In-step base transitions of the LAST segment (lifecycle projection — round revisions collapsed)
373
- // must land the class the delta requires; boundary and park→resume are exempt (every commit moves
374
- // HEAD); the tail binds only a live in-step chain.
375
- const baseMotionRefusals = (chain, planId, owner, motion) => {
376
- if (chain[0].owner !== owner) return [];
377
- const display = (b) => (b == null ? 'null' : short(b));
378
- const refusals = [];
379
- const classify = (fromBase, toBase) => classifyBaseMotion({
380
- baseDelta: motion.resolveBaseDelta(fromBase, toBase),
381
- changedSurface: motion.resolveChangedSurface(),
382
- });
383
- const requirement = (cls) => (cls.motion === 'disjoint' ? 'the delta is disjoint from the plan surface — re-baseline only, never a dispatch (#40)'
384
- : cls.motion === 'intersecting' ? `the delta intersects the plan surface at ${cls.witness}`
385
- : cls.reason);
386
- const seenRounds = new Set();
387
- const lifecycle = chain.filter((r) => {
388
- if (r.purpose !== 'round') return true;
389
- const key = JSON.stringify([r.cycle, r.stepId, r.round]);
390
- if (seenRounds.has(key)) return false;
391
- seenRounds.add(key);
392
- return true;
393
- });
394
- const isSegmentStart = (r) => r.purpose === 'resume' || r.purpose === 'unfreeze' || (r.purpose === 'round' && r.opensFrom !== null);
395
- const states = [];
396
- const walk = { mode: 'boundary', parked: false };
397
- for (const r of lifecycle) {
398
- states.push({ ...walk });
399
- if (r.purpose === 'park') walk.parked = true;
400
- else if (r.purpose === 'resume') walk.parked = false;
401
- else if (r.purpose === 'converged' || r.purpose === 'complete') walk.mode = 'boundary';
402
- else if (r.purpose === 'unfreeze' || (r.purpose === 'round' && walk.mode === 'boundary')) walk.mode = 'in-step';
403
- }
404
- const segStart = lifecycle.reduce((last, r, i) => (isSegmentStart(r) ? i : last), 0);
405
- for (let i = segStart + 1; i < lifecycle.length; i += 1) {
406
- const prev = lifecycle[i - 1];
407
- const r = lifecycle[i];
408
- if (states[i].mode !== 'in-step' || states[i].parked || r.base === prev.base) continue;
409
- const cls = classify(prev.base, r.base);
410
- if (r.purpose !== cls.requires) {
411
- refusals.push(`plan "${planId}": a mid-step base transition (${display(prev.base)} → ${display(r.base)}) landed a "${r.purpose}" record but requires a ${cls.requires} record — ${requirement(cls)}; final gates must re-run after base motion (#62)`);
412
- continue;
413
- }
414
- if (r.purpose === 're-baseline' && r.baseBefore !== prev.base) {
415
- refusals.push(`plan "${planId}": the mid-step re-baseline's baseBefore (${display(r.baseBefore)}) does not match the previous record's base (${display(prev.base)}) — a re-baseline binds the actual pre-motion base (#62)`);
416
- }
417
- }
418
- const state = walkChainState(chain);
419
- if (state.completed || state.parked || state.mode !== 'in-step') return refusals;
420
- const recorded = lifecycle[lifecycle.length - 1].base;
421
- if (recorded === motion.currentBase) return refusals;
422
- const cls = classify(recorded, motion.currentBase);
423
- const recovery = cls.requires === 're-baseline'
424
- ? writerCommand(`re-baseline -- ${shellQuote(planId)}`)
425
- : writerCommand(`refresh --cause='base motion' --refreshed-record=${canonicalFlowDigest(chain[chain.length - 1])} -- ${shellQuote(planId)}`);
426
- const tailRequirement = cls.requires === 're-baseline'
427
- ? 'a re-baseline record suffices (the delta is disjoint from the plan surface)'
428
- : `a refresh dispatch is REQUIRED (${cls.motion === 'intersecting' ? `the delta intersects the plan surface at ${cls.witness}` : cls.reason})`;
429
- refusals.push(`plan "${planId}": the base moved under the armed chain (recorded ${display(recorded)} → current ${display(motion.currentBase)}) and no ${cls.requires} record landed — ${tailRequirement}; final gates must re-run after base motion (#62). recovery (pasteable): ${recovery}`);
430
- return refusals;
431
- };
432
-
433
- // collectDegradeCoverageRefusals (#25/#39): every authoritative core degrade at the current tree
434
- // must be justified by a flow degrade-justification binding {downMark, degradeDigest, base} to a
435
- // then-active mark of the same backend. All instants are RECORDED and canonical — wall-clock never
436
- // enters the decision.
437
- // #25/#42: every relied-on-backend degrade at the tree needs ONE fully-valid justification.
438
- export const collectDegradeCoverageRefusals = ({ flowRecords, coreRecords, tree, owner, backends }) => {
439
- if (!hasOwnAdoption(flowRecords, owner)) return [];
440
- const refusals = [];
441
- const justifications = authoritativeFlowRecords(flowRecords).filter((r) => r.kind === 'degrade-justification');
442
- const justificationFailure = (j, degrade) => {
443
- if (j.base !== tree.base) {
444
- return `the degrade-justification for backend "${degrade.backend}" binds base ${j.base == null ? 'null' : short(j.base)}, not the current base — the {downMark, degradeDigest, base} binding is exact (#25)`;
445
- }
446
- if (j.fingerprint !== tree.fingerprint) {
447
- return `the degrade-justification for backend "${degrade.backend}" was minted at another tree (fingerprint ${short(j.fingerprint)}) — the per-{base, fingerprint} binding is exact (#25)`;
448
- }
449
- if (!isCanonicalInstant(j.timestamp)) {
450
- return `the degrade-justification for backend "${degrade.backend}" carries an unparseable instant ${JSON.stringify(j.timestamp)} — the decide layer requires a canonical UTC ISO instant (toISOString round-trip), by name (#39)`;
451
- }
452
- const jAt = flowRecords.indexOf(j);
453
- const mark = resolveRecordReference(flowRecords.slice(0, jAt), j.downMark);
454
- if (mark === undefined || mark.kind !== 'down-mark' || mark.backend !== degrade.backend) {
455
- return `the degrade-justification for backend "${degrade.backend}" does not ride a down-mark of that backend (${mark === undefined ? 'the downMark digest resolves to no EARLIER record — mint-time order decides' : `it targets a ${mark.kind} of backend "${mark.backend}"`}) — a mis-bound justification refuses (#25)`;
456
- }
457
- const closedBefore = flowRecords.some((c, k) => k < jAt && (c.kind === 'down-mark-up' || c.kind === 'down-mark-clear') && c.target === j.downMark);
458
- if (closedBefore) {
459
- return `the degrade-justification for backend "${degrade.backend}" rides a down-mark already closed by up/clear at mint time — a closed mark justifies nothing (#25)`;
460
- }
461
- if (!(Date.parse(j.timestamp) >= Date.parse(mark.timestamp) && Date.parse(j.timestamp) < Date.parse(mark.expiresAt))) {
462
- return `the degrade-justification for backend "${degrade.backend}" was minted outside its down-mark's active window (an expired-at-mint or pre-mark instant) — a then-unexpired mark is required (#25), never wall-clock at check time (#39)`;
463
- }
464
- return null;
465
- };
466
- for (const degrade of authoritativeOfKind(coreRecords, 'degrade')) {
467
- if (degrade.fingerprint !== tree.fingerprint || !backends.includes(degrade.backend)) continue;
468
- const digest = canonicalFlowDigest(degrade);
469
- const candidates = justifications.filter((x) => x.degradeDigest === digest);
470
- if (candidates.length === 0) {
471
- refusals.push(`a core degrade (backend "${degrade.backend}") the gate relies on at the current tree has no flow degrade-justification binding it — exact coverage refuses uncovered degrades on an armed flow (#25/#42). recovery (pasteable, needs a then-active down-mark): ${writerCommand(`degrade-justification --backend=${shellQuote(degrade.backend)}`)}`);
472
- continue;
473
- }
474
- const failures = candidates.map((j) => justificationFailure(j, degrade));
475
- if (!failures.includes(null)) refusals.push(failures[0]);
476
- }
477
- return refusals;
478
- };
479
-
480
- // evaluateInternalAttestationLenses (#15/#3, Phase 4.3): an internal-attestation whose lens set
481
- // CLAIMS a review provider's slot (a lens named like a configured backend) must ride a
482
- // THEN-ACTIVE down-mark for that backend — substitution is recorded, never silent; the refusal
483
- // quotes the fallback lens's additional-only contract from its one home. "Then-active" is decided
484
- // in the RAW prefix strictly before the attestation (mint-time order, the #25 discipline): the
485
- // mark is unclosed there and its TTL window contains the attestation's instant — an unparseable
486
- // attestation instant refuses by name, never passes.
487
- export const evaluateInternalAttestationLenses = ({ record, records, providerBackends }) => {
488
- const at = records.indexOf(record);
489
- if (at === -1) {
490
- return { ok: false, reason: 'the internal-attestation record does not belong to the supplied record list — prefix scoping is undecidable (fail closed)' };
491
- }
492
- const prefix = records.slice(0, at);
493
- for (const lens of record.lenses) {
494
- if (!providerBackends.includes(lens)) continue;
495
- let active = null;
496
- for (const r of prefix) {
497
- if (r.kind === 'down-mark' && r.backend === lens) active = r;
498
- else if ((r.kind === 'down-mark-up' || r.kind === 'down-mark-clear') && r.backend === lens) active = null;
499
- }
500
- const failure = active === null
501
- ? `no down-mark for backend "${lens}" is open at the attestation's position`
502
- : !isCanonicalInstant(record.timestamp)
503
- ? `the attestation instant ${JSON.stringify(record.timestamp)} is not a canonical UTC ISO instant, so then-activity is undecidable`
504
- : !(Date.parse(record.timestamp) >= Date.parse(active.timestamp) && Date.parse(record.timestamp) < Date.parse(active.expiresAt))
505
- ? `the down-mark for backend "${lens}" is outside its active window at the attestation's instant`
506
- : null;
507
- if (failure !== null) {
508
- return { ok: false, reason: `the internal-attestation's lens set claims backend "${lens}"'s slot without a then-active down-mark (${failure}) — ${FALLBACK_LENS_ADDITIONAL_ONLY} (fail closed)` };
509
- }
510
- }
511
- return { ok: true };
512
- };
513
-
514
- // The ONE relied-on receipt selector (#42/#61): the latest normal receipt at the CURRENT tree,
515
- // else — through an unbroken declared-path bookkeeping-delta chain — the backend's LAST receipt
516
- // judged at its own tree, carrying the lift metadata the PASS labels consume.
517
- export const selectReliedOnReceipt = ({ receipts, backend, tree, records, declaredPaths, refreshCap }) => {
518
- const own = receipts.filter((r) => r.backend === backend);
519
- const current = summarizeReviewReceiptsForTree(own, tree.fingerprint);
520
- if (current.state === 'current') return { receipt: current.receipt, lifted: 0 };
521
- const last = own[own.length - 1];
522
- const candidateFp = typeof last?.fingerprint === 'string' ? last.fingerprint : null;
523
- if (candidateFp == null || candidateFp === tree.fingerprint) return { receipt: null, lifted: 0 };
524
- const atCandidate = summarizeReviewReceiptsForTree(own, candidateFp);
525
- if (atCandidate.state !== 'current') return { receipt: null, lifted: 0 };
526
- const chain = classifyDeltaChain({ records, fromFingerprint: candidateFp, toFingerprint: tree.fingerprint, declaredPaths, refreshCap });
527
- if (chain.classification !== 'current') return { receipt: null, lifted: 0 };
528
- return { receipt: atCandidate.receipt, lifted: chain.links.length };
529
- };
530
-
531
- // #42: each relied-on backend's selected receipt must ride an OWN round's dispatch ledger; with
532
- // lift inputs supplied the selection spans the delta lift, so a LIFTED receipt demands its
533
- // binding at ITS OWN fingerprint; the entry's dispatchBase must equal the round's recorded base.
534
- export const collectReceiptCoverageRefusals = ({ flowRecords, receipts, tree, owner, backends, declaredPaths = null, refreshCap = null }) => {
535
- if (!hasOwnAdoption(flowRecords, owner)) return [];
536
- const refusals = [];
537
- const rounds = authoritativeFlowRecords(flowRecords).filter((r) => r.kind === CHAIN_KIND && r.purpose === 'round' && r.owner === owner);
538
- for (const backend of [...new Set(backends)]) {
539
- const relied = declaredPaths != null
540
- ? selectReliedOnReceipt({ receipts, backend, tree, records: flowRecords, declaredPaths, refreshCap }).receipt
541
- : summarizeReviewReceiptsForTree(receipts.filter((r) => r.backend === backend), tree.fingerprint).receipt;
542
- if (relied == null) continue;
543
- const digest = canonicalFlowDigest(relied);
544
- const covered = rounds.some((round) => round.fingerprint === relied.fingerprint
545
- && round.dispatches.some((e) => e.receiptDigest === digest && e.backend === relied.backend && e.dispatchBase === round.base));
546
- if (!covered) {
547
- refusals.push(`the review receipt the decision relies on (backend "${backend}", verdict ${JSON.stringify(relied.verdict)}) is bound by NO round dispatch-ledger entry of this worktree's chains — an unbound receipt never satisfies an armed flow (#42); land the {receiptDigest, backend, dispatchBase} entry through a round revision`);
548
- }
549
- }
550
- return refusals;
551
- };
552
-
553
- // ── the all-path git lane for base-motion inputs (#62) ───────────────────────────────────────────
554
-
555
- // computeChangedSurface exists for COVERAGE and excludes test files by design — the base-
556
- // intersection inputs come from these helpers instead: every changed path counts, tests included.
557
- const gitPathList = (args, cwd) => {
558
- const r = spawnSync('git', args, { cwd, maxBuffer: 256 * 1024 * 1024, windowsHide: true });
559
- if (r.error || r.status !== 0) return null;
560
- return r.stdout.toString('utf8').split('\0').filter(Boolean);
561
- };
34
+ import { decideFlowCheck } from './flow-check-cores.mjs';
35
+ import { short } from './flow-check-rungs.mjs';
36
+ import {
37
+ resolveGitToplevel, computeAllPathBaseDelta, computeAllPathWorktreeSurface,
38
+ } from './flow-check-git-lane.mjs';
562
39
 
563
- // All-path git lane (#62/P22): toplevel-rooted, submodules never ignored, test files included.
564
- const resolveGitToplevel = (cwd) => {
565
- const r = spawnSync('git', ['rev-parse', '--show-toplevel'], { cwd, windowsHide: true });
566
- if (r.error || r.status !== 0) return null;
567
- const top = r.stdout.toString('utf8').replace(/\r?\n$/, '');
568
- return top === '' ? null : top;
569
- };
40
+ export { deltaCustodyIssue, classifyBaseMotion, decideFlowCheck } from './flow-check-cores.mjs';
41
+ export {
42
+ classifyDeltaChain, evaluateVetoOverride, collectUnansweredRedRefusals,
43
+ collectDegradeCoverageRefusals, evaluateInternalAttestationLenses, selectReliedOnReceipt,
44
+ collectReceiptCoverageRefusals,
45
+ } from './flow-check-rungs.mjs';
46
+ export { computeAllPathBaseDelta, computeAllPathWorktreeSurface } from './flow-check-git-lane.mjs';
570
47
 
571
- export const computeAllPathBaseDelta = (cwd, fromBase, toBase) => {
572
- const isSha = (v) => typeof v === 'string' && /^([0-9a-f]{40}|[0-9a-f]{64})$/.test(v);
573
- if (!isSha(fromBase) || !isSha(toBase)) {
574
- return { ok: false, reason: `a base delta needs two shas (got ${JSON.stringify(fromBase)} → ${JSON.stringify(toBase)})` };
575
- }
576
- const root = resolveGitToplevel(cwd);
577
- if (root == null) return { ok: false, reason: 'not inside a git work tree — the base delta is unresolvable (fail closed)' };
578
- const paths = gitPathList(['diff', '--name-only', '--no-renames', '--ignore-submodules=none', '-z', fromBase, toBase], root);
579
- if (paths == null) return { ok: false, reason: `git diff ${short(fromBase)} ${short(toBase)} failed — an unresolvable base delta fails closed` };
580
- return { ok: true, paths };
581
- };
582
-
583
- export const computeAllPathWorktreeSurface = (cwd) => {
584
- const root = resolveGitToplevel(cwd);
585
- if (root == null) return { ok: false, reason: 'not inside a git work tree — the worktree surface is unresolvable (fail closed)' };
586
- // assume-unchanged/skip-worktree lie to git diff — any flagged entry fails the surface closed.
587
- const flagged = gitPathList(['ls-files', '-v', '-z'], root);
588
- if (flagged == null) return { ok: false, reason: 'the worktree surface is unresolvable (git ls-files -v failed) — fail closed' };
589
- for (const entry of flagged) {
590
- if (entry.length < 3 || entry[1] !== ' ') return { ok: false, reason: `the worktree surface is unresolvable (unparseable ls-files -v entry ${JSON.stringify(entry)}) — fail closed` };
591
- const assumeUnchanged = /[a-z]/.test(entry[0]);
592
- const skipWorktree = entry[0].toUpperCase() === 'S';
593
- if (assumeUnchanged || skipWorktree) {
594
- const flags = [assumeUnchanged ? 'assume-unchanged' : null, skipWorktree ? 'skip-worktree' : null].filter(Boolean).join(' + ');
595
- return { ok: false, reason: `index-flagged entry ${entry.slice(2)} (${flags}) hides changes from git diff — the worktree surface is undecidable (fail closed)` };
596
- }
597
- }
598
- const tracked = gitPathList(['diff', 'HEAD', '--name-only', '--no-renames', '--ignore-submodules=none', '-z'], root);
599
- const untracked = gitPathList(['ls-files', '--others', '--exclude-standard', '-z'], root);
600
- if (tracked == null || untracked == null) return { ok: false, reason: 'the worktree surface is unresolvable (git diff/ls-files failed) — fail closed' };
601
- return { ok: true, paths: [...new Set([...tracked, ...untracked])] };
602
- };
603
-
604
- // decideFlowCheck({ flowRead, coreRead, owner, motion?, evidence?, consumer? }) → { refusals,
605
- // advisories }. Pure — consumes the FULL read-results of both stores; store health fails closed
606
- // BEFORE any content judgment. `motion` ({ currentBase, resolveBaseDelta, resolveChangedSurface })
607
- // arms the Step-1.4 base-motion refusals; `evidence` ({ receipts, tree, backends }) arms the three
608
- // Phase-1 rungs (#65/#25/#42 — each self-gates on an OWN adoption). Absent inputs keep the decision
609
- // byte-identical to the Plan-2 checker. `consumer` rides through to the #65 lane split and defaults
610
- // to the STRICT lane, so a caller that forgets to thread it inherits strictness.
611
- export const decideFlowCheck = ({ flowRead, coreRead, owner, flowPath = 'the flow store', corePath = 'the core evidence store', motion = null, evidence = null, consumer = 'commit-guard' }) => {
612
- const refusals = [];
613
- const advisories = [];
614
- if (flowRead.readError) refusals.push(`the flow store is unreadable (${flowRead.readError}) — the checker consumes the FULL read-result; inspect ${flowPath} (fail closed)`);
615
- else if (flowRead.malformed > 0) refusals.push(`the flow store carries ${flowRead.malformed} malformed line(s) (${flowRead.malformedReasons[0]}) — unknown kinds and broken records fail closed; inspect ${flowPath}`);
616
- if (coreRead.readError) refusals.push(`the core evidence store is unreadable (${coreRead.readError}) — inspect ${corePath} (fail closed)`);
617
- else if ((coreRead.malformed ?? 0) > 0) refusals.push(`the core evidence store carries ${coreRead.malformed} malformed line(s) (${coreRead.malformedReasons[0]}) — inspect ${corePath} (fail closed)`);
618
- if (refusals.length > 0) return { refusals, advisories };
619
- const records = flowRead.records;
620
- const sup = validateSupersessions(records);
621
- if (!sup.ok) refusals.push(`supersession legality: ${sup.reason} — inspect ${flowPath}`);
622
- for (const planId of [...new Set(records.filter((r) => r.kind === CHAIN_KIND).map((r) => r.planId))]) {
623
- const chain = records.filter((r) => r.kind === CHAIN_KIND && r.planId === planId);
624
- const plan = planRefusals(records, chain, planId, owner, advisories);
625
- refusals.push(...plan.refusals);
626
- if (motion != null && plan.integrityClean) refusals.push(...baseMotionRefusals(chain, planId, owner, motion));
627
- }
628
- refusals.push(...deltaRefusals(records, owner));
629
- refusals.push(...degradeOrderingRefusals(coreRead.records));
630
- if (evidence != null) {
631
- refusals.push(...collectUnansweredRedRefusals({ flowRecords: records, coreRecords: coreRead.records, currentBase: evidence.tree.base, owner, consumer, currentFingerprint: evidence.tree.fingerprint }));
632
- refusals.push(...collectDegradeCoverageRefusals({ flowRecords: records, coreRecords: coreRead.records, tree: evidence.tree, owner, backends: evidence.degradeBackends }));
633
- refusals.push(...collectReceiptCoverageRefusals({ flowRecords: records, receipts: evidence.receipts, tree: evidence.tree, owner, backends: evidence.receiptBackends, declaredPaths: evidence.declaredPaths, refreshCap: evidence.refreshCap }));
634
- }
635
- return { refusals, advisories };
636
- };
48
+ const usageFail = (message) => Object.assign(new Error(message), { exitCode: 2 });
637
49
 
638
50
  // computeFlowDecision({ cwd, consumer }) → { present, owner, armed, broken, refusals, advisories }
639
51
  // — the two-tier answer EVERY composed consumer reads (P3): `present` is tier 1 (store-file