@sabaiway/agent-workflow-kit 1.40.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.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,39 @@ Semantically versioned ([semver](https://semver.org)), newest first. The `versio
4
4
  is the current release. `upgrade` mode reads a project's `docs/ai/.workflow-version` and applies
5
5
  every `migrations/<version>-<slug>.md` newer than it, in semver order.
6
6
 
7
+ ## 1.41.0 — review-state degraded lane: align the presence gate with the review-ledger (AD-050)
8
+
9
+ A **feature** release (kit-only; deployment-lineage head stays `1.3.0` — no migration;
10
+ memory/engine/bridges untouched). Closes the AD-049 residual: the family shipped two read-only review
11
+ gates that **disagreed** on the same tree when a ready backend genuinely can't review a diff.
12
+ `review-ledger --check` (convergence) already excused a recorded-degraded backend; `review-state
13
+ --check` (presence) had no degraded model, so a legitimately degraded agy (stalled → no receipt) read
14
+ missing/stale → exit 1. On BUGFREE-3 S2 the loop converged codex-only with agy recorded degraded, yet
15
+ review-ledger PASSED and review-state FAILED — a consumer wiring review-state into a pre-commit hook
16
+ was blocked on an honest degrade.
17
+
18
+ - **A neutral read-core (`review-ledger-core.mjs`)** — the validated review-ledger read/schema core
19
+ (path/base resolvers, `validateRecord` + its validators + schema constants, loop/segment filters)
20
+ moves VERBATIM into a new node-built-ins-only module both read-only checkers import; `review-ledger.mjs`
21
+ re-exports every symbol for back-compat. It breaks the `review-ledger ↔ review-state` import cycle so
22
+ review-state can read the ledger without a back-import (the `changed-surface.mjs` precedent). Pure
23
+ mechanical move — no behaviour change, pinned by the full pre-existing suite + import-split pins.
24
+ - **The `review-state` degraded exemption** — `review-state --check` now exempts a recipe-named backend
25
+ without a current grounded receipt when the in-flight segment's LATEST review-ledger round records it
26
+ `degraded:true` at the current tree fingerprint, with ≥1 non-degraded recipe-named backend present in
27
+ that round and grounded, the loop unambiguous, and the ledger clean. It **mirrors review-ledger's
28
+ `decideStop`** exactly (allPresent + presence) and stays **verdict-blind** — presence, not unanimity:
29
+ the two gates now AGREE on an honestly-converged-with-degrade tree, and still intentionally differ on
30
+ a non-converged one. `--await` inherits the exemption.
31
+ - **Fail-closed, exemption-scoped** — an unreadable/malformed ledger DENIES the exemption but never
32
+ fails a tree whose receipts independently satisfy the gate (all-current stays exit 0, the ledger issue
33
+ surfaced). More than one plan in flight suppresses the exemption without adding a fail-closed arm.
34
+
35
+ Contract surfaces moved in lockstep (the tool header, `--help`, the human render, the `--await`
36
+ comment, `references/modes/review-state.md`). Council-converged both segments (S1 codex + agy SHIP; S2
37
+ codex R1 revise → allPresent fold → R2 both SHIP), the two-gate agreement dogfooded live + pinned by a
38
+ detector-independent `two-gate-agreement.test.mjs` + a 17-case matrix; full suite 2969 green.
39
+
7
40
  ## 1.40.0 — Universal verification profile + session-loop economics (a)–(h) (BUGFREE-3, AD-049)
8
41
 
9
42
  A **feature** release (deployment-lineage head stays `1.3.0` — no migration; co-released with
package/SKILL.md CHANGED
@@ -3,7 +3,7 @@ name: agent-workflow-kit
3
3
  description: Deploy or upgrade a portable AI-agent memory-and-workflow system in any project. Use when the user wants to bootstrap `docs/ai/` + an entry-point `AGENTS.md` (+ `CLAUDE.md` alias) + cap/archive/index enforcement in a new or existing repo, set up the Memory Map and session protocols, install the docs-rotation pre-commit hook, or run `/agent-workflow-kit` / `/agent-workflow-kit upgrade`. Triggers on phrases like "set up the memory system", "deploy the AI workflow here", "bootstrap docs/ai", "upgrade the workflow".
4
4
  disable-model-invocation: true
5
5
  metadata:
6
- version: '1.40.0'
6
+ version: '1.41.0'
7
7
  ---
8
8
 
9
9
  # agent-workflow-kit
package/capability.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "schema": 1,
4
4
  "name": "agent-workflow-kit",
5
5
  "kind": "composition-root",
6
- "version": "1.40.0",
6
+ "version": "1.41.0",
7
7
  "provides": [],
8
8
  "roles": {},
9
9
  "detect": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sabaiway/agent-workflow-kit",
3
- "version": "1.40.0",
3
+ "version": "1.41.0",
4
4
  "description": "Portable, cross-agent memory & workflow for AI coding agents — Claude Code, Codex, Cursor, Devin Desktop. One command deploys an AGENTS.md entry point + docs/ai context with cap/archive/index enforcement into any repo.",
5
5
  "keywords": [
6
6
  "ai-agents",
@@ -5,9 +5,9 @@ Read-only **review-receipt checker** (AD-038) — it makes *"reviewed ≠ shippe
5
5
  Run `node ${CLAUDE_SKILL_DIR}/tools/review-state.mjs [--check] [--json]`:
6
6
 
7
7
  1. Plain run → the human report: resolved recipe + source, plan-in-flight, tree fingerprint, per-backend receipt state (current / stale / ungrounded / missing) with verdict + grounding + timestamp.
8
- 2. **`--check`** → the gate exit code. The **normative exit contract lives in the tool header** (the single home — do not re-enumerate it elsewhere): exit 0 for a solo-resolved recipe (configured, or degraded there — no ready reviewer), no plan in flight (the `docs/plans` naming convention: `queue.md` and `EXECUTE-`/`FEEDBACK-`-prefixed or `PROMPT`/`prompt`/`handoff`-carrying names are scratch), a clean tree, a non-git cwd, or every recipe-named backend receipted **current + grounded**; exit 1 when a backend is missing, **stale** (ANY edit after its review moves the fingerprint), or grounded:false under reviewed/council. **Presence, not unanimity:** verdict adjudication (ship/revise, the divergence crossover) stays orchestrator judgment — the gate only proves the configured backends really reviewed THIS tree. Plan/diff receipts and continuations (`agy-review --continue`) are **informational-only**: after a fold, only a **fresh grounded re-run** (`codex-review code`; `agy-review code --facts @f`) restores green.
8
+ 2. **`--check`** → the gate exit code. The **normative exit contract lives in the tool header** (the single home — do not re-enumerate it elsewhere): exit 0 for a solo-resolved recipe (configured, or degraded there — no ready reviewer), no plan in flight (the `docs/plans` naming convention: `queue.md` and `EXECUTE-`/`FEEDBACK-`-prefixed or `PROMPT`/`prompt`/`handoff`-carrying names are scratch), a clean tree, a non-git cwd, or every recipe-named backend receipted **current + grounded** **or degraded-exempt**: the current segment's latest **review-ledger** round records that backend `degraded:true` at the current tree fingerprint, with ≥1 non-degraded recipe-named backend present + grounded and the ledger reading clean (AD-050; a corrupt ledger DENIES the exemption fail-closed but never fails an otherwise-satisfied tree — the tool header is the single home). Exit 1 when a backend is missing, **stale** (ANY edit after its review moves the fingerprint), or grounded:false under reviewed/council, and is not degraded-exempt. **Presence, not unanimity:** verdict adjudication (ship/revise, the divergence crossover) stays orchestrator judgment — the gate only proves the configured backends really reviewed THIS tree (a recorded degrade counts as "reviewed", never as "converged" — that stays `review-ledger`'s job). Plan/diff receipts and continuations (`agy-review --continue`) are **informational-only**: after a fold, only a **fresh grounded re-run** (`codex-review code`; `agy-review code --facts @f`) restores green.
9
9
  3. **Wire it as a gate by hand OR via the explicit-consent seeder — never without consent (AD-021/AD-042).** The candidate line for your own `docs/ai/gates.json`: `{ "id": "review-state", "title": "Review receipts current for the uncommitted tree", "cmd": "node <path-to-this-skill>/tools/review-state.mjs --check" }` — with the path your project actually reaches the kit by, QUOTED so a path with spaces survives, executable from the project root. The consent-gated seeder (`${CLAUDE_SKILL_DIR}/references/modes/gates.md`, consent-seed section) offers exactly this entry — path resolved and quoted — ONLY when your `docs/ai/orchestration.json` declares `reviewed`/`council` on `plan-execution.review` (the slot this checker enforces); it writes nothing without your explicit yes. Once declared, the opt-in `${CLAUDE_SKILL_DIR}/references/modes/hook.md` auto-approves it like any other declared gate.
10
- 4. **`--await [--timeout <s>]`** (BUGFREE-3 / AD-049) → BLOCK until every recipe-named backend has a fresh grounded code receipt for the current tree (i.e. until `--check` would PASS), or the bounded timeout elapses (a loud exit 1; default 900s). Run it after dispatching the review bridges to WAIT for their receipts to land instead of hand-polling a pid: the durable completion signal is the **receipt**, never a process event (a harness "completed" notification fires early; a bridge's output late-flushes). It waits for ALL recipe-named backends review-state has no durable degraded-backend source before a round is recorded, so awaiting a backend you KNOW is degraded is your call (don't `--await` it). Still read-only (it only re-reads state); solo / no-plan / clean-tree resolve instantly.
10
+ 4. **`--await [--timeout <s>]`** (BUGFREE-3 / AD-049) → BLOCK until every recipe-named backend is SATISFIED for the current tree (i.e. until `--check` would PASS), or the bounded timeout elapses (a loud exit 1; default 900s). Run it after dispatching the review bridges to WAIT for their receipts to land instead of hand-polling a pid: the durable completion signal is the **receipt**, never a process event (a harness "completed" notification fires early; a bridge's output late-flushes). It waits until each backend has a fresh grounded receipt **OR is degraded-exempt**: once a current-tree degrade is recorded in the review-ledger, `--await` stops waiting for that backend and returns READY (AD-050 it inherits the `--check` exemption via the shared decision), so you no longer hand-`--await` around a known degrade. Still read-only (it only re-reads state, now the ledger too); solo / no-plan / clean-tree resolve instantly.
11
11
 
12
12
  **Human residual (stated, accepted):** `git commit --no-verify` and receipt-file deletion/forgery remain possible — this is a self-discipline mechanism against silent process drift, not a security boundary.
13
13
 
@@ -0,0 +1,354 @@
1
+ #!/usr/bin/env node
2
+ // review-ledger-core.mjs — the NEUTRAL ledger read/schema core (AD-050). One home for the validated
3
+ // review-ledger read path — the path/base resolvers, the tolerant schema validator + reader, and the
4
+ // loop/segment filters — extracted VERBATIM from review-ledger.mjs so BOTH read-only checkers can
5
+ // share it. review-ledger.mjs already imports review-state.mjs, so review-state.mjs cannot import
6
+ // review-ledger.mjs back (the cycle) — the degraded-exemption reader in review-state.mjs imports THIS
7
+ // neutral core instead (added in AD-050 Segment 2). review-ledger.mjs consumes + re-exports every
8
+ // symbol here for external back-compat — the changed-surface.mjs precedent (AD-048).
9
+ //
10
+ // Import-graph invariant (pinned by import-split tests): this module imports NOTHING from the
11
+ // family — node built-ins only. Everyone may import it; it imports no one:
12
+ // review-ledger.mjs → review-ledger-core.mjs ← review-state.mjs (AD-050 Segment 2)
13
+ //
14
+ // Read-only: never writes, never commits. It DOES spawn read-only `git` queries (rev-parse).
15
+ // Dependency-free, Node >= 18. No side effects on import.
16
+
17
+ import { readFileSync } from 'node:fs';
18
+ import { join } from 'node:path';
19
+ import { spawnSync } from 'node:child_process';
20
+
21
+ export const LEDGER_BASENAME = 'agent-workflow-review-ledger.jsonl';
22
+
23
+ // SCHEMA_VERSION is what the WRITER emits (M2/AD-046: a fixable-bug triage requires a non-null,
24
+ // well-formed testId — the red→green test that pins the fold; BUGFREE-1/AD-047: v3 adds the
25
+ // `override` record kind — the loud, durable waiver the fold-completeness gate consumes;
26
+ // BUGFREE-2/AD-048: v4 adds the SEGMENT — every record carries `base` = the commit the dirty tree
27
+ // sits on, and round numbering / caps / teeth / decideCheck all operate per (activity, loop, base) —
28
+ // plus the kind `gate-run` (the D5 green-baseline receipt run-gates --record mints), the override
29
+ // scope `size-cap` (the D4 diff-cap waiver), and the triage class `refuted` (the D6 honest lane for
30
+ // a phantom finding). The READER tolerates every SUPPORTED_SCHEMAS version under its own
31
+ // per-version rules, so historical/live v1..v3 ledgers never retroactively become malformed
32
+ // (Decision 2 — a malformed line cascades fail-closed refusals in the writer teeth AND the --check
33
+ // gate). v1 records keep the AD-045 rule (testId optional, unenforced); v2+ enforces the
34
+ // test-per-fold binding; ONLY v3+ may carry kind `override`; ONLY v4 may carry `base` / kind
35
+ // `gate-run` / scope `size-cap` / class `refuted` (older records never grow new surface).
36
+ // decideStop never reads testId, overrides, gate-runs, or base (not decideStop inputs — the caller
37
+ // passes ONE segment's records, exactly as it passes one loop's; D10: the truth table is untouched).
38
+ export const SCHEMA_VERSION = 4;
39
+ const SUPPORTED_SCHEMAS = new Set([1, 2, 3, 4]);
40
+
41
+ // The record vocabulary — the single home of every enum the schema validates.
42
+ const ACTIVITIES_SET = new Set(['plan-authoring', 'plan-execution']);
43
+ const KINDS_SET = new Set(['round', 'triage']);
44
+ const SEVERITIES = new Set(['blocker', 'major', 'minor']);
45
+ export const ORIGINS = ['first-draft', 'fold-induced', 'mechanics'];
46
+ const CLASSES = new Set(['fixable-bug', 'inherent-layer-residual', 'escalate']);
47
+ // v4 (BUGFREE-2 / D6): `refuted` — the honest lane for a phantom finding, refuted against code with
48
+ // a MANDATORY non-empty note citing the grounds; never silently dropped, never folded. Exported as
49
+ // the code-side vocabulary source the doc-parity lint (BUGFREE-3 / AD-049) checks the contract docs
50
+ // against — so the mode files can never drift from the schema's own class/scope lexicon.
51
+ export const V4_CLASSES = new Set([...CLASSES, 'refuted']);
52
+ const OVERRIDE_SCOPES = new Set(['oracle-change', 'red-proof']);
53
+ // v4 (BUGFREE-2 / D4): `size-cap` — the recorded waiver for a changed surface beyond the diff cap;
54
+ // exact payload carries the sanctioned magnitude, and it is SEGMENT-scoped (loop + base), unlike
55
+ // the two loop-scoped v3 scopes.
56
+ export const V4_OVERRIDE_SCOPES = new Set([...OVERRIDE_SCOPES, 'size-cap']);
57
+
58
+ // ── git-dir resolution (read-only queries; the ledger lives in the git dir, uncommittable) ──────
59
+
60
+ const gitLine = (args, cwd) => {
61
+ const r = spawnSync('git', args, { cwd, windowsHide: true });
62
+ if (r.error || r.status !== 0) return null;
63
+ return r.stdout.toString('utf8').replace(/\r?\n$/, '');
64
+ };
65
+
66
+ // The ledger path: AW_REVIEW_LEDGER overrides (mirrors AW_REVIEW_RECEIPTS); else <git dir>/basename.
67
+ // null when the cwd is not a git work tree (no git dir to anchor to).
68
+ export const resolveLedgerPath = (cwd, env = process.env) => {
69
+ if (env.AW_REVIEW_LEDGER) return env.AW_REVIEW_LEDGER;
70
+ const gitDir = gitLine(['rev-parse', '--absolute-git-dir'], cwd);
71
+ return gitDir == null ? null : join(gitDir, LEDGER_BASENAME);
72
+ };
73
+
74
+ // ── the segment (BUGFREE-2 / AD-048, D1) ─────────────────────────────────────────────────────────
75
+ // A SEGMENT = (activity, loop, base) where base = the commit the dirty tree sits on. Derived, never
76
+ // declared: `git rev-parse HEAD` is computed identically at write time and check time, matches the
77
+ // review's actual domain (the working-tree diff vs HEAD), and its reset is commit-gated — so
78
+ // resetting the round counter REQUIRES shipping a green, converged unit. An amend/rebase mid-loop
79
+ // orphans the segment's rounds — correct: the reviewed tree no longer exists.
80
+
81
+ // resolveBase(cwd) → the current HEAD commit sha, or null on an unborn branch / outside a git work
82
+ // tree (a caught refusal from git, never a crash — agy R1).
83
+ export const resolveBase = (cwd) => gitLine(['rev-parse', '--verify', '--quiet', 'HEAD'], cwd);
84
+
85
+ // ── schema validation (tolerant reader counts + surfaces malformed lines, never drops silently) ──
86
+
87
+ const isPlainObject = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
88
+ const isNonEmptyString = (v) => typeof v === 'string' && v.length > 0;
89
+ const isNonNegInt = (v) => Number.isInteger(v) && v >= 0;
90
+
91
+ // testId FORMAT (Decision 3): "<repo-relative test file>#<test-name-pattern>" — a "#" separator with
92
+ // BOTH halves non-empty. NO file-suffix rule: a suffix check would itself be a special case and would
93
+ // block a consumer's own naming (e.g. `.spec.js`; agy R1). The reader validates FORMAT only (it stays
94
+ // hermetic); the fold-completeness gate validates RESOLVABILITY via a bound-test probe run. Exported
95
+ // (with the splitter) as the single home of the format — the fold-completeness pair validates and
96
+ // splits testIds through THESE, so the format can never fork (BUGFREE-1 / AD-047).
97
+ const TESTID_SEPARATOR = '#';
98
+ export const isWellFormedTestId = (v) => {
99
+ if (typeof v !== 'string') return false;
100
+ const at = v.indexOf(TESTID_SEPARATOR);
101
+ return at > 0 && at < v.length - 1; // separator present, both halves non-empty
102
+ };
103
+ export const splitTestId = (v) => {
104
+ const at = v.indexOf(TESTID_SEPARATOR);
105
+ return { file: v.slice(0, at), pattern: v.slice(at + 1) };
106
+ };
107
+
108
+ // validateRound(obj) → { ok, reason }. Structural checks + the two internal-consistency invariants:
109
+ // the per-backend findings-by-severity equal that backend's counts, and the origins tally equals the
110
+ // aggregation of findings[].origin.
111
+ const validateRound = (obj) => {
112
+ if (!isPlainObject(obj.origins)) return { ok: false, reason: 'round: missing origins object' };
113
+ for (const k of ORIGINS) if (!isNonNegInt(obj.origins[k])) return { ok: false, reason: `round: origins.${k} must be a non-negative integer` };
114
+ if (!Array.isArray(obj.backends) || obj.backends.length === 0) return { ok: false, reason: 'round: backends must be a non-empty array' };
115
+ for (const b of obj.backends) {
116
+ if (!isPlainObject(b) || !isNonEmptyString(b.backend)) return { ok: false, reason: 'round: each backend needs a backend name' };
117
+ if (typeof b.degraded !== 'boolean') return { ok: false, reason: `round: backend ${b.backend} missing boolean degraded` };
118
+ if (!isNonNegInt(b.blockers) || !isNonNegInt(b.majors) || !isNonNegInt(b.minors)) return { ok: false, reason: `round: backend ${b.backend} counts must be non-negative integers` };
119
+ if (!isNonEmptyString(b.verdict)) return { ok: false, reason: `round: backend ${b.backend} missing verdict` };
120
+ // A degraded backend ran no real review: it MUST carry a reason, record 0/0/0 counts, and its
121
+ // verdict is exactly "degraded". Without this a degraded row could carry a blocking finding while
122
+ // convergence (which excludes degraded backends) still passes — a hidden-blocker hole (codex R1).
123
+ if (b.degraded === true) {
124
+ if (!isNonEmptyString(b.reason)) return { ok: false, reason: `round: degraded backend ${b.backend} must carry a reason` };
125
+ 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)` };
126
+ if (b.verdict !== 'degraded') return { ok: false, reason: `round: degraded backend ${b.backend} verdict must be "degraded"` };
127
+ }
128
+ }
129
+ // Duplicate backend names would make entryFor / the per-backend consistency ambiguous (agy R1).
130
+ if (new Set(obj.backends.map((b) => b.backend)).size !== obj.backends.length) return { ok: false, reason: 'round: duplicate backend name in backends[]' };
131
+ if (!Array.isArray(obj.findings)) return { ok: false, reason: 'round: findings must be an array' };
132
+ const backendSet = new Set(obj.backends.map((b) => b.backend));
133
+ const degradedSet = new Set(obj.backends.filter((b) => b.degraded === true).map((b) => b.backend));
134
+ for (const f of obj.findings) {
135
+ if (!isPlainObject(f) || !isNonEmptyString(f.findingKey)) return { ok: false, reason: 'round: each finding needs a findingKey' };
136
+ if (!SEVERITIES.has(f.severity)) return { ok: false, reason: `round: finding ${f.findingKey} bad severity "${f.severity}"` };
137
+ if (!ORIGINS.includes(f.origin)) return { ok: false, reason: `round: finding ${f.findingKey} bad origin "${f.origin}"` };
138
+ if (!isNonEmptyString(f.backend)) return { ok: false, reason: `round: finding ${f.findingKey} missing backend` };
139
+ if (!backendSet.has(f.backend)) return { ok: false, reason: `round: finding ${f.findingKey} backend "${f.backend}" is not in backends[]` };
140
+ 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)` };
141
+ }
142
+ // Internal consistency: per-backend findings-by-severity equal the recorded counts.
143
+ for (const b of obj.backends) {
144
+ const own = { blocker: 0, major: 0, minor: 0 };
145
+ for (const f of obj.findings) if (f.backend === b.backend) own[f.severity] += 1;
146
+ if (own.blocker !== b.blockers || own.major !== b.majors || own.minor !== b.minors) {
147
+ 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})` };
148
+ }
149
+ }
150
+ // Internal consistency: the origins tally equals the aggregation of findings[].origin.
151
+ const tally = { 'first-draft': 0, 'fold-induced': 0, mechanics: 0 };
152
+ for (const f of obj.findings) tally[f.origin] += 1;
153
+ 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]})` };
154
+ return { ok: true };
155
+ };
156
+
157
+ // validateTriage(obj, schema) → { ok, reason }. `schema` selects the per-version rules (v1 tolerant
158
+ // testId / v2 the test-per-fold binding / v4 the `refuted` class) — the shared structural checks
159
+ // run in every version.
160
+ const validateTriage = (obj, schema = SCHEMA_VERSION) => {
161
+ if (!Array.isArray(obj.classifications) || obj.classifications.length === 0) return { ok: false, reason: 'triage: classifications must be a non-empty array' };
162
+ const classes = schema >= 4 ? V4_CLASSES : CLASSES;
163
+ for (const c of obj.classifications) {
164
+ if (!isPlainObject(c) || !isNonEmptyString(c.findingKey)) return { ok: false, reason: 'triage: each classification needs a findingKey' };
165
+ if (!classes.has(c.class)) return { ok: false, reason: `triage: classification ${c.findingKey} bad class "${c.class}"` };
166
+ // D6 — `refuted` is the honest phantom-finding lane: the grounds are MANDATORY (a non-empty
167
+ // note citing what refutes it against code), never a silent drop.
168
+ 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)` };
169
+ if (typeof c.accepted !== 'boolean') return { ok: false, reason: `triage: classification ${c.findingKey} missing boolean accepted` };
170
+ // Structural (BOTH versions): testId is null/absent or a non-empty string — an ABSENT key is
171
+ // treated as null, never rejected here (agy R3). The writer normalizes it to null when stored.
172
+ 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` };
173
+ // Schema v2 (M2/AD-046) — the test-per-fold binding: a fixable-bug MUST carry a testId (the
174
+ // red→green test that pins the fold), and ANY present testId must be well-formed. v1 keeps the
175
+ // AD-045 rule (testId optional, unenforced) so historical/live v1 ledgers never become malformed.
176
+ if (schema >= 2) {
177
+ const present = isNonEmptyString(c.testId);
178
+ 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)` };
179
+ 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)` };
180
+ }
181
+ if (typeof c.note !== 'string') return { ok: false, reason: `triage: classification ${c.findingKey} note must be a string` };
182
+ }
183
+ return { ok: true };
184
+ };
185
+
186
+ // validateOverride(obj, schema) → { ok, reason }. v3+ (BUGFREE-1 / AD-047, D3): scope `oracle-change`
187
+ // carries non-empty repo-relative files[] + reason; scope `red-proof` carries a REQUIRED
188
+ // well-formed testId + reason, no files[]. v4 (BUGFREE-2 / D4) adds scope `size-cap`: a REQUIRED
189
+ // positive-integer sanctionedLines — the exact magnitude the waiver sanctions, segment-scoped.
190
+ // Payloads are EXACT — a stray cross-scope field is a forgery smell, rejected. The fingerprint is
191
+ // recorded for audit only.
192
+ const OVERRIDE_SHARED_KEYS = new Set(['schema', 'loop', 'activity', 'kind', 'round', 'base', 'fingerprint', 'timestamp', 'scope', 'reason']);
193
+ const OVERRIDE_PAYLOAD_KEY = { 'oracle-change': 'files', 'red-proof': 'testId', 'size-cap': 'sanctionedLines' };
194
+ const validateOverride = (obj, schema = SCHEMA_VERSION) => {
195
+ const scopes = schema >= 4 ? V4_OVERRIDE_SCOPES : OVERRIDE_SCOPES;
196
+ if (!scopes.has(obj.scope)) return { ok: false, reason: `override: bad scope "${obj.scope}" (expected ${[...scopes].join(' | ')})` };
197
+ // EXACT per-scope payloads via an allow-list (codex R5): a stray key — a cross-scope field or an
198
+ // arbitrary hand-added one — is a forgery smell, rejected by name (the mutation-shape precedent).
199
+ const payloadKey = OVERRIDE_PAYLOAD_KEY[obj.scope];
200
+ for (const k of Object.keys(obj)) {
201
+ if (!OVERRIDE_SHARED_KEYS.has(k) && k !== payloadKey) return { ok: false, reason: `override: unknown key "${k}" (exact per-scope payloads: shared frame + ${payloadKey})` };
202
+ }
203
+ if (!isNonEmptyString(obj.reason)) return { ok: false, reason: 'override: a non-empty reason is required (never a silent waiver)' };
204
+ if (obj.scope === 'oracle-change') {
205
+ if (!Array.isArray(obj.files) || obj.files.length === 0) return { ok: false, reason: 'override: oracle-change files[] must be a non-empty array' };
206
+ for (const f of obj.files) {
207
+ 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)})` };
208
+ }
209
+ return { ok: true };
210
+ }
211
+ if (obj.scope === 'size-cap') {
212
+ 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' };
213
+ return { ok: true };
214
+ }
215
+ if (!isWellFormedTestId(obj.testId)) return { ok: false, reason: 'override: a red-proof override requires a well-formed testId "<test-file>#<test-name-pattern>"' };
216
+ return { ok: true };
217
+ };
218
+
219
+ // ── the gate-run record (BUGFREE-2 / D5): the green-baseline receipt run-gates --record mints ────
220
+
221
+ // Per-kind frame rule: a gate-run carries the SEGMENT frame (loop, base, fingerprint before/after,
222
+ // timestamp) and NO round number. The body is machine-composed by recordGateRun, so the key set is
223
+ // EXACT (the override allow-list precedent): declared[] = the FULL declaration at run time (id +
224
+ // cmd — the cmd is what the process-gate classification reads); results[] = what actually ran (a
225
+ // --only subset records exactly that subset, honestly); summary mirrors the runner's machine
226
+ // summary line, consistency-checked against results[] so a forged verdict cannot ride beside
227
+ // honest-looking evidence.
228
+ const GATE_RUN_KEYS = new Set(['schema', 'loop', 'activity', 'kind', 'base', 'fingerprint', 'fingerprintAfter', 'declared', 'results', 'summary', 'timestamp']);
229
+ const SUMMARY_KEYS = ['failed', 'failedIds', 'gates', 'passed', 'status'];
230
+ // Gate ids are kebab-case — the same closed shape run-gates.mjs enforces on the declaration; it
231
+ // also kills the comma-aliasing class in the failedIds compare (internal sweep).
232
+ const GATE_RUN_ID_RE = /^[a-z0-9]+(-[a-z0-9]+)*$/;
233
+ const validateGateRun = (obj) => {
234
+ for (const k of Object.keys(obj)) {
235
+ if (!GATE_RUN_KEYS.has(k)) return { ok: false, reason: `gate-run: unknown key "${k}" (exact machine-composed payload)` };
236
+ }
237
+ 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)' };
238
+ 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 }' };
239
+ const declaredIds = new Set();
240
+ for (const d of obj.declared) {
241
+ 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)' };
242
+ 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)` };
243
+ if (declaredIds.has(d.id)) return { ok: false, reason: `gate-run: duplicate declared id "${d.id}"` };
244
+ declaredIds.add(d.id);
245
+ }
246
+ 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 }' };
247
+ const resultIds = new Set();
248
+ for (const r of obj.results) {
249
+ 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)' };
250
+ if (!declaredIds.has(r.id)) return { ok: false, reason: `gate-run: result id "${r.id}" is not in declared[]` };
251
+ if (resultIds.has(r.id)) return { ok: false, reason: `gate-run: duplicate result id "${r.id}"` };
252
+ resultIds.add(r.id);
253
+ }
254
+ const s = obj.summary;
255
+ if (!isPlainObject(s)) return { ok: false, reason: 'gate-run: summary must be an object' };
256
+ if (Object.keys(s).sort().join(',') !== SUMMARY_KEYS.join(',')) return { ok: false, reason: `gate-run: summary must carry exactly { ${SUMMARY_KEYS.join(', ')} }` };
257
+ const failing = obj.results.filter((r) => !r.ok);
258
+ // The status IS the verdict word: a valid gate-run always carries results, so the runner's
259
+ // vocabulary collapses to ok|fail here — tie it to the failing count (a forged "ok" beside red
260
+ // results must not validate; internal sweep).
261
+ const expectedStatus = failing.length === 0 ? 'ok' : 'fail';
262
+ 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)})` };
263
+ if (s.gates !== obj.results.length || s.passed !== obj.results.length - failing.length || s.failed !== failing.length) {
264
+ 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)` };
265
+ }
266
+ 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' };
267
+ return { ok: true };
268
+ };
269
+
270
+ // validateRecord(obj) → { ok, reason }. The shared frame (schema/loop/activity/kind/round/base/
271
+ // fingerprint/timestamp) then the per-kind body. `reason` names the exact failed check so the
272
+ // malformed-line surface and the per-check named tests can assert it. Kind vocabulary is
273
+ // per-version: `override` exists only under schema >= 3, `gate-run` only under schema >= 4, and
274
+ // `base` is a v4-only frame field (older records never grow new kinds OR new fields — D2).
275
+ export const validateRecord = (obj) => {
276
+ if (!isPlainObject(obj)) return { ok: false, reason: 'not an object' };
277
+ if (!SUPPORTED_SCHEMAS.has(obj.schema)) return { ok: false, reason: `schema must be one of ${[...SUPPORTED_SCHEMAS].join(', ')}` };
278
+ if (!isNonEmptyString(obj.loop)) return { ok: false, reason: 'missing loop' };
279
+ if (!ACTIVITIES_SET.has(obj.activity)) return { ok: false, reason: `bad activity "${obj.activity}"` };
280
+ 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}"` };
281
+ // The v4 SEGMENT frame (D1/D2): a v4 record REQUIRES base (null on an unborn branch, else the
282
+ // HEAD sha); a v1..v3 record must NOT carry it — an old record never grows new surface.
283
+ if (obj.schema >= 4) {
284
+ 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' };
285
+ } else if (obj.base !== undefined) {
286
+ return { ok: false, reason: `base is a v4 frame field — a schema-${obj.schema} record never carries it` };
287
+ }
288
+ // Per-kind frame (D5): a gate-run carries NO round number; every other kind requires one.
289
+ if (obj.kind === 'gate-run') {
290
+ if (obj.round !== undefined) return { ok: false, reason: 'gate-run: a gate-run carries no round number (it is segment-framed, not round-framed)' };
291
+ } else if (!(Number.isInteger(obj.round) && obj.round >= 1)) {
292
+ return { ok: false, reason: 'round must be an integer >= 1' };
293
+ }
294
+ if (!(obj.fingerprint === null || isNonEmptyString(obj.fingerprint))) return { ok: false, reason: 'fingerprint must be null or a non-empty string' };
295
+ if (!isNonEmptyString(obj.timestamp)) return { ok: false, reason: 'missing timestamp' };
296
+ if (obj.kind === 'round') return validateRound(obj);
297
+ if (obj.kind === 'override') return validateOverride(obj, obj.schema);
298
+ if (obj.kind === 'gate-run') return validateGateRun(obj);
299
+ return validateTriage(obj, obj.schema);
300
+ };
301
+
302
+ // readLedger(path) → { records, malformed, malformedReasons }. Absent file → empty (no review ran).
303
+ // A malformed line is counted + its reason surfaced, never silently dropped (mirrors readReceipts).
304
+ export const readLedger = (path, readFile = readFileSync) => {
305
+ let raw;
306
+ try {
307
+ raw = readFile(path, 'utf8');
308
+ } catch (err) {
309
+ // An ABSENT file → empty (no review ran). A non-ENOENT read error (EACCES/EIO) is NOT "no records":
310
+ // treating it as empty would fail the teeth OPEN and could clobber the ledger on rewrite (codex R1).
311
+ // Surface it as a readError so every caller (the writer, the gate) fails CLOSED.
312
+ if (err && err.code === 'ENOENT') return { records: [], malformed: 0, malformedReasons: [] };
313
+ return { records: [], malformed: 0, malformedReasons: [], readError: (err && err.code) || (err && err.message) || 'read failed' };
314
+ }
315
+ const records = [];
316
+ const malformedReasons = [];
317
+ for (const line of raw.split('\n')) {
318
+ if (line.trim() === '') continue;
319
+ let parsed;
320
+ try {
321
+ parsed = JSON.parse(line);
322
+ } catch (err) {
323
+ malformedReasons.push(`unparseable JSON (${err.message})`);
324
+ continue;
325
+ }
326
+ const v = validateRecord(parsed);
327
+ if (v.ok) records.push(parsed);
328
+ else malformedReasons.push(v.reason);
329
+ }
330
+ return { records, malformed: malformedReasons.length, malformedReasons };
331
+ };
332
+
333
+ // filterLoopRecords(records, { activity, loop }) → the records of ONE loop (all kinds), order
334
+ // preserved. The gate filters to activity==="plan-execution" AND loop===the in-flight plan stem;
335
+ // authoring rounds (and other plans' rounds) never enter the code gate.
336
+ export const filterLoopRecords = (records, { activity, loop }) =>
337
+ records.filter((r) => r.activity === activity && r.loop === loop);
338
+
339
+ // filterSegmentRecords(records, { activity, loop, base }) → the records of ONE segment (D1), order
340
+ // preserved. STRICT: only a v4+ record can be a segment member (a v1..v3 record carries no base and
341
+ // never enters one — the D7 legacy rule; the schema guard also keeps a defensive undefined base
342
+ // from matching a pre-v4 record's absent field, codex R1); records at baseA are invisible at baseB;
343
+ // base === null matches only null (an unborn-branch segment).
344
+ export const filterSegmentRecords = (records, { activity, loop, base }) =>
345
+ filterLoopRecords(records, { activity, loop }).filter((r) => r.schema >= 4 && r.base === base);
346
+
347
+ // roundSequenceIntact(records) → true iff the round records, in file order, number exactly 1,2,…,n
348
+ // (no duplicate, gap, or out-of-order round). Checks the EXISTING sequence, not just the incoming
349
+ // round: a ledger like [2] / [1,1] / [2,1] (reachable only by hand-editing the git-dir file — the
350
+ // stated residual) must fail closed rather than be trusted to compute the "latest" round (codex R3).
351
+ export const roundSequenceIntact = (records) => {
352
+ const nums = records.filter((r) => r.kind === 'round').map((r) => r.round);
353
+ return nums.every((n, i) => n === i + 1);
354
+ };
@@ -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,50 +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. Exported as
108
- // the code-side vocabulary source the doc-parity lint (BUGFREE-3 / AD-049) checks the contract docs
109
- // against — so the mode files can never drift from the schema's own class/scope lexicon.
110
- export const V4_CLASSES = new Set([...CLASSES, 'refuted']);
111
- const OVERRIDE_SCOPES = new Set(['oracle-change', 'red-proof']);
112
- // v4 (BUGFREE-2 / D4): `size-cap` — the recorded waiver for a changed surface beyond the diff cap;
113
- // exact payload carries the sanctioned magnitude, and it is SEGMENT-scoped (loop + base), unlike
114
- // the two loop-scoped v3 scopes.
115
- export const V4_OVERRIDE_SCOPES = new Set([...OVERRIDE_SCOPES, 'size-cap']);
116
-
117
- // ── git-dir resolution (read-only queries; the ledger lives in the git dir, uncommittable) ──────
107
+
108
+ // ── git-dir resolution (read-only queries) ──────────────────────────────────────────────────────
118
109
 
