@sabaiway/agent-workflow-kit 2.1.0 → 3.0.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 (99) hide show
  1. package/CHANGELOG.md +45 -0
  2. package/README.md +6 -5
  3. package/SKILL.md +12 -8
  4. package/bin/install.mjs +1 -1
  5. package/bridges/antigravity-cli-bridge/SKILL.md +8 -1
  6. package/bridges/antigravity-cli-bridge/bin/agy-review-honesty.test.mjs +150 -0
  7. package/bridges/antigravity-cli-bridge/bin/agy-review-model-screen.test.mjs +76 -0
  8. package/bridges/antigravity-cli-bridge/bin/agy-review.sh +80 -15
  9. package/bridges/antigravity-cli-bridge/bin/agy-review.test.mjs +95 -4
  10. package/bridges/antigravity-cli-bridge/capability.json +3 -2
  11. package/bridges/codex-cli-bridge/SKILL.md +8 -1
  12. package/bridges/codex-cli-bridge/bin/codex-review-honesty.test.mjs +143 -0
  13. package/bridges/codex-cli-bridge/bin/codex-review.sh +71 -10
  14. package/bridges/codex-cli-bridge/bin/codex-review.test.mjs +93 -10
  15. package/bridges/codex-cli-bridge/capability.json +3 -2
  16. package/capability.json +1 -1
  17. package/migrations/3.0.0-hardened-core-loop.md +44 -0
  18. package/migrations/README.md +1 -1
  19. package/package.json +2 -2
  20. package/references/hooks/gate-approve.mjs +1 -1
  21. package/references/modes/bootstrap.md +3 -3
  22. package/references/modes/commit-guard.md +19 -0
  23. package/references/modes/core-evidence.md +15 -0
  24. package/references/modes/coverage-check.md +12 -0
  25. package/references/modes/doc-parity.md +7 -6
  26. package/references/modes/gates.md +7 -6
  27. package/references/modes/grounding.md +1 -2
  28. package/references/modes/hook.md +1 -1
  29. package/references/modes/review-state.md +3 -3
  30. package/references/modes/upgrade.md +6 -6
  31. package/references/modes/velocity.md +2 -2
  32. package/references/scripts/archive-decisions.mjs +1 -1
  33. package/references/scripts/install-git-hooks-repo-exec.test.mjs +82 -0
  34. package/references/scripts/install-git-hooks.mjs +90 -18
  35. package/references/scripts/install-git-hooks.test.mjs +102 -0
  36. package/references/scripts/migrate-gates-branches.test.mjs +157 -0
  37. package/references/scripts/migrate-gates.mjs +395 -0
  38. package/references/scripts/migrate-gates.test.mjs +284 -0
  39. package/references/templates/agent_rules.md +2 -2
  40. package/tools/ack-write.mjs +1 -1
  41. package/tools/atomic-write.mjs +2 -2
  42. package/tools/autonomy-config.mjs +1 -1
  43. package/tools/autonomy-doctor.mjs +1 -1
  44. package/tools/autonomy-write.mjs +1 -1
  45. package/tools/bridge-settings-read.mjs +1 -1
  46. package/tools/bridge-settings.mjs +1 -1
  47. package/tools/changed-surface.mjs +8 -69
  48. package/tools/cheap-agents.mjs +2 -2
  49. package/tools/commands.mjs +17 -10
  50. package/tools/commit-guard.mjs +167 -0
  51. package/tools/core-evidence.mjs +914 -0
  52. package/tools/coverage-check.mjs +260 -0
  53. package/tools/delegation.mjs +1 -1
  54. package/tools/detect-backends.mjs +3 -3
  55. package/tools/doc-parity.mjs +11 -27
  56. package/tools/engine-source.mjs +1 -1
  57. package/tools/family-members.mjs +1 -1
  58. package/tools/family-registry.mjs +1 -1
  59. package/tools/fs-safe.mjs +1 -1
  60. package/tools/gate-hook.mjs +1 -1
  61. package/tools/{seed-gates.mjs → gates-init.mjs} +58 -52
  62. package/tools/grounding.mjs +6 -83
  63. package/tools/hide-footprint.mjs +1 -1
  64. package/tools/inject-methodology.mjs +1 -1
  65. package/tools/known-footprint.mjs +1 -1
  66. package/tools/labels.mjs +1 -1
  67. package/tools/lcov.mjs +6 -10
  68. package/tools/lens-region.mjs +1 -1
  69. package/tools/manifest/validate.mjs +19 -0
  70. package/tools/migrate-adr-store.mjs +2 -2
  71. package/tools/orchestration-config.mjs +1 -1
  72. package/tools/orchestration-write.mjs +1 -1
  73. package/tools/presentation.mjs +1 -1
  74. package/tools/procedures.mjs +2 -2
  75. package/tools/recipes.mjs +60 -9
  76. package/tools/recommendations.mjs +63 -7
  77. package/tools/release-scan.mjs +1 -1
  78. package/tools/renderers.mjs +1 -1
  79. package/tools/review-state.mjs +288 -340
  80. package/tools/run-gates.mjs +216 -92
  81. package/tools/sandbox-masks.mjs +1 -1
  82. package/tools/semver-lite.mjs +1 -1
  83. package/tools/set-autonomy.mjs +1 -1
  84. package/tools/set-recipe.mjs +1 -1
  85. package/tools/setup-backends.mjs +1 -1
  86. package/tools/surface.mjs +1 -1
  87. package/tools/uninstall.mjs +1 -1
  88. package/tools/velocity-profile.mjs +2 -2
  89. package/tools/view-model.mjs +1 -1
  90. package/references/modes/fold-completeness.md +0 -30
  91. package/references/modes/review-ledger.md +0 -34
  92. package/references/templates/verification-profile.json +0 -10
  93. package/tools/fold-completeness-run.mjs +0 -1120
  94. package/tools/fold-completeness.mjs +0 -672
  95. package/tools/review-ledger-core.mjs +0 -428
  96. package/tools/review-ledger-write.mjs +0 -647
  97. package/tools/review-ledger.mjs +0 -630
  98. package/tools/sarif.mjs +0 -52
  99. package/tools/verification-profile.mjs +0 -219
