@sabaiway/agent-workflow-kit 1.38.0 → 1.40.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.
@@ -0,0 +1,294 @@
1
+ #!/usr/bin/env node
2
+ // changed-surface.mjs — the NEUTRAL shared read-only core (BUGFREE-2 / AD-048, D4 + D8). One home
3
+ // for the changed-surface computation that BOTH the fold-completeness runner (coverage domain) and
4
+ // the review-ledger writer (the D4 diff-size cap) consume — one computation, so the cap and the
5
+ // coverage gate can never drift (the probeVerdict single-home precedent, AD-047). The D8 telemetry
6
+ // fold-ledger read path lives beside it: fold-completeness.mjs already imports review-ledger.mjs,
7
+ // so the review-ledger telemetry must reach the fold ledger WITHOUT importing back the other
8
+ // direction — it imports THIS module instead.
9
+ //
10
+ // Import-graph invariant (pinned by import-split tests): this module imports NOTHING from the
11
+ // family — node built-ins only. Everyone may import it; it imports no one:
12
+ // review-ledger.mjs → changed-surface.mjs ← fold-completeness.mjs ← fold-completeness-run.mjs
13
+ // review-ledger-write.mjs → changed-surface.mjs (the writer NEVER imports the runner — the
14
+ // sole-tree-toucher boundary, codex R2)
15
+ //
16
+ // Read-only: never writes, never commits. It DOES spawn read-only `git` queries (diff/ls-files/
17
+ // rev-parse). Dependency-free, Node >= 18. No side effects on import.
18
+
19
+ import { readFileSync, lstatSync } from 'node:fs';
20
+ import { join } from 'node:path';
21
+ import { spawnSync } from 'node:child_process';
22
+
23
+ const GIT_MAX_BUFFER = 256 * 1024 * 1024; // a full-tree diff can be large; never truncate
24
+
25
+ // ── the CLOSED changed-path classification rule (AD-046 Decision 5; no heuristics) ───────────────
26
+
27
+ const TEST_FILE_RE = /\.(test|spec)\.[^./]+$/; // a.test.mjs, b.spec.js, c.test.cjs, d.spec.ts
28
+ const ASSESSABLE_EXT = new Set(['.mjs', '.cjs', '.js']);
29
+ const UNSUPPORTED_EXT = new Set(['.ts', '.tsx', '.jsx', '.mts', '.cts']);
30
+
31
+ // classifyChangedPath(rel) → 'assessable' | 'unsupported' | 'out-of-domain' | 'excluded-test'.
32
+ export const classifyChangedPath = (rel) => {
33
+ const base = rel.split('/').pop();
34
+ if (TEST_FILE_RE.test(base)) return 'excluded-test';
35
+ const dot = base.lastIndexOf('.');
36
+ const ext = dot >= 0 ? base.slice(dot) : '';
37
+ if (ASSESSABLE_EXT.has(ext)) return 'assessable';
38
+ if (UNSUPPORTED_EXT.has(ext)) return 'unsupported';
39
+ return 'out-of-domain';
40
+ };
41
+
42
+ // ── diff-header path unquoting (git C-quotes paths carrying quotes/control/non-ASCII bytes) ──────
43
+
44
+ // Strip the quotes and decode escapes BYTE-wise (octal escapes are UTF-8 bytes) — an unparsed
45
+ // quoted path compares unequal to its classifier/testId form and would silently escape the
46
+ // coverage/cap surface (agy R5, AD-047).
47
+ const CQUOTE_SIMPLE = { n: 10, t: 9, r: 13, f: 12, v: 11, b: 8, a: 7, '"': 34, '\\': 92 };
48
+ export const unquoteDiffPath = (p) => {
49
+ if (!(p.length >= 2 && p.startsWith('"') && p.endsWith('"'))) return p;
50
+ const inner = p.slice(1, -1);
51
+ const bytes = [];
52
+ for (let i = 0; i < inner.length; i += 1) {
53
+ const c = inner[i];
54
+ if (c !== '\\') {
55
+ // Consume a full CODE POINT — 16-bit-unit iteration would split a surrogate pair (an
56
+ // unescaped non-BMP char, reachable under core.quotepath=false) into replacement bytes (agy R6).
57
+ const ch = String.fromCodePoint(inner.codePointAt(i));
58
+ for (const b of Buffer.from(ch, 'utf8')) bytes.push(b);
59
+ i += ch.length - 1;
60
+ continue;
61
+ }
62
+ const rest = inner.slice(i + 1);
63
+ const oct = /^[0-7]{1,3}/.exec(rest);
64
+ if (oct) {
65
+ bytes.push(Number.parseInt(oct[0], 8) & 0xff);
66
+ i += oct[0].length;
67
+ } else if (rest[0] in CQUOTE_SIMPLE) {
68
+ bytes.push(CQUOTE_SIMPLE[rest[0]]);
69
+ i += 1;
70
+ } else if (rest[0] !== undefined) {
71
+ for (const b of Buffer.from(rest[0], 'utf8')) bytes.push(b);
72
+ i += 1;
73
+ }
74
+ }
75
+ return Buffer.from(bytes).toString('utf8');
76
+ };
77
+
78
+ // ── unified-diff → new-side changed line numbers (line numbers only; content lines ignored) ──────
79
+
80
+ const DIFF_HUNK_RE = /^@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@/;
81
+
82
+ // parseUnifiedDiff(diffText) → Map<rel, number[]>. Robust against a content line that happens to look
83
+ // like a `+++ ` header: a `+++ ` line is a FILE header only in the header region (right after a
84
+ // `diff --git`, before any `@@`); inside a hunk body it is ignored. New-side lines come purely from the
85
+ // `@@` headers, so no content-line disambiguation is needed for the line numbers themselves.
86
+ export const parseUnifiedDiff = (diffText) => {
87
+ const map = new Map();
88
+ let current = null;
89
+ let inHeader = false;
90
+ for (const line of String(diffText).split('\n')) {
91
+ if (line.startsWith('diff --git ')) {
92
+ current = null;
93
+ inHeader = true;
94
+ continue;
95
+ }
96
+ if (inHeader && line.startsWith('--- ')) continue;
97
+ if (inHeader && line.startsWith('+++ ')) {
98
+ const p = unquoteDiffPath(line.slice(4).replace(/[\t\r]+$/, '')); // TAB/CR are git artifacts, never filename bytes (agy R6)
99
+ current = p === '/dev/null' ? null : p.startsWith('b/') ? p.slice(2) : p;
100
+ inHeader = false;
101
+ continue;
102
+ }
103
+ const m = DIFF_HUNK_RE.exec(line);
104
+ if (m) {
105
+ inHeader = false;
106
+ if (current == null) continue;
107
+ const start = Number(m[1]);
108
+ const count = m[2] === undefined ? 1 : Number(m[2]);
109
+ if (count > 0) {
110
+ const arr = map.get(current) ?? [];
111
+ for (let i = 0; i < count; i += 1) arr.push(start + i);
112
+ map.set(current, arr);
113
+ }
114
+ }
115
+ }
116
+ for (const [k, v] of map) map.set(k, [...new Set(v)].sort((a, b) => a - b));
117
+ return map;
118
+ };
119
+
120
+ // ── the changed surface (git-driven; the ONE computation both consumers read) ─────────────────────
121
+
122
+ // The one diff-invocation shape every surface pass uses. The a/ b/ prefixes are pinned EXPLICITLY
123
+ // (agy R6): a user's global diff.noprefix=true would otherwise drop them and the parsers would eat
124
+ // a real directory named "a" — user git config must never bend the parse.
125
+ export const DIFF_FLAGS = ['--unified=0', '--no-color', '--no-ext-diff', '--no-renames', '--src-prefix=a/', '--dst-prefix=b/'];
126
+
127
+ const runGit = (args, cwd) => spawnSync('git', args, { cwd, maxBuffer: GIT_MAX_BUFFER, encoding: 'utf8', windowsHide: true });
128
+ const gitStdout = (args, cwd) => {
129
+ const r = runGit(args, cwd);
130
+ return r.error || r.status > 1 ? null : r.stdout;
131
+ };
132
+ const gitLine = (args, cwd) => {
133
+ const r = runGit(args, cwd);
134
+ if (r.error || r.status !== 0) return null;
135
+ return r.stdout.replace(/\r?\n$/, '');
136
+ };
137
+ // Both leaf guards are exported for the unit tests of exactly their fail-closed edges (the
138
+ // hashFileBytes precedent, AD-047) — internal consumers stay this module's own passes.
139
+ export const readFileSafe = (path) => {
140
+ try {
141
+ return readFileSync(path, 'utf8');
142
+ } catch {
143
+ return null;
144
+ }
145
+ };
146
+ // A changed LEAF is read only if it is a REGULAR file. lstat (no-follow): a symlinked or non-regular
147
+ // leaf must NEVER be read/followed — following it could read outside the work tree or HANG on a
148
+ // FIFO/device (codex R2, AD-046). A non-regular leaf fails closed (routed to `unsupported`).
149
+ export const isRegularLeaf = (abs) => {
150
+ try {
151
+ return lstatSync(abs).isFile();
152
+ } catch {
153
+ return false;
154
+ }
155
+ };
156
+
157
+ // computeChangedSurface(root) → { assessable: Map<rel, number[]>, unsupported: [rel],
158
+ // outOfDomain: [rel], unsupportedLines: Map<rel, number[]> }.
159
+ // Domain = the review-payload domain (tracked working-vs-HEAD changes + untracked-not-ignored files),
160
+ // classified by the CLOSED rule. Tracked changed lines come from `git diff HEAD -U0` (new-side only —
161
+ // pure deletions cost nothing, subtractive folds stay free); an untracked file is wholly new, so all
162
+ // its lines are "changed". `unsupportedLines` is the D4 cap's view of the SAME pass: unsupported
163
+ // SOURCE files carry their new-side lines too (excluding them would gift a large-TS-fold bypass,
164
+ // codex R2 / BUGFREE-2 D4); an unreadable/non-regular leaf counts 0 lines but stays LISTED (the
165
+ // coverage gate still fails closed on it). excluded-test and out-of-domain never carry lines.
166
+ export const computeChangedSurface = (root) => {
167
+ // Unborn branch (no HEAD yet): the plain diff alone misses files STAGED for the initial commit —
168
+ // they sit in the index, so they are neither worktree-vs-index changes nor untracked. Merge the
169
+ // --cached diff (index vs the empty tree) with the plain one; parseUnifiedDiff unions per-file
170
+ // lines across concatenated diffs (codex R1).
171
+ const trackedDiff = gitStdout(['diff', 'HEAD', ...DIFF_FLAGS], root)
172
+ ?? `${gitStdout(['diff', '--cached', ...DIFF_FLAGS], root) ?? ''}\n${gitStdout(['diff', ...DIFF_FLAGS], root) ?? ''}`;
173
+ const trackedLines = parseUnifiedDiff(trackedDiff);
174
+ const untrackedZ = gitStdout(['ls-files', '--others', '--exclude-standard', '-z'], root) ?? '';
175
+ const untracked = untrackedZ.split('\0').filter(Boolean);
176
+
177
+ const assessable = new Map();
178
+ const unsupportedLines = new Map();
179
+ const outOfDomain = [];
180
+ const markUnsupported = (rel, lines) => {
181
+ if (!unsupportedLines.has(rel)) unsupportedLines.set(rel, lines);
182
+ };
183
+ const place = (rel, cls, lines) => {
184
+ if (cls === 'excluded-test') return;
185
+ if (cls === 'assessable') {
186
+ if (isRegularLeaf(join(root, rel))) assessable.set(rel, lines);
187
+ else markUnsupported(rel, lines); // a symlinked / non-regular source → fail closed, never followed
188
+ return;
189
+ }
190
+ if (cls === 'unsupported') markUnsupported(rel, lines);
191
+ else outOfDomain.push(rel);
192
+ };
193
+ for (const [rel, lines] of trackedLines) place(rel, classifyChangedPath(rel), lines);
194
+ for (const rel of untracked) {
195
+ const cls = classifyChangedPath(rel);
196
+ if (cls === 'excluded-test' || cls === 'out-of-domain') {
197
+ place(rel, cls, []);
198
+ continue;
199
+ }
200
+ // Guard the leaf BEFORE reading — never follow a symlink to count an untracked file's lines.
201
+ const abs = join(root, rel);
202
+ if (!isRegularLeaf(abs)) {
203
+ markUnsupported(rel, []);
204
+ continue;
205
+ }
206
+ const src = readFileSafe(abs);
207
+ // Content lines only: a trailing newline terminates the last line, it does not open a phantom
208
+ // empty one — the cap must count real lines (git's new-side numbering does the same).
209
+ const parts = src == null ? [] : src.split('\n');
210
+ if (parts.length > 0 && parts[parts.length - 1] === '') parts.pop();
211
+ const all = Array.from({ length: parts.length }, (_, i) => i + 1);
212
+ if (cls === 'assessable') assessable.set(rel, all);
213
+ else markUnsupported(rel, all);
214
+ }
215
+ return { assessable, unsupported: [...unsupportedLines.keys()].sort(), outOfDomain: outOfDomain.sort(), unsupportedLines };
216
+ };
217
+
218
+ // countCapLines(surface) → the D4 counted magnitude: new-side changed lines of assessable AND
219
+ // unsupported SOURCE files (the two counted classes, pinned by named tests); excluded-test and
220
+ // out-of-domain never count; pure deletions already cost nothing (new-side lines only). Stated
221
+ // consequence, INTENTIONAL (agy R1): under --no-renames a rename of a large file costs its full
222
+ // length — the single-home invariant outweighs a rename special case.
223
+ export const countCapLines = ({ assessable, unsupportedLines }) => {
224
+ let counted = 0;
225
+ for (const lines of assessable.values()) counted += lines.length;
226
+ for (const lines of unsupportedLines.values()) counted += lines.length;
227
+ return counted;
228
+ };
229
+
230
+ // ── the shared fail-closed positive-integer knob parser (AD-047 precedent) ───────────────────────
231
+
232
+ // Zero / negative / fractional / non-numeric values are refusals by name — the parseInt(...)||default
233
+ // idiom would silently accept bad truthy values (codex R2, AD-046). Unset → the default; set → a
234
+ // positive integer, exactly. `makeError` lets each caller throw its OWN typed STOP.
235
+ export const parsePositiveIntKnob = (env, name, fallback, makeError = (m) => new Error(m)) => {
236
+ const raw = env[name];
237
+ if (raw === undefined) return fallback;
238
+ if (!/^\d+$/.test(String(raw).trim()) || Number.parseInt(raw, 10) < 1) {
239
+ throw makeError(`${name} must be a positive integer (got "${raw}") — refusing to guess (fail closed)`);
240
+ }
241
+ return Number.parseInt(raw, 10);
242
+ };
243
+
244
+ // ── the D4 probe-verdict algebra (AD-047; the SINGLE home — moved here from fold-completeness.mjs,
245
+ // which re-exports it, so the runner, the checker, AND the review-ledger telemetry read ONE algebra) ─
246
+
247
+ // probeVerdict(entry) → 'green' | 'red' | 'quarantine' | 'unresolvable'. RED/GREEN are strict N/N
248
+ // verdicts; any timeout, mixed outcome, or partial resolution is QUARANTINE — it never converts and
249
+ // has no override lane (a flaky pin proves nothing — replace the test). Zero resolved runs (or a
250
+ // defensive runs=0) reads unresolvable.
251
+ export const probeVerdict = (t) => {
252
+ const unresolved = t.runs - t.greens - t.reds - t.timeouts;
253
+ if (unresolved >= t.runs) return 'unresolvable';
254
+ if (t.timeouts > 0 || unresolved > 0 || (t.greens > 0 && t.reds > 0)) return 'quarantine';
255
+ return t.greens === t.runs ? 'green' : 'red';
256
+ };
257
+
258
+ // ── the fold-result ledger locator + a tolerant row reader (the D8 telemetry read path) ──────────
259
+
260
+ export const RESULTS_BASENAME = 'agent-workflow-fold-completeness.jsonl';
261
+
262
+ // The result-ledger path: AW_FOLD_RESULTS overrides (mirrors AW_REVIEW_LEDGER); else <git dir>/basename.
263
+ export const resolveResultsPath = (cwd, env = process.env) => {
264
+ if (env.AW_FOLD_RESULTS) return env.AW_FOLD_RESULTS;
265
+ const gitDir = gitLine(['rev-parse', '--absolute-git-dir'], cwd);
266
+ return gitDir == null ? null : join(gitDir, RESULTS_BASENAME);
267
+ };
268
+
269
+ // readJsonlRows(path) → { rows, badLines, readError? } — the TOLERANT parse the telemetry counts
270
+ // ride on (counts only, no judgment — D8): a row is any parsed JSON object; a bad line is counted,
271
+ // never dropped silently. Schema validation stays with each ledger's own reader — telemetry must
272
+ // not fork a second validator (drift) nor import one across the cycle boundary.
273
+ export const readJsonlRows = (path, readFile = readFileSync) => {
274
+ let raw;
275
+ try {
276
+ raw = readFile(path, 'utf8');
277
+ } catch (err) {
278
+ if (err && err.code === 'ENOENT') return { rows: [], badLines: 0 };
279
+ return { rows: [], badLines: 0, readError: (err && err.code) || (err && err.message) || 'read failed' };
280
+ }
281
+ const rows = [];
282
+ let badLines = 0;
283
+ for (const line of raw.split('\n')) {
284
+ if (line.trim() === '') continue;
285
+ try {
286
+ const parsed = JSON.parse(line);
287
+ if (parsed !== null && typeof parsed === 'object' && !Array.isArray(parsed)) rows.push(parsed);
288
+ else badLines += 1;
289
+ } catch {
290
+ badLines += 1;
291
+ }
292
+ }
293
+ return { rows, badLines };
294
+ };
@@ -96,7 +96,7 @@ const CATALOG = [
96
96
  invocation: invocationOf('gates'),
97
97
  group: 'Inspect',
98
98
  kind: PROJECT_EXEC,
99
- oneLine: 'Run the project’s own declared gate commands (docs/ai/gates.json) as one batch — a PASS/FAIL table, one summary line. The mode itself writes nothing; a separate consent-gated seeder can propose entries from your project’s own scripts (preview first, written only on your yes).',
99
+ oneLine: 'Run the project’s own declared gate commands (docs/ai/gates.json) as one batch — a PASS/FAIL table, one summary line. Writes nothing by default; opt-in --record mints a gate-run record via the review-ledger writer (the segment’s green-baseline receipt). A separate consent-gated seeder can propose entries from your project’s own scripts (preview first, written only on your yes).',
100
100
  },