119
110
  const gitLine = (args, cwd) => {
120
111
  const r = spawnSync('git', args, { cwd, windowsHide: true });
@@ -124,25 +115,6 @@ const gitLine = (args, cwd) => {
124
115
 
125
116
  const gitRoot = (cwd) => gitLine(['rev-parse', '--show-toplevel'], cwd);
126
117
 
127
- // The ledger path: AW_REVIEW_LEDGER overrides (mirrors AW_REVIEW_RECEIPTS); else <git dir>/basename.
128
- // null when the cwd is not a git work tree (no git dir to anchor to).
129
- export const resolveLedgerPath = (cwd, env = process.env) => {
130
- if (env.AW_REVIEW_LEDGER) return env.AW_REVIEW_LEDGER;
131
- const gitDir = gitLine(['rev-parse', '--absolute-git-dir'], cwd);
132
- return gitDir == null ? null : join(gitDir, LEDGER_BASENAME);
133
- };
134
-
135
- // ── the segment (BUGFREE-2 / AD-048, D1) ─────────────────────────────────────────────────────────
136
- // A SEGMENT = (activity, loop, base) where base = the commit the dirty tree sits on. Derived, never
137
- // declared: `git rev-parse HEAD` is computed identically at write time and check time, matches the
138
- // review's actual domain (the working-tree diff vs HEAD), and its reset is commit-gated — so
139
- // resetting the round counter REQUIRES shipping a green, converged unit. An amend/rebase mid-loop
140
- // orphans the segment's rounds — correct: the reviewed tree no longer exists.
141
-
142
- // resolveBase(cwd) → the current HEAD commit sha, or null on an unborn branch / outside a git work
143
- // tree (a caught refusal from git, never a crash — agy R1).
144
- export const resolveBase = (cwd) => gitLine(['rev-parse', '--verify', '--quiet', 'HEAD'], cwd);
145
-
146
118
  // ── ship-verdict mapping (the single home; a named test pins it) ────────────────────────────────
147
119
 
148
120
  // isShipVerdict(verdict) — which free-text review verdicts are ship-class. SHIP / SHIP WITH NITS are
@@ -152,191 +124,6 @@ export const isShipVerdict = (verdict) => {
152
124
  return v === 'ship' || v === 'ship with nits';
153
125
  };
154
126
 
155
- // ── schema validation (tolerant reader counts + surfaces malformed lines, never drops silently) ──
156
-
157
- const isPlainObject = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
158
- const isNonEmptyString = (v) => typeof v === 'string' && v.length > 0;
159
- const isNonNegInt = (v) => Number.isInteger(v) && v >= 0;
160
-
161
- // testId FORMAT (Decision 3): "<repo-relative test file>#<test-name-pattern>" — a "#" separator with
162
- // BOTH halves non-empty. NO file-suffix rule: a suffix check would itself be a special case and would
163
- // block a consumer's own naming (e.g. `.spec.js`; agy R1). The reader validates FORMAT only (it stays
164
- // hermetic); the fold-completeness gate validates RESOLVABILITY via a bound-test probe run. Exported
165
- // (with the splitter) as the single home of the format — the fold-completeness pair validates and
166
- // splits testIds through THESE, so the format can never fork (BUGFREE-1 / AD-047).
167
- const TESTID_SEPARATOR = '#';
168
- export const isWellFormedTestId = (v) => {
169
- if (typeof v !== 'string') return false;
170
- const at = v.indexOf(TESTID_SEPARATOR);
171
- return at > 0 && at < v.length - 1; // separator present, both halves non-empty
172
- };
173
- export const splitTestId = (v) => {
174
- const at = v.indexOf(TESTID_SEPARATOR);
175
- return { file: v.slice(0, at), pattern: v.slice(at + 1) };
176
- };
177
-
178
- // validateRound(obj) → { ok, reason }. Structural checks + the two internal-consistency invariants:
179
- // the per-backend findings-by-severity equal that backend's counts, and the origins tally equals the
180
- // aggregation of findings[].origin.
181
- const validateRound = (obj) => {
182
- if (!isPlainObject(obj.origins)) return { ok: false, reason: 'round: missing origins object' };
183
- for (const k of ORIGINS) if (!isNonNegInt(obj.origins[k])) return { ok: false, reason: `round: origins.${k} must be a non-negative integer` };
184
- if (!Array.isArray(obj.backends) || obj.backends.length === 0) return { ok: false, reason: 'round: backends must be a non-empty array' };
185
- for (const b of obj.backends) {
186
- if (!isPlainObject(b) || !isNonEmptyString(b.backend)) return { ok: false, reason: 'round: each backend needs a backend name' };
187
- if (typeof b.degraded !== 'boolean') return { ok: false, reason: `round: backend ${b.backend} missing boolean degraded` };
188
- if (!isNonNegInt(b.blockers) || !isNonNegInt(b.majors) || !isNonNegInt(b.minors)) return { ok: false, reason: `round: backend ${b.backend} counts must be non-negative integers` };
189
- if (!isNonEmptyString(b.verdict)) return { ok: false, reason: `round: backend ${b.backend} missing verdict` };
190
- // A degraded backend ran no real review: it MUST carry a reason, record 0/0/0 counts, and its
191
- // verdict is exactly "degraded". Without this a degraded row could carry a blocking finding while
192
- // convergence (which excludes degraded backends) still passes — a hidden-blocker hole (codex R1).
193
- if (b.degraded === true) {
194
- if (!isNonEmptyString(b.reason)) return { ok: false, reason: `round: degraded backend ${b.backend} must carry a reason` };
195
- 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)` };
196
- if (b.verdict !== 'degraded') return { ok: false, reason: `round: degraded backend ${b.backend} verdict must be "degraded"` };
197
- }
198
- }
199
- // Duplicate backend names would make entryFor / the per-backend consistency ambiguous (agy R1).
200
- if (new Set(obj.backends.map((b) => b.backend)).size !== obj.backends.length) return { ok: false, reason: 'round: duplicate backend name in backends[]' };
201
- if (!Array.isArray(obj.findings)) return { ok: false, reason: 'round: findings must be an array' };
202
- const backendSet = new Set(obj.backends.map((b) => b.backend));
203
- const degradedSet = new Set(obj.backends.filter((b) => b.degraded === true).map((b) => b.backend));
204
- for (const f of obj.findings) {
205
- if (!isPlainObject(f) || !isNonEmptyString(f.findingKey)) return { ok: false, reason: 'round: each finding needs a findingKey' };
206
- if (!SEVERITIES.has(f.severity)) return { ok: false, reason: `round: finding ${f.findingKey} bad severity "${f.severity}"` };
207
- if (!ORIGINS.includes(f.origin)) return { ok: false, reason: `round: finding ${f.findingKey} bad origin "${f.origin}"` };
208
- if (!isNonEmptyString(f.backend)) return { ok: false, reason: `round: finding ${f.findingKey} missing backend` };
209
- if (!backendSet.has(f.backend)) return { ok: false, reason: `round: finding ${f.findingKey} backend "${f.backend}" is not in backends[]` };
210
- 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)` };
211
- }
212
- // Internal consistency: per-backend findings-by-severity equal the recorded counts.
213
- for (const b of obj.backends) {
214
- const own = { blocker: 0, major: 0, minor: 0 };
215
- for (const f of obj.findings) if (f.backend === b.backend) own[f.severity] += 1;
216
- if (own.blocker !== b.blockers || own.major !== b.majors || own.minor !== b.minors) {
217
- 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})` };
218
- }
219
- }
220
- // Internal consistency: the origins tally equals the aggregation of findings[].origin.
221
- const tally = { 'first-draft': 0, 'fold-induced': 0, mechanics: 0 };
222
- for (const f of obj.findings) tally[f.origin] += 1;
223
- 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]})` };
224
- return { ok: true };
225
- };
226
-
227
- // validateTriage(obj, schema) → { ok, reason }. `schema` selects the per-version rules (v1 tolerant
228
- // testId / v2 the test-per-fold binding / v4 the `refuted` class) — the shared structural checks
229
- // run in every version.
230
- const validateTriage = (obj, schema = SCHEMA_VERSION) => {
231
- if (!Array.isArray(obj.classifications) || obj.classifications.length === 0) return { ok: false, reason: 'triage: classifications must be a non-empty array' };
232
- const classes = schema >= 4 ? V4_CLASSES : CLASSES;
233
- for (const c of obj.classifications) {
234
- if (!isPlainObject(c) || !isNonEmptyString(c.findingKey)) return { ok: false, reason: 'triage: each classification needs a findingKey' };
235
- if (!classes.has(c.class)) return { ok: false, reason: `triage: classification ${c.findingKey} bad class "${c.class}"` };
236
- // D6 — `refuted` is the honest phantom-finding lane: the grounds are MANDATORY (a non-empty
237
- // note citing what refutes it against code), never a silent drop.
238
- 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)` };
239
- if (typeof c.accepted !== 'boolean') return { ok: false, reason: `triage: classification ${c.findingKey} missing boolean accepted` };
240
- // Structural (BOTH versions): testId is null/absent or a non-empty string — an ABSENT key is
241
- // treated as null, never rejected here (agy R3). The writer normalizes it to null when stored.
242
- 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` };
243
- // Schema v2 (M2/AD-046) — the test-per-fold binding: a fixable-bug MUST carry a testId (the
244
- // red→green test that pins the fold), and ANY present testId must be well-formed. v1 keeps the
245
- // AD-045 rule (testId optional, unenforced) so historical/live v1 ledgers never become malformed.
246
- if (schema >= 2) {
247
- const present = isNonEmptyString(c.testId);
248
- 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)` };
249
- 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)` };
250
- }
251
- if (typeof c.note !== 'string') return { ok: false, reason: `triage: classification ${c.findingKey} note must be a string` };
252
- }
253
- return { ok: true };
254
- };
255
-
256
- // validateOverride(obj, schema) → { ok, reason }. v3+ (BUGFREE-1 / AD-047, D3): scope `oracle-change`
257
- // carries non-empty repo-relative files[] + reason; scope `red-proof` carries a REQUIRED
258
- // well-formed testId + reason, no files[]. v4 (BUGFREE-2 / D4) adds scope `size-cap`: a REQUIRED
259
- // positive-integer sanctionedLines — the exact magnitude the waiver sanctions, segment-scoped.
260
- // Payloads are EXACT — a stray cross-scope field is a forgery smell, rejected. The fingerprint is
261
- // recorded for audit only.
262
- const OVERRIDE_SHARED_KEYS = new Set(['schema', 'loop', 'activity', 'kind', 'round', 'base', 'fingerprint', 'timestamp', 'scope', 'reason']);
263
- const OVERRIDE_PAYLOAD_KEY = { 'oracle-change': 'files', 'red-proof': 'testId', 'size-cap': 'sanctionedLines' };
264
- const validateOverride = (obj, schema = SCHEMA_VERSION) => {
265
- const scopes = schema >= 4 ? V4_OVERRIDE_SCOPES : OVERRIDE_SCOPES;
266
- if (!scopes.has(obj.scope)) return { ok: false, reason: `override: bad scope "${obj.scope}" (expected ${[...scopes].join(' | ')})` };
267
- // EXACT per-scope payloads via an allow-list (codex R5): a stray key — a cross-scope field or an
268
- // arbitrary hand-added one — is a forgery smell, rejected by name (the mutation-shape precedent).
269
- const payloadKey = OVERRIDE_PAYLOAD_KEY[obj.scope];
270
- for (const k of Object.keys(obj)) {
271
- if (!OVERRIDE_SHARED_KEYS.has(k) && k !== payloadKey) return { ok: false, reason: `override: unknown key "${k}" (exact per-scope payloads: shared frame + ${payloadKey})` };
272
- }
273
- if (!isNonEmptyString(obj.reason)) return { ok: false, reason: 'override: a non-empty reason is required (never a silent waiver)' };
274
- if (obj.scope === 'oracle-change') {
275
- if (!Array.isArray(obj.files) || obj.files.length === 0) return { ok: false, reason: 'override: oracle-change files[] must be a non-empty array' };
276
- for (const f of obj.files) {
277
- 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)})` };
278
- }
279
- return { ok: true };
280
- }
281
- if (obj.scope === 'size-cap') {
282
- 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' };
283
- return { ok: true };
284
- }
285
- if (!isWellFormedTestId(obj.testId)) return { ok: false, reason: 'override: a red-proof override requires a well-formed testId "<test-file>#<test-name-pattern>"' };
286
- return { ok: true };
287
- };
288
-
289
- // ── the gate-run record (BUGFREE-2 / D5): the green-baseline receipt run-gates --record mints ────
290
-
291
- // Per-kind frame rule: a gate-run carries the SEGMENT frame (loop, base, fingerprint before/after,
292
- // timestamp) and NO round number. The body is machine-composed by recordGateRun, so the key set is
293
- // EXACT (the override allow-list precedent): declared[] = the FULL declaration at run time (id +
294
- // cmd — the cmd is what the process-gate classification reads); results[] = what actually ran (a
295
- // --only subset records exactly that subset, honestly); summary mirrors the runner's machine
296
- // summary line, consistency-checked against results[] so a forged verdict cannot ride beside
297
- // honest-looking evidence.
298
- const GATE_RUN_KEYS = new Set(['schema', 'loop', 'activity', 'kind', 'base', 'fingerprint', 'fingerprintAfter', 'declared', 'results', 'summary', 'timestamp']);
299
- const SUMMARY_KEYS = ['failed', 'failedIds', 'gates', 'passed', 'status'];
300
- // Gate ids are kebab-case — the same closed shape run-gates.mjs enforces on the declaration; it
301
- // also kills the comma-aliasing class in the failedIds compare (internal sweep).
302
- const GATE_RUN_ID_RE = /^[a-z0-9]+(-[a-z0-9]+)*$/;
303
- const validateGateRun = (obj) => {
304
- for (const k of Object.keys(obj)) {
305
- if (!GATE_RUN_KEYS.has(k)) return { ok: false, reason: `gate-run: unknown key "${k}" (exact machine-composed payload)` };
306
- }
307
- 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)' };
308
- 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 }' };
309
- const declaredIds = new Set();
310
- for (const d of obj.declared) {
311
- 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)' };
312
- 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)` };
313
- if (declaredIds.has(d.id)) return { ok: false, reason: `gate-run: duplicate declared id "${d.id}"` };
314
- declaredIds.add(d.id);
315
- }
316
- 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 }' };
317
- const resultIds = new Set();
318
- for (const r of obj.results) {
319
- 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)' };
320
- if (!declaredIds.has(r.id)) return { ok: false, reason: `gate-run: result id "${r.id}" is not in declared[]` };
321
- if (resultIds.has(r.id)) return { ok: false, reason: `gate-run: duplicate result id "${r.id}"` };
322
- resultIds.add(r.id);
323
- }
324
- const s = obj.summary;
325
- if (!isPlainObject(s)) return { ok: false, reason: 'gate-run: summary must be an object' };
326
- if (Object.keys(s).sort().join(',') !== SUMMARY_KEYS.join(',')) return { ok: false, reason: `gate-run: summary must carry exactly { ${SUMMARY_KEYS.join(', ')} }` };
327
- const failing = obj.results.filter((r) => !r.ok);
328
- // The status IS the verdict word: a valid gate-run always carries results, so the runner's
329
- // vocabulary collapses to ok|fail here — tie it to the failing count (a forged "ok" beside red
330
- // results must not validate; internal sweep).
331
- const expectedStatus = failing.length === 0 ? 'ok' : 'fail';
332
- 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)})` };
333
- if (s.gates !== obj.results.length || s.passed !== obj.results.length - failing.length || s.failed !== failing.length) {
334
- 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)` };
335
- }
336
- 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' };
337
- return { ok: true };
338
- };
339
-
340
127
  // isProcessGateCmd(cmd) — the CLOSED, kit-owned process-gate classification (D5): the kit's own
