@sabaiway/agent-workflow-kit 2.1.0 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (99) hide show
  1. package/CHANGELOG.md +45 -0
  2. package/README.md +6 -5
  3. package/SKILL.md +12 -8
  4. package/bin/install.mjs +1 -1
  5. package/bridges/antigravity-cli-bridge/SKILL.md +8 -1
  6. package/bridges/antigravity-cli-bridge/bin/agy-review-honesty.test.mjs +150 -0
  7. package/bridges/antigravity-cli-bridge/bin/agy-review-model-screen.test.mjs +76 -0
  8. package/bridges/antigravity-cli-bridge/bin/agy-review.sh +80 -15
  9. package/bridges/antigravity-cli-bridge/bin/agy-review.test.mjs +95 -4
  10. package/bridges/antigravity-cli-bridge/capability.json +3 -2
  11. package/bridges/codex-cli-bridge/SKILL.md +8 -1
  12. package/bridges/codex-cli-bridge/bin/codex-review-honesty.test.mjs +143 -0
  13. package/bridges/codex-cli-bridge/bin/codex-review.sh +71 -10
  14. package/bridges/codex-cli-bridge/bin/codex-review.test.mjs +93 -10
  15. package/bridges/codex-cli-bridge/capability.json +3 -2
  16. package/capability.json +1 -1
  17. package/migrations/3.0.0-hardened-core-loop.md +44 -0
  18. package/migrations/README.md +1 -1
  19. package/package.json +2 -2
  20. package/references/hooks/gate-approve.mjs +1 -1
  21. package/references/modes/bootstrap.md +3 -3
  22. package/references/modes/commit-guard.md +19 -0
  23. package/references/modes/core-evidence.md +15 -0
  24. package/references/modes/coverage-check.md +12 -0
  25. package/references/modes/doc-parity.md +7 -6
  26. package/references/modes/gates.md +7 -6
  27. package/references/modes/grounding.md +1 -2
  28. package/references/modes/hook.md +1 -1
  29. package/references/modes/review-state.md +3 -3
  30. package/references/modes/upgrade.md +6 -6
  31. package/references/modes/velocity.md +2 -2
  32. package/references/scripts/archive-decisions.mjs +1 -1
  33. package/references/scripts/install-git-hooks-repo-exec.test.mjs +82 -0
  34. package/references/scripts/install-git-hooks.mjs +90 -18
  35. package/references/scripts/install-git-hooks.test.mjs +102 -0
  36. package/references/scripts/migrate-gates-branches.test.mjs +157 -0
  37. package/references/scripts/migrate-gates.mjs +395 -0
  38. package/references/scripts/migrate-gates.test.mjs +284 -0
  39. package/references/templates/agent_rules.md +2 -2
  40. package/tools/ack-write.mjs +1 -1
  41. package/tools/atomic-write.mjs +2 -2
  42. package/tools/autonomy-config.mjs +1 -1
  43. package/tools/autonomy-doctor.mjs +1 -1
  44. package/tools/autonomy-write.mjs +1 -1
  45. package/tools/bridge-settings-read.mjs +1 -1
  46. package/tools/bridge-settings.mjs +1 -1
  47. package/tools/changed-surface.mjs +8 -69
  48. package/tools/cheap-agents.mjs +2 -2
  49. package/tools/commands.mjs +17 -10
  50. package/tools/commit-guard.mjs +167 -0
  51. package/tools/core-evidence.mjs +914 -0
  52. package/tools/coverage-check.mjs +260 -0
  53. package/tools/delegation.mjs +1 -1
  54. package/tools/detect-backends.mjs +3 -3
  55. package/tools/doc-parity.mjs +11 -27
  56. package/tools/engine-source.mjs +1 -1
  57. package/tools/family-members.mjs +1 -1
  58. package/tools/family-registry.mjs +1 -1
  59. package/tools/fs-safe.mjs +1 -1
  60. package/tools/gate-hook.mjs +1 -1
  61. package/tools/{seed-gates.mjs → gates-init.mjs} +58 -52
  62. package/tools/grounding.mjs +6 -83
  63. package/tools/hide-footprint.mjs +1 -1
  64. package/tools/inject-methodology.mjs +1 -1
  65. package/tools/known-footprint.mjs +1 -1
  66. package/tools/labels.mjs +1 -1
  67. package/tools/lcov.mjs +6 -10
  68. package/tools/lens-region.mjs +1 -1
  69. package/tools/manifest/validate.mjs +19 -0
  70. package/tools/migrate-adr-store.mjs +2 -2
  71. package/tools/orchestration-config.mjs +1 -1
  72. package/tools/orchestration-write.mjs +1 -1
  73. package/tools/presentation.mjs +1 -1
  74. package/tools/procedures.mjs +2 -2
  75. package/tools/recipes.mjs +60 -9
  76. package/tools/recommendations.mjs +63 -7
  77. package/tools/release-scan.mjs +1 -1
  78. package/tools/renderers.mjs +1 -1
  79. package/tools/review-state.mjs +288 -340
  80. package/tools/run-gates.mjs +216 -92
  81. package/tools/sandbox-masks.mjs +1 -1
  82. package/tools/semver-lite.mjs +1 -1
  83. package/tools/set-autonomy.mjs +1 -1
  84. package/tools/set-recipe.mjs +1 -1
  85. package/tools/setup-backends.mjs +1 -1
  86. package/tools/surface.mjs +1 -1
  87. package/tools/uninstall.mjs +1 -1
  88. package/tools/velocity-profile.mjs +2 -2
  89. package/tools/view-model.mjs +1 -1
  90. package/references/modes/fold-completeness.md +0 -30
  91. package/references/modes/review-ledger.md +0 -34
  92. package/references/templates/verification-profile.json +0 -10
  93. package/tools/fold-completeness-run.mjs +0 -1120
  94. package/tools/fold-completeness.mjs +0 -672
  95. package/tools/review-ledger-core.mjs +0 -428
  96. package/tools/review-ledger-write.mjs +0 -647
  97. package/tools/review-ledger.mjs +0 -630
  98. package/tools/sarif.mjs +0 -52
  99. package/tools/verification-profile.mjs +0 -219
@@ -1,32 +1,39 @@
1
1
  #!/usr/bin/env node
2
2
  // review-state.mjs — the read-only review-receipt checker behind `/agent-workflow-kit review-state`
3
- // (AD-038). It makes "reviewed ≠ shipped" mechanically detectable: the bridge review wrappers
4
- // (codex-review / agy-review ≥2.2.0) append one receipt line per successful review; this tool
5
- // resolves the effective `plan-execution.review` recipe (the advisor's single-source readers),
6
- // recomputes the CURRENT canonical uncommitted-state fingerprint, and reports — per recipe-named
7
- // backend whether a FRESH, grounded, current-fingerprint receipt exists. `--check` turns the
8
- // report into a gate exit code (declare it in docs/ai/gates.json by hand OR via the
9
- // explicit-consent seeder, tools/seed-gates.mjs never without consent, AD-021/AD-042).
3
+ // (AD-038; HARDENED by the strip-the-kit core, D3(b)). It makes "reviewed ≠ shipped" mechanically
4
+ // detectable: the bridge review wrappers append one receipt line per successful review; this tool
5
+ // derives the review OBLIGATIONS from the CONFIGURED `plan-execution.review` recipe (the raw
6
+ // orchestration.json value never the readiness-degraded effective recipe: a computed
7
+ // readiness-degrade NEVER silently becomes solo), recomputes the CURRENT canonical
8
+ // uncommitted-state fingerprint, and judges per configured backendthe LATEST NORMAL receipt.
9
+ // `--check` turns the report into a gate exit code (declare it in docs/ai/gates.json).
10
10
  //
11
11
  // Normative `--check` exit contract (the single home of this list — SKILL.md points here):
