@sabaiway/agent-workflow-kit 1.40.0 → 1.42.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -50,8 +50,6 @@
50
50
  // `git` queries (via the reused review-state fingerprint reader + a git-dir resolver). Dependency-
51
51
  // free, Node >= 18. No side effects on import (the isDirectRun idiom).
52
52
 
53
- import { readFileSync } from 'node:fs';
54
- import { join } from 'node:path';
55
53
  import { pathToFileURL } from 'node:url';
56
54
  import { spawnSync } from 'node:child_process';
57
55
  import { detectBackends } from './detect-backends.mjs';
@@ -71,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
 
@@ -15,7 +15,7 @@ import { AUTONOMY_REL, loadAutonomy, resolveAutonomy, COMMAND_REDLINES } from '.
15
15
 
16
16
  // Deployment-lineage head this velocity build targets; bump together with agent-workflow-memory
17
17
  // LINEAGE_HEAD when the deployed docs/ai structure changes.
18
- export const EXPECTED_WORKFLOW_VERSION = '1.3.0';
18
+ export const EXPECTED_WORKFLOW_VERSION = '2.0.0';
19
19
  export const SETTINGS_FILE = '.claude/settings.json';
20
20
  export const SETTINGS_LOCAL_FILE = '.claude/settings.local.json';
21
21
  export const CLAUDE_DIR = '.claude';