341
128
  // process-loop `--check` commands (review-state / review-ledger / fold-completeness) legitimately
342
129
  // fail MID-loop, so "quality-green" excludes them — without the carve-out the D5 tooth is
@@ -363,83 +150,6 @@ export const isQualityGreenGateRun = (record) => {
363
150
  return record.declared.every((d) => isProcessGateCmd(d.cmd) || green.has(d.id));
364
151
  };
365
152
 
366
- // validateRecord(obj) → { ok, reason }. The shared frame (schema/loop/activity/kind/round/base/
367
- // fingerprint/timestamp) then the per-kind body. `reason` names the exact failed check so the
368
- // malformed-line surface and the per-check named tests can assert it. Kind vocabulary is
369
- // per-version: `override` exists only under schema >= 3, `gate-run` only under schema >= 4, and
370
- // `base` is a v4-only frame field (older records never grow new kinds OR new fields — D2).
371
- export const validateRecord = (obj) => {
372
- if (!isPlainObject(obj)) return { ok: false, reason: 'not an object' };
373
- if (!SUPPORTED_SCHEMAS.has(obj.schema)) return { ok: false, reason: `schema must be one of ${[...SUPPORTED_SCHEMAS].join(', ')}` };
374
- if (!isNonEmptyString(obj.loop)) return { ok: false, reason: 'missing loop' };
375
- if (!ACTIVITIES_SET.has(obj.activity)) return { ok: false, reason: `bad activity "${obj.activity}"` };
376
- 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}"` };
377
- // The v4 SEGMENT frame (D1/D2): a v4 record REQUIRES base (null on an unborn branch, else the
378
- // HEAD sha); a v1..v3 record must NOT carry it — an old record never grows new surface.
379
- if (obj.schema >= 4) {
380
- 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' };
381
- } else if (obj.base !== undefined) {
382
- return { ok: false, reason: `base is a v4 frame field — a schema-${obj.schema} record never carries it` };
383
- }
384
- // Per-kind frame (D5): a gate-run carries NO round number; every other kind requires one.
385
- if (obj.kind === 'gate-run') {
386
- if (obj.round !== undefined) return { ok: false, reason: 'gate-run: a gate-run carries no round number (it is segment-framed, not round-framed)' };
387
- } else if (!(Number.isInteger(obj.round) && obj.round >= 1)) {
388
- return { ok: false, reason: 'round must be an integer >= 1' };
389
- }
390
- if (!(obj.fingerprint === null || isNonEmptyString(obj.fingerprint))) return { ok: false, reason: 'fingerprint must be null or a non-empty string' };
391
- if (!isNonEmptyString(obj.timestamp)) return { ok: false, reason: 'missing timestamp' };
392
- if (obj.kind === 'round') return validateRound(obj);
393
- if (obj.kind === 'override') return validateOverride(obj, obj.schema);
394
- if (obj.kind === 'gate-run') return validateGateRun(obj);
395
- return validateTriage(obj, obj.schema);
396
- };
397
-
398
- // readLedger(path) → { records, malformed, malformedReasons }. Absent file → empty (no review ran).
399
- // A malformed line is counted + its reason surfaced, never silently dropped (mirrors readReceipts).
400
- export const readLedger = (path, readFile = readFileSync) => {
401
- let raw;
402
- try {
403
- raw = readFile(path, 'utf8');
404
- } catch (err) {
405
- // An ABSENT file → empty (no review ran). A non-ENOENT read error (EACCES/EIO) is NOT "no records":
406
- // treating it as empty would fail the teeth OPEN and could clobber the ledger on rewrite (codex R1).
407
- // Surface it as a readError so every caller (the writer, the gate) fails CLOSED.
408
- if (err && err.code === 'ENOENT') return { records: [], malformed: 0, malformedReasons: [] };
409
- return { records: [], malformed: 0, malformedReasons: [], readError: (err && err.code) || (err && err.message) || 'read failed' };
410
- }
411
- const records = [];
412
- const malformedReasons = [];
413
- for (const line of raw.split('\n')) {
414
- if (line.trim() === '') continue;
415
- let parsed;
416
- try {
417
- parsed = JSON.parse(line);
418
- } catch (err) {
419
- malformedReasons.push(`unparseable JSON (${err.message})`);
420
- continue;
421
- }
422
- const v = validateRecord(parsed);
423
- if (v.ok) records.push(parsed);
424
- else malformedReasons.push(v.reason);
425
- }
426
- return { records, malformed: malformedReasons.length, malformedReasons };
427
- };
428
-
429
- // filterLoopRecords(records, { activity, loop }) → the records of ONE loop (all kinds), order
430
- // preserved. The gate filters to activity==="plan-execution" AND loop===the in-flight plan stem;
431
- // authoring rounds (and other plans' rounds) never enter the code gate.
432
- export const filterLoopRecords = (records, { activity, loop }) =>
433
- records.filter((r) => r.activity === activity && r.loop === loop);
434
-
435
- // filterSegmentRecords(records, { activity, loop, base }) → the records of ONE segment (D1), order
436
- // preserved. STRICT: only a v4+ record can be a segment member (a v1..v3 record carries no base and
437
- // never enters one — the D7 legacy rule; the schema guard also keeps a defensive undefined base
438
- // from matching a pre-v4 record's absent field, codex R1); records at baseA are invisible at baseB;
439
- // base === null matches only null (an unborn-branch segment).
440
- export const filterSegmentRecords = (records, { activity, loop, base }) =>
441
- filterLoopRecords(records, { activity, loop }).filter((r) => r.schema >= 4 && r.base === base);
442
-
443
153
  // collectOverrides(records, { activity, loop }) → { oracleChangeFiles: Set, redProofTestIds: Set }.
