@sabaiway/agent-workflow-kit 1.35.0 → 1.37.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +70 -0
- package/README.md +2 -0
- package/SKILL.md +24 -14
- package/capability.json +1 -1
- package/package.json +1 -1
- package/references/modes/fold-completeness.md +22 -0
- package/references/modes/review-ledger.md +28 -0
- package/references/modes/set-autonomy.md +29 -0
- package/references/modes/velocity.md +13 -0
- package/tools/autonomy-config.mjs +306 -0
- package/tools/autonomy-write.mjs +27 -0
- package/tools/commands.mjs +21 -0
- package/tools/fold-completeness-run.mjs +526 -0
- package/tools/fold-completeness.mjs +364 -0
- package/tools/procedures.mjs +12 -3
- package/tools/review-ledger-write.mjs +261 -0
- package/tools/review-ledger.mjs +535 -0
- package/tools/seed-gates.mjs +45 -4
- package/tools/set-autonomy.mjs +195 -0
- package/tools/velocity-profile.mjs +468 -5
|
@@ -0,0 +1,535 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// review-ledger.mjs — the read-only review-round LEDGER checker behind `/agent-workflow-kit
|
|
3
|
+
// review-ledger` (DEBT-REVIEW-CAP / AD-045). It turns the prose review-loop crossover-stop
|
|
4
|
+
// (planning.md §9 "cap ≤2 / crossover-stop / fold-at-altitude"; procedures.md "{round N ·
|
|
5
|
+
// finding-origin tally · per-backend verdict} … computed signal, not a remembered rule") into a
|
|
6
|
+
// COMPUTED signal: the orchestrator records one round per review round to a git-dir JSONL, and this
|
|
7
|
+
// tool computes the stop decision from the recorded rounds + triage classifications, never from a
|
|
8
|
+
// remembered rule. It is the read-only sibling of review-state.mjs (presence vs convergence are
|
|
9
|
+
// distinct axes) — it NEVER imports the writer (review-ledger-write.mjs); an import-split test pins
|
|
10
|
+
// that structural invariant.
|
|
11
|
+
//
|
|
12
|
+
// Normative `--check` exit contract (the single home of this list — SKILL.md / the mode file point
|
|
13
|
+
// here). The gate enforces the plan-EXECUTION (code) loop — the loop that produces the committable
|
|
14
|
+
// artifact — filtered to activity==="plan-execution" AND the in-flight plan's filename stem:
|
|
15
|
+
// exit 0 when the resolved plan-execution.review recipe is solo (configured, or degraded there —
|
|
16
|
+
// no reviewer ready); when no plan is in flight (the review-state naming convention);
|
|
17
|
+
// when the tree is clean (nothing to review); when the cwd is not a git work tree; and
|
|
18
|
+
// when the in-flight plan-execution loop is `converged` or `resolved-residual` (its
|
|
19
|
+
// latest round's non-degraded backends carry grounded code receipts for the recorded
|
|
20
|
+
// fingerprint, and a recorded 0/0 is ship-class-consistent with those receipts).
|
|
21
|
+
// exit 1 for any DIRTY in-flight plan-execution loop that is neither `converged` nor
|
|
22
|
+
// `resolved-residual` — `triage-required`, `continue`, OR no round/receipt recorded at
|
|
23
|
+
// all (a dirty active plan with an empty/stale ledger is a FAILURE, not a fail-open pass);
|
|
24
|
+
// when MORE THAN ONE plan is in flight (ambiguous loop id); when a recorded ship-class 0/0
|
|
25
|
+
// coexists with a non-ship receipt verdict, or a non-degraded recorded backend lacks a
|
|
26
|
+
// grounded receipt for its fingerprint. Fail-CLOSED (unknown state, never a fail-open pass)
|
|
27
|
+
// on a detector failure, an unreadable (non-ENOENT) ledger, malformed ledger lines the
|
|
28
|
+
// reader dropped, or a corrupt round sequence (not 1..n) — the only detector-independent
|
|
29
|
+
// green is an EXPLICIT configured solo.
|
|
30
|
+
//
|
|
31
|
+
// The stop decision: `decideStop(records, { cap, currentFingerprint, requiredBackends })` reads the
|
|
32
|
+
// ordered ledger records of ONE loop (both `round` and `triage` kinds) and returns exactly one state
|
|
33
|
+
// ∈ {converged, resolved-residual, triage-required, continue} under the fixed precedence
|
|
34
|
+
// (converged > resolved-residual > triage-required > continue). It reads MACHINE fields only (counts,
|
|
35
|
+
// class, accepted, fingerprint), never free-text. `hardMax` is NOT an input — it is a writer-only
|
|
36
|
+
// ceiling (Decision 5), enforced in review-ledger-write.mjs.
|
|
37
|
+
//
|
|
38
|
+
// HUMAN residual (accepted, documented — exactly like review-state's): the ledger attests a review
|
|
39
|
+
// occurred and its ship-class is consistent; it does NOT prove the recorded COUNTS are truthful, nor
|
|
40
|
+
// that a self-reported `degraded:true` is real (`git commit --no-verify`, ledger-file editing, and
|
|
41
|
+
// forged counts remain possible). The ledger lives in the git dir (never committable) — an honest
|
|
42
|
+
// self-discipline mechanism against silent process drift, not a security boundary.
|
|
43
|
+
//
|
|
44
|
+
// Read-only: never writes, never commits, never runs a subscription CLI. It DOES spawn read-only
|
|
45
|
+
// `git` queries (via the reused review-state fingerprint reader + a git-dir resolver). Dependency-
|
|
46
|
+
// free, Node >= 18. No side effects on import (the isDirectRun idiom).
|
|
47
|
+
|
|
48
|
+
import { readFileSync } from 'node:fs';
|
|
49
|
+
import { join } from 'node:path';
|
|
50
|
+
import { pathToFileURL } from 'node:url';
|
|
51
|
+
import { spawnSync } from 'node:child_process';
|
|
52
|
+
import { detectBackends } from './detect-backends.mjs';
|
|
53
|
+
import { resolveActivityRecipe, planRecipe, DISPLAY_ALIASES } from './recipes.mjs';
|
|
54
|
+
import { CONFIG_REL, fail, loadConfig } from './orchestration-config.mjs';
|
|
55
|
+
// Reuse the review-state readers directly (the family read/write split — review-state is read-only,
|
|
56
|
+
// so importing it keeps this module writer-free): the canonical fingerprint, the tolerant receipt
|
|
57
|
+
// parse, the tree-clean preflight, the plan-in-flight detector, and the receipt-path resolver.
|
|
58
|
+
import {
|
|
59
|
+
computeTreeFingerprint,
|
|
60
|
+
isTreeClean,
|
|
61
|
+
plansInFlight,
|
|
62
|
+
readReceipts,
|
|
63
|
+
resolveReceiptsPath,
|
|
64
|
+
} from './review-state.mjs';
|
|
65
|
+
|
|
66
|
+
export const LEDGER_BASENAME = 'agent-workflow-review-ledger.jsonl';
|
|
67
|
+
const ACTIVITY = 'plan-execution';
|
|
68
|
+
const SLOT = 'review';
|
|
69
|
+
// The triage TRIGGER cap (Decision 5): reaching it with an unclassified surviving blocking finding
|
|
70
|
+
// forces triage. Shared with the writer (which imports it) — the writer-only hard-max ceiling lives
|
|
71
|
+
// there, never here (it is not a decideStop input).
|
|
72
|
+
export const REVIEW_CAP = 2;
|
|
73
|
+
// SCHEMA_VERSION is what the WRITER emits (M2/AD-046: a fixable-bug triage requires a non-null,
|
|
74
|
+
// well-formed testId — the red→green test that pins the fold). The READER tolerates every
|
|
75
|
+
// SUPPORTED_SCHEMAS version under its own per-version rules, so historical/live v1 ledgers never
|
|
76
|
+
// retroactively become malformed (Decision 2 — a malformed line cascades fail-closed refusals in the
|
|
77
|
+
// writer teeth AND the --check gate). v1 records keep the AD-045 rule (testId optional, unenforced);
|
|
78
|
+
// only v2 enforces the test-per-fold binding. decideStop never reads testId (not a decideStop input).
|
|
79
|
+
export const SCHEMA_VERSION = 2;
|
|
80
|
+
const SUPPORTED_SCHEMAS = new Set([1, 2]);
|
|
81
|
+
|
|
82
|
+
// The record vocabulary — the single home of every enum the schema validates.
|
|
83
|
+
const ACTIVITIES_SET = new Set(['plan-authoring', 'plan-execution']);
|
|
84
|
+
const KINDS_SET = new Set(['round', 'triage']);
|
|
85
|
+
const SEVERITIES = new Set(['blocker', 'major', 'minor']);
|
|
86
|
+
export const ORIGINS = ['first-draft', 'fold-induced', 'mechanics'];
|
|
87
|
+
const CLASSES = new Set(['fixable-bug', 'inherent-layer-residual', 'escalate']);
|
|
88
|
+
|
|
89
|
+
// ── git-dir resolution (read-only queries; the ledger lives in the git dir, uncommittable) ──────
|
|
90
|
+
|
|
91
|
+
const gitLine = (args, cwd) => {
|
|
92
|
+
const r = spawnSync('git', args, { cwd, windowsHide: true });
|
|
93
|
+
if (r.error || r.status !== 0) return null;
|
|
94
|
+
return r.stdout.toString('utf8').replace(/\r?\n$/, '');
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
const gitRoot = (cwd) => gitLine(['rev-parse', '--show-toplevel'], cwd);
|
|
98
|
+
|
|
99
|
+
// The ledger path: AW_REVIEW_LEDGER overrides (mirrors AW_REVIEW_RECEIPTS); else <git dir>/basename.
|
|
100
|
+
// null when the cwd is not a git work tree (no git dir to anchor to).
|
|
101
|
+
export const resolveLedgerPath = (cwd, env = process.env) => {
|
|
102
|
+
if (env.AW_REVIEW_LEDGER) return env.AW_REVIEW_LEDGER;
|
|
103
|
+
const gitDir = gitLine(['rev-parse', '--absolute-git-dir'], cwd);
|
|
104
|
+
return gitDir == null ? null : join(gitDir, LEDGER_BASENAME);
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
// ── ship-verdict mapping (the single home; a named test pins it) ────────────────────────────────
|
|
108
|
+
|
|
109
|
+
// isShipVerdict(verdict) — which free-text review verdicts are ship-class. SHIP / SHIP WITH NITS are
|
|
110
|
+
// ship; revise / REWORK / unknown / anything else are not. Case-insensitive, trimmed.
|
|
111
|
+
export const isShipVerdict = (verdict) => {
|
|
112
|
+
const v = String(verdict ?? '').trim().toLowerCase();
|
|
113
|
+
return v === 'ship' || v === 'ship with nits';
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
// ── schema validation (tolerant reader counts + surfaces malformed lines, never drops silently) ──
|
|
117
|
+
|
|
118
|
+
const isPlainObject = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
|
|
119
|
+
const isNonEmptyString = (v) => typeof v === 'string' && v.length > 0;
|
|
120
|
+
const isNonNegInt = (v) => Number.isInteger(v) && v >= 0;
|
|
121
|
+
|
|
122
|
+
// testId FORMAT (Decision 3): "<repo-relative test file>#<test-name-pattern>" — a "#" separator with
|
|
123
|
+
// BOTH halves non-empty. NO file-suffix rule: a suffix check would itself be a special case and would
|
|
124
|
+
// block a consumer's own naming (e.g. `.spec.js`; agy R1). The reader validates FORMAT only (it stays
|
|
125
|
+
// hermetic); the fold-completeness gate validates RESOLVABILITY via a bound-test probe run.
|
|
126
|
+
const TESTID_SEPARATOR = '#';
|
|
127
|
+
const isWellFormedTestId = (v) => {
|
|
128
|
+
if (typeof v !== 'string') return false;
|
|
129
|
+
const at = v.indexOf(TESTID_SEPARATOR);
|
|
130
|
+
return at > 0 && at < v.length - 1; // separator present, both halves non-empty
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
// validateRound(obj) → { ok, reason }. Structural checks + the two internal-consistency invariants:
|
|
134
|
+
// the per-backend findings-by-severity equal that backend's counts, and the origins tally equals the
|
|
135
|
+
// aggregation of findings[].origin.
|
|
136
|
+
const validateRound = (obj) => {
|
|
137
|
+
if (!isPlainObject(obj.origins)) return { ok: false, reason: 'round: missing origins object' };
|
|
138
|
+
for (const k of ORIGINS) if (!isNonNegInt(obj.origins[k])) return { ok: false, reason: `round: origins.${k} must be a non-negative integer` };
|
|
139
|
+
if (!Array.isArray(obj.backends) || obj.backends.length === 0) return { ok: false, reason: 'round: backends must be a non-empty array' };
|
|
140
|
+
for (const b of obj.backends) {
|
|
141
|
+
if (!isPlainObject(b) || !isNonEmptyString(b.backend)) return { ok: false, reason: 'round: each backend needs a backend name' };
|
|
142
|
+
if (typeof b.degraded !== 'boolean') return { ok: false, reason: `round: backend ${b.backend} missing boolean degraded` };
|
|
143
|
+
if (!isNonNegInt(b.blockers) || !isNonNegInt(b.majors) || !isNonNegInt(b.minors)) return { ok: false, reason: `round: backend ${b.backend} counts must be non-negative integers` };
|
|
144
|
+
if (!isNonEmptyString(b.verdict)) return { ok: false, reason: `round: backend ${b.backend} missing verdict` };
|
|
145
|
+
// A degraded backend ran no real review: it MUST carry a reason, record 0/0/0 counts, and its
|
|
146
|
+
// verdict is exactly "degraded". Without this a degraded row could carry a blocking finding while
|
|
147
|
+
// convergence (which excludes degraded backends) still passes — a hidden-blocker hole (codex R1).
|
|
148
|
+
if (b.degraded === true) {
|
|
149
|
+
if (!isNonEmptyString(b.reason)) return { ok: false, reason: `round: degraded backend ${b.backend} must carry a reason` };
|
|
150
|
+
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)` };
|
|
151
|
+
if (b.verdict !== 'degraded') return { ok: false, reason: `round: degraded backend ${b.backend} verdict must be "degraded"` };
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
// Duplicate backend names would make entryFor / the per-backend consistency ambiguous (agy R1).
|
|
155
|
+
if (new Set(obj.backends.map((b) => b.backend)).size !== obj.backends.length) return { ok: false, reason: 'round: duplicate backend name in backends[]' };
|
|
156
|
+
if (!Array.isArray(obj.findings)) return { ok: false, reason: 'round: findings must be an array' };
|
|
157
|
+
const backendSet = new Set(obj.backends.map((b) => b.backend));
|
|
158
|
+
const degradedSet = new Set(obj.backends.filter((b) => b.degraded === true).map((b) => b.backend));
|
|
159
|
+
for (const f of obj.findings) {
|
|
160
|
+
if (!isPlainObject(f) || !isNonEmptyString(f.findingKey)) return { ok: false, reason: 'round: each finding needs a findingKey' };
|
|
161
|
+
if (!SEVERITIES.has(f.severity)) return { ok: false, reason: `round: finding ${f.findingKey} bad severity "${f.severity}"` };
|
|
162
|
+
if (!ORIGINS.includes(f.origin)) return { ok: false, reason: `round: finding ${f.findingKey} bad origin "${f.origin}"` };
|
|
163
|
+
if (!isNonEmptyString(f.backend)) return { ok: false, reason: `round: finding ${f.findingKey} missing backend` };
|
|
164
|
+
if (!backendSet.has(f.backend)) return { ok: false, reason: `round: finding ${f.findingKey} backend "${f.backend}" is not in backends[]` };
|
|
165
|
+
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)` };
|
|
166
|
+
}
|
|
167
|
+
// Internal consistency: per-backend findings-by-severity equal the recorded counts.
|
|
168
|
+
for (const b of obj.backends) {
|
|
169
|
+
const own = { blocker: 0, major: 0, minor: 0 };
|
|
170
|
+
for (const f of obj.findings) if (f.backend === b.backend) own[f.severity] += 1;
|
|
171
|
+
if (own.blocker !== b.blockers || own.major !== b.majors || own.minor !== b.minors) {
|
|
172
|
+
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})` };
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
// Internal consistency: the origins tally equals the aggregation of findings[].origin.
|
|
176
|
+
const tally = { 'first-draft': 0, 'fold-induced': 0, mechanics: 0 };
|
|
177
|
+
for (const f of obj.findings) tally[f.origin] += 1;
|
|
178
|
+
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]})` };
|
|
179
|
+
return { ok: true };
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
// validateTriage(obj, schema) → { ok, reason }. `schema` selects the per-version testId rule (v1
|
|
183
|
+
// tolerant / v2 the test-per-fold binding) — the shared structural checks run in both versions.
|
|
184
|
+
const validateTriage = (obj, schema = SCHEMA_VERSION) => {
|
|
185
|
+
if (!Array.isArray(obj.classifications) || obj.classifications.length === 0) return { ok: false, reason: 'triage: classifications must be a non-empty array' };
|
|
186
|
+
for (const c of obj.classifications) {
|
|
187
|
+
if (!isPlainObject(c) || !isNonEmptyString(c.findingKey)) return { ok: false, reason: 'triage: each classification needs a findingKey' };
|
|
188
|
+
if (!CLASSES.has(c.class)) return { ok: false, reason: `triage: classification ${c.findingKey} bad class "${c.class}"` };
|
|
189
|
+
if (typeof c.accepted !== 'boolean') return { ok: false, reason: `triage: classification ${c.findingKey} missing boolean accepted` };
|
|
190
|
+
// Structural (BOTH versions): testId is null/absent or a non-empty string — an ABSENT key is
|
|
191
|
+
// treated as null, never rejected here (agy R3). The writer normalizes it to null when stored.
|
|
192
|
+
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` };
|
|
193
|
+
// Schema v2 (M2/AD-046) — the test-per-fold binding: a fixable-bug MUST carry a testId (the
|
|
194
|
+
// red→green test that pins the fold), and ANY present testId must be well-formed. v1 keeps the
|
|
195
|
+
// AD-045 rule (testId optional, unenforced) so historical/live v1 ledgers never become malformed.
|
|
196
|
+
if (schema >= 2) {
|
|
197
|
+
const present = isNonEmptyString(c.testId);
|
|
198
|
+
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)` };
|
|
199
|
+
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)` };
|
|
200
|
+
}
|
|
201
|
+
if (typeof c.note !== 'string') return { ok: false, reason: `triage: classification ${c.findingKey} note must be a string` };
|
|
202
|
+
}
|
|
203
|
+
return { ok: true };
|
|
204
|
+
};
|
|
205
|
+
|
|
206
|
+
// validateRecord(obj) → { ok, reason }. The shared frame (schema/loop/activity/kind/round/
|
|
207
|
+
// fingerprint/timestamp) then the per-kind body. `reason` names the exact failed check so the
|
|
208
|
+
// malformed-line surface and the per-check named tests can assert it.
|
|
209
|
+
export const validateRecord = (obj) => {
|
|
210
|
+
if (!isPlainObject(obj)) return { ok: false, reason: 'not an object' };
|
|
211
|
+
if (!SUPPORTED_SCHEMAS.has(obj.schema)) return { ok: false, reason: `schema must be one of ${[...SUPPORTED_SCHEMAS].join(', ')}` };
|
|
212
|
+
if (!isNonEmptyString(obj.loop)) return { ok: false, reason: 'missing loop' };
|
|
213
|
+
if (!ACTIVITIES_SET.has(obj.activity)) return { ok: false, reason: `bad activity "${obj.activity}"` };
|
|
214
|
+
if (!KINDS_SET.has(obj.kind)) return { ok: false, reason: `bad kind "${obj.kind}"` };
|
|
215
|
+
if (!(Number.isInteger(obj.round) && obj.round >= 1)) return { ok: false, reason: 'round must be an integer >= 1' };
|
|
216
|
+
if (!(obj.fingerprint === null || isNonEmptyString(obj.fingerprint))) return { ok: false, reason: 'fingerprint must be null or a non-empty string' };
|
|
217
|
+
if (!isNonEmptyString(obj.timestamp)) return { ok: false, reason: 'missing timestamp' };
|
|
218
|
+
return obj.kind === 'round' ? validateRound(obj) : validateTriage(obj, obj.schema);
|
|
219
|
+
};
|
|
220
|
+
|
|
221
|
+
// readLedger(path) → { records, malformed, malformedReasons }. Absent file → empty (no review ran).
|
|
222
|
+
// A malformed line is counted + its reason surfaced, never silently dropped (mirrors readReceipts).
|
|
223
|
+
export const readLedger = (path, readFile = readFileSync) => {
|
|
224
|
+
let raw;
|
|
225
|
+
try {
|
|
226
|
+
raw = readFile(path, 'utf8');
|
|
227
|
+
} catch (err) {
|
|
228
|
+
// An ABSENT file → empty (no review ran). A non-ENOENT read error (EACCES/EIO) is NOT "no records":
|
|
229
|
+
// treating it as empty would fail the teeth OPEN and could clobber the ledger on rewrite (codex R1).
|
|
230
|
+
// Surface it as a readError so every caller (the writer, the gate) fails CLOSED.
|
|
231
|
+
if (err && err.code === 'ENOENT') return { records: [], malformed: 0, malformedReasons: [] };
|
|
232
|
+
return { records: [], malformed: 0, malformedReasons: [], readError: (err && err.code) || (err && err.message) || 'read failed' };
|
|
233
|
+
}
|
|
234
|
+
const records = [];
|
|
235
|
+
const malformedReasons = [];
|
|
236
|
+
for (const line of raw.split('\n')) {
|
|
237
|
+
if (line.trim() === '') continue;
|
|
238
|
+
let parsed;
|
|
239
|
+
try {
|
|
240
|
+
parsed = JSON.parse(line);
|
|
241
|
+
} catch (err) {
|
|
242
|
+
malformedReasons.push(`unparseable JSON (${err.message})`);
|
|
243
|
+
continue;
|
|
244
|
+
}
|
|
245
|
+
const v = validateRecord(parsed);
|
|
246
|
+
if (v.ok) records.push(parsed);
|
|
247
|
+
else malformedReasons.push(v.reason);
|
|
248
|
+
}
|
|
249
|
+
return { records, malformed: malformedReasons.length, malformedReasons };
|
|
250
|
+
};
|
|
251
|
+
|
|
252
|
+
// filterLoopRecords(records, { activity, loop }) → the records of ONE loop (both kinds), order
|
|
253
|
+
// preserved. The gate filters to activity==="plan-execution" AND loop===the in-flight plan stem;
|
|
254
|
+
// authoring rounds (and other plans' rounds) never enter the code gate.
|
|
255
|
+
export const filterLoopRecords = (records, { activity, loop }) =>
|
|
256
|
+
records.filter((r) => r.activity === activity && r.loop === loop);
|
|
257
|
+
|
|
258
|
+
// roundSequenceIntact(records) → true iff the round records, in file order, number exactly 1,2,…,n
|
|
259
|
+
// (no duplicate, gap, or out-of-order round). Checks the EXISTING sequence, not just the incoming
|
|
260
|
+
// round: a ledger like [2] / [1,1] / [2,1] (reachable only by hand-editing the git-dir file — the
|
|
261
|
+
// stated residual) must fail closed rather than be trusted to compute the "latest" round (codex R3).
|
|
262
|
+
export const roundSequenceIntact = (records) => {
|
|
263
|
+
const nums = records.filter((r) => r.kind === 'round').map((r) => r.round);
|
|
264
|
+
return nums.every((n, i) => n === i + 1);
|
|
265
|
+
};
|
|
266
|
+
|
|
267
|
+
// ── the computed crossover-stop (pure; machine fields only) ─────────────────────────────────────
|
|
268
|
+
|
|
269
|
+
const BLOCKING = new Set(['blocker', 'major']);
|
|
270
|
+
const isBlocking = (f) => BLOCKING.has(f.severity);
|
|
271
|
+
|
|
272
|
+
// decideStop(records, { cap, currentFingerprint, requiredBackends }) → { state, reason }.
|
|
273
|
+
// state ∈ {converged, resolved-residual, triage-required, continue}, fixed precedence
|
|
274
|
+
// converged > resolved-residual > triage-required > continue. Machine fields only.
|
|
275
|
+
export const decideStop = (records, { cap = REVIEW_CAP, currentFingerprint = null, requiredBackends = [] } = {}) => {
|
|
276
|
+
const rounds = records.filter((r) => r.kind === 'round');
|
|
277
|
+
const triages = records.filter((r) => r.kind === 'triage');
|
|
278
|
+
if (rounds.length === 0) return { state: 'continue', reason: 'no review round recorded for this loop yet' };
|
|
279
|
+
const latest = rounds[rounds.length - 1];
|
|
280
|
+
|
|
281
|
+
// Classification map, BOUND to the triggering (latest) round: only a triage that targets the latest
|
|
282
|
+
// round classifies its surviving findings. A triage recorded for an earlier round — or a future /
|
|
283
|
+
// nonexistent round — must NOT satisfy resolved-residual by findingKey alone (codex R1): a finding
|
|
284
|
+
// that recurs into a new round is re-triaged for that round. A later triage overrides an earlier one;
|
|
285
|
+
// each carries the triage's own fingerprint (the tree state its resolution was decided against).
|
|
286
|
+
const classOf = new Map();
|
|
287
|
+
for (const t of triages) if (t.round === latest.round) for (const c of t.classifications) classOf.set(c.findingKey, { ...c, triageFingerprint: t.fingerprint });
|
|
288
|
+
const isClassified = (key) => classOf.has(key);
|
|
289
|
+
const isResolvedClass = (c) => c && (c.class === 'inherent-layer-residual' || (c.class === 'escalate' && c.accepted === true));
|
|
290
|
+
|
|
291
|
+
const survivingBlocking = latest.findings.filter(isBlocking);
|
|
292
|
+
|
|
293
|
+
// ── converged ── every requiredBackend present + non-degraded at 0/0 (a degraded requiredBackend
|
|
294
|
+
// is excluded but must be recorded), at least one real non-degraded 0/0 review, current tree.
|
|
295
|
+
const entryFor = (rb) => latest.backends.find((b) => b.backend === rb);
|
|
296
|
+
const allPresent = requiredBackends.every((rb) => entryFor(rb) !== undefined);
|
|
297
|
+
const nonDegradedReq = requiredBackends.map(entryFor).filter((b) => b && !b.degraded);
|
|
298
|
+
const convergedCounts = allPresent && nonDegradedReq.length >= 1 && nonDegradedReq.every((b) => b.blockers === 0 && b.majors === 0);
|
|
299
|
+
if (convergedCounts && currentFingerprint != null && currentFingerprint === latest.fingerprint) {
|
|
300
|
+
return { state: 'converged', reason: `every recipe-named backend reviewed the current tree at 0 blockers + 0 majors (${requiredBackends.join(' + ') || 'none named'})` };
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// A blocking findingKey's presence across ROUND records (recurrence, Decision 5).
|
|
304
|
+
const blockingRoundCount = new Map();
|
|
305
|
+
for (const r of rounds) {
|
|
306
|
+
const keys = new Set(r.findings.filter(isBlocking).map((f) => f.findingKey));
|
|
307
|
+
for (const k of keys) blockingRoundCount.set(k, (blockingRoundCount.get(k) ?? 0) + 1);
|
|
308
|
+
}
|
|
309
|
+
const recurred = (key) => (blockingRoundCount.get(key) ?? 0) >= 2;
|
|
310
|
+
// Recurrence is keyed on the LATEST round's SURVIVING findings ONLY. A finding that was FIXED (no
|
|
311
|
+
// longer surviving) must never force triage-required — else the gate DEADLOCKS: recordTriage rightly
|
|
312
|
+
// refuses to classify a vanished finding, so a historical recurring key that is gone could never be
|
|
313
|
+
// cleared. This is the root simplification (council R3, confirmed by both backends).
|
|
314
|
+
const triggerReached = latest.round >= cap || survivingBlocking.some((f) => recurred(f.findingKey));
|
|
315
|
+
|
|
316
|
+
// ── resolved-residual ── trigger reached AND every recipe-named backend present (the SAME presence
|
|
317
|
+
// discipline as converged — a residual accepted while a recipe-named backend never reviewed is not
|
|
318
|
+
// resolved, codex R4) AND every surviving blocking finding classified inherent-layer-residual (or
|
|
319
|
+
// accepted-escalate) AND the classifying triage's fingerprint equals the current tree.
|
|
320
|
+
if (triggerReached && allPresent && survivingBlocking.length >= 1) {
|
|
321
|
+
const allResolved = survivingBlocking.every((f) => {
|
|
322
|
+
const c = classOf.get(f.findingKey);
|
|
323
|
+
return isResolvedClass(c) && currentFingerprint != null && c.triageFingerprint === currentFingerprint;
|
|
324
|
+
});
|
|
325
|
+
if (allResolved) {
|
|
326
|
+
return { state: 'resolved-residual', reason: `${survivingBlocking.length} surviving blocking finding(s) classified as an accepted residual at the current tree — never folded again` };
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
// ── triage-required (writer HARD STOP) ── at/after the cap an UNCLASSIFIED surviving blocking
|
|
331
|
+
// finding, OR a blocking findingKey recurred in >= 2 rounds and is still unclassified (even under
|
|
332
|
+
// the cap). Only the UNCLASSIFIED state blocks — a classified fixable-bug lets the fix round run.
|
|
333
|
+
const unclassifiedSurviving = survivingBlocking.filter((f) => !isClassified(f.findingKey));
|
|
334
|
+
// Keys reference only LIVE (surviving) findings — never a vanished historical key (the deadlock).
|
|
335
|
+
const hasUnclassifiedRecurrence = unclassifiedSurviving.some((f) => recurred(f.findingKey));
|
|
336
|
+
if ((latest.round >= cap && unclassifiedSurviving.length >= 1) || hasUnclassifiedRecurrence) {
|
|
337
|
+
const keys = [...new Set(unclassifiedSurviving.map((f) => f.findingKey))];
|
|
338
|
+
const recurNote = hasUnclassifiedRecurrence ? ` (recurred in ≥2 rounds — classify whether it is an inherent-layer-residual before another fold)` : '';
|
|
339
|
+
return { state: 'triage-required', reason: `classify each surviving blocking finding before another round: ${keys.join(', ')}${recurNote}` };
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
// ── continue (explicit catch-all) ──
|
|
343
|
+
let reason;
|
|
344
|
+
if (convergedCounts) reason = 'a clean review exists but the tree was edited after it — re-review the edited tree';
|
|
345
|
+
else if (survivingBlocking.length >= 1) reason = 'surviving blocking findings — fold and re-review, or classify at the cap';
|
|
346
|
+
else reason = 'not yet converged — record the current review round';
|
|
347
|
+
return { state: 'continue', reason };
|
|
348
|
+
};
|
|
349
|
+
|
|
350
|
+
// ── receipt cross-check (integrity binding, Decision 7) ──────────────────────────────────────────
|
|
351
|
+
|
|
352
|
+
// receiptCrossCheck(round, receipts, fingerprint) → { ok, reason }. For each NON-degraded backend of
|
|
353
|
+
// `round`: a grounded code receipt must exist for (backend, fingerprint) — a round cannot pass for a
|
|
354
|
+
// tree no bridge reviewed — AND a recorded ship-class 0/0 must not coexist with a non-ship receipt
|
|
355
|
+
// verdict. A degraded backend minted no receipt (it ran no real review) and is exempt.
|
|
356
|
+
export const receiptCrossCheck = (round, receipts, fingerprint) => {
|
|
357
|
+
for (const b of round.backends) {
|
|
358
|
+
if (b.degraded) continue;
|
|
359
|
+
const own = receipts.filter(
|
|
360
|
+
(r) => r.backend === b.backend && r.fingerprint === fingerprint && r.artifact === 'code' && r.fresh === true && r.grounded === true,
|
|
361
|
+
);
|
|
362
|
+
if (own.length === 0) {
|
|
363
|
+
return { ok: false, reason: `no grounded code receipt for ${b.backend} at the recorded fingerprint (a recorded round must bind to a real review)` };
|
|
364
|
+
}
|
|
365
|
+
if (b.blockers === 0 && b.majors === 0) {
|
|
366
|
+
const latest = own[own.length - 1];
|
|
367
|
+
if (!isShipVerdict(latest.verdict)) {
|
|
368
|
+
return { ok: false, reason: `${b.backend} recorded 0 blockers/0 majors but its receipt verdict "${latest.verdict}" is not ship-class` };
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
return { ok: true };
|
|
373
|
+
};
|
|
374
|
+
|
|
375
|
+
// ── the check + report core ─────────────────────────────────────────────────────────────────────
|
|
376
|
+
|
|
377
|
+
// buildLedgerState({ cwd, env, detect }) → everything both renders need. Pure I/O at the edges;
|
|
378
|
+
// every project-relative read anchors at the git work-tree ROOT when one exists (the fingerprint is
|
|
379
|
+
// root-anchored — the same discipline review-state uses).
|
|
380
|
+
export const buildLedgerState = ({ cwd, env = process.env, detect = detectBackends } = {}) => {
|
|
381
|
+
const root = gitRoot(cwd) ?? cwd;
|
|
382
|
+
const { config } = loadConfig(root);
|
|
383
|
+
let detection = [];
|
|
384
|
+
let detectionWarning = null;
|
|
385
|
+
try {
|
|
386
|
+
detection = detect();
|
|
387
|
+
} catch (err) {
|
|
388
|
+
detectionWarning = `backend detection failed (${(err && err.message) || err}) — treating all backends as not ready; the review recipe floors at solo.`;
|
|
389
|
+
}
|
|
390
|
+
const resolved = resolveActivityRecipe({ config: config ?? {}, readiness: detection, activity: ACTIVITY, slot: SLOT });
|
|
391
|
+
const { dispatch } = planRecipe(resolved.recipe, detection);
|
|
392
|
+
const requiredBackends = dispatch.map((d) => DISPLAY_ALIASES[d.backend] ?? d.backend);
|
|
393
|
+
const plans = plansInFlight(root);
|
|
394
|
+
const fingerprint = computeTreeFingerprint(cwd);
|
|
395
|
+
const clean = fingerprint == null ? null : isTreeClean(cwd);
|
|
396
|
+
const ledgerPath = resolveLedgerPath(cwd, env);
|
|
397
|
+
const { records, malformed, malformedReasons, readError } = ledgerPath ? readLedger(ledgerPath) : { records: [], malformed: 0, malformedReasons: [] };
|
|
398
|
+
const receiptsPath = resolveReceiptsPath(cwd, env);
|
|
399
|
+
const { receipts } = receiptsPath ? readReceipts(receiptsPath) : { receipts: [] };
|
|
400
|
+
return { resolved, requiredBackends, plans, fingerprint, clean, ledgerPath, records, malformed, malformedReasons, readError, receipts, receiptsPath, detectionWarning };
|
|
401
|
+
};
|
|
402
|
+
|
|
403
|
+
// The normative --check decision (the header contract, in order) → { code, reason }.
|
|
404
|
+
export const decideCheck = (state) => {
|
|
405
|
+
// A detector failure is UNKNOWN state, not "no reviewer ready" — fail closed (like review-state).
|
|
406
|
+
// The only detector-independent green is an EXPLICIT configured solo.
|
|
407
|
+
const explicitSolo = state.resolved.recipe === 'solo' && state.resolved.source === 'config' && !state.resolved.degradedFrom;
|
|
408
|
+
if (state.detectionWarning && !explicitSolo) return { code: 1, reason: `cannot verify the ledger — ${state.detectionWarning}` };
|
|
409
|
+
if (state.resolved.recipe === 'solo') {
|
|
410
|
+
const why = state.resolved.degradedFrom
|
|
411
|
+
? `resolved ${ACTIVITY}.${SLOT} recipe degrades to solo here (${state.resolved.reason})`
|
|
412
|
+
: `resolved ${ACTIVITY}.${SLOT} recipe is solo`;
|
|
413
|
+
return { code: 0, reason: `${why} — no ledger required` };
|
|
414
|
+
}
|
|
415
|
+
if (state.plans.length === 0) return { code: 0, reason: 'no plan in flight (docs/plans/ holds no active plan) — no ledger required' };
|
|
416
|
+
// More than one plan in flight → ambiguous loop id: fail CLOSED (Decision 6), never guess.
|
|
417
|
+
if (state.plans.length > 1) return { code: 1, reason: `more than one plan in flight (${state.plans.join(', ')}) — ambiguous loop id; resolve to one active plan` };
|
|
418
|
+
if (state.fingerprint == null) return { code: 0, reason: 'not a git work tree — nothing to fingerprint' };
|
|
419
|
+
if (state.clean === true) return { code: 0, reason: 'the working tree is clean — nothing to review' };
|
|
420
|
+
// Two unknown-state conditions on a dirty active plan → fail CLOSED, never a fail-open pass. A
|
|
421
|
+
// non-ENOENT read error (codex R1); AND malformed lines the tolerant reader dropped, which could
|
|
422
|
+
// silently remove the latest/non-converged round and let a stale converged one PASS (codex R3).
|
|
423
|
+
if (state.readError) return { code: 1, reason: `cannot read the ledger (${state.readError}) — failing closed; inspect ${state.ledgerPath}` };
|
|
424
|
+
if (state.malformed > 0) return { code: 1, reason: `the ledger has ${state.malformed} malformed line(s) — failing closed (a dropped line could hide a non-converged round); inspect ${state.ledgerPath}` };
|
|
425
|
+
|
|
426
|
+
const loop = state.plans[0].replace(/\.md$/, '');
|
|
427
|
+
const filtered = filterLoopRecords(state.records, { activity: ACTIVITY, loop });
|
|
428
|
+
const rounds = filtered.filter((r) => r.kind === 'round');
|
|
429
|
+
// A dirty active plan with NO round recorded is a FAILURE, not a fail-open pass (codex R2 hole).
|
|
430
|
+
if (rounds.length === 0) {
|
|
431
|
+
return { code: 1, reason: `dirty plan-execution loop "${loop}" but no review round recorded — record the current round` };
|
|
432
|
+
}
|
|
433
|
+
// A corrupt round sequence (not 1..n) is unknown state → fail closed rather than trust "latest" (codex R3).
|
|
434
|
+
if (!roundSequenceIntact(rounds)) return { code: 1, reason: `the round sequence for "${loop}" is corrupt (not 1..n) — failing closed; inspect ${state.ledgerPath}` };
|
|
435
|
+
const decision = decideStop(filtered, { cap: REVIEW_CAP, currentFingerprint: state.fingerprint, requiredBackends: state.requiredBackends });
|
|
436
|
+
if (decision.state === 'converged' || decision.state === 'resolved-residual') {
|
|
437
|
+
// Integrity binding: cross-check the latest round's non-degraded backends against their receipts
|
|
438
|
+
// at the RECORDED fingerprint (where the review happened). For converged that fingerprint equals
|
|
439
|
+
// the current tree by construction; for resolved-residual it is the reviewed round's tree.
|
|
440
|
+
const latest = rounds[rounds.length - 1];
|
|
441
|
+
const cc = receiptCrossCheck(latest, state.receipts, latest.fingerprint);
|
|
442
|
+
if (!cc.ok) return { code: 1, reason: `${decision.state} recorded but ${cc.reason}` };
|
|
443
|
+
return { code: 0, reason: `${decision.state} — ${decision.reason}` };
|
|
444
|
+
}
|
|
445
|
+
return { code: 1, reason: `${decision.state} — ${decision.reason}` };
|
|
446
|
+
};
|
|
447
|
+
|
|
448
|
+
// ── rendering ─────────────────────────────────────────────────────────────────────────────────
|
|
449
|
+
|
|
450
|
+
const roundLine = (r) => {
|
|
451
|
+
const backends = r.backends
|
|
452
|
+
.map((b) => `${b.backend} ${b.degraded ? `degraded(${b.reason})` : `${b.blockers}/${b.majors}/${b.minors} ${b.verdict}`}`)
|
|
453
|
+
.join(', ');
|
|
454
|
+
const origins = ORIGINS.map((k) => `${k}:${r.origins[k]}`).join(' ');
|
|
455
|
+
const findings = r.findings.length ? ` [${r.findings.map((f) => `${f.findingKey}(${f.severity})`).join(', ')}]` : '';
|
|
456
|
+
return ` round ${r.round} (${origins}) — ${backends}${findings}`;
|
|
457
|
+
};
|
|
458
|
+
|
|
459
|
+
const triageLine = (t) =>
|
|
460
|
+
` triage @round ${t.round} — ${t.classifications.map((c) => `${c.findingKey}=${c.class}${c.class === 'escalate' ? `(accepted:${c.accepted})` : ''}`).join(', ')}`;
|
|
461
|
+
|
|
462
|
+
const formatHuman = (state, check) => {
|
|
463
|
+
const lines = [
|
|
464
|
+
`review-ledger — ${ACTIVITY}.${SLOT} = ${state.resolved.recipe} (${state.resolved.source === 'config' ? `from ${CONFIG_REL}` : 'computed default'})${state.requiredBackends.length ? ` → ${state.requiredBackends.join(' + ')}` : ''}`,
|
|
465
|
+
];
|
|
466
|
+
if (state.detectionWarning) lines.push(` ⚠ ${state.detectionWarning}`);
|
|
467
|
+
lines.push(` plan in flight: ${state.plans.length ? state.plans.join(', ') : '(none)'}`);
|
|
468
|
+
if (state.fingerprint == null) lines.push(' tree: not a git work tree');
|
|
469
|
+
else if (state.clean === true) lines.push(' tree: clean (nothing to review)');
|
|
470
|
+
else lines.push(` tree fingerprint: ${state.fingerprint}`);
|
|
471
|
+
lines.push(` ledger: ${state.ledgerPath ?? '(unresolvable — no git dir)'} (${state.records.length} record(s)${state.malformed ? `, ${state.malformed} malformed — inspect the file` : ''})`);
|
|
472
|
+
if (state.plans.length === 1) {
|
|
473
|
+
const loop = state.plans[0].replace(/\.md$/, '');
|
|
474
|
+
const forLoop = filterLoopRecords(state.records, { activity: ACTIVITY, loop });
|
|
475
|
+
for (const r of forLoop) lines.push(r.kind === 'round' ? roundLine(r) : triageLine(r));
|
|
476
|
+
}
|
|
477
|
+
lines.push(` check: ${check.code === 0 ? 'PASS' : 'FAIL'} — ${check.reason}`);
|
|
478
|
+
return lines.join('\n');
|
|
479
|
+
};
|
|
480
|
+
|
|
481
|
+
const HELP = `review-ledger — read-only review-round LEDGER checker (agent-workflow family, AD-045).
|
|
482
|
+
|
|
483
|
+
Usage:
|
|
484
|
+
node review-ledger.mjs [--check | --status | --json]
|
|
485
|
+
|
|
486
|
+
Reads the review-round ledger the orchestrator records to (<git dir>/${LEDGER_BASENAME};
|
|
487
|
+
AW_REVIEW_LEDGER overrides), resolves the effective ${ACTIVITY}.${SLOT} recipe, recomputes the
|
|
488
|
+
canonical uncommitted-state fingerprint, and computes the crossover-stop decision for the in-flight
|
|
489
|
+
plan-execution loop (decideStop → converged / resolved-residual / triage-required / continue).
|
|
490
|
+
|
|
491
|
+
--status (default) → the human report: resolved recipe, plan-in-flight, per-round tally with
|
|
492
|
+
findings, per-backend, the decideStop verdict.
|
|
493
|
+
--check → the gate exit code. The normative exit contract lives in the tool header (the single home):
|
|
494
|
+
exit 0 for solo / no plan in flight / a clean tree / not-a-git-tree / a converged or
|
|
495
|
+
resolved-residual in-flight loop; exit 1 for a dirty non-converged loop, triage-required, a loop
|
|
496
|
+
with no round/receipt recorded, more than one plan in flight, or a receipt inconsistency.
|
|
497
|
+
--json → the structured state + decision.
|
|
498
|
+
|
|
499
|
+
The writer is a SEPARATE tool (review-ledger-write.mjs record/classify) — this read-only checker
|
|
500
|
+
never imports it. Human residual: git commit --no-verify, ledger-file editing, and forged counts
|
|
501
|
+
remain possible — a self-discipline mechanism, not a security boundary.
|
|
502
|
+
|
|
503
|
+
Exit codes: 0 pass (or plain report); 1 check failed or config error (loud); 2 usage.`;
|
|
504
|
+
|
|
505
|
+
const KNOWN_ARGS = new Set(['--help', '-h', '--check', '--status', '--json']);
|
|
506
|
+
|
|
507
|
+
export const main = (argv, ctx = {}) => {
|
|
508
|
+
const cwd = ctx.cwd ?? process.cwd();
|
|
509
|
+
const env = ctx.env ?? process.env;
|
|
510
|
+
const detect = ctx.detect ?? detectBackends;
|
|
511
|
+
try {
|
|
512
|
+
if (argv.includes('--help') || argv.includes('-h')) return { code: 0, stdout: HELP, stderr: '' };
|
|
513
|
+
const unknown = argv.find((a) => !KNOWN_ARGS.has(a));
|
|
514
|
+
if (unknown !== undefined) throw fail(2, `unknown argument: ${unknown}`);
|
|
515
|
+
const state = buildLedgerState({ cwd, env, detect });
|
|
516
|
+
const check = decideCheck(state);
|
|
517
|
+
if (argv.includes('--json')) {
|
|
518
|
+
return { code: argv.includes('--check') ? check.code : 0, stdout: JSON.stringify({ ...state, check }, null, 2), stderr: '' };
|
|
519
|
+
}
|
|
520
|
+
if (argv.includes('--check')) {
|
|
521
|
+
return { code: check.code, stdout: `review-ledger check: ${check.code === 0 ? 'PASS' : 'FAIL'} — ${check.reason}`, stderr: '' };
|
|
522
|
+
}
|
|
523
|
+
return { code: 0, stdout: formatHuman(state, check), stderr: '' };
|
|
524
|
+
} catch (err) {
|
|
525
|
+
return { code: err.exitCode ?? 1, stdout: '', stderr: `review-ledger: ${err.message}` };
|
|
526
|
+
}
|
|
527
|
+
};
|
|
528
|
+
|
|
529
|
+
const isDirectRun = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href;
|
|
530
|
+
if (isDirectRun) {
|
|
531
|
+
const r = main(process.argv.slice(2));
|
|
532
|
+
if (r.stdout) process.stdout.write(r.stdout.endsWith('\n') ? r.stdout : `${r.stdout}\n`);
|
|
533
|
+
if (r.stderr) process.stderr.write(r.stderr.endsWith('\n') ? r.stderr : `${r.stderr}\n`);
|
|
534
|
+
process.exitCode = r.code;
|
|
535
|
+
}
|
package/tools/seed-gates.mjs
CHANGED
|
@@ -44,6 +44,7 @@ const HERE = dirname(fileURLToPath(import.meta.url));
|
|
|
44
44
|
const KIT_ROOT = resolve(HERE, '..');
|
|
45
45
|
const TEMPLATE_PATH = join(KIT_ROOT, 'references', 'templates', 'gates.json');
|
|
46
46
|
const REVIEW_STATE_TOOL = join(KIT_ROOT, 'tools', 'review-state.mjs');
|
|
47
|
+
const REVIEW_LEDGER_TOOL = join(KIT_ROOT, 'tools', 'review-ledger.mjs');
|
|
47
48
|
const STAMP_REL = join('docs', 'ai', '.workflow-version');
|
|
48
49
|
|
|
49
50
|
const EXIT_OK = 0;
|
|
@@ -205,6 +206,41 @@ export const reviewStateCandidate = (cwd, deps = {}) => {
|
|
|
205
206
|
}
|
|
206
207
|
};
|
|
207
208
|
|
|
209
|
+
// The conditional review-LEDGER candidate (AD-045) — the SAME consent + conditional rule as the
|
|
210
|
+
// review-state candidate (offered ONLY when plan-execution.review is reviewed/council), keyed on the
|
|
211
|
+
// same slot, path resolved + QUOTED. It gates the review-ROUND ledger (converged / accepted-residual);
|
|
212
|
+
// review-state gates receipt PRESENCE. Both may be offered together — distinct axes.
|
|
213
|
+
export const reviewLedgerCandidate = (cwd, deps = {}) => {
|
|
214
|
+
const toolPath = deps.reviewLedgerTool ?? REVIEW_LEDGER_TOOL;
|
|
215
|
+
try {
|
|
216
|
+
const { config } = loadConfig(resolve(cwd), deps.readFile ?? readFileSync, deps.lstat ?? lstatSync);
|
|
217
|
+
const declared = config?.['plan-execution']?.review;
|
|
218
|
+
if (declared !== 'reviewed' && declared !== 'council') return { candidate: null, note: null };
|
|
219
|
+
if (DQ_UNSAFE_PATH_PATTERN.test(toolPath)) {
|
|
220
|
+
return {
|
|
221
|
+
candidate: null,
|
|
222
|
+
note:
|
|
223
|
+
`the review-ledger candidate was withheld: the resolved kit path contains shell ` +
|
|
224
|
+
`metacharacters that do not survive double-quoting (${toolPath}) — declare the gate ` +
|
|
225
|
+
`by hand per references/modes/review-ledger.md`,
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
return {
|
|
229
|
+
candidate: {
|
|
230
|
+
id: 'review-ledger',
|
|
231
|
+
title: 'Review-round ledger: the in-flight loop is converged or accepted-residual',
|
|
232
|
+
cmd: `node "${toolPath}" --check`,
|
|
233
|
+
},
|
|
234
|
+
note: null,
|
|
235
|
+
};
|
|
236
|
+
} catch (err) {
|
|
237
|
+
return {
|
|
238
|
+
candidate: null,
|
|
239
|
+
note: `orchestration config unreadable (${err.message}) — the review-ledger candidate was not evaluated`,
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
};
|
|
243
|
+
|
|
208
244
|
// Every --only id must name an OFFERED entry — enforced in BOTH paths (dry-run and apply), before
|
|
209
245
|
// any empty-offer shortcut, so a typo is a loud usage error, never a silent filter or a silent
|
|
210
246
|
// "nothing to offer" success.
|
|
@@ -216,13 +252,18 @@ const assertOnlyIdsOffered = (offer, onlyIds = []) => {
|
|
|
216
252
|
}
|
|
217
253
|
};
|
|
218
254
|
|
|
219
|
-
// The full offer: script entries + the conditional review-state
|
|
255
|
+
// The full offer: script entries + the conditional review-state + review-ledger candidates (last),
|
|
256
|
+
// plus loud notes. Both review candidates key on the same slot (plan-execution.review reviewed/council)
|
|
257
|
+
// but gate distinct axes (receipt presence vs review-round convergence) — offered together.
|
|
220
258
|
export const buildOffer = (cwd, deps = {}) => {
|
|
221
259
|
const entries = deriveScriptEntries(cwd, deps);
|
|
222
|
-
const
|
|
260
|
+
const rs = reviewStateCandidate(cwd, deps);
|
|
261
|
+
const rl = reviewLedgerCandidate(cwd, deps);
|
|
262
|
+
const candidates = [rs.candidate, rl.candidate].filter(Boolean);
|
|
263
|
+
const notes = [rs.note, rl.note].filter(Boolean);
|
|
223
264
|
return {
|
|
224
|
-
entries:
|
|
225
|
-
notes
|
|
265
|
+
entries: [...entries, ...candidates],
|
|
266
|
+
notes,
|
|
226
267
|
};
|
|
227
268
|
};
|
|
228
269
|
|