@sabaiway/agent-workflow-memory 4.2.0 → 4.4.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.
@@ -12,6 +12,11 @@
12
12
  // --check-index verify docs/ai/index.md is in sync with source frontmatter;
13
13
  // exit 1 (and print how to fix) if stale. Catches the silent
14
14
  // drift `--write-index` is supposed to prevent.
15
+ // --ensure-index the idempotent finalizer every deploy/upgrade path runs after its last
16
+ // docs/ai mutation: probe, write only when the navigator is missing or stale,
17
+ // print ONE outcome line (`ensure-index: regenerated|already-current` on
18
+ // stdout; `ensure-index: write-refused|probe-failed — <path>: …` on stderr).
19
+ // Exit 0 on either written state, 2 on a named refusal — never a stack trace.
15
20
  //
16
21
  // CLI overrides:
17
22
  // --today=YYYY-MM-DD (default today UTC) — useful for tests / reproducible runs
@@ -19,10 +24,11 @@
19
24
  // hook passes it so a rotation regenerates the right project's index
20
25
  // --quiet print only failures (and final summary)
21
26
 
22
- import { readFile, writeFile, readdir, stat } from 'node:fs/promises';
23
- import { existsSync } from 'node:fs';
24
- import { dirname, resolve, relative, join, basename } from 'node:path';
27
+ import { readFile, writeFile, readdir, stat, rename, rm } from 'node:fs/promises';
28
+ import { existsSync, lstatSync } from 'node:fs';
29
+ import { dirname, resolve, relative, join, basename, sep } from 'node:path';
25
30
  import { fileURLToPath, pathToFileURL } from 'node:url';
31
+ import { randomBytes } from 'node:crypto';
26
32
 
27
33
  const __filename = fileURLToPath(import.meta.url);
28
34
  const __dirname = dirname(__filename);
@@ -34,10 +40,17 @@ const INDEX_PATH = resolve(DOCS_DIR, 'index.md');
34
40
  // (this deployment's own root); `--root=<dir>` and the exported `regenerateIndex(root, today)`
35
41
  // override them so the ADR-rotation hook (archive-decisions.mjs) and hermetic tests can regenerate
36
42
  // 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') });
43
+ const pathsFor = (root) => {
44
+ const base = resolve(root);
45
+ return { root: base, docsDir: resolve(base, 'docs/ai'), indexPath: resolve(base, 'docs/ai/index.md') };
46
+ };
38
47
 
39
48
  const MS_PER_DAY = 24 * 60 * 60 * 1000;
40
49
 
50
+ // The one token every `--ensure-index` outcome line opens with — deploy/upgrade prose relays it
51
+ // verbatim and the kit's ensure op reads it, so it is a contract, not a message.
52
+ const ENSURE_INDEX_PREFIX = 'ensure-index:';
53
+
41
54
  // Project-name + footer links for the index are auto-discovered (no hardcoding):
42
55
  // project name ← package.json "name" (fallback: repo dir basename)
43
56
  // hierarchical ← every AGENTS.md / CLAUDE.md below the repo root
@@ -45,18 +58,30 @@ const MS_PER_DAY = 24 * 60 * 60 * 1000;
45
58
  const DEFAULT_PROJECT_NAME = 'this project';
46
59
  const SKIP_DIRS = new Set(['node_modules', '.git', 'dist', 'dist-ssr', 'coverage', 'build', '.next']);
47
60
 