444
154
  // The UNION of the loop's recorded v3-scope override payloads — loop + payload scoped, never
445
155
  // fingerprint-bound (D3: re-affirmation churn on every later edit would train rubber-stamping). The
@@ -469,15 +179,6 @@ export const collectSizeCapLimit = (records, { activity = ACTIVITY, loop, base }
469
179
  return limit;
470
180
  };
471
181
 
472
- // roundSequenceIntact(records) → true iff the round records, in file order, number exactly 1,2,…,n
473
- // (no duplicate, gap, or out-of-order round). Checks the EXISTING sequence, not just the incoming
474
- // round: a ledger like [2] / [1,1] / [2,1] (reachable only by hand-editing the git-dir file — the
475
- // stated residual) must fail closed rather than be trusted to compute the "latest" round (codex R3).
476
- export const roundSequenceIntact = (records) => {
477
- const nums = records.filter((r) => r.kind === 'round').map((r) => r.round);
478
- return nums.every((n, i) => n === i + 1);
479
- };
480
-
481
182
  // ── the computed crossover-stop (pure; machine fields only) ─────────────────────────────────────
482
183
 
483
184
  const BLOCKING = new Set(['blocker', 'major']);
@@ -15,11 +15,18 @@
15
15
  // EXECUTE- / FEEDBACK-, or a name containing PROMPT / prompt / handoff); when the tree is