@@ -1,647 +0,0 @@
1
- #!/usr/bin/env node
2
- // review-ledger-write.mjs — the SOLE filesystem WRITER for the review-round ledger (AD-045). It is
3
- // the write half of the family read/write split (mirrors orchestration-config.mjs /
4
- // orchestration-write.mjs): review-ledger.mjs (schema + read + decideStop + --check) is read-only and
5
- // NEVER imports this module; an import-split test pins that. This module imports the read core the
6
- // OTHER direction (the schema + decideStop + the tolerant reader) and appends records through the
7
- // shared hardened atomic-write core (tools/atomic-write.mjs — exclusive-create tmp + rename, TOCTOU
8
- // re-check, symlink STOPs). The ledger lives in the git dir (uncommittable by construction).
9
- //
10
- // Record kinds, one JSONL ledger. Every verb evaluates the current SEGMENT — (activity, loop,
11
- // base = `git rev-parse HEAD`), BUGFREE-2 / AD-048 D1: round numbering, the caps, and every tooth
12
- // are per segment; a segment closes only through a gated commit, so a counter reset is earned:
13
- // recordRound — one review round: per-backend counts + verdict + degraded, finding-origin tally,
14
- // findings[]. Binds to the canonical tree fingerprint + base. THE TEETH (Decision 5
15
- // + AD-048): REFUSES (typed STOP) while decideStop on the segment's records is
16
- // `triage-required`; refuses ANY round beyond the per-segment hard-max ceiling
17
- // unconditionally; refuses while a blocking finding of the segment's previous
18
- // round VANISHED unclassified (D6 — no-repro-no-fold; `refuted` is the honest
19
- // phantom lane); refuses while the changed source surface exceeds the diff cap
20
- // without a recorded segment size-cap override (D4). Integrity binding (Decision 7):
21
- // each NON-degraded backend needs an ATTESTING code receipt for the current tree
22
- // (the shared review-ledger-core predicate: grounded AND probe:false — a probe,
23
- // unmarked or malformed marker never attests), so a round cannot be recorded for a
24
- // tree no bridge really reviewed.
25
- // recordTriage — the classification that BREAKS the deadlock: each surviving blocking finding of a
26
- // SEGMENT round classified fixable-bug / inherent-layer-residual / escalate /
27
- // refuted (v4). No teeth (a triage is exactly what lets the next round proceed),
28
- // no receipt binding (it reviews nothing).
29
- //
30
- // HONEST residual (stated, accepted — like review-state's): the ledger attests a review occurred; it
31
- // does NOT prove the recorded COUNTS are truthful nor that a self-reported `degraded:true` is real.
32
- // A self-discipline mechanism against silent process drift, not a security boundary.
33
- //
34
- // Dependency-free, Node >= 18. Every fs primitive is injectable (deps.*) so the guards are unit-
35
- // testable. No side effects on import (the isDirectRun idiom).
36
-
37
- import { readFileSync } from 'node:fs';
38
- import { dirname } from 'node:path';
39
- import { pathToFileURL } from 'node:url';
40
- import { spawnSync } from 'node:child_process';
41
- import { writeContainedFileAtomic } from './atomic-write.mjs';
42
- import { buildState, computeTreeFingerprint, plansInFlight, readReceipts, resolveReceiptsPath } from './review-state.mjs';
43
- import {
44
- REVIEW_CAP,
45
- SCHEMA_VERSION,
46
- resolveLedgerPath,
47
- resolveBase,
48
- readLedger,
49
- filterSegmentRecords,
50
- collectSizeCapLimit,
51
- isQualityGreenGateRun,
52
- roundSequenceIntact,
53
- decideStop,
54
- validateRecord,
55
- } from './review-ledger.mjs';
56
- // The SHARED attesting-receipt predicate (D3) — read straight from the neutral core, the same one
57
- // review-state and the round cross-check use: one definition of "this receipt may attest a tree".
58
- import { summarizeReviewReceiptsForTree, describeMissingReviewAttestation } from './review-ledger-core.mjs';
59
- // The NEUTRAL shared changed-surface computation (BUGFREE-2 / D4): the D4 diff-cap and the
60
- // fold-completeness coverage gate consume ONE computation, so they can never drift. The writer
61
- // imports the NEUTRAL module, never the runner (the sole-tree-toucher boundary, codex R2 — an
62
- // import-split test pins that this file never imports fold-completeness-run.mjs).
63
- import { computeChangedSurface, countCapLines, parsePositiveIntKnob } from './changed-surface.mjs';
64
-
65
- // The absolute WRITER ceiling (Decision 5): hard-max lives ONLY here — it is NOT a decideStop input.
66
- // Even a fully-classified resolved-residual loop cannot reach a round beyond this. Since AD-048 it
67
- // is scoped per SEGMENT (D3 — value unchanged, scope corrected): round numbers restart when a gated
68
- // commit moves base, so a multiphase plan records fully while round 4 within ONE segment stays
69
- // refused — that ceiling is the point (the 2026-06-30 six-round incident class).
70
- export const HARD_MAX = 3;
71
- // The D4 diff-size review cap (default 400 changed source lines; AW_REVIEW_DIFF_CAP overrides
72
- // through the shared fail-closed positive-integer parser). Counted classes are pinned in
73
- // changed-surface.mjs: assessable + unsupported SOURCE lines count; tests and out-of-domain never
74
- // do; pure deletions are free.
75
- export const DEFAULT_DIFF_CAP = 400;
76
- const DEFAULT_ACTIVITY = 'plan-execution';
77
-
78
- // A typed STOP — a deliberate refusal we surface (the teeth / a malformed record / a missing
79
- // receipt), distinct from a native fs error. The codebase's typed-error idiom (no classes).
80
- export const LEDGER_WRITE_STOP = 'LEDGER_WRITE_STOP';
81
- const stop = (message) => Object.assign(new Error(`[agent-workflow-kit] ${message}`), { name: 'LedgerWriteStop', code: LEDGER_WRITE_STOP });
82
-
83
- // A tagged usage failure (exit 2) for the CLI parser.
84
- const usageFail = (message) => Object.assign(new Error(`[agent-workflow-kit] ${message}`), { exitCode: 2 });
85
-
86
- const isoNow = () => new Date().toISOString();
87
-
88
- // ── the append primitive (whole-file read → add one JSONL line → atomic rewrite) ────────────────
89
- // The ledger is a git-dir JSONL, never a docs/ai file, so the append reads the current file, adds the
90
- // new line in memory, and rewrites through the contained-write core (root = the ledger file's dir).
91
- const appendRecord = (ledgerPath, record, deps = {}) => {
92
- const readFile = deps.readFile ?? readFileSync;
93
- let existing = '';
94
- try {
95
- existing = readFile(ledgerPath, 'utf8');
96
- } catch (err) {
97
- // Only a truly-absent file starts from empty. A non-ENOENT read failure (EACCES/EIO) must NOT be
98
- // treated as "no file" — rewriting from empty would DESTROY the existing ledger (codex R1).
99
- if (err && err.code === 'ENOENT') existing = '';
100
- else throw stop(`cannot read the ledger before appending (${(err && err.code) || (err && err.message) || err}) — refusing to overwrite it (fail closed)`);
101
- }
102
- const prefix = existing === '' ? '' : existing.endsWith('\n') ? existing : `${existing}\n`;
103
- const body = `${prefix}${JSON.stringify(record)}\n`;
104
- const root = dirname(ledgerPath);
105
- writeContainedFileAtomic(root, ledgerPath, body, deps, { stop, label: ledgerPath });
106
- return { writtenPath: ledgerPath, record };
107
- };
108
-
109
- // ── (g) record --from-receipts: draft the backends[] from the current-fingerprint receipts ────────
110
- // (BUGFREE-3 / AD-049). The orchestrator hand-maintained each backend's { backend, verdict } beside
111
- // the counts every round — drift-prone busywork. --from-receipts DRAFTS the backends[] instead: for
112
- // each recipe-named backend it reads the review-state receipt status (verdict from the fresh grounded
113
- // code receipt) and computes the counts from the orchestrator's OWN supplied findings; `origins` and
114
- // `findings` stay explicit input. It NEVER invents a backend — a recipe-named backend with no fresh
115
- // grounded receipt is a LOUD STOP (run its review, or supply it explicitly as a degraded backend).
116
- // The assembled backends[] then rides the normal recordRound teeth (validateRound re-checks that
117
- // findings-by-severity equals the drafted counts — true by construction).
118
- export const draftBackendsFromReceipts = ({ state, findings = [], explicitBackends = [] }, deps = {}) => {
119
- const stopFn = deps.stop ?? stop;
120
- const explicitByName = new Map((Array.isArray(explicitBackends) ? explicitBackends : []).filter((b) => b && typeof b.backend === 'string').map((b) => [b.backend, b]));
121
- const findingsArr = Array.isArray(findings) ? findings : [];
122
- const countFor = (name) => {
123
- const own = findingsArr.filter((f) => f && f.backend === name);
124
- return {
125
- blockers: own.filter((f) => f.severity === 'blocker').length,
126
- majors: own.filter((f) => f.severity === 'major').length,
127
- minors: own.filter((f) => f.severity === 'minor').length,
128
- };
129
- };
130
- return state.requiredBackends.map((name) => {
131
- const explicit = explicitByName.get(name);
132
- if (explicit) {
133
- // An explicit row is honored verbatim ONLY for a DEGRADED backend (a bridge the operator knows
134
- // is down, which minted no receipt). A NON-degraded explicit row would BYPASS the
135
- // receipt-derived verdict --from-receipts exists to compute (a stale hand-composed row silently
136
- // winning) — a loud STOP, fail-closed (codex council R1). Drop --from-receipts to compose a
137
- // non-degraded row by hand, or mark the backend degraded.
138
- if (explicit.degraded !== true) {
139
- throw stopFn(
140
- `refusing --from-receipts: an explicit non-degraded backends[] row for ${name} would bypass the receipt-derived verdict this flag computes — mark it degraded (a bridge that is down), or drop --from-receipts and compose the round by hand`,
141
- );
142
- }
143
- return explicit;
144
- }
145
- const status = state.backends.find((b) => b.backend === name);
146
- if (!status || status.state !== 'current') {
147
- throw stopFn(
148
- `refusing --from-receipts: no fresh grounded code receipt for ${name} (state: ${status?.state ?? 'missing'}) — run its review wrapper (codex-review code / agy-review code --facts @f) first, or supply it explicitly as a degraded backend in the payload's backends[]`,
149
- );
150
- }
151
- return { backend: name, degraded: false, ...countFor(name), verdict: status.verdict };
152
- });
153
- };
154
-
155
- // ── recordRound (the teeth + the integrity binding) ─────────────────────────────────────────────
156
-
157
- // recordRound({ cwd, env, loop, activity, round, origins, backends, findings, timestamp }, deps) →
158
- // { writtenPath, record }. THROWS a typed STOP (the teeth / a malformed record / a missing receipt)
159
- // or a native fs error. Every fs edge + the fingerprint is injectable for hermetic tests.
160
- export const recordRound = (params, deps = {}) => {
161
- const { cwd = process.cwd(), env = process.env, loop, activity = DEFAULT_ACTIVITY, round, origins, backends, findings, timestamp } = params;
162
- const ledgerPath = deps.ledgerPath ?? resolveLedgerPath(cwd, env);
163
- if (ledgerPath == null) throw stop('cannot resolve the ledger path — not a git work tree and AW_REVIEW_LEDGER is unset');
164
- if (!(Number.isInteger(round) && round >= 1)) throw stop(`round must be an integer >= 1 (got ${round})`);
165
- // The hard-max ceiling: refuse ANY round beyond it, independent of triage state (Decision 5).
166
- // Per SEGMENT since AD-048 (D3): the round number is the segment round number.
167
- if (round > HARD_MAX) {
168
- throw stop(`refusing to record round ${round}: beyond the hard-max ceiling of ${HARD_MAX} rounds within one segment — the segment must converge (or its surviving finding escalate) and ship through a gated commit; the counter resets only at the commit boundary, never by declaration`);
169
- }
170
-
171
- const fingerprint = deps.computeFingerprint ? deps.computeFingerprint(cwd) : computeTreeFingerprint(cwd);
172
- const base = deps.resolveBase ? deps.resolveBase(cwd) : resolveBase(cwd);
173
-
174
- // The teeth: refuse a new round WHILE decideStop on the existing records is triage-required. Once
175
- // the surviving blocking finding is classified (recordTriage), decideStop is no longer
176
- // triage-required and the next round is permitted (a fixable-bug classification permits the fix
177
- // round — no deadlock). triage-required is fingerprint/backend-independent, so a minimal decideStop
178
- // suffices here.
179
- const { records, malformed, malformedReasons, readError } = readLedger(ledgerPath, deps.readFile);
180
- // Fail CLOSED before the teeth: a ledger the reader could not fully trust (an unreadable file, or
181
- // malformed lines the reader dropped) could make decideStop miss a triage-required round and fail
182
- // the teeth OPEN (codex R1). Refuse to append until the ledger is sound.
183
- if (readError) throw stop(`cannot read the existing ledger (${readError}) — refusing to append (fail closed)`);
184
- if (malformed > 0) throw stop(`the existing ledger has ${malformed} malformed line(s) — refusing to append until they are fixed (fail closed): ${malformedReasons.join('; ')}`);
185
- // EVERY tooth below evaluates the current SEGMENT (activity, loop, base) — D1: records of earlier
186
- // segments (other bases, or pre-v4 records with no base) are closed history; the field-proven
187
- // 11-round / 4-base loop records completely while round 4 within ONE segment stays refused.
188
- const segment = filterSegmentRecords(records, { activity, loop, base });
189
- // Sequence integrity + sequentiality (codex R2+R3): the EXISTING segment rounds must already be
190
- // exactly 1..n (never trust a hand-corrupted [2]/[1,1]/[2,1] to compute "latest"), AND the
191
- // incoming round must be exactly the next (n+1). A duplicate, decreasing, or gapped round would
192
- // let a fabricated "later" round become the latest that decideStop reads, bypassing the teeth.
193
- const priorRounds = segment.filter((r) => r.kind === 'round').map((r) => r.round);
194
- if (!roundSequenceIntact(segment)) {
195
- throw stop(`refusing to append to loop "${loop}": its recorded round sequence for the current segment is corrupt (${priorRounds.join(',') || 'empty'}, not 1..n) — fix the ledger by hand before recording another round`);
196
- }
197
- const nextRound = priorRounds.length + 1;
198
- if (round !== nextRound) {
199
- throw stop(`refusing to record round ${round}: rounds must be sequential within the segment — the next round for loop "${loop}" at the current base is ${nextRound} (a duplicate, out-of-order, or gapped round would corrupt the crossover computation)`);
200
- }
201
- const pre = decideStop(segment, { cap: REVIEW_CAP });
202
- if (pre.state === 'triage-required') {
203
- throw stop(`refusing to record a new round while triage is required — ${pre.reason}. Classify the surviving blocking finding(s) with the "classify" command first (a fixable-bug classification permits the fix round).`);
204
- }
205
-
206
- // D6 — no-repro-no-fold: no blocking finding of the segment's previous round may VANISH
207
- // unclassified. Present-again is fine (still live); classified is fine (fixable-bug folded with
208
- // its red→green testId at the round it was folded — late binding restored; inherent-layer-residual
209
- // documented; escalate handed over; refuted — the honest phantom lane, grounds mandatory). A
210
- // silent disappearance is exactly the sycophancy hole this pillar closes. Minors stay exempt.
211
- const previous = segment.find((r) => r.kind === 'round' && r.round === nextRound - 1);
212
- if (previous) {
213
- // "Present" means present AS BLOCKING: a blocker/major re-reported as a minor did not survive —
214
- // it was softened, and the silent-soften lane is exactly the bypass D6 closes (codex R1).
215
- const incoming = new Set(
216
- Array.isArray(findings)
217
- ? findings.filter((f) => f && (f.severity === 'blocker' || f.severity === 'major')).map((f) => f.findingKey)
218
- : [],
219
- );
220
- // A classification CLEARS the vanish only when it actually resolves the finding's fate:
221
- // fixable-bug (folded, testId bound), inherent-layer-residual (documented), refuted (grounds
222
- // cited), or an ACCEPTED escalate — a pending escalate (accepted:false) is still undecided and
223
- // must not disappear into a clean round (codex R1).
224
- const clearsVanish = (c) =>
225
- c.class === 'fixable-bug' || c.class === 'inherent-layer-residual' || c.class === 'refuted' || (c.class === 'escalate' && c.accepted === true);
226
- const classified = new Set();
227
- for (const t of segment) {
228
- if (t.kind === 'triage' && t.round === previous.round) for (const c of t.classifications) if (clearsVanish(c)) classified.add(c.findingKey);
229
- }
230
- const vanished = [...new Set(
231
- previous.findings
232
- .filter((f) => f.severity === 'blocker' || f.severity === 'major')
233
- .map((f) => f.findingKey)
234
- .filter((k) => !incoming.has(k) && !classified.has(k)),
235
- )];
236
- if (vanished.length > 0) {
237
- throw stop(`refusing to record round ${round}: blocking finding(s) of round ${previous.round} vanished without a classification: ${vanished.join(', ')} (D6 — no blocking finding disappears silently). Classify each with the "classify" command first: fixable-bug (bind the red→green testId), inherent-layer-residual, escalate, or refuted (a phantom finding — cite the refuting grounds in note).`);
238
- }
239
- }
240
-
241
- // D4 — the diff-size cap over the ONE shared changed-surface computation (changed-surface.mjs):
242
- // a round is refused while the changed source surface exceeds the cap, unless the SEGMENT carries
243
- // a recorded size-cap override sanctioning at least the counted magnitude. Subtractive folds are
244
- // free (new-side lines only); tests and out-of-domain files never count.
245
- const cap = parsePositiveIntKnob(env, 'AW_REVIEW_DIFF_CAP', DEFAULT_DIFF_CAP, stop);
246
- const counted = deps.countChangedLines ? deps.countChangedLines(cwd) : countCapLines(computeChangedSurface(gitRoot(cwd) ?? cwd));
247
- if (counted > cap) {
248
- const sanctioned = collectSizeCapLimit(records, { activity, loop, base });
249
- if (sanctioned === null || counted > sanctioned) {
250
- throw stop(
251
- `refusing to record round ${round}: the changed source surface is ${counted} lines — over the ${cap}-line review cap (AW_REVIEW_DIFF_CAP)${sanctioned !== null ? ` and over the segment's recorded size-cap sanction of ${sanctioned}` : ''}. ` +
252
- `Split the change into reviewable units (commit a converged part first), or record the LOUD waiver: node review-ledger-write.mjs override --json '{"loop":"${loop}","round":${round},"scope":"size-cap","sanctionedLines":${counted},"reason":"<why this surface must review as one unit>"}'`,
253
- );
254
- }
255
- }
256
-
257
- const record = { schema: SCHEMA_VERSION, loop, activity, kind: 'round', round, base, fingerprint, origins, backends, findings, timestamp: timestamp ?? isoNow() };
258
- const v = validateRecord(record);
259
- if (!v.ok) throw stop(`refusing to record a malformed round: ${v.reason}`);
260
-
261
- // Integrity binding: each NON-degraded backend needs an ATTESTING receipt for this tree — a round
262
- // cannot be recorded for a tree no bridge really reviewed. A degraded backend minted no receipt.
263
- // The predicate is the SHARED one (review-ledger-core), the same review-state and the round
264
- // cross-check read — a probe receipt never counted as a review, and the stated reason names WHICH
265
- // exclusion applied, because the recoveries differ (run a real review / refresh the bridge / fix
266
- // the receipt source / re-run grounded) and a silent "no receipt" would hide that.
267
- const receiptsPath = deps.receiptsPath ?? resolveReceiptsPath(cwd, env);
268
- const { receipts } = receiptsPath ? readReceipts(receiptsPath, deps.readFile) : { receipts: [] };
269
- for (const b of backends) {
270
- if (b.degraded) continue;
271
- const own = receipts.filter((r) => r.backend === b.backend);
272
- const failure = describeMissingReviewAttestation(summarizeReviewReceiptsForTree(own, fingerprint));
273
- if (failure !== null) {
274
- throw stop(`refusing to record a round for ${b.backend}: no grounded code receipt for the current tree can attest this round — ${failure}; run its real review (codex-review code / agy-review code --facts @f), or mark the backend degraded with a reason`);
275
- }
276
- }
277
-
278
- // D5 — the green-baseline tooth (armed at Step 2.3, AFTER run-gates --record exists — the
279
- // bootstrap order): a round records only over a tree whose FULL declared non-process gate set
280
- // was proven green by a recorded gate-run at the CURRENT fingerprint. "Gates ran before review"
281
- // is now computed, never remembered. A `--only` subset is recorded honestly but never satisfies
282
- // this (the R1 converged subset-bypass hole); a run whose tree changed under it attests no
283
- // particular tree (codex R2); process-gate failures never block (the closed carve-out).
284
- const gateRuns = segment.filter((r) => r.kind === 'gate-run');
285
- if (!gateRuns.some((g) => g.fingerprint === fingerprint && isQualityGreenGateRun(g))) {
286
- throw stop(`refusing to record round ${round}: no quality-green gate-run for the current tree in this segment (D5 — gates run before review is computed, not remembered). Run the FULL declared matrix with a recorded receipt first: node agent-workflow-kit/tools/run-gates.mjs --record (a --only subset never satisfies this; a run whose tree changed under it never counts).`);
287
- }
288
-
289
- return appendRecord(ledgerPath, record, deps);
290
- };
291
-
292
- // ── recordGateRun (BUGFREE-2 / AD-048, D5 — the green-baseline receipt run-gates --record mints) ──
293
-
294
- // recordGateRun({ cwd, env, activity, declared, results, summary, fingerprintBefore,
295
- // fingerprintAfter, timestamp }, deps) → { writtenPath, record }. The SOLE ledger entry point for
296
- // run-gates (`--record` DELEGATES here — the runner never opens the ledger file itself; an
297
- // import/structure pin holds the boundary). The loop is DERIVED from the single in-flight plan
298
- // (the recordOverride precedent — a gate-run is minted only inside its live loop, never
299
- // retro-recorded); the record carries the segment frame and NO round number (per-kind frame, D5).
300
- // A red run records honestly (telemetry fuel — consecutive red gate-runs are the revert-first
301
- // visibility); quality-green is judged at read time, never stored.
302
- export const recordGateRun = (params, deps = {}) => {
303
- const { cwd = process.cwd(), env = process.env, activity = DEFAULT_ACTIVITY, declared, results, summary, fingerprintBefore, fingerprintAfter, timestamp } = params;
304
- const ledgerPath = deps.ledgerPath ?? resolveLedgerPath(cwd, env);
305
- if (ledgerPath == null) throw stop('cannot resolve the ledger path — not a git work tree and AW_REVIEW_LEDGER is unset');
306
- const plans = deps.plansInFlight ? deps.plansInFlight() : plansInFlight(gitRoot(cwd) ?? cwd);
307
- if (plans.length !== 1) {
308
- throw stop(`refusing to record a gate-run: it is minted only inside the SINGLE in-flight loop (in flight: ${plans.length ? plans.join(', ') : 'none'})`);
309
- }
310
- const loop = plans[0].replace(/\.md$/, '');
311
- const base = deps.resolveBase ? deps.resolveBase(cwd) : resolveBase(cwd);
312
- const record = {
313
- schema: SCHEMA_VERSION, loop, activity, kind: 'gate-run', base,
314
- fingerprint: fingerprintBefore, fingerprintAfter, declared, results, summary,
315
- timestamp: timestamp ?? isoNow(),
316
- };
317
- const v = validateRecord(record);
318
- if (!v.ok) throw stop(`refusing to record a malformed gate-run: ${v.reason}`);
319
- const { records, malformed, malformedReasons, readError } = readLedger(ledgerPath, deps.readFile);
320
- if (readError) throw stop(`cannot read the existing ledger (${readError}) — refusing to append (fail closed)`);
321
- if (malformed > 0) throw stop(`the existing ledger has ${malformed} malformed line(s) — refusing to append until they are fixed (fail closed): ${malformedReasons.join('; ')}`);
322
- if (!roundSequenceIntact(filterSegmentRecords(records, { activity, loop, base }))) {
323
- throw stop(`refusing to record a gate-run for loop "${loop}": its recorded round sequence for the current segment is corrupt (not 1..n) — fix the ledger by hand first`);
324
- }
325
- return appendRecord(ledgerPath, record, deps);
326
- };
327
-
328
- // ── recordOverride (BUGFREE-1 / AD-047, D3/D7 — the loud, recorded waiver) ──────────────────────
329
-
330
- // A read-only git-root probe for the in-flight tooth (the writer's only other git query is inside
331
- // computeTreeFingerprint).
332
- const gitRoot = (cwd) => {
333
- const r = spawnSync('git', ['rev-parse', '--show-toplevel'], { cwd, windowsHide: true });
334
- if (r.error || r.status !== 0) return null;
335
- return r.stdout.toString('utf8').replace(/\r?\n$/, '');
336
- };
337
-
338
- // recordOverride({ cwd, env, loop, activity, round, scope, files, testId, reason, timestamp }, deps)
339
- // → { writtenPath, record }. Standard teeth: field validation via the schema (exact per-scope
340
- // payloads), fail-closed ledger read, and the IN-FLIGHT tooth — an override is a waiver, minted only
341
- // inside its live loop, never retro-recorded for a finished or foreign one. The fold-completeness
342
- // gate matches on loop + payload, never on the (audit-only) fingerprint.
343
- export const recordOverride = (params, deps = {}) => {
344
- const { cwd = process.cwd(), env = process.env, loop, activity = DEFAULT_ACTIVITY, round, scope, files, testId, sanctionedLines, reason, timestamp } = params;
345
- const ledgerPath = deps.ledgerPath ?? resolveLedgerPath(cwd, env);
346
- if (ledgerPath == null) throw stop('cannot resolve the ledger path — not a git work tree and AW_REVIEW_LEDGER is unset');
347
- if (!(Number.isInteger(round) && round >= 1)) throw stop(`round must be an integer >= 1 (got ${round})`);
348
- // Exactly ONE in-flight plan, and it must be the named loop (codex+agy R5): a waiver minted while
349
- // multiple plans are active could later cover a now-single loop — the ambiguity the rest of the
350
- // family refuses everywhere (the single-plan rule).
351
- const plans = deps.plansInFlight ? deps.plansInFlight() : plansInFlight(gitRoot(cwd) ?? cwd);
352
- if (plans.length !== 1 || plans[0] !== `${loop}.md`) {
353
- throw stop(`refusing to record an override for loop "${loop}": an override is minted only inside its SINGLE in-flight loop (in flight: ${plans.length ? plans.join(', ') : 'none'})`);
354
- }
355
- const fingerprint = deps.computeFingerprint ? deps.computeFingerprint(cwd) : computeTreeFingerprint(cwd);
356
- const base = deps.resolveBase ? deps.resolveBase(cwd) : resolveBase(cwd);
357
- const record = {
358
- schema: SCHEMA_VERSION, loop, activity, kind: 'override', round, base, fingerprint, scope,
359
- ...(files !== undefined ? { files } : {}), ...(testId !== undefined ? { testId } : {}),
360
- ...(sanctionedLines !== undefined ? { sanctionedLines } : {}),
361
- reason, timestamp: timestamp ?? isoNow(),
362
- };
363
- const v = validateRecord(record);
364
- if (!v.ok) throw stop(`refusing to record a malformed override: ${v.reason}`);
365
- const { records, malformed, malformedReasons, readError } = readLedger(ledgerPath, deps.readFile);
366
- if (readError) throw stop(`cannot read the existing ledger (${readError}) — refusing to append (fail closed)`);
367
- if (malformed > 0) throw stop(`the existing ledger has ${malformed} malformed line(s) — refusing to append until they are fixed (fail closed): ${malformedReasons.join('; ')}`);
368
- if (!roundSequenceIntact(filterSegmentRecords(records, { activity, loop, base }))) {
369
- throw stop(`refusing to record an override for loop "${loop}": its recorded round sequence for the current segment is corrupt (not 1..n) — fix the ledger by hand first`);
370
- }
371
- return appendRecord(ledgerPath, record, deps);
372
- };
373
-
374
- // ── recordTriage (the deadlock-breaker — no teeth, no receipt binding) ──────────────────────────
375
-
376
- // recordTriage({ cwd, env, loop, activity, round, classifications, timestamp }, deps) →
377
- // { writtenPath, record }. Appends the classification of the round's surviving blocking findings.
378
- export const recordTriage = (params, deps = {}) => {
379
- const { cwd = process.cwd(), env = process.env, loop, activity = DEFAULT_ACTIVITY, round, classifications, timestamp } = params;
380
- const ledgerPath = deps.ledgerPath ?? resolveLedgerPath(cwd, env);
381
- if (ledgerPath == null) throw stop('cannot resolve the ledger path — not a git work tree and AW_REVIEW_LEDGER is unset');
382
- if (!(Number.isInteger(round) && round >= 1)) throw stop(`round must be an integer >= 1 (got ${round})`);
383
- const fingerprint = deps.computeFingerprint ? deps.computeFingerprint(cwd) : computeTreeFingerprint(cwd);
384
- const base = deps.resolveBase ? deps.resolveBase(cwd) : resolveBase(cwd);
385
- // Normalize each classification: an absent testId → null, an absent note → '' — an absent optional
386
- // field is FILLED, never rejected as malformed (agy R3). Under schema v2 (M2/AD-046) a fixable-bug
387
- // normalized to a null testId then FAILS validateRecord below (a typed STOP naming the rule + the
388
- // red-test-first fix) — the test-per-fold binding rides the existing validate path; a `refuted`
389
- // classification normalized to an empty note fails the same way (D6 — the grounds are mandatory).
390
- // A non-array is left as-is for validateRecord to reject with a typed STOP (never a raw .map TypeError).
391
- const normalized = Array.isArray(classifications) ? classifications.map((c) => ({ ...c, testId: c?.testId ?? null, note: c?.note ?? '' })) : classifications;
392
- const record = { schema: SCHEMA_VERSION, loop, activity, kind: 'triage', round, base, fingerprint, classifications: normalized, timestamp: timestamp ?? isoNow() };
393
- const v = validateRecord(record);
394
- if (!v.ok) throw stop(`refusing to record a malformed triage: ${v.reason}`);
395
-
396
- // Bind the triage to a REAL round of the current SEGMENT: the referenced round must exist there
397
- // and every classified findingKey must be a surviving blocking finding (blocker or major) of THAT
398
- // round — a classification for a nonexistent/future/other-segment round, or for a key the round
399
- // never raised, must not satisfy resolved-residual downstream (codex R1; D1 — a committed
400
- // segment's rounds are closed, the cross-segment lane is the D7 red-proof override). Fail CLOSED
401
- // on an unreadable / malformed ledger.
402
- const { records, malformed, malformedReasons, readError } = readLedger(ledgerPath, deps.readFile);
403
- if (readError) throw stop(`cannot read the existing ledger (${readError}) — refusing to append (fail closed)`);
404
- if (malformed > 0) throw stop(`the existing ledger has ${malformed} malformed line(s) — refusing to append until they are fixed (fail closed): ${malformedReasons.join('; ')}`);
405
- const segment = filterSegmentRecords(records, { activity, loop, base });
406
- if (!roundSequenceIntact(segment)) throw stop(`refusing to classify loop "${loop}": its recorded round sequence for the current segment is corrupt (not 1..n) — fix the ledger by hand first`);
407
- const targetRound = segment.find((r) => r.kind === 'round' && r.round === round);
408
- if (!targetRound) throw stop(`refusing to classify round ${round} of loop "${loop}": no such recorded round in the current segment — classify a round that exists at the current base`);
409
- const survivingKeys = new Set(targetRound.findings.filter((f) => f.severity === 'blocker' || f.severity === 'major').map((f) => f.findingKey));
410
- for (const c of classifications) {
411
- if (!survivingKeys.has(c.findingKey)) throw stop(`refusing to classify "${c.findingKey}": it is not a surviving blocking finding of round ${round} (classify only that round's blockers/majors)`);
412
- }
413
-
414
- return appendRecord(ledgerPath, record, deps);
415
- };
416
-
417
- // ── batch (D4/D5 — one invocation applies an ordered list of record/classify/override ops) ────────
418
- // The prompt-economy lane (WRITER-BURST-BATCH): the ledger triad a records stage would otherwise
419
- // fire one writer call at a time — records, classifications, overrides — rides ONE `batch`
420
- // invocation. Every op runs the SAME single-verb code path (no forked validator, D4), so a batch of
421
- // N ops is record-equivalent to the same N single-verb invocations. TWO passes (D5): pass 1
422
- // validates the WHOLE envelope structurally with ZERO writes (a bad envelope stops before any op
423
- // runs); pass 2 applies the ops sequentially — a DOMAIN failure (a tooth, a missing receipt, a
424
- // malformed record) stops the batch with an honest report, and the ops already applied stay recorded
425
- // (the ledger is append-only — no rollback pretense).
426
-
427
- // The verbs a batch operation may carry — the SAME functions the single verbs dispatch.
428
- const BATCH_VERBS = new Set(['record', 'classify', 'override']);
429
-
430
- // Pass 1 — structural envelope validation, ZERO writes (a usage failure, exit 2). Deep per-op payload
431
- // validity is NOT checked here (that is pass 2's single-verb code path — checking it here would fork
432
- // the validator); pass 1 proves only the envelope shape + a known verb per op, so no raw TypeError
433
- // and no silent success reaches pass 2.
434
- export const validateBatchEnvelope = (payload) => {
435
- if (payload == null || typeof payload !== 'object' || Array.isArray(payload)) {
436
- throw usageFail('batch payload must be an object of the form { "operations": [ … ] }');
437
- }
438
- const operations = payload.operations;
439
- if (!Array.isArray(operations)) throw usageFail('batch payload.operations must be an array of operations');
440
- if (operations.length === 0) throw usageFail('batch payload.operations is empty — nothing to record');
441
- operations.forEach((op, i) => {
442
- if (op == null || typeof op !== 'object' || Array.isArray(op)) {
443
- throw usageFail(`batch operation [${i}] must be an object of the form { "verb": …, … }`);
444
- }
445
- if (!BATCH_VERBS.has(op.verb)) {
446
- throw usageFail(`batch operation [${i}] names an unknown verb "${op.verb}" (expected: record | classify | override)`);
447
- }
448
- // The batch resolves cwd/env from the INVOCATION, never per operation — a per-op override could
449
- // read one project's state and write another's ledger. Reject it loudly.
450
- for (const forbidden of ['cwd', 'env']) {
451
- if (forbidden in op) {
452
- throw usageFail(`batch operation [${i}] carries a "${forbidden}" field — the batch resolves cwd/env from the invocation, never per operation`);
453
- }
454
- }
455
- // fromReceipts must be a real boolean: a string "false" is truthy and would silently enable the
456
- // draft. Reject any non-boolean when the field is present.
457
- if ('fromReceipts' in op && typeof op.fromReceipts !== 'boolean') {
458
- throw usageFail(`batch operation [${i}] has a non-boolean fromReceipts (${JSON.stringify(op.fromReceipts)}) — it must be the literal true or false`);
459
- }
460
- if ('fromReceipts' in op && op.verb !== 'record') {
461
- throw usageFail(`batch operation [${i}] sets fromReceipts on "${op.verb}" — it applies only to record (even the literal false does not belong here)`);
462
- }
463
- });
464
- return operations;
465
- };
466
-
467
- // Apply ONE operation through the SAME single-verb code path main() dispatches (including the
468
- // --from-receipts backends[] draft for a record op). deps is threaded so a batch is as hermetic as
469
- // the single verbs.
470
- const applyOperation = (op, { cwd, env }, deps = {}) => {
471
- const { verb, fromReceipts, ...payload } = op;
472
- // The invocation's cwd/env WIN (spread last) — validateBatchEnvelope already forbids them in the
473
- // payload, so this is defense-in-depth against a direct runBatch call bypassing the preflight.
474
- if (verb === 'classify') return recordTriage({ ...payload, cwd, env }, deps);
475
- if (verb === 'override') return recordOverride({ ...payload, cwd, env }, deps);
476
- const params = { ...payload };
477
- if (fromReceipts) {
478
- const state = deps.buildState ? deps.buildState({ cwd, env }) : buildState({ cwd, env });
479
- params.backends = draftBackendsFromReceipts({ state, findings: params.findings, explicitBackends: params.backends ?? [] }, deps);
480
- }
481
- return recordRound({ ...params, cwd, env }, deps);
482
- };
483
-
484
- // runBatch({ cwd, env, operations }, deps) → { applied: [ per-op results ], count }. Pass 2: apply
485
- // sequentially, fail-fast on the first typed STOP; the ops already appended stay recorded.
486
- export const runBatch = ({ cwd = process.cwd(), env = process.env, operations }, deps = {}) => {
487
- const applied = [];
488
- for (const [i, operation] of operations.entries()) {
489
- try {
490
- applied.push(applyOperation(operation, { cwd, env }, deps));
491
- } catch (err) {
492
- // Honest partial-success (D5): the STOP itself names the failing op index + the applied count.
493
- // The ledger is append-only, so those ops are durable — resume by re-running the REMAINING ops.
494
- err.message = `${err.message} [batch stopped at operation [${i}]: ${applied.length} of ${operations.length} operation(s) recorded before the stop and durable (append-only ledger); re-run the remaining operations]`;
495
- throw err;
496
- }
497
- }
498
- return { applied, count: applied.length };
499
- };
500
-
501
- // ── CLI (record / classify / override / batch) ─────────────────────────────────────────────────────
502
-
503
- const HELP = `review-ledger-write — the review-round ledger WRITER (agent-workflow family, AD-045 + AD-047 + AD-048).
504
-
505
- Usage:
506
- node review-ledger-write.mjs record --json '<round-payload>' [--from-receipts] [--cwd <dir>]
507
- node review-ledger-write.mjs classify --json '<triage-payload>' [--cwd <dir>]
508
- node review-ledger-write.mjs override --json '<override-payload>' [--cwd <dir>]
509
- node review-ledger-write.mjs batch --json '<{ "operations": [ … ] }>' [--cwd <dir>]
510
- (every verb also accepts --json @<file> — the payload read from a file, keeping the command
511
- line PLAIN: an inline JSON argv falls outside plain-invocation allow heuristics and prompts)
512
-
513
- Every verb operates on the current SEGMENT — (loop, base = git rev-parse HEAD): round numbers,
514
- caps, and teeth reset only when a gated commit moves base (schema 4; records carry base).
515
-
516
- record appends one review round. The JSON payload carries { loop, round, origins, backends,
517
- findings } (activity defaults to plan-execution; timestamp defaults to now). With
518
- --from-receipts the backends[] is DRAFTED from the current-fingerprint grounded code
519
- receipts (verdict per backend) with counts computed from the supplied findings — origins /
520
- findings stay explicit; a recipe-named backend with no receipt is a LOUD stop (run its
521
- review, or supply it explicitly as a degraded backend). REFUSES while
522
- triage is required; beyond the hard-max ceiling of ${HARD_MAX} rounds within one segment;
523
- while a blocking finding of the segment's previous round VANISHED unclassified (D6 —
524
- classify it first, "refuted" is the honest phantom lane); while the changed source surface
525
- exceeds the ${DEFAULT_DIFF_CAP}-line diff cap (AW_REVIEW_DIFF_CAP) without a recorded
526
- segment size-cap override (D4); while the segment lacks a quality-green gate-run at the
527
- current fingerprint (D5 — run the FULL matrix first: run-gates.mjs --record; a --only
528
- subset or a tree-changed run never satisfies; red PROCESS gates never block); or when a
529
- non-degraded backend lacks an ATTESTING code receipt for the current tree (grounded AND
530
- probe:false — a probe, unmarked or malformed marker never attests).
531
- classify appends one triage record. The JSON payload carries { loop, round, classifications } (each
532
- { findingKey, class, accepted, testId, note }). A fixable-bug REQUIRES a testId — the
533
- red→green test that pins the fold, formatted "<test-file>#<test-name-pattern>" (write it
534
- first); refuted REQUIRES a non-empty note citing the refuting grounds;
535
- inherent-layer-residual / escalate may omit the testId. This is what permits the next round.
536
- override appends one override record — the LOUD, durable waiver the gates consume. The JSON payload
537
- carries { loop, round, scope, reason } plus, per scope:
538
- scope "oracle-change" → files[] (non-empty repo-relative paths whose tamper flag it lifts);
539
- scope "red-proof" → testId (the exact bound testId whose observed-red receipt + custody it
540
- waives — for a red that is GENUINELY unestablishable pre-fold, D7);
541
- scope "size-cap" → sanctionedLines (the exact changed-surface magnitude the waiver
542
- sanctions, SEGMENT-scoped — it dies at the next commit, D4).
543
- REFUSES for a loop that is not the in-flight plan. QUARANTINE (a flaky/timed-out probe)
544
- has NO override lane.
545
- batch applies an ordered list of record / classify / override operations in ONE invocation — the
546
- prompt-economy lane for a records stage (one writer call, not one per op). The payload is
547
- { "operations": [ { "verb": "record"|"classify"|"override", …that verb's payload } ] }; a
548
- record op may carry "fromReceipts": true (the SAME draft as --from-receipts). Each op runs
549
- the SAME per-verb code path (no forked validator), so a batch of N ops is record-equivalent
550
- to the same N single invocations. TWO passes: the WHOLE envelope is validated structurally
551
- first with ZERO writes (a bad envelope stops before any op runs); then ops apply sequentially
552
- and fail-fast on the first typed STOP — the ops already applied stay recorded (the ledger is
553
- append-only, no rollback), and the STOP names the failing op index + the applied count.
554
- Resume by re-running the REMAINING operations.
555
-
556
- The read-only checker is a SEPARATE tool: node review-ledger.mjs --check / --status / --json / --telemetry.
557
- Exit codes: 0 written; 1 a typed STOP (teeth / malformed / missing receipt / fs error); 2 usage.`;
558
-
559
- // The CLI subcommand set — the SINGLE source the dispatch and the doc-contract pin both read, so a
560
- // documented verb list can never lag the dispatch (Phase 2.3 — the contract-test class).
561
- export const SUBCOMMANDS = ['record', 'classify', 'override', 'batch'];
562
-
563
- const parseArgs = (argv) => {
564
- const opts = { cwd: undefined, json: undefined, fromReceipts: false };
565
- for (let i = 0; i < argv.length; i += 1) {
566
- const a = argv[i];
567
- if (a === '--cwd') {
568
- opts.cwd = argv[i + 1];
569
- if (opts.cwd === undefined) throw usageFail('--cwd needs a directory');
570
- i += 1;
571
- } else if (a === '--json') {
572
- opts.json = argv[i + 1];
573
- if (opts.json === undefined) throw usageFail('--json needs a JSON payload');
574
- // `--json @<file>` reads the payload from a file: a large inline JSON argv (quotes, braces)
575
- // falls outside every plain-invocation allow heuristic and prompts, and hand-composing it on
576
- // a command line is exactly the error-prone class --from-receipts exists to shrink — the
577
- // file form keeps the COMMAND plain while the payload stays explicit (AD-044 Plan 4).
578
- if (opts.json.startsWith('@')) {
579
- const payloadPath = opts.json.slice(1);
580
- try {
581
- opts.json = readFileSync(payloadPath, 'utf8');
582
- } catch (err) {
583
- throw usageFail(`--json @${payloadPath}: unreadable payload file (${err.code ?? err.message})`);
584
- }
585
- }
586
- i += 1;
587
- } else if (a === '--from-receipts') {
588
- opts.fromReceipts = true;
589
- } else {
590
- throw usageFail(`unknown argument: ${a}`);
591
- }
592
- }
593
- return opts;
594
- };
595
-
596
- const parsePayload = (json) => {
597
- if (json === undefined) throw usageFail('a --json payload is required');
598
- try {
599
- return JSON.parse(json);
600
- } catch (err) {
601
- throw usageFail(`--json is not valid JSON (${err.message})`);
602
- }
603
- };
604
-
605
- export const main = (argv, ctx = {}) => {
606
- const cwd0 = ctx.cwd ?? process.cwd();
607
- const env = ctx.env ?? process.env;
608
- try {
609
- if (argv.includes('--help') || argv.includes('-h') || argv.length === 0) return { code: argv.length === 0 ? 2 : 0, stdout: HELP, stderr: '' };
610
- const sub = argv[0];
611
- if (!SUBCOMMANDS.includes(sub)) throw usageFail(`unknown subcommand "${sub}" (expected: ${SUBCOMMANDS.join(' | ')})`);
612
- const opts = parseArgs(argv.slice(1));
613
- if (opts.fromReceipts && sub !== 'record') throw usageFail('--from-receipts applies only to `record` (a batch carries fromReceipts per operation)');
614
- const payload = parsePayload(opts.json);
615
- const cwd = opts.cwd ?? cwd0;
616
- if (sub === 'batch') {
617
- // Pass 1 (structural, ZERO writes) → pass 2 (sequential apply, fail-fast — prior ops durable).
618
- const operations = validateBatchEnvelope(payload);
619
- const { applied, count } = runBatch({ cwd, env, operations });
620
- const writtenPath = applied.length ? applied[applied.length - 1].writtenPath : '(none)';
621
- return { code: 0, stdout: `review-ledger-write: recorded ${count} operation(s) in one batch → ${writtenPath}`, stderr: '' };
622
- }
623
- if (opts.fromReceipts) {
624
- // Draft backends[] from the current-fingerprint receipts + the supplied findings; origins /
625
- // findings stay explicit. The drafted array then rides the normal recordRound teeth.
626
- const state = buildState({ cwd, env });
627
- payload.backends = draftBackendsFromReceipts({ state, findings: payload.findings, explicitBackends: payload.backends ?? [] });
628
- }
629
- const result =
630
- sub === 'record'
631
- ? recordRound({ ...payload, cwd, env })
632
- : sub === 'classify'
633
- ? recordTriage({ ...payload, cwd, env })
634
- : recordOverride({ ...payload, cwd, env });
635
- return { code: 0, stdout: `review-ledger-write: recorded a ${result.record.kind} for loop "${result.record.loop}" round ${result.record.round} → ${result.writtenPath}`, stderr: '' };
636
- } catch (err) {
637
- return { code: err.exitCode ?? 1, stdout: '', stderr: `review-ledger-write: ${err.message}` };
638
- }
639
- };
640
-
641
- const isDirectRun = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href;
642
- if (isDirectRun) {
643
- const r = main(process.argv.slice(2));
644
- if (r.stdout) process.stdout.write(r.stdout.endsWith('\n') ? r.stdout : `${r.stdout}\n`);
645
- if (r.stderr) process.stderr.write(r.stderr.endsWith('\n') ? r.stderr : `${r.stderr}\n`);
646
- process.exitCode = r.code;
647
- }