@sabaiway/agent-workflow-kit 2.0.0 → 3.0.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.
Files changed (102) hide show
  1. package/CHANGELOG.md +74 -0
  2. package/README.md +6 -5
  3. package/SKILL.md +12 -8
  4. package/bin/install.mjs +1 -1
  5. package/bridges/antigravity-cli-bridge/SKILL.md +16 -4
  6. package/bridges/antigravity-cli-bridge/bin/agy-review-honesty.test.mjs +150 -0
  7. package/bridges/antigravity-cli-bridge/bin/agy-review-model-screen.test.mjs +76 -0
  8. package/bridges/antigravity-cli-bridge/bin/agy-review.sh +131 -23
  9. package/bridges/antigravity-cli-bridge/bin/agy-review.test.mjs +193 -15
  10. package/bridges/antigravity-cli-bridge/capability.json +12 -8
  11. package/bridges/antigravity-cli-bridge/references/driving-agy.md +6 -2
  12. package/bridges/antigravity-cli-bridge/references/models-and-flags.md +3 -2
  13. package/bridges/antigravity-cli-bridge/references/review-prompt.md +1 -1
  14. package/bridges/codex-cli-bridge/SKILL.md +8 -1
  15. package/bridges/codex-cli-bridge/bin/codex-review-honesty.test.mjs +143 -0
  16. package/bridges/codex-cli-bridge/bin/codex-review.sh +71 -10
  17. package/bridges/codex-cli-bridge/bin/codex-review.test.mjs +93 -10
  18. package/bridges/codex-cli-bridge/capability.json +3 -2
  19. package/capability.json +1 -1
  20. package/migrations/3.0.0-hardened-core-loop.md +44 -0
  21. package/migrations/README.md +1 -1
  22. package/package.json +2 -2
  23. package/references/hooks/gate-approve.mjs +1 -1
  24. package/references/modes/bootstrap.md +3 -3
  25. package/references/modes/commit-guard.md +19 -0
  26. package/references/modes/core-evidence.md +15 -0
  27. package/references/modes/coverage-check.md +12 -0
  28. package/references/modes/doc-parity.md +7 -6
  29. package/references/modes/gates.md +7 -6
  30. package/references/modes/grounding.md +1 -2
  31. package/references/modes/hook.md +1 -1
  32. package/references/modes/review-state.md +3 -3
  33. package/references/modes/upgrade.md +6 -6
  34. package/references/modes/velocity.md +2 -2
  35. package/references/scripts/archive-decisions.mjs +1 -1
  36. package/references/scripts/install-git-hooks-repo-exec.test.mjs +82 -0
  37. package/references/scripts/install-git-hooks.mjs +90 -18
  38. package/references/scripts/install-git-hooks.test.mjs +102 -0
  39. package/references/scripts/migrate-gates-branches.test.mjs +157 -0
  40. package/references/scripts/migrate-gates.mjs +395 -0
  41. package/references/scripts/migrate-gates.test.mjs +284 -0
  42. package/references/templates/agent_rules.md +2 -2
  43. package/tools/ack-write.mjs +1 -1
  44. package/tools/atomic-write.mjs +2 -2
  45. package/tools/autonomy-config.mjs +1 -1
  46. package/tools/autonomy-doctor.mjs +1 -1
  47. package/tools/autonomy-write.mjs +1 -1
  48. package/tools/bridge-settings-read.mjs +1 -1
  49. package/tools/bridge-settings.mjs +1 -1
  50. package/tools/changed-surface.mjs +8 -69
  51. package/tools/cheap-agents.mjs +2 -2
  52. package/tools/commands.mjs +17 -10
  53. package/tools/commit-guard.mjs +167 -0
  54. package/tools/core-evidence.mjs +914 -0
  55. package/tools/coverage-check.mjs +260 -0
  56. package/tools/delegation.mjs +1 -1
  57. package/tools/detect-backends.mjs +7 -6
  58. package/tools/doc-parity.mjs +11 -27
  59. package/tools/engine-source.mjs +1 -1
  60. package/tools/family-members.mjs +1 -1
  61. package/tools/family-registry.mjs +1 -1
  62. package/tools/fs-safe.mjs +1 -1
  63. package/tools/gate-hook.mjs +1 -1
  64. package/tools/{seed-gates.mjs → gates-init.mjs} +58 -52
  65. package/tools/grounding.mjs +6 -83
  66. package/tools/hide-footprint.mjs +1 -1
  67. package/tools/inject-methodology.mjs +1 -1
  68. package/tools/known-footprint.mjs +1 -1
  69. package/tools/labels.mjs +1 -1
  70. package/tools/lcov.mjs +6 -10
  71. package/tools/lens-region.mjs +1 -1
  72. package/tools/manifest/validate.mjs +19 -0
  73. package/tools/migrate-adr-store.mjs +2 -2
  74. package/tools/orchestration-config.mjs +1 -1
  75. package/tools/orchestration-write.mjs +1 -1
  76. package/tools/presentation.mjs +1 -1
  77. package/tools/procedures.mjs +2 -2
  78. package/tools/recipes.mjs +60 -9
  79. package/tools/recommendations.mjs +63 -7
  80. package/tools/release-scan.mjs +1 -1
  81. package/tools/renderers.mjs +1 -1
  82. package/tools/review-state.mjs +288 -340
  83. package/tools/run-gates.mjs +216 -92
  84. package/tools/sandbox-masks.mjs +1 -1
  85. package/tools/semver-lite.mjs +1 -1
  86. package/tools/set-autonomy.mjs +1 -1
  87. package/tools/set-recipe.mjs +1 -1
  88. package/tools/setup-backends.mjs +1 -1
  89. package/tools/surface.mjs +1 -1
  90. package/tools/uninstall.mjs +1 -1
  91. package/tools/velocity-profile.mjs +2 -2
  92. package/tools/view-model.mjs +1 -1
  93. package/references/modes/fold-completeness.md +0 -30
  94. package/references/modes/review-ledger.md +0 -34
  95. package/references/templates/verification-profile.json +0 -10
  96. package/tools/fold-completeness-run.mjs +0 -1120
  97. package/tools/fold-completeness.mjs +0 -672
  98. package/tools/review-ledger-core.mjs +0 -428
  99. package/tools/review-ledger-write.mjs +0 -647
  100. package/tools/review-ledger.mjs +0 -630
  101. package/tools/sarif.mjs +0 -52
  102. package/tools/verification-profile.mjs +0 -219
