@sabaiway/agent-workflow-kit 1.39.0 → 1.41.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.
@@ -50,8 +50,6 @@
50
50
  // `git` queries (via the reused review-state fingerprint reader + a git-dir resolver). Dependency-
51
51
  // free, Node >= 18. No side effects on import (the isDirectRun idiom).
52
52
 
53
- import { readFileSync } from 'node:fs';
54
- import { join } from 'node:path';
55
53
  import { pathToFileURL } from 'node:url';
56
54
  import { spawnSync } from 'node:child_process';
57
55
  import { detectBackends } from './detect-backends.mjs';
@@ -71,48 +69,43 @@ import {
71
69
  // module — never through fold-completeness.mjs, which imports THIS module (the import-cycle
72
70
  // invariant, codex R2; an import-split test pins it). probeVerdict is the one D4 algebra home.
73
71
  import { probeVerdict, resolveResultsPath, readJsonlRows } from './changed-surface.mjs';
72
+ // The NEUTRAL ledger read/schema core (AD-050): the validated read path — path/base resolvers,
73
+ // schema validator + reader, loop/segment filters — extracted so review-state.mjs can read the
74
+ // ledger for its degraded exemption WITHOUT importing this module back (the cycle). Imported here
75
+ // for internal use AND re-exported below so every existing importer (grounding, fold-completeness
76
+ // [-run], review-ledger-write, doc-parity, the tests) resolves unchanged.
77
+ import {
78
+ LEDGER_BASENAME,
79
+ ORIGINS,
80
+ resolveLedgerPath,
81
+ resolveBase,
82
+ readLedger,
83
+ filterLoopRecords,
84
+ filterSegmentRecords,
85
+ roundSequenceIntact,
86
+ } from './review-ledger-core.mjs';
87
+ export {
88
+ LEDGER_BASENAME,
89
+ ORIGINS,
90
+ resolveLedgerPath,
91
+ resolveBase,
92
+ readLedger,
93
+ filterLoopRecords,
94
+ filterSegmentRecords,
95
+ roundSequenceIntact,
96
+ };
97
+ // Re-export-only (validated in the core, unused internally here): the schema/vocabulary + testId
98
+ // format the writer, doc-parity, and fold-completeness import through this module.
99
+ export { SCHEMA_VERSION, V4_CLASSES, V4_OVERRIDE_SCOPES, isWellFormedTestId, splitTestId, validateRecord } from './review-ledger-core.mjs';
74
100
 
75
- export const LEDGER_BASENAME = 'agent-workflow-review-ledger.jsonl';
76
101
  const ACTIVITY = 'plan-execution';
77
102
  const SLOT = 'review';
78
103
  // The triage TRIGGER cap (Decision 5): reaching it with an unclassified surviving blocking finding
79
104
  // forces triage. Shared with the writer (which imports it) — the writer-only hard-max ceiling lives
80
105
  // there, never here (it is not a decideStop input).
81
106
  export const REVIEW_CAP = 2;
82
- // SCHEMA_VERSION is what the WRITER emits (M2/AD-046: a fixable-bug triage requires a non-null,
83
- // well-formed testId — the red→green test that pins the fold; BUGFREE-1/AD-047: v3 adds the
84
- // `override` record kind — the loud, durable waiver the fold-completeness gate consumes;
85
- // BUGFREE-2/AD-048: v4 adds the SEGMENT — every record carries `base` = the commit the dirty tree
86
- // sits on, and round numbering / caps / teeth / decideCheck all operate per (activity, loop, base) —
87
- // plus the kind `gate-run` (the D5 green-baseline receipt run-gates --record mints), the override
88
- // scope `size-cap` (the D4 diff-cap waiver), and the triage class `refuted` (the D6 honest lane for
89
- // a phantom finding). The READER tolerates every SUPPORTED_SCHEMAS version under its own
90
- // per-version rules, so historical/live v1..v3 ledgers never retroactively become malformed
91
- // (Decision 2 — a malformed line cascades fail-closed refusals in the writer teeth AND the --check
92
- // gate). v1 records keep the AD-045 rule (testId optional, unenforced); v2+ enforces the
93
- // test-per-fold binding; ONLY v3+ may carry kind `override`; ONLY v4 may carry `base` / kind
94
- // `gate-run` / scope `size-cap` / class `refuted` (older records never grow new surface).
95
- // decideStop never reads testId, overrides, gate-runs, or base (not decideStop inputs — the caller
96
- // passes ONE segment's records, exactly as it passes one loop's; D10: the truth table is untouched).
97
- export const SCHEMA_VERSION = 4;
98
- const SUPPORTED_SCHEMAS = new Set([1, 2, 3, 4]);
99
-
100
- // The record vocabulary — the single home of every enum the schema validates.
101
- const ACTIVITIES_SET = new Set(['plan-authoring', 'plan-execution']);
102
- const KINDS_SET = new Set(['round', 'triage']);
103
- const SEVERITIES = new Set(['blocker', 'major', 'minor']);
104
- export const ORIGINS = ['first-draft', 'fold-induced', 'mechanics'];
105
- const CLASSES = new Set(['fixable-bug', 'inherent-layer-residual', 'escalate']);
106
- // v4 (BUGFREE-2 / D6): `refuted` — the honest lane for a phantom finding, refuted against code with
107
- // a MANDATORY non-empty note citing the grounds; never silently dropped, never folded.
108
- const V4_CLASSES = new Set([...CLASSES, 'refuted']);
109
- const OVERRIDE_SCOPES = new Set(['oracle-change', 'red-proof']);
110
- // v4 (BUGFREE-2 / D4): `size-cap` — the recorded waiver for a changed surface beyond the diff cap;
111
- // exact payload carries the sanctioned magnitude, and it is SEGMENT-scoped (loop + base), unlike
112
- // the two loop-scoped v3 scopes.
113
- const V4_OVERRIDE_SCOPES = new Set([...OVERRIDE_SCOPES, 'size-cap']);
114
-
115
- // ── git-dir resolution (read-only queries; the ledger lives in the git dir, uncommittable) ──────
107
+
108
+ // ── git-dir resolution (read-only queries) ──────────────────────────────────────────────────────
116
109
 
117
110
  const gitLine = (args, cwd) => {
118
111
  const r = spawnSync('git', args, { cwd, windowsHide: true });
@@ -122,25 +115,6 @@ const gitLine = (args, cwd) => {
122
115
 
123
116
  const gitRoot = (cwd) => gitLine(['rev-parse', '--show-toplevel'], cwd);
124
117
 
125
- // The ledger path: AW_REVIEW_LEDGER overrides (mirrors AW_REVIEW_RECEIPTS); else <git dir>/basename.
126
- // null when the cwd is not a git work tree (no git dir to anchor to).
127
- export const resolveLedgerPath = (cwd, env = process.env) => {
128
- if (env.AW_REVIEW_LEDGER) return env.AW_REVIEW_LEDGER;
129
- const gitDir = gitLine(['rev-parse', '--absolute-git-dir'], cwd);
130
- return gitDir == null ? null : join(gitDir, LEDGER_BASENAME);
131
- };
132
-
133
- // ── the segment (BUGFREE-2 / AD-048, D1) ─────────────────────────────────────────────────────────
134
- // A SEGMENT = (activity, loop, base) where base = the commit the dirty tree sits on. Derived, never
135
- // declared: `git rev-parse HEAD` is computed identically at write time and check time, matches the
136
- // review's actual domain (the working-tree diff vs HEAD), and its reset is commit-gated — so
137
- // resetting the round counter REQUIRES shipping a green, converged unit. An amend/rebase mid-loop
138
- // orphans the segment's rounds — correct: the reviewed tree no longer exists.
139
-
140
- // resolveBase(cwd) → the current HEAD commit sha, or null on an unborn branch / outside a git work
141
- // tree (a caught refusal from git, never a crash — agy R1).
142
- export const resolveBase = (cwd) => gitLine(['rev-parse', '--verify', '--quiet', 'HEAD'], cwd);
143
-
144
118
  // ── ship-verdict mapping (the single home; a named test pins it) ────────────────────────────────
145
119
 
146
120
  // isShipVerdict(verdict) — which free-text review verdicts are ship-class. SHIP / SHIP WITH NITS are
@@ -150,191 +124,6 @@ export const isShipVerdict = (verdict) => {
150
124
  return v === 'ship' || v === 'ship with nits';
151
125
  };
152
126
 
153
- // ── schema validation (tolerant reader counts + surfaces malformed lines, never drops silently) ──
154
-
155
- const isPlainObject = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
156
- const isNonEmptyString = (v) => typeof v === 'string' && v.length > 0;
157
- const isNonNegInt = (v) => Number.isInteger(v) && v >= 0;
158
-
159
- // testId FORMAT (Decision 3): "<repo-relative test file>#<test-name-pattern>" — a "#" separator with
160
- // BOTH halves non-empty. NO file-suffix rule: a suffix check would itself be a special case and would
161
- // block a consumer's own naming (e.g. `.spec.js`; agy R1). The reader validates FORMAT only (it stays
162
- // hermetic); the fold-completeness gate validates RESOLVABILITY via a bound-test probe run. Exported
163
- // (with the splitter) as the single home of the format — the fold-completeness pair validates and
164
- // splits testIds through THESE, so the format can never fork (BUGFREE-1 / AD-047).
165
- const TESTID_SEPARATOR = '#';
166
- export const isWellFormedTestId = (v) => {
167
- if (typeof v !== 'string') return false;
168
- const at = v.indexOf(TESTID_SEPARATOR);
169
- return at > 0 && at < v.length - 1; // separator present, both halves non-empty
170
- };
171
- export const splitTestId = (v) => {
172
- const at = v.indexOf(TESTID_SEPARATOR);
173
- return { file: v.slice(0, at), pattern: v.slice(at + 1) };
174
- };
175
-
176
- // validateRound(obj) → { ok, reason }. Structural checks + the two internal-consistency invariants:
177
- // the per-backend findings-by-severity equal that backend's counts, and the origins tally equals the
178
- // aggregation of findings[].origin.
179
- const validateRound = (obj) => {
180
- if (!isPlainObject(obj.origins)) return { ok: false, reason: 'round: missing origins object' };
181
- for (const k of ORIGINS) if (!isNonNegInt(obj.origins[k])) return { ok: false, reason: `round: origins.${k} must be a non-negative integer` };
182
- if (!Array.isArray(obj.backends) || obj.backends.length === 0) return { ok: false, reason: 'round: backends must be a non-empty array' };
183
- for (const b of obj.backends) {
184
- if (!isPlainObject(b) || !isNonEmptyString(b.backend)) return { ok: false, reason: 'round: each backend needs a backend name' };
185
- if (typeof b.degraded !== 'boolean') return { ok: false, reason: `round: backend ${b.backend} missing boolean degraded` };
186
- if (!isNonNegInt(b.blockers) || !isNonNegInt(b.majors) || !isNonNegInt(b.minors)) return { ok: false, reason: `round: backend ${b.backend} counts must be non-negative integers` };
187
- if (!isNonEmptyString(b.verdict)) return { ok: false, reason: `round: backend ${b.backend} missing verdict` };
188
- // A degraded backend ran no real review: it MUST carry a reason, record 0/0/0 counts, and its
189
- // verdict is exactly "degraded". Without this a degraded row could carry a blocking finding while
190
- // convergence (which excludes degraded backends) still passes — a hidden-blocker hole (codex R1).
191
- if (b.degraded === true) {
192
- if (!isNonEmptyString(b.reason)) return { ok: false, reason: `round: degraded backend ${b.backend} must carry a reason` };
193
- if (b.blockers !== 0 || b.majors !== 0 || b.minors !== 0) return { ok: false, reason: `round: degraded backend ${b.backend} must record 0/0/0 counts (it ran no real review)` };
194
- if (b.verdict !== 'degraded') return { ok: false, reason: `round: degraded backend ${b.backend} verdict must be "degraded"` };
195
- }
196
- }
197
- // Duplicate backend names would make entryFor / the per-backend consistency ambiguous (agy R1).
198
- if (new Set(obj.backends.map((b) => b.backend)).size !== obj.backends.length) return { ok: false, reason: 'round: duplicate backend name in backends[]' };
199
- if (!Array.isArray(obj.findings)) return { ok: false, reason: 'round: findings must be an array' };
200
- const backendSet = new Set(obj.backends.map((b) => b.backend));
201
- const degradedSet = new Set(obj.backends.filter((b) => b.degraded === true).map((b) => b.backend));
202
- for (const f of obj.findings) {
203
- if (!isPlainObject(f) || !isNonEmptyString(f.findingKey)) return { ok: false, reason: 'round: each finding needs a findingKey' };
204
- if (!SEVERITIES.has(f.severity)) return { ok: false, reason: `round: finding ${f.findingKey} bad severity "${f.severity}"` };
205
- if (!ORIGINS.includes(f.origin)) return { ok: false, reason: `round: finding ${f.findingKey} bad origin "${f.origin}"` };
206
- if (!isNonEmptyString(f.backend)) return { ok: false, reason: `round: finding ${f.findingKey} missing backend` };
207
- if (!backendSet.has(f.backend)) return { ok: false, reason: `round: finding ${f.findingKey} backend "${f.backend}" is not in backends[]` };
208
- if (degradedSet.has(f.backend)) return { ok: false, reason: `round: finding ${f.findingKey} references degraded backend ${f.backend} (a degraded backend ran no review, mints no finding)` };
209
- }
210
- // Internal consistency: per-backend findings-by-severity equal the recorded counts.
211
- for (const b of obj.backends) {
212
- const own = { blocker: 0, major: 0, minor: 0 };
213
- for (const f of obj.findings) if (f.backend === b.backend) own[f.severity] += 1;
214
- if (own.blocker !== b.blockers || own.major !== b.majors || own.minor !== b.minors) {
215
- return { ok: false, reason: `round: findings-vs-counts mismatch for ${b.backend} (findings ${own.blocker}/${own.major}/${own.minor} ≠ counts ${b.blockers}/${b.majors}/${b.minors})` };
216
- }
217
- }
218
- // Internal consistency: the origins tally equals the aggregation of findings[].origin.
219
- const tally = { 'first-draft': 0, 'fold-induced': 0, mechanics: 0 };
220
- for (const f of obj.findings) tally[f.origin] += 1;
221
- for (const k of ORIGINS) if (tally[k] !== obj.origins[k]) return { ok: false, reason: `round: origins-vs-findings mismatch for "${k}" (findings ${tally[k]} ≠ origins ${obj.origins[k]})` };
222
- return { ok: true };
223
- };
224
-
225
- // validateTriage(obj, schema) → { ok, reason }. `schema` selects the per-version rules (v1 tolerant
226
- // testId / v2 the test-per-fold binding / v4 the `refuted` class) — the shared structural checks
227
- // run in every version.
228
- const validateTriage = (obj, schema = SCHEMA_VERSION) => {
229
- if (!Array.isArray(obj.classifications) || obj.classifications.length === 0) return { ok: false, reason: 'triage: classifications must be a non-empty array' };
230
- const classes = schema >= 4 ? V4_CLASSES : CLASSES;
231
- for (const c of obj.classifications) {
232
- if (!isPlainObject(c) || !isNonEmptyString(c.findingKey)) return { ok: false, reason: 'triage: each classification needs a findingKey' };
233
- if (!classes.has(c.class)) return { ok: false, reason: `triage: classification ${c.findingKey} bad class "${c.class}"` };
234
- // D6 — `refuted` is the honest phantom-finding lane: the grounds are MANDATORY (a non-empty
235
- // note citing what refutes it against code), never a silent drop.
236
- if (c.class === 'refuted' && !isNonEmptyString(c.note)) return { ok: false, reason: `triage: classification ${c.findingKey} is refuted but carries no note — cite the grounds that refute it against code (mandatory)` };
237
- if (typeof c.accepted !== 'boolean') return { ok: false, reason: `triage: classification ${c.findingKey} missing boolean accepted` };
238
- // Structural (BOTH versions): testId is null/absent or a non-empty string — an ABSENT key is
239
- // treated as null, never rejected here (agy R3). The writer normalizes it to null when stored.
240
- if (!(c.testId === undefined || c.testId === null || isNonEmptyString(c.testId))) return { ok: false, reason: `triage: classification ${c.findingKey} testId must be null/absent or a non-empty string` };
241
- // Schema v2 (M2/AD-046) — the test-per-fold binding: a fixable-bug MUST carry a testId (the
242
- // red→green test that pins the fold), and ANY present testId must be well-formed. v1 keeps the
243
- // AD-045 rule (testId optional, unenforced) so historical/live v1 ledgers never become malformed.
244
- if (schema >= 2) {
245
- const present = isNonEmptyString(c.testId);
246
- if (c.class === 'fixable-bug' && !present) return { ok: false, reason: `triage: classification ${c.findingKey} is a fixable-bug but carries no testId — record the red→green test that pins the fold (write it first)` };
247
- if (present && !isWellFormedTestId(c.testId)) return { ok: false, reason: `triage: classification ${c.findingKey} testId "${c.testId}" is malformed — expected "<test-file>#<test-name-pattern>" (a "#" separator, both halves non-empty)` };
248
- }
249
- if (typeof c.note !== 'string') return { ok: false, reason: `triage: classification ${c.findingKey} note must be a string` };
250
- }
251
- return { ok: true };
252
- };
253
-
254
- // validateOverride(obj, schema) → { ok, reason }. v3+ (BUGFREE-1 / AD-047, D3): scope `oracle-change`
255
- // carries non-empty repo-relative files[] + reason; scope `red-proof` carries a REQUIRED
256
- // well-formed testId + reason, no files[]. v4 (BUGFREE-2 / D4) adds scope `size-cap`: a REQUIRED
257
- // positive-integer sanctionedLines — the exact magnitude the waiver sanctions, segment-scoped.
258
- // Payloads are EXACT — a stray cross-scope field is a forgery smell, rejected. The fingerprint is
259
- // recorded for audit only.
260
- const OVERRIDE_SHARED_KEYS = new Set(['schema', 'loop', 'activity', 'kind', 'round', 'base', 'fingerprint', 'timestamp', 'scope', 'reason']);
261
- const OVERRIDE_PAYLOAD_KEY = { 'oracle-change': 'files', 'red-proof': 'testId', 'size-cap': 'sanctionedLines' };
262
- const validateOverride = (obj, schema = SCHEMA_VERSION) => {
263
- const scopes = schema >= 4 ? V4_OVERRIDE_SCOPES : OVERRIDE_SCOPES;
264
- if (!scopes.has(obj.scope)) return { ok: false, reason: `override: bad scope "${obj.scope}" (expected ${[...scopes].join(' | ')})` };
265
- // EXACT per-scope payloads via an allow-list (codex R5): a stray key — a cross-scope field or an
266
- // arbitrary hand-added one — is a forgery smell, rejected by name (the mutation-shape precedent).
267
- const payloadKey = OVERRIDE_PAYLOAD_KEY[obj.scope];
268
- for (const k of Object.keys(obj)) {
269
- if (!OVERRIDE_SHARED_KEYS.has(k) && k !== payloadKey) return { ok: false, reason: `override: unknown key "${k}" (exact per-scope payloads: shared frame + ${payloadKey})` };
270
- }
271
- if (!isNonEmptyString(obj.reason)) return { ok: false, reason: 'override: a non-empty reason is required (never a silent waiver)' };
272
- if (obj.scope === 'oracle-change') {
273
- if (!Array.isArray(obj.files) || obj.files.length === 0) return { ok: false, reason: 'override: oracle-change files[] must be a non-empty array' };
274
- for (const f of obj.files) {
275
- if (!isNonEmptyString(f) || f.startsWith('/') || /^[a-zA-Z]:[\\/]/.test(f)) return { ok: false, reason: `override: files[] entries must be non-empty repo-relative paths (got ${JSON.stringify(f)})` };
276
- }
277
- return { ok: true };
278
- }
279
- if (obj.scope === 'size-cap') {
280
- if (!(Number.isInteger(obj.sanctionedLines) && obj.sanctionedLines >= 1)) return { ok: false, reason: 'override: a size-cap override requires sanctionedLines — the exact positive-integer magnitude it sanctions' };
281
- return { ok: true };
282
- }
283
- if (!isWellFormedTestId(obj.testId)) return { ok: false, reason: 'override: a red-proof override requires a well-formed testId "<test-file>#<test-name-pattern>"' };
284
- return { ok: true };
285
- };
286
-
287
- // ── the gate-run record (BUGFREE-2 / D5): the green-baseline receipt run-gates --record mints ────
288
-
289
- // Per-kind frame rule: a gate-run carries the SEGMENT frame (loop, base, fingerprint before/after,
290
- // timestamp) and NO round number. The body is machine-composed by recordGateRun, so the key set is
291
- // EXACT (the override allow-list precedent): declared[] = the FULL declaration at run time (id +
292
- // cmd — the cmd is what the process-gate classification reads); results[] = what actually ran (a
293
- // --only subset records exactly that subset, honestly); summary mirrors the runner's machine
294
- // summary line, consistency-checked against results[] so a forged verdict cannot ride beside
295
- // honest-looking evidence.
296
- const GATE_RUN_KEYS = new Set(['schema', 'loop', 'activity', 'kind', 'base', 'fingerprint', 'fingerprintAfter', 'declared', 'results', 'summary', 'timestamp']);
297
- const SUMMARY_KEYS = ['failed', 'failedIds', 'gates', 'passed', 'status'];
298
- // Gate ids are kebab-case — the same closed shape run-gates.mjs enforces on the declaration; it
299
- // also kills the comma-aliasing class in the failedIds compare (internal sweep).
300
- const GATE_RUN_ID_RE = /^[a-z0-9]+(-[a-z0-9]+)*$/;
301
- const validateGateRun = (obj) => {
302
- for (const k of Object.keys(obj)) {
303
- if (!GATE_RUN_KEYS.has(k)) return { ok: false, reason: `gate-run: unknown key "${k}" (exact machine-composed payload)` };
304
- }
305
- if (!(obj.fingerprintAfter === null || isNonEmptyString(obj.fingerprintAfter))) return { ok: false, reason: 'gate-run: fingerprintAfter must be null or a non-empty string (the post-run tree)' };
306
- if (!Array.isArray(obj.declared) || obj.declared.length === 0) return { ok: false, reason: 'gate-run: declared must be a non-empty array of { id, cmd }' };
307
- const declaredIds = new Set();
308
- for (const d of obj.declared) {
309
- if (!isPlainObject(d) || !isNonEmptyString(d.id) || !isNonEmptyString(d.cmd) || Object.keys(d).length !== 2) return { ok: false, reason: 'gate-run: each declared entry must be exactly { id, cmd } (non-empty strings)' };
310
- if (!GATE_RUN_ID_RE.test(d.id)) return { ok: false, reason: `gate-run: id "${d.id}" must be kebab-case (the run-gates declaration shape)` };
311
- if (declaredIds.has(d.id)) return { ok: false, reason: `gate-run: duplicate declared id "${d.id}"` };
312
- declaredIds.add(d.id);
313
- }
314
- if (!Array.isArray(obj.results) || obj.results.length === 0) return { ok: false, reason: 'gate-run: results must be a non-empty array of { id, ok, code }' };
315
- const resultIds = new Set();
316
- for (const r of obj.results) {
317
- if (!isPlainObject(r) || !isNonEmptyString(r.id) || typeof r.ok !== 'boolean' || !Number.isInteger(r.code) || Object.keys(r).length !== 3) return { ok: false, reason: 'gate-run: each result must be exactly { id, ok, code } (string, boolean, integer)' };
318
- if (!declaredIds.has(r.id)) return { ok: false, reason: `gate-run: result id "${r.id}" is not in declared[]` };
319
- if (resultIds.has(r.id)) return { ok: false, reason: `gate-run: duplicate result id "${r.id}"` };
320
- resultIds.add(r.id);
321
- }
322
- const s = obj.summary;
323
- if (!isPlainObject(s)) return { ok: false, reason: 'gate-run: summary must be an object' };
324
- if (Object.keys(s).sort().join(',') !== SUMMARY_KEYS.join(',')) return { ok: false, reason: `gate-run: summary must carry exactly { ${SUMMARY_KEYS.join(', ')} }` };
325
- const failing = obj.results.filter((r) => !r.ok);
326
- // The status IS the verdict word: a valid gate-run always carries results, so the runner's
327
- // vocabulary collapses to ok|fail here — tie it to the failing count (a forged "ok" beside red
328
- // results must not validate; internal sweep).
329
- const expectedStatus = failing.length === 0 ? 'ok' : 'fail';
330
- if (s.status !== expectedStatus) return { ok: false, reason: `gate-run: summary.status must be "${expectedStatus}" for ${failing.length} failing result(s) (got ${JSON.stringify(s.status)})` };
331
- if (s.gates !== obj.results.length || s.passed !== obj.results.length - failing.length || s.failed !== failing.length) {
332
- return { ok: false, reason: `gate-run: summary counts do not match results (${s.gates}/${s.passed}/${s.failed} vs ${obj.results.length} results, ${failing.length} failing)` };
333
- }
334
- if (!Array.isArray(s.failedIds) || s.failedIds.length !== failing.length || !s.failedIds.every((id, i) => id === failing[i].id)) return { ok: false, reason: 'gate-run: summary.failedIds must equal the failing result ids, in results order' };
335
- return { ok: true };
336
- };
337
-
338
127
  // isProcessGateCmd(cmd) — the CLOSED, kit-owned process-gate classification (D5): the kit's own
339
128
  // process-loop `--check` commands (review-state / review-ledger / fold-completeness) legitimately
340
129
  // fail MID-loop, so "quality-green" excludes them — without the carve-out the D5 tooth is
@@ -361,83 +150,6 @@ export const isQualityGreenGateRun = (record) => {
361
150
  return record.declared.every((d) => isProcessGateCmd(d.cmd) || green.has(d.id));
362
151
  };
363
152
 
364
- // validateRecord(obj) → { ok, reason }. The shared frame (schema/loop/activity/kind/round/base/
365
- // fingerprint/timestamp) then the per-kind body. `reason` names the exact failed check so the
366
- // malformed-line surface and the per-check named tests can assert it. Kind vocabulary is
367
- // per-version: `override` exists only under schema >= 3, `gate-run` only under schema >= 4, and
368
- // `base` is a v4-only frame field (older records never grow new kinds OR new fields — D2).
369
- export const validateRecord = (obj) => {
370
- if (!isPlainObject(obj)) return { ok: false, reason: 'not an object' };
371
- if (!SUPPORTED_SCHEMAS.has(obj.schema)) return { ok: false, reason: `schema must be one of ${[...SUPPORTED_SCHEMAS].join(', ')}` };
372
- if (!isNonEmptyString(obj.loop)) return { ok: false, reason: 'missing loop' };
373
- if (!ACTIVITIES_SET.has(obj.activity)) return { ok: false, reason: `bad activity "${obj.activity}"` };
374
- if (!KINDS_SET.has(obj.kind) && !(obj.schema >= 3 && obj.kind === 'override') && !(obj.schema >= 4 && obj.kind === 'gate-run')) return { ok: false, reason: `bad kind "${obj.kind}"` };
375
- // The v4 SEGMENT frame (D1/D2): a v4 record REQUIRES base (null on an unborn branch, else the
376
- // HEAD sha); a v1..v3 record must NOT carry it — an old record never grows new surface.
377
- if (obj.schema >= 4) {
378
- if (!(obj.base === null || isNonEmptyString(obj.base))) return { ok: false, reason: 'a v4 record requires base — null (unborn branch) or the HEAD commit the dirty tree sits on' };
379
- } else if (obj.base !== undefined) {
380
- return { ok: false, reason: `base is a v4 frame field — a schema-${obj.schema} record never carries it` };
381
- }
382
- // Per-kind frame (D5): a gate-run carries NO round number; every other kind requires one.
383
- if (obj.kind === 'gate-run') {
384
- if (obj.round !== undefined) return { ok: false, reason: 'gate-run: a gate-run carries no round number (it is segment-framed, not round-framed)' };
385
- } else if (!(Number.isInteger(obj.round) && obj.round >= 1)) {
386
- return { ok: false, reason: 'round must be an integer >= 1' };
387
- }
388
- if (!(obj.fingerprint === null || isNonEmptyString(obj.fingerprint))) return { ok: false, reason: 'fingerprint must be null or a non-empty string' };
389
- if (!isNonEmptyString(obj.timestamp)) return { ok: false, reason: 'missing timestamp' };
390
- if (obj.kind === 'round') return validateRound(obj);
391
- if (obj.kind === 'override') return validateOverride(obj, obj.schema);
392
- if (obj.kind === 'gate-run') return validateGateRun(obj);
393
- return validateTriage(obj, obj.schema);
394
- };
395
-
396
- // readLedger(path) → { records, malformed, malformedReasons }. Absent file → empty (no review ran).
397
- // A malformed line is counted + its reason surfaced, never silently dropped (mirrors readReceipts).
398
- export const readLedger = (path, readFile = readFileSync) => {
399
- let raw;
400
- try {
401
- raw = readFile(path, 'utf8');
402
- } catch (err) {
403
- // An ABSENT file → empty (no review ran). A non-ENOENT read error (EACCES/EIO) is NOT "no records":
404
- // treating it as empty would fail the teeth OPEN and could clobber the ledger on rewrite (codex R1).
405
- // Surface it as a readError so every caller (the writer, the gate) fails CLOSED.
406
- if (err && err.code === 'ENOENT') return { records: [], malformed: 0, malformedReasons: [] };
407
- return { records: [], malformed: 0, malformedReasons: [], readError: (err && err.code) || (err && err.message) || 'read failed' };
408
- }
409
- const records = [];
410
- const malformedReasons = [];
411
- for (const line of raw.split('\n')) {
412
- if (line.trim() === '') continue;
413
- let parsed;
414
- try {
415
- parsed = JSON.parse(line);
416
- } catch (err) {
417
- malformedReasons.push(`unparseable JSON (${err.message})`);
418
- continue;
419
- }
420
- const v = validateRecord(parsed);
421
- if (v.ok) records.push(parsed);
422
- else malformedReasons.push(v.reason);
423
- }
424
- return { records, malformed: malformedReasons.length, malformedReasons };
425
- };
426
-
427
- // filterLoopRecords(records, { activity, loop }) → the records of ONE loop (all kinds), order
428
- // preserved. The gate filters to activity==="plan-execution" AND loop===the in-flight plan stem;
429
- // authoring rounds (and other plans' rounds) never enter the code gate.
430
- export const filterLoopRecords = (records, { activity, loop }) =>
431
- records.filter((r) => r.activity === activity && r.loop === loop);
432
-
433
- // filterSegmentRecords(records, { activity, loop, base }) → the records of ONE segment (D1), order
434
- // preserved. STRICT: only a v4+ record can be a segment member (a v1..v3 record carries no base and
435
- // never enters one — the D7 legacy rule; the schema guard also keeps a defensive undefined base
436
- // from matching a pre-v4 record's absent field, codex R1); records at baseA are invisible at baseB;
437
- // base === null matches only null (an unborn-branch segment).
438
- export const filterSegmentRecords = (records, { activity, loop, base }) =>
439
- filterLoopRecords(records, { activity, loop }).filter((r) => r.schema >= 4 && r.base === base);
440
-
441
153
  // collectOverrides(records, { activity, loop }) → { oracleChangeFiles: Set, redProofTestIds: Set }.
442
154
  // The UNION of the loop's recorded v3-scope override payloads — loop + payload scoped, never
443
155
  // fingerprint-bound (D3: re-affirmation churn on every later edit would train rubber-stamping). The
@@ -467,15 +179,6 @@ export const collectSizeCapLimit = (records, { activity = ACTIVITY, loop, base }
467
179
  return limit;
468
180
  };
469
181
 
470
- // roundSequenceIntact(records) → true iff the round records, in file order, number exactly 1,2,…,n
471
- // (no duplicate, gap, or out-of-order round). Checks the EXISTING sequence, not just the incoming
472
- // round: a ledger like [2] / [1,1] / [2,1] (reachable only by hand-editing the git-dir file — the
473
- // stated residual) must fail closed rather than be trusted to compute the "latest" round (codex R3).
474
- export const roundSequenceIntact = (records) => {
475
- const nums = records.filter((r) => r.kind === 'round').map((r) => r.round);
476
- return nums.every((n, i) => n === i + 1);
477
- };
478
-
479
182
  // ── the computed crossover-stop (pure; machine fields only) ─────────────────────────────────────
480
183
 
481
184
  const BLOCKING = new Set(['blocker', 'major']);