@sabaiway/agent-workflow-kit 5.1.0 → 5.2.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.
Files changed (49) hide show
  1. package/CHANGELOG.md +55 -0
  2. package/SKILL.md +13 -1
  3. package/bridges/antigravity-cli-bridge/SKILL.md +14 -3
  4. package/bridges/antigravity-cli-bridge/bin/agy-review.sh +220 -30
  5. package/bridges/antigravity-cli-bridge/bin/agy-review.test.mjs +264 -8
  6. package/bridges/antigravity-cli-bridge/bin/agy.sh +12 -2
  7. package/bridges/antigravity-cli-bridge/bin/agy.test.mjs +18 -0
  8. package/bridges/antigravity-cli-bridge/capability.json +19 -13
  9. package/bridges/antigravity-cli-bridge/references/driving-agy.md +3 -2
  10. package/bridges/codex-cli-bridge/SKILL.md +8 -5
  11. package/bridges/codex-cli-bridge/bin/codex-exec.sh +3 -2
  12. package/bridges/codex-cli-bridge/bin/codex-review.sh +205 -34
  13. package/bridges/codex-cli-bridge/bin/codex-review.test.mjs +276 -5
  14. package/bridges/codex-cli-bridge/capability.json +8 -6
  15. package/bridges/codex-cli-bridge/references/driving-codex.md +2 -2
  16. package/bridges/codex-cli-bridge/references/sandbox-and-flags.md +2 -2
  17. package/capability.json +1 -1
  18. package/package.json +1 -1
  19. package/references/modes/flow-writer.md +37 -0
  20. package/references/modes/gates.md +4 -4
  21. package/references/modes/procedures.md +4 -2
  22. package/references/modes/receipt-deadline.md +16 -0
  23. package/references/modes/review-state.md +1 -1
  24. package/references/modes/set-flow.md +22 -0
  25. package/tools/cheap-agents.mjs +8 -2
  26. package/tools/commands.mjs +24 -2
  27. package/tools/commit-guard.mjs +44 -9
  28. package/tools/core-evidence.mjs +25 -22
  29. package/tools/detect-backends.mjs +32 -11
  30. package/tools/doc-parity.mjs +21 -6
  31. package/tools/flow-check.mjs +806 -0
  32. package/tools/flow-record.mjs +795 -0
  33. package/tools/flow-store-read.mjs +114 -0
  34. package/tools/flow-store.mjs +1178 -0
  35. package/tools/flow-writer.mjs +1265 -0
  36. package/tools/fs-read-nofollow.mjs +128 -0
  37. package/tools/gates-declaration.mjs +184 -0
  38. package/tools/gates-init.mjs +59 -17
  39. package/tools/orchestration-config.mjs +87 -10
  40. package/tools/orchestration-write.mjs +3 -3
  41. package/tools/plan-files.mjs +35 -0
  42. package/tools/procedures.mjs +75 -11
  43. package/tools/receipt-deadline.mjs +242 -0
  44. package/tools/recipes.mjs +21 -0
  45. package/tools/repo-lex.mjs +22 -0
  46. package/tools/review-state.mjs +240 -80
  47. package/tools/run-gates.mjs +361 -139
  48. package/tools/set-flow.mjs +465 -0
  49. package/tools/velocity-profile.mjs +8 -2