48
- const walkForName = async (dir, name, acc = [], depth = 0) => {
61
+ // `strict` is the finalizer's lens on the SAME walk: for a report, an unreadable subtree is fairly
62
+ // skipped, but a run that WRITES the navigator may not silently treat "could not read" as "nothing
63
+ // there" — it would publish an index missing whatever it could not see and call that success. Only
64
+ // a genuine ENOENT stays an absence; every other fs error propagates.
65
+ // Only a genuine ENOENT is an absence. A code-LESS throw (an injected reader, a wrapped client) is
66
+ // not evidence of absence either, so it propagates too — "unknown" must never read as "empty".
67
+ const rethrowUnlessAbsent = (err, strict) => {
68
+ if (strict && err?.code !== 'ENOENT') throw err;
69
+ };
70
+
71
+ const walkForName = async (dir, name, acc = [], depth = 0, strict = false, deps = {}) => {
49
72
  if (depth > 6) return acc;
73
+ const readDir = deps.readdir ?? readdir;
50
74
  let entries;
51
75
  try {
52
- entries = await readdir(dir, { withFileTypes: true });
53
- } catch {
76
+ entries = await readDir(dir, { withFileTypes: true });
77
+ } catch (err) {
78
+ rethrowUnlessAbsent(err, strict);
54
79
  return acc;
55
80
  }
56
81
  for (const entry of entries) {
57
82
  if (entry.isDirectory()) {
58
83
  if (SKIP_DIRS.has(entry.name)) continue;
59
- await walkForName(join(dir, entry.name), name, acc, depth + 1);
84
+ await walkForName(join(dir, entry.name), name, acc, depth + 1, strict, deps);
60
85
  } else if (entry.isFile() && entry.name === name) {
61
86
  acc.push(join(dir, entry.name));
62
87
  }
@@ -64,16 +89,29 @@ const walkForName = async (dir, name, acc = [], depth = 0) => {
64
89
  return acc;
65
90
  };
66
91
 
67
- export const discoverMeta = async (root = ROOT) => {
92
+ export const discoverMeta = async (root = ROOT, { strict = false, deps = {} } = {}) => {
93
+ const read = deps.readFile ?? readFile;
94
+ const readDir = deps.readdir ?? readdir;
68
95
  let projectName = basename(root);
96
+ // The READ and the PARSE are separate on purpose: an unreadable package.json is a tree this run
97
+ // could not see (strict propagates it), while a MALFORMED one is authored content — the basename
98
+ // fallback, under strict too.
99
+ let manifest = null;
69
100
  try {
70
- const pkg = JSON.parse(await readFile(resolve(root, 'package.json'), 'utf8'));
71
- if (pkg.name) projectName = pkg.name;
72
- } catch {
73
- /* no package.json — keep dir basename */
101
+ manifest = await read(resolve(root, 'package.json'), 'utf8');
102
+ } catch (err) {
103
+ rethrowUnlessAbsent(err, strict);
74
104
  }
75
- const agentsFiles = await walkForName(root, 'AGENTS.md');
76
- const claudeFiles = await walkForName(root, 'CLAUDE.md');
105
+ if (manifest !== null) {
106
+ try {
107
+ const pkg = JSON.parse(manifest);
108
+ if (pkg.name) projectName = pkg.name;
109
+ } catch {
110
+ /* malformed package.json — keep the dir basename */
111
+ }
112
+ }
113
+ const agentsFiles = await walkForName(root, 'AGENTS.md', [], 0, strict, deps);
114
+ const claudeFiles = await walkForName(root, 'CLAUDE.md', [], 0, strict, deps);
77
115
  const rootAgents = resolve(root, 'AGENTS.md');
78
116
  const rootClaude = resolve(root, 'CLAUDE.md');
79
117
  // A subdir typically holds AGENTS.md plus a CLAUDE.md symlink to it — list each
@@ -91,26 +129,28 @@ export const discoverMeta = async (root = ROOT) => {
91
129
  .map((rel) => `[\`${rel}\`](../../${rel})`);
92
130
  let onDemandLinks = [];
93
131
  try {
94
- const skillDirs = await readdir(resolve(root, '.agents/skills'), { withFileTypes: true });
132
+ const skillDirs = await readDir(resolve(root, '.agents/skills'), { withFileTypes: true });
95
133
  onDemandLinks = skillDirs
96
134
  .filter((dirent) => dirent.isDirectory() && /-(patterns|commands)$/.test(dirent.name))
97
135
  .map((dirent) => dirent.name)
98
136
  .sort()
99
137
  .map((name) => `[\`${name}\`](../../.agents/skills/${name}/SKILL.md)`);
100
- } catch {
101
- /* no .agents/skills — omit the section */
138
+ } catch (err) {
139
+ // No .agents/skills — omit the section (under strict, only a real absence may omit it).
140
+ rethrowUnlessAbsent(err, strict);
102
141
  }
103
142
  return { projectName, hierarchicalLinks, onDemandLinks };
104
143
  };
105
144
 
106
145
  // Pure argv parser (no I/O, no exit): `help` / `error` ride out as data for runCli to render.
107
146
  const parseArgs = (argv) => {
108
- const flags = { report: false, writeIndex: false, checkIndex: false, quiet: false };
147
+ const flags = { report: false, writeIndex: false, checkIndex: false, ensureIndex: false, quiet: false };
109
148
  const opts = { today: null, root: null };
110
149
  for (const arg of argv) {
111
150
  if (arg === '--report') flags.report = true;
112
151
  else if (arg === '--write-index') flags.writeIndex = true;
113
152
  else if (arg === '--check-index') flags.checkIndex = true;
153
+ else if (arg === '--ensure-index') flags.ensureIndex = true;
114
154
  else if (arg === '--quiet') flags.quiet = true;
115
155
  else if (arg.startsWith('--today=')) opts.today = arg.slice('--today='.length);
116
156
  else if (arg.startsWith('--root=')) opts.root = arg.slice('--root='.length);
@@ -334,9 +374,74 @@ export const checkIndexFreshness = (rows, onDiskText, meta = {}) => {
334
374
  return { fresh: expected === onDiskText, expected };
335
375
  };
336
376
 
337
- const writeIndex = async (rows, today, meta, indexPath = INDEX_PATH) => {
377
+ // The navigator is a GENERATED artifact, so its write must land on the deployment's own file and
378
+ // nowhere else: every component of <root>/docs/ai/index.md is lstat'ed no-follow (a symlinked root,
379
+ // `docs`, `docs/ai` or leaf REFUSES — publishing through one would clobber whatever it points at),
380
+ // the body goes out through a unique exclusive-create temp renamed into place with the chain
381
+ // re-checked immediately before the rename, and the temp never survives a failure. The kit runs the
382
+ // same discipline in atomic-write.mjs; this deployment script ships dependency-free, so the
383
+ // semantics are REIMPLEMENTED here rather than imported.
384
+ export const INDEX_WRITE_REFUSED = 'INDEX_WRITE_REFUSED';
385
+ const refuse = (message) => Object.assign(new Error(message), { code: INDEX_WRITE_REFUSED });
386
+
387
+ const lstatNoFollow = (target, lstat) => {
388
+ try {
389
+ return lstat(target);
390
+ } catch (err) {
391
+ if (err && err.code === 'ENOENT') return null;
392
+ throw err;
393
+ }
394
+ };
395
+
396
+ // The target is always DERIVED from `root` here (the navigator and its temp sibling), never handed
397
+ // in by a caller, so there is no escape arm to guard: what remains is the no-follow walk.
398
+ const assertContainedNoSymlink = (root, target, lstat) => {
399
+ const rel = relative(root, target);
400
+ if (lstatNoFollow(root, lstat)?.isSymbolicLink()) {
401
+ throw refuse(`${root} is a symlink — refusing to write the navigator through it`);
402
+ }
403
+ rel.split(sep).filter(Boolean).reduce((walked, part) => {
404
+ const current = join(walked, part);
405
+ if (lstatNoFollow(current, lstat)?.isSymbolicLink()) {
406
+ throw refuse(`${current} is a symlink — refusing to write the navigator through it`);
407
+ }
408
+ return current;
409
+ }, root);
410
+ };
411
+
412
+ const writeIndex = async (rows, today, meta, { root = ROOT, indexPath = INDEX_PATH, deps = {} } = {}) => {
413
+ const lstat = deps.lstat ?? lstatSync;
414
+ const write = deps.writeFile ?? writeFile;
415
+ const publish = deps.rename ?? rename;
416
+ const remove = deps.rm ?? rm;
417
+ const uniqueSuffix = deps.rand ?? (() => randomBytes(6).toString('hex'));
338
418
  const body = buildIndex(rows, today.toISOString().slice(0, 10), meta);
339
- await writeFile(indexPath, body, 'utf8');
419
+ assertContainedNoSymlink(root, indexPath, lstat);
420
+ const tmp = `${indexPath}.${uniqueSuffix()}.tmp`;
421
+ assertContainedNoSymlink(root, tmp, lstat);
422
+ const discardTemp = async (err) => {
423
+ try {
424
+ await remove(tmp, { force: true });
425
+ } catch (cleanupErr) {
426
+ throw refuse(`${err.message} — and its temp file could not be removed, delete it by hand: ${tmp} (${cleanupErr.message})`);
427
+ }
428
+ throw err;
429
+ };
430
+ try {
431
+ await write(tmp, body, { encoding: 'utf8', flag: 'wx' });
432
+ } catch (err) {
433
+ // EEXIST means the name is SOMEONE ELSE's file: exclusive-create refused, this run wrote
434
+ // nothing, and removing it would delete a file we never made. Every other failure can leave a
435
+ // partial temp behind, and that one is ours to discard.
436
+ if (err && err.code === 'EEXIST') throw err;
437
+ await discardTemp(err);
438
+ }
439
+ try {
440
+ assertContainedNoSymlink(root, indexPath, lstat);
441
+ await publish(tmp, indexPath);
442
+ } catch (err) {
443
+ await discardTemp(err);
444
+ }
340
445
  };
341
446
 
342
447
  // regenerateIndex(root, todayStr) — the ONE reused generator, root-parameterized (item (h)). It runs
@@ -344,15 +449,46 @@ const writeIndex = async (rows, today, meta, indexPath = INDEX_PATH) => {
344
449
  // (default this deployment). The ADR-rotation hook reaches it via the CLI (`--write-index --root=…`);
345
450
  // hermetic tests call it directly. `todayStr` is 'YYYY-MM-DD' (null → today). Returns the written
346
451
  // index path + row count. No second index implementation exists.
347
- export const regenerateIndex = async (root, todayStr = null) => {
348
- const { docsDir, indexPath } = pathsFor(root);
452
+ export const regenerateIndex = async (root, todayStr = null, deps = {}) => {
453
+ const paths = pathsFor(root);
349
454
  const today = computeToday(todayStr);
350
- const files = (await walkMarkdownFiles(docsDir)).sort();
351
- const inspected = await Promise.all(files.map((f) => inspectFile(f, today, root)));
455
+ const files = (await walkMarkdownFiles(paths.docsDir)).sort();
456
+ const inspected = await Promise.all(files.map((f) => inspectFile(f, today, paths.root)));
352
457
  const rows = inspected.map(formatRow);
353
- const meta = await discoverMeta(root);
354
- await writeIndex(rows, today, meta, indexPath);
355
- return { indexPath, files: rows.length };
458
+ const meta = await discoverMeta(paths.root);
459
+ await writeIndex(rows, today, meta, { root: paths.root, indexPath: paths.indexPath, deps });
460
+ return { indexPath: paths.indexPath, files: rows.length };
461
+ };
462
+
463
+ // The finalizer promises its caller EXACTLY ONE outcome line, so every step it owns — the walk, the
464
+ // metadata discovery, the freshness read and the write — runs inside one classified error path: an
465
+ // unreadable docs/ai is a NAMED refusal, never a stack trace. The containment guard runs BEFORE the
466
+ // freshness read for the same reason `already-present` needs a kind probe: a symlinked navigator
467
+ // whose target happens to hold current bytes would otherwise report `already-current` over a file
468
+ // this mode refuses to write through — an exit 0 proving nothing about the deployment's own file.
469
+ const runEnsureIndex = async ({ root, docsDir, indexPath, today, deps }) => {
470
+ const lstat = deps.lstat ?? lstatSync;
471
+ const read = deps.readFile ?? readFile;
472
+ const line = (token) => `${ENSURE_INDEX_PREFIX} ${token} — ${relative(root, indexPath)}`;
473
+ // The two refusals name STAGES, not error codes: once the write has been entered, ANY failure —
474
+ // a containment refusal, EIO, EACCES — is a write refusal, because that is what the reader has to
475
+ // act on. A raw fs error reported as a failed PROBE would send them to the wrong half of the run.
476
+ let writing = false;
477
+ try {
478
+ assertContainedNoSymlink(root, indexPath, lstat);
479
+ const files = (await walkMarkdownFiles(docsDir)).sort();
480
+ const inspected = await Promise.all(files.map((file) => inspectFile(file, today, root)));
481
+ const rows = inspected.map(formatRow);
482
+ const meta = await discoverMeta(root, { strict: true, deps });
483
+ const onDisk = existsSync(indexPath) ? await read(indexPath, 'utf8') : null;
484
+ if (checkIndexFreshness(rows, onDisk, meta).fresh) return { code: 0, out: line('already-current') };
485
+ writing = true;
486
+ await writeIndex(rows, today, meta, { root, indexPath, deps });
487
+ return { code: 0, out: line('regenerated') };
488
+ } catch (err) {
489
+ const cause = writing || err?.code === INDEX_WRITE_REFUSED ? 'write-refused' : 'probe-failed';
490
+ return { code: 2, err: `${ENSURE_INDEX_PREFIX} ${cause} — ${indexPath}: ${err.message}` };
491
+ }
356
492
  };
357
493
 
358
494
  // The return-code entry point (no process.argv / process.exit / console inside): argv[] →
@@ -370,7 +506,7 @@ export const runCli = async (argv, deps = {}) => {
370
506
 
371
507
  const { flags, opts, help, error } = parseArgs(argv);
372
508
  if (help) {
373
- log('Usage: check-docs-size.mjs [--report|--write-index|--check-index] [--today=YYYY-MM-DD] [--root=<dir>] [--quiet]');
509
+ log('Usage: check-docs-size.mjs [--report|--write-index|--check-index|--ensure-index] [--today=YYYY-MM-DD] [--root=<dir>] [--quiet]');
374
510
  return result(0);
375
511
  }
376
512
  if (error) {
@@ -379,6 +515,16 @@ export const runCli = async (argv, deps = {}) => {
379
515
  }
380
516
  const { root, docsDir, indexPath } = pathsFor(opts.root ? resolve(opts.root) : (deps.root ?? ROOT));
381
517
  const today = computeToday(opts.today);
518
+
519
+ // The finalizer owns its whole pipeline (above), so it returns BEFORE the shared walk: a tree the
520
+ // walk would throw on must still close with one outcome line.
521
+ if (flags.ensureIndex) {
522
+ const { code, out, err } = await runEnsureIndex({ root, docsDir, indexPath, today, deps });
523
+ if (out) log(out);
524
+ if (err) logError(err);
525
+ return result(code);
526
+ }
527
+
382
528
  const files = (await walkMarkdownFiles(docsDir)).sort();
383
529
  const inspected = await Promise.all(files.map((f) => inspectFile(f, today, root)));
384
530
  const rows = inspected.map(formatRow);
@@ -386,7 +532,12 @@ export const runCli = async (argv, deps = {}) => {
386
532
  const meta = flags.writeIndex || flags.checkIndex ? await discoverMeta(root) : null;
387
533
 
388
534
  if (flags.writeIndex) {
389
- await writeIndex(rows, today, meta, indexPath);
535
+ try {
536
+ await writeIndex(rows, today, meta, { root, indexPath, deps });
537
+ } catch (err) {
538
+ logError(`[check-docs-size] FAIL: ${indexPath}: ${err.message}`);
539
+ return result(2);
540
+ }
390
541
  log(`Wrote ${relative(root, indexPath)}`);
391
542
  const after = await stat(indexPath);
392
543
  if (after.size === 0) {
@@ -10,10 +10,13 @@ import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, readdirSync, lstat
10
10
  import { tmpdir } from 'node:os';
11
11
  import { join } from 'node:path';
12
12
  import { spawnSync } from 'node:child_process';
13
- import { UNIT_TESTS_COVERAGE_FLAGS, RETIRED_STORE_BASENAMES, main } from './migrate-gates.mjs';
13
+ import { CHECKER_CLAIM, UNIT_TESTS_COVERAGE_FLAGS, RETIRED_STORE_BASENAMES, checkerClaimTool, classifyCheckerClaim, main } from './migrate-gates.mjs';
14
14
 
15
+ // Both core checks exist as real files — canonicity is a realpath anchor, so a check whose file is
16
+ // absent resolves to nothing and is no claim at all (the fail-closed answer run-gates gives too).
15
17
  const KIT_TOOLS = mkdtempSync(join(tmpdir(), 'migrate-branches-kit-'));
16
18
  writeFileSync(join(KIT_TOOLS, 'coverage-check.mjs'), '// the installed checker the migration points at\n');
19
+ writeFileSync(join(KIT_TOOLS, 'review-state.mjs'), '// the installed review-state check\n');
17
20
 
18
21
  const mkProject = (gates) => {
19
22
  const root = mkdtempSync(join(tmpdir(), 'migrate-branches-'));
@@ -34,6 +37,8 @@ const CHECKER = { id: 'coverage-check', title: 'CC', cmd: `node "${join(KIT_TOOL
34
37
  const REVIEW_STATE = { id: 'review-state', title: 'RS', cmd: `node "${join(KIT_TOOLS, 'review-state.mjs')}" --check` };
35
38
  const LEGACY = { id: 'review-ledger', title: 'L', cmd: 'node "/kit/tools/review-ledger.mjs" --check' };
36
39
  const UNIT = { id: 'unit-tests', title: 'U', cmd: 'node --test tools/*.test.mjs' };
40
+ // A suite the closed producer world cannot express, declaring itself with the optional marker.
41
+ const MARKED_SUITE = { id: 'suite', title: 'S', cmd: 'pnpm vitest run --coverage', lcovProducer: true };
37
42
 
38
43
  describe('migrate-gates — refusal and no-op branches', () => {
39
44
  it('--help prints the contract and exits 0', () => {
@@ -141,6 +146,146 @@ describe('migrate-gates — refusal and no-op branches', () => {
141
146
  rmSync(root, { recursive: true, force: true });
142
147
  });
143
148
 
149
+ it('a marker-carrying entry survives an apply UNCHANGED — the loader is lenient, the writer opaque', () => {
150
+ // The declaration this tool rewrites may carry keys it knows nothing about. The loader accepts
151
+ // any `{ gates: [...] }` shape and the writer re-serializes the ENTRY, not a reconstruction of
152
+ // it, so an upgrade over a marker-carrying deployment never silently drops the claim.
153
+ const root = mkProject([LEGACY, MARKED_SUITE, REVIEW_STATE]);
154
+ const io = quiet();
155
+ assert.equal(main(['--cwd', root, '--kit-tools', KIT_TOOLS, '--apply'], io), 0, io.err.join('\n'));
156
+ const raw = readFileSync(join(root, 'docs', 'ai', 'gates.json'), 'utf8');
157
+ const written = JSON.parse(raw).gates;
158
+ assert.deepEqual(written.map((g) => g.id), ['suite', 'review-state', 'coverage-check'], 'the checker is ADDED over a marker-claimed producer');
159
+ assert.deepEqual(written[0], MARKED_SUITE, 'the marked entry round-trips key for key');
160
+ assert.match(raw, /"lcovProducer": true/, 'and the marker is really in the written bytes');
161
+ assert.doesNotMatch(io.out.join('\n'), /WARNING/, 'nothing is withheld over a declared producer');
162
+ rmSync(root, { recursive: true, force: true });
163
+ });
164
+
165
+ it('a marker on the CHECKER ITSELF never self-pairs — the declared pair stays INERT', () => {
166
+ // The producer question is POSITIONAL: the checker always ends up last, so it can never be its
167
+ // own producer. Asking it over the whole kept set would let this declaration certify itself
168
+ // into final-run-capability with nothing writing the lcov.
169
+ const root = mkProject([{ id: 'lint', title: 'L', cmd: 'eslint .' }, REVIEW_STATE, { ...CHECKER, lcovProducer: true }]);
170
+ const io = quiet();
171
+ assert.equal(main(['--cwd', root, '--kit-tools', KIT_TOOLS], io), 0, io.err.join('\n'));
172
+ const text = io.out.join('\n');
173
+ assert.match(text, /INERT/, 'the dead pair is named');
174
+ assert.doesNotMatch(text, /already final-run-capable/, 'and never claimed capable');
175
+ rmSync(root, { recursive: true, force: true });
176
+ });
177
+
178
+ it('a MARKED unit-tests entry is a zero-diff keep — never extended, never reported customized', () => {
179
+ // Both arms the marker settles at once: `npm test` is a cmd this tool cannot verify (customized
180
+ // without the marker), and rewriting a cmd whose owner declared it the producer would change
181
+ // bytes the byte-exact hook approval binds.
182
+ const marked = { id: 'unit-tests', title: 'U', cmd: 'npm test', lcovProducer: true };
183
+ const root = mkProject([marked, REVIEW_STATE]);
184
+ const io = quiet();
185
+ assert.equal(main(['--cwd', root, '--kit-tools', KIT_TOOLS], io), 0, io.err.join('\n'));
186
+ const text = io.out.join('\n');
187
+ assert.match(text, /ADD coverage-check/, 'the claimed producer unlocks the checker');
188
+ assert.doesNotMatch(text, /EXTEND unit-tests/, 'a claimed producer cmd is never rewritten');
189
+ assert.doesNotMatch(text, /CUSTOMIZED/, 'nor reported as a cmd the tool cannot verify');
190
+ rmSync(root, { recursive: true, force: true });
191
+
192
+ // The SAME entry unmarked is the customized/withheld path — the marker is what settles it.
193
+ const bare = mkProject([{ id: 'unit-tests', title: 'U', cmd: 'npm test' }, REVIEW_STATE]);
194
+ const io2 = quiet();
195
+ assert.equal(main(['--cwd', bare, '--kit-tools', KIT_TOOLS], io2), 0, io2.err.join('\n'));
196
+ const text2 = io2.out.join('\n');
197
+ assert.match(text2, /CUSTOMIZED/);
198
+ assert.doesNotMatch(text2, /ADD coverage-check/, 'the checker stays withheld with no producer');
199
+ rmSync(bare, { recursive: true, force: true });
200
+ });
201
+
202
+ it('a marker over an UNRUNNABLE cmd never unlocks the checker — the lenient loader has no validator', () => {
203
+ // This tool accepts any `{ gates: [...] }` shape, so an entry the strict validator would refuse
204
+ // reaches the plan builder intact. A marker on such an entry must not make the migration ADD the
205
+ // canonical checker: the result would be the dead pair the withhold exists to prevent, and the
206
+ // written declaration would then fail run-gates outright.
207
+ for (const cmd of [' ', 'echo a\nrm -rf b']) {
208
+ const root = mkProject([{ id: 'suite', title: 'S', cmd, lcovProducer: true }, REVIEW_STATE]);
209
+ const io = quiet();
210
+ assert.equal(main(['--cwd', root, '--kit-tools', KIT_TOOLS], io), 0, io.err.join('\n'));
211
+ const text = io.out.join('\n');
212
+ assert.doesNotMatch(text, /ADD coverage-check/, `an unrunnable cmd must not unlock the checker: ${JSON.stringify(cmd)}`);
213
+ assert.match(text, /WARNING: the canonical coverage-check gate was NOT added/, 'and the withhold is stated');
214
+ rmSync(root, { recursive: true, force: true });
215
+ }
216
+ });
217
+
218
+ it('a marker on a DUPLICATE canonical checker never produces for the other — nor claims capability', () => {
219
+ // `--final` accepts exactly ONE canonical checker, and a checker cannot write the lcov it reads.
220
+ // Excluding only the LAST checker row from the producer search let a marker on the first one pair
221
+ // with the second, and the preview then called the result final-run-capable over a declaration
222
+ // --final rejects outright, with nothing writing the file.
223
+ const root = mkProject([{ ...CHECKER, id: 'coverage-check', lcovProducer: true }, REVIEW_STATE, { ...CHECKER, id: 'coverage-check-2' }]);
224
+ const io = quiet();
225
+ assert.equal(main(['--cwd', root, '--kit-tools', KIT_TOOLS], io), 0, io.err.join('\n'));
226
+ const text = io.out.join('\n');
227
+ assert.doesNotMatch(text, /already final-run-capable/, 'two checkers are never a final-run-capable result');
228
+ assert.match(text, /2 declared gates are the canonical coverage checker/, 'the duplication is NAMED');
229
+ assert.match(text, /INERT/, 'and the pair is still reported inert — nothing writes the lcov');
230
+ rmSync(root, { recursive: true, force: true });
231
+ });
232
+
233
+ it('the tool-claim twin RUNS in this module — three outcomes, fail-closed on the unresolvable', () => {
234
+ // The text drift guard (beside the kit's own copy) proves the two owners are byte-equal; it
235
+ // cannot prove this copy WORKS, because the region is byte-equal inside a DIFFERENT host with
236
+ // different imports. Executing it here is what proves the twin resolves everything it uses.
237
+ const canonical = join(KIT_TOOLS, 'coverage-check.mjs');
238
+ const root = mkProject([]);
239
+ try {
240
+ const elsewhere = join(root, 'vendor-coverage-check.mjs');
241
+ writeFileSync(elsewhere, '// a vendored copy\n');
242
+ const tool = checkerClaimTool('coverage-check.mjs', canonical);
243
+ assert.equal(classifyCheckerClaim(tool, `node "${canonical}" --check`, KIT_TOOLS), CHECKER_CLAIM.CANONICAL);
244
+ const vendored = checkerClaimTool('vendor-coverage-check.mjs', canonical);
245
+ assert.equal(classifyCheckerClaim(vendored, `node "${elsewhere}" --check`, KIT_TOOLS), CHECKER_CLAIM.ELSEWHERE);
246
+ assert.equal(classifyCheckerClaim(tool, `node "${canonical}" --check || true`, KIT_TOOLS), CHECKER_CLAIM.NOT_THE_TOOL, 'a masked form is no claim');
247
+ assert.equal(classifyCheckerClaim(tool, `node "${join(KIT_TOOLS, 'nowhere', 'coverage-check.mjs')}" --check`, KIT_TOOLS), CHECKER_CLAIM.NOT_THE_TOOL, 'unresolvable fails closed');
248
+ assert.equal(classifyCheckerClaim(tool, 'node $(pwd)/coverage-check.mjs --check', KIT_TOOLS), CHECKER_CLAIM.NOT_THE_TOOL, 'a shell-active bare token is no claim');
249
+ } finally {
250
+ rmSync(root, { recursive: true, force: true }); // every other case here cleans up; this one held its root only for a path
251
+ }
252
+ });
253
+
254
+ it('a VENDORED deployment previews at exit 0 and its --apply writes ZERO bytes', () => {
255
+ // The upgrade path this fixes: every preview AND every apply over a deployment that declared the
256
+ // checker through its own vendored copy used to exit 1 on an id collision, so such a deployment
257
+ // could not be upgraded at all.
258
+ const vendoredTools = mkdtempSync(join(tmpdir(), 'migrate-branches-vendored-'));
259
+ writeFileSync(join(vendoredTools, 'coverage-check.mjs'), '// a vendored copy of the checker\n');
260
+ const vendored = { id: 'coverage-check', title: 'CC', cmd: `node "${join(vendoredTools, 'coverage-check.mjs')}" --check` };
261
+ const root = mkProject([UNIT_DONE, REVIEW_STATE, vendored]);
262
+ const before = readFileSync(join(root, 'docs', 'ai', 'gates.json'), 'utf8');
263
+
264
+ const io = quiet();
265
+ assert.equal(main(['--cwd', root, '--kit-tools', KIT_TOOLS], io), 0, io.err.join('\n'));
266
+ const preview = io.out.join('\n');
267
+ assert.match(preview, /VERIFY \(preserved exactly as declared\): coverage-check/);
268
+ assert.doesNotMatch(preview, /ADD coverage-check/, 'nothing is added over a checker that is already declared');
269
+ assert.doesNotMatch(io.err.join('\n'), /id collision/, 'a vendored copy is not a squatter');
270
+
271
+ const io2 = quiet();
272
+ assert.equal(main(['--cwd', root, '--kit-tools', KIT_TOOLS, '--apply'], io2), 0, io2.err.join('\n'));
273
+ assert.equal(readFileSync(join(root, 'docs', 'ai', 'gates.json'), 'utf8'), before, 'the apply is a ZERO-DIFF write');
274
+ assert.match(io2.out.join('\n'), /NOT final-run-capable/, 'and the withheld claim survives the no-op apply');
275
+ rmSync(vendoredTools, { recursive: true, force: true });
276
+ rmSync(root, { recursive: true, force: true });
277
+ });
278
+
279
+ it('a vendored copy named by a RELATIVE path resolves against the PROJECT root, as the runner resolves it', () => {
280
+ const root = mkProject([UNIT_DONE, REVIEW_STATE, { id: 'coverage-check', title: 'CC', cmd: 'node "vendor/coverage-check.mjs" --check' }]);
281
+ mkdirSync(join(root, 'vendor'), { recursive: true });
282
+ writeFileSync(join(root, 'vendor', 'coverage-check.mjs'), '// a vendored copy inside the project\n');
283
+ const io = quiet();
284
+ assert.equal(main(['--cwd', root, '--kit-tools', KIT_TOOLS], io), 0, io.err.join('\n'));
285
+ assert.match(io.out.join('\n'), /VERIFY \(preserved exactly as declared\): coverage-check/, 'a relative token is resolved, not dismissed');
286
+ rmSync(root, { recursive: true, force: true });
287
+ });
288
+
144
289
  it('an un-unlinkable retired store is reported LOUDLY and never fails the migration', () => {
145
290
  const root = mkProject([LEGACY, UNIT]);
146
291
  spawnSync('git', ['init', '-q'], { cwd: root, encoding: 'utf8' });