12
- // exit 0 when the resolved plan-execution.review recipe is solo (configured, or degraded there
13
- // i.e. no reviewer backend is ready); when no plan is in flight (docs/plans/ holds no
14
- // top-level .md that is not queue.md and not scratch by the naming convention: prefixes
15
- // EXECUTE- / FEEDBACK-, or a name containing PROMPT / prompt / handoff); when the tree is
16
- // clean (nothing to review); when the cwd is not a git work tree (nothing to fingerprint);
17
- // and when EVERY recipe-named backend has a current-fingerprint receipt with acceptable
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).
12
+ // exit 0 when the CONFIGURED plan-execution.review recipe is solo (or the computed default is
13
+ // absent config with no reviewer backend ready); when no plan is in flight (docs/plans/
14
+ // holds no top-level .md that is not queue.md and not scratch by the naming convention:
15
+ // prefixes EXECUTE- / FEEDBACK-, or a name containing PROMPT / prompt / handoff); when
16
+ // the tree is clean (nothing to review); when the cwd is not a git work tree; and when
17
+ // the obligations are SATISFIED: under `reviewed`, >=1 backend's latest normal receipt
18
+ // attests SHIP-CLASS (ship / ship with nits) for the current tree; under `council`,
19
+ // EVERY review-capable backend attests ship-class OR carries an explicit current-tree
20
+ // degrade record in the core-evidence store and NEVER all backends (>=1 non-degraded
21
+ // ship-class attestation is required whenever >=1 backend is configured).
22
+ // exit 1 on an authoritative VETO (any configured backend's latest normal receipt carries a
23
+ // recognized NEGATIVE verdict revise / rethink / rework for the current tree; a
24
+ // degrade record never lifts a veto); on an UNRECOGNIZED verdict on the latest normal
25
+ // receipt (an `unknown` verdict never attests fail closed, so a later unknown never
26
+ // lets an earlier SHIP survive); on a missing/stale/ungrounded/probe-only backend under
27
+ // council without a current-tree degrade record; when every configured backend is
28
+ // degrade-recorded (all-degraded refused); and, with NO configured recipe, when the
29
+ // backend detector is down (the computed default is unknowable fail closed). An
30
+ // unreadable/malformed evidence store DENIES the degrade escape (fail-closed) but NEVER
31
+ // fails a tree whose receipts independently satisfy the gate (surfaced either way).
32
+ // Selection is LATEST-NORMAL-FIRST: among current-fingerprint, marker-valid, probe-free receipts
33
+ // the LATEST is selected and THEN judged (the verdict-vocabulary arm first, then grounding — an
34
+ // unrecognized verdict is an unconditional refusal that grounding never reclassifies) — so a
35
+ // revise-class latest VETOES an earlier ship-class one, and a probe/forged-marker receipt written
36
+ // after a real review never becomes the authoritative verdict.
30
37
  // Informational receipts NEVER satisfy (nor fail) the tree check: plan/diff-mode receipts
31
38
  // (artifact ≠ "code") and continuations (fresh:false — agy --continue/--conversation cannot attest
32
39
  // a folded tree; only a fresh grounded re-run mints a gate-satisfying receipt).
@@ -61,28 +68,44 @@
61
68
  //
62
69
  // Read-only: never writes, never commits, never runs a subscription CLI. It DOES spawn `git`
63
70
  // (read-only queries) to compute the fingerprint — stated honestly in the catalog. Dependency-free,
64
- // Node >= 18. No side effects on import (the isDirectRun idiom).
71
+ // Node >= 22. No side effects on import (the isDirectRun idiom).
65
72
 
66
- import { readFileSync, readdirSync, lstatSync, readlinkSync, openSync, readSync, closeSync } from 'node:fs';
73
+ import { readdirSync, lstatSync } from 'node:fs';
67
74
  import { join, dirname } from 'node:path';
68
75
  import { pathToFileURL, fileURLToPath } from 'node:url';
69
76
  import { spawnSync } from 'node:child_process';
70
- import { createHash } from 'node:crypto';
71
- import { detectBackends } from './detect-backends.mjs';
72
- import { resolveActivityRecipe, planRecipe, DISPLAY_ALIASES } from './recipes.mjs';
77
+ import { detectBackends, READY } from './detect-backends.mjs';
78
+ import { resolveActivityRecipe, DISPLAY_ALIASES } from './recipes.mjs';
73
79
  import { CONFIG_REL, fail, loadConfig } from './orchestration-config.mjs';
74
- // The NEUTRAL ledger read-core (AD-050): review-state reads the review-ledger ONLY for the degraded
75
- // exemption, through the neutral core never review-ledger.mjs (which imports THIS module, the cycle).
80
+ // The canonical review-domain primitives live in the core-evidence DAG bottom (ONE home for the
81
+ // fingerprint, the receipt read path, and the attesting predicate); this module RE-EXPORTS its
82
+ // historical public API from there and consumes the degrade records the same store owns.
76
83
  import {
77
- resolveLedgerPath,
78
- resolveBase,
79
- readLedger,
80
- filterSegmentRecords,
81
- roundSequenceIntact,
84
+ RECEIPTS_BASENAME,
85
+ computeFingerprintPayload,
86
+ computeTreeFingerprint,
87
+ isTreeClean,
88
+ isNeverCommittableStat,
89
+ resolveReceiptsPath,
90
+ readReceipts,
82
91
  summarizeReviewReceiptsForTree,
83
- } from './review-ledger-core.mjs';
92
+ resolveBase,
93
+ isShipVerdict,
94
+ resolveEvidencePath,
95
+ readEvidence,
96
+ authoritativeOfKind,
97
+ } from './core-evidence.mjs';
98
+
99
+ export {
100
+ RECEIPTS_BASENAME,
101
+ computeFingerprintPayload,
102
+ computeTreeFingerprint,
103
+ isTreeClean,
104
+ isNeverCommittableStat,
105
+ resolveReceiptsPath,
106
+ readReceipts,
107
+ };
84
108
 
85
- export const RECEIPTS_BASENAME = 'agent-workflow-review-receipts.jsonl';
86
109
  export const PLANS_REL = 'docs/plans';
87
110
  const ACTIVITY = 'plan-execution';
88
111
  const SLOT = 'review';
@@ -110,111 +133,8 @@ const gitLine = (args, cwd) => {
110
133
  return buf == null ? null : buf.toString('utf8').replace(/\r?\n$/, '');
111
134
  };
112
135
 