@@ -0,0 +1,806 @@
1
+ // flow-check.mjs — the checker refusal core (flow-orchestration, Phase 3): pure refusal predicates
2
+ // over the FULL read-results of BOTH stores (flow + core evidence) and the tree context, plus a
3
+ // standalone --check CLI. A malformed or unreadable store is itself a fail-closed refusal, never a
4
+ // silent empty; every refusal names its recovery as a VERBATIM pasteable flow-writer command
5
+ // (Decision 3/8 — the writer CLI ships beside this checker, so a refusal is never a dead end).
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
+ // COMPOSED (Plan 3 Phase 2): review-state's decideCheck consumes the decision cores as gated
11
+ // arms, commit-guard consults computeFlowDecision as its flow arm, and gates-init offers this
12
+ // CLI as a declarable gate whenever the orchestration config carries a flow block.
13
+ //
14
+ // Consumer env discipline: the checker resolves FIXED git-derived store paths; AW_FLOW_STORE /
15
+ // AW_CORE_EVIDENCE stay PRODUCER test seams this consumer ignores (the commit-guard sanitization
16
+ // discipline) — a poisoned override can neither redirect nor mask the real stores.
17
+
18
+ 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 {
23
+ CHAIN_KIND, validateChainSequence, validateSupersessions, canonicalFlowDigest,
24
+ authoritativeFlowRecords, flowTreeIdentity, ownerScopedFlowProjection, flowProjectionHash,
25
+ } from './flow-record.mjs';
26
+ import {
27
+ resolveFlowStorePath, readFlowStore, deriveFlowOwner,
28
+ walkChainState, validateOpenerReference, resolveRecordReference, isAuthoritativeReferenceTarget,
29
+ } from './flow-store.mjs';
30
+ import {
31
+ resolveEvidencePath, readEvidence, resolveBase, authoritativeOfKind, summarizeReviewReceiptsForTree,
32
+ resolveReceiptsPath, readReceipts, computeTreeFingerprint,
33
+ } from './core-evidence.mjs';
34
+ import { loadConfig } from './orchestration-config.mjs';
35
+ import { requiredBackendsForConfiguredRecipe, DISPLAY_ALIASES } from './recipes.mjs';
36
+ 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
+ export const collectUnansweredRedRefusals = ({ flowRecords, coreRecords, currentBase, owner }) => {
285
+ if (!hasOwnAdoption(flowRecords, owner)) return [];
286
+ const adoptionInstants = flowRecords
287
+ .filter((r) => r.kind === CHAIN_KIND && r.purpose === 'adoption' && r.owner === owner)
288
+ .map((r) => (isCanonicalInstant(r.timestamp) ? Date.parse(r.timestamp) : null));
289
+ const armingInstant = adoptionInstants.length > 0 && adoptionInstants.every((t) => t !== null)
290
+ ? Math.min(...adoptionInstants) : null;
291
+ const refusals = [];
292
+ const identities = flowRecords.map(flowTreeIdentity);
293
+ const basesAt = (fp) => [...new Set(identities.filter((t) => t.fingerprint === fp).map((t) => t.base))];
294
+ const rerunCauses = authoritativeFlowRecords(flowRecords).filter((r) => r.kind === 'rerun-cause');
295
+ const finals = coreRecords.map((r, i) => ({ r, i })).filter(({ r }) => r.kind === 'final');
296
+ const firstFinalByAttempt = new Map();
297
+ for (const { r, i } of finals) {
298
+ if (!firstFinalByAttempt.has(r.attempt)) firstFinalByAttempt.set(r.attempt, i);
299
+ }
300
+ const answeredBy = (red, redAt) => rerunCauses.some((c) => c.attempt === red.attempt
301
+ && finals.some(({ r: g, i: gi }) => gi > redAt
302
+ && basesAt(g.fingerprintBefore).length === 1 && basesAt(g.fingerprintBefore)[0] === currentBase
303
+ && firstFinalByAttempt.get(g.attempt) === gi
304
+ && c.fingerprint === g.fingerprintBefore));
305
+ for (const { r, i } of finals) {
306
+ if (r.status !== 'red') continue;
307
+ if (armingInstant !== null && isCanonicalInstant(r.timestamp) && Date.parse(r.timestamp) < armingInstant) continue;
308
+ const bases = basesAt(r.fingerprintBefore);
309
+ if (bases.length === 0) {
310
+ 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`);
311
+ continue;
312
+ }
313
+ if (bases.length > 1) {
314
+ 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`);
315
+ continue;
316
+ }
317
+ if (bases[0] !== currentBase) continue;
318
+ if (!answeredBy(r, i)) {
319
+ 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>'`)}`);
320
+ }
321
+ }
322
+ return refusals;
323
+ };
324
+
325
+ // #62: base delta ∩ plan surface — disjoint ⇒ re-baseline, intersecting/undecidable ⇒ refresh.
326
+ export const classifyBaseMotion = ({ baseDelta, changedSurface }) => {
327
+ if (!baseDelta?.ok) {
328
+ return { motion: 'undecidable', requires: 'refresh', reason: `the base delta is undecidable (${baseDelta?.reason ?? 'no delta supplied'}) — fail closed: a refresh dispatch is REQUIRED (#62)` };
329
+ }
330
+ if (!changedSurface?.ok) {
331
+ return { motion: 'undecidable', requires: 'refresh', reason: `the changed surface is undecidable (${changedSurface?.reason ?? 'no surface supplied'}) — fail closed: a refresh dispatch is REQUIRED (#62)` };
332
+ }
333
+ const surface = new Set(changedSurface.paths);
334
+ const witness = baseDelta.paths.find((p) => surface.has(p));
335
+ if (witness !== undefined) return { motion: 'intersecting', requires: 'refresh', witness };
336
+ return { motion: 'disjoint', requires: 're-baseline' };
337
+ };
338
+
339
+ // In-step base transitions of the LAST segment (lifecycle projection — round revisions collapsed)
340
+ // must land the class the delta requires; boundary and park→resume are exempt (every commit moves
341
+ // HEAD); the tail binds only a live in-step chain.
342
+ const baseMotionRefusals = (chain, planId, owner, motion) => {
343
+ if (chain[0].owner !== owner) return [];
344
+ const display = (b) => (b == null ? 'null' : short(b));
345
+ const refusals = [];
346
+ const classify = (fromBase, toBase) => classifyBaseMotion({
347
+ baseDelta: motion.resolveBaseDelta(fromBase, toBase),
348
+ changedSurface: motion.resolveChangedSurface(),
349
+ });
350
+ const requirement = (cls) => (cls.motion === 'disjoint' ? 'the delta is disjoint from the plan surface — re-baseline only, never a dispatch (#40)'
351
+ : cls.motion === 'intersecting' ? `the delta intersects the plan surface at ${cls.witness}`
352
+ : cls.reason);
353
+ const seenRounds = new Set();
354
+ const lifecycle = chain.filter((r) => {
355
+ if (r.purpose !== 'round') return true;
356
+ const key = JSON.stringify([r.cycle, r.stepId, r.round]);
357
+ if (seenRounds.has(key)) return false;
358
+ seenRounds.add(key);
359
+ return true;
360
+ });
361
+ const isSegmentStart = (r) => r.purpose === 'resume' || r.purpose === 'unfreeze' || (r.purpose === 'round' && r.opensFrom !== null);
362
+ const states = [];
363
+ const walk = { mode: 'boundary', parked: false };
364
+ for (const r of lifecycle) {
365
+ states.push({ ...walk });
366
+ if (r.purpose === 'park') walk.parked = true;
367
+ else if (r.purpose === 'resume') walk.parked = false;
368
+ else if (r.purpose === 'converged' || r.purpose === 'complete') walk.mode = 'boundary';
369
+ else if (r.purpose === 'unfreeze' || (r.purpose === 'round' && walk.mode === 'boundary')) walk.mode = 'in-step';
370
+ }
371
+ const segStart = lifecycle.reduce((last, r, i) => (isSegmentStart(r) ? i : last), 0);
372
+ for (let i = segStart + 1; i < lifecycle.length; i += 1) {
373
+ const prev = lifecycle[i - 1];
374
+ const r = lifecycle[i];
375
+ if (states[i].mode !== 'in-step' || states[i].parked || r.base === prev.base) continue;
376
+ const cls = classify(prev.base, r.base);
377
+ if (r.purpose !== cls.requires) {
378
+ 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)`);
379
+ continue;
380
+ }
381
+ if (r.purpose === 're-baseline' && r.baseBefore !== prev.base) {
382
+ 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)`);
383
+ }
384
+ }
385
+ const state = walkChainState(chain);
386
+ if (state.completed || state.parked || state.mode !== 'in-step') return refusals;
387
+ const recorded = lifecycle[lifecycle.length - 1].base;
388
+ if (recorded === motion.currentBase) return refusals;
389
+ const cls = classify(recorded, motion.currentBase);
390
+ const recovery = cls.requires === 're-baseline'
391
+ ? writerCommand(`re-baseline -- ${shellQuote(planId)}`)
392
+ : writerCommand(`refresh --cause='base motion' --refreshed-record=${canonicalFlowDigest(chain[chain.length - 1])} -- ${shellQuote(planId)}`);
393
+ const tailRequirement = cls.requires === 're-baseline'
394
+ ? 'a re-baseline record suffices (the delta is disjoint from the plan surface)'
395
+ : `a refresh dispatch is REQUIRED (${cls.motion === 'intersecting' ? `the delta intersects the plan surface at ${cls.witness}` : cls.reason})`;
396
+ 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}`);
397
+ return refusals;
398
+ };
399
+
400
+ // collectDegradeCoverageRefusals (#25/#39): every authoritative core degrade at the current tree
401
+ // must be justified by a flow degrade-justification binding {downMark, degradeDigest, base} to a
402
+ // then-active mark of the same backend. All instants are RECORDED and canonical — wall-clock never
403
+ // enters the decision.
404
+ // #25/#42: every relied-on-backend degrade at the tree needs ONE fully-valid justification.
405
+ export const collectDegradeCoverageRefusals = ({ flowRecords, coreRecords, tree, owner, backends }) => {
406
+ if (!hasOwnAdoption(flowRecords, owner)) return [];
407
+ const refusals = [];
408
+ const justifications = authoritativeFlowRecords(flowRecords).filter((r) => r.kind === 'degrade-justification');
409
+ const justificationFailure = (j, degrade) => {
410
+ if (j.base !== tree.base) {
411
+ 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)`;
412
+ }
413
+ if (j.fingerprint !== tree.fingerprint) {
414
+ 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)`;
415
+ }
416
+ if (!isCanonicalInstant(j.timestamp)) {
417
+ 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)`;
418
+ }
419
+ const jAt = flowRecords.indexOf(j);
420
+ const mark = resolveRecordReference(flowRecords.slice(0, jAt), j.downMark);
421
+ if (mark === undefined || mark.kind !== 'down-mark' || mark.backend !== degrade.backend) {
422
+ 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)`;
423
+ }
424
+ const closedBefore = flowRecords.some((c, k) => k < jAt && (c.kind === 'down-mark-up' || c.kind === 'down-mark-clear') && c.target === j.downMark);
425
+ if (closedBefore) {
426
+ 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)`;
427
+ }
428
+ if (!(Date.parse(j.timestamp) >= Date.parse(mark.timestamp) && Date.parse(j.timestamp) < Date.parse(mark.expiresAt))) {
429
+ 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)`;
430
+ }
431
+ return null;
432
+ };
433
+ for (const degrade of authoritativeOfKind(coreRecords, 'degrade')) {
434
+ if (degrade.fingerprint !== tree.fingerprint || !backends.includes(degrade.backend)) continue;
435
+ const digest = canonicalFlowDigest(degrade);
436
+ const candidates = justifications.filter((x) => x.degradeDigest === digest);
437
+ if (candidates.length === 0) {
438
+ 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)}`)}`);
439
+ continue;
440
+ }
441
+ const failures = candidates.map((j) => justificationFailure(j, degrade));
442
+ if (!failures.includes(null)) refusals.push(failures[0]);
443
+ }
444
+ return refusals;
445
+ };
446
+
447
+ // evaluateInternalAttestationLenses (#15/#3, Phase 4.3): an internal-attestation whose lens set
448
+ // CLAIMS a review provider's slot (a lens named like a configured backend) must ride a
449
+ // THEN-ACTIVE down-mark for that backend — substitution is recorded, never silent; the refusal
450
+ // quotes the fallback lens's additional-only contract from its one home. "Then-active" is decided
451
+ // in the RAW prefix strictly before the attestation (mint-time order, the #25 discipline): the
452
+ // mark is unclosed there and its TTL window contains the attestation's instant — an unparseable
453
+ // attestation instant refuses by name, never passes.
454
+ export const evaluateInternalAttestationLenses = ({ record, records, providerBackends }) => {
455
+ const at = records.indexOf(record);
456
+ if (at === -1) {
457
+ return { ok: false, reason: 'the internal-attestation record does not belong to the supplied record list — prefix scoping is undecidable (fail closed)' };
458
+ }
459
+ const prefix = records.slice(0, at);
460
+ for (const lens of record.lenses) {
461
+ if (!providerBackends.includes(lens)) continue;
462
+ let active = null;
463
+ for (const r of prefix) {
464
+ if (r.kind === 'down-mark' && r.backend === lens) active = r;
465
+ else if ((r.kind === 'down-mark-up' || r.kind === 'down-mark-clear') && r.backend === lens) active = null;
466
+ }
467
+ const failure = active === null
468
+ ? `no down-mark for backend "${lens}" is open at the attestation's position`
469
+ : !isCanonicalInstant(record.timestamp)
470
+ ? `the attestation instant ${JSON.stringify(record.timestamp)} is not a canonical UTC ISO instant, so then-activity is undecidable`
471
+ : !(Date.parse(record.timestamp) >= Date.parse(active.timestamp) && Date.parse(record.timestamp) < Date.parse(active.expiresAt))
472
+ ? `the down-mark for backend "${lens}" is outside its active window at the attestation's instant`
473
+ : null;
474
+ if (failure !== null) {
475
+ 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)` };
476
+ }
477
+ }
478
+ return { ok: true };
479
+ };
480
+
481
+ // The ONE relied-on receipt selector (#42/#61): the latest normal receipt at the CURRENT tree,
482
+ // else — through an unbroken declared-path bookkeeping-delta chain — the backend's LAST receipt
483
+ // judged at its own tree, carrying the lift metadata the PASS labels consume.
484
+ export const selectReliedOnReceipt = ({ receipts, backend, tree, records, declaredPaths, refreshCap }) => {
485
+ const own = receipts.filter((r) => r.backend === backend);
486
+ const current = summarizeReviewReceiptsForTree(own, tree.fingerprint);
487
+ if (current.state === 'current') return { receipt: current.receipt, lifted: 0 };
488
+ const last = own[own.length - 1];
489
+ const candidateFp = typeof last?.fingerprint === 'string' ? last.fingerprint : null;
490
+ if (candidateFp == null || candidateFp === tree.fingerprint) return { receipt: null, lifted: 0 };
491
+ const atCandidate = summarizeReviewReceiptsForTree(own, candidateFp);
492
+ if (atCandidate.state !== 'current') return { receipt: null, lifted: 0 };
493
+ const chain = classifyDeltaChain({ records, fromFingerprint: candidateFp, toFingerprint: tree.fingerprint, declaredPaths, refreshCap });
494
+ if (chain.classification !== 'current') return { receipt: null, lifted: 0 };
495
+ return { receipt: atCandidate.receipt, lifted: chain.links.length };
496
+ };
497
+
498
+ // #42: each relied-on backend's selected receipt must ride an OWN round's dispatch ledger; with
499
+ // lift inputs supplied the selection spans the delta lift, so a LIFTED receipt demands its
500
+ // binding at ITS OWN fingerprint; the entry's dispatchBase must equal the round's recorded base.
501
+ export const collectReceiptCoverageRefusals = ({ flowRecords, receipts, tree, owner, backends, declaredPaths = null, refreshCap = null }) => {
502
+ if (!hasOwnAdoption(flowRecords, owner)) return [];
503
+ const refusals = [];
504
+ const rounds = authoritativeFlowRecords(flowRecords).filter((r) => r.kind === CHAIN_KIND && r.purpose === 'round' && r.owner === owner);
505
+ for (const backend of [...new Set(backends)]) {
506
+ const relied = declaredPaths != null
507
+ ? selectReliedOnReceipt({ receipts, backend, tree, records: flowRecords, declaredPaths, refreshCap }).receipt
508
+ : summarizeReviewReceiptsForTree(receipts.filter((r) => r.backend === backend), tree.fingerprint).receipt;
509
+ if (relied == null) continue;
510
+ const digest = canonicalFlowDigest(relied);
511
+ const covered = rounds.some((round) => round.fingerprint === relied.fingerprint
512
+ && round.dispatches.some((e) => e.receiptDigest === digest && e.backend === relied.backend && e.dispatchBase === round.base));
513
+ if (!covered) {
514
+ 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`);
515
+ }
516
+ }
517
+ return refusals;
518
+ };
519
+
520
+ // ── the all-path git lane for base-motion inputs (#62) ───────────────────────────────────────────
521
+
522
+ // computeChangedSurface exists for COVERAGE and excludes test files by design — the base-
523
+ // intersection inputs come from these helpers instead: every changed path counts, tests included.
524
+ const gitPathList = (args, cwd) => {
525
+ const r = spawnSync('git', args, { cwd, maxBuffer: 256 * 1024 * 1024, windowsHide: true });
526
+ if (r.error || r.status !== 0) return null;
527
+ return r.stdout.toString('utf8').split('\0').filter(Boolean);
528
+ };
529
+
530
+ // All-path git lane (#62/P22): toplevel-rooted, submodules never ignored, test files included.
531
+ const resolveGitToplevel = (cwd) => {
532
+ const r = spawnSync('git', ['rev-parse', '--show-toplevel'], { cwd, windowsHide: true });
533
+ if (r.error || r.status !== 0) return null;
534
+ const top = r.stdout.toString('utf8').replace(/\r?\n$/, '');
535
+ return top === '' ? null : top;
536
+ };
537
+
538
+ export const computeAllPathBaseDelta = (cwd, fromBase, toBase) => {
539
+ const isSha = (v) => typeof v === 'string' && /^([0-9a-f]{40}|[0-9a-f]{64})$/.test(v);
540
+ if (!isSha(fromBase) || !isSha(toBase)) {
541
+ return { ok: false, reason: `a base delta needs two shas (got ${JSON.stringify(fromBase)} → ${JSON.stringify(toBase)})` };
542
+ }
543
+ const root = resolveGitToplevel(cwd);
544
+ if (root == null) return { ok: false, reason: 'not inside a git work tree — the base delta is unresolvable (fail closed)' };
545
+ const paths = gitPathList(['diff', '--name-only', '--no-renames', '--ignore-submodules=none', '-z', fromBase, toBase], root);
546
+ if (paths == null) return { ok: false, reason: `git diff ${short(fromBase)} ${short(toBase)} failed — an unresolvable base delta fails closed` };
547
+ return { ok: true, paths };
548
+ };
549
+
550
+ export const computeAllPathWorktreeSurface = (cwd) => {
551
+ const root = resolveGitToplevel(cwd);
552
+ if (root == null) return { ok: false, reason: 'not inside a git work tree — the worktree surface is unresolvable (fail closed)' };
553
+ // assume-unchanged/skip-worktree lie to git diff — any flagged entry fails the surface closed.
554
+ const flagged = gitPathList(['ls-files', '-v', '-z'], root);
555
+ if (flagged == null) return { ok: false, reason: 'the worktree surface is unresolvable (git ls-files -v failed) — fail closed' };
556
+ for (const entry of flagged) {
557
+ 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` };
558
+ const assumeUnchanged = /[a-z]/.test(entry[0]);
559
+ const skipWorktree = entry[0].toUpperCase() === 'S';
560
+ if (assumeUnchanged || skipWorktree) {
561
+ const flags = [assumeUnchanged ? 'assume-unchanged' : null, skipWorktree ? 'skip-worktree' : null].filter(Boolean).join(' + ');
562
+ return { ok: false, reason: `index-flagged entry ${entry.slice(2)} (${flags}) hides changes from git diff — the worktree surface is undecidable (fail closed)` };
563
+ }
564
+ }
565
+ const tracked = gitPathList(['diff', 'HEAD', '--name-only', '--no-renames', '--ignore-submodules=none', '-z'], root);
566
+ const untracked = gitPathList(['ls-files', '--others', '--exclude-standard', '-z'], root);
567
+ if (tracked == null || untracked == null) return { ok: false, reason: 'the worktree surface is unresolvable (git diff/ls-files failed) — fail closed' };
568
+ return { ok: true, paths: [...new Set([...tracked, ...untracked])] };
569
+ };
570
+
571
+ // decideFlowCheck({ flowRead, coreRead, owner, motion?, evidence? }) → { refusals, advisories }.
572
+ // Pure — consumes the FULL read-results of both stores; store health fails closed BEFORE any
573
+ // content judgment. `motion` ({ currentBase, resolveBaseDelta, resolveChangedSurface }) arms the
574
+ // Step-1.4 base-motion refusals; `evidence` ({ receipts, tree, backends }) arms the three Phase-1
575
+ // rungs (#65/#25/#42 — each self-gates on an OWN adoption). Absent inputs keep the decision
576
+ // byte-identical to the Plan-2 checker.
577
+ export const decideFlowCheck = ({ flowRead, coreRead, owner, flowPath = 'the flow store', corePath = 'the core evidence store', motion = null, evidence = null }) => {
578
+ const refusals = [];
579
+ const advisories = [];
580
+ if (flowRead.readError) refusals.push(`the flow store is unreadable (${flowRead.readError}) — the checker consumes the FULL read-result; inspect ${flowPath} (fail closed)`);
581
+ 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}`);
582
+ if (coreRead.readError) refusals.push(`the core evidence store is unreadable (${coreRead.readError}) — inspect ${corePath} (fail closed)`);
583
+ 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)`);
584
+ if (refusals.length > 0) return { refusals, advisories };
585
+ const records = flowRead.records;
586
+ const sup = validateSupersessions(records);
587
+ if (!sup.ok) refusals.push(`supersession legality: ${sup.reason} — inspect ${flowPath}`);
588
+ for (const planId of [...new Set(records.filter((r) => r.kind === CHAIN_KIND).map((r) => r.planId))]) {
589
+ const chain = records.filter((r) => r.kind === CHAIN_KIND && r.planId === planId);
590
+ const plan = planRefusals(records, chain, planId, owner, advisories);
591
+ refusals.push(...plan.refusals);
592
+ if (motion != null && plan.integrityClean) refusals.push(...baseMotionRefusals(chain, planId, owner, motion));
593
+ }
594
+ refusals.push(...deltaRefusals(records, owner));
595
+ refusals.push(...degradeOrderingRefusals(coreRead.records));
596
+ if (evidence != null) {
597
+ refusals.push(...collectUnansweredRedRefusals({ flowRecords: records, coreRecords: coreRead.records, currentBase: evidence.tree.base, owner }));
598
+ refusals.push(...collectDegradeCoverageRefusals({ flowRecords: records, coreRecords: coreRead.records, tree: evidence.tree, owner, backends: evidence.degradeBackends }));
599
+ refusals.push(...collectReceiptCoverageRefusals({ flowRecords: records, receipts: evidence.receipts, tree: evidence.tree, owner, backends: evidence.receiptBackends, declaredPaths: evidence.declaredPaths, refreshCap: evidence.refreshCap }));
600
+ }
601
+ return { refusals, advisories };
602
+ };
603
+
604
+ // computeFlowDecision({ cwd, consumer }) → { present, owner, armed, broken, refusals, advisories }
605
+ // — the two-tier answer EVERY composed consumer reads (P3): `present` is tier 1 (store-file
606
+ // existence; an unstatable leaf reads as a fail-closed health failure), `armed` is tier 2 (>=1
607
+ // adoption on a clean read). Store HEALTH (flow or core) always refuses; SEMANTIC refusals bind
608
+ // only an ARMED store — a valid store with no adoption changes nothing. Under an armed store the
609
+ // decision also carries the three evidence rungs, with `backends` = the SAME consumed set
610
+ // review-state derives (the configured recipe; the computed default consults offline readiness —
611
+ // #42 never falls open). `consumer` (Plan 4 Decision 2): the D10 flow→final comparison runs ONLY
612
+ // on the 'commit-guard' lane — the default 'gate' lane (the in-matrix flow-check --check gate)
613
+ // stays inert on it, because during a final run the "latest final" is by construction the
614
+ // PREVIOUS one and an in-matrix comparison would make a new green final unreachable.
615
+ export const computeFlowDecision = ({ cwd = process.cwd(), consumer = 'gate', probes = {} } = {}) => {
616
+ const fingerprintProbe = probes.fingerprint ?? computeTreeFingerprint;
617
+ const owner = deriveFlowOwner(cwd);
618
+ if (owner == null) {
619
+ // The guard reaches this arm only INSIDE a work tree, so a dead owner probe there must
620
+ // refuse — a silent empty answer would skip the D10 comparison (round-3 disposition). The
621
+ // default 'gate' consumer keeps the empty shape (the CLI owns the not-a-work-tree message).
622
+ return {
623
+ present: false, owner: null, armed: false, broken: null,
624
+ refusals: consumer === 'commit-guard'
625
+ ? ['the owning worktree identity is unresolvable — the D10 flow binding cannot be verified (fail closed); re-run inside the git work tree']
626
+ : [],
627
+ advisories: [],
628
+ };
629
+ }
630
+ const flowPath = resolveFlowStorePath(cwd, {});
631
+ const corePath = resolveEvidencePath(cwd, {});
632
+ const flowStat = (() => {
633
+ if (flowPath == null) return null;
634
+ try {
635
+ return lstatSync(flowPath);
636
+ } catch (err) {
637
+ return err && err.code === 'ENOENT' ? null : 'unstatable';
638
+ }
639
+ })();
640
+ const present = flowStat !== null;
641
+ const flowRead = !present ? { records: [], authoritative: [], malformed: 0, malformedReasons: [] }
642
+ : flowStat === 'unstatable' ? { records: [], authoritative: [], malformed: 0, malformedReasons: [], readError: 'the store leaf cannot be stat-ed (fail closed)' }
643
+ : readFlowStore(flowPath);
644
+ const coreRead = readEvidence(corePath);
645
+ const healthBroken = flowRead.readError != null || flowRead.malformed > 0
646
+ || coreRead.readError != null || (coreRead.malformed ?? 0) > 0;
647
+ const armed = !healthBroken && flowRead.records.some((r) => r.kind === CHAIN_KIND && r.purpose === 'adoption');
648
+ const motion = {
649
+ currentBase: resolveBase(cwd),
650
+ resolveBaseDelta: (from, to) => computeAllPathBaseDelta(cwd, from, to),
651
+ resolveChangedSurface: () => computeAllPathWorktreeSurface(cwd),
652
+ };
653
+ const evidenceRefusals = [];
654
+ let evidence = null;
655
+ if (armed) {
656
+ // The config anchors at the git TOPLEVEL — the same anchor review-state's buildState uses —
657
+ // so a subdirectory invocation can never derive a different recipe.
658
+ const top = resolveGitToplevel(cwd);
659
+ let config = null;
660
+ let configFailure = top == null ? 'the git toplevel is unresolvable' : null;
661
+ if (configFailure == null) {
662
+ try {
663
+ config = loadConfig(top).config;
664
+ } catch (err) {
665
+ configFailure = (err && err.message) || String(err);
666
+ }
667
+ }
668
+ let readiness = [];
669
+ let detectionFailed = false;
670
+ if (configFailure == null && config?.['plan-execution']?.review == null) {
671
+ try {
672
+ readiness = detectBackends();
673
+ } catch {
674
+ detectionFailed = true;
675
+ }
676
+ }
677
+ const obligations = configFailure == null
678
+ ? requiredBackendsForConfiguredRecipe({ config, readiness, detectionFailed })
679
+ : null;
680
+ if (configFailure != null) {
681
+ evidenceRefusals.push(`the relied-on backend set cannot be derived (${configFailure}) — exact coverage is undecidable on an armed flow (fail closed)`);
682
+ } else if (obligations.unknowable) {
683
+ evidenceRefusals.push('the relied-on backend set cannot be derived (no configured recipe and the backend detector is down) — exact coverage is undecidable on an armed flow (fail closed)');
684
+ } else {
685
+ // Split sets (P2 council ruling): the solo floor consults EVERY provider's latest receipt,
686
+ // so receipt coverage binds all providers under solo; the degrade escape is never consulted
687
+ // under solo, so a stray degrade must not demand coverage there.
688
+ const receiptsPath = resolveReceiptsPath(cwd, {});
689
+ const receiptsRead = receiptsPath ? readReceipts(receiptsPath) : { receipts: [], malformed: 0 };
690
+ if (receiptsRead.readError != null || receiptsRead.malformed > 0) {
691
+ // RECEIPTS-READER-NOFOLLOW: the decision consults receipts, so a store that cannot be
692
+ // read clean (symlinked/foreign leaf, I/O failure, malformed lines) refuses — an empty
693
+ // success here would wave every receipt-consuming arm through.
694
+ evidenceRefusals.push(`the review-receipts store is unreadable or malformed (${receiptsRead.readError ?? `${receiptsRead.malformed} malformed line(s)`}) — the flow decision consults receipts, so it fails closed; inspect ${receiptsPath}`);
695
+ } else {
696
+ evidence = {
697
+ receipts: receiptsRead.receipts,
698
+ tree: { base: motion.currentBase, fingerprint: fingerprintProbe(cwd) },
699
+ receiptBackends: obligations.recipe === 'solo' ? Object.values(DISPLAY_ALIASES) : obligations.backends,
700
+ degradeBackends: obligations.backends,
701
+ declaredPaths: [config?.flow?.debtQueue, config?.flow?.convergenceSummary].filter((p) => typeof p === 'string'),
702
+ refreshCap: config?.flow?.councilRounds ?? null,
703
+ };
704
+ }
705
+ }
706
+ }
707
+ // The D10 arm (Plan 4 Decision 2 + the round-2 sharpening) — commit-guard lane ONLY, and NOT
708
+ // gated on `armed`: evidenceHashes.flow attests "a flow store EXISTED at final time" (a
709
+ // valid-unadopted store also mints it), so a receipt carrying the field demands a LIVE store
710
+ // and a matching projection whatever the current armed state — deletion or truncation after
711
+ // the final is movement. The MISSING-field refusal stays armed-gated (a pre-upgrade final
712
+ // under an unarmed flow passes unchanged). Ordering respects the dead-green contract: the
713
+ // authoritative completed final at the CURRENT fingerprint, status green FIRST (a newer red
714
+ // is never bypassed by an older green's matching hash — the guard's own red arm refuses it),
715
+ // then the hash comparison.
716
+ const bindingRefusals = [];
717
+ if (consumer === 'commit-guard' && !healthBroken) {
718
+ const currentFingerprint = evidence?.tree.fingerprint ?? fingerprintProbe(cwd);
719
+ if (currentFingerprint == null) {
720
+ bindingRefusals.push('the current tree fingerprint is unresolvable — the D10 flow binding cannot be verified (fail closed); re-run run-gates.mjs --final on a healthy tree');
721
+ }
722
+ const currentFinal = currentFingerprint == null ? undefined : authoritativeOfKind(coreRead.records, 'final')
723
+ .find((r) => r.fingerprintBefore === currentFingerprint);
724
+ if (currentFinal !== undefined && currentFinal.status === 'green') {
725
+ const bound = currentFinal.evidenceHashes?.flow;
726
+ if (typeof bound === 'string') {
727
+ if (!present) {
728
+ bindingRefusals.push('the green final receipt carries evidenceHashes.flow but the flow store is ABSENT — the store the receipt attested vanished after the final run (disappearance is movement; fail closed); restore the flow store or re-run run-gates.mjs --final');
729
+ } else {
730
+ const projectionCtx = { owner, currentFingerprint };
731
+ const live = flowProjectionHash(flowRead.records, projectionCtx);
732
+ if (live !== bound) {
733
+ const projection = ownerScopedFlowProjection(flowRead.records, projectionCtx);
734
+ const tail = projection[projection.length - 1];
735
+ const tailShown = tail === undefined
736
+ ? 'the live projection is EMPTY'
737
+ : `the live projection tail is a ${tail.kind === CHAIN_KIND ? `chain/${tail.purpose}` : tail.kind} record (${short(canonicalFlowDigest(tail))})`;
738
+ bindingRefusals.push(`the flow store moved after the final run — the live owner-scoped projection (${short(live)}) no longer matches the receipt's evidenceHashes.flow (${short(bound)}). DIAGNOSTIC hypothesis only (an aggregate hash cannot prove WHICH record appended): ${tailShown}; re-run run-gates.mjs --final`);
739
+ }
740
+ }
741
+ } else if (armed && ownerScopedFlowProjection(flowRead.records, { owner, currentFingerprint }).length > 0) {
742
+ // Owner-scoped relevance (round-8 fold): an EMPTY projection has nothing the receipt
743
+ // failed to bind — a foreign-only store stays advisory and never stales the guard.
744
+ bindingRefusals.push('the green final receipt at this tree carries NO evidenceHashes.flow (a pre-upgrade final) — the flow→final binding cannot be verified on an armed flow (fail closed); re-run run-gates.mjs --final');
745
+ }
746
+ }
747
+ }
748
+ const { refusals, advisories } = decideFlowCheck({ flowRead, coreRead, owner, flowPath, corePath, motion, evidence });
749
+ // Semantic refusals bind only an ARMED store; the D10 binding refusals ride the commit-guard
750
+ // lane UNCONDITIONALLY — a deleted or truncated store must never un-arm the binding.
751
+ const effectiveRefusals = healthBroken ? refusals : [...(armed ? [...refusals, ...evidenceRefusals] : []), ...bindingRefusals];
752
+ return { present, owner, armed, broken: healthBroken ? refusals[0] ?? 'store health failed closed' : null, refusals: effectiveRefusals, advisories: armed ? advisories : [] };
753
+ };
754
+
755
+ // runFlowCheck({ cwd }) → { code, lines }. Resolution runs on an EMPTY env by construction — see
756
+ // the consumer env discipline in the header.
757
+ export const runFlowCheck = ({ cwd = process.cwd() } = {}) => {
758
+ const d = computeFlowDecision({ cwd });
759
+ if (d.owner == null) return { code: 1, lines: ['flow-check: not a git work tree — there is no flow store to check'] };
760
+ const lines = [
761
+ ...d.advisories.map((a) => `flow-check: advisory — ${a}`),
762
+ ...d.refusals.map((r) => `flow-check: REFUSED — ${r}`),
763
+ ];
764
+ if (d.refusals.length === 0) lines.push(`flow-check: PASS — no flow refusal for this tree (owner ${d.owner})`);
765
+ return { code: d.refusals.length === 0 ? 0 : 1, lines };
766
+ };
767
+
768
+ const HELP = `flow-check — the standalone flow-store checker (flow-orchestration).
769
+
770
+ Usage:
771
+ node flow-check.mjs --check
772
+
773
+ Pure refusal predicates over the FULL read-results of BOTH stores (flow + core evidence) and the
774
+ tree context: store health (malformed/unreadable = fail-closed refusal), chain adoption and
775
+ transition legality, prior-terminal references, worktree scoping (an own OPEN chain refuses; a
776
+ foreign one is advisory only), bookkeeping-delta custody + re-attestation, the
777
+ degrade-before-final ordering (raw order, grouped by fingerprint), and armed base motion
778
+ (in-step transitions must land the class the delta requires: re-baseline or refresh). Reads FIXED
779
+ git-derived store paths — the AW_* overrides stay producer test seams this consumer ignores.
780
+
781
+ COMPOSED (Plan 3 Phase 2): the same decision feeds review-state's gated arms and the
782
+ commit-guard flow arm; declare this CLI as a gates.json gate (the gates-init candidate offers
783
+ it whenever the orchestration config carries a flow block).
784
+
785
+ Exit codes: 0 pass (advisories may print); 1 refused (reason + recovery named); 2 usage.`;
786
+
787
+ export const main = (argv, ctx = {}) => {
788
+ try {
789
+ if (argv.includes('--help') || argv.includes('-h')) return { code: 0, stdout: HELP, stderr: '' };
790
+ const rest = argv.filter((a) => a !== '--check');
791
+ if (rest.length > 0) throw usageFail(`unknown argument: ${rest[0]} (usage: node flow-check.mjs --check)`);
792
+ if (!argv.includes('--check')) throw usageFail('nothing to do — pass --check (or --help)');
793
+ const { code, lines } = runFlowCheck({ cwd: ctx.cwd ?? process.cwd() });
794
+ return { code, stdout: lines.join('\n'), stderr: '' };
795
+ } catch (err) {
796
+ return { code: err.exitCode ?? 1, stdout: '', stderr: `flow-check: ${err.message}` };
797
+ }
798
+ };
799
+
800
+ const isDirectRun = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href;
801
+ if (isDirectRun) {
802
+ const r = main(process.argv.slice(2));
803
+ if (r.stdout) process.stdout.write(r.stdout.endsWith('\n') ? r.stdout : `${r.stdout}\n`);
804
+ if (r.stderr) process.stderr.write(r.stderr.endsWith('\n') ? r.stderr : `${r.stderr}\n`);
805
+ process.exitCode = r.code;
806
+ }