@@ -1,12 +1,25 @@
1
1
  #!/usr/bin/env node
2
2
  // Idempotent installer for the project's git hooks.
3
3
  //
4
- // Installs `.git/hooks/pre-commit` running the docs cap-validator + index-freshness
4
+ // Installs the `pre-commit` hook running the docs cap-validator + index-freshness
5
5
  // gate + changelog/issues rotation-freshness checks + the `scripts/` test
6
6
  // suite, so docs files cannot drift over their declared `maxLines`, the auto-generated
7
7
  // `index.md` navigator cannot silently fall out of sync, stale archive entries never
8
8
  // reach a commit, and regressions to the scripts themselves are caught at commit time.
9
9
  //
10
+ // The hooks location comes from git's OWN answer (`git rev-parse --git-path hooks`) — a linked
11
+ // worktree resolves ITS OWN hooks path (its `.git` is a FILE; a hardcoded `.git/hooks` would
12
+ // miss or mis-write).
13
+ //
14
+ // COMMIT GUARD (D10, optional): `--commit-guard <path>` names the composition root's read-only
15
+ // commit-guard tool explicitly — the hook then gains its `--check` line with the RESOLVED,
16
+ // quoted path written at install time (a portable contract: no runtime guessing; this substrate
17
+ // knows no sibling by name, so the path always arrives from the caller). The guard binds the
18
+ // latest final-run receipt to the exact commit tree. An armed guard line SURVIVES flagless
19
+ // re-runs (carried forward from the managed hook — exactly ONE strictly-parsed line; an
20
+ // ambiguous duplicate fails closed); `--no-commit-guard` is the one consented disable; passing
21
+ // both flags together is a usage error.
22
+ //
10
23
  // Package-manager-agnostic: the hook calls the scripts via `node` directly (no pnpm/npm
11
24
  // assumption). Re-running is safe — the script detects a previously installed hook via
12
25
  // the MAGIC_MARKER comment and rewrites only that file.