113
- // ── the canonical fingerprint (node twin of the wrappers' bash implementation) ──────
114
-
115
- // First 8 KiB contain a NUL byte → binary (git's own heuristic; mirrors the wrappers' is_binary).
116
- const isBinaryFile = (path) => {
117
- let fd;
118
- try {
119
- fd = openSync(path, 'r');
120
- const buf = Buffer.alloc(8192);
121
- const n = readSync(fd, buf, 0, 8192, 0);
122
- return buf.subarray(0, n).includes(0);
123
- } catch {
124
- return false;
125
- } finally {
126
- if (fd !== undefined) closeSync(fd);
127
- }
128
- };
129
-
130
- // The never-committable untracked stat classes (Decision 1, AD-044 Plan 4): character/block
131
- // devices, FIFOs, sockets — git content can never carry them, so they are excluded from the ENTIRE
132
- // review domain (fingerprint payload, clean check; the wrappers' bash twin filters the assembled
133
- // payload identically). lstat-keyed by design: the sandbox mask class surfaces exactly where the
134
- // dirent LIES (readdir says file, lstat says char device). A null stat (vanished path) is NOT in
135
- // the class — it keeps its name-only note, like directories (gitlinks) and symlinks.
136
- export const isNeverCommittableStat = (stat) =>
137
- stat != null &&
138
- (stat.isCharacterDevice() || stat.isBlockDevice() || stat.isFIFO() || stat.isSocket());
139
-
140
- // The canonical payload bytes: staged diff + unstaged diff + the untracked-not-ignored section —
141
- // byte-identical to the wrappers' emit_fingerprint_payload (same git invocations, same headers,
142
- // same ls-files ordering), emitted from the work-tree ROOT. Returns null outside a git work tree.
143
- // The lstat is injectable ONLY so tests can prove the never-committable filter non-vacuously (a
144
- // lying lstat over a git-visible fixture path — the sandbox mechanism itself); production callers
145
- // never pass it.
146
- export const computeFingerprintPayload = (cwd, { lstat = lstatSync } = {}) => {
147
- const top = gitLine(['rev-parse', '--show-toplevel'], cwd);
148
- if (top == null) return null;
149
- const staged = gitBuf(['diff', '--cached', '--no-ext-diff'], top);
150
- const unstaged = gitBuf(['diff', '--no-ext-diff'], top);
151
- const untrackedZ = gitBuf(['ls-files', '--others', '--exclude-standard', '-z'], top);
152
- if (staged == null || unstaged == null || untrackedZ == null) return null;
153
- const chunks = [staged, unstaged];
154
- for (const rel of untrackedZ.toString('utf8').split('\0').filter(Boolean)) {
155
- const full = join(top, rel);
156
- let stat = null;
157
- try {
158
- stat = lstat(full);
159
- } catch {
160
- stat = null;
161
- }
162
- if (isNeverCommittableStat(stat)) continue;
163
- if (stat?.isSymbolicLink()) {
164
- let target = '?';
165
- try {
166
- target = readlinkSync(full);
167
- } catch {
168
- target = '?';
169
- }
170
- chunks.push(Buffer.from(`untracked-symlink:${rel} -> ${target}\n`));
171
- } else if (!stat?.isFile()) {
172
- chunks.push(Buffer.from(`untracked-nonregular:${rel}\n`));
173
- } else if (isBinaryFile(full)) {
174
- chunks.push(Buffer.from(`untracked-binary:${rel}\n`));
175
- } else {
176
- chunks.push(Buffer.from(`untracked:${rel}\n`));
177
- chunks.push(readFileSync(full));
178
- }
179
- }
180
- return Buffer.concat(chunks);
181
- };
182
-
183
- // sha256 hex of the canonical payload, or null outside a git work tree.
184
- export const computeTreeFingerprint = (cwd, fsx) => {
185
- const payload = computeFingerprintPayload(cwd, fsx);
186
- return payload == null ? null : createHash('sha256').update(payload).digest('hex');
187
- };
188
-
189
- // Clean = nothing staged, nothing unstaged, no REVIEWABLE untracked-not-ignored paths (the
190
- // wrappers' no-diff preflight). Never-committable untracked paths (device/FIFO/socket) do not
191
- // count as dirty — same filter as the fingerprint, so the two can never disagree about a
192
- // masks-only tree. An lstat failure keeps the path in the domain (dirty), mirroring the
193
- // fingerprint's null-stat note. null when not decidable (not a git work tree). Anchored at the
194
- // work-tree ROOT like the fingerprint: `git ls-files --others` is cwd-SCOPED, so a subdirectory
195
- // invocation would otherwise miss root/sibling untracked paths and report a dirty tree as clean
196
- // (codex R1 finding).
197
- export const isTreeClean = (cwd, { lstat = lstatSync } = {}) => {
198
- const top = gitLine(['rev-parse', '--show-toplevel'], cwd);
199
- if (top == null) return null;
200
- const staged = gitRaw(['diff', '--cached', '--quiet'], top);
201
- const unstaged = gitRaw(['diff', '--quiet'], top);
202
- if (staged.error || unstaged.error || staged.status > 1 || unstaged.status > 1) return null;
203
- const untrackedZ = gitBuf(['ls-files', '--others', '--exclude-standard', '-z'], top);
204
- if (untrackedZ == null) return null;
205
- const reviewable = untrackedZ
206
- .toString('utf8')
207
- .split('\0')
208
- .filter(Boolean)
209
- .filter((rel) => {
210
- try {
211
- return !isNeverCommittableStat(lstat(join(top, rel)));
212
- } catch {
213
- return true;
214
- }
215
- });
216
- return staged.status === 0 && unstaged.status === 0 && reviewable.length === 0;
217
- };
136
+ // The canonical fingerprint / clean check / never-committable filter live in core-evidence.mjs
137
+ // (the ONE home, re-exported above); this module keeps only its own presentation plumbing.
218
138
 
219
139
  // ── the sandbox-masks advisory (D lane, AD-044 Plan 4 Phase 1.5) ────────────────────
220
140
 
@@ -277,49 +197,22 @@ export const plansInFlight = (cwd, readdir = readdirSync) => {
277
197
  .sort();
278
198
  };
279
199
 
280
- // ── receipts ───────────────────────────────────────────────────────────────────────
281
-
282
- export const resolveReceiptsPath = (cwd, env = process.env) => {
283
- if (env.AW_REVIEW_RECEIPTS) return env.AW_REVIEW_RECEIPTS;
284
- const gitDir = gitLine(['rev-parse', '--absolute-git-dir'], cwd);
285
- return gitDir == null ? null : join(gitDir, RECEIPTS_BASENAME);
286
- };
287
-
288
- // Parse the receipt file → { receipts, malformed }. Absent file → empty (not an error: no review
289
- // ever ran). A malformed line is counted + reported, never silently dropped.
290
- export const readReceipts = (path, readFile = readFileSync) => {
291
- let raw;
292
- try {
293
- raw = readFile(path, 'utf8');
294
- } catch {
295
- return { receipts: [], malformed: 0 };
296
- }
297
- const receipts = [];
298
- let malformed = 0;
299
- for (const line of raw.split('\n')) {
300
- if (line.trim() === '') continue;
301
- try {
302
- const parsed = JSON.parse(line);
303
- if (parsed && typeof parsed === 'object' && typeof parsed.backend === 'string') receipts.push(parsed);
304
- else malformed += 1;
305
- } catch {
306
- malformed += 1;
307
- }
308
- }
309
- return { receipts, malformed };
310
- };
200
+ // ── receipts (path + reader re-exported from the core-evidence one-home above) ──────
311
201
 
312
202
  // Per-backend receipt status for the current fingerprint, over the ONE shared attesting-receipt
