@sabaiway/agent-workflow-kit 1.39.0 → 1.41.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -15,6 +15,8 @@
15
15
  //
16
16
  // CLI overrides:
17
17
  // --today=YYYY-MM-DD (default today UTC) — useful for tests / reproducible runs
18
+ // --root=<dir> run against another project root (default this deployment) — the ADR-rotation
19
+ // hook passes it so a rotation regenerates the right project's index
18
20
  // --quiet print only failures (and final summary)
19
21
 
20
22
  import { readFile, writeFile, readdir, stat } from 'node:fs/promises';
@@ -28,6 +30,12 @@ const ROOT = resolve(__dirname, '..');
28
30
  const DOCS_DIR = resolve(ROOT, 'docs/ai');
29
31
  const INDEX_PATH = resolve(DOCS_DIR, 'index.md');
30
32
 
33
+ // Root-parameterized (BUGFREE-3 / AD-049, item (h)): the module ROOT constants are the CLI DEFAULT
34
+ // (this deployment's own root); `--root=<dir>` and the exported `regenerateIndex(root, today)`
35
+ // override them so the ADR-rotation hook (archive-decisions.mjs) and hermetic tests can regenerate
36
+ // an arbitrary root's index without ever touching the real repo tree.
37
+ const pathsFor = (root) => ({ root, docsDir: resolve(root, 'docs/ai'), indexPath: resolve(root, 'docs/ai/index.md') });
38
+
31
39
  const MS_PER_DAY = 24 * 60 * 60 * 1000;
32
40
 
33
41
  // Project-name + footer links for the index are auto-discovered (no hardcoding):
@@ -56,18 +64,18 @@ const walkForName = async (dir, name, acc = [], depth = 0) => {
56
64
  return acc;
57
65
  };
58
66
 
