@sabaiway/agent-workflow-kit 1.35.0 → 1.36.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.
@@ -0,0 +1,257 @@
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
+ // Two record kinds, one JSONL ledger:
11
+ // recordRound — one review round: per-backend counts + verdict + degraded, finding-origin tally,
12
+ // findings[]. Binds to the canonical tree fingerprint. THE TEETH (Decision 5):
13
+ // REFUSES (typed STOP) while decideStop on the existing records is `triage-required`
14
+ // (an unclassified surviving blocking finding at/after the cap), and refuses ANY
15
+ // round beyond the hard-max ceiling unconditionally. Integrity binding (Decision 7):
16
+ // each NON-degraded backend needs a grounded code receipt for the current tree, so a
17
+ // round cannot be recorded for a tree no bridge reviewed.
18
+ // recordTriage — the classification that BREAKS the deadlock: each surviving blocking finding
19
+ // classified fixable-bug / inherent-layer-residual / escalate. No teeth (a triage is
20
+ // exactly what lets the next round proceed), no receipt binding (it reviews nothing).
21
+ //
22
+ // HONEST residual (stated, accepted — like review-state's): the ledger attests a review occurred; it
23
+ // does NOT prove the recorded COUNTS are truthful nor that a self-reported `degraded:true` is real.
24
+ // A self-discipline mechanism against silent process drift, not a security boundary.
25
+ //
26
+ // Dependency-free, Node >= 18. Every fs primitive is injectable (deps.*) so the guards are unit-
27
+ // testable. No side effects on import (the isDirectRun idiom).
28
+
29
+ import { readFileSync } from 'node:fs';
30
+ import { dirname } from 'node:path';
31
+ import { pathToFileURL } from 'node:url';
32
+ import { writeContainedFileAtomic } from './atomic-write.mjs';
33
+ import { computeTreeFingerprint, readReceipts, resolveReceiptsPath } from './review-state.mjs';
34
+ import {
35
+ REVIEW_CAP,
36
+ SCHEMA_VERSION,
37
+ resolveLedgerPath,
38
+ readLedger,
39
+ filterLoopRecords,
40
+ roundSequenceIntact,
41
+ decideStop,
42
+ validateRecord,
43
+ } from './review-ledger.mjs';
44
+
45
+ // The absolute WRITER ceiling (Decision 5): hard-max lives ONLY here — it is NOT a decideStop input.
46
+ // Even a fully-classified resolved-residual loop cannot reach a round beyond this.
47
+ export const HARD_MAX = 3;
48
+ const DEFAULT_ACTIVITY = 'plan-execution';
49
+
50
+ // A typed STOP — a deliberate refusal we surface (the teeth / a malformed record / a missing
51
+ // receipt), distinct from a native fs error. The codebase's typed-error idiom (no classes).
52
+ export const LEDGER_WRITE_STOP = 'LEDGER_WRITE_STOP';
53
+ const stop = (message) => Object.assign(new Error(`[agent-workflow-kit] ${message}`), { name: 'LedgerWriteStop', code: LEDGER_WRITE_STOP });
54
+
55
+ // A tagged usage failure (exit 2) for the CLI parser.
56
+ const usageFail = (message) => Object.assign(new Error(`[agent-workflow-kit] ${message}`), { exitCode: 2 });
57
+
58
+ const isoNow = () => new Date().toISOString();
59
+
60
+ // ── the append primitive (whole-file read → add one JSONL line → atomic rewrite) ────────────────
61
+ // The ledger is a git-dir JSONL, never a docs/ai file, so the append reads the current file, adds the
62
+ // new line in memory, and rewrites through the contained-write core (root = the ledger file's dir).
63
+ const appendRecord = (ledgerPath, record, deps = {}) => {
64
+ const readFile = deps.readFile ?? readFileSync;
65
+ let existing = '';
66
+ try {
67
+ existing = readFile(ledgerPath, 'utf8');
68
+ } catch (err) {
69
+ // Only a truly-absent file starts from empty. A non-ENOENT read failure (EACCES/EIO) must NOT be
70
+ // treated as "no file" — rewriting from empty would DESTROY the existing ledger (codex R1).
71
+ if (err && err.code === 'ENOENT') existing = '';
72
+ else throw stop(`cannot read the ledger before appending (${(err && err.code) || (err && err.message) || err}) — refusing to overwrite it (fail closed)`);
73
+ }
74
+ const prefix = existing === '' ? '' : existing.endsWith('\n') ? existing : `${existing}\n`;
75
+ const body = `${prefix}${JSON.stringify(record)}\n`;
76
+ const root = dirname(ledgerPath);
77
+ writeContainedFileAtomic(root, ledgerPath, body, deps, { stop, label: ledgerPath });
78
+ return { writtenPath: ledgerPath, record };
79
+ };
80
+
81
+ // ── recordRound (the teeth + the integrity binding) ─────────────────────────────────────────────
82
+
83
+ // recordRound({ cwd, env, loop, activity, round, origins, backends, findings, timestamp }, deps) →
84
+ // { writtenPath, record }. THROWS a typed STOP (the teeth / a malformed record / a missing receipt)
85
+ // or a native fs error. Every fs edge + the fingerprint is injectable for hermetic tests.
86
+ export const recordRound = (params, deps = {}) => {
87
+ const { cwd = process.cwd(), env = process.env, loop, activity = DEFAULT_ACTIVITY, round, origins, backends, findings, timestamp } = params;
88
+ const ledgerPath = deps.ledgerPath ?? resolveLedgerPath(cwd, env);
89
+ if (ledgerPath == null) throw stop('cannot resolve the ledger path — not a git work tree and AW_REVIEW_LEDGER is unset');
90
+ if (!(Number.isInteger(round) && round >= 1)) throw stop(`round must be an integer >= 1 (got ${round})`);
91
+ // The hard-max ceiling: refuse ANY round beyond it, independent of triage state (Decision 5).
92
+ if (round > HARD_MAX) {
93
+ throw stop(`refusing to record round ${round}: beyond the hard-max ceiling of ${HARD_MAX} rounds — the loop must converge or the surviving finding must escalate, never another round`);
94
+ }
95
+
96
+ const fingerprint = deps.computeFingerprint ? deps.computeFingerprint(cwd) : computeTreeFingerprint(cwd);
97
+
98
+ // The teeth: refuse a new round WHILE decideStop on the existing records is triage-required. Once
99
+ // the surviving blocking finding is classified (recordTriage), decideStop is no longer
100
+ // triage-required and the next round is permitted (a fixable-bug classification permits the fix
101
+ // round — no deadlock). triage-required is fingerprint/backend-independent, so a minimal decideStop
102
+ // suffices here.
103
+ const { records, malformed, malformedReasons, readError } = readLedger(ledgerPath, deps.readFile);
104
+ // Fail CLOSED before the teeth: a ledger the reader could not fully trust (an unreadable file, or
105
+ // malformed lines the reader dropped) could make decideStop miss a triage-required round and fail
106
+ // the teeth OPEN (codex R1). Refuse to append until the ledger is sound.
107
+ if (readError) throw stop(`cannot read the existing ledger (${readError}) — refusing to append (fail closed)`);
108
+ if (malformed > 0) throw stop(`the existing ledger has ${malformed} malformed line(s) — refusing to append until they are fixed (fail closed): ${malformedReasons.join('; ')}`);
109
+ const existingLoop = filterLoopRecords(records, { activity, loop });
110
+ // Sequence integrity + sequentiality (codex R2+R3): the EXISTING rounds must already be exactly
111
+ // 1..n (never trust a hand-corrupted [2]/[1,1]/[2,1] to compute "latest"), AND the incoming round
112
+ // must be exactly the next (n+1). A duplicate, decreasing, or gapped round would let a fabricated
113
+ // "later" round become the latest that decideStop reads, bypassing the "latest round" teeth.
114
+ const priorRounds = existingLoop.filter((r) => r.kind === 'round').map((r) => r.round);
115
+ if (!roundSequenceIntact(existingLoop)) {
116
+ throw stop(`refusing to append to loop "${loop}": its recorded round sequence is corrupt (${priorRounds.join(',') || 'empty'}, not 1..n) — fix the ledger by hand before recording another round`);
117
+ }
118
+ const nextRound = priorRounds.length + 1;
119
+ if (round !== nextRound) {
120
+ throw stop(`refusing to record round ${round}: rounds must be sequential — the next round for loop "${loop}" is ${nextRound} (a duplicate, out-of-order, or gapped round would corrupt the crossover computation)`);
121
+ }
122
+ const pre = decideStop(existingLoop, { cap: REVIEW_CAP });
123
+ if (pre.state === 'triage-required') {
124
+ 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).`);
125
+ }
126
+
127
+ const record = { schema: SCHEMA_VERSION, loop, activity, kind: 'round', round, fingerprint, origins, backends, findings, timestamp: timestamp ?? isoNow() };
128
+ const v = validateRecord(record);
129
+ if (!v.ok) throw stop(`refusing to record a malformed round: ${v.reason}`);
130
+
131
+ // Integrity binding: each NON-degraded backend needs a grounded code receipt for this tree — a
132
+ // round cannot be recorded for a tree no bridge reviewed. A degraded backend minted no receipt.
133
+ const receiptsPath = deps.receiptsPath ?? resolveReceiptsPath(cwd, env);
134
+ const { receipts } = receiptsPath ? readReceipts(receiptsPath, deps.readFile) : { receipts: [] };
135
+ for (const b of backends) {
136
+ if (b.degraded) continue;
137
+ const own = receipts.filter(
138
+ (r) => r.backend === b.backend && r.fingerprint === fingerprint && r.artifact === 'code' && r.fresh === true && r.grounded === true,
139
+ );
140
+ if (own.length === 0) {
141
+ throw stop(`refusing to record a round for ${b.backend}: no grounded code receipt for the current tree — run its review wrapper (codex-review code / agy-review code --facts @f) first, or mark the backend degraded with a reason`);
142
+ }
143
+ }
144
+
145
+ return appendRecord(ledgerPath, record, deps);
146
+ };
147
+
148
+ // ── recordTriage (the deadlock-breaker — no teeth, no receipt binding) ──────────────────────────
149
+
150
+ // recordTriage({ cwd, env, loop, activity, round, classifications, timestamp }, deps) →
151
+ // { writtenPath, record }. Appends the classification of the round's surviving blocking findings.
152
+ export const recordTriage = (params, deps = {}) => {
153
+ const { cwd = process.cwd(), env = process.env, loop, activity = DEFAULT_ACTIVITY, round, classifications, timestamp } = params;
154
+ const ledgerPath = deps.ledgerPath ?? resolveLedgerPath(cwd, env);
155
+ if (ledgerPath == null) throw stop('cannot resolve the ledger path — not a git work tree and AW_REVIEW_LEDGER is unset');
156
+ if (!(Number.isInteger(round) && round >= 1)) throw stop(`round must be an integer >= 1 (got ${round})`);
157
+ const fingerprint = deps.computeFingerprint ? deps.computeFingerprint(cwd) : computeTreeFingerprint(cwd);
158
+ // Normalize each classification: testId "defaults null" (Decision 8), note defaults to '' — an
159
+ // absent optional field is FILLED, never rejected as malformed (agy R3). A non-array is left as-is
160
+ // for validateRecord to reject with a typed STOP (never a raw .map TypeError).
161
+ const normalized = Array.isArray(classifications) ? classifications.map((c) => ({ ...c, testId: c?.testId ?? null, note: c?.note ?? '' })) : classifications;
162
+ const record = { schema: SCHEMA_VERSION, loop, activity, kind: 'triage', round, fingerprint, classifications: normalized, timestamp: timestamp ?? isoNow() };
163
+ const v = validateRecord(record);
164
+ if (!v.ok) throw stop(`refusing to record a malformed triage: ${v.reason}`);
165
+
166
+ // Bind the triage to a REAL round: the referenced round must exist and every classified findingKey
167
+ // must be a surviving blocking finding (blocker or major) of THAT round — a classification for a
168
+ // nonexistent/future round, or for a key the round never raised, must not satisfy resolved-residual
169
+ // downstream (codex R1). Fail CLOSED on an unreadable / malformed ledger.
170
+ const { records, malformed, malformedReasons, readError } = readLedger(ledgerPath, deps.readFile);
171
+ if (readError) throw stop(`cannot read the existing ledger (${readError}) — refusing to append (fail closed)`);
172
+ if (malformed > 0) throw stop(`the existing ledger has ${malformed} malformed line(s) — refusing to append until they are fixed (fail closed): ${malformedReasons.join('; ')}`);
173
+ const loopRecords = filterLoopRecords(records, { activity, loop });
174
+ if (!roundSequenceIntact(loopRecords)) throw stop(`refusing to classify loop "${loop}": its recorded round sequence is corrupt (not 1..n) — fix the ledger by hand first`);
175
+ const targetRound = loopRecords.find((r) => r.kind === 'round' && r.round === round);
176
+ if (!targetRound) throw stop(`refusing to classify round ${round} of loop "${loop}": no such recorded round — classify a round that exists`);
177
+ const survivingKeys = new Set(targetRound.findings.filter((f) => f.severity === 'blocker' || f.severity === 'major').map((f) => f.findingKey));
178
+ for (const c of classifications) {
179
+ 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)`);
180
+ }
181
+
182
+ return appendRecord(ledgerPath, record, deps);
183
+ };
184
+
185
+ // ── CLI (record / classify) ──────────────────────────────────────────────────────────────────────
186
+
187
+ const HELP = `review-ledger-write — the review-round ledger WRITER (agent-workflow family, AD-045).
188
+
189
+ Usage:
190
+ node review-ledger-write.mjs record --json '<round-payload>' [--cwd <dir>]
191
+ node review-ledger-write.mjs classify --json '<triage-payload>' [--cwd <dir>]
192
+
193
+ record appends one review round. The JSON payload carries { loop, round, origins, backends,
194
+ findings } (activity defaults to plan-execution; timestamp defaults to now). REFUSES while
195
+ triage is required, beyond the hard-max ceiling of ${HARD_MAX} rounds, or when a non-degraded
196
+ backend lacks a grounded code receipt for the current tree.
197
+ classify appends one triage record. The JSON payload carries { loop, round, classifications } (each
198
+ { findingKey, class, accepted, testId, note }). This is what permits the next round.
199
+
200
+ The read-only checker is a SEPARATE tool: node review-ledger.mjs --check / --status / --json.
201
+ Exit codes: 0 written; 1 a typed STOP (teeth / malformed / missing receipt / fs error); 2 usage.`;
202
+
203
+ const parseArgs = (argv) => {
204
+ const opts = { cwd: undefined, json: undefined };
205
+ for (let i = 0; i < argv.length; i += 1) {
206
+ const a = argv[i];
207
+ if (a === '--cwd') {
208
+ opts.cwd = argv[i + 1];
209
+ if (opts.cwd === undefined) throw usageFail('--cwd needs a directory');
210
+ i += 1;
211
+ } else if (a === '--json') {
212
+ opts.json = argv[i + 1];
213
+ if (opts.json === undefined) throw usageFail('--json needs a JSON payload');
214
+ i += 1;
215
+ } else {
216
+ throw usageFail(`unknown argument: ${a}`);
217
+ }
218
+ }
219
+ return opts;
220
+ };
221
+
222
+ const parsePayload = (json) => {
223
+ if (json === undefined) throw usageFail('a --json payload is required');
224
+ try {
225
+ return JSON.parse(json);
226
+ } catch (err) {
227
+ throw usageFail(`--json is not valid JSON (${err.message})`);
228
+ }
229
+ };
230
+
231
+ export const main = (argv, ctx = {}) => {
232
+ const cwd0 = ctx.cwd ?? process.cwd();
233
+ const env = ctx.env ?? process.env;
234
+ try {
235
+ if (argv.includes('--help') || argv.includes('-h') || argv.length === 0) return { code: argv.length === 0 ? 2 : 0, stdout: HELP, stderr: '' };
236
+ const sub = argv[0];
237
+ if (sub !== 'record' && sub !== 'classify') throw usageFail(`unknown subcommand "${sub}" (expected: record | classify)`);
238
+ const opts = parseArgs(argv.slice(1));
239
+ const payload = parsePayload(opts.json);
240
+ const cwd = opts.cwd ?? cwd0;
241
+ const result =
242
+ sub === 'record'
243
+ ? recordRound({ cwd, env, ...payload })
244
+ : recordTriage({ cwd, env, ...payload });
245
+ return { code: 0, stdout: `review-ledger-write: recorded a ${result.record.kind} for loop "${result.record.loop}" round ${result.record.round} → ${result.writtenPath}`, stderr: '' };
246
+ } catch (err) {
247
+ return { code: err.exitCode ?? 1, stdout: '', stderr: `review-ledger-write: ${err.message}` };
248
+ }
249
+ };
250
+
251
+ const isDirectRun = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href;
252
+ if (isDirectRun) {
253
+ const r = main(process.argv.slice(2));
254
+ if (r.stdout) process.stdout.write(r.stdout.endsWith('\n') ? r.stdout : `${r.stdout}\n`);
255
+ if (r.stderr) process.stderr.write(r.stderr.endsWith('\n') ? r.stderr : `${r.stderr}\n`);
256
+ process.exitCode = r.code;
257
+ }