313
- // predicate (review-ledger-core) that the round cross-check and the round writer read too — two
314
- // gates disagreeing about what counts as an attestation is the class AD-050 closed:
315
- // current — an ATTESTING receipt exists (fresh code, probe:false, grounded) — its verdict rides;
316
- // ungrounded real (probe:false) current receipts exist but every one carries grounded:false;
317
- // probe — current receipts exist and EVERY one is a well-formed probe (D3);
318
- // rejected — current receipts exist, none attests, and >=1 marker was malformed or absent;
319
- // stale this backend has receipts, none for the current fingerprint (edited after review);
320
- // missing no receipt from this backend at all.
321
- // The counts state what was dropped, so a PASS over a partially-excluded set still says so
322
- // (No-silent-failures) and the --json surface can show it.
203
+ // predicate the LATEST NORMAL (probe-free, marker-valid, current-fingerprint) receipt is
204
+ // selected FIRST and THEN judged, so a later unknown/ungrounded receipt never lets an earlier
205
+ // SHIP survive:
206
+ // current the latest normal receipt ATTESTS (grounded + recognized verdict);
207
+ // its verdict rides ONLY ship-class satisfies, a recognized negative
208
+ // is an authoritative veto;
209
+ // ungrounded — the latest normal receipt carries grounded:false;
210
+ // unrecognized-verdict the latest normal receipt carries a verdict outside the closed
211
+ // vocabulary (an unknown verdict never attests fail closed);
212
+ // probe — current receipts exist and EVERY one is a well-formed probe (D3);
213
+ // rejected — current receipts exist, none normal, and >=1 marker malformed/absent;
214
+ // stale — receipts exist, none for the current fingerprint (edited after review);
215
+ // missing — no receipt from this backend at all.
323
216
  export const backendReceiptStatus = (receipts, backend, fingerprint) => {
324
217
  const own = receipts.filter((r) => r.backend === backend);
325
218
  const summary = summarizeReviewReceiptsForTree(own, fingerprint);
@@ -327,56 +220,60 @@ export const backendReceiptStatus = (receipts, backend, fingerprint) => {
327
220
  probeExcluded: summary.probeExcluded,
328
221
  markerRejected: summary.markerRejected,
329
222
  unmarkedRejected: summary.unmarkedRejected,
223
+ postureRejected: summary.postureRejected,
330
224
  };
331
225
  if (summary.state === 'current') {
332
- return { state: 'current', verdict: summary.receipt.verdict ?? 'unknown', grounded: true, timestamp: summary.receipt.timestamp ?? null, ...counts };
226
+ return { state: 'current', verdict: summary.receipt.verdict ?? 'unknown', shipClass: isShipVerdict(summary.receipt.verdict), grounded: true, timestamp: summary.receipt.timestamp ?? null, ...counts };
333
227
  }
334
- if (summary.state === 'ungrounded') {
335
- return { state: 'ungrounded', verdict: summary.receipt.verdict ?? 'unknown', grounded: false, timestamp: summary.receipt.timestamp ?? null, ...counts };
228
+ if (summary.state === 'ungrounded' || summary.state === 'unrecognized-verdict') {
229
+ return { state: summary.state, verdict: summary.receipt.verdict ?? 'unknown', shipClass: false, grounded: summary.receipt.grounded === true, timestamp: summary.receipt.timestamp ?? null, ...counts };
336
230
  }
337
231
  if (summary.state === 'probe' || summary.state === 'rejected') {
338
- return { state: summary.state, verdict: null, grounded: null, timestamp: null, ...counts };
232
+ return { state: summary.state, verdict: null, shipClass: false, grounded: null, timestamp: null, ...counts };
233
+ }
234
+ return { state: own.length > 0 ? 'stale' : 'missing', verdict: null, shipClass: false, grounded: null, timestamp: null, ...counts };
235
+ };
236
+
237
+ // ── obligations from the CONFIGURED recipe + the D3(b) degrade-record escape ────────
238
+
239
+ // Obligations derive from the CONFIGURED recipe — the RAW orchestration.json value — never the
240
+ // readiness-degraded effective recipe: the resolver degrades council→reviewed→solo BEFORE any
241
+ // check would see the missing backend, which would silently drop an obligation (a computed
242
+ // readiness-degrade NEVER silently becomes solo). The resolver stays for display/diagnostics
243
+ // only. The computed DEFAULT (absent config) is readiness-aware by design — a default never
244
+ // mints an unsatisfiable obligation; an EXPLICIT configured recipe never degrades here.
245
+ // solo → no obligation (the existing honest exit-0 contract);
246
+ // reviewed → ONE ship-class attestation from ANY review-capable backend (minShip 1);
247
+ // council → EVERY review-capable backend attests ship-class OR carries a current-tree degrade
248
+ // record — and NEVER all degraded (minShip 1 stands whenever >=1 backend is configured).
249
+ export const requiredBackendsForConfiguredRecipe = ({ config, readiness = [], detectionFailed = false } = {}) => {
250
+ const configured = config?.[ACTIVITY]?.[SLOT];
251
+ const providers = Object.values(DISPLAY_ALIASES); // every review-capable backend, codex first
252
+ if (configured == null && detectionFailed) {
253
+ // No config + no readiness signal: the computed default is UNKNOWABLE — fail closed upstream.
254
+ return { recipe: null, source: 'default', backends: [], minShip: 0, perBackend: false, unknowable: true };
339
255
  }
340
- return { state: own.length > 0 ? 'stale' : 'missing', verdict: null, grounded: null, timestamp: null, ...counts };
256
+ const anyReady = readiness.some((b) => b.readiness === READY);
257
+ const recipe = configured ?? (anyReady ? 'reviewed' : 'solo');
258
+ const source = configured != null ? 'config' : 'default';
259
+ if (recipe === 'solo') return { recipe, source, backends: [], minShip: 0, perBackend: false, unknowable: false };
260
+ if (recipe === 'council') return { recipe, source, backends: providers, minShip: 1, perBackend: true, unknowable: false };
261
+ return { recipe, source, backends: providers, minShip: 1, perBackend: false, unknowable: false };
341
262
  };
342
263
 
343
- // ── the degraded exemption (AD-050): read the review-ledger for a recorded current-tree degrade ────
344
-
345
- // degradedExemptSet(args) the Set of recipe-named backends EXEMPT from --check because the current
346
- // segment's LATEST round records them degraded at the current tree fingerprint. It MIRRORS review-ledger
347
- // decideStop's degraded handling: a backend WITHOUT a current grounded code receipt is exempt IFF
348
- // (i) exactly one plan is in flight (else the loop is ambiguous the exempt set is empty, NO fail-closed
349
- // exit-1 arm) AND the ledger reads clean (a readError / malformed line DENIES the exemption, fail-closed);
350
- // (ii) the segment (activity=plan-execution, loop, base=resolveBase) has >=1 round with an intact
351
- // sequence; (iii) its LATEST round records THAT backend degraded; (iv) that round's fingerprint equals
352
- // the CURRENT tree (the degrade attests THIS tree); (v) >=1 NON-degraded recipe-named backend is present
353
- // with a current grounded receipt (never everyone degraded). It is VERDICT-BLIND — it mirrors only the
354
- // PRESENCE half of decideStop (nonDegradedReq >= 1), never its 0/0 counts (Decision 7).
355
- export const degradedExemptSet = ({ records, readError, malformed, base, plans, currentFingerprint, requiredBackends, backends }) => {
356
- const empty = new Set();
357
- if (plans.length !== 1) return empty; // (i) ambiguous loop → exemption suppressed (no fail-closed exit-1 arm)
358
- if (readError || malformed > 0) return empty; // fail-closed: a corrupt ledger denies the exemption
359
- if (currentFingerprint == null) return empty;
360
- const loop = plans[0].replace(/\.md$/, '');
361
- const rounds = filterSegmentRecords(records, { activity: ACTIVITY, loop, base }).filter((r) => r.kind === 'round');
362
- if (rounds.length === 0) return empty; // (ii) empty segment → nothing recorded yet
363
- if (!roundSequenceIntact(rounds)) return empty; // (ii) corrupt sequence → fail closed
364
- const latest = rounds[rounds.length - 1];
365
- if (latest.fingerprint !== currentFingerprint) return empty; // (iv) the degrade must attest THIS tree
366
- // Mirror decideStop's PRESENCE discipline (review-ledger.mjs): EVERY recipe-named backend must be IN
367
- // the latest round (allPresent) — a backend absent from the round reviewed nothing there, so a stray
368
- // current receipt for a NON-recorded backend can never justify the exemption (codex R1: else a
369
- // degrade-only round `[{agy degraded}]` + any current codex receipt would exempt agy, disagreeing
370
- // with review-ledger, whose decideStop fails allPresent on the absent codex).
371
- const entryFor = (rb) => latest.backends.find((b) => b.backend === rb);
372
- if (!requiredBackends.every((rb) => entryFor(rb) !== undefined)) return empty;
373
- const receiptCurrent = new Set(backends.filter((b) => b.state === 'current').map((b) => b.backend));
374
- // (v) >=1 non-degraded recipe-named backend PRESENT in the latest round with a current grounded
375
- // receipt — never all degraded (mirrors decideStop's nonDegradedReq >= 1, plus review-state's own
376
- // "it really reviewed" = a current receipt).
377
- if (!requiredBackends.some((rb) => { const e = entryFor(rb); return e && !e.degraded && receiptCurrent.has(rb); })) return empty;
378
- // (iii) exempt each recipe-named backend the latest round records degraded.
379
- return new Set(requiredBackends.filter((rb) => { const e = entryFor(rb); return e && e.degraded === true; }));
264
+ // degradeRecordSet the D3(b) escape: an EXPLICIT per-backend, per-tree degrade RECORD in the
265
+ // core-evidence store is the ONLY exemption lane. Fail-closed: an unreadable/malformed store
266
+ // DENIES every exemption (surfaced), but never fails a tree whose receipts independently satisfy
267
+ // the gate. A stale-fingerprint record never exempts (the authoritative record per {backend,
268
+ // fingerprint} must attest THIS tree).
269
+ export const degradeRecordSet = ({ cwd, env = process.env, fingerprint }) => {
270
+ const storePath = resolveEvidencePath(cwd, env);
271
+ const read = storePath ? readEvidence(storePath) : { records: [], malformed: 0, malformedReasons: [] };
272
+ const unavailable = (read.malformed ?? 0) > 0 || read.readError != null;
273
+ const set = unavailable || fingerprint == null
274
+ ? new Set()
275
+ : new Set(authoritativeOfKind(read.records, 'degrade').filter((r) => r.fingerprint === fingerprint).map((r) => r.backend));
276
+ return { set, storePath, malformed: read.malformed ?? 0, readError: read.readError ?? null, unavailable };
380
277
  };
381
278
 
382
279
  // ── the check + report core ─────────────────────────────────────────────────────────
@@ -394,29 +291,33 @@ export const buildState = ({ cwd, env = process.env, detect = detectBackends, ls
394
291
  try {
395
292
  detection = detect();
396
293
  } catch (err) {
397
- detectionWarning = `backend detection failed (${(err && err.message) || err}) — treating all backends as not ready; the review recipe floors at solo.`;
294
+ detectionWarning = `backend detection failed (${(err && err.message) || err}) — readiness unknown.`;
398
295
  }
296
+ // The resolver stays for DISPLAY/diagnostics only; the OBLIGATIONS come from the configured
297
+ // recipe (never the readiness-degraded effective one — no silent solo).
399
298
  const resolved = resolveActivityRecipe({ config: config ?? {}, readiness: detection, activity: ACTIVITY, slot: SLOT });
400
- const { dispatch } = planRecipe(resolved.recipe, detection);
401
- const requiredBackends = dispatch.map((d) => DISPLAY_ALIASES[d.backend] ?? d.backend);
299
+ const obligations = requiredBackendsForConfiguredRecipe({ config: config ?? {}, readiness: detection, detectionFailed: detectionWarning != null });
300
+ const requiredBackends = obligations.backends;
402
301
  const plans = plansInFlight(root);
403
302
  // The injected lstat threads through EVERY stat-dependent computation (fingerprint, clean, the
404
- // mask count) — a partial injection would let a test observe an inconsistent state (codex R3).
303
+ // mask count) — a partial injection would let a test observe an inconsistent state.
405
304
  const fingerprint = computeTreeFingerprint(cwd, { lstat });
406
305
  const clean = fingerprint == null ? null : isTreeClean(cwd, { lstat });
407
306
  const receiptsPath = resolveReceiptsPath(cwd, env);
408
- const { receipts, malformed } = receiptsPath ? readReceipts(receiptsPath) : { receipts: [], malformed: 0 };
307
+ const receiptsRead = receiptsPath ? readReceipts(receiptsPath) : { receipts: [], malformed: 0 };
308
+ const { receipts, malformed } = receiptsRead;
309
+ const receiptsReadError = receiptsRead.readError ?? null;
409
310
  const backends = requiredBackends.map((b) => ({ backend: b, ...backendReceiptStatus(receipts, b, fingerprint) }));
410
- // The degraded exemption (AD-050): read the review-ledger ONLY here, ONLY for the exemption — the
411
- // whole gate never depends on the ledger (a corrupt ledger fails the exemption CLOSED, never a tree
412
- // whose receipts independently satisfy the gate; Decision 3). base/ledger locate the current segment.
311
+ // The D3(b) degrade escape: read the core-evidence store ONLY here, ONLY for the exemption —
312
+ // the gate never otherwise depends on it (an unavailable store denies the exemption CLOSED,
313
+ // never fails a tree whose receipts independently satisfy the gate).
413
314
  const base = resolveBase(cwd);
414
- const ledgerPath = resolveLedgerPath(cwd, env);
415
- const { records, malformed: ledgerMalformed, readError: ledgerReadError } = ledgerPath ? readLedger(ledgerPath) : { records: [], malformed: 0 };
416
- const degradedExempt = [...degradedExemptSet({ records, readError: ledgerReadError, malformed: ledgerMalformed, base, plans, currentFingerprint: fingerprint, requiredBackends, backends })];
315
+ const degrade = degradeRecordSet({ cwd, env, fingerprint });
316
+ const degradedExempt = requiredBackends.filter((b) => degrade.set.has(b));
417
317
  return {
418
318
  resolved,
419
319
  configSource,
320
+ obligations,
420
321
  requiredBackends,
421
322
  backends,
422
323
  plans,
@@ -426,10 +327,12 @@ export const buildState = ({ cwd, env = process.env, detect = detectBackends, ls
426
327
  receiptsPath,
427
328
  receiptCount: receipts.length,
428
329
  malformed,
330
+ receiptsReadError,
429
331
  base,
430
- ledgerPath,
431
- ledgerMalformed: ledgerMalformed ?? 0,
432
- ledgerReadError: ledgerReadError ?? null,
332
+ evidenceStorePath: degrade.storePath,
333
+ evidenceMalformed: degrade.malformed,
334
+ evidenceReadError: degrade.readError,
335
+ evidenceUnavailable: degrade.unavailable,
433
336
  degradedExempt,
434
337
  maskedUntracked: countNeverCommittableUntracked(cwd, { lstat }),
435
338
  detectionWarning,
@@ -444,83 +347,120 @@ const rejectionCause = (b) => {
444
347
  if (b.unmarkedRejected > 0) {
445
348
  parts.push(`${b.unmarkedRejected} with no probe marker — silence is not a declaration, so the probe status is untrustworthy; re-run the review with a bridge that marks its runs`);
446
349
  }
350
+ if ((b.postureRejected ?? 0) > 0) {
351
+ parts.push(`${b.postureRejected} with an absent/invalid run posture (D5) — a pre-posture wrapper minted it; re-run the review on the current bridge`);
352
+ }
447
353
  return parts.join(' + ');
448
354
  };
449
355
 
356
+ // One failing backend row → its stated recovery. Shared by the council per-backend arm and the
357
+ // reviewed closest-recovery listing.
358
+ const backendFailurePart = (b, state) => {
359
+ if (b.state === 'current') return `${b.backend}: latest recognized verdict is ${JSON.stringify(b.verdict)} — a recognized negative is an authoritative veto; fold and re-review`;
360
+ if (b.state === 'unrecognized-verdict') return `${b.backend}: the latest normal receipt carries an unrecognized verdict (${JSON.stringify(b.verdict)}) — an unknown verdict never attests (fail-closed); re-run the review`;
361
+ if (b.state === 'ungrounded') return `${b.backend}: the latest normal receipt is ungrounded — re-run grounded (--facts)`;
362
+ if (b.state === 'probe') return `${b.backend}: only probe receipts for the current tree (CODEX_PROBE=1 / AGY_PROBE=1 relaxes the quality guards) — a probe review never attests; re-run a real one`;
363
+ if (b.state === 'rejected') return `${b.backend}: current-tree receipts rejected — ${rejectionCause(b)} (fail-closed); inspect ${state.receiptsPath}`;
364
+ if (b.state === 'stale') return `${b.backend}: receipts exist but none matches the current tree (edited after review) — run a fresh review`;
365
+ return `${b.backend}: no receipt — run its review wrapper, or record an explicit degrade (node ${shellQuoteArg(join(dirname(fileURLToPath(import.meta.url)), 'core-evidence.mjs'))} degrade --backend ${b.backend} --reason "...")`;
366
+ };
367
+
450
368
  // The normative --check decision (the header contract, in order). → { code, reason }.
369
+ // Obligations come from the CONFIGURED recipe; satisfaction is SHIP-CLASS ONLY; a recognized
370
+ // negative on the latest normal receipt VETOES; an explicit current-tree degrade record is the
371
+ // only escape for an unavailable backend under council — and never all backends (>=1 ship-class
372
+ // attestation is required whenever >=1 backend is configured).
451
373
  export const decideCheck = (state) => {
452
- // A DETECTOR FAILURE is unknown state, not "no reviewer ready" the advisory tools warn and
453
- // floor at solo, but a GATE must fail closed (codex R2+R3 findings): a broken detector would
454
- // otherwise disable the receipt requirement both for a configured non-solo recipe AND for a
455
- // default-config project (whose computed default would be `reviewed` had a backend been ready
456
- // unknowable while the detector is down). The ONLY detector-independent green is an EXPLICIT
457
- // configured solo (that project asked for no reviewer, readiness is irrelevant to it).
458
- const explicitSolo = state.resolved.recipe === 'solo' && state.resolved.source === 'config' && !state.resolved.degradedFrom;
459
- if (state.detectionWarning && !explicitSolo) {
460
- return { code: 1, reason: `cannot verify receipts ${state.detectionWarning}` };
374
+ // Store diagnostics ride EVERY check line, early exits (and the unknowable arm) included a
375
+ // malformed receipt line, an unavailable evidence store, or an unreadable receipts store is
376
+ // never hidden behind any exit.
377
+ const malformedNote = state.malformed > 0 ? ` ${state.malformed} malformed receipt line(s) ignored; inspect ${state.receiptsPath}` : '';
378
+ const evidenceNote = state.evidenceUnavailable
379
+ ? ` evidence store unavailable (${state.evidenceMalformed} malformed line(s)${state.evidenceReadError ? `, read error: ${state.evidenceReadError}` : ''}); the degrade escape is denied (fail-closed) — inspect ${state.evidenceStorePath}`
380
+ : '';
381
+ const earlyNotes = `${malformedNote}${evidenceNote}${state.receiptsReadError ? ` — receipts store unreadable (${state.receiptsReadError}); inspect ${state.receiptsPath}` : ''}`;
382
+ // Detector failure with NO configured recipe: the computed default is unknowable → fail closed.
383
+ // An EXPLICIT configured recipe needs no detector — its obligations are readiness-independent.
384
+ if (state.obligations.unknowable) {
385
+ return { code: 1, reason: `cannot verify receipts — ${state.detectionWarning} No configured ${ACTIVITY}.${SLOT} recipe: the computed default is unknowable while the detector is down (fail closed).${earlyNotes}` };
461
386
  }
462
- if (state.resolved.recipe === 'solo') {
463
- const why = state.resolved.degradedFrom
464
- ? `resolved ${ACTIVITY}.${SLOT} recipe degrades to solo here (${state.resolved.reason})`
465
- : `resolved ${ACTIVITY}.${SLOT} recipe is solo`;
466
- return { code: 0, reason: `${why} — no receipt required` };
387
+ if (state.obligations.recipe === 'solo') {
388
+ const why = state.obligations.source === 'config'
389
+ ? `configured ${ACTIVITY}.${SLOT} recipe is solo`
390
+ : `no reviewer backend is ready — the computed ${ACTIVITY}.${SLOT} default is solo`;
391
+ return { code: 0, reason: `${why} — no receipt required${earlyNotes}` };
467
392
  }
468
- if (state.plans.length === 0) return { code: 0, reason: 'no plan in flight (docs/plans/ holds no active plan) — no receipt required' };
469
- if (state.fingerprint == null) return { code: 0, reason: 'not a git work tree — nothing to fingerprint' };
470
- if (state.clean === true) return { code: 0, reason: 'the working tree is clean — nothing to review' };
471
- // A malformed receipt line is never silently ignored (No-silent-failures Hard Constraint): it
472
- // cannot fail the gate by itself (a forged/corrupt line must not brick commits), but the check
473
- // line always names it so a PASS over a partially-corrupt file is visible.
474
- const malformedNote = state.malformed > 0 ? ` — ${state.malformed} malformed receipt line(s) ignored; inspect ${state.receiptsPath}` : '';
475
- // The review-ledger is consulted ONLY for the degraded exemption; a corrupt ledger DENIES it
476
- // (fail-closed) but never fails a tree the receipts independently satisfy — surfaced either way
477
- // (No-silent-failures; Decision 3).
478
- const ledgerNote = state.ledgerReadError
479
- ? ` — review ledger unreadable (${state.ledgerReadError}); degraded exemption unavailable (fail-closed) — inspect ${state.ledgerPath}`
480
- : state.ledgerMalformed > 0
481
- ? ` — review ledger has ${state.ledgerMalformed} malformed line(s); degraded exemption unavailable (fail-closed) — inspect ${state.ledgerPath}`
482
- : '';
483
- // The degraded exemption (AD-050): a backend recorded degraded for the current tree is excluded from
484
- // `failing` (it reviewed nothing to receipt — MIRRORS decideStop excluding a degraded backend). It
485
- // stays verdict-blind: the exemption proves the degrade was RECORDED, never that the tree converged.
393
+ if (state.plans.length === 0) return { code: 0, reason: `no plan in flight (docs/plans/ holds no active plan) — no receipt required${earlyNotes}` };
394
+ if (state.fingerprint == null) return { code: 0, reason: `not a git work tree — nothing to fingerprint${earlyNotes}` };
395
+ if (state.clean === true) return { code: 0, reason: `the working tree is clean — nothing to review${earlyNotes}` };
486
396
  const exempt = new Set(state.degradedExempt);
487
- const failing = state.backends.filter((b) => b.state !== 'current' && !exempt.has(b.backend));
488
- // A receipt dropped for an untrustworthy probe marker is never silently dropped either (D3): it
489
- // cannot fail a tree that other receipts satisfy, but a report over a partially-rejected set says
490
- // so. It counts only the exclusions NOT already named per backend a FAILING `rejected` backend
491
- // prints its own cause below, and repeating it in the summary is noise. Every other exclusion (a
492
- // partial one on a satisfied backend, or one on a degraded-exempt backend) still needs a voice.
493
- const namedPerBackend = new Set(failing.filter((b) => b.state === 'rejected').map((b) => b.backend));
494
- const untrustedTotal = state.backends
495
- .filter((b) => !namedPerBackend.has(b.backend))
496
- .reduce((n, b) => n + (b.markerRejected ?? 0) + (b.unmarkedRejected ?? 0), 0);
497
- const markerNote = untrustedTotal > 0
498
- ? ` — ${untrustedTotal} receipt(s) rejected: an untrustworthy probe marker (malformed, or absent — silence is not a declaration) — fail-closed; inspect ${state.receiptsPath}`
499
- : '';
500
- if (failing.length === 0) {
397
+ const satisfied = state.backends.filter((b) => b.state === 'current' && b.shipClass);
398
+ const vetoed = state.backends.filter((b) => b.state === 'current' && !b.shipClass);
399
+ // The marker note is PATH-AWARE (no silent rejections on any exit): it counts every backend's
400
+ // untrusted-marker exclusions EXCEPT those whose printed part already names themi.e. only a
401
+ // PRINTED `rejected` row (backendFailurePart's rejectionCause) suppresses its own counts; a
402
+ // printed veto/unrecognized/ungrounded row does not, and success paths suppress nothing.
403
+ const notesFor = (printed) => {
404
+ const total = state.backends
405
+ .filter((b) => !(printed.has(b.backend) && b.state === 'rejected'))
406
+ .reduce((n, b) => n + (b.markerRejected ?? 0) + (b.unmarkedRejected ?? 0), 0);
407
+ const markerNote = total > 0
408
+ ? ` — ${total} receipt(s) rejected: an untrustworthy probe marker (malformed, or absent — silence is not a declaration) — fail-closed; inspect ${state.receiptsPath}`
409
+ : '';
410
+ return `${markerNote}${earlyNotes}`;
411
+ };
412
+ const NONE_PRINTED = new Set();
413
+ // UNCONDITIONAL refusals, checked BEFORE minShip/exemptions: a recognized NEGATIVE (the
414
+ // authoritative veto) and an UNRECOGNIZED verdict (fail closed) — another backend's SHIP never
415
+ // masks them and a degrade record never lifts them (the backend demonstrably ran).
416
+ const unrecognized = state.backends.filter((b) => b.state === 'unrecognized-verdict');
417
+ const unconditional = [...vetoed, ...unrecognized];
418
+ if (unconditional.length > 0) {
419
+ return { code: 1, reason: `${unconditional.map((b) => backendFailurePart(b, state)).join('; ')}${notesFor(new Set(unconditional.map((b) => b.backend)))}` };
420
+ }
421
+ // Never all degraded: >=1 ship-class attestation whenever >=1 backend is configured. An
422
+ // already-exempt backend renders its own honest part — never the "record an explicit degrade"
423
+ // recovery it has already taken.
424
+ if (satisfied.length < state.obligations.minShip) {
425
+ const failing = state.backends.filter((b) => !(b.state === 'current' && b.shipClass));
426
+ const allExempt = failing.length > 0 && failing.every((b) => exempt.has(b.backend));
427
+ const head = allExempt
428
+ ? `every configured backend is degrade-recorded for this tree — never all degraded: >=1 non-degraded ship-class attestation is required; run at least one real review`
429
+ : failing
430
+ .map((b) => (exempt.has(b.backend)
431
+ ? `${b.backend}: degrade-recorded for this tree — a degrade never counts toward the >=1 ship-class floor; run a real review on another backend`
432
+ : backendFailurePart(b, state)))
433
+ .join('; ');
434
+ // Only backends that actually rendered their backendFailurePart suppress their counts — an
435
+ // exempt row prints the degrade string (no rejectionCause), so its exclusions stay named.
436
+ return { code: 1, reason: `${head}${notesFor(new Set(failing.filter((b) => !exempt.has(b.backend)).map((b) => b.backend)))}` };
437
+ }
438
+ if (state.obligations.perBackend) {
439
+ // Council: EVERY configured backend must attest ship-class OR carry a current-tree degrade record.
440
+ const failing = state.backends.filter((b) => !(b.state === 'current' && b.shipClass) && !exempt.has(b.backend));
441
+ if (failing.length > 0) {
442
+ return { code: 1, reason: `${failing.map((b) => backendFailurePart(b, state)).join('; ')}${notesFor(new Set(failing.map((b) => b.backend)))}` };
443
+ }
501
444
  if (exempt.size === 0) {
502
- return { code: 0, reason: `every recipe-named backend has a fresh grounded receipt for the current tree (${state.requiredBackends.join(' + ')})${markerNote}${malformedNote}${ledgerNote}` };
445
+ return { code: 0, reason: `every configured backend attests ship-class for the current tree (${state.requiredBackends.join(' + ')})${notesFor(NONE_PRINTED)}` };
503
446
  }
504
- 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(', ')}${markerNote}${malformedNote}${ledgerNote}` };
447
+ return { code: 0, reason: `council satisfied: ship-class attestation(s) from ${satisfied.map((b) => b.backend).join(' + ')}; degrade-recorded for this tree: ${[...exempt].join(', ')}${notesFor(NONE_PRINTED)}` };
505
448
  }
506
- const parts = failing.map((b) => {
507
- if (b.state === 'ungrounded') return `${b.backend}: only ungrounded receipts for the current tree — re-run grounded (--facts)`;
508
- if (b.state === 'probe') return `${b.backend}: only probe receipts for the current tree (CODEX_PROBE=1 / AGY_PROBE=1 relaxes the quality guards) — a probe review never attests; re-run a real one`;
509
- if (b.state === 'rejected') return `${b.backend}: current-tree receipts rejected — ${rejectionCause(b)} (fail-closed); inspect ${state.receiptsPath}`;
510
- if (b.state === 'stale') return `${b.backend}: receipts exist but none matches the current tree (edited after review) — run a fresh review`;
511
- return `${b.backend}: no receipt — run its review wrapper`;
512
- });
513
- return { code: 1, reason: `${parts.join('; ')}${markerNote}${malformedNote}${ledgerNote}` };
449
+ // Reviewed: >=1 ship-class attestation from any review-capable backend satisfies.
450
+ return { code: 0, reason: `reviewed satisfied: ship-class attestation from ${satisfied.map((b) => b.backend).join(' + ')} for the current tree${notesFor(NONE_PRINTED)}` };
514
451
  };
515
452
 
516
453
  // ── rendering ───────────────────────────────────────────────────────────────────────
517
454
 
518
- const STATE_GLYPH = { current: '✓', ungrounded: '✗', probe: '✗', rejected: '✗', stale: '✗', missing: '✗' };
455
+ // The glyph reflects SATISFACTION, not bare state: a `current` row carrying a recognized
456
+ // NEGATIVE is an authoritative veto and renders ✗.
457
+ const STATE_GLYPH = { 'unrecognized-verdict': '✗', ungrounded: '✗', probe: '✗', rejected: '✗', stale: '✗', missing: '✗' };
458
+ const glyphFor = (b) => (b.state === 'current' ? (b.shipClass ? '✓' : '✗') : STATE_GLYPH[b.state]);
519
459
 
520
460
  const formatHuman = (state, check) => {
521
- const src = state.resolved.source === 'config' ? `from ${CONFIG_REL}` : 'computed default';
461
+ const src = state.obligations.source === 'config' ? `from ${CONFIG_REL}` : 'computed default';
522
462
  const lines = [
523
- `review-state — ${ACTIVITY}.${SLOT} = ${state.resolved.recipe} (${src})${state.requiredBackends.length ? ` → ${state.requiredBackends.join(' + ')}` : ''}`,
463
+ `review-state — ${ACTIVITY}.${SLOT} = ${state.obligations.recipe ?? '(unknowable)'} (${src})${state.requiredBackends.length ? ` → ${state.requiredBackends.join(' + ')}${state.obligations.perBackend ? '' : ' (any one, ship-class)'}` : ''}`,
524
464
  ];
525
465
  if (state.detectionWarning) lines.push(` ⚠ ${state.detectionWarning}`);
526
466
  lines.push(` plan in flight: ${state.plans.length ? state.plans.join(', ') : '(none)'}`);
@@ -528,27 +468,32 @@ const formatHuman = (state, check) => {
528
468
  else if (state.clean === true) lines.push(' tree: clean (nothing to review)');
529
469
  else lines.push(` tree fingerprint: ${state.fingerprint}`);
530
470
  lines.push(` receipts: ${state.receiptsPath ?? '(unresolvable — no git dir)'} (${state.receiptCount} line(s)${state.malformed ? `, ${state.malformed} malformed — inspect the file` : ''})`);
531
- if (state.ledgerReadError) lines.push(` ⚠ review ledger unreadable (${state.ledgerReadError}) — degraded exemption unavailable`);
532
- else if (state.ledgerMalformed) lines.push(` ⚠ review ledger: ${state.ledgerMalformed} malformed line(s) — degraded exemption unavailable`);
471
+ if (state.receiptsReadError) lines.push(` ⚠ receipts store unreadable (${state.receiptsReadError}) — inspect ${state.receiptsPath}`);
472
+ if (state.evidenceUnavailable) lines.push(` ⚠ evidence store unavailable (${state.evidenceMalformed} malformed line(s)${state.evidenceReadError ? `, read error: ${state.evidenceReadError}` : ''}) the degrade escape is denied (fail-closed)`);
533
473
  const exempt = new Set(state.degradedExempt);
534
474
  for (const b of state.backends) {
535
- const exemptTag = exempt.has(b.backend) ? ' — degraded-exempt (recorded degraded in the review ledger for the current tree)' : '';
475
+ const exemptTag = exempt.has(b.backend) ? ' — degrade-recorded for the current tree (core-evidence store)' : '';
536
476
  const detail =
537
477
  b.state === 'current'
538
- ? `current (verdict: ${b.verdict}, grounded, ${b.timestamp ?? '?'})`
539
- : b.state === 'ungrounded'
540
- ? `ungrounded for the current tree (verdict: ${b.verdict}) — a grounded fresh run is required`
541
- : b.state === 'probe'
542
- ? 'only probe receipts for the current tree (quality guards relaxed) — a probe review never attests'
543
- : b.state === 'rejected'
544
- ? `current-tree receipts rejected ${rejectionCause(b)} (fail-closed)`
545
- : b.state === 'stale'
546
- ? 'stale no receipt matches the current tree (edited after review)'
547
- : 'missing no receipt from this backend';
478
+ ? `current (verdict: ${JSON.stringify(b.verdict)}${b.shipClass ? ', ship-class' : ' — a recognized negative, an authoritative VETO'}, grounded, ${b.timestamp ?? '?'})`
479
+ : b.state === 'unrecognized-verdict'
480
+ ? `latest normal receipt carries an unrecognized verdict (${JSON.stringify(b.verdict)}) — never attests (fail-closed)`
481
+ : b.state === 'ungrounded'
482
+ ? `ungrounded latest normal receipt (verdict: ${JSON.stringify(b.verdict)}) — a grounded fresh run is required`
483
+ : b.state === 'probe'
484
+ ? 'only probe receipts for the current tree (quality guards relaxed) — a probe review never attests'
485
+ : b.state === 'rejected'
486
+ ? `current-tree receipts rejected ${rejectionCause(b)} (fail-closed)`
487
+ : b.state === 'stale'
488
+ ? 'stale — no receipt matches the current tree (edited after review)'
489
+ : 'missing — no receipt from this backend';
548
490
  const excludedTag = b.probeExcluded || b.markerRejected || b.unmarkedRejected
549
491
  ? ` [excluded: ${b.probeExcluded} probe, ${b.markerRejected} malformed-marker, ${b.unmarkedRejected} unmarked]`
550
492
  : '';
551
- lines.push(` ${exempt.has(b.backend) ? '' : STATE_GLYPH[b.state]} ${b.backend}: ${detail}${excludedTag}${exemptTag}`);
493
+ //only where the escape actually applies: a PRODUCED receipt outranks the record — a
494
+ // negative/unknown verdict keeps its ✗ (the degrade lifts neither).
495
+ const escapeApplies = exempt.has(b.backend) && b.state !== 'current' && b.state !== 'unrecognized-verdict';
496
+ lines.push(` ${escapeApplies ? '⊘' : glyphFor(b)} ${b.backend}: ${detail}${excludedTag}${exemptTag}`);
552
497
  }
553
498
  lines.push(` check: ${check.code === 0 ? 'PASS' : 'FAIL'} — ${check.reason}`);
554
499
  return lines.join('\n');
@@ -559,23 +504,25 @@ const HELP = `review-state — read-only review-receipt checker (agent-workflow
559
504
  Usage:
560
505
  node review-state.mjs [--check] [--json]
561
506
 
562
- Resolves the effective ${ACTIVITY}.${SLOT} recipe (${CONFIG_REL} + the read-only backend detector),
563
- recomputes the canonical uncommitted-state fingerprint (staged + unstaged + untracked-not-ignored —
564
- the review-payload domain), reads the receipt file the review wrappers append to
565
- (<git dir>/${RECEIPTS_BASENAME}; AW_REVIEW_RECEIPTS overrides), and reports per-backend receipt
566
- presence + verdict + grounding for the CURRENT tree. Plan/diff-mode receipts and continuations
567
- (fresh:false) are informational-only they never satisfy the tree check, and neither does a PROBE
568
- receipt (probe:true a CODEX_PROBE=1 / AGY_PROBE=1 run has the quality guards off). The probe
569
- filter is per receipt, so a real receipt at the same fingerprint still satisfies. Every marker-aware
570
- wrapper writes the probe field on EVERY review (true or false), so a malformed OR absent marker is
571
- rejected fail-closed silence is not a declaration.
507
+ Derives the review OBLIGATIONS from the CONFIGURED ${ACTIVITY}.${SLOT} recipe (${CONFIG_REL} raw
508
+ value — never the readiness-degraded effective recipe), recomputes the canonical uncommitted-state
509
+ fingerprint (staged + unstaged + untracked-not-ignored — the review-payload domain), reads the
510
+ receipt file the review wrappers append to (<git dir>/${RECEIPTS_BASENAME}; AW_REVIEW_RECEIPTS
511
+ overrides), and judges each configured backend's LATEST NORMAL receipt: only SHIP-CLASS verdicts
512
+ (ship / ship with nits) satisfy; a recognized negative (revise / rethink / rework) is an
513
+ authoritative VETO; an unrecognized verdict never attests (fail closed). Plan/diff-mode receipts
514
+ and continuations (fresh:false) are informational-only, and a PROBE receipt (probe:true) never
515
+ attests; a malformed OR absent probe marker is rejected fail-closed silence is not a declaration.
516
+ The ONLY escape for an unavailable backend under council is an explicit current-tree degrade
517
+ record (node core-evidence.mjs degrade) — and never all backends.
572
518
 
573
519
  --check exits 0/1 per the normative contract in the tool header: 0 for solo / no plan in flight /
574
- a clean tree / not-a-git-tree / all recipe-named backends receipted-current-and-grounded OR
575
- degraded-exempt (a recorded current-tree degrade in the review-ledger for that backend; AD-050); 1 when
576
- a recipe-named backend is missing, stale (edited after review), or grounded:false under reviewed/council
577
- AND is not degraded-exempt. Declare it as a project gate by hand (docs/ai/gates.json) or via the
578
- explicit-consent seeder (tools/seed-gates.mjs) never without consent.
520
+ a clean tree / not-a-git-tree / obligations satisfied (reviewed: >=1 ship-class attestation;
521
+ council: every backend ship-class or degrade-recorded, >=1 real ship); 1 on a veto, an
522
+ unrecognized verdict, a missing/stale/ungrounded/probe-only backend without a degrade record,
523
+ an all-degraded tree, or a down detector with no configured recipe.
524
+ Declare it as a project gate by hand (docs/ai/gates.json) or via the
525
+ explicit-consent init preview (tools/gates-init.mjs) — never without consent.
579
526
 
580
527
  Read-only: never writes, never commits, never runs a subscription CLI; spawns read-only git queries.
581
528
  Sandbox-safe: runs fully inside an OS sandbox (fs + git reads, no network) — the D4 sandbox lane.
@@ -611,17 +558,18 @@ export const main = (argv, ctx = {}) => {
611
558
  }
612
559
  };
613
560
 
614
- // ── --await: block until every recipe-named backend has receipted the current tree ─────
615
- // (BUGFREE-3 / AD-049, item (d)). It waits for every recipe-named backend to be SATISFIED — a fresh
616
- // grounded current-tree receipt, OR (AD-050) the degraded exemption: once a current-tree degrade is
617
- // RECORDED in the review-ledger, --await stops waiting for that backend and returns READY (before,
618
- // it waited forever for a receipt that never comes). It inherits the exemption for FREE — it polls
619
- // the SAME decideCheck(buildState()) `--check` computes. The completion signal is the RECEIPT (i.e.
620
- // `--check` would PASS), NEVER a process event — a harness "completed" notification fires early and a
621
- // bridge's output late-flushes, so polling a pid/receipt-file is the durable mechanization of
622
- // receipts-not-pgrep. Stays read-only (it only re-reads state now the ledger too, a few KB per
623
- // tick); the clock is injectable (ctx.now / ctx.sleep / ctx.pollMs) so hermetic tests never spend
624
- // wall-clock.
561
+ // ── --await: block until the configured review obligations are SATISFIED ───────────────
562
+ // It waits until `--check` would pass: under `reviewed`, a ship-class attestation from any
563
+ // review-capable backend; under `council`, every configured backend ship-class OR carrying a
564
+ // current-tree degrade RECORD in the core-evidence store (never all backends) once such a
565
+ // record lands, --await stops waiting for that backend and returns READY (before, it waited
566
+ // forever for a receipt that never comes). It inherits everything for FREE — it polls the SAME
567
+ // decideCheck(buildState()) `--check` computes. The completion signal is the RECEIPT/RECORD
568
+ // (i.e. `--check` would PASS), NEVER a process event a harness "completed" notification fires
569
+ // early and a bridge's output late-flushes, so polling state is the durable mechanization of
570
+ // receipts-not-pgrep. Stays read-only (it only re-reads state now the evidence store too, a
571
+ // few KB per tick); the clock is injectable (ctx.now / ctx.sleep / ctx.pollMs) so hermetic tests
572
+ // never spend wall-clock.
625
573
 
626
574
  const AWAIT_ALLOWED_ARGS = new Set(['--await', '--timeout']);
627
575