16
16
  // clean (nothing to review); when the cwd is not a git work tree (nothing to fingerprint);
17
17
  // and when EVERY recipe-named backend has a current-fingerprint receipt with acceptable
18
- // grounding (fresh:true, artifact "code", grounded:true).
19
- // exit 1 when a recipe-named backend has no current-fingerprint receipt — including the
20
- // stale-after-edit case (any tracked/untracked change after the review moves the
21
- // fingerprint) or when its only current receipts carry grounded:false (an ungrounded
22
- // agy review under reviewed/council never satisfies the gate).
18
+ // grounding (fresh:true, artifact "code", grounded:true) OR is degraded-exempt: the current
19
+ // plan-execution SEGMENT's latest review-ledger round records that backend degraded:true at
20
+ // the current tree fingerprint, with >= 1 non-degraded recipe-named backend present with a
21
+ // current grounded receipt and the ledger reading clean (AD-050; MIRRORS review-ledger
22
+ // decideStop's degraded handling presence, not unanimity, never a 0/0-counts gate).
23
+ // exit 1 when a recipe-named backend has no current-fingerprint receipt AND is not degraded-exempt —
24
+ // including the stale-after-edit case (any tracked/untracked change after the review moves the
25
+ // fingerprint) — or when its only current receipts carry grounded:false AND it is not
26
+ // degraded-exempt (an ungrounded agy review under reviewed/council never satisfies the gate on
27
+ // its own — but a recorded current-tree degrade still exempts it). An unreadable/malformed
28
+ // review-ledger DENIES the degraded exemption (fail-closed) but NEVER fails a tree whose
29
+ // receipts independently satisfy the gate (that stays exit 0, the ledger issue surfaced).
23
30
  // Informational receipts NEVER satisfy (nor fail) the tree check: plan/diff-mode receipts
