@sabaiway/agent-workflow-kit 5.1.0 → 5.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +95 -0
- package/SKILL.md +13 -1
- package/bridges/antigravity-cli-bridge/SKILL.md +14 -3
- package/bridges/antigravity-cli-bridge/bin/agy-review.sh +220 -30
- package/bridges/antigravity-cli-bridge/bin/agy-review.test.mjs +264 -8
- package/bridges/antigravity-cli-bridge/bin/agy.sh +12 -2
- package/bridges/antigravity-cli-bridge/bin/agy.test.mjs +18 -0
- package/bridges/antigravity-cli-bridge/capability.json +19 -13
- package/bridges/antigravity-cli-bridge/references/driving-agy.md +3 -2
- package/bridges/codex-cli-bridge/SKILL.md +17 -7
- package/bridges/codex-cli-bridge/bin/codex-exec.sh +156 -36
- package/bridges/codex-cli-bridge/bin/codex-exec.test.mjs +228 -4
- package/bridges/codex-cli-bridge/bin/codex-review.sh +205 -34
- package/bridges/codex-cli-bridge/bin/codex-review.test.mjs +276 -5
- package/bridges/codex-cli-bridge/capability.json +10 -7
- package/bridges/codex-cli-bridge/references/driving-codex.md +7 -5
- package/bridges/codex-cli-bridge/references/sandbox-and-flags.md +26 -12
- package/capability.json +1 -1
- package/package.json +1 -1
- package/references/modes/flow-writer.md +37 -0
- package/references/modes/gates.md +4 -4
- package/references/modes/procedures.md +4 -2
- package/references/modes/receipt-deadline.md +16 -0
- package/references/modes/review-state.md +1 -1
- package/references/modes/set-flow.md +22 -0
- package/references/scripts/archive-decisions.mjs +340 -15
- package/references/scripts/archive-decisions.test.mjs +522 -2
- package/tools/cheap-agents.mjs +8 -2
- package/tools/commands.mjs +24 -2
- package/tools/commit-guard.mjs +44 -9
- package/tools/core-evidence.mjs +25 -22
- package/tools/detect-backends.mjs +33 -11
- package/tools/dispatch-record.mjs +926 -0
- package/tools/doc-parity.mjs +21 -6
- package/tools/flow-check.mjs +842 -0
- package/tools/flow-record.mjs +795 -0
- package/tools/flow-store-read.mjs +114 -0
- package/tools/flow-store.mjs +1178 -0
- package/tools/flow-writer.mjs +1265 -0
- package/tools/fs-read-nofollow.mjs +128 -0
- package/tools/gates-declaration.mjs +184 -0
- package/tools/gates-init.mjs +59 -17
- package/tools/orchestration-config.mjs +87 -10
- package/tools/orchestration-write.mjs +3 -3
- package/tools/plan-files.mjs +35 -0
- package/tools/procedures.mjs +75 -11
- package/tools/receipt-deadline.mjs +242 -0
- package/tools/recipes.mjs +21 -0
- package/tools/repo-lex.mjs +22 -0
- package/tools/review-state.mjs +240 -80
- package/tools/run-gates.mjs +361 -139
- package/tools/set-flow.mjs +465 -0
- package/tools/velocity-profile.mjs +8 -2
|
@@ -0,0 +1,842 @@
|
|
|
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
|
+
//
|
|
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
|
+
};
|
|
562
|
+
|
|
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
|
+
};
|
|
570
|
+
|
|
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
|
+
};
|
|
637
|
+
|
|
638
|
+
// computeFlowDecision({ cwd, consumer }) → { present, owner, armed, broken, refusals, advisories }
|
|
639
|
+
// — the two-tier answer EVERY composed consumer reads (P3): `present` is tier 1 (store-file
|
|
640
|
+
// existence; an unstatable leaf reads as a fail-closed health failure), `armed` is tier 2 (>=1
|
|
641
|
+
// adoption on a clean read). Store HEALTH (flow or core) always refuses; SEMANTIC refusals bind
|
|
642
|
+
// only an ARMED store — a valid store with no adoption changes nothing. Under an armed store the
|
|
643
|
+
// decision also carries the three evidence rungs, with `backends` = the SAME consumed set
|
|
644
|
+
// review-state derives (the configured recipe; the computed default consults offline readiness —
|
|
645
|
+
// #42 never falls open). `consumer` (Plan 4 Decision 2): the D10 flow→final comparison runs ONLY
|
|
646
|
+
// on the 'commit-guard' lane — the default 'gate' lane (the in-matrix flow-check --check gate)
|
|
647
|
+
// stays inert on it, because during a final run the "latest final" is by construction the
|
|
648
|
+
// PREVIOUS one and an in-matrix comparison would make a new green final unreachable. The SAME
|
|
649
|
+
// distinction reaches the #65 unanswered-red rung (its own header states the split): the 'gate'
|
|
650
|
+
// lane also answers a red under a provable in-progress retry, for the same reason.
|
|
651
|
+
export const computeFlowDecision = ({ cwd = process.cwd(), consumer = 'gate', probes = {} } = {}) => {
|
|
652
|
+
const fingerprintProbe = probes.fingerprint ?? computeTreeFingerprint;
|
|
653
|
+
const owner = deriveFlowOwner(cwd);
|
|
654
|
+
if (owner == null) {
|
|
655
|
+
// The guard reaches this arm only INSIDE a work tree, so a dead owner probe there must
|
|
656
|
+
// refuse — a silent empty answer would skip the D10 comparison (round-3 disposition). The
|
|
657
|
+
// default 'gate' consumer keeps the empty shape (the CLI owns the not-a-work-tree message).
|
|
658
|
+
return {
|
|
659
|
+
present: false, owner: null, armed: false, broken: null,
|
|
660
|
+
refusals: consumer === 'commit-guard'
|
|
661
|
+
? ['the owning worktree identity is unresolvable — the D10 flow binding cannot be verified (fail closed); re-run inside the git work tree']
|
|
662
|
+
: [],
|
|
663
|
+
advisories: [],
|
|
664
|
+
};
|
|
665
|
+
}
|
|
666
|
+
const flowPath = resolveFlowStorePath(cwd, {});
|
|
667
|
+
const corePath = resolveEvidencePath(cwd, {});
|
|
668
|
+
const flowStat = (() => {
|
|
669
|
+
if (flowPath == null) return null;
|
|
670
|
+
try {
|
|
671
|
+
return lstatSync(flowPath);
|
|
672
|
+
} catch (err) {
|
|
673
|
+
return err && err.code === 'ENOENT' ? null : 'unstatable';
|
|
674
|
+
}
|
|
675
|
+
})();
|
|
676
|
+
const present = flowStat !== null;
|
|
677
|
+
const flowRead = !present ? { records: [], authoritative: [], malformed: 0, malformedReasons: [] }
|
|
678
|
+
: flowStat === 'unstatable' ? { records: [], authoritative: [], malformed: 0, malformedReasons: [], readError: 'the store leaf cannot be stat-ed (fail closed)' }
|
|
679
|
+
: readFlowStore(flowPath);
|
|
680
|
+
const coreRead = readEvidence(corePath);
|
|
681
|
+
const healthBroken = flowRead.readError != null || flowRead.malformed > 0
|
|
682
|
+
|| coreRead.readError != null || (coreRead.malformed ?? 0) > 0;
|
|
683
|
+
const armed = !healthBroken && flowRead.records.some((r) => r.kind === CHAIN_KIND && r.purpose === 'adoption');
|
|
684
|
+
const motion = {
|
|
685
|
+
currentBase: resolveBase(cwd),
|
|
686
|
+
resolveBaseDelta: (from, to) => computeAllPathBaseDelta(cwd, from, to),
|
|
687
|
+
resolveChangedSurface: () => computeAllPathWorktreeSurface(cwd),
|
|
688
|
+
};
|
|
689
|
+
const evidenceRefusals = [];
|
|
690
|
+
let evidence = null;
|
|
691
|
+
if (armed) {
|
|
692
|
+
// The config anchors at the git TOPLEVEL — the same anchor review-state's buildState uses —
|
|
693
|
+
// so a subdirectory invocation can never derive a different recipe.
|
|
694
|
+
const top = resolveGitToplevel(cwd);
|
|
695
|
+
let config = null;
|
|
696
|
+
let configFailure = top == null ? 'the git toplevel is unresolvable' : null;
|
|
697
|
+
if (configFailure == null) {
|
|
698
|
+
try {
|
|
699
|
+
config = loadConfig(top).config;
|
|
700
|
+
} catch (err) {
|
|
701
|
+
configFailure = (err && err.message) || String(err);
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
let readiness = [];
|
|
705
|
+
let detectionFailed = false;
|
|
706
|
+
if (configFailure == null && config?.['plan-execution']?.review == null) {
|
|
707
|
+
try {
|
|
708
|
+
readiness = detectBackends();
|
|
709
|
+
} catch {
|
|
710
|
+
detectionFailed = true;
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
const obligations = configFailure == null
|
|
714
|
+
? requiredBackendsForConfiguredRecipe({ config, readiness, detectionFailed })
|
|
715
|
+
: null;
|
|
716
|
+
if (configFailure != null) {
|
|
717
|
+
evidenceRefusals.push(`the relied-on backend set cannot be derived (${configFailure}) — exact coverage is undecidable on an armed flow (fail closed)`);
|
|
718
|
+
} else if (obligations.unknowable) {
|
|
719
|
+
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)');
|
|
720
|
+
} else {
|
|
721
|
+
// Split sets (P2 council ruling): the solo floor consults EVERY provider's latest receipt,
|
|
722
|
+
// so receipt coverage binds all providers under solo; the degrade escape is never consulted
|
|
723
|
+
// under solo, so a stray degrade must not demand coverage there.
|
|
724
|
+
const receiptsPath = resolveReceiptsPath(cwd, {});
|
|
725
|
+
const receiptsRead = receiptsPath ? readReceipts(receiptsPath) : { receipts: [], malformed: 0 };
|
|
726
|
+
if (receiptsRead.readError != null || receiptsRead.malformed > 0) {
|
|
727
|
+
// RECEIPTS-READER-NOFOLLOW: the decision consults receipts, so a store that cannot be
|
|
728
|
+
// read clean (symlinked/foreign leaf, I/O failure, malformed lines) refuses — an empty
|
|
729
|
+
// success here would wave every receipt-consuming arm through.
|
|
730
|
+
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}`);
|
|
731
|
+
} else {
|
|
732
|
+
evidence = {
|
|
733
|
+
receipts: receiptsRead.receipts,
|
|
734
|
+
tree: { base: motion.currentBase, fingerprint: fingerprintProbe(cwd) },
|
|
735
|
+
receiptBackends: obligations.recipe === 'solo' ? Object.values(DISPLAY_ALIASES) : obligations.backends,
|
|
736
|
+
degradeBackends: obligations.backends,
|
|
737
|
+
declaredPaths: [config?.flow?.debtQueue, config?.flow?.convergenceSummary].filter((p) => typeof p === 'string'),
|
|
738
|
+
refreshCap: config?.flow?.councilRounds ?? null,
|
|
739
|
+
};
|
|
740
|
+
}
|
|
741
|
+
}
|
|
742
|
+
}
|
|
743
|
+
// The D10 arm (Plan 4 Decision 2 + the round-2 sharpening) — commit-guard lane ONLY, and NOT
|
|
744
|
+
// gated on `armed`: evidenceHashes.flow attests "a flow store EXISTED at final time" (a
|
|
745
|
+
// valid-unadopted store also mints it), so a receipt carrying the field demands a LIVE store
|
|
746
|
+
// and a matching projection whatever the current armed state — deletion or truncation after
|
|
747
|
+
// the final is movement. The MISSING-field refusal stays armed-gated (a pre-upgrade final
|
|
748
|
+
// under an unarmed flow passes unchanged). Ordering respects the dead-green contract: the
|
|
749
|
+
// authoritative completed final at the CURRENT fingerprint, status green FIRST (a newer red
|
|
750
|
+
// is never bypassed by an older green's matching hash — the guard's own red arm refuses it),
|
|
751
|
+
// then the hash comparison.
|
|
752
|
+
const bindingRefusals = [];
|
|
753
|
+
if (consumer === 'commit-guard' && !healthBroken) {
|
|
754
|
+
const currentFingerprint = evidence?.tree.fingerprint ?? fingerprintProbe(cwd);
|
|
755
|
+
if (currentFingerprint == null) {
|
|
756
|
+
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');
|
|
757
|
+
}
|
|
758
|
+
const currentFinal = currentFingerprint == null ? undefined : authoritativeOfKind(coreRead.records, 'final')
|
|
759
|
+
.find((r) => r.fingerprintBefore === currentFingerprint);
|
|
760
|
+
if (currentFinal !== undefined && currentFinal.status === 'green') {
|
|
761
|
+
const bound = currentFinal.evidenceHashes?.flow;
|
|
762
|
+
if (typeof bound === 'string') {
|
|
763
|
+
if (!present) {
|
|
764
|
+
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');
|
|
765
|
+
} else {
|
|
766
|
+
const projectionCtx = { owner, currentFingerprint };
|
|
767
|
+
const live = flowProjectionHash(flowRead.records, projectionCtx);
|
|
768
|
+
if (live !== bound) {
|
|
769
|
+
const projection = ownerScopedFlowProjection(flowRead.records, projectionCtx);
|
|
770
|
+
const tail = projection[projection.length - 1];
|
|
771
|
+
const tailShown = tail === undefined
|
|
772
|
+
? 'the live projection is EMPTY'
|
|
773
|
+
: `the live projection tail is a ${tail.kind === CHAIN_KIND ? `chain/${tail.purpose}` : tail.kind} record (${short(canonicalFlowDigest(tail))})`;
|
|
774
|
+
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`);
|
|
775
|
+
}
|
|
776
|
+
}
|
|
777
|
+
} else if (armed && ownerScopedFlowProjection(flowRead.records, { owner, currentFingerprint }).length > 0) {
|
|
778
|
+
// Owner-scoped relevance (round-8 fold): an EMPTY projection has nothing the receipt
|
|
779
|
+
// failed to bind — a foreign-only store stays advisory and never stales the guard.
|
|
780
|
+
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');
|
|
781
|
+
}
|
|
782
|
+
}
|
|
783
|
+
}
|
|
784
|
+
const { refusals, advisories } = decideFlowCheck({ flowRead, coreRead, owner, flowPath, corePath, motion, evidence, consumer });
|
|
785
|
+
// Semantic refusals bind only an ARMED store; the D10 binding refusals ride the commit-guard
|
|
786
|
+
// lane UNCONDITIONALLY — a deleted or truncated store must never un-arm the binding.
|
|
787
|
+
const effectiveRefusals = healthBroken ? refusals : [...(armed ? [...refusals, ...evidenceRefusals] : []), ...bindingRefusals];
|
|
788
|
+
return { present, owner, armed, broken: healthBroken ? refusals[0] ?? 'store health failed closed' : null, refusals: effectiveRefusals, advisories: armed ? advisories : [] };
|
|
789
|
+
};
|
|
790
|
+
|
|
791
|
+
// runFlowCheck({ cwd }) → { code, lines }. Resolution runs on an EMPTY env by construction — see
|
|
792
|
+
// the consumer env discipline in the header.
|
|
793
|
+
export const runFlowCheck = ({ cwd = process.cwd() } = {}) => {
|
|
794
|
+
const d = computeFlowDecision({ cwd });
|
|
795
|
+
if (d.owner == null) return { code: 1, lines: ['flow-check: not a git work tree — there is no flow store to check'] };
|
|
796
|
+
const lines = [
|
|
797
|
+
...d.advisories.map((a) => `flow-check: advisory — ${a}`),
|
|
798
|
+
...d.refusals.map((r) => `flow-check: REFUSED — ${r}`),
|
|
799
|
+
];
|
|
800
|
+
if (d.refusals.length === 0) lines.push(`flow-check: PASS — no flow refusal for this tree (owner ${d.owner})`);
|
|
801
|
+
return { code: d.refusals.length === 0 ? 0 : 1, lines };
|
|
802
|
+
};
|
|
803
|
+
|
|
804
|
+
const HELP = `flow-check — the standalone flow-store checker (flow-orchestration).
|
|
805
|
+
|
|
806
|
+
Usage:
|
|
807
|
+
node flow-check.mjs --check
|
|
808
|
+
|
|
809
|
+
Pure refusal predicates over the FULL read-results of BOTH stores (flow + core evidence) and the
|
|
810
|
+
tree context: store health (malformed/unreadable = fail-closed refusal), chain adoption and
|
|
811
|
+
transition legality, prior-terminal references, worktree scoping (an own OPEN chain refuses; a
|
|
812
|
+
foreign one is advisory only), bookkeeping-delta custody + re-attestation, the
|
|
813
|
+
degrade-before-final ordering (raw order, grouped by fingerprint), and armed base motion
|
|
814
|
+
(in-step transitions must land the class the delta requires: re-baseline or refresh). Reads FIXED
|
|
815
|
+
git-derived store paths — the AW_* overrides stay producer test seams this consumer ignores.
|
|
816
|
+
|
|
817
|
+
COMPOSED (Plan 3 Phase 2): the same decision feeds review-state's gated arms and the
|
|
818
|
+
commit-guard flow arm; declare this CLI as a gates.json gate (the gates-init candidate offers
|
|
819
|
+
it whenever the orchestration config carries a flow block).
|
|
820
|
+
|
|
821
|
+
Exit codes: 0 pass (advisories may print); 1 refused (reason + recovery named); 2 usage.`;
|
|
822
|
+
|
|
823
|
+
export const main = (argv, ctx = {}) => {
|
|
824
|
+
try {
|
|
825
|
+
if (argv.includes('--help') || argv.includes('-h')) return { code: 0, stdout: HELP, stderr: '' };
|
|
826
|
+
const rest = argv.filter((a) => a !== '--check');
|
|
827
|
+
if (rest.length > 0) throw usageFail(`unknown argument: ${rest[0]} (usage: node flow-check.mjs --check)`);
|
|
828
|
+
if (!argv.includes('--check')) throw usageFail('nothing to do — pass --check (or --help)');
|
|
829
|
+
const { code, lines } = runFlowCheck({ cwd: ctx.cwd ?? process.cwd() });
|
|
830
|
+
return { code, stdout: lines.join('\n'), stderr: '' };
|
|
831
|
+
} catch (err) {
|
|
832
|
+
return { code: err.exitCode ?? 1, stdout: '', stderr: `flow-check: ${err.message}` };
|
|
833
|
+
}
|
|
834
|
+
};
|
|
835
|
+
|
|
836
|
+
const isDirectRun = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href;
|
|
837
|
+
if (isDirectRun) {
|
|
838
|
+
const r = main(process.argv.slice(2));
|
|
839
|
+
if (r.stdout) process.stdout.write(r.stdout.endsWith('\n') ? r.stdout : `${r.stdout}\n`);
|
|
840
|
+
if (r.stderr) process.stderr.write(r.stderr.endsWith('\n') ? r.stderr : `${r.stderr}\n`);
|
|
841
|
+
process.exitCode = r.code;
|
|
842
|
+
}
|