@sabaiway/agent-workflow-kit 1.37.1 → 1.39.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.
@@ -11,22 +11,27 @@
11
11
  //
12
12
  // Normative `--check` exit contract (the single home of this list — SKILL.md / the mode file point
13
13
  // here). The gate enforces the plan-EXECUTION (code) loop — the loop that produces the committable
14
- // artifact — filtered to activity==="plan-execution" AND the in-flight plan's filename stem:
14
+ // artifact — filtered to the current SEGMENT: activity==="plan-execution" AND the in-flight plan's
15
+ // filename stem AND base===`git rev-parse HEAD` (BUGFREE-2 / AD-048, D1 — a segment is the
16
+ // uncommitted change set over one base commit; it closes only by a gated commit, so a round-counter
17
+ // reset is earned, never declared):
15
18
  // exit 0 when the resolved plan-execution.review recipe is solo (configured, or degraded there —
16
19
  // no reviewer ready); when no plan is in flight (the review-state naming convention);
17
20
  // when the tree is clean (nothing to review); when the cwd is not a git work tree; and
18
- // when the in-flight plan-execution loop is `converged` or `resolved-residual` (its
21
+ // when the in-flight plan-execution SEGMENT is `converged` or `resolved-residual` (its
19
22
  // latest round's non-degraded backends carry grounded code receipts for the recorded
20
23
  // fingerprint, and a recorded 0/0 is ship-class-consistent with those receipts).
21
- // exit 1 for any DIRTY in-flight plan-execution loop that is neither `converged` nor
22
- // `resolved-residual` — `triage-required`, `continue`, OR no round/receipt recorded at
23
- // all (a dirty active plan with an empty/stale ledger is a FAILURE, not a fail-open pass);
24
+ // exit 1 for any DIRTY in-flight plan-execution segment that is neither `converged` nor
25
+ // `resolved-residual` — `triage-required`, `continue`, OR no round/receipt recorded in
26
+ // the CURRENT segment (a dirty active plan with an empty/stale/other-segment ledger is a
27
+ // FAILURE, not a fail-open pass; when the loop holds only pre-v4 records the reason names
28
+ // the schema upgrade — old records never enter a segment, D7);
24
29
  // when MORE THAN ONE plan is in flight (ambiguous loop id); when a recorded ship-class 0/0
25
30
  // coexists with a non-ship receipt verdict, or a non-degraded recorded backend lacks a
26
31
  // grounded receipt for its fingerprint. Fail-CLOSED (unknown state, never a fail-open pass)
27
32
  // on a detector failure, an unreadable (non-ENOENT) ledger, malformed ledger lines the
28
- // reader dropped, or a corrupt round sequence (not 1..n) — the only detector-independent
29
- // green is an EXPLICIT configured solo.
33
+ // reader dropped, or a corrupt segment round sequence (not 1..n) — the only
34
+ // detector-independent green is an EXPLICIT configured solo.
30
35
  //
31
36
  // The stop decision: `decideStop(records, { cap, currentFingerprint, requiredBackends })` reads the
32
37
  // ordered ledger records of ONE loop (both `round` and `triage` kinds) and returns exactly one state
@@ -62,6 +67,10 @@ import {
62
67
  readReceipts,
63
68
  resolveReceiptsPath,
64
69
  } from './review-state.mjs';
70
+ // The NEUTRAL shared core (D4/D8): the D8 telemetry reads the FOLD ledger through the neutral
71
+ // module — never through fold-completeness.mjs, which imports THIS module (the import-cycle
72
+ // invariant, codex R2; an import-split test pins it). probeVerdict is the one D4 algebra home.
73
+ import { probeVerdict, resolveResultsPath, readJsonlRows } from './changed-surface.mjs';
65
74
 
66
75
  export const LEDGER_BASENAME = 'agent-workflow-review-ledger.jsonl';
67
76
  const ACTIVITY = 'plan-execution';
@@ -71,13 +80,22 @@ const SLOT = 'review';
71
80
  // there, never here (it is not a decideStop input).
72
81
  export const REVIEW_CAP = 2;
73
82
  // SCHEMA_VERSION is what the WRITER emits (M2/AD-046: a fixable-bug triage requires a non-null,
74
- // well-formed testId — the red→green test that pins the fold). The READER tolerates every
75
- // SUPPORTED_SCHEMAS version under its own per-version rules, so historical/live v1 ledgers never
76
- // retroactively become malformed (Decision 2a malformed line cascades fail-closed refusals in the
77
- // writer teeth AND the --check gate). v1 records keep the AD-045 rule (testId optional, unenforced);
78
- // only v2 enforces the test-per-fold binding. decideStop never reads testId (not a decideStop input).
79
- export const SCHEMA_VERSION = 2;
80
- const SUPPORTED_SCHEMAS = new Set([1, 2]);
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 SEGMENTevery 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]);
81
99
 