@@ -17,12 +30,11 @@ import { writeFile, readFile, mkdir, chmod } from 'node:fs/promises';
17
30
  import { existsSync, readFileSync } from 'node:fs';
18
31
  import { dirname, resolve, basename } from 'node:path';
19
32
  import { fileURLToPath } from 'node:url';
33
+ import { spawnSync } from 'node:child_process';
20
34
 
21
35
  const __filename = fileURLToPath(import.meta.url);
22
36
  const __dirname = dirname(__filename);
23
37
  const ROOT = resolve(__dirname, '..');
24
- const HOOKS_DIR = resolve(ROOT, '.git/hooks');
25
- const PRE_COMMIT_PATH = resolve(HOOKS_DIR, 'pre-commit');
26
38
 
27
39
  const readProjectName = () => {
28
40
  try {
@@ -36,7 +48,58 @@ const readProjectName = () => {
36
48
 
37
49
  const MAGIC_MARKER = `# ${readProjectName()}:install-git-hooks.mjs`;
38
50
 
39
- const PRE_COMMIT_CONTENT = `#!/usr/bin/env bash
51
+ // git's own hooks location (worktree-correct), or null outside a git checkout.
52
+ const gitHooksDir = () => {
53
+ const r = spawnSync('git', ['rev-parse', '--git-path', 'hooks'], { cwd: ROOT, encoding: 'utf8', windowsHide: true });
54
+ if (r.error || r.status !== 0) return null;
55
+ return resolve(ROOT, r.stdout.replace(/\r?\n$/, ''));
56
+ };
57
+
58
+ // The exact shape this installer writes — the strict carry-forward parse anchors on it
59
+ // (quoted path + a bare ` --check` tail; nothing else in the managed hook matches).
60
+ const GUARD_LINE_RE = /^node "([^"]+)" --check$/;
61
+
62
+ const ARGV = process.argv.slice(2);
63
+
64
+ // The optional commit-guard line: an explicit --commit-guard <path> arms it (resolved at install
65
+ // time); --no-commit-guard is the one consented disable; both together are a usage error —
66
+ // never an order-dependent pick.
67
+ const resolveGuardFlags = (argv) => {
68
+ const at = argv.indexOf('--commit-guard');
69
+ const noGuard = argv.includes('--no-commit-guard');
70
+ if (at !== -1 && noGuard) {
71
+ console.error('[install-git-hooks] --commit-guard and --no-commit-guard contradict each other — pass exactly one');
72
+ process.exit(2);
73
+ }
74
+ if (noGuard) return { mode: 'disable' };
75
+ if (at === -1) return { mode: 'carry' };
76
+ const given = argv[at + 1];
77
+ if (!given) {
78
+ console.error('[install-git-hooks] --commit-guard needs a path');
79
+ process.exit(2);
80
+ }
81
+ const abs = resolve(ROOT, given);
82
+ if (!existsSync(abs)) {
83
+ console.error(`[install-git-hooks] --commit-guard ${abs} does not exist`);
84
+ process.exit(1);
85
+ }
86
+ return { mode: 'arm', path: abs };
87
+ };
88
+
89
+ // Flagless re-runs carry the armed guard forward from the MANAGED hook — strictly parsed:
90
+ // exactly one guard-shaped line keeps its path; none keeps the hook plain; more than one is
91
+ // ambiguous and fails CLOSED (an installer must never guess which line was the real guard).
92
+ const carriedGuardPath = (existingHook) => {
93
+ if (existingHook === null) return null;
94
+ const matches = existingHook.split('\n').map((l) => GUARD_LINE_RE.exec(l)).filter(Boolean);
95
+ if (matches.length > 1) {
96
+ console.error(`[install-git-hooks] the managed pre-commit hook carries ${matches.length} commit-guard --check lines — ambiguous; remove the duplicates by hand and re-run (refusing to guess)`);
97
+ process.exit(1);
98
+ }
99
+ return matches.length === 1 ? matches[0][1] : null;
100
+ };
101
+
102
+ const preCommitContent = (guardPath) => `#!/usr/bin/env bash
40
103
  ${MAGIC_MARKER}
41
104
  # Auto-installed by scripts/install-git-hooks.mjs (run it from "prepare" or by hand).
42
105
  # Runs the docs cap-validator + index-freshness gate + rotation-freshness checks
@@ -50,32 +113,41 @@ node scripts/archive-changelog.mjs --check
50
113
  node scripts/archive-issues.mjs --check
51
114
  node scripts/archive-decisions.mjs --check
52
115
  node --test scripts/*.test.mjs
53
- `;
116
+ ${guardPath ? `node "${guardPath}" --check\n` : ''}`;
54
117
 
55
118
  const main = async () => {
56
- if (!existsSync(resolve(ROOT, '.git'))) {
57
- console.log('[install-git-hooks] .git directory not found — skipping (not a git checkout).');
119
+ const flags = resolveGuardFlags(ARGV);
120
+ const hooksDir = gitHooksDir();
121
+ if (hooksDir === null) {
122
+ console.log('[install-git-hooks] git cannot resolve a hooks path here — skipping (not a git checkout).');
58
123
  return;
59
124
  }
60
- await mkdir(HOOKS_DIR, { recursive: true });
125
+ const preCommitPath = resolve(hooksDir, 'pre-commit');
61
126
 
62
- if (existsSync(PRE_COMMIT_PATH)) {
63
- const existing = await readFile(PRE_COMMIT_PATH, 'utf8');
64
- if (existing.includes(MAGIC_MARKER) && existing === PRE_COMMIT_CONTENT) {
65
- console.log('[install-git-hooks] pre-commit already up to date.');
66
- return;
67
- }
127
+ let existing = null;
128
+ if (existsSync(preCommitPath)) {
129
+ existing = await readFile(preCommitPath, 'utf8');
68
130
  if (!existing.includes(MAGIC_MARKER)) {
69
131
  console.warn(
70
- '[install-git-hooks] WARNING: .git/hooks/pre-commit exists and was not installed by this script. Refusing to overwrite — remove or merge it manually.',
132
+ `[install-git-hooks] WARNING: ${preCommitPath} exists and was not installed by this script. Refusing to overwrite — remove or merge it manually.`,
71
133
  );
72
134
  process.exit(1);
73
135
  }
74
136
  }
75
137
 
76
- await writeFile(PRE_COMMIT_PATH, PRE_COMMIT_CONTENT, 'utf8');
77
- await chmod(PRE_COMMIT_PATH, 0o755);
78
- console.log('[install-git-hooks] installed .git/hooks/pre-commit (docs caps + index freshness + archive checks + scripts/ tests).');
138
+ const guardPath = flags.mode === 'arm' ? flags.path : flags.mode === 'disable' ? null : carriedGuardPath(existing);
139
+ const content = preCommitContent(guardPath);
140
+ if (existing === content) {
141
+ console.log('[install-git-hooks] pre-commit already up to date.');
142
+ return;
143
+ }
144
+
145
+ await mkdir(hooksDir, { recursive: true });
146
+ await writeFile(preCommitPath, content, 'utf8');
147
+ await chmod(preCommitPath, 0o755);
148
+ console.log(
149
+ `[install-git-hooks] installed ${preCommitPath} (docs caps + index freshness + archive checks + scripts/ tests${guardPath ? ' + commit guard' : ''}).`,
150
+ );
79
151
  };
80
152
 
81
153
  main().catch((err) => {
@@ -0,0 +1,102 @@
1
+ // install-git-hooks.test.mjs — spec for the pre-commit installer (strip-the-kit round-1 folds):
2
+ // the hooks path comes from git plumbing (a linked worktree resolves ITS OWN hooks path, never a
3
+ // hardcoded `.git/hooks`), and an armed commit-guard line SURVIVES a flagless re-run (exactly one
4
+ // strictly-parsed canonical guard line carries forward; --no-commit-guard is the only consented
5
+ // disable; both flags together are a usage error; a duplicated guard line fails closed).
6
+
7
+ import { describe, it } from 'node:test';
8
+ import assert from 'node:assert/strict';
9
+ import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, rmSync, cpSync, existsSync } from 'node:fs';
10
+ import { tmpdir } from 'node:os';
11
+ import { join, dirname, resolve } from 'node:path';
12
+ import { fileURLToPath } from 'node:url';
13
+ import { spawnSync } from 'node:child_process';
14
+
15
+ const HERE = dirname(fileURLToPath(import.meta.url));
16
+ const INSTALLER_SRC = join(HERE, 'install-git-hooks.mjs');
17
+
18
+ // The installer anchors ROOT at <its dir>/.. — deploy it into a fixture's scripts/ like a consumer.
19
+ const mkProject = () => {
20
+ const root = mkdtempSync(join(tmpdir(), 'install-hooks-'));
21
+ const g = (...a) => spawnSync('git', a, { cwd: root, encoding: 'utf8' });
22
+ g('init', '-q');
23
+ g('config', 'user.email', 'p@e');
24
+ g('config', 'user.name', 'p');
25
+ mkdirSync(join(root, 'scripts'), { recursive: true });
26
+ cpSync(INSTALLER_SRC, join(root, 'scripts', 'install-git-hooks.mjs'));
27
+ writeFileSync(join(root, 'guard.mjs'), '// a stand-in commit-guard tool\n');
28
+ writeFileSync(join(root, 'base.txt'), 'base\n');
29
+ g('add', '-A');
30
+ g('commit', '-qm', 'base');
31
+ return { root, g };
32
+ };
33
+
34
+ const runInstaller = (root, args = []) =>
35
+ spawnSync('node', [join(root, 'scripts', 'install-git-hooks.mjs'), ...args], { cwd: root, encoding: 'utf8' });
36
+
37
+ const hooksPathOf = (root) => {
38
+ const r = spawnSync('git', ['rev-parse', '--git-path', 'hooks'], { cwd: root, encoding: 'utf8' });
39
+ return resolve(root, r.stdout.trim());
40
+ };
41
+
42
+ describe('install-git-hooks — the hooks path comes from git plumbing (C7)', () => {
43
+ it('a normal repo installs at the git-path hooks location', () => {
44
+ const { root } = mkProject();
45
+ const r = runInstaller(root);
46
+ assert.equal(r.status, 0, r.stderr);
47
+ assert.ok(existsSync(join(hooksPathOf(root), 'pre-commit')), 'the hook lands where git says hooks live');
48
+ rmSync(root, { recursive: true, force: true });
49
+ });
50
+
51
+ it('a LINKED WORKTREE installs at ITS OWN git-path hooks (never a hardcoded <worktree>/.git/hooks)', () => {
52
+ const { root, g } = mkProject();
53
+ const wt = join(root, '..', `wt-${Date.now() % 1e6}`);
54
+ g('worktree', 'add', '-q', wt);
55
+ mkdirSync(join(wt, 'scripts'), { recursive: true });
56
+ cpSync(INSTALLER_SRC, join(wt, 'scripts', 'install-git-hooks.mjs'));
57
+ const r = runInstaller(wt);
58
+ assert.equal(r.status, 0, r.stderr);
59
+ const expected = join(hooksPathOf(wt), 'pre-commit');
60
+ assert.ok(existsSync(expected), `the worktree hook lands at git's own answer (${expected})`);
61
+ assert.ok(!existsSync(join(wt, '.git', 'hooks', 'pre-commit')), 'never a literal <worktree>/.git/hooks (a worktree .git is a FILE)');
62
+ rmSync(wt, { recursive: true, force: true });
63
+ rmSync(root, { recursive: true, force: true });
64
+ });
65
+ });
66
+
67
+ describe('install-git-hooks — the commit-guard arm persists (C8)', () => {
68
+ it('an armed guard SURVIVES a later flagless re-run (carried forward from the managed hook)', () => {
69
+ const { root } = mkProject();
70
+ assert.equal(runInstaller(root, ['--commit-guard', 'guard.mjs']).status, 0);
71
+ const armed = readFileSync(join(hooksPathOf(root), 'pre-commit'), 'utf8');
72
+ assert.match(armed, /guard\.mjs" --check/, 'the guard line is written armed');
73
+ assert.equal(runInstaller(root).status, 0, 'the flagless canonical re-run succeeds');
74
+ const after = readFileSync(join(hooksPathOf(root), 'pre-commit'), 'utf8');
75
+ assert.match(after, /guard\.mjs" --check/, 'the armed guard line survives a flagless re-run');
76
+ rmSync(root, { recursive: true, force: true });
77
+ });
78
+
79
+ it('--no-commit-guard is the ONLY consented disable; combining both flags is a usage error', () => {
80
+ const { root } = mkProject();
81
+ assert.equal(runInstaller(root, ['--commit-guard', 'guard.mjs']).status, 0);
82
+ assert.equal(runInstaller(root, ['--no-commit-guard']).status, 0);
83
+ const after = readFileSync(join(hooksPathOf(root), 'pre-commit'), 'utf8');
84
+ assert.doesNotMatch(after, /" --check$/m, 'the guard line is removed on the explicit disable');
85
+ const both = runInstaller(root, ['--commit-guard', 'guard.mjs', '--no-commit-guard']);
86
+ assert.equal(both.status, 2, 'both flags together are a usage error, never an order-dependent pick');
87
+ rmSync(root, { recursive: true, force: true });
88
+ });
89
+
90
+ it('a DUPLICATED/malformed guard line in the managed hook fails CLOSED on a flagless re-run', () => {
91
+ const { root } = mkProject();
92
+ assert.equal(runInstaller(root, ['--commit-guard', 'guard.mjs']).status, 0);
93
+ const hookPath = join(hooksPathOf(root), 'pre-commit');
94
+ const armed = readFileSync(hookPath, 'utf8');
95
+ const guardLine = armed.split('\n').find((l) => /" --check$/.test(l));
96
+ writeFileSync(hookPath, `${armed}${guardLine}\n`); // duplicate the guard line
97
+ const r = runInstaller(root);
98
+ assert.equal(r.status, 1, 'two guard lines are ambiguous — fail closed, never guess');
99
+ assert.match(r.stderr, /guard/i);
100
+ rmSync(root, { recursive: true, force: true });
101
+ });
102
+ });
@@ -0,0 +1,157 @@
1
+ // migrate-gates-branches.test.mjs — the refusal/no-op branch pins the main spec file leaves
2
+ // unexercised (colocated so the D3(d) changed-line check reads real executions, not intentions):
3
+ // the --help arm, the missing-declaration no-op, the invalid-shape STOP, the honest no-op split
4
+ // on final-run-capability, the mid-write parent-verification failure (fail closed + tmp cleanup),
5
+ // and the loud-but-non-fatal retired-store cleanup error.
6
+
7
+ import { describe, it } from 'node:test';
8
+ import assert from 'node:assert/strict';
9
+ import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, readdirSync, lstatSync, rmSync, symlinkSync } from 'node:fs';
10
+ import { tmpdir } from 'node:os';
11
+ import { join } from 'node:path';
12
+ import { spawnSync } from 'node:child_process';
13
+ import { UNIT_TESTS_COVERAGE_FLAGS, RETIRED_STORE_BASENAMES, main } from './migrate-gates.mjs';
14
+
15
+ const KIT_TOOLS = mkdtempSync(join(tmpdir(), 'migrate-branches-kit-'));
16
+ writeFileSync(join(KIT_TOOLS, 'coverage-check.mjs'), '// the installed checker the migration points at\n');
17
+
18
+ const mkProject = (gates) => {
19
+ const root = mkdtempSync(join(tmpdir(), 'migrate-branches-'));
20
+ mkdirSync(join(root, 'docs', 'ai'), { recursive: true });
21
+ if (gates !== undefined) {
22
+ writeFileSync(join(root, 'docs', 'ai', 'gates.json'), typeof gates === 'string' ? gates : `${JSON.stringify({ gates }, null, 2)}\n`);
23
+ }
24
+ return root;
25
+ };
26
+ const quiet = () => {
27
+ const out = [];
28
+ const err = [];
29
+ return { log: (l) => out.push(String(l)), error: (l) => err.push(String(l)), out, err };
30
+ };
31
+
32
+ const UNIT_DONE = { id: 'unit-tests', title: 'U', cmd: `node --test ${UNIT_TESTS_COVERAGE_FLAGS} tools/*.test.mjs` };
33
+ const CHECKER = { id: 'coverage-check', title: 'CC', cmd: `node "${join(KIT_TOOLS, 'coverage-check.mjs')}" --check` };
34
+ const REVIEW_STATE = { id: 'review-state', title: 'RS', cmd: `node "${join(KIT_TOOLS, 'review-state.mjs')}" --check` };
35
+ const LEGACY = { id: 'review-ledger', title: 'L', cmd: 'node "/kit/tools/review-ledger.mjs" --check' };
36
+ const UNIT = { id: 'unit-tests', title: 'U', cmd: 'node --test tools/*.test.mjs' };
37
+
38
+ describe('migrate-gates — refusal and no-op branches', () => {
39
+ it('--help prints the contract and exits 0', () => {
40
+ const io = quiet();
41
+ assert.equal(main(['--help'], io), 0);
42
+ assert.match(io.out.join('\n'), /Usage:/);
43
+ });
44
+
45
+ it('the no-op split is HONEST about final-run-capability (capable names it; incapable never does)', () => {
46
+ const capable = mkProject([UNIT_DONE, REVIEW_STATE, CHECKER]);
47
+ const io = quiet();
48
+ assert.equal(main(['--cwd', capable, '--kit-tools', KIT_TOOLS], io), 0);
49
+ assert.match(io.out.join('\n'), /already final-run-capable/);
50
+ rmSync(capable, { recursive: true, force: true });
51
+
52
+ const incapable = mkProject([UNIT_DONE, CHECKER]);
53
+ const io2 = quiet();
54
+ assert.equal(main(['--cwd', incapable, '--kit-tools', KIT_TOOLS], io2), 0);
55
+ const text = io2.out.join('\n');
56
+ assert.match(text, /nothing to migrate mechanically/);
57
+ assert.match(text, /NOT final-run-capable/);
58
+ assert.doesNotMatch(text, /already final-run-capable/);
59
+ rmSync(incapable, { recursive: true, force: true });
60
+ });
61
+
62
+ it('docs/ai WITHOUT a gates.json (and no stores) is a stated no-op, never a crash', () => {
63
+ const root = mkProject(undefined);
64
+ const io = quiet();
65
+ assert.equal(main(['--cwd', root, '--kit-tools', KIT_TOOLS], io), 0);
66
+ assert.match(io.out.join('\n'), /nothing to migrate/);
67
+ rmSync(root, { recursive: true, force: true });
68
+ });
69
+
70
+ it('a parseable-but-invalid declaration ({ gates: <non-array> }) is a loud STOP', () => {
71
+ const root = mkProject('{ "gates": 5 }\n');
72
+ const io = quiet();
73
+ assert.equal(main(['--cwd', root, '--kit-tools', KIT_TOOLS, '--apply'], io), 1);
74
+ assert.match(io.err.join('\n'), /not a \{ gates/);
75
+ rmSync(root, { recursive: true, force: true });
76
+ });
77
+
78
+ it('an lstat failure DURING the write fails CLOSED — nothing written, the tmp cleaned', () => {
79
+ const root = mkProject([LEGACY, UNIT]);
80
+ const before = readFileSync(join(root, 'docs', 'ai', 'gates.json'), 'utf8');
81
+ const tmpExists = () => readdirSync(join(root, 'docs', 'ai')).some((f) => f.endsWith('.tmp'));
82
+ const io = {
83
+ ...quiet(),
84
+ // Fail the parent verification the moment the tmp file exists — that is exactly the
85
+ // PRE-RENAME re-check, whatever the lstat call count is (count-anchored injection went
86
+ // silently stale once loadDeclaration became an lstat consumer too).
87
+ lstat: (p) => {
88
+ if (tmpExists()) throw Object.assign(new Error('injected EACCES'), { code: 'EACCES' });
89
+ return lstatSync(p);
90
+ },
91
+ };
92
+ assert.equal(main(['--cwd', root, '--kit-tools', KIT_TOOLS, '--apply'], io), 1);
93
+ assert.match(io.err.join('\n'), /cannot verify the declaration parent/);
94
+ assert.equal(readFileSync(join(root, 'docs', 'ai', 'gates.json'), 'utf8'), before, 'the declaration is untouched');
95
+ assert.deepEqual(readdirSync(join(root, 'docs', 'ai')).filter((f) => f.endsWith('.tmp')), [], 'no tmp litter survives (the writer catch cleaned it)');
96
+ rmSync(root, { recursive: true, force: true });
97
+ });
98
+
99
+ it('a NON-canonical entry squatting the checker id is a loud STOP on preview AND apply (never a duplicate row)', () => {
100
+ const squatter = { id: 'coverage-check', title: 'C', cmd: 'node scripts/coverage-check.mjs --check' };
101
+ const root = mkProject([squatter, UNIT]);
102
+ for (const argv of [['--cwd', root, '--kit-tools', KIT_TOOLS], ['--cwd', root, '--kit-tools', KIT_TOOLS, '--apply']]) {
103
+ const io = quiet();
104
+ assert.equal(main(argv, io), 1, `must STOP: ${argv.join(' ')}`);
105
+ assert.match(io.err.join('\n'), /id collision/);
106
+ }
107
+ const after = JSON.parse(readFileSync(join(root, 'docs', 'ai', 'gates.json'), 'utf8')).gates;
108
+ assert.equal(after.filter((g) => g.id === 'coverage-check').length, 1, 'nothing was written');
109
+ rmSync(root, { recursive: true, force: true });
110
+ });
111
+
112
+ it('a canonical checker addressed THROUGH A SYMLINKED kit dir is recognized (realpath, never lexical)', () => {
113
+ const linked = join(tmpdir(), `migrate-branches-link-${process.pid}`);
114
+ rmSync(linked, { force: true });
115
+ symlinkSync(KIT_TOOLS, linked);
116
+ const throughLink = { id: 'coverage-check', title: 'CC', cmd: `node "${join(linked, 'coverage-check.mjs')}" --check` };
117
+ const root = mkProject([throughLink, UNIT]);
118
+ const io = quiet();
119
+ assert.equal(main(['--cwd', root, '--kit-tools', KIT_TOOLS], io), 0, io.err.join('\n'));
120
+ const text = io.out.join('\n');
121
+ assert.doesNotMatch(text, /id collision/, 'the symlinked canonical checker is never a collision');
122
+ assert.doesNotMatch(text, /ADD coverage-check/, 'no duplicate checker is added');
123
+ rmSync(linked, { force: true });
124
+ rmSync(root, { recursive: true, force: true });
125
+ });
126
+
127
+ it('a NON-ENOENT lstat failure on the gates.json LEAF is a loud STOP — never read as "missing"', () => {
128
+ const root = mkProject([LEGACY, UNIT]);
129
+ spawnSync('git', ['init', '-q'], { cwd: root, encoding: 'utf8' });
130
+ writeFileSync(join(root, '.git', RETIRED_STORE_BASENAMES[0]), '{"dead":1}\n');
131
+ const io = {
132
+ ...quiet(),
133
+ lstat: (p) => {
134
+ if (p.endsWith('gates.json')) throw Object.assign(new Error('injected EACCES'), { code: 'EACCES' });
135
+ return lstatSync(p);
136
+ },
137
+ };
138
+ assert.equal(main(['--cwd', root, '--kit-tools', KIT_TOOLS, '--apply'], io), 1, 'an unreadable declaration must STOP the apply');
139
+ assert.match(io.err.join('\n'), /EACCES/);
140
+ assert.ok(lstatSync(join(root, '.git', RETIRED_STORE_BASENAMES[0])).isFile(), 'the retired store is untouched on the STOP');
141
+ rmSync(root, { recursive: true, force: true });
142
+ });
143
+
144
+ it('an un-unlinkable retired store is reported LOUDLY and never fails the migration', () => {
145
+ const root = mkProject([LEGACY, UNIT]);
146
+ spawnSync('git', ['init', '-q'], { cwd: root, encoding: 'utf8' });
147
+ // A DIRECTORY at the store path: existsSync sees it, unlink refuses (EISDIR/EPERM) — the
148
+ // deterministic un-unlinkable shape (permission bits vary by runner; a dir does not).
149
+ mkdirSync(join(root, '.git', RETIRED_STORE_BASENAMES[0]), { recursive: true });
150
+ const io = quiet();
151
+ assert.equal(main(['--cwd', root, '--kit-tools', KIT_TOOLS, '--apply'], io), 0);
152
+ const text = io.out.join('\n');
153
+ assert.match(text, /could not clean .*review-ledger\.jsonl/, 'the cleanup failure is named');
154
+ assert.match(text, /migrated .*gates\.json/, 'the migration itself still landed');
155
+ rmSync(root, { recursive: true, force: true });
156
+ });
157
+ });