@sabaiway/agent-workflow-kit 2.1.0 → 3.1.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 (107) hide show
  1. package/CHANGELOG.md +94 -1
  2. package/README.md +7 -5
  3. package/SKILL.md +16 -8
  4. package/bin/install.mjs +11 -2
  5. package/bridges/antigravity-cli-bridge/SKILL.md +8 -1
  6. package/bridges/antigravity-cli-bridge/bin/agy-review-honesty.test.mjs +163 -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 +80 -15
  9. package/bridges/antigravity-cli-bridge/bin/agy-review.test.mjs +171 -22
  10. package/bridges/antigravity-cli-bridge/bin/agy.test.mjs +59 -41
  11. package/bridges/antigravity-cli-bridge/capability.json +3 -2
  12. package/bridges/codex-cli-bridge/SKILL.md +8 -1
  13. package/bridges/codex-cli-bridge/bin/codex-exec.test.mjs +67 -22
  14. package/bridges/codex-cli-bridge/bin/codex-review-honesty.test.mjs +156 -0
  15. package/bridges/codex-cli-bridge/bin/codex-review.sh +71 -10
  16. package/bridges/codex-cli-bridge/bin/codex-review.test.mjs +158 -27
  17. package/bridges/codex-cli-bridge/capability.json +3 -2
  18. package/capability.json +1 -1
  19. package/migrations/3.0.0-hardened-core-loop.md +44 -0
  20. package/migrations/README.md +1 -1
  21. package/package.json +2 -2
  22. package/references/hooks/gate-approve.mjs +1 -1
  23. package/references/modes/bootstrap.md +3 -3
  24. package/references/modes/commit-guard.md +19 -0
  25. package/references/modes/core-evidence.md +15 -0
  26. package/references/modes/coverage-check.md +12 -0
  27. package/references/modes/doc-parity.md +7 -6
  28. package/references/modes/gates.md +7 -6
  29. package/references/modes/grounding.md +1 -2
  30. package/references/modes/hook.md +1 -1
  31. package/references/modes/recommendations.md +1 -0
  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/modes/worktrees.md +120 -0
  36. package/references/scripts/archive-decisions.mjs +3 -3
  37. package/references/scripts/archive-decisions.test.mjs +5 -5
  38. package/references/scripts/check-docs-size-cli.test.mjs +41 -0
  39. package/references/scripts/check-docs-size.mjs +46 -29
  40. package/references/scripts/install-git-hooks-repo-exec.test.mjs +82 -0
  41. package/references/scripts/install-git-hooks.mjs +90 -18
  42. package/references/scripts/install-git-hooks.test.mjs +102 -0
  43. package/references/scripts/migrate-gates-branches.test.mjs +157 -0
  44. package/references/scripts/migrate-gates.mjs +395 -0
  45. package/references/scripts/migrate-gates.test.mjs +284 -0
  46. package/references/templates/agent_rules.md +2 -2
  47. package/tools/ack-write.mjs +1 -1
  48. package/tools/atomic-write.mjs +2 -2
  49. package/tools/autonomy-config.mjs +1 -1
  50. package/tools/autonomy-doctor.mjs +2 -2
  51. package/tools/autonomy-write.mjs +1 -1
  52. package/tools/bridge-settings-read.mjs +1 -1
  53. package/tools/bridge-settings.mjs +1 -1
  54. package/tools/changed-surface.mjs +16 -77
  55. package/tools/cheap-agents.mjs +2 -2
  56. package/tools/commands.mjs +26 -10
  57. package/tools/commit-guard.mjs +167 -0
  58. package/tools/core-evidence.mjs +914 -0
  59. package/tools/coverage-check.mjs +260 -0
  60. package/tools/delegation.mjs +1 -1
  61. package/tools/detect-backends.mjs +3 -3
  62. package/tools/doc-parity.mjs +11 -27
  63. package/tools/engine-source.mjs +1 -1
  64. package/tools/family-members.mjs +1 -1
  65. package/tools/family-registry.mjs +1 -1
  66. package/tools/fs-safe.mjs +1 -1
  67. package/tools/gate-hook.mjs +1 -1
  68. package/tools/{seed-gates.mjs → gates-init.mjs} +58 -52
  69. package/tools/grounding.mjs +19 -96
  70. package/tools/hide-footprint.mjs +1 -1
  71. package/tools/inject-methodology.mjs +72 -41
  72. package/tools/known-footprint.mjs +1 -1
  73. package/tools/labels.mjs +1 -1
  74. package/tools/lcov.mjs +6 -10
  75. package/tools/lens-region.mjs +1 -1
  76. package/tools/manifest/validate.mjs +19 -0
  77. package/tools/migrate-adr-store.mjs +5 -5
  78. package/tools/orchestration-config.mjs +1 -1
  79. package/tools/orchestration-write.mjs +1 -1
  80. package/tools/presentation.mjs +1 -1
  81. package/tools/procedures.mjs +3 -3
  82. package/tools/recipes.mjs +62 -11
  83. package/tools/recommendations.mjs +97 -14
  84. package/tools/release-scan.mjs +10 -3
  85. package/tools/renderers.mjs +1 -1
  86. package/tools/review-state.mjs +291 -343
  87. package/tools/run-gates.mjs +216 -92
  88. package/tools/sandbox-masks.mjs +14 -14
  89. package/tools/semver-lite.mjs +1 -1
  90. package/tools/set-autonomy.mjs +1 -1
  91. package/tools/set-recipe.mjs +2 -2
  92. package/tools/setup-backends.mjs +1 -1
  93. package/tools/surface.mjs +1 -1
  94. package/tools/uninstall.mjs +1 -1
  95. package/tools/velocity-profile.mjs +9 -9
  96. package/tools/view-model.mjs +1 -1
  97. package/tools/worktrees.mjs +2292 -0
  98. package/references/modes/fold-completeness.md +0 -30
  99. package/references/modes/review-ledger.md +0 -34
  100. package/references/templates/verification-profile.json +0 -10
  101. package/tools/fold-completeness-run.mjs +0 -1120
  102. package/tools/fold-completeness.mjs +0 -672
  103. package/tools/review-ledger-core.mjs +0 -428
  104. package/tools/review-ledger-write.mjs +0 -647
  105. package/tools/review-ledger.mjs +0 -630
  106. package/tools/sarif.mjs +0 -52
  107. package/tools/verification-profile.mjs +0 -219