101
101
  {
102
102
  key: 'setup',
@@ -180,14 +180,21 @@ const CATALOG = [
180
180
  invocation: invocationOf('review-ledger'),
181
181
  group: 'Orchestrate',
182
182
  kind: WRITER,
183
- oneLine: 'Record each review round, its triage, and recorded overrides (oracle-change / red-proof waivers) and read the computed crossover-stop for the plan-execution loop; --check turns it into a gate exit code and forces a triage before over-running the review.',
183
+ oneLine: 'Record each review round, its triage, and recorded overrides (oracle-change / red-proof / size-cap waivers) and read the computed crossover-stop for the plan-execution loop — per SEGMENT since v4 (base = HEAD; caps and teeth reset only at a gated commit; class refuted is the honest phantom lane); --check turns it into a gate exit code, --telemetry renders counts-only gate-efficacy data.',
184
184
  },
185
185
  {
186
186
  key: 'fold-completeness',
187
187
  invocation: invocationOf('fold-completeness'),
188
188
  group: 'Orchestrate',
189
189
  kind: WRITER,
190
- oneLine: 'Verify the review loop’s folded fixes are pinned by HONEST tests — every changed executable line executed, and each bound test carries an observed-red receipt (--red, minted BEFORE the fix), N/N-green probes, and content custody (waivable per-testId only by a recorded red-proof override), over a test surface whose tampered files carry recorded oracle-change overrides; --check turns the result into a gate exit code.',
190
+ oneLine: 'Verify the review loop’s folded fixes are pinned by HONEST tests — every changed executable line executed, and each bound test carries an observed-red receipt (--red, minted BEFORE the fix), N/N-green probes, and content custody (waivable per-testId only by a recorded red-proof override), over a test surface whose tampered files carry recorded oracle-change overrides; SEGMENT-scoped since v3 (a committed phase’s custody obligations close with its commit); --check turns the result into a gate exit code.',
191
+ },
192
+ {
193
+ key: 'doc-parity',
194
+ invocation: invocationOf('doc-parity'),
195
+ group: 'Orchestrate',
196
+ kind: READ_ONLY,
197
+ oneLine: 'Check that the documented contract values (review caps, schema versions, ledger vocabulary) still match the live code constants they describe — a read-only lint that fails closed on drift; --check turns it into a gate exit code.',
191
198
  },
192
199
  ];