24
31
  // (artifact ≠ "code") and continuations (fresh:false — agy --continue/--conversation cannot attest
25
32
  // a folded tree; only a fresh grounded re-run mints a gate-satisfying receipt).
@@ -46,6 +53,9 @@ import { createHash } from 'node:crypto';
46
53
  import { detectBackends } from './detect-backends.mjs';
47
54
  import { resolveActivityRecipe, planRecipe, DISPLAY_ALIASES } from './recipes.mjs';
48
55
  import { CONFIG_REL, fail, loadConfig } from './orchestration-config.mjs';
56
+ // The NEUTRAL ledger read-core (AD-050): review-state reads the review-ledger ONLY for the degraded
57
+ // exemption, through the neutral core — never review-ledger.mjs (which imports THIS module, the cycle).
58
+ import { resolveLedgerPath, resolveBase, readLedger, filterSegmentRecords, roundSequenceIntact } from './review-ledger-core.mjs';
49
59
 
50
60
  export const RECEIPTS_BASENAME = 'agent-workflow-review-receipts.jsonl';
51
61
  export const PLANS_REL = 'docs/plans';
@@ -236,6 +246,45 @@ export const backendReceiptStatus = (receipts, backend, fingerprint) => {
236
246
  return { state: own.length > 0 ? 'stale' : 'missing', verdict: null, grounded: null, timestamp: null };
237
247
  };
