@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,1265 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// flow-writer.mjs — the flow-store writer CLI (flow-orchestration, Plan 3 Step 3.2 + Plan 4
|
|
3
|
+
// Phase 3). The explicit arm set is Decision 8: park / resume / complete / adoption / refresh /
|
|
4
|
+
// re-baseline / rerun-cause / down-mark / down-mark-up / down-mark-clear / degrade-justification /
|
|
5
|
+
// maintainer-override / consult-attestation / round-open / round-land / freeze / unfreeze /
|
|
6
|
+
// converged / internal-attestation — EVERY record class a flow refusal names as its recovery has a
|
|
7
|
+
// pasteable mint arm here, so an armed chain can never become unrecoverably red.
|
|
8
|
+
// The consult-attestation arm (Phase 4.2, Decision 8) binds {backend,
|
|
9
|
+
// nonce, findingDigest} FROM the wrapper-minted finding manifest — the digest is computed over the
|
|
10
|
+
// manifest's findings payload, never hand-supplied — while proposedFixDigest stays the EXPLICIT
|
|
11
|
+
// consult-time input (the digest of the proposed-fix payload the orchestrator submits).
|
|
12
|
+
//
|
|
13
|
+
// The round machinery (Plan 4 Phase 3, Decision 3 — writer arms only): ONE round record per
|
|
14
|
+
// round, REVISED under the round-ledger revision contract — `round-open` mints it (per-dispatch
|
|
15
|
+
// watermark + nonce, minted BEFORE any backend runs, #41), `round-land` revises it (receipt +
|
|
16
|
+
// manifest digests COMPUTED from the files beside the receipts store, never hand-supplied, plus
|
|
17
|
+
// the per-finding disposition ledger, #42/#13/#33). A fingerprint move never rides a revision —
|
|
18
|
+
// it always opens a NEW round (the revision contract keeps round.fingerprint immutable). Design
|
|
19
|
+
// caps are ENFORCED AT THESE ARMS, not at the store (the transition table allows the records):
|
|
20
|
+
// ROUND_HARD_MAX rounds per {cycle, stepId}, UNFREEZE_CAP post-freeze unfreezes per cycle, and no
|
|
21
|
+
// premature terminal (freeze/converged refuse over an unlanded-undegraded dispatch or a delivered
|
|
22
|
+
// non-ship receipt with an empty disposition ledger). Per Decision 8 every cap refusal is
|
|
23
|
+
// SELF-SERVABLE: the over-cap mint requires an explicit non-empty --justification INPUT — the
|
|
24
|
+
// chain shapes are closed (no justification field), so the durable trail is the over-cap record
|
|
25
|
+
// ITSELF (a round past the cap / a second unfreeze is structurally visible in the store) plus the
|
|
26
|
+
// echoed writer report the phase's commit ask quotes; the waste bound is the CAP, never the prose.
|
|
27
|
+
//
|
|
28
|
+
// Every arm computes its tree context (owner, base, fingerprint; cycle/round/commitEpoch from the
|
|
29
|
+
// chain walk) and appends through appendFlowRecord — the store's semantic preflight is the SINGLE
|
|
30
|
+
// legality door; this writer adds NO second validator, and an illegal transition surfaces the
|
|
31
|
+
// store's own refusal verbatim (#59). Chain arms refuse a FOREIGN worktree's chain by name (#57).
|
|
32
|
+
//
|
|
33
|
+
// write-plan-id adds the frontmatter planId line to a plan file, bound tight: the target must be
|
|
34
|
+
// an EXISTING regular file under docs/plans/ (lexically repo-relative, never a symlink), the
|
|
35
|
+
// write is contained-atomic, a file already carrying the SAME planId is an idempotent no-op, and
|
|
36
|
+
// a DIFFERENT existing planId refuses — chain identity never silently changes (#58). The adoption
|
|
37
|
+
// MINT itself stays read-only over the plan file.
|
|
38
|
+
//
|
|
39
|
+
// maintainer-override prints the FULL bound set it is about to record and requires the explicit
|
|
40
|
+
// --checkpoint-approved flag (#38) — without the flag the bound set still prints and nothing is
|
|
41
|
+
// written.
|
|
42
|
+
//
|
|
43
|
+
// Output is ENGLISH/structured (repo-artifact Hard Constraint); the agent localizes when
|
|
44
|
+
// narrating. Exit codes: 0 success (incl. the write-plan-id idempotent no-op); 2 usage; 1 refusal
|
|
45
|
+
// (a store STOP verbatim, a derivation failure, or the missing checkpoint flag). main(argv, ctx)
|
|
46
|
+
// → { code, stdout, stderr }; cwd / env / now are injectable. Dependency-free, Node >= 22. No
|
|
47
|
+
// side effects on import (the isDirectRun idiom).
|
|
48
|
+
|
|
49
|
+
import { readFileSync, lstatSync } from 'node:fs';
|
|
50
|
+
import { join, dirname } from 'node:path';
|
|
51
|
+
import { pathToFileURL, fileURLToPath } from 'node:url';
|
|
52
|
+
import { createHash, randomBytes } from 'node:crypto';
|
|
53
|
+
import {
|
|
54
|
+
FLOW_SCHEMA_VERSION, CHAIN_KIND, canonicalFlowDigest, flowTreeIdentity,
|
|
55
|
+
SAFE_NONCE_RE, findingManifestBasename, decodeFindingManifest,
|
|
56
|
+
} from './flow-record.mjs';
|
|
57
|
+
import {
|
|
58
|
+
FLOW_STORE_STOP, resolveFlowStorePath, readFlowStore, deriveFlowOwner, walkChainState,
|
|
59
|
+
appendFlowRecord, appendFlowRecordWithPreflight, mintAdoption, readPlanFrontmatterId,
|
|
60
|
+
resolveRecordReference, priorChainTerminal,
|
|
61
|
+
} from './flow-store.mjs';
|
|
62
|
+
import { readFileBytesNoFollow, gitLine } from './flow-store-read.mjs';
|
|
63
|
+
import {
|
|
64
|
+
resolveBase, computeTreeFingerprint, lexicalRepoRelative,
|
|
65
|
+
resolveReceiptsPath, readReceipts, summarizeReviewReceiptsForTree,
|
|
66
|
+
resolveEvidencePath, readEvidence, authoritativeOfKind,
|
|
67
|
+
isRecognizedVerdict, isShipVerdict,
|
|
68
|
+
REVIEW_RECEIPT_CLASS, classifyReviewReceiptForTree,
|
|
69
|
+
} from './core-evidence.mjs';
|
|
70
|
+
import { classifyDeltaChain, deltaCustodyIssue } from './flow-check.mjs';
|
|
71
|
+
import { loadConfig } from './orchestration-config.mjs';
|
|
72
|
+
import { computePlanAdoptionCoverage, plansInFlight, quoteReportName } from './review-state.mjs';
|
|
73
|
+
import { writeContainedFileAtomic, lstatNoFollow } from './atomic-write.mjs';
|
|
74
|
+
|
|
75
|
+
const usageFail = (message) => Object.assign(new Error(message), { exitCode: 2 });
|
|
76
|
+
const refuse = (message) => Object.assign(new Error(message), { exitCode: 1 });
|
|
77
|
+
|
|
78
|
+
const PLANS_DIR = 'docs/plans';
|
|
79
|
+
const HEX64_RE = /^[0-9a-f]{64}$/;
|
|
80
|
+
|
|
81
|
+
// ── shared derivation (reads only; legality stays the store preflight's) ────────────
|
|
82
|
+
|
|
83
|
+
const treeContext = (cwd) => {
|
|
84
|
+
const owner = deriveFlowOwner(cwd);
|
|
85
|
+
if (owner == null) throw refuse('not inside a git work tree — the writer derives owner/base/fingerprint from git (fail closed)');
|
|
86
|
+
const fingerprint = computeTreeFingerprint(cwd);
|
|
87
|
+
if (fingerprint == null) throw refuse('cannot compute the tree fingerprint — every flow record binds tree identity (fail closed)');
|
|
88
|
+
return { owner, base: resolveBase(cwd), fingerprint };
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
const readStoreRecords = (cwd, env) => {
|
|
92
|
+
const path = resolveFlowStorePath(cwd, env);
|
|
93
|
+
if (path == null) throw refuse('not inside a git work tree (and no AW_FLOW_STORE override) — there is no flow store');
|
|
94
|
+
const read = readFlowStore(path);
|
|
95
|
+
if (read.readError) throw refuse(`the flow store is unreadable (${read.readError}) — fail closed`);
|
|
96
|
+
if (read.malformed > 0) throw refuse(`the flow store carries ${read.malformed} malformed line(s) (${read.malformedReasons[0]}) — fail closed`);
|
|
97
|
+
return read.records;
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
// The invoking worktree may only move its OWN chains (#57) — a foreign chain stays that
|
|
101
|
+
// worktree's business (the checker already treats it as advisory-only there).
|
|
102
|
+
const chainContext = (records, planId, owner) => {
|
|
103
|
+
const chain = records.filter((r) => r.kind === CHAIN_KIND && r.planId === planId);
|
|
104
|
+
if (chain.length === 0) {
|
|
105
|
+
throw refuse(`plan "${planId}" has no chain in the flow store — adoption is a chain's first record (flow-writer adoption <plan-file>)`);
|
|
106
|
+
}
|
|
107
|
+
if (chain[0].owner !== owner) {
|
|
108
|
+
throw refuse(`plan "${planId}"'s chain is owned by "${chain[0].owner}" (a foreign worktree) — chain records are minted from their own worktree only (#57)`);
|
|
109
|
+
}
|
|
110
|
+
return { chain, state: walkChainState(chain), commitEpoch: chain.reduce((m, r) => Math.max(m, r.commitEpoch), 0) };
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
const chainCommons = ({ planId, tree, state, commitEpoch, timestamp }) => ({
|
|
114
|
+
schema: FLOW_SCHEMA_VERSION, kind: CHAIN_KIND, planId,
|
|
115
|
+
cycle: state.cycle, round: state.round, commitEpoch,
|
|
116
|
+
owner: tree.owner, base: tree.base, timestamp,
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
const activeDownMark = (records, backend) => {
|
|
120
|
+
let active = null;
|
|
121
|
+
for (const r of records) {
|
|
122
|
+
if (r.kind === 'down-mark' && r.backend === backend) active = r;
|
|
123
|
+
else if ((r.kind === 'down-mark-up' || r.kind === 'down-mark-clear') && r.backend === backend) active = null;
|
|
124
|
+
}
|
|
125
|
+
return active;
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
const overrideHeadDigest = (records, vetoReceiptDigest) => {
|
|
129
|
+
let head = null;
|
|
130
|
+
for (const r of records) {
|
|
131
|
+
if (r.kind === 'maintainer-override' && r.vetoReceiptDigest === vetoReceiptDigest) head = r;
|
|
132
|
+
}
|
|
133
|
+
return head === null ? null : canonicalFlowDigest(head);
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
// ── flag parsing ────────────────────────────────────────────────────────────────────
|
|
137
|
+
|
|
138
|
+
// parseFlags(rest, spec) → { values, positionals }. spec: { '--flag': 'value' | 'boolean' | 'list' }
|
|
139
|
+
// — a 'list' flag repeats and collects an array (in argv order); the scalar kinds refuse a repeat.
|
|
140
|
+
// A literal `--` terminates flag parsing (every later token is a positional — the lane a
|
|
141
|
+
// leading-dash operand rides), and a value flag accepts the inline `--flag=value` form (the lane
|
|
142
|
+
// a leading-dash VALUE rides) — the checker's printed recoveries compose exactly these shapes.
|
|
143
|
+
const parseFlags = (rest, spec) => {
|
|
144
|
+
const values = {};
|
|
145
|
+
const positionals = [];
|
|
146
|
+
let terminated = false;
|
|
147
|
+
const set = (flag, value) => {
|
|
148
|
+
const key = flag.slice(2);
|
|
149
|
+
if (spec[flag] === 'list') {
|
|
150
|
+
(values[key] ??= []).push(value);
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
if (values[key] !== undefined) throw usageFail(`duplicate flag: ${flag}`);
|
|
154
|
+
values[key] = value;
|
|
155
|
+
};
|
|
156
|
+
for (let i = 0; i < rest.length; i += 1) {
|
|
157
|
+
const a = rest[i];
|
|
158
|
+
if (terminated || !a.startsWith('--')) {
|
|
159
|
+
positionals.push(a);
|
|
160
|
+
continue;
|
|
161
|
+
}
|
|
162
|
+
if (a === '--') {
|
|
163
|
+
terminated = true;
|
|
164
|
+
continue;
|
|
165
|
+
}
|
|
166
|
+
const eq = a.indexOf('=');
|
|
167
|
+
if (eq !== -1) {
|
|
168
|
+
const flag = a.slice(0, eq);
|
|
169
|
+
if (spec[flag] !== 'value' && spec[flag] !== 'list') throw usageFail(spec[flag] === 'boolean' ? `${flag} takes no value` : `unknown flag: ${flag}`);
|
|
170
|
+
set(flag, a.slice(eq + 1));
|
|
171
|
+
continue;
|
|
172
|
+
}
|
|
173
|
+
const kind = spec[a];
|
|
174
|
+
if (kind === undefined) throw usageFail(`unknown flag: ${a}`);
|
|
175
|
+
if (kind === 'boolean') set(a, true);
|
|
176
|
+
else {
|
|
177
|
+
const v = rest[i + 1];
|
|
178
|
+
if (v === undefined || v.startsWith('--')) throw usageFail(`${a} requires a value (or use ${a}=<value>)`);
|
|
179
|
+
set(a, v);
|
|
180
|
+
i += 1;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
return { values, positionals };
|
|
184
|
+
};
|
|
185
|
+
|
|
186
|
+
const onePlanId = (positionals, arm) => {
|
|
187
|
+
if (positionals.length !== 1) throw usageFail(`${arm} takes exactly one <planId> (got ${positionals.length})`);
|
|
188
|
+
return positionals[0];
|
|
189
|
+
};
|
|
190
|
+
|
|
191
|
+
const requireValue = (values, flag, arm) => {
|
|
192
|
+
const v = values[flag];
|
|
193
|
+
if (v === undefined) throw usageFail(`${arm} requires --${flag}`);
|
|
194
|
+
return v;
|
|
195
|
+
};
|
|
196
|
+
|
|
197
|
+
const requireDigest = (raw, flag) => {
|
|
198
|
+
if (!HEX64_RE.test(raw)) throw usageFail(`--${flag} must be a 64-hex per-record canonical digest (got "${raw}")`);
|
|
199
|
+
return raw;
|
|
200
|
+
};
|
|
201
|
+
|
|
202
|
+
// ── the arms ────────────────────────────────────────────────────────────────────────
|
|
203
|
+
|
|
204
|
+
const buildPlanLaneRecord = ({ purpose, planId, cwd, env, timestamp }) => {
|
|
205
|
+
const tree = treeContext(cwd);
|
|
206
|
+
const { state, commitEpoch } = chainContext(readStoreRecords(cwd, env), planId, tree.owner);
|
|
207
|
+
return { ...chainCommons({ planId, tree, state, commitEpoch, timestamp }), purpose, stepId: null, fingerprint: tree.fingerprint };
|
|
208
|
+
};
|
|
209
|
+
|
|
210
|
+
const buildRefreshRecord = ({ planId, cause, refreshedRecord, cwd, env, timestamp }) => {
|
|
211
|
+
const tree = treeContext(cwd);
|
|
212
|
+
const records = readStoreRecords(cwd, env);
|
|
213
|
+
const { state, commitEpoch } = chainContext(records, planId, tree.owner);
|
|
214
|
+
if (state.stepId == null) throw refuse(`plan "${planId}" has no open step — a refresh is a within-step re-attestation; open the step's round first`);
|
|
215
|
+
const target = resolveRecordReference(records, refreshedRecord);
|
|
216
|
+
if (target === undefined) throw refuse(`no record in the flow store digests to ${refreshedRecord.slice(0, 12)}… — a re-attestation binds an existing record`);
|
|
217
|
+
return {
|
|
218
|
+
...chainCommons({ planId, tree, state, commitEpoch, timestamp }), purpose: 'refresh', stepId: state.stepId,
|
|
219
|
+
fingerprintBefore: flowTreeIdentity(target).fingerprint, fingerprintAfter: tree.fingerprint, cause, refreshedRecord,
|
|
220
|
+
};
|
|
221
|
+
};
|
|
222
|
+
|
|
223
|
+
const buildReBaselineRecord = ({ planId, cwd, env, timestamp }) => {
|
|
224
|
+
const tree = treeContext(cwd);
|
|
225
|
+
const records = readStoreRecords(cwd, env);
|
|
226
|
+
const { chain, state, commitEpoch } = chainContext(records, planId, tree.owner);
|
|
227
|
+
const recorded = chain[chain.length - 1].base;
|
|
228
|
+
if (recorded == null) throw refuse(`plan "${planId}"'s recorded base is null (an unborn branch) — base motion from an unborn branch is not expressible as a re-baseline`);
|
|
229
|
+
const stepId = state.mode === 'in-step' ? state.stepId
|
|
230
|
+
: state.lastTerminal != null && state.lastTerminal.purpose !== 'adoption' ? state.lastTerminal.stepId
|
|
231
|
+
: null;
|
|
232
|
+
return { ...chainCommons({ planId, tree, state, commitEpoch, timestamp }), purpose: 're-baseline', stepId, fingerprint: tree.fingerprint, baseBefore: recorded };
|
|
233
|
+
};
|
|
234
|
+
|
|
235
|
+
const buildDownMarkFamilyRecord = ({ kind, backend, target, cwd, env, timestamp }) => {
|
|
236
|
+
const tree = treeContext(cwd);
|
|
237
|
+
const resolved = target ?? (() => {
|
|
238
|
+
const active = activeDownMark(readStoreRecords(cwd, env), backend);
|
|
239
|
+
if (active === null) throw refuse(`no ACTIVE down-mark for backend "${backend}" — up/clear supersede the backend's active mark; pass --target to name one explicitly`);
|
|
240
|
+
return canonicalFlowDigest(active);
|
|
241
|
+
})();
|
|
242
|
+
return { schema: FLOW_SCHEMA_VERSION, kind, fingerprint: tree.fingerprint, backend, target: resolved, base: tree.base, timestamp };
|
|
243
|
+
};
|
|
244
|
+
|
|
245
|
+
// The justification's mark must be USABLE at mint time, whichever lane named it: it resolves to a
|
|
246
|
+
// down-mark of THIS backend, is not closed by a later up/clear, and its TTL window contains the
|
|
247
|
+
// new record's instant — a justification outside any of these can never satisfy the checker
|
|
248
|
+
// (#25/#39), so minting it would only strand a dead record in the append-only store.
|
|
249
|
+
const resolveUsableDownMark = ({ records, backend, explicit, timestamp }) => {
|
|
250
|
+
const mark = explicit === undefined
|
|
251
|
+
? activeDownMark(records, backend)
|
|
252
|
+
: records.findLast((r) => canonicalFlowDigest(r) === explicit) ?? null;
|
|
253
|
+
if (mark === null) {
|
|
254
|
+
throw refuse(explicit === undefined
|
|
255
|
+
? `no ACTIVE down-mark for backend "${backend}" — a justification rides a then-active mark (#25); mint the down-mark first`
|
|
256
|
+
: `no record in the flow store digests to ${explicit.slice(0, 12)}… — a justification binds an existing down-mark (#25)`);
|
|
257
|
+
}
|
|
258
|
+
if (mark.kind !== 'down-mark' || mark.backend !== backend) {
|
|
259
|
+
throw refuse(`the --down-mark digest resolves to a ${mark.kind} of backend "${mark.backend}" — a justification rides a down-mark of backend "${backend}" (#25)`);
|
|
260
|
+
}
|
|
261
|
+
const digest = canonicalFlowDigest(mark);
|
|
262
|
+
const at = records.indexOf(mark);
|
|
263
|
+
if (records.some((r, i) => i > at && (r.kind === 'down-mark-up' || r.kind === 'down-mark-clear') && r.target === digest)) {
|
|
264
|
+
throw refuse(`the down-mark for backend "${backend}" is already closed by up/clear — a closed mark justifies nothing (#25)`);
|
|
265
|
+
}
|
|
266
|
+
if (!(Date.parse(timestamp) >= Date.parse(mark.timestamp) && Date.parse(timestamp) < Date.parse(mark.expiresAt))) {
|
|
267
|
+
throw refuse(`the down-mark for backend "${backend}" is outside its active window at mint time (expires ${mark.expiresAt}) — close it (down-mark-clear) and mint a fresh mark; a justification outside the window can never satisfy (#25/#39)`);
|
|
268
|
+
}
|
|
269
|
+
return digest;
|
|
270
|
+
};
|
|
271
|
+
|
|
272
|
+
const buildDegradeJustificationRecord = ({ backend, downMark, degradeDigest, cwd, env, timestamp }) => {
|
|
273
|
+
const tree = treeContext(cwd);
|
|
274
|
+
const records = readStoreRecords(cwd, env);
|
|
275
|
+
const mark = resolveUsableDownMark({ records, backend, explicit: downMark, timestamp });
|
|
276
|
+
// The core store is ALWAYS read fail-closed and the authoritative degrade resolved — an
|
|
277
|
+
// explicit --degrade-digest only VERIFIES that resolution (the --veto-receipt rule): an unknown
|
|
278
|
+
// or foreign digest would mint a justification that can never close the refusal.
|
|
279
|
+
const corePath = resolveEvidencePath(cwd, env);
|
|
280
|
+
const coreRead = corePath == null ? { records: [] } : readEvidence(corePath);
|
|
281
|
+
if (coreRead.readError || (coreRead.malformed ?? 0) > 0) {
|
|
282
|
+
throw refuse(`the core evidence store is unreadable or malformed (${coreRead.readError ?? coreRead.malformedReasons[0]}) — cannot resolve the degrade record (fail closed)`);
|
|
283
|
+
}
|
|
284
|
+
const candidates = authoritativeOfKind(coreRead.records, 'degrade').filter((r) => r.backend === backend && r.fingerprint === tree.fingerprint);
|
|
285
|
+
if (candidates.length === 0) {
|
|
286
|
+
throw refuse(`no core degrade record for backend "${backend}" at the current tree — mint it first (core-evidence degrade), then justify it here`);
|
|
287
|
+
}
|
|
288
|
+
const degrade = canonicalFlowDigest(candidates[candidates.length - 1]);
|
|
289
|
+
if (degradeDigest !== undefined && degradeDigest !== degrade) {
|
|
290
|
+
throw refuse(`--degrade-digest ${degradeDigest.slice(0, 12)}… is not the authoritative core degrade of backend "${backend}" at the current tree (${degrade.slice(0, 12)}…) — a foreign digest never mints (#25)`);
|
|
291
|
+
}
|
|
292
|
+
return { schema: FLOW_SCHEMA_VERSION, kind: 'degrade-justification', fingerprint: tree.fingerprint, downMark: mark, degradeDigest: degrade, base: tree.base, timestamp };
|
|
293
|
+
};
|
|
294
|
+
|
|
295
|
+
const buildOverride = ({ planId, backend, vetoReceipt, chainRecord, cwd, env, timestamp }) => {
|
|
296
|
+
const tree = treeContext(cwd);
|
|
297
|
+
const records = readStoreRecords(cwd, env);
|
|
298
|
+
const { chain } = chainContext(records, planId, tree.owner);
|
|
299
|
+
// ONE resolution point: the backend's authoritative CURRENT-tree receipt. An explicit
|
|
300
|
+
// --veto-receipt only VERIFIES it (an old, foreign-backend, or foreign-tree receipt never
|
|
301
|
+
// mints), and only the recognized NEGATIVE class is overridable — decideCheck consults
|
|
302
|
+
// overrides for exactly that class (#48/#56; unrecognized verdicts are never overridable).
|
|
303
|
+
const receiptsPath = resolveReceiptsPath(cwd, env);
|
|
304
|
+
const receiptsRead = receiptsPath == null ? { receipts: [], malformed: 0 } : readReceipts(receiptsPath);
|
|
305
|
+
if (receiptsRead.readError) {
|
|
306
|
+
throw refuse(`the receipts store is unreadable (${receiptsRead.readError}) — an override never binds a partially read store (fail closed)`);
|
|
307
|
+
}
|
|
308
|
+
if (receiptsRead.malformed > 0) {
|
|
309
|
+
throw refuse(`the receipts store carries ${receiptsRead.malformed} malformed line(s) — an override never binds a partially read store (fail closed)`);
|
|
310
|
+
}
|
|
311
|
+
const receipts = receiptsRead.receipts;
|
|
312
|
+
const current = summarizeReviewReceiptsForTree(receipts.filter((r) => r.backend === backend), tree.fingerprint);
|
|
313
|
+
if (current.state !== 'current' || current.receipt == null) {
|
|
314
|
+
throw refuse(`no current-tree review receipt of backend "${backend}" to override — the bound set pins the vetoing receipt's own tree (#56)`);
|
|
315
|
+
}
|
|
316
|
+
const vetoDigest = canonicalFlowDigest(current.receipt);
|
|
317
|
+
if (vetoReceipt !== undefined && vetoReceipt !== vetoDigest) {
|
|
318
|
+
throw refuse(`--veto-receipt ${vetoReceipt.slice(0, 12)}… is not the backend's authoritative CURRENT-tree receipt (${vetoDigest.slice(0, 12)}…) — an old, foreign-backend, or foreign-tree receipt never mints an override (#56)`);
|
|
319
|
+
}
|
|
320
|
+
const verdict = current.receipt.verdict;
|
|
321
|
+
if (!(isRecognizedVerdict(verdict) && !isShipVerdict(verdict))) {
|
|
322
|
+
throw refuse(`the current receipt verdict ${JSON.stringify(verdict)} of backend "${backend}" is not a recognized NEGATIVE — only the overridable veto class mints an override (#48/#56; unrecognized verdicts are never overridable)`);
|
|
323
|
+
}
|
|
324
|
+
if (chainRecord !== undefined && !chain.some((r) => canonicalFlowDigest(r) === chainRecord)) {
|
|
325
|
+
throw refuse(`--chain-record ${chainRecord.slice(0, 12)}… does not resolve to a record of plan "${planId}"'s chain — an override never binds a foreign plan's chain from this arm (#56)`);
|
|
326
|
+
}
|
|
327
|
+
const record = {
|
|
328
|
+
schema: FLOW_SCHEMA_VERSION, kind: 'maintainer-override', fingerprint: tree.fingerprint,
|
|
329
|
+
vetoReceiptDigest: vetoDigest, backend, verdict,
|
|
330
|
+
chainRecord: chainRecord ?? canonicalFlowDigest(chain[chain.length - 1]),
|
|
331
|
+
supersedes: overrideHeadDigest(records, vetoDigest), base: tree.base, timestamp,
|
|
332
|
+
};
|
|
333
|
+
const boundSet = [
|
|
334
|
+
'maintainer-override — the FULL bound set about to be recorded (#38/#56):',
|
|
335
|
+
` vetoReceiptDigest: ${record.vetoReceiptDigest}`,
|
|
336
|
+
` backend: ${record.backend}`,
|
|
337
|
+
` verdict: ${JSON.stringify(record.verdict)}`,
|
|
338
|
+
` base: ${record.base}`,
|
|
339
|
+
` fingerprint: ${record.fingerprint}`,
|
|
340
|
+
` chainRecord: ${record.chainRecord}`,
|
|
341
|
+
` supersedes: ${record.supersedes ?? 'null (the first override of this veto instance)'}`,
|
|
342
|
+
];
|
|
343
|
+
return { record, boundSet };
|
|
344
|
+
};
|
|
345
|
+
|
|
346
|
+
// The ONE {backend, nonce}-named manifest resolver the manifest-consuming arms share
|
|
347
|
+
// (consult-attestation, round-land): compose the containment-checked name, read it through the
|
|
348
|
+
// kit's race-free no-follow reader (a binding never rides a link, a FIFO can never block the
|
|
349
|
+
// mint, a foreign node refuses by class — the wrapper mints regular files only), decode
|
|
350
|
+
// fail-closed, and verify the declared identity — each arm maps the typed outcome to its own
|
|
351
|
+
// refusal wording.
|
|
352
|
+
const readManifestDecoded = ({ receiptsPath, backend, nonce }) => {
|
|
353
|
+
const basename = findingManifestBasename(backend, nonce);
|
|
354
|
+
if (basename == null) return { outcome: 'unsafe' };
|
|
355
|
+
const path = join(dirname(receiptsPath), basename);
|
|
356
|
+
const read = readFileBytesNoFollow(path);
|
|
357
|
+
if (read.outcome === 'absent') return { outcome: 'absent', path };
|
|
358
|
+
if (read.outcome === 'foreign') return { outcome: 'foreign', path, className: read.className };
|
|
359
|
+
if (read.outcome !== 'ok') return { outcome: 'error', path, code: read.code };
|
|
360
|
+
const decoded = decodeFindingManifest(read.bytes);
|
|
361
|
+
if (!decoded.ok) return { outcome: 'malformed', path, reason: decoded.reason };
|
|
362
|
+
if (decoded.manifest.backend !== backend || decoded.manifest.nonce !== nonce) {
|
|
363
|
+
return { outcome: 'foreign-identity', path, manifest: decoded.manifest };
|
|
364
|
+
}
|
|
365
|
+
return { outcome: 'ok', path, bytes: read.bytes, manifest: decoded.manifest };
|
|
366
|
+
};
|
|
367
|
+
|
|
368
|
+
const requireReceiptsPath = (cwd, env, why) => {
|
|
369
|
+
const receiptsPath = resolveReceiptsPath(cwd, env);
|
|
370
|
+
if (receiptsPath == null) throw refuse(`the receipts path is unresolvable (no git dir and no AW_REVIEW_RECEIPTS) — ${why} (fail closed)`);
|
|
371
|
+
return receiptsPath;
|
|
372
|
+
};
|
|
373
|
+
|
|
374
|
+
// The consult-attestation arm reads the {backend, nonce}-named finding manifest beside the
|
|
375
|
+
// receipts file fail-closed: findingDigest is COMPUTED from the manifest's findings payload
|
|
376
|
+
// (form-provable binding — a hand-supplied digest could name findings nobody delivered), and the
|
|
377
|
+
// record binds the open step's {cycle, stepId, round} from the chain walk.
|
|
378
|
+
const buildConsultAttestation = ({ planId, backend, nonce, proposedFixDigest, cwd, env, timestamp }) => {
|
|
379
|
+
if (!SAFE_NONCE_RE.test(nonce)) throw usageFail(`--nonce must satisfy the safe nonce grammar ([A-Za-z0-9._-]{1,64}) — got ${JSON.stringify(nonce)}`);
|
|
380
|
+
const tree = treeContext(cwd);
|
|
381
|
+
const records = readStoreRecords(cwd, env);
|
|
382
|
+
const { state } = chainContext(records, planId, tree.owner);
|
|
383
|
+
if (state.stepId == null) throw refuse(`plan "${planId}" has no open step — a consult-attestation binds an open step's round; open the step's round first`);
|
|
384
|
+
const receiptsPath = requireReceiptsPath(cwd, env, 'the finding manifest lives beside the receipts file');
|
|
385
|
+
const m = readManifestDecoded({ receiptsPath, backend, nonce });
|
|
386
|
+
if (m.outcome === 'unsafe') {
|
|
387
|
+
throw refuse(`no manifest name composes for {backend ${JSON.stringify(backend)}, nonce ${JSON.stringify(nonce)}} under the safe grammar — an unsafe token never resolves a manifest (fail closed)`);
|
|
388
|
+
}
|
|
389
|
+
if (m.outcome === 'absent') {
|
|
390
|
+
throw refuse(`no readable finding manifest for {backend "${backend}", nonce "${nonce}"} at ${m.path} (ENOENT) — the wrapper mints it on a nonce-supplied dispatch; a consult binds a real manifest (fail closed)`);
|
|
391
|
+
}
|
|
392
|
+
if (m.outcome === 'foreign') {
|
|
393
|
+
throw refuse(`the finding manifest at ${m.path} is a ${m.className}, not a regular file — an attestation never binds through a symlink or a FIFO (fail closed)`);
|
|
394
|
+
}
|
|
395
|
+
if (m.outcome === 'error') {
|
|
396
|
+
throw refuse(`the finding manifest at ${m.path} is unreadable (${m.code}) — fail closed`);
|
|
397
|
+
}
|
|
398
|
+
if (m.outcome === 'malformed') throw refuse(`the finding manifest at ${m.path} is malformed — ${m.reason} — it never mints a consult-attestation`);
|
|
399
|
+
if (m.outcome === 'foreign-identity') {
|
|
400
|
+
throw refuse(`the finding manifest at ${m.path} declares {backend "${m.manifest.backend}", nonce "${m.manifest.nonce}"} — a foreign-identity manifest never mints a consult-attestation (fail closed)`);
|
|
401
|
+
}
|
|
402
|
+
return {
|
|
403
|
+
schema: FLOW_SCHEMA_VERSION, kind: 'consult-attestation', fingerprint: tree.fingerprint,
|
|
404
|
+
backend, nonce, planId, cycle: state.cycle, stepId: state.stepId, round: state.round,
|
|
405
|
+
findingDigest: createHash('sha256').update(m.manifest.findings, 'utf8').digest('hex'),
|
|
406
|
+
proposedFixDigest, base: tree.base, timestamp,
|
|
407
|
+
};
|
|
408
|
+
};
|
|
409
|
+
|
|
410
|
+
// ── the round machinery (Plan 4 Phase 3 — Decision 3: writer arms only) ──────────────
|
|
411
|
+
|
|
412
|
+
// Design §2 caps, enforced AT THE ARMS (the transition table allows the records; the store stays
|
|
413
|
+
// the single legality door for TRANSITIONS): HARD_MAX council rounds per {cycle, stepId}, one
|
|
414
|
+
// post-freeze unfreeze per cycle, and the redesign valve at 2 cycles per plan (--new-cycle
|
|
415
|
+
// reopens a converged step in the next cycle — round-1 fold F3). Per Decision 8 an over-cap
|
|
416
|
+
// mint is SELF-SERVABLE — it requires an explicit non-empty --justification input, never a
|
|
417
|
+
// human wait-state.
|
|
418
|
+
export const ROUND_HARD_MAX = 3;
|
|
419
|
+
export const UNFREEZE_CAP = 1;
|
|
420
|
+
export const REDESIGN_CYCLE_CAP = 2;
|
|
421
|
+
|
|
422
|
+
const shellQuote = (v) => `'${String(v).replaceAll("'", "'\\''")}'`;
|
|
423
|
+
const RECEIPT_DEADLINE_TOOL = join(dirname(fileURLToPath(import.meta.url)), 'receipt-deadline.mjs');
|
|
424
|
+
|
|
425
|
+
// Decision 8: the cap refusal names the recorded-justification lane; a justification INSIDE the
|
|
426
|
+
// cap refuses too (fail closed — an input that binds nothing is never silently dropped, the
|
|
427
|
+
// subset-attempt diagnosis discipline).
|
|
428
|
+
const gateCapJustification = ({ justification, overCap, refusal }) => {
|
|
429
|
+
if (!overCap) {
|
|
430
|
+
if (justification !== undefined) throw usageFail('--justification rides only an over-cap mint (Decision 8) — inside the cap it binds nothing; drop it');
|
|
431
|
+
return null;
|
|
432
|
+
}
|
|
433
|
+
if (typeof justification !== 'string' || justification.length === 0) {
|
|
434
|
+
throw refuse(`${refusal}; the over-cap mint requires an explicit non-empty --justification <text> (Decision 8 — recorded and self-servable, never a wait-for-maintainer)`);
|
|
435
|
+
}
|
|
436
|
+
return justification;
|
|
437
|
+
};
|
|
438
|
+
|
|
439
|
+
// The receipts store, read ONCE through the race-free no-follow reader → { path, bytes | null }.
|
|
440
|
+
const readReceiptsBytesFailClosed = (cwd, env, why) => {
|
|
441
|
+
const path = requireReceiptsPath(cwd, env, why);
|
|
442
|
+
const read = readFileBytesNoFollow(path);
|
|
443
|
+
if (read.outcome === 'absent') return { path, bytes: null };
|
|
444
|
+
if (read.outcome === 'foreign') throw refuse(`the receipts store at ${path} is a ${read.className}, not a regular file — never followed, never read (fail closed)`);
|
|
445
|
+
if (read.outcome !== 'ok') throw refuse(`the receipts store at ${path} is unreadable (${read.code}) — fail closed`);
|
|
446
|
+
return { path, bytes: read.bytes };
|
|
447
|
+
};
|
|
448
|
+
|
|
449
|
+
// Complete (newline-terminated) JSONL lines with their byte offsets. ANY malformed complete line
|
|
450
|
+
// refuses — a round binding never rides a partially readable store (the maintainer-override
|
|
451
|
+
// precedent). A trailing unterminated fragment is an in-flight append, not a line — it never
|
|
452
|
+
// binds and never refuses.
|
|
453
|
+
const parseReceiptLines = (bytes, path) => {
|
|
454
|
+
const lines = [];
|
|
455
|
+
let offset = 0;
|
|
456
|
+
for (;;) {
|
|
457
|
+
const nl = bytes.indexOf(0x0a, offset);
|
|
458
|
+
if (nl === -1) break;
|
|
459
|
+
const raw = bytes.subarray(offset, nl).toString('utf8');
|
|
460
|
+
if (raw.trim() !== '') {
|
|
461
|
+
let parsed = null;
|
|
462
|
+
try {
|
|
463
|
+
parsed = JSON.parse(raw);
|
|
464
|
+
} catch { parsed = null; }
|
|
465
|
+
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
466
|
+
throw refuse(`the receipts store at ${path} carries a malformed line at byte ${offset} — a round binding never rides a partially readable store (fail closed)`);
|
|
467
|
+
}
|
|
468
|
+
lines.push({ offset, record: parsed });
|
|
469
|
+
}
|
|
470
|
+
offset = nl + 1;
|
|
471
|
+
}
|
|
472
|
+
return lines;
|
|
473
|
+
};
|
|
474
|
+
|
|
475
|
+
// The latest (authoritative) revision of each round index of one step's {cycle, stepId}.
|
|
476
|
+
const stepRoundHeads = (chain, cycle, stepId) => {
|
|
477
|
+
const byRound = new Map();
|
|
478
|
+
for (const r of chain) {
|
|
479
|
+
if (r.purpose === 'round' && r.cycle === cycle && r.stepId === stepId) byRound.set(r.round, r);
|
|
480
|
+
}
|
|
481
|
+
return [...byRound.values()];
|
|
482
|
+
};
|
|
483
|
+
|
|
484
|
+
// The authoritative core degrade set — the core store reads fail-closed.
|
|
485
|
+
const coreDegradeRecords = (cwd, env) => {
|
|
486
|
+
const corePath = resolveEvidencePath(cwd, env);
|
|
487
|
+
const read = corePath == null ? { records: [] } : readEvidence(corePath);
|
|
488
|
+
if (read.readError || (read.malformed ?? 0) > 0) {
|
|
489
|
+
throw refuse(`the core evidence store is unreadable or malformed (${read.readError ?? read.malformedReasons[0]}) — cannot resolve degrade coverage for pending dispatches (fail closed)`);
|
|
490
|
+
}
|
|
491
|
+
return authoritativeOfKind(read.records, 'degrade');
|
|
492
|
+
};
|
|
493
|
+
|
|
494
|
+
const isCanonicalInstant = (v) => typeof v === 'string' && Number.isFinite(Date.parse(v)) && new Date(v).toISOString() === v;
|
|
495
|
+
|
|
496
|
+
// F1/B3 (round-1 folds): a pending dispatch is exempt only through the design's degradation
|
|
497
|
+
// lane — an authoritative core degrade at {backend, the round's dispatched tree} PLUS a
|
|
498
|
+
// mint-time-valid flow degrade-justification binding that degrade at the ROUND's {base,
|
|
499
|
+
// fingerprint} (#25 exactness, mirrored raw-side). The scan is RAW over mint-time prefixes —
|
|
500
|
+
// the {downMark}-keyed authoritative selection would let a later justification on the same
|
|
501
|
+
// sticky mark evict an earlier round's exemption (B3). A bare core degrade never exempts alone
|
|
502
|
+
// (the core record carries no base field to bind).
|
|
503
|
+
const hasJustifiedDegradeAt = ({ flowRecords, degrades, backend, round }) => {
|
|
504
|
+
const digests = new Set(degrades
|
|
505
|
+
.filter((r) => r.backend === backend && r.fingerprint === round.fingerprint)
|
|
506
|
+
.map((r) => canonicalFlowDigest(r)));
|
|
507
|
+
if (digests.size === 0) return false;
|
|
508
|
+
return flowRecords.some((j, at) => {
|
|
509
|
+
if (j.kind !== 'degrade-justification' || !digests.has(j.degradeDigest)) return false;
|
|
510
|
+
if (j.base !== round.base || j.fingerprint !== round.fingerprint) return false;
|
|
511
|
+
if (!isCanonicalInstant(j.timestamp)) return false;
|
|
512
|
+
const prefix = flowRecords.slice(0, at);
|
|
513
|
+
const mark = resolveRecordReference(prefix, j.downMark);
|
|
514
|
+
if (mark === undefined || mark.kind !== 'down-mark' || mark.backend !== backend) return false;
|
|
515
|
+
if (prefix.some((c) => (c.kind === 'down-mark-up' || c.kind === 'down-mark-clear') && c.target === j.downMark)) return false;
|
|
516
|
+
return Date.parse(j.timestamp) >= Date.parse(mark.timestamp) && Date.parse(j.timestamp) < Date.parse(mark.expiresAt);
|
|
517
|
+
});
|
|
518
|
+
};
|
|
519
|
+
|
|
520
|
+
const openStepState = (state, planId, verb) => {
|
|
521
|
+
if (!(state.mode === 'in-step' && !state.parked && !state.completed)) {
|
|
522
|
+
throw refuse(`plan "${planId}" has no open step — ${verb}`);
|
|
523
|
+
}
|
|
524
|
+
};
|
|
525
|
+
|
|
526
|
+
// The ONE completeness walk (F2/F6 folds) — string issue or null; runs BOTH lock-free (the
|
|
527
|
+
// early named refusal) and as the under-lock preflight on the locked snapshot (the snapshot
|
|
528
|
+
// decides — a concurrent revision can never slip a stale terminal or a stranding round
|
|
529
|
+
// through). `onlyRound` narrows round-open's re-check to the current round; terminals walk the
|
|
530
|
+
// whole step. Honest limit: the disposition floor proves FORM (>= 1 recorded disposition on a
|
|
531
|
+
// delivering round), never semantic per-finding completeness — the manifest carries findings as
|
|
532
|
+
// ONE string.
|
|
533
|
+
const stepCompletenessIssue = ({ flowRecords, chain, cycle, stepId, onlyRound = null, label, cwd, env }) => {
|
|
534
|
+
const heads = stepRoundHeads(chain, cycle, stepId).filter((r) => onlyRound == null || r.round === onlyRound);
|
|
535
|
+
let degrades = null;
|
|
536
|
+
for (const round of heads) {
|
|
537
|
+
for (const e of round.dispatches) {
|
|
538
|
+
if (e.receiptDigest !== null) continue;
|
|
539
|
+
degrades ??= coreDegradeRecords(cwd, env);
|
|
540
|
+
if (hasJustifiedDegradeAt({ flowRecords, degrades, backend: e.backend, round })) continue;
|
|
541
|
+
return `${label}: pending dispatch {backend "${e.backend}", nonce "${e.dispatchNonce}"} of round ${round.round} has no landed binding and no JUSTIFIED core degrade at its dispatched tree (${round.fingerprint.slice(0, 12)}…) — land the arrival (flow-writer round-land), or record the failure through the degradation lane: core-evidence degrade, then flow-writer down-mark + degrade-justification at the round's tree (#25; a bare degrade is not base-bound and never exempts alone)`;
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
const landedEntries = heads.flatMap((round) => round.dispatches.filter((e) => e.receiptDigest !== null).map((e) => ({ round, e })));
|
|
545
|
+
if (landedEntries.length === 0) return null;
|
|
546
|
+
const { path: receiptsPath, bytes } = readReceiptsBytesFailClosed(cwd, env, `${label} resolves the landed receipts' verdicts`);
|
|
547
|
+
const byDigest = new Map();
|
|
548
|
+
for (const l of bytes == null ? [] : parseReceiptLines(bytes, receiptsPath)) byDigest.set(canonicalFlowDigest(l.record), l.record);
|
|
549
|
+
for (const { round, e } of landedEntries) {
|
|
550
|
+
const receipt = byDigest.get(e.receiptDigest);
|
|
551
|
+
if (receipt === undefined) {
|
|
552
|
+
return `the landed receipt of dispatch {backend "${e.backend}", nonce "${e.dispatchNonce}"} (round ${round.round}) no longer resolves in the receipts store — a terminal never rides an unresolvable binding (fail closed); inspect ${receiptsPath}`;
|
|
553
|
+
}
|
|
554
|
+
if (!isRecognizedVerdict(receipt.verdict)) {
|
|
555
|
+
return `the landed receipt of backend "${e.backend}" (round ${round.round}) carries the unrecognized verdict ${JSON.stringify(receipt.verdict)} — an unknown verdict never rides a terminal (fail closed)`;
|
|
556
|
+
}
|
|
557
|
+
if (!isShipVerdict(receipt.verdict) && round.dispositions.length === 0) {
|
|
558
|
+
return `${label}: the landed receipt of backend "${e.backend}" (round ${round.round}, verdict ${JSON.stringify(receipt.verdict)}) delivered findings and the round's disposition ledger is EMPTY — land each finding's disposition first (flow-writer round-land --dispose folded|queued|rejected …)`;
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
return null;
|
|
562
|
+
};
|
|
563
|
+
|
|
564
|
+
// F7/B1/B2 (round-1 folds): a terminal binds the CURRENT tree, so a fingerprint move since the
|
|
565
|
+
// last round must be the design's ONE sanctioned post-review move — the declared bookkeeping-
|
|
566
|
+
// delta chain (+ refresh re-attestations), classified by the checker's own classifier with its
|
|
567
|
+
// exact parameter names (B1), and ANCHORED: every link delta and its attesting refresh must sit
|
|
568
|
+
// strictly after the last authoritative round head in raw order (B2 — a pre-round chain never
|
|
569
|
+
// re-certifies a later identical move). Anything else refuses naming the new-round recovery.
|
|
570
|
+
const terminalMoveIssue = ({ flowRecords, chain, cycle, stepId, tree, cwd }) => {
|
|
571
|
+
const heads = stepRoundHeads(chain, cycle, stepId);
|
|
572
|
+
if (heads.length === 0) return null;
|
|
573
|
+
const lastHead = heads.reduce((a, b) => (a.round > b.round ? a : b));
|
|
574
|
+
if (tree.fingerprint === lastHead.fingerprint) return null;
|
|
575
|
+
const top = gitLine(['rev-parse', '--show-toplevel'], cwd) ?? cwd;
|
|
576
|
+
let config;
|
|
577
|
+
try {
|
|
578
|
+
config = loadConfig(top).config;
|
|
579
|
+
} catch (err) {
|
|
580
|
+
return `the tree moved after round ${lastHead.round} and the orchestration config cannot be loaded (${(err && err.message) || err}) — the move classification fails closed; fix the config or open a NEW round on the moved tree`;
|
|
581
|
+
}
|
|
582
|
+
const flow = config?.flow;
|
|
583
|
+
const chainClass = classifyDeltaChain({
|
|
584
|
+
records: flowRecords,
|
|
585
|
+
fromFingerprint: lastHead.fingerprint,
|
|
586
|
+
toFingerprint: tree.fingerprint,
|
|
587
|
+
declaredPaths: [flow?.debtQueue, flow?.convergenceSummary].filter((p) => typeof p === 'string'),
|
|
588
|
+
refreshCap: flow?.councilRounds ?? null,
|
|
589
|
+
});
|
|
590
|
+
if (chainClass.classification !== 'current') {
|
|
591
|
+
return `the tree moved after round ${lastHead.round} (${lastHead.fingerprint.slice(0, 12)}… → ${tree.fingerprint.slice(0, 12)}…) and the move is not a declared bookkeeping-delta chain (${chainClass.reason}) — reviews are contextual: land the declared deltas + refresh, or open a NEW round on the moved tree`;
|
|
592
|
+
}
|
|
593
|
+
const anchor = flowRecords.indexOf(lastHead);
|
|
594
|
+
for (const d of chainClass.links) {
|
|
595
|
+
// G2 (round-2 fold): the checker's custody predicate applies to every anchored link — a
|
|
596
|
+
// store-valid delta with a forged proof never carries a terminal (the gate-time walk alone
|
|
597
|
+
// would let the terminal land first and red only later).
|
|
598
|
+
const custody = deltaCustodyIssue(d);
|
|
599
|
+
if (custody !== null) {
|
|
600
|
+
return `the delta chain carrying the move rides an unproven custody proof (bookkeeping-delta at ${d.path}: ${custody}) — a forged link never carries a terminal; open a NEW round on the moved tree`;
|
|
601
|
+
}
|
|
602
|
+
const at = flowRecords.indexOf(d);
|
|
603
|
+
const digest = canonicalFlowDigest(d);
|
|
604
|
+
const refreshAt = flowRecords.findIndex((s, j) => j > at && s.kind === CHAIN_KIND && s.purpose === 'refresh'
|
|
605
|
+
&& s.refreshedRecord === digest && s.fingerprintBefore === d.fingerprintAfter);
|
|
606
|
+
if (at <= anchor || refreshAt <= anchor) {
|
|
607
|
+
return `the delta chain carrying the move is not anchored after the last round head — a pre-round chain never re-certifies a later identical move (fail closed); open a NEW round on the moved tree`;
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
return null;
|
|
611
|
+
};
|
|
612
|
+
|
|
613
|
+
// round-open — the PRE-DISPATCH half (#41): the round record is minted BEFORE any backend runs,
|
|
614
|
+
// carrying one dispatch-ledger entry per backend {dispatchBase = the round's base, the
|
|
615
|
+
// receipts-file byte-length watermark, a fresh nonce}; receipt/manifest digests stay null until
|
|
616
|
+
// round-land. A boundary invocation opens the step (opensFrom = the chain's prior terminal); an
|
|
617
|
+
// in-step invocation opens the NEXT round (a fingerprint move never rides a revision).
|
|
618
|
+
const buildRoundOpen = ({ planId, stepId, backends, newCycle, justification, cwd, env, timestamp }) => {
|
|
619
|
+
// The backend list is judged BEFORE any store read — a usage-shaped input never surfaces as a
|
|
620
|
+
// state-dependent refusal.
|
|
621
|
+
const seen = new Set();
|
|
622
|
+
for (const backend of backends) {
|
|
623
|
+
if (!SAFE_NONCE_RE.test(backend)) throw usageFail(`--backend must satisfy the safe token grammar ([A-Za-z0-9._-]{1,64}) — got ${JSON.stringify(backend)}`);
|
|
624
|
+
if (seen.has(backend)) throw usageFail(`duplicate --backend "${backend}" — one dispatch per backend per round (a re-dispatch opens a new round)`);
|
|
625
|
+
seen.add(backend);
|
|
626
|
+
}
|
|
627
|
+
const tree = treeContext(cwd);
|
|
628
|
+
const records = readStoreRecords(cwd, env);
|
|
629
|
+
const { chain, state, commitEpoch } = chainContext(records, planId, tree.owner);
|
|
630
|
+
const inStep = state.mode === 'in-step' && !state.parked && !state.completed;
|
|
631
|
+
if (newCycle && inStep) throw usageFail('--new-cycle opens at a step boundary only — an open step continues through its own rounds');
|
|
632
|
+
// The in-step guard re-runs on the LOCKED snapshot too (M6) — this closure is the preflight.
|
|
633
|
+
const roundOpenGuard = (flowRecords) => {
|
|
634
|
+
const lockedChain = flowRecords.filter((r) => r.kind === CHAIN_KIND && r.planId === planId);
|
|
635
|
+
const lockedState = walkChainState(lockedChain);
|
|
636
|
+
if (!(lockedState.mode === 'in-step' && !lockedState.parked && !lockedState.completed)) return;
|
|
637
|
+
// The dead-end guard (F2 extended): after round N+1 opens, round N can never be revised
|
|
638
|
+
// again (the round index only increases within a step), so an unlanded-unjustified dispatch
|
|
639
|
+
// OR an undispositioned non-ship round there would make the step permanently unterminable.
|
|
640
|
+
const issue = stepCompletenessIssue({
|
|
641
|
+
flowRecords, chain: lockedChain, cycle: lockedState.cycle, stepId: lockedState.stepId,
|
|
642
|
+
onlyRound: lockedState.round, label: `round-open refuses — a NEW round would strand round ${lockedState.round}`, cwd, env,
|
|
643
|
+
});
|
|
644
|
+
if (issue != null) throw refuse(issue);
|
|
645
|
+
};
|
|
646
|
+
let target;
|
|
647
|
+
if (inStep) {
|
|
648
|
+
if (stepId !== undefined && stepId !== state.stepId) {
|
|
649
|
+
throw usageFail(`--step "${stepId}" does not match the open step "${state.stepId}" — an in-step round-open derives its step from the chain walk`);
|
|
650
|
+
}
|
|
651
|
+
target = { stepId: state.stepId, round: state.round + 1, opensFrom: null, cycle: state.cycle };
|
|
652
|
+
roundOpenGuard(records);
|
|
653
|
+
} else {
|
|
654
|
+
if (stepId === undefined) throw usageFail('round-open at a step boundary requires --step <stepId> (the step this round opens)');
|
|
655
|
+
const prior = priorChainTerminal(chain);
|
|
656
|
+
if (prior == null) throw refuse(`plan "${planId}" has no prior terminal to open from — the chain is broken (fail closed)`);
|
|
657
|
+
target = { stepId, round: 1, opensFrom: canonicalFlowDigest(prior), cycle: newCycle ? state.cycle + 1 : state.cycle };
|
|
658
|
+
}
|
|
659
|
+
const existingRounds = new Set(
|
|
660
|
+
chain.filter((r) => r.purpose === 'round' && r.cycle === target.cycle && r.stepId === target.stepId).map((r) => r.round),
|
|
661
|
+
);
|
|
662
|
+
const roundOverCap = !existingRounds.has(target.round) && existingRounds.size >= ROUND_HARD_MAX;
|
|
663
|
+
const valveOverCap = target.cycle > REDESIGN_CYCLE_CAP;
|
|
664
|
+
const recordedJustification = gateCapJustification({
|
|
665
|
+
justification,
|
|
666
|
+
overCap: roundOverCap || valveOverCap,
|
|
667
|
+
refusal: roundOverCap
|
|
668
|
+
? `round ${target.round} of step "${target.stepId}" (cycle ${target.cycle}) exceeds HARD_MAX ${ROUND_HARD_MAX} rounds per cycle (design §2)`
|
|
669
|
+
: `cycle ${target.cycle} passes the redesign valve (capped at ${REDESIGN_CYCLE_CAP} cycles per plan, design §2)`,
|
|
670
|
+
});
|
|
671
|
+
const { path: receiptsPath, bytes } = readReceiptsBytesFailClosed(cwd, env, 'the dispatch watermark is the receipts-file byte length');
|
|
672
|
+
const watermark = bytes == null ? 0 : bytes.byteLength;
|
|
673
|
+
// The receipt-deadline boundary rule, applied at MINT time: a watermark on an unterminated tail
|
|
674
|
+
// would let an appended receipt physically continue that malformed line.
|
|
675
|
+
if (watermark > 0 && bytes[watermark - 1] !== 0x0a) {
|
|
676
|
+
throw refuse(`the receipts store at ${receiptsPath} ends in an unterminated line — a watermark minted here could never be awaited (the receipt-deadline line-boundary rule); repair the store tail first (fail closed)`);
|
|
677
|
+
}
|
|
678
|
+
const dispatches = backends.map((backend) => ({
|
|
679
|
+
backend, dispatchBase: tree.base, receiptWatermark: watermark,
|
|
680
|
+
dispatchNonce: randomBytes(16).toString('hex'),
|
|
681
|
+
receiptDigest: null, findingManifestDigest: null,
|
|
682
|
+
}));
|
|
683
|
+
const record = {
|
|
684
|
+
...chainCommons({ planId, tree, state, commitEpoch, timestamp }),
|
|
685
|
+
cycle: target.cycle, round: target.round, purpose: 'round', stepId: target.stepId,
|
|
686
|
+
fingerprint: tree.fingerprint, opensFrom: target.opensFrom, dispatches, dispositions: [],
|
|
687
|
+
};
|
|
688
|
+
return { record, justification: recordedJustification, preflight: roundOpenGuard };
|
|
689
|
+
};
|
|
690
|
+
|
|
691
|
+
// round-land — the POST-ARRIVAL half (#42/#13/#33): revises the open round IN PLACE. Arrival
|
|
692
|
+
// selection rides the receipt-deadline watermark discipline (complete lines past the persisted
|
|
693
|
+
// offset; the prefix hash stays that runner's in-process guard, never a persisted proof): exactly
|
|
694
|
+
// ONE non-probe receipt line of the dispatched backend must sit past the watermark — none keeps
|
|
695
|
+
// the dispatch pending, more is an ambiguous newer set and refuses. Both digests are COMPUTED
|
|
696
|
+
// from the files (receipt line → canonical digest; manifest bytes → sha256) and land together;
|
|
697
|
+
// a foreign-tree receipt, a missing/malformed/symlinked manifest, or a foreign-identity manifest
|
|
698
|
+
// refuses the binding. A --dispose input appends ONE disposition entry whose findingDigest is the
|
|
699
|
+
// sha256 of the QUOTED finding text, verified a SUBSTRING of a landed manifest's findings payload.
|
|
700
|
+
const buildRoundLand = ({ planId, dispose, cwd, env, timestamp }) => {
|
|
701
|
+
const tree = treeContext(cwd);
|
|
702
|
+
const records = readStoreRecords(cwd, env);
|
|
703
|
+
const { chain, state } = chainContext(records, planId, tree.owner);
|
|
704
|
+
openStepState(state, planId, 'round-land revises the open step\'s round; open it first (flow-writer round-open)');
|
|
705
|
+
const head = stepRoundHeads(chain, state.cycle, state.stepId).find((r) => r.round === state.round);
|
|
706
|
+
if (head == null) throw refuse(`plan "${planId}" has no round record at the open context (cycle ${state.cycle}, step "${state.stepId}", round ${state.round}) — the chain is broken (fail closed)`);
|
|
707
|
+
const { path: receiptsPath, bytes } = readReceiptsBytesFailClosed(cwd, env, 'round-land binds arrivals from the receipts store');
|
|
708
|
+
const lines = bytes == null ? [] : parseReceiptLines(bytes, receiptsPath);
|
|
709
|
+
const landed = [];
|
|
710
|
+
const pending = [];
|
|
711
|
+
const dispatches = head.dispatches.map((entry) => {
|
|
712
|
+
if (entry.receiptDigest !== null) return entry;
|
|
713
|
+
const length = bytes == null ? 0 : bytes.byteLength;
|
|
714
|
+
if (length < entry.receiptWatermark) {
|
|
715
|
+
throw refuse(`the receipts store shrank below watermark offset ${entry.receiptWatermark} (${length} bytes at ${receiptsPath}) — a shrunken store never binds an arrival (fail closed)`);
|
|
716
|
+
}
|
|
717
|
+
if (entry.receiptWatermark > 0 && bytes[entry.receiptWatermark - 1] !== 0x0a) {
|
|
718
|
+
throw refuse(`watermark offset ${entry.receiptWatermark} does not sit on a line boundary (${receiptsPath}) — the pre-dispatch tail was unterminated, so an appended receipt physically continues that malformed line; a binding never rides it (fail closed)`);
|
|
719
|
+
}
|
|
720
|
+
// F4/m7 + G1 (round-1/2 folds): candidates are CODE-artifact, fresh, non-probe lines of the
|
|
721
|
+
// dispatched backend carrying THIS dispatch's EXACT nonce (the wrapper stamps AW_REVIEW_NONCE
|
|
722
|
+
// into the receipt — dispatch identity end-to-end; a nonce-less or foreign-nonce line is
|
|
723
|
+
// never this dispatch's answer, so a delayed receipt of a degraded dispatch or another
|
|
724
|
+
// plan's dispatch can never cross-bind). The ONE candidate then rides the canonical
|
|
725
|
+
// attesting-receipt classification: only ATTESTING binds; NOT_CURRENT here means a
|
|
726
|
+
// fingerprint mismatch (artifact/freshness pre-screened); every other class is a DEFECTIVE
|
|
727
|
+
// answer from OUR dispatch and refuses loudly with the justified-degrade recovery (M4).
|
|
728
|
+
const matches = lines.filter((l) => l.offset >= entry.receiptWatermark && l.record.backend === entry.backend
|
|
729
|
+
&& l.record.nonce === entry.dispatchNonce
|
|
730
|
+
&& l.record.artifact === 'code' && l.record.fresh === true && l.record.probe !== true);
|
|
731
|
+
if (matches.length === 0) {
|
|
732
|
+
pending.push(entry);
|
|
733
|
+
return entry;
|
|
734
|
+
}
|
|
735
|
+
if (matches.length > 1) {
|
|
736
|
+
throw refuse(`${matches.length} receipt lines from backend "${entry.backend}" sit past watermark offset ${entry.receiptWatermark} — an ambiguous newer set never binds a dispatch (fail closed); inspect ${receiptsPath}`);
|
|
737
|
+
}
|
|
738
|
+
const receipt = matches[0].record;
|
|
739
|
+
const cls = classifyReviewReceiptForTree(receipt, head.fingerprint);
|
|
740
|
+
if (cls === REVIEW_RECEIPT_CLASS.NOT_CURRENT) {
|
|
741
|
+
throw refuse(`the arrived receipt of backend "${entry.backend}" attests fingerprint ${String(receipt.fingerprint).slice(0, 12)}…, not the round's dispatched tree (${head.fingerprint.slice(0, 12)}…) — a foreign-tree receipt never binds this round (fail closed)`);
|
|
742
|
+
}
|
|
743
|
+
if (cls !== REVIEW_RECEIPT_CLASS.ATTESTING) {
|
|
744
|
+
throw refuse(`the arrived answer of backend "${entry.backend}" is a non-attesting receipt (class "${cls}") — a defective answer never binds a dispatch (fail closed); recovery: record the failure (core-evidence degrade, then flow-writer down-mark + degrade-justification at the round's tree), then open a NEW round to re-dispatch`);
|
|
745
|
+
}
|
|
746
|
+
const m = readManifestDecoded({ receiptsPath, backend: entry.backend, nonce: entry.dispatchNonce });
|
|
747
|
+
if (m.outcome === 'absent') {
|
|
748
|
+
throw refuse(`the receipt of backend "${entry.backend}" arrived but its finding manifest is missing at ${m.path} — the wrapper mints the manifest BEFORE the receipt append, so an arrived receipt without one never binds (fail closed)`);
|
|
749
|
+
}
|
|
750
|
+
if (m.outcome === 'foreign') {
|
|
751
|
+
throw refuse(`the finding manifest at ${m.path} is a ${m.className}, not a regular file — a binding never rides a symlink or a FIFO (fail closed)`);
|
|
752
|
+
}
|
|
753
|
+
if (m.outcome === 'error') throw refuse(`the finding manifest at ${m.path} is unreadable (${m.code}) — fail closed`);
|
|
754
|
+
if (m.outcome === 'malformed') throw refuse(`the finding manifest at ${m.path} is malformed — ${m.reason} — it never binds a dispatch`);
|
|
755
|
+
if (m.outcome === 'foreign-identity') {
|
|
756
|
+
throw refuse(`the finding manifest at ${m.path} declares {backend "${m.manifest.backend}", nonce "${m.manifest.nonce}"} — a foreign-identity manifest never binds this dispatch (fail closed)`);
|
|
757
|
+
}
|
|
758
|
+
if (m.outcome !== 'ok') throw refuse(`no manifest name composes for the dispatch nonce — the ledger entry is corrupt (fail closed)`);
|
|
759
|
+
if (m.manifest.fingerprint !== null && m.manifest.fingerprint !== head.fingerprint) {
|
|
760
|
+
throw refuse(`the finding manifest at ${m.path} attests fingerprint ${m.manifest.fingerprint.slice(0, 12)}…, not the round's dispatched tree (${head.fingerprint.slice(0, 12)}…) — a foreign-tree manifest never binds this round (fail closed)`);
|
|
761
|
+
}
|
|
762
|
+
landed.push({ backend: entry.backend, nonce: entry.dispatchNonce });
|
|
763
|
+
return {
|
|
764
|
+
...entry,
|
|
765
|
+
receiptDigest: canonicalFlowDigest(receipt),
|
|
766
|
+
findingManifestDigest: createHash('sha256').update(m.bytes).digest('hex'),
|
|
767
|
+
};
|
|
768
|
+
});
|
|
769
|
+
let dispositions = head.dispositions;
|
|
770
|
+
let disposed = null;
|
|
771
|
+
if (dispose != null) {
|
|
772
|
+
const findingDigest = createHash('sha256').update(dispose.finding, 'utf8').digest('hex');
|
|
773
|
+
const carriers = dispatches.filter((e) => e.receiptDigest !== null);
|
|
774
|
+
if (carriers.length === 0) throw refuse('no landed dispatch carries a finding manifest yet — a disposition binds a delivered finding of a landed round (fail closed)');
|
|
775
|
+
// F5 (round-1 fold): every carrier's manifest is re-read and its byte digest MUST equal the
|
|
776
|
+
// LEDGER's findingManifestDigest — a manifest swapped after landing never carries a
|
|
777
|
+
// disposition, however well its payload matches the quote.
|
|
778
|
+
const payloads = carriers.map((e) => {
|
|
779
|
+
const m = readManifestDecoded({ receiptsPath, backend: e.backend, nonce: e.dispatchNonce });
|
|
780
|
+
if (m.outcome !== 'ok') {
|
|
781
|
+
throw refuse(`the landed manifest for {backend "${e.backend}", nonce "${e.dispatchNonce}"} is no longer cleanly readable (${m.outcome}) — a disposition binds live manifest custody (fail closed)`);
|
|
782
|
+
}
|
|
783
|
+
if (createHash('sha256').update(m.bytes).digest('hex') !== e.findingManifestDigest) {
|
|
784
|
+
throw refuse(`the manifest at ${m.path} no longer matches the landed findingManifestDigest — a swapped manifest never carries a disposition (fail closed)`);
|
|
785
|
+
}
|
|
786
|
+
return m.manifest.findings;
|
|
787
|
+
});
|
|
788
|
+
if (!payloads.some((findings) => findings.includes(dispose.finding))) {
|
|
789
|
+
throw refuse('the quoted finding is not a substring of any landed finding-manifest payload of this round — a disposition binds a real delivered finding, verbatim (fail closed)');
|
|
790
|
+
}
|
|
791
|
+
const entry = (() => {
|
|
792
|
+
if (dispose.action === 'folded') {
|
|
793
|
+
// The fold's proof must RESOLVE (the consult-attestation precedent — an unverified digest
|
|
794
|
+
// could name a proof nobody minted): consult-attestation in the flow store, red-proof in
|
|
795
|
+
// the core store.
|
|
796
|
+
if (dispose.proofKind === 'consult-attestation') {
|
|
797
|
+
const target = records.findLast((r) => r.kind === 'consult-attestation' && canonicalFlowDigest(r) === dispose.proofDigest);
|
|
798
|
+
if (target === undefined) throw refuse(`--proof-digest ${dispose.proofDigest.slice(0, 12)}… does not resolve to a consult-attestation in the flow store — a fold's proof binds an existing record (fail closed)`);
|
|
799
|
+
} else {
|
|
800
|
+
const corePath = resolveEvidencePath(cwd, env);
|
|
801
|
+
const coreRead = corePath == null ? { records: [] } : readEvidence(corePath);
|
|
802
|
+
if (coreRead.readError || (coreRead.malformed ?? 0) > 0) {
|
|
803
|
+
throw refuse(`the core evidence store is unreadable or malformed (${coreRead.readError ?? coreRead.malformedReasons[0]}) — cannot resolve the red-proof (fail closed)`);
|
|
804
|
+
}
|
|
805
|
+
if (!coreRead.records.some((r) => r.kind === 'red-proof' && canonicalFlowDigest(r) === dispose.proofDigest)) {
|
|
806
|
+
throw refuse(`--proof-digest ${dispose.proofDigest.slice(0, 12)}… does not resolve to a red-proof in the core evidence store — a fold's proof binds an existing record (fail closed)`);
|
|
807
|
+
}
|
|
808
|
+
}
|
|
809
|
+
return { findingDigest, action: 'folded', proofKind: dispose.proofKind, proofDigest: dispose.proofDigest };
|
|
810
|
+
}
|
|
811
|
+
if (dispose.action === 'queued') {
|
|
812
|
+
// Honest limit: the debt entry's {id, digest} are form-checked only — no debt-file reader
|
|
813
|
+
// exists to resolve them; the checker's declared-path lane owns the file itself.
|
|
814
|
+
return { findingDigest, action: 'queued', debtId: dispose.debtId, debtDigest: dispose.debtDigest };
|
|
815
|
+
}
|
|
816
|
+
return { findingDigest, action: 'rejected', reason: dispose.reason };
|
|
817
|
+
})();
|
|
818
|
+
dispositions = [...head.dispositions, entry];
|
|
819
|
+
disposed = { action: dispose.action, findingDigest };
|
|
820
|
+
}
|
|
821
|
+
if (landed.length === 0 && disposed == null) {
|
|
822
|
+
throw refuse(`nothing to land — no pending dispatch has an arrived receipt (${pending.length} still pending) and no --dispose was given; a no-op revision never mints`);
|
|
823
|
+
}
|
|
824
|
+
// A revision re-states its round: every identity field rides the head verbatim (the store's
|
|
825
|
+
// revision contract pins opensFrom/base/fingerprint/commitEpoch byte-equal), only the ledgers
|
|
826
|
+
// and the timestamp move.
|
|
827
|
+
return { record: { ...head, dispatches, dispositions, timestamp }, landed, pending, disposed };
|
|
828
|
+
};
|
|
829
|
+
|
|
830
|
+
// freeze / converged — the step terminals, gated on COMPLETENESS + the sanctioned-move rule
|
|
831
|
+
// (no premature terminal): every dispatch of the step's rounds landed or justified-degraded at
|
|
832
|
+
// its dispatched tree; every landed non-ship receipt rides a round with a non-empty disposition
|
|
833
|
+
// ledger; the tree either sits at the last round's fingerprint or reached it through an
|
|
834
|
+
// ANCHORED declared bookkeeping-delta chain. Both walks re-run on the LOCKED snapshot (F6) —
|
|
835
|
+
// the returned preflight is the last word.
|
|
836
|
+
const buildStepTerminal = ({ purpose, planId, cwd, env, timestamp }) => {
|
|
837
|
+
const tree = treeContext(cwd);
|
|
838
|
+
const records = readStoreRecords(cwd, env);
|
|
839
|
+
const { chain, state, commitEpoch } = chainContext(records, planId, tree.owner);
|
|
840
|
+
openStepState(state, planId, `${purpose} terminates an open step's sequence`);
|
|
841
|
+
const terminalGuard = (flowRecords) => {
|
|
842
|
+
const lockedChain = flowRecords.filter((r) => r.kind === CHAIN_KIND && r.planId === planId);
|
|
843
|
+
const lockedState = walkChainState(lockedChain);
|
|
844
|
+
if (!(lockedState.mode === 'in-step' && !lockedState.parked && !lockedState.completed)) return;
|
|
845
|
+
const issue = stepCompletenessIssue({
|
|
846
|
+
flowRecords, chain: lockedChain, cycle: lockedState.cycle, stepId: lockedState.stepId,
|
|
847
|
+
label: `${purpose} refuses — no premature terminal`, cwd, env,
|
|
848
|
+
}) ?? terminalMoveIssue({
|
|
849
|
+
flowRecords, chain: lockedChain, cycle: lockedState.cycle, stepId: lockedState.stepId, tree, cwd,
|
|
850
|
+
});
|
|
851
|
+
if (issue != null) throw refuse(issue);
|
|
852
|
+
};
|
|
853
|
+
terminalGuard(records);
|
|
854
|
+
return {
|
|
855
|
+
record: { ...chainCommons({ planId, tree, state, commitEpoch, timestamp }), purpose, stepId: state.stepId, fingerprint: tree.fingerprint },
|
|
856
|
+
preflight: terminalGuard,
|
|
857
|
+
};
|
|
858
|
+
};
|
|
859
|
+
|
|
860
|
+
// unfreeze — reopens the frozen step (in-step, after freeze) or the just-converged step (at the
|
|
861
|
+
// boundary); the store owns transition legality. Cap: UNFREEZE_CAP per cycle (design §2 Phase 4 —
|
|
862
|
+
// the post-freeze checkpoint), self-servable per Decision 8.
|
|
863
|
+
const buildUnfreeze = ({ planId, justification, cwd, env, timestamp }) => {
|
|
864
|
+
const tree = treeContext(cwd);
|
|
865
|
+
const records = readStoreRecords(cwd, env);
|
|
866
|
+
const { chain, state, commitEpoch } = chainContext(records, planId, tree.owner);
|
|
867
|
+
const inStep = state.mode === 'in-step' && !state.parked && !state.completed;
|
|
868
|
+
const target = inStep
|
|
869
|
+
? { stepId: state.stepId, round: state.round, cycle: state.cycle }
|
|
870
|
+
: state.lastTerminal != null && state.lastTerminal.purpose === 'converged'
|
|
871
|
+
? { stepId: state.lastTerminal.stepId, round: state.lastTerminal.round, cycle: state.lastTerminal.cycle }
|
|
872
|
+
: null;
|
|
873
|
+
if (target == null) throw refuse(`plan "${planId}" has nothing to unfreeze — unfreeze reopens the frozen step or the just-converged terminal`);
|
|
874
|
+
const priorUnfreezes = chain.filter((r) => r.purpose === 'unfreeze' && r.cycle === target.cycle).length;
|
|
875
|
+
const recordedJustification = gateCapJustification({
|
|
876
|
+
justification,
|
|
877
|
+
overCap: priorUnfreezes >= UNFREEZE_CAP,
|
|
878
|
+
refusal: `a further unfreeze in cycle ${target.cycle} passes the design checkpoint (post-freeze cap: ${UNFREEZE_CAP} unfreeze per cycle, design §2 Phase 4)`,
|
|
879
|
+
});
|
|
880
|
+
return {
|
|
881
|
+
record: {
|
|
882
|
+
...chainCommons({ planId, tree, state, commitEpoch, timestamp }),
|
|
883
|
+
cycle: target.cycle, round: target.round, purpose: 'unfreeze', stepId: target.stepId, fingerprint: tree.fingerprint,
|
|
884
|
+
},
|
|
885
|
+
justification: recordedJustification,
|
|
886
|
+
};
|
|
887
|
+
};
|
|
888
|
+
|
|
889
|
+
// internal-attestation (#28) — gated on the #68 arming predicate: EVERY in-flight plan must be
|
|
890
|
+
// covered by an adopted chain (frontmatter planId + content digest + owner, the ONE coverage
|
|
891
|
+
// predicate review-state's internal-only floor consumes) — an uncovered plan is a refusal naming
|
|
892
|
+
// the file, never a relaxation.
|
|
893
|
+
const buildInternalAttestation = ({ planId, lenses, degraded, model, effort, tier, authority, cwd, env, timestamp, ctx }) => {
|
|
894
|
+
const tree = treeContext(cwd);
|
|
895
|
+
const records = readStoreRecords(cwd, env);
|
|
896
|
+
const { state } = chainContext(records, planId, tree.owner);
|
|
897
|
+
openStepState(state, planId, 'an internal-attestation binds an open step\'s round (#28); open the step\'s round first');
|
|
898
|
+
const root = gitLine(['rev-parse', '--show-toplevel'], cwd) ?? cwd;
|
|
899
|
+
const coverage = computePlanAdoptionCoverage({
|
|
900
|
+
root, plans: plansInFlight(root), records, owner: tree.owner,
|
|
901
|
+
readFile: ctx.readFileSync ?? readFileSync,
|
|
902
|
+
});
|
|
903
|
+
const uncovered = coverage.filter((p) => !p.covered);
|
|
904
|
+
if (uncovered.length > 0) {
|
|
905
|
+
throw refuse(`internal-attestation refuses (#68): ${uncovered.map((p) => `plan ${quoteReportName(p.plan)} — ${p.reason}`).join('; ')} — an uncovered in-flight plan is a refusal, never a relaxation`);
|
|
906
|
+
}
|
|
907
|
+
return {
|
|
908
|
+
schema: FLOW_SCHEMA_VERSION, kind: 'internal-attestation', fingerprint: tree.fingerprint,
|
|
909
|
+
planId, stepId: state.stepId, cycle: state.cycle, round: state.round,
|
|
910
|
+
lenses, degraded, posture: { model, effort: effort ?? null, tier: tier ?? null }, authority,
|
|
911
|
+
base: tree.base, timestamp,
|
|
912
|
+
};
|
|
913
|
+
};
|
|
914
|
+
|
|
915
|
+
// ── write-plan-id (#58 — bounded frontmatter write; the mint itself stays read-only) ─
|
|
916
|
+
|
|
917
|
+
// Pure composer: insert the planId line into an existing closed leading frontmatter block, or
|
|
918
|
+
// prepend a fresh block. The round-trip guard re-reads the RESULT through the adoption mint's own
|
|
919
|
+
// parser (injectable so the compose-vs-parse drift contract is testable) — an id the mint would
|
|
920
|
+
// not read back is never written.
|
|
921
|
+
export const composePlanIdFrontmatter = (text, planId, parse = readPlanFrontmatterId) => {
|
|
922
|
+
const lines = text.split('\n');
|
|
923
|
+
const hasClosedBlock = lines[0]?.trim() === '---' && lines.findIndex((line, i) => i > 0 && line.trim() === '---') > 0;
|
|
924
|
+
const next = hasClosedBlock
|
|
925
|
+
? [lines[0], `planId: ${planId}`, ...lines.slice(1)].join('\n')
|
|
926
|
+
: `---\nplanId: ${planId}\n---\n${text}`;
|
|
927
|
+
if (parse(next) !== planId) {
|
|
928
|
+
throw refuse(`the composed frontmatter does not round-trip to planId "${planId}" — refusing to write an id the adoption mint would not read (fail closed)`);
|
|
929
|
+
}
|
|
930
|
+
return next;
|
|
931
|
+
};
|
|
932
|
+
|
|
933
|
+
const writePlanId = ({ planPath, planId, cwd, ctx }) => {
|
|
934
|
+
if (!/^\S+$/.test(planId)) throw usageFail(`--plan-id must be a single non-whitespace token (got ${JSON.stringify(planId)})`);
|
|
935
|
+
const lex = lexicalRepoRelative(planPath);
|
|
936
|
+
if (!lex.ok) throw refuse(`the plan path must be lexically repo-relative — ${lex.reason} (fail closed)`);
|
|
937
|
+
// A backslash byte is refused before ANY lstat/read: on Windows it is a separator the raw
|
|
938
|
+
// checks below do not judge, so "docs/plans/..\\…" would resolve outside the plans dir there.
|
|
939
|
+
if (planPath.includes('\\')) {
|
|
940
|
+
throw refuse(`the plan path "${planPath}" carries a backslash — forward-slash is the only separator write-plan-id judges (fail closed)`);
|
|
941
|
+
}
|
|
942
|
+
// lexicalRepoRelative NORMALIZES interior dot segments — a "docs/plans/../…" spelling would pass
|
|
943
|
+
// a raw prefix check while resolving outside the plans dir, so segments are refused explicitly.
|
|
944
|
+
if (planPath.split('/').some((s) => s === '..' || s === '.' || s === '')) {
|
|
945
|
+
throw refuse(`the plan path must be a plain forward-slash path without "." or ".." segments (got "${planPath}") — write-plan-id is bounded to ${PLANS_DIR}/ lexically`);
|
|
946
|
+
}
|
|
947
|
+
if (!planPath.startsWith(`${PLANS_DIR}/`)) throw refuse(`the plan path must live under ${PLANS_DIR}/ (got "${planPath}") — write-plan-id is bounded to the plans dir`);
|
|
948
|
+
const full = join(cwd, planPath);
|
|
949
|
+
const lstat = ctx.lstatSync ?? lstatSync;
|
|
950
|
+
const st = lstatNoFollow(full, lstat);
|
|
951
|
+
if (st == null) throw refuse(`${planPath} does not exist — write-plan-id targets an EXISTING regular plan file (the id write never creates plans)`);
|
|
952
|
+
if (st.isSymbolicLink()) throw refuse(`${planPath} is a symlink — refusing to write plan identity through a link (fail closed)`);
|
|
953
|
+
if (!st.isFile()) throw refuse(`${planPath} is not a regular file — refusing (fail closed)`);
|
|
954
|
+
const readFile = ctx.readFileSync ?? readFileSync;
|
|
955
|
+
// Fatal decode (BOM preserved as U+FEFF): a lossy 'utf8' read would fold invalid bytes to
|
|
956
|
+
// U+FFFD and the atomic rewrite below would then corrupt the original body irreversibly.
|
|
957
|
+
const bytes = readFile(full);
|
|
958
|
+
let text;
|
|
959
|
+
try {
|
|
960
|
+
text = new TextDecoder('utf-8', { fatal: true, ignoreBOM: true }).decode(bytes);
|
|
961
|
+
} catch {
|
|
962
|
+
throw refuse(`${planPath} is not valid UTF-8 — a rewrite would corrupt the original bytes (fail closed)`);
|
|
963
|
+
}
|
|
964
|
+
const existing = readPlanFrontmatterId(text);
|
|
965
|
+
if (existing === planId) return { noop: true, message: `${planPath} already carries planId "${planId}" — idempotent no-op; nothing written` };
|
|
966
|
+
if (existing !== null) {
|
|
967
|
+
throw refuse(`${planPath} already carries planId "${existing}" — a DIFFERENT id refuses: chain identity never silently changes (#58)`);
|
|
968
|
+
}
|
|
969
|
+
const next = composePlanIdFrontmatter(text, planId);
|
|
970
|
+
writeContainedFileAtomic(join(cwd, PLANS_DIR), full, next, ctx, { stop: refuse, label: planPath });
|
|
971
|
+
return { noop: false, message: `wrote planId "${planId}" into ${planPath} (contained-atomic); the adoption mint reads exactly this frontmatter line` };
|
|
972
|
+
};
|
|
973
|
+
|
|
974
|
+
// ── main ────────────────────────────────────────────────────────────────────────────
|
|
975
|
+
|
|
976
|
+
const ARMS = ['park', 'resume', 'complete', 'adoption', 'refresh', 're-baseline', 'rerun-cause', 'down-mark', 'down-mark-up', 'down-mark-clear', 'degrade-justification', 'maintainer-override', 'consult-attestation', 'round-open', 'round-land', 'freeze', 'unfreeze', 'converged', 'internal-attestation', 'write-plan-id'];
|
|
977
|
+
|
|
978
|
+
const HELP = `flow-writer — the explicit flow-store writer (flow-orchestration; the store preflight is the single legality door).
|
|
979
|
+
|
|
980
|
+
Usage:
|
|
981
|
+
node flow-writer.mjs park <planId>
|
|
982
|
+
node flow-writer.mjs resume <planId>
|
|
983
|
+
node flow-writer.mjs complete <planId>
|
|
984
|
+
node flow-writer.mjs adoption <plan-file> [--label <label>] [--cycle <n>]
|
|
985
|
+
node flow-writer.mjs refresh <planId> --cause <text> --refreshed-record <digest>
|
|
986
|
+
node flow-writer.mjs re-baseline <planId>
|
|
987
|
+
node flow-writer.mjs rerun-cause --attempt <id> --cause <text>
|
|
988
|
+
node flow-writer.mjs down-mark --backend <name> --reason <text> --expires-at <UTC ISO instant>
|
|
989
|
+
node flow-writer.mjs down-mark-up --backend <name> [--target <digest>]
|
|
990
|
+
node flow-writer.mjs down-mark-clear --backend <name> [--target <digest>]
|
|
991
|
+
node flow-writer.mjs degrade-justification --backend <name> [--down-mark <digest>] [--degrade-digest <digest>]
|
|
992
|
+
node flow-writer.mjs maintainer-override <planId> --backend <name> --checkpoint-approved [--veto-receipt <digest>] [--chain-record <digest>]
|
|
993
|
+
node flow-writer.mjs consult-attestation <planId> --backend <name> --nonce <nonce> --proposed-fix-digest <digest>
|
|
994
|
+
node flow-writer.mjs round-open <planId> --backend <name> [--backend <name> ...] [--step <stepId>] [--new-cycle] [--justification <text>]
|
|
995
|
+
node flow-writer.mjs round-land <planId> [--dispose folded --finding <quote> --proof-kind consult-attestation|red-proof --proof-digest <digest>]
|
|
996
|
+
node flow-writer.mjs round-land <planId> [--dispose queued --finding <quote> --debt-id <id> --debt-digest <digest>]
|
|
997
|
+
node flow-writer.mjs round-land <planId> [--dispose rejected --finding <quote> --reason <text>]
|
|
998
|
+
node flow-writer.mjs freeze <planId>
|
|
999
|
+
node flow-writer.mjs unfreeze <planId> [--justification <text>]
|
|
1000
|
+
node flow-writer.mjs converged <planId>
|
|
1001
|
+
node flow-writer.mjs internal-attestation <planId> --lens <name> [--lens <name> ...] [--degraded <backend> ...] --model <model> [--effort <effort>] [--tier <tier>] --authority <text>
|
|
1002
|
+
node flow-writer.mjs write-plan-id <plan-file> --plan-id <id>
|
|
1003
|
+
|
|
1004
|
+
Operand shapes: a positional may follow a literal -- and a value flag accepts --flag=<value>
|
|
1005
|
+
(the lanes a leading-dash operand rides; printed recoveries compose exactly these shapes).
|
|
1006
|
+
Every arm computes its tree context (owner, base, fingerprint; cycle/round from the chain walk)
|
|
1007
|
+
and appends through the lock-serialized store append — an illegal transition surfaces the store's
|
|
1008
|
+
own refusal verbatim; the writer adds NO second validator. Chain arms refuse a foreign worktree's
|
|
1009
|
+
chain (#57). maintainer-override prints its FULL bound set and requires --checkpoint-approved
|
|
1010
|
+
(#38). write-plan-id is bounded to an existing regular file under ${PLANS_DIR}/ (same-id
|
|
1011
|
+
idempotent, different-id refuses, contained-atomic; #58).
|
|
1012
|
+
|
|
1013
|
+
The round lifecycle (Plan 4 Phase 3): ONE record per round, revised in place — round-open mints
|
|
1014
|
+
the pre-dispatch half (a fresh nonce + the receipts-file byte-length watermark per --backend,
|
|
1015
|
+
BEFORE any backend runs; stdout prints one "dispatch backend=<b> nonce=<n> watermark=<w>" line
|
|
1016
|
+
per dispatch — pass the nonce as --nonce <n> on the wrapper invocation (the plain-argument lane
|
|
1017
|
+
onto the AW_REVIEW_NONCE seam) and hand the pair to
|
|
1018
|
+
receipt-deadline); round-land binds arrivals (ONLY the canonical ATTESTING receipt class binds —
|
|
1019
|
+
a defective answer refuses naming its class and the justified-degrade recovery; receipt +
|
|
1020
|
+
manifest digests computed FROM the files; one revision per invocation, dispositions append via
|
|
1021
|
+
--dispose with the manifest byte digest re-verified against the ledger). A fingerprint move
|
|
1022
|
+
always opens a NEW round; --new-cycle reopens a converged step in the next cycle. Caps enforced
|
|
1023
|
+
at the arms (Decision 8 — every refusal self-servable, never a human wait-state): HARD_MAX
|
|
1024
|
+
${ROUND_HARD_MAX} rounds per {cycle, step}, ${UNFREEZE_CAP} post-freeze unfreeze per cycle, and
|
|
1025
|
+
the redesign valve at ${REDESIGN_CYCLE_CAP} cycles per plan — an over-cap mint requires
|
|
1026
|
+
--justification <text> (echoed in the report; the over-cap record itself is the durable trail).
|
|
1027
|
+
freeze/converged refuse over an unlanded unjustified dispatch, a delivering round with an empty
|
|
1028
|
+
disposition ledger, or an unsanctioned fingerprint move (only an anchored declared
|
|
1029
|
+
bookkeeping-delta chain + refresh carries a terminal across a move); the completeness walks
|
|
1030
|
+
re-run on the LOCKED store snapshot at append time. internal-attestation refuses while ANY
|
|
1031
|
+
in-flight plan lacks an adopted chain, naming the file (#68).
|
|
1032
|
+
|
|
1033
|
+
Exit codes: 0 success; 2 usage; 1 refusal (store STOP verbatim / derivation failure / missing checkpoint flag).`;
|
|
1034
|
+
|
|
1035
|
+
export const main = (argv, ctx = {}) => {
|
|
1036
|
+
const cwd = ctx.cwd ?? process.cwd();
|
|
1037
|
+
const env = ctx.env ?? process.env;
|
|
1038
|
+
const timestamp = ctx.now ? ctx.now() : new Date().toISOString();
|
|
1039
|
+
try {
|
|
1040
|
+
// Help binds the FIRST token only — a later '--help' byte may be a legal operand value.
|
|
1041
|
+
if (argv.length === 0 || argv[0] === '--help' || argv[0] === '-h') return { code: 0, stdout: HELP, stderr: '' };
|
|
1042
|
+
const arm = argv[0];
|
|
1043
|
+
if (!ARMS.includes(arm)) throw usageFail(`unknown arm "${arm}" (known: ${ARMS.join(', ')})`);
|
|
1044
|
+
const rest = argv.slice(1);
|
|
1045
|
+
|
|
1046
|
+
if (arm === 'write-plan-id') {
|
|
1047
|
+
const { values, positionals } = parseFlags(rest, { '--plan-id': 'value' });
|
|
1048
|
+
if (positionals.length !== 1) throw usageFail('write-plan-id takes exactly one <plan-file>');
|
|
1049
|
+
const r = writePlanId({ planPath: positionals[0], planId: requireValue(values, 'plan-id', 'write-plan-id'), cwd, ctx });
|
|
1050
|
+
return { code: 0, stdout: `flow-writer: ${r.message}`, stderr: '' };
|
|
1051
|
+
}
|
|
1052
|
+
|
|
1053
|
+
if (arm === 'adoption') {
|
|
1054
|
+
const { values, positionals } = parseFlags(rest, { '--label': 'value', '--cycle': 'value' });
|
|
1055
|
+
if (positionals.length !== 1) throw usageFail('adoption takes exactly one <plan-file>');
|
|
1056
|
+
const cycle = values.cycle === undefined ? 1 : Number(values.cycle);
|
|
1057
|
+
if (!Number.isInteger(cycle) || cycle < 1) throw usageFail(`--cycle must be a positive integer (got "${values.cycle}")`);
|
|
1058
|
+
const minted = mintAdoption({ cwd, env, planPath: positionals[0], planLabel: values.label, cycle, timestamp });
|
|
1059
|
+
return { code: 0, stdout: `flow-writer: appended chain/adoption for plan "${minted.record.planId}" — digest ${minted.digest}\n store: ${minted.writtenPath}`, stderr: '' };
|
|
1060
|
+
}
|
|
1061
|
+
|
|
1062
|
+
if (arm === 'round-open') {
|
|
1063
|
+
const { values, positionals } = parseFlags(rest, { '--backend': 'list', '--step': 'value', '--new-cycle': 'boolean', '--justification': 'value' });
|
|
1064
|
+
const planId = onePlanId(positionals, arm);
|
|
1065
|
+
if (!Array.isArray(values.backend) || values.backend.length === 0) {
|
|
1066
|
+
throw usageFail('round-open requires at least one --backend <name> (the dispatch set is minted BEFORE any backend runs, #41)');
|
|
1067
|
+
}
|
|
1068
|
+
const built = buildRoundOpen({
|
|
1069
|
+
planId, stepId: values.step, backends: values.backend,
|
|
1070
|
+
newCycle: values['new-cycle'] === true, justification: values.justification, cwd, env, timestamp,
|
|
1071
|
+
});
|
|
1072
|
+
const appended = appendFlowRecordWithPreflight({ cwd, record: built.record, env, deps: ctx.storeDeps ?? {}, preflight: built.preflight });
|
|
1073
|
+
const r = appended.record;
|
|
1074
|
+
const lines = [
|
|
1075
|
+
`flow-writer: appended chain/round (open) for plan "${planId}" — step "${r.stepId}" cycle ${r.cycle} round ${r.round} — digest ${canonicalFlowDigest(r)}`,
|
|
1076
|
+
` store: ${appended.writtenPath}`,
|
|
1077
|
+
...(built.justification != null ? [` justification (Decision 8, over-cap mint — the record above is the durable trail): ${built.justification}`] : []),
|
|
1078
|
+
...r.dispatches.flatMap((d) => [
|
|
1079
|
+
` dispatch backend=${d.backend} nonce=${d.dispatchNonce} watermark=${d.receiptWatermark}`,
|
|
1080
|
+
` dispatch with --nonce ${d.dispatchNonce} on the wrapper invocation (the plain-argument lane onto the AW_REVIEW_NONCE seam) so the wrapper mints the {backend, nonce}-named finding manifest`,
|
|
1081
|
+
` await (pasteable): node ${shellQuote(RECEIPT_DEADLINE_TOOL)} --backend=${d.backend} --watermark=${d.receiptWatermark} --nonce=${d.dispatchNonce}`,
|
|
1082
|
+
]),
|
|
1083
|
+
];
|
|
1084
|
+
return { code: 0, stdout: lines.join('\n'), stderr: '' };
|
|
1085
|
+
}
|
|
1086
|
+
|
|
1087
|
+
if (arm === 'round-land') {
|
|
1088
|
+
const { values, positionals } = parseFlags(rest, { '--dispose': 'value', '--finding': 'value', '--proof-kind': 'value', '--proof-digest': 'value', '--debt-id': 'value', '--debt-digest': 'value', '--reason': 'value' });
|
|
1089
|
+
const planId = onePlanId(positionals, arm);
|
|
1090
|
+
const dispose = (() => {
|
|
1091
|
+
if (values.dispose === undefined) {
|
|
1092
|
+
const stray = ['finding', 'proof-kind', 'proof-digest', 'debt-id', 'debt-digest', 'reason'].find((f) => values[f] !== undefined);
|
|
1093
|
+
if (stray !== undefined) throw usageFail(`--${stray} rides only a --dispose invocation`);
|
|
1094
|
+
return null;
|
|
1095
|
+
}
|
|
1096
|
+
const action = values.dispose;
|
|
1097
|
+
if (!['folded', 'queued', 'rejected'].includes(action)) throw usageFail(`--dispose must be folded | queued | rejected (got "${action}")`);
|
|
1098
|
+
// G3 (round-2 fold): a flag of another disposition branch refuses as usage BEFORE any
|
|
1099
|
+
// required-value error — an incompatible input is named, never silently dropped.
|
|
1100
|
+
const branchFlags = { folded: ['proof-kind', 'proof-digest'], queued: ['debt-id', 'debt-digest'], rejected: ['reason'] };
|
|
1101
|
+
const allowedFlags = new Set(branchFlags[action]);
|
|
1102
|
+
const strayFlag = ['proof-kind', 'proof-digest', 'debt-id', 'debt-digest', 'reason'].find((f) => values[f] !== undefined && !allowedFlags.has(f));
|
|
1103
|
+
if (strayFlag !== undefined) {
|
|
1104
|
+
throw usageFail(`--${strayFlag} does not ride --dispose ${action} — the ${action} arm takes exactly {--finding, ${branchFlags[action].map((f) => `--${f}`).join(', ')}}`);
|
|
1105
|
+
}
|
|
1106
|
+
const finding = requireValue(values, 'finding', arm);
|
|
1107
|
+
// An empty quote must never reach the substring check — '' is a substring of EVERYTHING.
|
|
1108
|
+
if (finding.length === 0) throw usageFail('--finding must be the non-empty quoted finding text (verbatim from the delivered manifest)');
|
|
1109
|
+
if (action === 'folded') {
|
|
1110
|
+
const proofKind = requireValue(values, 'proof-kind', arm);
|
|
1111
|
+
if (proofKind !== 'consult-attestation' && proofKind !== 'red-proof') throw usageFail(`--proof-kind must be consult-attestation | red-proof (got "${proofKind}")`);
|
|
1112
|
+
return { action, finding, proofKind, proofDigest: requireDigest(requireValue(values, 'proof-digest', arm), 'proof-digest') };
|
|
1113
|
+
}
|
|
1114
|
+
if (action === 'queued') {
|
|
1115
|
+
return { action, finding, debtId: requireValue(values, 'debt-id', arm), debtDigest: requireDigest(requireValue(values, 'debt-digest', arm), 'debt-digest') };
|
|
1116
|
+
}
|
|
1117
|
+
return { action, finding, reason: requireValue(values, 'reason', arm) };
|
|
1118
|
+
})();
|
|
1119
|
+
const built = buildRoundLand({ planId, dispose, cwd, env, timestamp });
|
|
1120
|
+
const appended = appendFlowRecord({ cwd, record: built.record, env });
|
|
1121
|
+
const lines = [
|
|
1122
|
+
`flow-writer: revised chain/round for plan "${planId}" — step "${built.record.stepId}" cycle ${built.record.cycle} round ${built.record.round} — digest ${canonicalFlowDigest(appended.record)}`,
|
|
1123
|
+
` store: ${appended.writtenPath}`,
|
|
1124
|
+
...built.landed.map((l) => ` landed backend=${l.backend} nonce=${l.nonce}`),
|
|
1125
|
+
...built.pending.map((p) => ` pending backend=${p.backend} nonce=${p.dispatchNonce} watermark=${p.receiptWatermark}`),
|
|
1126
|
+
...(built.disposed != null ? [` disposition ${built.disposed.action} findingDigest=${built.disposed.findingDigest}`] : []),
|
|
1127
|
+
];
|
|
1128
|
+
return { code: 0, stdout: lines.join('\n'), stderr: '' };
|
|
1129
|
+
}
|
|
1130
|
+
|
|
1131
|
+
if (arm === 'freeze' || arm === 'converged') {
|
|
1132
|
+
const { positionals } = parseFlags(rest, {});
|
|
1133
|
+
const built = buildStepTerminal({ purpose: arm, planId: onePlanId(positionals, arm), cwd, env, timestamp });
|
|
1134
|
+
const appended = appendFlowRecordWithPreflight({ cwd, record: built.record, env, deps: ctx.storeDeps ?? {}, preflight: built.preflight });
|
|
1135
|
+
return { code: 0, stdout: `flow-writer: appended chain/${arm} for plan "${built.record.planId}" — digest ${canonicalFlowDigest(appended.record)}\n store: ${appended.writtenPath}`, stderr: '' };
|
|
1136
|
+
}
|
|
1137
|
+
|
|
1138
|
+
if (arm === 'unfreeze') {
|
|
1139
|
+
const { values, positionals } = parseFlags(rest, { '--justification': 'value' });
|
|
1140
|
+
const built = buildUnfreeze({ planId: onePlanId(positionals, arm), justification: values.justification, cwd, env, timestamp });
|
|
1141
|
+
const appended = appendFlowRecord({ cwd, record: built.record, env });
|
|
1142
|
+
const lines = [
|
|
1143
|
+
`flow-writer: appended chain/unfreeze for plan "${built.record.planId}" — digest ${canonicalFlowDigest(appended.record)}`,
|
|
1144
|
+
` store: ${appended.writtenPath}`,
|
|
1145
|
+
...(built.justification != null ? [` justification (Decision 8, over-cap mint — the record above is the durable trail): ${built.justification}`] : []),
|
|
1146
|
+
];
|
|
1147
|
+
return { code: 0, stdout: lines.join('\n'), stderr: '' };
|
|
1148
|
+
}
|
|
1149
|
+
|
|
1150
|
+
const record = (() => {
|
|
1151
|
+
if (arm === 'park' || arm === 'resume' || arm === 'complete') {
|
|
1152
|
+
const { positionals } = parseFlags(rest, {});
|
|
1153
|
+
return buildPlanLaneRecord({ purpose: arm, planId: onePlanId(positionals, arm), cwd, env, timestamp });
|
|
1154
|
+
}
|
|
1155
|
+
if (arm === 'refresh') {
|
|
1156
|
+
const { values, positionals } = parseFlags(rest, { '--cause': 'value', '--refreshed-record': 'value' });
|
|
1157
|
+
return buildRefreshRecord({
|
|
1158
|
+
planId: onePlanId(positionals, arm), cause: requireValue(values, 'cause', arm),
|
|
1159
|
+
refreshedRecord: requireDigest(requireValue(values, 'refreshed-record', arm), 'refreshed-record'), cwd, env, timestamp,
|
|
1160
|
+
});
|
|
1161
|
+
}
|
|
1162
|
+
if (arm === 're-baseline') {
|
|
1163
|
+
const { positionals } = parseFlags(rest, {});
|
|
1164
|
+
return buildReBaselineRecord({ planId: onePlanId(positionals, arm), cwd, env, timestamp });
|
|
1165
|
+
}
|
|
1166
|
+
if (arm === 'rerun-cause') {
|
|
1167
|
+
const { values, positionals } = parseFlags(rest, { '--attempt': 'value', '--cause': 'value' });
|
|
1168
|
+
if (positionals.length > 0) throw usageFail(`rerun-cause takes flags only (unexpected "${positionals[0]}")`);
|
|
1169
|
+
const tree = treeContext(cwd);
|
|
1170
|
+
return {
|
|
1171
|
+
schema: FLOW_SCHEMA_VERSION, kind: 'rerun-cause', fingerprint: tree.fingerprint,
|
|
1172
|
+
cause: requireValue(values, 'cause', arm), attempt: requireValue(values, 'attempt', arm), base: tree.base, timestamp,
|
|
1173
|
+
};
|
|
1174
|
+
}
|
|
1175
|
+
if (arm === 'down-mark') {
|
|
1176
|
+
const { values, positionals } = parseFlags(rest, { '--backend': 'value', '--reason': 'value', '--expires-at': 'value' });
|
|
1177
|
+
if (positionals.length > 0) throw usageFail(`down-mark takes flags only (unexpected "${positionals[0]}")`);
|
|
1178
|
+
const tree = treeContext(cwd);
|
|
1179
|
+
return {
|
|
1180
|
+
schema: FLOW_SCHEMA_VERSION, kind: 'down-mark', fingerprint: tree.fingerprint,
|
|
1181
|
+
backend: requireValue(values, 'backend', arm), reason: requireValue(values, 'reason', arm),
|
|
1182
|
+
expiresAt: requireValue(values, 'expires-at', arm), base: tree.base, timestamp,
|
|
1183
|
+
};
|
|
1184
|
+
}
|
|
1185
|
+
if (arm === 'down-mark-up' || arm === 'down-mark-clear') {
|
|
1186
|
+
const { values, positionals } = parseFlags(rest, { '--backend': 'value', '--target': 'value' });
|
|
1187
|
+
if (positionals.length > 0) throw usageFail(`${arm} takes flags only (unexpected "${positionals[0]}")`);
|
|
1188
|
+
return buildDownMarkFamilyRecord({
|
|
1189
|
+
kind: arm, backend: requireValue(values, 'backend', arm),
|
|
1190
|
+
target: values.target === undefined ? undefined : requireDigest(values.target, 'target'), cwd, env, timestamp,
|
|
1191
|
+
});
|
|
1192
|
+
}
|
|
1193
|
+
if (arm === 'degrade-justification') {
|
|
1194
|
+
const { values, positionals } = parseFlags(rest, { '--backend': 'value', '--down-mark': 'value', '--degrade-digest': 'value' });
|
|
1195
|
+
if (positionals.length > 0) throw usageFail(`degrade-justification takes flags only (unexpected "${positionals[0]}")`);
|
|
1196
|
+
return buildDegradeJustificationRecord({
|
|
1197
|
+
backend: requireValue(values, 'backend', arm),
|
|
1198
|
+
downMark: values['down-mark'] === undefined ? undefined : requireDigest(values['down-mark'], 'down-mark'),
|
|
1199
|
+
degradeDigest: values['degrade-digest'] === undefined ? undefined : requireDigest(values['degrade-digest'], 'degrade-digest'),
|
|
1200
|
+
cwd, env, timestamp,
|
|
1201
|
+
});
|
|
1202
|
+
}
|
|
1203
|
+
if (arm === 'consult-attestation') {
|
|
1204
|
+
const { values, positionals } = parseFlags(rest, { '--backend': 'value', '--nonce': 'value', '--proposed-fix-digest': 'value' });
|
|
1205
|
+
return buildConsultAttestation({
|
|
1206
|
+
planId: onePlanId(positionals, arm), backend: requireValue(values, 'backend', arm),
|
|
1207
|
+
nonce: requireValue(values, 'nonce', arm),
|
|
1208
|
+
proposedFixDigest: requireDigest(requireValue(values, 'proposed-fix-digest', arm), 'proposed-fix-digest'),
|
|
1209
|
+
cwd, env, timestamp,
|
|
1210
|
+
});
|
|
1211
|
+
}
|
|
1212
|
+
if (arm === 'internal-attestation') {
|
|
1213
|
+
const { values, positionals } = parseFlags(rest, { '--lens': 'list', '--degraded': 'list', '--model': 'value', '--effort': 'value', '--tier': 'value', '--authority': 'value' });
|
|
1214
|
+
if (!Array.isArray(values.lens) || values.lens.length === 0) {
|
|
1215
|
+
throw usageFail('internal-attestation requires at least one --lens <name> (the required-lens set, #28)');
|
|
1216
|
+
}
|
|
1217
|
+
return buildInternalAttestation({
|
|
1218
|
+
planId: onePlanId(positionals, arm), lenses: values.lens, degraded: values.degraded ?? [],
|
|
1219
|
+
model: requireValue(values, 'model', arm), effort: values.effort, tier: values.tier,
|
|
1220
|
+
authority: requireValue(values, 'authority', arm), cwd, env, timestamp, ctx,
|
|
1221
|
+
});
|
|
1222
|
+
}
|
|
1223
|
+
return null; // maintainer-override — handled below (it prints its bound set first)
|
|
1224
|
+
})();
|
|
1225
|
+
|
|
1226
|
+
if (arm === 'maintainer-override') {
|
|
1227
|
+
const { values, positionals } = parseFlags(rest, { '--backend': 'value', '--checkpoint-approved': 'boolean', '--veto-receipt': 'value', '--chain-record': 'value' });
|
|
1228
|
+
const built = buildOverride({
|
|
1229
|
+
planId: onePlanId(positionals, arm), backend: requireValue(values, 'backend', arm),
|
|
1230
|
+
vetoReceipt: values['veto-receipt'] === undefined ? undefined : requireDigest(values['veto-receipt'], 'veto-receipt'),
|
|
1231
|
+
chainRecord: values['chain-record'] === undefined ? undefined : requireDigest(values['chain-record'], 'chain-record'),
|
|
1232
|
+
cwd, env, timestamp,
|
|
1233
|
+
});
|
|
1234
|
+
if (values['checkpoint-approved'] !== true) {
|
|
1235
|
+
return {
|
|
1236
|
+
code: 1,
|
|
1237
|
+
stdout: built.boundSet.join('\n'),
|
|
1238
|
+
stderr: 'flow-writer: maintainer-override requires the explicit --checkpoint-approved flag (#38) — the bound set above is what a checkpoint-approved re-run would record; nothing was written',
|
|
1239
|
+
};
|
|
1240
|
+
}
|
|
1241
|
+
const appended = appendFlowRecord({ cwd, record: built.record, env });
|
|
1242
|
+
return {
|
|
1243
|
+
code: 0,
|
|
1244
|
+
stdout: [...built.boundSet, `flow-writer: appended maintainer-override — digest ${canonicalFlowDigest(appended.record)}`, ` store: ${appended.writtenPath}`].join('\n'),
|
|
1245
|
+
stderr: '',
|
|
1246
|
+
};
|
|
1247
|
+
}
|
|
1248
|
+
|
|
1249
|
+
const appended = appendFlowRecord({ cwd, record, env });
|
|
1250
|
+
const label = record.kind === CHAIN_KIND ? `chain/${record.purpose} for plan "${record.planId}"` : record.kind;
|
|
1251
|
+
return { code: 0, stdout: `flow-writer: appended ${label} — digest ${canonicalFlowDigest(appended.record)}\n store: ${appended.writtenPath}`, stderr: '' };
|
|
1252
|
+
} catch (err) {
|
|
1253
|
+
// A store STOP passes through byte-verbatim (the "surfaces the store's own refusal
|
|
1254
|
+
// verbatim" contract); the flow-writer prefix marks only WRITER-owned failures.
|
|
1255
|
+
return { code: err.exitCode ?? 1, stdout: '', stderr: err.code === FLOW_STORE_STOP ? err.message : `flow-writer: ${err.message}` };
|
|
1256
|
+
}
|
|
1257
|
+
};
|
|
1258
|
+
|
|
1259
|
+
const isDirectRun = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href;
|
|
1260
|
+
if (isDirectRun) {
|
|
1261
|
+
const r = main(process.argv.slice(2));
|
|
1262
|
+
if (r.stdout) console.log(r.stdout);
|
|
1263
|
+
if (r.stderr) console.error(r.stderr);
|
|
1264
|
+
process.exit(r.code);
|
|
1265
|
+
}
|