82
100
  // The record vocabulary — the single home of every enum the schema validates.
83
101
  const ACTIVITIES_SET = new Set(['plan-authoring', 'plan-execution']);
@@ -85,6 +103,14 @@ const KINDS_SET = new Set(['round', 'triage']);
85
103
  const SEVERITIES = new Set(['blocker', 'major', 'minor']);
86
104
  export const ORIGINS = ['first-draft', 'fold-induced', 'mechanics'];
87
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']);
88
114
 
89
115
  // ── git-dir resolution (read-only queries; the ledger lives in the git dir, uncommittable) ──────
90
116
 
@@ -104,6 +130,17 @@ export const resolveLedgerPath = (cwd, env = process.env) => {
104
130
  return gitDir == null ? null : join(gitDir, LEDGER_BASENAME);
105
131
  };
106
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
+
107
144
  // ── ship-verdict mapping (the single home; a named test pins it) ────────────────────────────────
108
145
 
109
146
  // isShipVerdict(verdict) — which free-text review verdicts are ship-class. SHIP / SHIP WITH NITS are
@@ -122,13 +159,19 @@ const isNonNegInt = (v) => Number.isInteger(v) && v >= 0;
122
159
  // testId FORMAT (Decision 3): "<repo-relative test file>#<test-name-pattern>" — a "#" separator with
123
160
  // BOTH halves non-empty. NO file-suffix rule: a suffix check would itself be a special case and would
124
161
  // block a consumer's own naming (e.g. `.spec.js`; agy R1). The reader validates FORMAT only (it stays
125
- // hermetic); the fold-completeness gate validates RESOLVABILITY via a bound-test probe run.
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).
126
165
  const TESTID_SEPARATOR = '#';