238
248
 
249
+ // ── the degraded exemption (AD-050): read the review-ledger for a recorded current-tree degrade ────
250
+
251
+ // degradedExemptSet(args) → the Set of recipe-named backends EXEMPT from --check because the current
252
+ // segment's LATEST round records them degraded at the current tree fingerprint. It MIRRORS review-ledger
253
+ // decideStop's degraded handling: a backend WITHOUT a current grounded code receipt is exempt IFF
254
+ // (i) exactly one plan is in flight (else the loop is ambiguous — the exempt set is empty, NO fail-closed
255
+ // exit-1 arm) AND the ledger reads clean (a readError / malformed line DENIES the exemption, fail-closed);
256
+ // (ii) the segment (activity=plan-execution, loop, base=resolveBase) has >=1 round with an intact
257
+ // sequence; (iii) its LATEST round records THAT backend degraded; (iv) that round's fingerprint equals
258
+ // the CURRENT tree (the degrade attests THIS tree); (v) >=1 NON-degraded recipe-named backend is present
259
+ // with a current grounded receipt (never everyone degraded). It is VERDICT-BLIND — it mirrors only the
260
+ // PRESENCE half of decideStop (nonDegradedReq >= 1), never its 0/0 counts (Decision 7).
261
+ export const degradedExemptSet = ({ records, readError, malformed, base, plans, currentFingerprint, requiredBackends, backends }) => {
262
+ const empty = new Set();
263
+ if (plans.length !== 1) return empty; // (i) ambiguous loop → exemption suppressed (no fail-closed exit-1 arm)
264
+ if (readError || malformed > 0) return empty; // fail-closed: a corrupt ledger denies the exemption
265
+ if (currentFingerprint == null) return empty;
266
+ const loop = plans[0].replace(/\.md$/, '');
267
+ const rounds = filterSegmentRecords(records, { activity: ACTIVITY, loop, base }).filter((r) => r.kind === 'round');
268
+ if (rounds.length === 0) return empty; // (ii) empty segment → nothing recorded yet
269
+ if (!roundSequenceIntact(rounds)) return empty; // (ii) corrupt sequence → fail closed
270
+ const latest = rounds[rounds.length - 1];
271
+ if (latest.fingerprint !== currentFingerprint) return empty; // (iv) the degrade must attest THIS tree
272
+ // Mirror decideStop's PRESENCE discipline (review-ledger.mjs): EVERY recipe-named backend must be IN
273
+ // the latest round (allPresent) — a backend absent from the round reviewed nothing there, so a stray
274
+ // current receipt for a NON-recorded backend can never justify the exemption (codex R1: else a
275
+ // degrade-only round `[{agy degraded}]` + any current codex receipt would exempt agy, disagreeing
276
+ // with review-ledger, whose decideStop fails allPresent on the absent codex).
277
+ const entryFor = (rb) => latest.backends.find((b) => b.backend === rb);
278
+ if (!requiredBackends.every((rb) => entryFor(rb) !== undefined)) return empty;
279
+ const receiptCurrent = new Set(backends.filter((b) => b.state === 'current').map((b) => b.backend));
280
+ // (v) >=1 non-degraded recipe-named backend PRESENT in the latest round with a current grounded
281
+ // receipt — never all degraded (mirrors decideStop's nonDegradedReq >= 1, plus review-state's own
282
+ // "it really reviewed" = a current receipt).
283
+ if (!requiredBackends.some((rb) => { const e = entryFor(rb); return e && !e.degraded && receiptCurrent.has(rb); })) return empty;
284
+ // (iii) exempt each recipe-named backend the latest round records degraded.
285
+ return new Set(requiredBackends.filter((rb) => { const e = entryFor(rb); return e && e.degraded === true; }));
286
+ };
287
+
239
288
  // ── the check + report core ─────────────────────────────────────────────────────────
240
289
 
241
290
  // buildState({ cwd, env, detect }) → everything both renders need. Pure I/O at the edges.
