@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.
Files changed (53) hide show
  1. package/CHANGELOG.md +95 -0
  2. package/SKILL.md +13 -1
  3. package/bridges/antigravity-cli-bridge/SKILL.md +14 -3
  4. package/bridges/antigravity-cli-bridge/bin/agy-review.sh +220 -30
  5. package/bridges/antigravity-cli-bridge/bin/agy-review.test.mjs +264 -8
  6. package/bridges/antigravity-cli-bridge/bin/agy.sh +12 -2
  7. package/bridges/antigravity-cli-bridge/bin/agy.test.mjs +18 -0
  8. package/bridges/antigravity-cli-bridge/capability.json +19 -13
  9. package/bridges/antigravity-cli-bridge/references/driving-agy.md +3 -2
  10. package/bridges/codex-cli-bridge/SKILL.md +17 -7
  11. package/bridges/codex-cli-bridge/bin/codex-exec.sh +156 -36
  12. package/bridges/codex-cli-bridge/bin/codex-exec.test.mjs +228 -4
  13. package/bridges/codex-cli-bridge/bin/codex-review.sh +205 -34
  14. package/bridges/codex-cli-bridge/bin/codex-review.test.mjs +276 -5
  15. package/bridges/codex-cli-bridge/capability.json +10 -7
  16. package/bridges/codex-cli-bridge/references/driving-codex.md +7 -5
  17. package/bridges/codex-cli-bridge/references/sandbox-and-flags.md +26 -12
  18. package/capability.json +1 -1
  19. package/package.json +1 -1
  20. package/references/modes/flow-writer.md +37 -0
  21. package/references/modes/gates.md +4 -4
  22. package/references/modes/procedures.md +4 -2
  23. package/references/modes/receipt-deadline.md +16 -0
  24. package/references/modes/review-state.md +1 -1
  25. package/references/modes/set-flow.md +22 -0
  26. package/references/scripts/archive-decisions.mjs +340 -15
  27. package/references/scripts/archive-decisions.test.mjs +522 -2
  28. package/tools/cheap-agents.mjs +8 -2
  29. package/tools/commands.mjs +24 -2
  30. package/tools/commit-guard.mjs +44 -9
  31. package/tools/core-evidence.mjs +25 -22
  32. package/tools/detect-backends.mjs +33 -11
  33. package/tools/dispatch-record.mjs +926 -0
  34. package/tools/doc-parity.mjs +21 -6
  35. package/tools/flow-check.mjs +842 -0
  36. package/tools/flow-record.mjs +795 -0
  37. package/tools/flow-store-read.mjs +114 -0
  38. package/tools/flow-store.mjs +1178 -0
  39. package/tools/flow-writer.mjs +1265 -0
  40. package/tools/fs-read-nofollow.mjs +128 -0
  41. package/tools/gates-declaration.mjs +184 -0
  42. package/tools/gates-init.mjs +59 -17
  43. package/tools/orchestration-config.mjs +87 -10
  44. package/tools/orchestration-write.mjs +3 -3
  45. package/tools/plan-files.mjs +35 -0
  46. package/tools/procedures.mjs +75 -11
  47. package/tools/receipt-deadline.mjs +242 -0
  48. package/tools/recipes.mjs +21 -0
  49. package/tools/repo-lex.mjs +22 -0
  50. package/tools/review-state.mjs +240 -80
  51. package/tools/run-gates.mjs +361 -139
  52. package/tools/set-flow.mjs +465 -0
  53. 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
+ }