127
- const isWellFormedTestId = (v) => {
166
+ export const isWellFormedTestId = (v) => {
128
167
  if (typeof v !== 'string') return false;
129
168
  const at = v.indexOf(TESTID_SEPARATOR);
130
169
  return at > 0 && at < v.length - 1; // separator present, both halves non-empty
131
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
+ };
132
175
 
133
176
  // validateRound(obj) → { ok, reason }. Structural checks + the two internal-consistency invariants:
134
177
  // the per-backend findings-by-severity equal that backend's counts, and the origins tally equals the
@@ -179,13 +222,18 @@ const validateRound = (obj) => {
179
222
  return { ok: true };
180
223
  };
181
224
 
182
- // validateTriage(obj, schema) → { ok, reason }. `schema` selects the per-version testId rule (v1
183
- // tolerant / v2 the test-per-fold binding) — the shared structural checks run in both versions.
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.
184
228
  const validateTriage = (obj, schema = SCHEMA_VERSION) => {
185
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;
186
231
  for (const c of obj.classifications) {
187
232
  if (!isPlainObject(c) || !isNonEmptyString(c.findingKey)) return { ok: false, reason: 'triage: each classification needs a findingKey' };
188
- if (!CLASSES.has(c.class)) return { ok: false, reason: `triage: classification ${c.findingKey} bad class "${c.class}"` };
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)` };
189
237
  if (typeof c.accepted !== 'boolean') return { ok: false, reason: `triage: classification ${c.findingKey} missing boolean accepted` };
190
238
  // Structural (BOTH versions): testId is null/absent or a non-empty string — an ABSENT key is
191
239
  // treated as null, never rejected here (agy R3). The writer normalizes it to null when stored.
@@ -203,19 +251,146 @@ const validateTriage = (obj, schema = SCHEMA_VERSION) => {
203
251
  return { ok: true };
204
252
  };
205
253
 
206
- // validateRecord(obj) → { ok, reason }. The shared frame (schema/loop/activity/kind/round/
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
+ // isProcessGateCmd(cmd) — the CLOSED, kit-owned process-gate classification (D5): the kit's own
339
+ // process-loop `--check` commands (review-state / review-ledger / fold-completeness) legitimately
340
+ // fail MID-loop, so "quality-green" excludes them — without the carve-out the D5 tooth is
341
+ // unsatisfiable by construction. Closed by the gate's cmd being EXACTLY one `--check` invocation of
342
+ // one of the three checkers (an optionally-quoted path token whose basename is the tool — never a
343
+ // substring: fold-completeness-run.mjs must NOT match). A COMPOUND line (`… && checker --check`)
344
+ // is never process: exempting it would forgive the failing quality half — fail-open against the
345
+ // D5 direction (internal sweep, live-probed). Pinned by its own named test.
346
+ // The tool BASENAME must sit at a path boundary (start-of-token or after a separator): a
347
+ // suffix-named sibling like `my-review-ledger.mjs` is a consumer's own tool, and misclassifying it
348
+ // as process would forgive its red result (codex R2, fold-induced by the R1 exact-form rewrite).
349
+ const PROCESS_GATE_RE = /^node\s+("(?:[^"]*[/\\])?(?:review-state|review-ledger|fold-completeness)\.mjs"|(?:[^\s"]*[/\\])?(?:review-state|review-ledger|fold-completeness)\.mjs)\s+--check$/;
350
+ export const isProcessGateCmd = (cmd) => PROCESS_GATE_RE.test(String(cmd).trim());
351
+
352
+ // isQualityGreenGateRun(record) → boolean (D5). Quality-green = the run covers EVERY declared
353
+ // NON-process gate with a green result (a `--only` subset is recorded honestly but never satisfies
354
+ // this — the R1 converged subset-bypass hole), AND the tree did not change under the run
355
+ // (fingerprint === fingerprintAfter, both non-null — a mutating gate attests no particular tree,
356
+ // codex R2). Process-gate failures never block.
357
+ export const isQualityGreenGateRun = (record) => {
358
+ if (record.kind !== 'gate-run') return false;
359
+ if (record.fingerprint == null || record.fingerprint !== record.fingerprintAfter) return false;
360
+ const green = new Set(record.results.filter((r) => r.ok).map((r) => r.id));
361
+ return record.declared.every((d) => isProcessGateCmd(d.cmd) || green.has(d.id));
362
+ };
363
+
364
+ // validateRecord(obj) → { ok, reason }. The shared frame (schema/loop/activity/kind/round/base/
207
365
  // fingerprint/timestamp) then the per-kind body. `reason` names the exact failed check so the
208
- // malformed-line surface and the per-check named tests can assert it.
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).
209
369
  export const validateRecord = (obj) => {
210
370
  if (!isPlainObject(obj)) return { ok: false, reason: 'not an object' };
211
371
  if (!SUPPORTED_SCHEMAS.has(obj.schema)) return { ok: false, reason: `schema must be one of ${[...SUPPORTED_SCHEMAS].join(', ')}` };
212
372
  if (!isNonEmptyString(obj.loop)) return { ok: false, reason: 'missing loop' };
213
373
  if (!ACTIVITIES_SET.has(obj.activity)) return { ok: false, reason: `bad activity "${obj.activity}"` };
214
- if (!KINDS_SET.has(obj.kind)) return { ok: false, reason: `bad kind "${obj.kind}"` };
215
- if (!(Number.isInteger(obj.round) && obj.round >= 1)) return { ok: false, reason: 'round must be an integer >= 1' };
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
+ }
216
388
  if (!(obj.fingerprint === null || isNonEmptyString(obj.fingerprint))) return { ok: false, reason: 'fingerprint must be null or a non-empty string' };
217
389
  if (!isNonEmptyString(obj.timestamp)) return { ok: false, reason: 'missing timestamp' };
218
- return obj.kind === 'round' ? validateRound(obj) : validateTriage(obj, obj.schema);
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);
219
394
  };
220
395
 
221
396
  // readLedger(path) → { records, malformed, malformedReasons }. Absent file → empty (no review ran).
@@ -249,12 +424,49 @@ export const readLedger = (path, readFile = readFileSync) => {
249
424
  return { records, malformed: malformedReasons.length, malformedReasons };
250
425
  };
251
426
 
252
- // filterLoopRecords(records, { activity, loop }) → the records of ONE loop (both kinds), order
427
+ // filterLoopRecords(records, { activity, loop }) → the records of ONE loop (all kinds), order
253
428
  // preserved. The gate filters to activity==="plan-execution" AND loop===the in-flight plan stem;
254
429
  // authoring rounds (and other plans' rounds) never enter the code gate.
255
430
  export const filterLoopRecords = (records, { activity, loop }) =>
256
431
  records.filter((r) => r.activity === activity && r.loop === loop);
257
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
+ // collectOverrides(records, { activity, loop }) → { oracleChangeFiles: Set, redProofTestIds: Set }.
442
+ // The UNION of the loop's recorded v3-scope override payloads — loop + payload scoped, never
443
+ // fingerprint-bound (D3: re-affirmation churn on every later edit would train rubber-stamping). The
444
+ // fold-completeness checker consumes THIS (both modules are read-only — the read/write split holds).
445
+ // The v4 `size-cap` scope is deliberately NOT here: it is SEGMENT-scoped and consumed by the writer
446
+ // tooth via collectSizeCapLimit.
447
+ export const collectOverrides = (records, { activity = ACTIVITY, loop } = {}) => {
448
+ const oracleChangeFiles = new Set();
449
+ const redProofTestIds = new Set();
450
+ for (const r of filterLoopRecords(records, { activity, loop })) {
451
+ if (r.kind !== 'override') continue;
452
+ if (r.scope === 'oracle-change') for (const f of r.files) oracleChangeFiles.add(f);
453
+ else if (r.scope === 'red-proof') redProofTestIds.add(r.testId);
454
+ }
455
+ return { oracleChangeFiles, redProofTestIds };
456
+ };
457
+
458
+ // collectSizeCapLimit(records, { activity, loop, base }) → the LARGEST sanctionedLines among the
459
+ // segment's recorded size-cap overrides, or null when none exists (D4). Segment-scoped by
460
+ // construction: a magnitude sanctioned for one base never leaks into the next segment.
461
+ export const collectSizeCapLimit = (records, { activity = ACTIVITY, loop, base } = {}) => {
462
+ let limit = null;
463
+ for (const r of filterSegmentRecords(records, { activity, loop, base })) {
464
+ if (r.kind !== 'override' || r.scope !== 'size-cap') continue;
465
+ if (limit === null || r.sanctionedLines > limit) limit = r.sanctionedLines;
466
+ }
467
+ return limit;
468
+ };
469
+
258
470
  // roundSequenceIntact(records) → true iff the round records, in file order, number exactly 1,2,…,n
259
471
  // (no duplicate, gap, or out-of-order round). Checks the EXISTING sequence, not just the incoming
260
472
  // round: a ledger like [2] / [1,1] / [2,1] (reachable only by hand-editing the git-dir file — the
@@ -286,7 +498,12 @@ export const decideStop = (records, { cap = REVIEW_CAP, currentFingerprint = nul
286
498
  const classOf = new Map();
287
499
  for (const t of triages) if (t.round === latest.round) for (const c of t.classifications) classOf.set(c.findingKey, { ...c, triageFingerprint: t.fingerprint });
288
500
  const isClassified = (key) => classOf.has(key);
289
- const isResolvedClass = (c) => c && (c.class === 'inherent-layer-residual' || (c.class === 'escalate' && c.accepted === true));
501
+ // `refuted` (v4, AD-048) resolves like an inherent-layer-residual: a documented, grounds-cited
502
+ // resolution of a phantom finding. Without this arm a phantom minted at round HARD_MAX and
503
+ // honestly refuted would WEDGE the segment (the immutable round record keeps the minting
504
+ // backend's counts non-0/0, and no further round exists to vanish it into). Additive beside the
505
+ // frozen truth table — every pre-existing row is untouched (D10).
506
+ const isResolvedClass = (c) => c && (c.class === 'inherent-layer-residual' || c.class === 'refuted' || (c.class === 'escalate' && c.accepted === true));
290
507
 
291
508
  const survivingBlocking = latest.findings.filter(isBlocking);
292
509
 
@@ -393,13 +610,18 @@ export const buildLedgerState = ({ cwd, env = process.env, detect = detectBacken
393
610
  const plans = plansInFlight(root);
394
611
  const fingerprint = computeTreeFingerprint(cwd);
395
612
  const clean = fingerprint == null ? null : isTreeClean(cwd);
613
+ const base = resolveBase(cwd);
396
614
  const ledgerPath = resolveLedgerPath(cwd, env);
397
615
  const { records, malformed, malformedReasons, readError } = ledgerPath ? readLedger(ledgerPath) : { records: [], malformed: 0, malformedReasons: [] };
398
616
  const receiptsPath = resolveReceiptsPath(cwd, env);
399
617
  const { receipts } = receiptsPath ? readReceipts(receiptsPath) : { receipts: [] };
400
- return { resolved, requiredBackends, plans, fingerprint, clean, ledgerPath, records, malformed, malformedReasons, readError, receipts, receiptsPath, detectionWarning };
618
+ return { resolved, requiredBackends, plans, fingerprint, clean, base, ledgerPath, records, malformed, malformedReasons, readError, receipts, receiptsPath, detectionWarning };
401
619
  };
402
620
 
621
+ // The human label of a segment base (null = an unborn branch; undefined never occurs in a real
622
+ // state — resolveBase returns string | null).
623
+ const baseLabel = (base) => (base === null ? '(unborn branch)' : String(base).slice(0, 12));
624
+
403
625
  // The normative --check decision (the header contract, in order) → { code, reason }.
404
626
  export const decideCheck = (state) => {
405
627
  // A detector failure is UNKNOWN state, not "no reviewer ready" — fail closed (like review-state).
@@ -424,14 +646,23 @@ export const decideCheck = (state) => {
424
646
  if (state.malformed > 0) return { code: 1, reason: `the ledger has ${state.malformed} malformed line(s) — failing closed (a dropped line could hide a non-converged round); inspect ${state.ledgerPath}` };
425
647
 
426
648
  const loop = state.plans[0].replace(/\.md$/, '');
427
- const filtered = filterLoopRecords(state.records, { activity: ACTIVITY, loop });
649
+ // SEGMENT scope (D1): the gate judges the current segment — (plan-execution, loop, base = the
650
+ // HEAD the dirty tree sits on). Records of earlier segments (other bases) and pre-v4 records
651
+ // (no base — they can never enter a segment) are history, not the current loop state.
652
+ const filtered = filterSegmentRecords(state.records, { activity: ACTIVITY, loop, base: state.base });
428
653
  const rounds = filtered.filter((r) => r.kind === 'round');
429
- // A dirty active plan with NO round recorded is a FAILURE, not a fail-open pass (codex R2 hole).
654
+ // A dirty active plan whose SEGMENT has no round is a FAILURE, not a fail-open pass (codex R2
655
+ // hole) — same failure direction as AD-045, per-segment remedy. When the loop holds only
656
+ // older-schema records, the reason names the schema upgrade (D7 legacy rule).
430
657
  if (rounds.length === 0) {
431
- return { code: 1, reason: `dirty plan-execution loop "${loop}" but no review round recorded — record the current round` };
658
+ const loopAll = filterLoopRecords(state.records, { activity: ACTIVITY, loop });
659
+ const legacy = loopAll.length > 0 && loopAll.every((r) => !(r.schema >= 4))
660
+ ? ' (the loop has only pre-v4 records, which never enter a segment — the schema upgrade requires a fresh v4 round)'
661
+ : '';
662
+ return { code: 1, reason: `dirty plan-execution loop "${loop}" but no review round recorded for the current segment (base ${baseLabel(state.base)}) — record the current round${legacy}` };
432
663
  }
433
664
  // A corrupt round sequence (not 1..n) is unknown state → fail closed rather than trust "latest" (codex R3).
434
- if (!roundSequenceIntact(rounds)) return { code: 1, reason: `the round sequence for "${loop}" is corrupt (not 1..n) — failing closed; inspect ${state.ledgerPath}` };
665
+ if (!roundSequenceIntact(rounds)) return { code: 1, reason: `the round sequence for "${loop}" (base ${baseLabel(state.base)}) is corrupt (not 1..n) — failing closed; inspect ${state.ledgerPath}` };
435
666
  const decision = decideStop(filtered, { cap: REVIEW_CAP, currentFingerprint: state.fingerprint, requiredBackends: state.requiredBackends });
436
667
  if (decision.state === 'converged' || decision.state === 'resolved-residual') {
437
668
  // Integrity binding: cross-check the latest round's non-degraded backends against their receipts
@@ -459,6 +690,47 @@ const roundLine = (r) => {
459
690
  const triageLine = (t) =>
460
691
  ` triage @round ${t.round} — ${t.classifications.map((c) => `${c.findingKey}=${c.class}${c.class === 'escalate' ? `(accepted:${c.accepted})` : ''}`).join(', ')}`;
461
692
 
693
+ const overridePayload = (o) => {
694
+ if (o.scope === 'oracle-change') return o.files.join(', ');
695
+ if (o.scope === 'size-cap') return `sanctioned ${o.sanctionedLines} lines`;
696
+ return o.testId;
697
+ };
698
+ const overrideLine = (o) => ` override @round ${o.round} [${o.scope}] — ${overridePayload(o)}: ${o.reason}`;
699
+
700
+ const gateRunLine = (g) => {
701
+ const posture = isQualityGreenGateRun(g)
702
+ ? 'quality-green'
703
+ : g.fingerprint !== g.fingerprintAfter
704
+ ? 'NOT quality-green (the tree changed under the run)'
705
+ : 'NOT quality-green (a subset, a red gate, or an unfingerprinted tree)';
706
+ return ` gate-run — status=${g.summary.status} ${g.summary.passed}/${g.summary.gates} green of ${g.declared.length} declared — ${posture}`;
707
+ };
708
+
709
+ const recordLine = (r) =>
710
+ r.kind === 'round' ? roundLine(r) : r.kind === 'override' ? overrideLine(r) : r.kind === 'gate-run' ? gateRunLine(r) : triageLine(r);
711
+
712
+ // The loop's records grouped by SEGMENT (D1): v4 records group by base in order of first
713
+ // appearance; pre-v4 records (no base) group under a legacy header — readable history that never
714
+ // enters a segment.
715
+ const segmentGroups = (forLoop, currentBase) => {
716
+ const lines = [];
717
+ const seen = new Set();
718
+ const legacy = forLoop.filter((r) => !(r.schema >= 4));
719
+ if (legacy.length > 0) {
720
+ lines.push(' pre-v4 records (no segment — readable history):');
721
+ for (const r of legacy) lines.push(` ${recordLine(r)}`);
722
+ }
723
+ for (const r of forLoop) {
724
+ if (!(r.schema >= 4)) continue;
725
+ const key = JSON.stringify(r.base);
726
+ if (seen.has(key)) continue;
727
+ seen.add(key);
728
+ lines.push(` segment @ base ${baseLabel(r.base)}${r.base === currentBase ? ' (current)' : ''}:`);
729
+ for (const s of forLoop) if (s.schema >= 4 && s.base === r.base) lines.push(` ${recordLine(s)}`);
730
+ }
731
+ return lines;
732
+ };
733
+
462
734
  const formatHuman = (state, check) => {
463
735
  const lines = [
464
736
  `review-ledger — ${ACTIVITY}.${SLOT} = ${state.resolved.recipe} (${state.resolved.source === 'config' ? `from ${CONFIG_REL}` : 'computed default'})${state.requiredBackends.length ? ` → ${state.requiredBackends.join(' + ')}` : ''}`,
@@ -468,41 +740,138 @@ const formatHuman = (state, check) => {
468
740
  if (state.fingerprint == null) lines.push(' tree: not a git work tree');
469
741
  else if (state.clean === true) lines.push(' tree: clean (nothing to review)');
470
742
  else lines.push(` tree fingerprint: ${state.fingerprint}`);
743
+ if (state.fingerprint != null) lines.push(` segment base: ${baseLabel(state.base)}`);
471
744
  lines.push(` ledger: ${state.ledgerPath ?? '(unresolvable — no git dir)'} (${state.records.length} record(s)${state.malformed ? `, ${state.malformed} malformed — inspect the file` : ''})`);
472
745
  if (state.plans.length === 1) {
473
746
  const loop = state.plans[0].replace(/\.md$/, '');
474
- const forLoop = filterLoopRecords(state.records, { activity: ACTIVITY, loop });
475
- for (const r of forLoop) lines.push(r.kind === 'round' ? roundLine(r) : triageLine(r));
747
+ lines.push(...segmentGroups(filterLoopRecords(state.records, { activity: ACTIVITY, loop }), state.base));
476
748
  }
477
749
  lines.push(` check: ${check.code === 0 ? 'PASS' : 'FAIL'} — ${check.reason}`);
478
750
  return lines.join('\n');
479
751
  };
480
752
 
481
- const HELP = `review-ledger read-only review-round LEDGER checker (agent-workflow family, AD-045).
753
+ // ── telemetry (D8): read-only counts across ALL loops and BOTH ledgers — no judgment ─────────────
754
+
755
+ // computeTelemetry(reviewRecords, foldRows) → { loops: [...] } — deterministic counts with named
756
+ // fields, pinned by a named test. Interpretation (which gates earn their keep) stays with the
757
+ // maintainer. Fold rows come through the TOLERANT reader (readJsonlRows): fields are guarded, a
758
+ // half-shaped row counts only what it provably carries.
759
+ export const computeTelemetry = (reviewRecords, foldRows) => {
760
+ const loops = new Map();
761
+ const bump = (obj, key) => { obj[key] = (obj[key] ?? 0) + 1; };
762
+ const forLoop = (activity, loop) => {
763
+ const key = JSON.stringify([activity, loop]);
764
+ if (!loops.has(key)) {
765
+ loops.set(key, {
766
+ activity, loop, rounds: 0, segments: new Set(), legacyRecords: 0,
767
+ origins: { 'first-draft': 0, 'fold-induced': 0, mechanics: 0 },
768
+ classifications: {}, backendVerdicts: {}, divergenceRounds: 0, overrides: {},
769
+ gateRuns: 0, qualityGreenGateRuns: 0, redByGateId: {},
770
+ foldRuns: 0, redProbes: 0, quarantinedProbes: 0,
771
+ });
772
+ }
773
+ return loops.get(key);
774
+ };
775
+ for (const r of reviewRecords) {
776
+ const t = forLoop(r.activity, r.loop);
777
+ if (r.schema >= 4) t.segments.add(JSON.stringify(r.base));
778
+ else t.legacyRecords += 1;
779
+ if (r.kind === 'round') {
780
+ t.rounds += 1;
781
+ for (const k of ORIGINS) t.origins[k] += r.origins[k];
782
+ const nonDegraded = r.backends.filter((b) => !b.degraded);
783
+ for (const b of r.backends) {
784
+ const verdicts = (t.backendVerdicts[b.backend] ??= {});
785
+ bump(verdicts, b.degraded ? 'degraded' : b.verdict);
786
+ }
787
+ // Divergence = a round where the non-degraded backends SPLIT on clean (0 blockers + 0 majors)
788
+ // vs not — the computed crossover signal, counted, never judged.
789
+ const clean = nonDegraded.filter((b) => b.blockers === 0 && b.majors === 0).length;
790
+ if (nonDegraded.length >= 2 && clean > 0 && clean < nonDegraded.length) t.divergenceRounds += 1;
791
+ } else if (r.kind === 'triage') {
792
+ for (const c of r.classifications) bump(t.classifications, c.class);
793
+ } else if (r.kind === 'override') {
794
+ bump(t.overrides, r.scope);
795
+ } else if (r.kind === 'gate-run') {
796
+ t.gateRuns += 1;
797
+ if (isQualityGreenGateRun(r)) t.qualityGreenGateRuns += 1;
798
+ for (const res of r.results) if (!res.ok) bump(t.redByGateId, res.id);
799
+ }
800
+ }
801
+ for (const row of foldRows) {
802
+ if (typeof row.loop !== 'string' || row.loop.length === 0) continue;
803
+ const t = forLoop('plan-execution', row.loop); // the fold ledger is plan-execution-scoped
804
+ if (row.kind === 'red-probe') t.redProbes += 1;
805
+ else if (row.kind === 'run' || (row.schema === 1 && row.kind === undefined)) {
806
+ t.foldRuns += 1;
807
+ if (Array.isArray(row.testIds)) {
808
+ for (const e of row.testIds) {
809
+ const counted = e && [e.runs, e.greens, e.reds, e.timeouts].every(Number.isInteger);
810
+ if (counted && probeVerdict(e) === 'quarantine') t.quarantinedProbes += 1;
811
+ }
812
+ }
813
+ }
814
+ }
815
+ const sorted = [...loops.values()].sort((a, b) => (a.activity + a.loop < b.activity + b.loop ? -1 : 1));
816
+ return { loops: sorted.map((t) => ({ ...t, segments: t.segments.size })) };
817
+ };
818
+
819
+ const countsOf = (obj) => {
820
+ const keys = Object.keys(obj).sort();
821
+ return keys.length === 0 ? '(none)' : keys.map((k) => `${k}:${obj[k]}`).join(' ');
822
+ };
823
+
824
+ export const renderTelemetry = (telemetry, meta) => {
825
+ const lines = [
826
+ `review-ledger telemetry — counts only, no judgment (D8). review ledger: ${meta.records} record(s)${meta.malformed ? `, ${meta.malformed} malformed` : ''}; fold ledger: ${meta.rows} row(s)${meta.badLines ? `, ${meta.badLines} unparseable` : ''}.`,
827
+ ];
828
+ if (meta.readError) lines.push(` ⚠ review ledger unreadable (${meta.readError}) — counts exclude it`);
829
+ if (meta.foldReadError) lines.push(` ⚠ fold ledger unreadable (${meta.foldReadError}) — counts exclude it`);
830
+ if (telemetry.loops.length === 0) lines.push(' (no loops recorded)');
831
+ for (const t of telemetry.loops) {
832
+ lines.push(` ${t.activity} / ${t.loop}:`);
833
+ lines.push(` rounds ${t.rounds} across ${t.segments} segment(s)${t.legacyRecords ? ` (+${t.legacyRecords} pre-v4 record(s))` : ''} · divergence rounds ${t.divergenceRounds}`);
834
+ lines.push(` finding origins — ${ORIGINS.map((k) => `${k}:${t.origins[k]}`).join(' ')}`);
835
+ lines.push(` classifications — ${countsOf(t.classifications)}`);
836
+ lines.push(` backend verdicts — ${Object.keys(t.backendVerdicts).sort().map((b) => `${b}{${countsOf(t.backendVerdicts[b])}}`).join(' · ') || '(none)'}`);
837
+ lines.push(` overrides — ${countsOf(t.overrides)}`);
838
+ lines.push(` gate-runs ${t.gateRuns} (quality-green ${t.qualityGreenGateRuns}) · red results by gate — ${countsOf(t.redByGateId)}`);
839
+ lines.push(` fold runs ${t.foldRuns} · observed-red receipts ${t.redProbes} · quarantined probe entries ${t.quarantinedProbes}`);
840
+ }
841
+ return lines.join('\n');
842
+ };
843
+
844
+ const HELP = `review-ledger — read-only review-round LEDGER checker (agent-workflow family, AD-045 + AD-048).
482
845
 
483
846
  Usage:
484
- node review-ledger.mjs [--check | --status | --json]
847
+ node review-ledger.mjs [--check | --status | --json | --telemetry]
485
848
 
486
849
  Reads the review-round ledger the orchestrator records to (<git dir>/${LEDGER_BASENAME};
487
850
  AW_REVIEW_LEDGER overrides), resolves the effective ${ACTIVITY}.${SLOT} recipe, recomputes the
488
851
  canonical uncommitted-state fingerprint, and computes the crossover-stop decision for the in-flight
489
- plan-execution loop (decideStop converged / resolved-residual / triage-required / continue).
852
+ plan-execution loop's current SEGMENT — (activity, loop, base = the HEAD commit the dirty tree sits
853
+ on; schema v4). Round numbering, caps, and the teeth are per segment; a segment closes only by a
854
+ gated commit, so the round-counter reset is earned, never declared.
490
855
 
491
856
  --status (default) → the human report: resolved recipe, plan-in-flight, per-round tally with
492
- findings, per-backend, the decideStop verdict.
857
+ findings, grouped by segment, the decideStop verdict.
493
858
  --check → the gate exit code. The normative exit contract lives in the tool header (the single home):
494
859
  exit 0 for solo / no plan in flight / a clean tree / not-a-git-tree / a converged or
495
- resolved-residual in-flight loop; exit 1 for a dirty non-converged loop, triage-required, a loop
496
- with no round/receipt recorded, more than one plan in flight, or a receipt inconsistency.
860
+ resolved-residual in-flight SEGMENT; exit 1 for a dirty non-converged segment, triage-required, a
861
+ segment with no round/receipt recorded, more than one plan in flight, or a receipt inconsistency.
497
862
  --json → the structured state + decision.
863
+ --telemetry → read-only counts across ALL loops and BOTH ledgers (D8): rounds/segments per loop,
864
+ finding origins, classification distribution (incl. refuted), per-backend verdicts + divergence
865
+ rounds, override usage by scope, gate-run counts (quality-green / red-by-gate), fold runs,
866
+ observed-red receipts, quarantined probes. Counts only — interpretation stays with you.
498
867
 
499
- The writer is a SEPARATE tool (review-ledger-write.mjs record/classify) — this read-only checker
500
- never imports it. Human residual: git commit --no-verify, ledger-file editing, and forged counts
501
- remain possible — a self-discipline mechanism, not a security boundary.
868
+ The writer is a SEPARATE tool (review-ledger-write.mjs record/classify/override) — this read-only
869
+ checker never imports it. Human residual: git commit --no-verify, ledger-file editing, and forged
870
+ counts remain possible — a self-discipline mechanism, not a security boundary.
502
871
 
503
872
  Exit codes: 0 pass (or plain report); 1 check failed or config error (loud); 2 usage.`;
504
873
 
505
- const KNOWN_ARGS = new Set(['--help', '-h', '--check', '--status', '--json']);
874
+ const KNOWN_ARGS = new Set(['--help', '-h', '--check', '--status', '--json', '--telemetry']);
506
875
 
507
876
  export const main = (argv, ctx = {}) => {
508
877
  const cwd = ctx.cwd ?? process.cwd();
@@ -512,6 +881,19 @@ export const main = (argv, ctx = {}) => {
512
881
  if (argv.includes('--help') || argv.includes('-h')) return { code: 0, stdout: HELP, stderr: '' };
513
882
  const unknown = argv.find((a) => !KNOWN_ARGS.has(a));
514
883
  if (unknown !== undefined) throw fail(2, `unknown argument: ${unknown}`);
884
+ // --telemetry is a standalone report: combined with --check it would WIN the dispatch and exit
885
+ // 0 without running decideCheck — a silently passing gate cmd (codex R2). Reject mixed modes.
886
+ if (argv.includes('--telemetry') && ['--check', '--status', '--json'].some((a) => argv.includes(a))) {
887
+ throw fail(2, '--telemetry never combines with --check/--status/--json — a mixed-mode invocation would bypass the gate; run them separately');
888
+ }
889
+ if (argv.includes('--telemetry')) {
890
+ const ledgerPath = resolveLedgerPath(cwd, env);
891
+ const { records, malformed, readError } = ledgerPath ? readLedger(ledgerPath) : { records: [], malformed: 0 };
892
+ const foldPath = resolveResultsPath(cwd, env);
893
+ const { rows, badLines, readError: foldReadError } = foldPath ? readJsonlRows(foldPath) : { rows: [], badLines: 0 };
894
+ const telemetry = computeTelemetry(records, rows);
895
+ return { code: 0, stdout: renderTelemetry(telemetry, { records: records.length, malformed, rows: rows.length, badLines, readError, foldReadError }), stderr: '' };
896
+ }
515
897
  const state = buildLedgerState({ cwd, env, detect });
516
898
  const check = decideCheck(state);
517
899
  if (argv.includes('--json')) {