@@ -262,6 +311,13 @@ export const buildState = ({ cwd, env = process.env, detect = detectBackends } =
262
311
  const receiptsPath = resolveReceiptsPath(cwd, env);
263
312
  const { receipts, malformed } = receiptsPath ? readReceipts(receiptsPath) : { receipts: [], malformed: 0 };
264
313
  const backends = requiredBackends.map((b) => ({ backend: b, ...backendReceiptStatus(receipts, b, fingerprint) }));
314
+ // The degraded exemption (AD-050): read the review-ledger ONLY here, ONLY for the exemption — the
315
+ // whole gate never depends on the ledger (a corrupt ledger fails the exemption CLOSED, never a tree
316
+ // whose receipts independently satisfy the gate; Decision 3). base/ledger locate the current segment.
317
+ const base = resolveBase(cwd);
318
+ const ledgerPath = resolveLedgerPath(cwd, env);
319
+ const { records, malformed: ledgerMalformed, readError: ledgerReadError } = ledgerPath ? readLedger(ledgerPath) : { records: [], malformed: 0 };
320
+ const degradedExempt = [...degradedExemptSet({ records, readError: ledgerReadError, malformed: ledgerMalformed, base, plans, currentFingerprint: fingerprint, requiredBackends, backends })];
265
321
  return {
266
322
  resolved,
267
323
  configSource,
@@ -273,6 +329,11 @@ export const buildState = ({ cwd, env = process.env, detect = detectBackends } =
273
329
  receiptsPath,
274
330
  receiptCount: receipts.length,
275
331
  malformed,
332
+ base,
333
+ ledgerPath,
334
+ ledgerMalformed: ledgerMalformed ?? 0,
335
+ ledgerReadError: ledgerReadError ?? null,
336
+ degradedExempt,
276
337
  detectionWarning,
277
338
  };
278
339
  };
@@ -302,16 +363,31 @@ export const decideCheck = (state) => {
302
363
  // cannot fail the gate by itself (a forged/corrupt line must not brick commits), but the check
303
364
  // line always names it so a PASS over a partially-corrupt file is visible.
304
365
  const malformedNote = state.malformed > 0 ? ` — ${state.malformed} malformed receipt line(s) ignored; inspect ${state.receiptsPath}` : '';
305
- const failing = state.backends.filter((b) => b.state !== 'current');
366
+ // The review-ledger is consulted ONLY for the degraded exemption; a corrupt ledger DENIES it
367
+ // (fail-closed) but never fails a tree the receipts independently satisfy — surfaced either way
368
+ // (No-silent-failures; Decision 3).
369
+ const ledgerNote = state.ledgerReadError
370
+ ? ` — review ledger unreadable (${state.ledgerReadError}); degraded exemption unavailable (fail-closed) — inspect ${state.ledgerPath}`
371
+ : state.ledgerMalformed > 0
372
+ ? ` — review ledger has ${state.ledgerMalformed} malformed line(s); degraded exemption unavailable (fail-closed) — inspect ${state.ledgerPath}`
373
+ : '';
374
+ // The degraded exemption (AD-050): a backend recorded degraded for the current tree is excluded from
375
+ // `failing` (it reviewed nothing to receipt — MIRRORS decideStop excluding a degraded backend). It
376
+ // stays verdict-blind: the exemption proves the degrade was RECORDED, never that the tree converged.
377
+ const exempt = new Set(state.degradedExempt);
378
+ const failing = state.backends.filter((b) => b.state !== 'current' && !exempt.has(b.backend));
306
379
  if (failing.length === 0) {
307
- return { code: 0, reason: `every recipe-named backend has a fresh grounded receipt for the current tree (${state.requiredBackends.join(' + ')})${malformedNote}` };
380
+ if (exempt.size === 0) {
381
+ return { code: 0, reason: `every recipe-named backend has a fresh grounded receipt for the current tree (${state.requiredBackends.join(' + ')})${malformedNote}${ledgerNote}` };
382
+ }
383
+ return { code: 0, reason: `every recipe-named backend reviewed the current tree (${state.requiredBackends.join(' + ')}) — degraded-exempt (recorded degraded for the current tree in the review ledger): ${[...exempt].join(', ')}${malformedNote}${ledgerNote}` };
308
384
  }
309
385
  const parts = failing.map((b) => {
310
386
  if (b.state === 'ungrounded') return `${b.backend}: only ungrounded receipts for the current tree — re-run grounded (--facts)`;
311
387
  if (b.state === 'stale') return `${b.backend}: receipts exist but none matches the current tree (edited after review) — run a fresh review`;
312
388
  return `${b.backend}: no receipt — run its review wrapper`;
313
389
  });
314
- return { code: 1, reason: `${parts.join('; ')}${malformedNote}` };
390
+ return { code: 1, reason: `${parts.join('; ')}${malformedNote}${ledgerNote}` };
315
391
  };
316
392
 
317
393
  // ── rendering ───────────────────────────────────────────────────────────────────────
@@ -329,7 +405,11 @@ const formatHuman = (state, check) => {
329
405
  else if (state.clean === true) lines.push(' tree: clean (nothing to review)');
330
406
  else lines.push(` tree fingerprint: ${state.fingerprint}`);
331
407
  lines.push(` receipts: ${state.receiptsPath ?? '(unresolvable — no git dir)'} (${state.receiptCount} line(s)${state.malformed ? `, ${state.malformed} malformed — inspect the file` : ''})`);
408
+ if (state.ledgerReadError) lines.push(` ⚠ review ledger unreadable (${state.ledgerReadError}) — degraded exemption unavailable`);
409
+ else if (state.ledgerMalformed) lines.push(` ⚠ review ledger: ${state.ledgerMalformed} malformed line(s) — degraded exemption unavailable`);
410
+ const exempt = new Set(state.degradedExempt);
332
411
  for (const b of state.backends) {
412
+ const exemptTag = exempt.has(b.backend) ? ' — degraded-exempt (recorded degraded in the review ledger for the current tree)' : '';
333
413
  const detail =
334
414
  b.state === 'current'
335
415
  ? `current (verdict: ${b.verdict}, grounded, ${b.timestamp ?? '?'})`
@@ -338,7 +418,7 @@ const formatHuman = (state, check) => {
338
418
  : b.state === 'stale'
339
419
  ? 'stale — no receipt matches the current tree (edited after review)'
340
420
  : 'missing — no receipt from this backend';
341
- lines.push(` ${STATE_GLYPH[b.state]} ${b.backend}: ${detail}`);
421
+ lines.push(` ${exempt.has(b.backend) ? '⊘' : STATE_GLYPH[b.state]} ${b.backend}: ${detail}${exemptTag}`);
342
422
  }
343
423
  lines.push(` check: ${check.code === 0 ? 'PASS' : 'FAIL'} — ${check.reason}`);
344
424
  return lines.join('\n');
@@ -357,9 +437,10 @@ presence + verdict + grounding for the CURRENT tree. Plan/diff-mode receipts and
357
437
  (fresh:false) are informational-only — they never satisfy the tree check.
358
438
 
359
439
  --check exits 0/1 per the normative contract in the tool header: 0 for solo / no plan in flight /
360
- a clean tree / not-a-git-tree / all recipe-named backends receipted-current-and-grounded; 1 when a
361
- recipe-named backend is missing, stale (edited after review), or grounded:false under
362
- reviewed/council. Declare it as a project gate by hand (docs/ai/gates.json) or via the
440
+ a clean tree / not-a-git-tree / all recipe-named backends receipted-current-and-grounded OR
441
+ degraded-exempt (a recorded current-tree degrade in the review-ledger for that backend; AD-050); 1 when
442
+ a recipe-named backend is missing, stale (edited after review), or grounded:false under reviewed/council
443
+ AND is not degraded-exempt. Declare it as a project gate by hand (docs/ai/gates.json) or via the
363
444
  explicit-consent seeder (tools/seed-gates.mjs) — never without consent.
364
445
 
365
446
  Read-only: never writes, never commits, never runs a subscription CLI; spawns read-only git queries.
@@ -393,14 +474,16 @@ export const main = (argv, ctx = {}) => {
393
474
  };
394
475
 
395
476
  // ── --await: block until every recipe-named backend has receipted the current tree ─────
396
- // (BUGFREE-3 / AD-049, item (d)). It waits for ALL recipe-named backendsreview-state has NO
397
- // durable degraded-backend source before a round is recorded (degraded is a review-LEDGER round
398
- // field, not a review-state input), so "non-degraded" is not knowable here; awaiting a backend the
399
- // operator KNOWS is degraded is the operator's call (don't --await it). The completion signal is the
400
- // RECEIPT (i.e. `--check` would PASS), NEVER a process event a harness "completed" notification
401
- // fires early and a bridge's output late-flushes, so polling a pid/receipt-file is the durable
402
- // mechanization of receipts-not-pgrep. Stays read-only (it only re-reads state); the clock is
403
- // injectable (ctx.now / ctx.sleep / ctx.pollMs) so hermetic tests never spend wall-clock.
477
+ // (BUGFREE-3 / AD-049, item (d)). It waits for every recipe-named backend to be SATISFIED a fresh
478
+ // grounded current-tree receipt, OR (AD-050) the degraded exemption: once a current-tree degrade is
479
+ // RECORDED in the review-ledger, --await stops waiting for that backend and returns READY (before,
480
+ // it waited forever for a receipt that never comes). It inherits the exemption for FREE — it polls
481
+ // the SAME decideCheck(buildState()) `--check` computes. The completion signal is the RECEIPT (i.e.
482
+ // `--check` would PASS), NEVER a process event a harness "completed" notification fires early and a
483
+ // bridge's output late-flushes, so polling a pid/receipt-file is the durable mechanization of
484
+ // receipts-not-pgrep. Stays read-only (it only re-reads state now the ledger too, a few KB per
485
+ // tick); the clock is injectable (ctx.now / ctx.sleep / ctx.pollMs) so hermetic tests never spend
486
+ // wall-clock.
404
487
 
405
488
  const AWAIT_ALLOWED_ARGS = new Set(['--await', '--timeout']);
406
489