59
- export const discoverMeta = async () => {
60
- let projectName = basename(ROOT);
67
+ export const discoverMeta = async (root = ROOT) => {
68
+ let projectName = basename(root);
61
69
  try {
62
- const pkg = JSON.parse(await readFile(resolve(ROOT, 'package.json'), 'utf8'));
70
+ const pkg = JSON.parse(await readFile(resolve(root, 'package.json'), 'utf8'));
63
71
  if (pkg.name) projectName = pkg.name;
64
72
  } catch {
65
73
  /* no package.json — keep dir basename */
66
74
  }
67
- const agentsFiles = await walkForName(ROOT, 'AGENTS.md');
68
- const claudeFiles = await walkForName(ROOT, 'CLAUDE.md');
69
- const rootAgents = resolve(ROOT, 'AGENTS.md');
70
- const rootClaude = resolve(ROOT, 'CLAUDE.md');
75
+ const agentsFiles = await walkForName(root, 'AGENTS.md');
76
+ const claudeFiles = await walkForName(root, 'CLAUDE.md');
77
+ const rootAgents = resolve(root, 'AGENTS.md');
78
+ const rootClaude = resolve(root, 'CLAUDE.md');
71
79
  // A subdir typically holds AGENTS.md plus a CLAUDE.md symlink to it — list each
72
80
  // dir once (prefer AGENTS.md, drop its sibling CLAUDE.md alias).
73
81
  const agentsDirs = new Set(agentsFiles.map((file) => dirname(resolve(file))));
@@ -78,12 +86,12 @@ export const discoverMeta = async () => {
78
86
  ),
79
87
  ];
80
88
  const hierarchicalLinks = nestedFiles
81
- .map((file) => relative(ROOT, file))
89
+ .map((file) => relative(root, file))
82
90
  .sort()
83
91
  .map((rel) => `[\`${rel}\`](../../${rel})`);
84
92
  let onDemandLinks = [];
85
93
  try {
86
- const skillDirs = await readdir(resolve(ROOT, '.agents/skills'), { withFileTypes: true });
94
+ const skillDirs = await readdir(resolve(root, '.agents/skills'), { withFileTypes: true });
87
95
  onDemandLinks = skillDirs
88
96
  .filter((dirent) => dirent.isDirectory() && /-(patterns|commands)$/.test(dirent.name))
89
97
  .map((dirent) => dirent.name)
@@ -97,16 +105,17 @@ export const discoverMeta = async () => {
97
105
 
98
106
  const parseArgs = (argv) => {
99
107
  const flags = { report: false, writeIndex: false, checkIndex: false, quiet: false };
100
- const opts = { today: null };
108
+ const opts = { today: null, root: null };
101
109
  for (const arg of argv.slice(2)) {
102
110
  if (arg === '--report') flags.report = true;
103
111
  else if (arg === '--write-index') flags.writeIndex = true;
104
112
  else if (arg === '--check-index') flags.checkIndex = true;
105
113
  else if (arg === '--quiet') flags.quiet = true;
106
114
  else if (arg.startsWith('--today=')) opts.today = arg.slice('--today='.length);
115
+ else if (arg.startsWith('--root=')) opts.root = arg.slice('--root='.length);
107
116
  else if (arg === '--help' || arg === '-h') {
108
117
  console.log(
109
- 'Usage: check-docs-size.mjs [--report|--write-index|--check-index] [--today=YYYY-MM-DD] [--quiet]',
118
+ 'Usage: check-docs-size.mjs [--report|--write-index|--check-index] [--today=YYYY-MM-DD] [--root=<dir>] [--quiet]',
110
119
  );
111
120
  process.exit(0);
112
121
  } else {
@@ -161,11 +170,11 @@ export const computeToday = (todayStr) =>
161
170
  ? new Date(`${todayStr}T00:00:00Z`)
162
171
  : new Date(new Date().toISOString().slice(0, 10) + 'T00:00:00Z');
163
172
 
164
- export const inspectFile = async (filePath, today) => {
173
+ export const inspectFile = async (filePath, today, root = ROOT) => {
165
174
  const text = await readFile(filePath, 'utf8');
166
175
  const lineCount = text.split('\n').length - (text.endsWith('\n') ? 1 : 0);
167
176
  const fm = parseFrontmatter(text);
168
- const rel = relative(ROOT, filePath);
177
+ const rel = relative(root, filePath);
169
178
 
170
179
  if (!fm) {
171
180
  return {
@@ -301,24 +310,41 @@ export const checkIndexFreshness = (rows, onDiskText, meta = {}) => {
301
310
  return { fresh: expected === onDiskText, expected };
302
311
  };
303
312
 
304
- const writeIndex = async (rows, today, meta) => {
313
+ const writeIndex = async (rows, today, meta, indexPath = INDEX_PATH) => {
305
314
  const body = buildIndex(rows, today.toISOString().slice(0, 10), meta);
306
- await writeFile(INDEX_PATH, body, 'utf8');
315
+ await writeFile(indexPath, body, 'utf8');
316
+ };
317
+
318
+ // regenerateIndex(root, todayStr) — the ONE reused generator, root-parameterized (item (h)). It runs
319
+ // the SAME walk → inspect → discoverMeta → writeIndex pipeline as `--write-index`, against `root`
320
+ // (default this deployment). The ADR-rotation hook reaches it via the CLI (`--write-index --root=…`);
321
+ // hermetic tests call it directly. `todayStr` is 'YYYY-MM-DD' (null → today). Returns the written
322
+ // index path + row count. No second index implementation exists.
323
+ export const regenerateIndex = async (root, todayStr = null) => {
324
+ const { docsDir, indexPath } = pathsFor(root);
325
+ const today = computeToday(todayStr);
326
+ const files = (await walkMarkdownFiles(docsDir)).sort();
327
+ const inspected = await Promise.all(files.map((f) => inspectFile(f, today, root)));
328
+ const rows = inspected.map(formatRow);
329
+ const meta = await discoverMeta(root);
330
+ await writeIndex(rows, today, meta, indexPath);
331
+ return { indexPath, files: rows.length };
307
332
  };
308
333
 
309
334
  const main = async () => {
310
335
  const { flags, opts } = parseArgs(process.argv);
336
+ const { root, docsDir, indexPath } = pathsFor(opts.root ? resolve(opts.root) : ROOT);
311
337
  const today = computeToday(opts.today);
312
- const files = (await walkMarkdownFiles(DOCS_DIR)).sort();
313
- const inspected = await Promise.all(files.map((f) => inspectFile(f, today)));
338
+ const files = (await walkMarkdownFiles(docsDir)).sort();
339
+ const inspected = await Promise.all(files.map((f) => inspectFile(f, today, root)));
314
340
  const rows = inspected.map(formatRow);
315
341
 
316
- const meta = flags.writeIndex || flags.checkIndex ? await discoverMeta() : null;
342
+ const meta = flags.writeIndex || flags.checkIndex ? await discoverMeta(root) : null;
317
343
 
318
344
  if (flags.writeIndex) {
319
- await writeIndex(rows, today, meta);
320
- console.log(`Wrote ${relative(ROOT, INDEX_PATH)}`);
321
- const after = await stat(INDEX_PATH);
345
+ await writeIndex(rows, today, meta, indexPath);
346
+ console.log(`Wrote ${relative(root, indexPath)}`);
347
+ const after = await stat(indexPath);
322
348
  if (after.size === 0) {
323
349
  console.error('index.md was written empty');
324
350
  process.exit(2);
@@ -326,16 +352,16 @@ const main = async () => {
326
352
  }
327
353
 
328
354
  if (flags.checkIndex) {
329
- const onDisk = existsSync(INDEX_PATH) ? await readFile(INDEX_PATH, 'utf8') : null;
355
+ const onDisk = existsSync(indexPath) ? await readFile(indexPath, 'utf8') : null;
330
356
  const { fresh } = checkIndexFreshness(rows, onDisk, meta);
331
357
  if (!fresh) {
332
358
  console.error(
333
- `[check-docs-size] FAIL: ${relative(ROOT, INDEX_PATH)} is stale (out of sync with source frontmatter). Regenerate the index (--write-index) and commit the regenerated file.`,
359
+ `[check-docs-size] FAIL: ${relative(root, indexPath)} is stale (out of sync with source frontmatter). Regenerate the index (--write-index) and commit the regenerated file.`,
334
360
  );
335
361
  process.exit(1);
336
362
  }
337
363
  console.log(
338
- `[check-docs-size] OK — ${relative(ROOT, INDEX_PATH)} is in sync with source frontmatter.`,
364
+ `[check-docs-size] OK — ${relative(root, indexPath)} is in sync with source frontmatter.`,
339
365
  );
340
366
  return;
341
367
  }
@@ -1,8 +1,11 @@
1
1
  import { describe, it, beforeEach, afterEach } from 'node:test';
2
2
  import { expect } from './_expect-shim.mjs';
3
- import { mkdtemp, writeFile, rm } from 'node:fs/promises';
3
+ import { mkdtemp, writeFile, mkdir, readFile, rm } from 'node:fs/promises';
4
+ import { existsSync } from 'node:fs';
5
+ import { spawnSync } from 'node:child_process';
4
6
  import { tmpdir } from 'node:os';
5
7
  import { join } from 'node:path';
8
+ import { fileURLToPath } from 'node:url';
6
9
  import {
7
10
  parseFrontmatter,
8
11
  parseStaleAfter,
@@ -11,6 +14,7 @@ import {
11
14
  buildIndex,
12
15
  checkIndexFreshness,
13
16
  walkMarkdownFiles,
17
+ regenerateIndex,
14
18
  } from './check-docs-size.mjs';
15
19
 
16
20
  describe('parseFrontmatter', () => {
@@ -202,3 +206,71 @@ describe('checkIndexFreshness', () => {
202
206
  expect(checkIndexFreshness(rows, onDisk).fresh).toBe(true);
203
207
  });
204
208
  });
209
+
210
+ // ── (h) — root-parameterization: the ADR-rotation hook regenerates ANOTHER root's index ────
211
+ // The generator's module ROOT is the CLI default only; --root / regenerateIndex(root) target an
212
+ // arbitrary tree so the rotation hook (and hermetic tests) never touch the real repo.
213
+ describe('root parameterization (item (h))', () => {
214
+ let root;
215
+ beforeEach(async () => {
216
+ root = await mkdtemp(join(tmpdir(), 'check-docs-root-'));
217
+ await mkdir(join(root, 'docs', 'ai'), { recursive: true });
218
+ });
219
+ afterEach(async () => {
220
+ await rm(root, { recursive: true, force: true });
221
+ });
222
+
223
+ const seedDoc = (name, extra = '') =>
224
+ writeFile(join(root, 'docs', 'ai', name), `---\ntype: reference\nlastUpdated: 2026-07-08\nscope: permanent\nstaleAfter: 30d\nowner: none\nmaxLines: 100\n---\n\n# ${name}\n${extra}`);
225
+
226
+ it('inspectFile computes the file path RELATIVE to the passed root (not the module ROOT)', async () => {
227
+ await seedDoc('handover.md');
228
+ const result = await inspectFile(join(root, 'docs', 'ai', 'handover.md'), computeToday('2026-07-08'), root);
229
+ expect(result.path).toBe('docs/ai/handover.md');
230
+ });
231
+
232
+ it('regenerateIndex(root, today) writes THAT root\'s docs/ai/index.md from its frontmatter', async () => {
233
+ await seedDoc('a.md');
234
+ await seedDoc('b.md');
235
+ const res = await regenerateIndex(root, '2026-07-08');
236
+ expect(res.indexPath).toBe(join(root, 'docs', 'ai', 'index.md'));
237
+ expect(existsSync(res.indexPath)).toBe(true);
238
+ const index = await readFile(res.indexPath, 'utf8');
239
+ expect(index).toMatch(/lastUpdated: 2026-07-08/); // header date is the argument
240
+ expect(index).toMatch(/a\.md/);
241
+ expect(index).toMatch(/b\.md/);
242
+ expect(index).not.toMatch(/\[`index\.md`\]/); // the index never lists itself
243
+ });
244
+
245
+ it('a re-run with unchanged sources is byte-identical (deterministic, --check-index safe)', async () => {
246
+ await seedDoc('a.md');
247
+ const first = await regenerateIndex(root, '2026-07-08');
248
+ const bytesA = await readFile(first.indexPath, 'utf8');
249
+ await regenerateIndex(root, '2026-07-08');
250
+ const bytesB = await readFile(first.indexPath, 'utf8');
251
+ expect(bytesB).toBe(bytesA);
252
+ });
253
+
254
+ // The CLI entry (main) over --root — a subprocess smoke so the --help usage + the --check-index
255
+ // fresh/stale branches (root-parameterized) are exercised end-to-end.
256
+ const SCRIPT = fileURLToPath(new URL('./check-docs-size.mjs', import.meta.url));
257
+ const runCli = (args) => spawnSync(process.execPath, [SCRIPT, ...args], { encoding: 'utf8' });
258
+
259
+ it('--help prints the usage (naming --root) and exits 0', () => {
260
+ const r = runCli(['--help']);
261
+ expect(r.status).toBe(0);
262
+ expect(r.stdout).toMatch(/--root=/);
263
+ });
264
+
265
+ it('--check-index --root: a fresh index is OK (exit 0); a drifted one is stale (exit 1)', async () => {
266
+ await seedDoc('a.md');
267
+ expect(runCli(['--write-index', `--root=${root}`]).status).toBe(0);
268
+ const fresh = runCli(['--check-index', `--root=${root}`]);
269
+ expect(fresh.status).toBe(0);
270
+ expect(fresh.stdout).toMatch(/in sync/);
271
+ await seedDoc('b.md'); // a new source row drifts the on-disk index
272
+ const stale = runCli(['--check-index', `--root=${root}`]);
273
+ expect(stale.status).toBe(1);
274
+ expect(stale.stderr).toMatch(/stale/);
275
+ });
276
+ });
@@ -53,6 +53,7 @@ Before proposing changes or committing, review against:
53
53
  ### 2.2. Clean Code
54
54
  - **No magic literals** — extract string/numeric constants to named consts at module level.
55
55
  - **DRY** — no duplicated logic.
56
+ - **Minimal comments (a BASELINE this project may tighten)** — if this project sets a stricter rule (e.g. comments forbidden entirely), that stricter rule ALWAYS wins; this is only a floor. Otherwise comment only where vitally necessary (a non-obvious invariant, a fail-closed rationale, a subtle edge). Make the code self-explaining first — clear variable/function names and compact-but-unambiguous test descriptions replace most comments; never restate what the code already says.
56
57
 
57
58
  ### 2.3. Strict Compliance
58
59
  - Only `const` (no `let`); no classes — pure functions, closures, modules.
@@ -0,0 +1,10 @@
1
+ {
2
+ "_README": "Optional per-project VERIFICATION PROFILE for the fold-completeness gate (the language-independence contract). DELETE this file to reproduce the exact default behaviour (V8 line coverage + node:test TAP on stdout) — an absent profile is fully supported. Present, it GENERALIZES three inputs so a consumer on another language/runner can drive the same gate: (1) coverage.kind is \"v8\" (default) or \"lcov\" — with lcov, set coverage.lcovPath to where your suite leaves an LCOV file; (2) singleTest.argv is the shell-free command template for probing ONE test (placeholders {file} and {pattern} are required; a file-based resultFormat also requires {resultPath}), and singleTest.resultFormat is \"tap-stdout\" (default), \"tap-file\", or \"junit-xml\"; (3) findings.sarifPath (optional) points at a SARIF file for advisory-only findings (never gate-blocking). The suite COMMAND is NOT declared here — it stays your docs/ai/gates.json unit-tests gate (so the fold run and the gate share command-identity). Every DECLARED path (coverage.lcovPath, findings.sarifPath) MUST be gitignored or outside the repo (a symlink is refused): an in-tree, non-ignored file the suite writes would move the review fingerprint. Env knobs still override (AW_FOLD_SUITE_CMD / AW_FOLD_BOUND_CMD / AW_FOLD_RESULTS). Strict JSON — no comments.",
3
+ "schema": 1,
4
+ "coverage": { "kind": "v8" },
5
+ "singleTest": {
6
+ "argv": ["node", "--test", "--test-reporter", "tap", "--test-name-pattern={pattern}", "{file}"],
7
+ "resultFormat": "tap-stdout"
8
+ },
9
+ "findings": {}
10
+ }
@@ -189,6 +189,13 @@ const CATALOG = [
189
189
  kind: WRITER,
190
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
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.',
198
+ },
192
199
  ];
193
200
 
194
201
  // Deep-freeze: freeze the array AND every entry, so the catalog is genuinely immutable at runtime
@@ -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
+ }