@@ -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
+ });
@@ -0,0 +1,395 @@
1
+ #!/usr/bin/env node
2
+ // migrate-gates.mjs — the consented LEGACY gates.json migration (strip-the-kit D8). A deployment
3
+ // that predates the hardened core declares gates the stripped kit no longer ships (the
4
+ // review-ledger / fold-completeness checks); this tool migrates the declaration ATOMICALLY and
5
+ // COMPLETELY in one consented step: it REMOVES the canonical legacy entries, EXTENDS the
6
+ // canonical `unit-tests` cmd with the built-in lcov reporters (the D3(d) coverage source), and
7
+ // ADDS the coverage-check gate LAST (removal alone is not a migration — the result must satisfy
8
+ // `run-gates --final` and carry a working commit path).
9
+ //
10
+ // Matching is by the DOCUMENTED cmd forms ONLY (hand-wired history included — never by seed
11
+ // provenance): a legacy entry is `node <path>/review-ledger.mjs --check` or
12
+ // `node <path>/fold-completeness.mjs --check` (quoted or bare path, ONE plain invocation); the
13
+ // canonical unit-tests entry is id `unit-tests` with a cmd starting `node --test `. ANYTHING
14
+ // else is a CUSTOMIZED entry — never auto-touched: the preview reports it loudly and prints the
15
+ // paste-ready recovery so the maintainer applies the intent by hand; the commit guard is NOT to
16
+ // be installed until the declaration is final-run-capable.
17
+ //
18
+ // Write discipline (the family's consented-writer contract): preview (dry-run) is the DEFAULT
19
+ // and writes NOTHING; `--apply` rewrites docs/ai/gates.json via exclusive tmp+rename in the same
20
+ // directory; a symlinked gates.json (or docs/ai parent) is a STOP; a malformed declaration is
21
+ // never written over. `--kit-tools <dir>` names the installed kit's tools directory — the
22
+ // migration writes RESOLVED, QUOTED paths for the coverage-check gate (no runtime guessing).
23
+ //
24
+ // Exit codes: 0 done / preview / nothing-to-migrate; 1 precondition STOP; 2 usage.
25
+ // Dependency-free. No side effects on import.
26
+
27
+ import { existsSync, lstatSync, readFileSync, writeFileSync, renameSync, unlinkSync, realpathSync } from 'node:fs';
28
+ import { join, resolve, isAbsolute } from 'node:path';
29
+ import { pathToFileURL, fileURLToPath } from 'node:url';
30
+ import { randomBytes } from 'node:crypto';
31
+ import { spawnSync } from 'node:child_process';
32
+
33
+ export const MIGRATE_GATES_STOP = 'MIGRATE_GATES_STOP';
34
+ const stop = (message) =>
35
+ Object.assign(new Error(`[agent-workflow] ${message}`), { name: 'MigrateGatesStop', code: MIGRATE_GATES_STOP, exitCode: 1 });
36
+ const usageFail = (message) => Object.assign(new Error(`[agent-workflow] ${message}`), { exitCode: 2 });
37
+
38
+ export const GATES_REL = join('docs', 'ai', 'gates.json');
39
+
40
+ // The documented canonical forms — ONE plain invocation, quoted or bare path, `--check` at END
41
+ // (the run-gates strict-matcher shape; a masked/compound form is a CUSTOMIZED entry).
42
+ const legacyRe = (basename) => new RegExp(`^node\\s+(?:"(?:[^"]*[/\\\\])?${basename}"|(?:[^\\s"]*[/\\\\])?${basename})\\s+--check$`);
43
+ export const LEGACY_FORMS = Object.freeze([
44
+ { name: 'review-ledger', re: legacyRe('review-ledger\\.mjs') },
45
+ { name: 'fold-completeness', re: legacyRe('fold-completeness\\.mjs') },
46
+ ]);
47
+
48
+ // The D3(d) reporter flags the canonical unit-tests cmd gains — lcov at the fixed git-dir path
49
+ // run-gates --final exports, plus an explicit stdout reporter (without it the lcov reporter
50
+ // swallows the human TAP/spec stream).
51
+ export const UNIT_TESTS_COVERAGE_FLAGS =
52
+ '--experimental-test-coverage --test-reporter=lcov --test-reporter-destination="$AW_GIT_DIR/agent-workflow-lcov.info" --test-reporter=spec --test-reporter-destination=stdout';
53
+
54
+ // The RETIRED kit-owned git-dir stores the deleted machinery wrote — dead data a consumer's
55
+ // upgrade would otherwise strand forever. The migration cleans them (consented via the preview;
56
+ // ENOENT is a silent no-op; any other unlink error is reported loudly but never fails the
57
+ // migration — the stores are not a gate input).
58
+ export const RETIRED_STORE_BASENAMES = Object.freeze([
59
+ 'agent-workflow-review-ledger.jsonl',
60
+ 'agent-workflow-review-ledger.v5-orphans.jsonl',
61
+ 'agent-workflow-fold-completeness.jsonl',
62
+ ]);
63
+
64
+ const trueGitDir = (cwd) => {
65
+ const r = spawnSync('git', ['rev-parse', '--absolute-git-dir'], { cwd, encoding: 'utf8', windowsHide: true });
66
+ return r.error || r.status !== 0 ? null : r.stdout.replace(/\r?\n$/, '');
67
+ };
68
+
69
+ // findRetiredStores(cwd) → the absolute paths of retired stores PRESENT in the target's git dir.
70
+ export const findRetiredStores = (cwd) => {
71
+ const gitDir = trueGitDir(cwd);
72
+ if (gitDir == null) return [];
73
+ return RETIRED_STORE_BASENAMES.map((name) => join(gitDir, name)).filter((p) => existsSync(p));
74
+ };
75
+
76
+ const UNIT_TESTS_PREFIX = 'node --test ';
77
+
78
+ // The core-check forms the stripped core anchors on (same strict single-invocation shape as the
79
+ // legacy matcher). Canonicity is PURE path equality against the caller-named kit tools dir —
80
+ // an ABSOLUTE token resolving to the installed tool; run-gates --final does the live realpath
81
+ // check. A cmd that MATCHES the shape but resolves elsewhere (or relatively) is a LOOKALIKE —
82
+ // reported customized, never counted as the core check.
83
+ const CORE_CHECK_RE = { 'coverage-check': legacyRe('coverage-check\\.mjs'), 'review-state': legacyRe('review-state\\.mjs') };
84
+ const coreCheckToken = (cmd) => /^node\s+(?:"([^"]+)"|([^\s"]+))\s+--check$/.exec(cmd.trim())?.slice(1).find(Boolean) ?? null;
85
+ const samePath = (a, b) => {
86
+ try {
87
+ return realpathSync(a) === realpathSync(b);
88
+ } catch {
89
+ return resolve(a) === resolve(b); // an unresolvable side falls back to the lexical compare
90
+ }
91
+ };
92
+ const isCanonicalCoreCheck = (name, cmd, kitToolsDir) => {
93
+ if (!CORE_CHECK_RE[name].test(cmd.trim())) return false;
94
+ const token = coreCheckToken(cmd);
95
+ return token !== null && isAbsolute(token) && samePath(token, join(kitToolsDir, `${name}.mjs`));
96
+ };
97
+
98
+ // buildMigrationPlan(gates, kitToolsDir) → the PURE migration plan.
99
+ // plan rows: { action: 'keep' | 'remove' | 'extend' | 'move' | 'add', entry, reason }.
100
+ // finalCapable mirrors the run-gates --final acceptance shape: the canonical review-state check
101
+ // must be PRESENT (the checker itself is guaranteed last by the plan) — missing means the result
102
+ // is NOT final-run-capable and the preview says so loudly with the paste-ready candidate.
103
+ export const buildMigrationPlan = (gates, kitToolsDir) => {
104
+ const plan = [];
105
+ const customized = [];
106
+ let unitTestsExtended = false;
107
+ let checkerRow = null;
108
+ let hasReviewState = false;
109
+ const coverageCmd = `node "${join(kitToolsDir, 'coverage-check.mjs')}" --check`;
110
+ for (const gate of gates) {
111
+ const legacy = LEGACY_FORMS.find((f) => f.re.test(gate.cmd.trim()));
112
+ if (legacy) {
113
+ plan.push({ action: 'remove', entry: gate, reason: `the ${legacy.name} check died with its tool (strip-the-kit)` });
114
+ continue;
115
+ }
116
+ if (isCanonicalCoreCheck('coverage-check', gate.cmd, kitToolsDir)) {
117
+ checkerRow = { action: 'keep', entry: gate, reason: null };
118
+ plan.push(checkerRow);
119
+ continue;
120
+ }
121
+ if (isCanonicalCoreCheck('review-state', gate.cmd, kitToolsDir)) {
122
+ hasReviewState = true;
123
+ plan.push({ action: 'keep', entry: gate, reason: null });
124
+ continue;
125
+ }
126
+ if (gate.id === 'unit-tests') {
127
+ if (gate.cmd.includes(UNIT_TESTS_COVERAGE_FLAGS)) {
128
+ plan.push({ action: 'keep', entry: gate, reason: null }); // already fully configured
129
+ continue;
130
+ }
131
+ if (gate.cmd.startsWith(UNIT_TESTS_PREFIX) && !/--experimental-test-coverage|--test-reporter/.test(gate.cmd)) {
132
+ const extended = { ...gate, cmd: `node --test ${UNIT_TESTS_COVERAGE_FLAGS} ${gate.cmd.slice(UNIT_TESTS_PREFIX.length)}` };
133
+ plan.push({ action: 'extend', entry: extended, reason: 'the unit-tests cmd gains the built-in lcov reporters (the D3(d) coverage source)' });
134
+ unitTestsExtended = true;
135
+ continue;
136
+ }
137
+ // npm test / a wrapper / a partial flag set: the coverage contract cannot be VERIFIED on a
138
+ // cmd this tool does not understand — customized, never silently counted as configured.
139
+ customized.push(gate);
140
+ plan.push({ action: 'keep', entry: gate, reason: null });
141
+ continue;
142
+ }
143
+ // A dead-tool reference or a core-check LOOKALIKE in a non-canonical form — reported, never touched.
144
+ if (/review-ledger|fold-completeness|verification-profile|seed-gates|sarif|coverage-check\.mjs|review-state\.mjs/.test(gate.cmd)) {
145
+ customized.push(gate);
146
+ }
147
+ plan.push({ action: 'keep', entry: gate, reason: null });
148
+ }
149
+ const kept = plan.filter((r) => r.action === 'keep' || r.action === 'extend');
150
+ let collision = null;
151
+ if (checkerRow === null) {
152
+ // A surviving NON-canonical entry already holding the checker's id blocks the add — two
153
+ // `coverage-check` rows would be ambiguous; the customized entry must be resolved by hand
154
+ // FIRST (the caller turns this into a loud STOP on preview and apply alike).
155
+ if (kept.some((r) => r.entry.id === 'coverage-check')) {
156
+ collision = 'coverage-check';
157
+ } else {
158
+ plan.push({
159
+ action: 'add',
160
+ entry: { id: 'coverage-check', title: 'Changed-line coverage + red-proof verification (the final-run checker)', cmd: coverageCmd },
161
+ reason: 'run-gates --final requires the canonical checker as the LAST declared gate',
162
+ });
163
+ }
164
+ } else if (kept[kept.length - 1] !== checkerRow) {
165
+ checkerRow.action = 'move';
166
+ checkerRow.reason = 'the canonical checker must be the LAST declared gate (nothing may run after it consumed the lcov)';
167
+ }
168
+ const reviewStateCandidate = `{ "id": "review-state", "title": "Review receipts converged (D3(b))", "cmd": "node \\"${join(kitToolsDir, 'review-state.mjs')}\\" --check" }`;
169
+ return { plan, customized, unitTestsExtended, finalCapable: hasReviewState, reviewStateCandidate, collision };
170
+ };
171
+
172
+ export const resultingGates = (plan) => {
173
+ const kept = plan.filter((r) => r.action === 'keep' || r.action === 'extend').map((r) => r.entry);
174
+ const moved = plan.filter((r) => r.action === 'move').map((r) => r.entry);
175
+ const added = plan.filter((r) => r.action === 'add').map((r) => r.entry);
176
+ return [...kept, ...moved, ...added]; // move/add go LAST — the checker ends up the last declared gate
177
+ };
178
+
179
+ // The per-entry customized recovery: a unit-tests-class entry gets the FULL canonical flag set
180
+ // (the intent is known — the cmd shape just cannot be verified); anything else gets the
181
+ // dead-tool recovery.
182
+ const customizedRecovery = (gate) =>
183
+ gate.id === 'unit-tests'
184
+ ? `declare the canonical suite gate by hand so the coverage contract is verifiable: node --test ${UNIT_TESTS_COVERAGE_FLAGS} <your test paths>`
185
+ : 'remove the entry, or repoint it at a living check — the review-ledger / fold-completeness tools no longer exist.';
186
+
187
+ const warningLines = ({ customized, finalCapable, reviewStateCandidate }) => {
188
+ const lines = [];
189
+ for (const gate of customized) {
190
+ lines.push(` CUSTOMIZED (untouched): ${gate.id}: ${gate.cmd}`);
191
+ lines.push(` recovery (apply by hand if the intent still stands): ${customizedRecovery(gate)}`);
192
+ }
193
+ if (customized.length) {
194
+ lines.push(' IMPORTANT: do NOT install the commit guard until every customized entry above is resolved — a declaration that cannot pass run-gates --final would block every commit.');
195
+ }
196
+ if (!finalCapable) {
197
+ lines.push(' WARNING: the result is NOT final-run-capable — no canonical review-state check is declared. Add it (paste-ready), then run-gates --final can mint the receipt:');
198
+ lines.push(` ${reviewStateCandidate}`);
199
+ }
200
+ return lines;
201
+ };
202
+
203
+ export const formatPreview = (analysis, applyHint) => {
204
+ const { plan, unitTestsExtended, finalCapable, retiredStores = [] } = analysis;
205
+ const lines = ['[agent-workflow] legacy gates.json migration preview (dry-run — nothing was written):'];
206
+ const acted = plan.filter((r) => r.action !== 'keep');
207
+ for (const r of acted) {
208
+ lines.push(` ${r.action.toUpperCase()} ${r.entry.id}: ${r.entry.cmd} (${r.reason})`);
209
+ }
210
+ for (const p of retiredStores) {
211
+ lines.push(` CLEAN ${p} (a retired kit-owned store the deleted machinery wrote — dead data, unlinked on apply)`);
212
+ }
213
+ if (!acted.length && !retiredStores.length) {
214
+ lines.push(
215
+ finalCapable
216
+ ? ' nothing to migrate — no canonical legacy entries, no retired stores, and the declaration is already final-run-capable.'
217
+ : ' nothing to migrate mechanically — no canonical legacy entries and no retired stores; the warnings below still need a hand.',
218
+ );
219
+ }
220
+ if (!unitTestsExtended && !plan.some((r) => r.entry.id === 'unit-tests')) {
221
+ lines.push(' note: no canonical `unit-tests` entry found — declare your suite gate with the lcov reporters by hand (the coverage-check gate reads the file it produces).');
222
+ }
223
+ lines.push(...warningLines(analysis));
224
+ if (acted.length || retiredStores.length) {
225
+ lines.push(` apply exactly this migration: ${applyHint}`);
226
+ }
227
+ return lines.join('\n');
228
+ };
229
+
230
+ // The parent chain (docs, docs/ai) is lstat'd NO-FOLLOW: a symlinked parent means the write
231
+ // would land OUTSIDE the target project — a STOP on preview and apply alike. ENOENT reads as
232
+ // "no declaration" (a preview no-op); any OTHER lstat failure (EACCES, EIO) fails CLOSED —
233
+ // an unverifiable parent is never treated as safe. lstatFn is the injectable test seam.
234
+ const checkRealParents = (cwd, lstatFn) => {
235
+ for (const rel of ['docs', join('docs', 'ai')]) {
236
+ const p = join(cwd, rel);
237
+ let st;
238
+ try {
239
+ st = lstatFn(p);
240
+ } catch (err) {
241
+ if (err.code === 'ENOENT') return { missing: true };
242
+ throw stop(`${p}: cannot verify the declaration parent (${err.code ?? err.message}) — refusing to proceed (fail closed)`);
243
+ }
244
+ if (st.isSymbolicLink()) throw stop(`${p} is a symlink — refusing to read or write the declaration through a symlinked parent`);
245
+ if (!st.isDirectory()) throw stop(`${p} is not a real directory — fix the layout by hand`);
246
+ }
247
+ return { missing: false };
248
+ };
249
+
250
+ const loadDeclaration = (cwd, lstatFn = lstatSync) => {
251
+ const full = join(cwd, GATES_REL);
252
+ let leaf = null;
253
+ try {
254
+ leaf = lstatFn(full);
255
+ } catch (err) {
256
+ // ONLY "not there" reads as missing — an EACCES/EIO leaf must never let apply proceed
257
+ // (it would clean the retired stores while silently not migrating the declaration).
258
+ if (err.code === 'ENOENT') return { outcome: 'missing' };
259
+ throw stop(`${GATES_REL}: cannot lstat the declaration (${err.code ?? err.message}) — refusing to proceed (fail closed)`);
260
+ }
261
+ if (leaf.isSymbolicLink()) throw stop(`${GATES_REL} is a symlink — refusing to touch it`);
262
+ let parsed;
263
+ try {
264
+ parsed = JSON.parse(readFileSync(full, 'utf8'));
265
+ } catch (err) {
266
+ throw stop(`${GATES_REL}: malformed JSON (${err.message}) — fix it by hand; the migration never writes over a declaration it cannot parse`);
267
+ }
268
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed) || !Array.isArray(parsed.gates)) {
269
+ throw stop(`${GATES_REL}: not a { gates: [...] } declaration — fix it by hand`);
270
+ }
271
+ return { outcome: 'loaded', parsed };
272
+ };
273
+
274
+ // The parent chain is re-verified immediately before the tmp write AND again before the rename
275
+ // (an honest-effort TOCTOU guard — a parent swapped for a symlink mid-migration never gets
276
+ // written through).
277
+ const writeAtomic = (cwd, body, lstatFn) => {
278
+ const full = join(cwd, GATES_REL);
279
+ if (checkRealParents(cwd, lstatFn).missing) throw stop(`${GATES_REL}: the docs/ai parent vanished during the migration — nothing was written`);
280
+ const tmp = `${full}.${randomBytes(6).toString('hex')}.tmp`;
281
+ writeFileSync(tmp, body, { flag: 'wx' });
282
+ try {
283
+ if (checkRealParents(cwd, lstatFn).missing) throw stop(`${GATES_REL}: the docs/ai parent vanished during the migration — nothing was written`);
284
+ renameSync(tmp, full);
285
+ } catch (err) {
286
+ try { unlinkSync(tmp); } catch { /* best-effort tmp cleanup */ }
287
+ throw err;
288
+ }
289
+ return full;
290
+ };
291
+
292
+ export const parseArgs = (argv) => {
293
+ const args = { cwd: process.cwd(), apply: false, kitTools: null, help: false };
294
+ for (let i = 0; i < argv.length; i += 1) {
295
+ const a = argv[i];
296
+ if (a === '--help' || a === '-h') args.help = true;
297
+ else if (a === '--apply') args.apply = true;
298
+ else if (a === '--dry-run') args.apply = false;
299
+ else if (a === '--cwd') {
300
+ i += 1;
301
+ if (!argv[i]) throw usageFail('--cwd needs a directory');
302
+ args.cwd = argv[i];
303
+ } else if (a === '--kit-tools') {
304
+ i += 1;
305
+ if (!argv[i]) throw usageFail('--kit-tools needs the installed kit tools directory');
306
+ args.kitTools = argv[i];
307
+ } else throw usageFail(`unknown argument: ${a}`);
308
+ }
309
+ return args;
310
+ };
311
+
312
+ const HELP = `migrate-gates — the consented legacy gates.json migration (strip-the-kit D8).
313
+
314
+ Usage:
315
+ node migrate-gates.mjs --kit-tools <installed-kit-tools-dir> [--cwd <project>] [--apply]
316
+
317
+ Default is a dry-run PREVIEW (writes nothing). --apply rewrites ${GATES_REL} atomically:
318
+ canonical legacy entries (review-ledger / fold-completeness --check, matched by their documented
319
+ single-invocation forms) are REMOVED; the canonical unit-tests cmd gains the built-in lcov
320
+ reporters; the coverage-check gate is ADDED last (resolved, QUOTED path). Customized entries are
321
+ NEVER auto-touched — the preview names each with a paste-ready recovery, and the commit guard
322
+ must not be installed until they are resolved.`;
323
+
324
+ export const main = (argv = process.argv.slice(2), io = {}) => {
325
+ const out = io.log ?? console.log;
326
+ const err = io.error ?? console.error;
327
+ try {
328
+ const args = parseArgs(argv);
329
+ if (args.help) {
330
+ out(HELP);
331
+ return 0;
332
+ }
333
+ if (!args.kitTools) throw usageFail('--kit-tools <dir> is required (the installed kit tools directory — the migration writes resolved paths, never guesses)');
334
+ const kitTools = isAbsolute(args.kitTools) ? args.kitTools : resolve(args.cwd, args.kitTools);
335
+ // The kit path lands INSIDE a double-quoted bash cmd line — a path bash would interpret there
336
+ // ($ ` " \) is a STOP before any plan is built or byte written (never a silently broken gate).
337
+ if (/[$"`\\\r\n]/.test(kitTools)) {
338
+ throw stop(`--kit-tools resolves to ${kitTools} — the path contains shell metacharacters unsafe inside a double-quoted gate cmd ($ \` " \\); relocate the kit to a safe path`);
339
+ }
340
+ if (!existsSync(join(kitTools, 'coverage-check.mjs'))) {
341
+ throw stop(`--kit-tools ${kitTools} does not contain coverage-check.mjs — point it at the installed kit's tools directory`);
342
+ }
343
+ const lstatFn = io.lstat ?? lstatSync;
344
+ const parents = checkRealParents(args.cwd, lstatFn);
345
+ const retiredStores = findRetiredStores(args.cwd);
346
+ const declaration = parents.missing ? { outcome: 'missing' } : loadDeclaration(args.cwd, lstatFn);
347
+ if (declaration.outcome === 'missing' && !retiredStores.length) {
348
+ out(`[agent-workflow] no ${GATES_REL} and no retired stores — nothing to migrate.`);
349
+ return 0;
350
+ }
351
+ const parsed = declaration.outcome === 'loaded' ? declaration.parsed : { gates: [] };
352
+ const analysis = { ...buildMigrationPlan(parsed.gates, kitTools), retiredStores };
353
+ if (analysis.collision) {
354
+ throw stop(
355
+ `id collision — a NON-canonical entry already uses id "${analysis.collision}"; resolve it by hand first ` +
356
+ '(two rows under the checker id would be ambiguous — the canonical checker is never added alongside a customized twin)',
357
+ );
358
+ }
359
+ const applyHint = `node "${fileURLToPath(import.meta.url)}" --kit-tools "${kitTools}" --cwd "${args.cwd}" --apply`;
360
+ if (!args.apply) {
361
+ out(formatPreview(analysis, applyHint));
362
+ return 0;
363
+ }
364
+ const acted = analysis.plan.filter((r) => r.action !== 'keep');
365
+ if (acted.length && declaration.outcome === 'loaded') {
366
+ const merged = { ...parsed, gates: resultingGates(analysis.plan) };
367
+ const writtenPath = writeAtomic(args.cwd, `${JSON.stringify(merged, null, 2)}\n`, lstatFn);
368
+ out(`[agent-workflow] migrated ${writtenPath}: ${acted.map((r) => `${r.action} ${r.entry.id}`).join(', ')}`);
369
+ }
370
+ for (const p of retiredStores) {
371
+ try {
372
+ unlinkSync(p);
373
+ out(` cleaned ${p} (a retired kit-owned store)`);
374
+ } catch (e) {
375
+ if (e.code !== 'ENOENT') out(` could not clean ${p} (${e.code ?? e.message}) — dead data, remove it by hand; the migration itself is unaffected`);
376
+ }
377
+ }
378
+ // The warnings survive EVERY apply outcome — a no-op apply must still say what needs a hand.
379
+ for (const line of warningLines(analysis)) out(line);
380
+ if (!acted.length && !retiredStores.length) {
381
+ out(
382
+ analysis.finalCapable
383
+ ? '[agent-workflow] nothing to migrate — the declaration is already final-run-capable and no retired stores remain.'
384
+ : '[agent-workflow] nothing to migrate mechanically — no canonical legacy entries and no retired stores remain; the warnings above still need a hand.',
385
+ );
386
+ }
387
+ return 0;
388
+ } catch (e) {
389
+ err(e.message);
390
+ return e.exitCode ?? 1;
391
+ }
392
+ };
393
+
394
+ const isDirectRun = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href;
395
+ if (isDirectRun) process.exitCode = main();