193
200
 
@@ -0,0 +1,139 @@
1
+ #!/usr/bin/env node
2
+ // doc-parity.mjs — the deterministic doc-drift lint behind `/agent-workflow-kit doc-parity`
3
+ // (BUGFREE-3 / AD-049, economics item (b)). A whole class of BUGFREE-2 review churn came from a
4
+ // mode-contract doc silently lagging a code constant (a `--check` doc still saying "300" after the
5
+ // diff cap moved to 400). This tool closes it mechanically: a CLOSED, exported registry of bindings
6
+ // each ties ONE live code constant to the exact token its contract doc must carry, and the checker
7
+ // asserts the current value renders into every bound `references/modes/*.md` file.
8
+ //
9
+ // Why the modes/*.md docs and not the tool HELP strings: every tool's HELP INTERPOLATES the same
10
+ // constant (`the ${DEFAULT_DIFF_CAP}-line diff cap`), so it can never drift from the code — there is
11
+ // nothing to check there. The hand-authored contract prose in `references/modes/*.md` is the surface
12
+ // that DOES drift, so that is exactly what this lint pins. The numeric/version tokens are IMPORTED
13
+ // live from the tools (never re-typed here), and the ledger vocabulary is sourced from the schema's
14
+ // own exported `V4_CLASSES` / `V4_OVERRIDE_SCOPES` Sets — so the registry cannot itself go stale.
15
+ //
16
+ // Edit-safe by construction (the U2-DEBT closed-world lesson): adding a binding ADDS a checked entry;
17
+ // it never widens a blocklist. A token that stops appearing, a file that cannot be read, or an
18
+ // unknown binding all FAIL CLOSED — never a silent pass.
19
+ //
20
+ // Read-only: never writes, never commits, never runs a subscription CLI, spawns nothing. Dependency-
21
+ // free, Node >= 18. No side effects on import (the isDirectRun idiom).
22
+
23
+ import { readFileSync } from 'node:fs';
24
+ import { dirname, resolve } from 'node:path';
25
+ import { fileURLToPath, pathToFileURL } from 'node:url';
26
+ import { SCHEMA_VERSION, REVIEW_CAP, V4_CLASSES, V4_OVERRIDE_SCOPES } from './review-ledger.mjs';
27
+ import { HARD_MAX, DEFAULT_DIFF_CAP } from './review-ledger-write.mjs';
28
+ import { RESULT_SCHEMA_VERSION } from './fold-completeness.mjs';
29
+
30
+ const KIT_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
31
+
32
+ const REVIEW_LEDGER_DOC = 'references/modes/review-ledger.md';
33
+ const FOLD_DOC = 'references/modes/fold-completeness.md';
34
+
35
+ // A typed usage failure (exit 2) for the CLI parser — the codebase's typed-error idiom (no classes).
36
+ const usageFail = (message) => Object.assign(new Error(message), { exitCode: 2 });
37
+
38
+ // ── the closed binding registry ──────────────────────────────────────────────────────
39
+ // Each binding: { constant, value (live), token (value rendered into the doc's exact phrasing),
40
+ // files[] (the contract docs that MUST carry the token) }. The token phrasings match the current
41
+ // prose in the named files; a value drift makes the current-value token absent → a loud failure.
42
+ const valueBinding = (constant, value, phrase, files) => ({ constant, value, token: phrase, files });
43
+
44
+ // The ledger vocabulary — sourced from the schema's own exported Sets plus the v4 `gate-run` kind,
45
+ // so the closed set can never disagree with the code. Every word must appear in the ledger contract.
46
+ const LEDGER_VOCAB = [...V4_CLASSES, ...V4_OVERRIDE_SCOPES, 'gate-run'];
47
+
48
+ export const BINDINGS = Object.freeze([
49
+ valueBinding('SCHEMA_VERSION', SCHEMA_VERSION, `schema v${SCHEMA_VERSION}`, [REVIEW_LEDGER_DOC]),
50
+ valueBinding('HARD_MAX', HARD_MAX, `hard-max ceiling of ${HARD_MAX}`, [REVIEW_LEDGER_DOC]),
51
+ valueBinding('DEFAULT_DIFF_CAP', DEFAULT_DIFF_CAP, `default ${DEFAULT_DIFF_CAP}`, [REVIEW_LEDGER_DOC]),
52
+ valueBinding('REVIEW_CAP', REVIEW_CAP, `cap ≤${REVIEW_CAP}`, [REVIEW_LEDGER_DOC]),
53
+ valueBinding('RESULT_SCHEMA_VERSION', RESULT_SCHEMA_VERSION, `schema v${RESULT_SCHEMA_VERSION}`, [FOLD_DOC]),
54
+ ...LEDGER_VOCAB.map((word) => valueBinding(`vocab:${word}`, word, word, [REVIEW_LEDGER_DOC])),
55
+ ].map((b) => Object.freeze(b)));
56
+
57
+ // ── the pure checker (readText is injectable for hermetic tests) ────────────────────────
58
+ // checkBinding(binding, readText) → { constant, token, files: [{ rel, ok, reason }], ok }.
59
+ // readText(rel) returns the file text or THROWS (an unreadable bound file fails closed).
60
+ export const checkBinding = (binding, readText) => {
61
+ const files = binding.files.map((rel) => {
62
+ let text;
63
+ try {
64
+ text = readText(rel);
65
+ } catch (err) {
66
+ return { rel, ok: false, reason: `unreadable (${(err && err.code) || (err && err.message) || 'read failed'})` };
67
+ }
68
+ const present = text.includes(binding.token);
69
+ return { rel, ok: present, reason: present ? null : `token ${JSON.stringify(binding.token)} not found` };
70
+ });
71
+ return { constant: binding.constant, token: binding.token, files, ok: files.every((f) => f.ok) };
72
+ };
73
+
74
+ const defaultReadText = (rel) => readFileSync(resolve(KIT_ROOT, rel), 'utf8');
75
+
76
+ // checkParity(bindings, readText) → [ per-binding result ]. Default reads the real modes/*.md files
77
+ // relative to the kit root.
78
+ export const checkParity = (bindings = BINDINGS, readText = defaultReadText) => bindings.map((b) => checkBinding(b, readText));
79
+
80
+ // ── rendering ───────────────────────────────────────────────────────────────────────
81
+ const formatHuman = (results) => {
82
+ const lines = ['doc-parity — code constants ⟷ references/modes/*.md contract (read-only, BUGFREE-3)'];
83
+ for (const r of results) {
84
+ for (const f of r.files) {
85
+ lines.push(` ${f.ok ? '✓' : '✗'} ${r.constant} → ${f.rel}${f.ok ? '' : ` — ${f.reason}`}`);
86
+ }
87
+ }
88
+ const failed = results.flatMap((r) => r.files.filter((f) => !f.ok).map((f) => `${r.constant} @ ${f.rel}`));
89
+ lines.push(` check: ${failed.length === 0 ? 'PASS' : 'FAIL'} — ${failed.length === 0 ? `${results.length} binding(s) consistent` : `${failed.length} drifted binding(s): ${failed.join('; ')}`}`);
90
+ return lines.join('\n');
91
+ };
92
+
93
+ const HELP = `doc-parity — deterministic doc-drift lint for the agent-workflow family (BUGFREE-3 / AD-049).
94
+
95
+ Usage:
96
+ node doc-parity.mjs [--check | --json]
97
+
98
+ A CLOSED, exported registry binds each live code constant (review-ledger SCHEMA_VERSION / REVIEW_CAP,
99
+ review-ledger-write HARD_MAX / DEFAULT_DIFF_CAP, fold-completeness RESULT_SCHEMA_VERSION) and the
100
+ ledger vocabulary (V4_CLASSES / V4_OVERRIDE_SCOPES + gate-run) to the exact token its
101
+ references/modes/*.md contract must carry, and asserts the CURRENT value renders into every bound
102
+ file. A drifted doc, an unreadable bound file, or an absent token FAILS CLOSED.
103
+
104
+ --check exits 0/1 as a gate (declare it in docs/ai/gates.json by hand). --json prints the structured
105
+ result. Default prints the per-binding report.
106
+
107
+ Read-only: never writes, never commits, spawns nothing. Exit codes: 0 pass (or plain report); 1 drift
108
+ (under --check) or error; 2 usage.`;
109
+
110
+ const KNOWN_ARGS = new Set(['--help', '-h', '--check', '--json']);
111
+
112
+ export const main = (argv, ctx = {}) => {
113
+ const readText = ctx.readText ?? defaultReadText;
114
+ try {
115
+ if (argv.includes('--help') || argv.includes('-h')) return { code: 0, stdout: HELP, stderr: '' };
116
+ const unknown = argv.find((a) => !KNOWN_ARGS.has(a));
117
+ if (unknown !== undefined) throw usageFail(`unknown argument: ${unknown}`);
118
+ const results = checkParity(BINDINGS, readText);
119
+ const failed = results.filter((r) => !r.ok);
120
+ if (argv.includes('--json')) {
121
+ return { code: argv.includes('--check') && failed.length > 0 ? 1 : 0, stdout: JSON.stringify({ results, ok: failed.length === 0 }, null, 2), stderr: '' };
122
+ }
123
+ if (argv.includes('--check')) {
124
+ const reason = failed.length === 0 ? `${results.length} binding(s) consistent` : `${failed.length} drifted binding(s): ${failed.map((r) => r.constant).join(', ')} — update the contract doc(s) in the SAME edit as the code`;
125
+ return { code: failed.length === 0 ? 0 : 1, stdout: `doc-parity check: ${failed.length === 0 ? 'PASS' : 'FAIL'} — ${reason}`, stderr: '' };
126
+ }
127
+ return { code: 0, stdout: formatHuman(results), stderr: '' };
128
+ } catch (err) {
129
+ return { code: err.exitCode ?? 1, stdout: '', stderr: `doc-parity: ${err.message}` };
130
+ }
131
+ };
132
+
133
+ const isDirectRun = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href;
134
+ if (isDirectRun) {
135
+ const r = main(process.argv.slice(2));
136
+ if (r.stdout) process.stdout.write(r.stdout.endsWith('\n') ? r.stdout : `${r.stdout}\n`);
137
+ if (r.stderr) process.stderr.write(r.stderr.endsWith('\n') ? r.stderr : `${r.stderr}\n`);
138
+ process.exitCode = r.code;
139
+ }