@haposoft/cafekit 0.18.2 → 0.18.4

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 (141) hide show
  1. package/README.md +1 -1
  2. package/bin/__tests__/adv-skill.test.js +370 -0
  3. package/bin/__tests__/agent-authority.test.js +38 -0
  4. package/bin/__tests__/agent-instructions.test.js +54 -0
  5. package/bin/__tests__/claude-plugin-build.test.js +151 -0
  6. package/bin/__tests__/codex-hook-runtime-fixture.cjs +26 -0
  7. package/bin/__tests__/codex-hooks-ownership.test.js +8 -11
  8. package/bin/__tests__/codex-hooks.test.js +87 -85
  9. package/bin/__tests__/codex-implicit-policy.test.js +160 -0
  10. package/bin/__tests__/codex-native-hook-boundaries.test.js +2 -4
  11. package/bin/__tests__/codex-native.test.js +136 -2298
  12. package/bin/__tests__/codex-plugin-build.test.js +230 -0
  13. package/bin/__tests__/codex-plugin-context.test.js +135 -0
  14. package/bin/__tests__/codex-plugin-hooks.test.js +220 -0
  15. package/bin/__tests__/codex-quiet-hooks.test.js +2 -8
  16. package/bin/__tests__/codex-runtime-layout.test.js +81 -0
  17. package/bin/__tests__/codex-setup-agents.test.js +216 -0
  18. package/bin/__tests__/codex-skill-projections.test.js +101 -0
  19. package/bin/__tests__/develop-contract.test.js +100 -874
  20. package/bin/__tests__/legacy-hooks-retired.test.js +63 -13
  21. package/bin/__tests__/migrate-apply.test.js +373 -0
  22. package/bin/__tests__/migrate-cli.test.js +152 -0
  23. package/bin/__tests__/migrate-merged.test.js +269 -0
  24. package/bin/__tests__/migrate-plan.test.js +271 -0
  25. package/bin/__tests__/omp-bridge.test.js +81 -5
  26. package/bin/__tests__/omp-hooks.test.js +24 -0
  27. package/bin/__tests__/orca-session.test.js +2 -16
  28. package/bin/__tests__/package-inventory.test.js +228 -2044
  29. package/bin/__tests__/plugin-safety-keys.test.js +121 -0
  30. package/bin/__tests__/privacy-paths.test.js +184 -0
  31. package/bin/__tests__/secret-scanner.test.js +73 -0
  32. package/bin/__tests__/skill-routing-source.test.js +6 -0
  33. package/bin/__tests__/toaa-skill.test.js +195 -0
  34. package/bin/__tests__/usage-hook-retired.test.js +24 -6
  35. package/bin/__tests__/validator-grounding.test.js +0 -198
  36. package/bin/__tests__/vibe-run.test.js +215 -0
  37. package/bin/__tests__/vibe-skill.test.js +548 -0
  38. package/bin/__tests__/website-docs.test.js +58 -0
  39. package/bin/install.js +99 -1
  40. package/bin/lib/backup.js +43 -24
  41. package/bin/lib/codex-install.js +12 -3
  42. package/bin/lib/context.js +5 -0
  43. package/bin/lib/lock.js +98 -18
  44. package/bin/lib/managed-writer.js +16 -10
  45. package/bin/lib/migrate-apply.js +202 -0
  46. package/bin/lib/migrate-merged.js +342 -0
  47. package/bin/lib/migrate-plan.js +419 -0
  48. package/bin/lib/plugin-inventory.js +33 -0
  49. package/bin/phases/claude-runtime.js +5 -3
  50. package/bin/phases/codex-hooks.js +38 -0
  51. package/bin/phases/codex-runtime.js +106 -74
  52. package/bin/phases/copy-payload.js +49 -1
  53. package/bin/phases/omp-runtime.js +4 -26
  54. package/bin/phases/root-config.js +5 -2
  55. package/package.json +1 -1
  56. package/src/claude/CLAUDE.md +1 -1
  57. package/src/claude/agents/advisor.md +55 -0
  58. package/src/claude/agents/debugger.md +2 -3
  59. package/src/claude/agents/deployer.md +2 -1
  60. package/src/claude/agents/docs-keeper.md +12 -0
  61. package/src/claude/agents/inspector.md +1 -1
  62. package/src/claude/agents/project-manager.md +2 -2
  63. package/src/claude/agents/researcher.md +0 -1
  64. package/src/claude/agents/spec-maker.md +4 -1
  65. package/src/claude/hooks/__tests__/plugin-context.test.js +96 -0
  66. package/src/claude/hooks/__tests__/plugin-options.test.js +141 -0
  67. package/src/claude/hooks/__tests__/plugin-state-isolation.test.js +125 -0
  68. package/src/claude/hooks/__tests__/precompact.test.js +7 -1
  69. package/src/claude/hooks/__tests__/privacy-block.test.js +33 -35
  70. package/src/claude/hooks/__tests__/setup-statusline.test.js +179 -0
  71. package/src/claude/hooks/__tests__/spec-gate.test.js +86 -48
  72. package/src/claude/hooks/__tests__/state.test.js +21 -0
  73. package/src/claude/hooks/agent.cjs +5 -2
  74. package/src/claude/hooks/inspect-block.cjs +3 -1
  75. package/src/claude/hooks/lib/hook-state-dir.cjs +19 -1
  76. package/src/claude/hooks/lib/plugin-options.cjs +47 -0
  77. package/src/claude/hooks/lib/privacy-command-analysis.cjs +130 -11
  78. package/src/claude/hooks/lib/privacy-paths.cjs +370 -10
  79. package/src/claude/hooks/lib/spec-touch.cjs +14 -1
  80. package/src/claude/hooks/plugin-context.cjs +80 -0
  81. package/src/claude/hooks/privacy-block.cjs +2 -1
  82. package/src/claude/hooks/rules.cjs +5 -2
  83. package/src/claude/hooks/session.cjs +4 -1
  84. package/src/claude/hooks/spec-state.cjs +5 -4
  85. package/src/claude/hooks/state.cjs +27 -1
  86. package/src/claude/migration-manifest.json +7 -1
  87. package/src/claude/plugin/commands/setup-statusline.md +12 -0
  88. package/src/claude/plugin/core.md +71 -0
  89. package/src/claude/rules/manage-docs.md +1 -2
  90. package/src/claude/rules/review-audit-self-decision.md +1 -1
  91. package/src/claude/rules/skill-workflow-routing.md +1 -1
  92. package/src/claude/rules/state-sync.md +14 -0
  93. package/src/claude/rules/workflow.md +1 -1
  94. package/src/claude/scripts/generate-skill-catalog.cjs +5 -0
  95. package/src/claude/scripts/provenance.cjs +40 -16
  96. package/src/claude/scripts/scan-staged-secrets.cjs +49 -3
  97. package/src/claude/scripts/setup-statusline.cjs +88 -0
  98. package/src/claude/skills/adv/SKILL.md +139 -0
  99. package/src/claude/skills/adv/references/optional-modes.md +41 -0
  100. package/src/claude/skills/adv/references/ultra-mode.md +29 -0
  101. package/src/claude/skills/brainstorm/SKILL.md +1 -1
  102. package/src/claude/skills/code-review/SKILL.md +4 -1
  103. package/src/claude/skills/code-review/references/pr-fix-reply.md +42 -0
  104. package/src/claude/skills/develop/SKILL.md +11 -3
  105. package/src/claude/skills/develop/references/quality-gate.md +1 -1
  106. package/src/claude/skills/fix/SKILL.md +2 -2
  107. package/src/claude/skills/git/SKILL.md +4 -2
  108. package/src/claude/skills/git/references/merge-pr.md +125 -0
  109. package/src/claude/skills/specs/SKILL.md +2 -2
  110. package/src/claude/skills/specs/references/review.md +1 -1
  111. package/src/claude/skills/specs/references/templates.md +2 -0
  112. package/src/claude/skills/sync/SKILL.md +4 -0
  113. package/src/claude/skills/toaa/SKILL.md +82 -0
  114. package/src/claude/skills/toaa/references/autonomous-orchestration.md +54 -0
  115. package/src/claude/skills/toaa/references/prerequisites-checklist.md +37 -0
  116. package/src/claude/skills/vibe/SKILL.md +69 -0
  117. package/src/claude/skills/vibe/references/pipeline-skill-integration.md +34 -0
  118. package/src/claude/skills/vibe/references/report-templates.md +84 -0
  119. package/src/claude/skills/vibe/references/subagent-orchestration.md +23 -0
  120. package/src/codex/AGENTS.md +1 -1
  121. package/src/codex/hooks/inspect-block.cjs +7 -4
  122. package/src/codex/hooks/lib/hook-context.cjs +119 -4
  123. package/src/codex/hooks/lib/plugin-options.cjs +2 -0
  124. package/src/codex/hooks/lib/privacy-state.cjs +18 -1
  125. package/src/codex/hooks/lib/state-store.cjs +31 -6
  126. package/src/codex/hooks/precompact.cjs +9 -8
  127. package/src/codex/hooks/privacy-approval.cjs +2 -2
  128. package/src/codex/hooks/privacy-block.cjs +5 -4
  129. package/src/codex/hooks/rules.cjs +12 -6
  130. package/src/codex/hooks/session.cjs +22 -10
  131. package/src/codex/plugin/core.md +76 -0
  132. package/src/codex/plugin/hooks/plugin-context.cjs +63 -0
  133. package/src/codex/plugin/skills/setup-agents/SKILL.md +17 -0
  134. package/src/codex/scripts/setup-agents.cjs +124 -0
  135. package/src/common/AGENTS.md +1 -1
  136. package/src/omp/extensions/{cafekit-bridge.mjs → cafekit-bridge.js} +73 -5
  137. package/src/omp/hooks/privacy-block.cjs +2 -1
  138. package/bin/__tests__/codex-research-projection.test.js +0 -70
  139. package/bin/__tests__/codex-specs-projection.test.js +0 -76
  140. package/bin/__tests__/specs-v2-execution-closeout.test.js +0 -58
  141. package/src/claude/skills/chrome-devtools/scripts/__tests__/error-handling.test.js +0 -102
@@ -0,0 +1,269 @@
1
+ 'use strict';
2
+
3
+ const { test, before, after } = require('node:test');
4
+ const assert = require('node:assert/strict');
5
+ const fs = require('node:fs');
6
+ const os = require('node:os');
7
+ const path = require('node:path');
8
+ const { spawnSync } = require('node:child_process');
9
+
10
+ const { planManifestFiles } = require('../lib/migrate-plan');
11
+ const { planMergedFiles, renderMergedPlan } = require('../lib/migrate-merged');
12
+ const { managedHandlerScript } = require('../phases/codex-hooks');
13
+
14
+ const INSTALLER = path.join(__dirname, '../install.js');
15
+ const SETTINGS_TEMPLATE = require('../../src/claude/settings/settings.json');
16
+ const TEMPLATE_COMMANDS = new Set(Object.values(SETTINGS_TEMPLATE.hooks).flat().flatMap((group) => group.hooks.map((handler) => handler.command)));
17
+ const USER_LINT = 'node "$CLAUDE_PROJECT_DIR/.claude/hooks/user-lint.cjs"';
18
+
19
+ let work;
20
+ let pristine;
21
+ let fixture;
22
+ let codexOnly;
23
+
24
+ function copyRepo(from, name) {
25
+ const target = path.join(work, name, 'repo');
26
+ fs.cpSync(from, target, { recursive: true, verbatimSymlinks: true });
27
+ return target;
28
+ }
29
+
30
+ function install(repo, platforms) {
31
+ fs.mkdirSync(repo, { recursive: true });
32
+ spawnSync('git', ['init', '-q'], { cwd: repo });
33
+ // The installer writes into its cwd, so it must run inside the temp repository.
34
+ const run = spawnSync(process.execPath, [INSTALLER, '--platform', platforms, '--with-document-skills', '-y'], {
35
+ cwd: repo,
36
+ env: { ...process.env, HOME: path.join(work, 'home'), CI: '1' },
37
+ encoding: 'utf8'
38
+ });
39
+ assert.equal(run.status, 0, run.stderr || run.stdout);
40
+ }
41
+
42
+ function applyPlan(repo, manifest, merged) {
43
+ for (const entry of manifest.entries.filter((item) => item.action === 'remove')) fs.rmSync(path.join(repo, entry.path));
44
+ for (const file of merged.files) {
45
+ if (file.action === 'write') fs.writeFileSync(path.join(repo, file.path), file.after);
46
+ if (file.action === 'remove') fs.rmSync(path.join(repo, file.path));
47
+ }
48
+ }
49
+
50
+ function plan(repo) {
51
+ const manifest = planManifestFiles(repo);
52
+ return { manifest, merged: planMergedFiles(repo, manifest) };
53
+ }
54
+
55
+ const fileOf = (merged, rel) => merged.files.find((file) => file.path === rel);
56
+ const handlers = (config) => Object.values(config.hooks || {}).flat().flatMap((group) => group.hooks);
57
+
58
+ before(() => {
59
+ work = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'ck-migrate-merged-')));
60
+ fs.mkdirSync(path.join(work, 'home'));
61
+ pristine = path.join(work, 'pristine', 'repo');
62
+ install(pristine, 'claude,codex');
63
+ codexOnly = path.join(work, 'codex-only', 'repo');
64
+ install(codexOnly, 'codex');
65
+
66
+ fixture = copyRepo(pristine, 'fixture');
67
+ const settingsFile = path.join(fixture, '.claude/settings.json');
68
+ const settings = JSON.parse(fs.readFileSync(settingsFile, 'utf8'));
69
+ settings.hooks.PreToolUse.push({ matcher: 'Edit', hooks: [{ type: 'command', command: USER_LINT }] });
70
+ fs.writeFileSync(settingsFile, JSON.stringify(settings, null, 2));
71
+ fs.writeFileSync(path.join(fixture, '.claude/hooks/user-lint.cjs'), 'process.exit(0);\n');
72
+
73
+ const hooksFile = path.join(fixture, '.codex/hooks.json');
74
+ const hooks = JSON.parse(fs.readFileSync(hooksFile, 'utf8'));
75
+ hooks.hooks.Stop.push({ hooks: [{ type: 'command', command: `node '${path.join(fixture, '.codex/hooks/my.cjs')}'` }] });
76
+ fs.writeFileSync(hooksFile, JSON.stringify(hooks, null, 2));
77
+ fs.writeFileSync(path.join(fixture, '.codex/hooks/my.cjs'), 'process.exit(0);\n');
78
+
79
+ const agentsFile = path.join(fixture, 'AGENTS.md');
80
+ fs.writeFileSync(agentsFile, `# Team rules before\n\n${fs.readFileSync(agentsFile, 'utf8')}\n## Team notes after\n`);
81
+ const gitignoreFile = path.join(fixture, '.gitignore');
82
+ fs.writeFileSync(gitignoreFile, fs.readFileSync(gitignoreFile, 'utf8').replace('.agents/\n', '.agents/\nmy-local-secrets.txt\n'));
83
+ fs.writeFileSync(path.join(fixture, '.claude/.env'), 'TOKEN=[redacted]\n');
84
+ });
85
+
86
+ after(() => fs.rmSync(work, { recursive: true, force: true }));
87
+
88
+ test('claude-user-hook-kept: a user hook script and its registration survive', () => {
89
+ const { manifest, merged } = plan(fixture);
90
+ const settings = JSON.parse(fileOf(merged, '.claude/settings.json').after);
91
+ assert.ok(handlers(settings).some((handler) => handler.command === USER_LINT));
92
+ assert.ok(manifest.untracked.includes('.claude/hooks/user-lint.cjs'));
93
+ });
94
+
95
+ test('cafekit-handlers-dropped: no CafeKit handler and no empty container remain', () => {
96
+ const { merged } = plan(fixture);
97
+ const settingsFile = fileOf(merged, '.claude/settings.json');
98
+ assert.equal(settingsFile.action, 'write');
99
+ const settings = JSON.parse(settingsFile.after);
100
+ assert.deepEqual(handlers(settings).map((handler) => handler.command), [USER_LINT]);
101
+ assert.ok(!handlers(settings).some((handler) => TEMPLATE_COMMANDS.has(handler.command)));
102
+ for (const config of [settings, JSON.parse(fileOf(merged, '.codex/hooks.json').after)]) {
103
+ for (const [event, groups] of Object.entries(config.hooks || {})) {
104
+ assert.ok(groups.length > 0, `${event} left empty`);
105
+ for (const group of groups) assert.ok(group.hooks.length > 0, `${event} group left empty`);
106
+ }
107
+ assert.ok(!handlers(config).some((handler) => managedHandlerScript(handler)));
108
+ }
109
+ // Without user hooks nothing is left for Codex, so its hooks.json goes.
110
+ assert.equal(fileOf(plan(pristine).merged, '.codex/hooks.json').action, 'remove');
111
+ });
112
+
113
+ test('dangling-handler-reported: a kept handler that still runs a removed hook is reported', () => {
114
+ const repo = copyRepo(fixture, 'dangling');
115
+ const settingsFile = path.join(repo, '.claude/settings.json');
116
+ const settings = JSON.parse(fs.readFileSync(settingsFile, 'utf8'));
117
+ const strict = 'node "$CLAUDE_PROJECT_DIR/.claude/hooks/privacy-block.cjs" --strict';
118
+ settings.hooks.Stop.push({ hooks: [{ type: 'command', command: strict }] });
119
+ fs.writeFileSync(settingsFile, JSON.stringify(settings, null, 2));
120
+ const { merged } = plan(repo);
121
+ assert.ok(handlers(JSON.parse(fileOf(merged, '.claude/settings.json').after)).some((handler) => handler.command === strict));
122
+ assert.ok(merged.reports.some((report) => report.includes('privacy-block.cjs') && report.includes('still runs')));
123
+ });
124
+
125
+ test('statusline-rule: the status line goes only with status.cjs', () => {
126
+ const { merged } = plan(fixture);
127
+ assert.equal(JSON.parse(fileOf(merged, '.claude/settings.json').after).statusLine, undefined);
128
+ assert.ok(merged.reports.some((report) => report.includes('/cf:setup-statusline')));
129
+
130
+ const edited = copyRepo(fixture, 'status-edited');
131
+ fs.appendFileSync(path.join(edited, '.claude/status.cjs'), '\n// my tweak\n');
132
+ const kept = plan(edited).merged;
133
+ assert.deepEqual(JSON.parse(fileOf(kept, '.claude/settings.json').after).statusLine, SETTINGS_TEMPLATE.statusLine);
134
+ assert.ok(!kept.reports.some((report) => report.includes('/cf:setup-statusline')));
135
+ });
136
+
137
+ test('outputstyle-unchanged: the selected output style stays as written', () => {
138
+ const { merged } = plan(fixture);
139
+ const settingsFile = fileOf(merged, '.claude/settings.json');
140
+ assert.equal(JSON.parse(settingsFile.after).outputStyle, JSON.parse(settingsFile.before).outputStyle);
141
+ });
142
+
143
+ test('codex-standdown-reported: a user handler under .codex/hooks survives and is reported', () => {
144
+ const { merged } = plan(fixture);
145
+ const hooksFile = fileOf(merged, '.codex/hooks.json');
146
+ const commands = handlers(JSON.parse(hooksFile.after)).map((handler) => handler.command);
147
+ const user = `node '${path.join(fixture, '.codex/hooks/my.cjs')}'`;
148
+ assert.deepEqual(commands, [user]);
149
+ assert.deepEqual(hooksFile.standDown, [user]);
150
+ assert.ok(merged.reports.some((report) => report.includes('keeps standing down') && report.includes('my.cjs')));
151
+ assert.deepEqual(fileOf(plan(pristine).merged, '.codex/hooks.json').standDown, []);
152
+
153
+ // Like the runtime check, a handler script that links out of .codex/hooks does not count.
154
+ const linked = copyRepo(fixture, 'linked-handler');
155
+ const outside = path.join(work, 'linked-handler', 'my.cjs');
156
+ fs.renameSync(path.join(linked, '.codex/hooks/my.cjs'), outside);
157
+ fs.symlinkSync(outside, path.join(linked, '.codex/hooks/my.cjs'));
158
+ const linkedHooks = JSON.parse(fs.readFileSync(path.join(linked, '.codex/hooks.json'), 'utf8'));
159
+ for (const group of linkedHooks.hooks.Stop) for (const handler of group.hooks) handler.command = handler.command.replace(fixture, linked);
160
+ fs.writeFileSync(path.join(linked, '.codex/hooks.json'), JSON.stringify(linkedHooks));
161
+ assert.deepEqual(fileOf(plan(linked).merged, '.codex/hooks.json').standDown, []);
162
+ });
163
+
164
+ test('agents-import-kept: AGENTS.md keeps the user text and CLAUDE.md keeps importing it', () => {
165
+ const { merged } = plan(fixture);
166
+ const agents = fileOf(merged, 'AGENTS.md');
167
+ assert.equal(agents.action, 'write');
168
+ assert.match(agents.after, /^# Team rules before/);
169
+ assert.match(agents.after, /## Team notes after\n$/);
170
+ assert.doesNotMatch(agents.after, /CAFEKIT/);
171
+ assert.ok(merged.reports.some((report) => report.includes('response-language')));
172
+ const claude = fileOf(merged, 'CLAUDE.md');
173
+ assert.equal(claude.action, 'write');
174
+ assert.match(claude.after, /^@AGENTS\.md$/m);
175
+ assert.doesNotMatch(claude.after, /CAFEKIT/);
176
+ // Removing a block keeps the user's indentation and the file's own line endings.
177
+ const crlf = copyRepo(fixture, 'crlf');
178
+ const crlfFile = path.join(crlf, 'AGENTS.md');
179
+ fs.writeFileSync(crlfFile, `${fs.readFileSync(path.join(pristine, 'AGENTS.md'), 'utf8')}\n indented code\n`.replace(/\n/g, '\r\n'));
180
+ const crlfAfter = fileOf(plan(crlf).merged, 'AGENTS.md').after;
181
+ assert.equal(crlfAfter, ' indented code\r\n');
182
+
183
+ // With no user text anywhere both files go.
184
+ assert.equal(fileOf(plan(pristine).merged, 'AGENTS.md').action, 'remove');
185
+ assert.equal(fileOf(plan(pristine).merged, 'CLAUDE.md').action, 'remove');
186
+ });
187
+
188
+ test('env-stays-ignored: after the planned removals .claude/.env is still ignored', () => {
189
+ const repo = copyRepo(fixture, 'applied');
190
+ // Only the root `.claude/` line covers this file; the nested .claude/.gitignore does not.
191
+ fs.writeFileSync(path.join(repo, '.claude/team-notes.txt'), 'notes\n');
192
+ const { manifest, merged } = plan(repo);
193
+ applyPlan(repo, manifest, merged);
194
+ // The developer's own global ignore file must not decide the result.
195
+ const env = { ...process.env, HOME: path.join(work, 'home'), XDG_CONFIG_HOME: path.join(work, 'home'), GIT_CONFIG_GLOBAL: '/dev/null', GIT_CONFIG_NOSYSTEM: '1' };
196
+ const ignored = (at, rel) => spawnSync('git', ['check-ignore', '-q', rel], { cwd: at, env }).status === 0;
197
+ assert.ok(ignored(repo, '.claude/.env'));
198
+ assert.ok(ignored(repo, '.claude/team-notes.txt'));
199
+ assert.ok(ignored(repo, 'my-local-secrets.txt'));
200
+
201
+ // A folder migrate leaves alone keeps its line: here no Claude install exists at all.
202
+ const onlyCodex = copyRepo(codexOnly, 'codex-only-applied');
203
+ fs.mkdirSync(path.join(onlyCodex, '.claude'));
204
+ fs.writeFileSync(path.join(onlyCodex, '.claude/.env'), 'TOKEN=[redacted]\n');
205
+ const codexPlan = plan(onlyCodex);
206
+ assert.equal(codexPlan.manifest.platforms.claude.status, 'absent');
207
+ applyPlan(onlyCodex, codexPlan.manifest, codexPlan.merged);
208
+ assert.ok(ignored(onlyCodex, '.claude/.env'));
209
+
210
+ // ...and here the Claude install is refused, so its whole folder stays.
211
+ const refused = copyRepo(fixture, 'claude-refused');
212
+ fs.writeFileSync(path.join(refused, '.claude/cafekit-manifest.json'), '{ "files": ');
213
+ fs.writeFileSync(path.join(refused, '.claude/team-notes.txt'), 'notes\n');
214
+ const refusedPlan = plan(refused);
215
+ assert.equal(refusedPlan.manifest.platforms.claude.status, 'refused');
216
+ assert.ok(!fileOf(refusedPlan.merged, '.gitignore').removedLines.includes('.claude/'));
217
+ applyPlan(refused, refusedPlan.manifest, refusedPlan.merged);
218
+ assert.ok(ignored(refused, '.claude/team-notes.txt'));
219
+ });
220
+
221
+ test('gitignore-user-line-kept: a user line right after the CafeKit block stays', () => {
222
+ const { merged } = plan(fixture);
223
+ const gitignore = fileOf(merged, '.gitignore');
224
+ const lines = gitignore.after.split('\n');
225
+ assert.ok(lines.includes('my-local-secrets.txt'));
226
+ for (const kept of ['.cafekit-backup/', '.cafekit.lock', 'specs/_shared/', '.omp/', 'plans/*', '.claude/']) assert.ok(lines.includes(kept), kept);
227
+
228
+ // Once nothing stays under .agents/, only its line leaves the block.
229
+ const repo = copyRepo(fixture, 'agents-gone');
230
+ const manifest = planManifestFiles(repo);
231
+ for (const entry of manifest.entries) {
232
+ if (entry.path.startsWith('.agents/')) Object.assign(entry, { action: 'remove', reason: null });
233
+ }
234
+ const gone = fileOf(planMergedFiles(repo, manifest), '.gitignore');
235
+ assert.deepEqual(gone.removedLines, ['.agents/']);
236
+ assert.ok(gone.after.split('\n').includes('my-local-secrets.txt'));
237
+ });
238
+
239
+ test('omp-refused: a repository with an omp install is refused', () => {
240
+ const repo = copyRepo(fixture, 'omp');
241
+ fs.mkdirSync(path.join(repo, '.omp'));
242
+ fs.writeFileSync(path.join(repo, '.omp/cafekit-manifest.json'), '{"files":{}}');
243
+ const { merged } = plan(repo);
244
+ assert.match(merged.refused, /^omp/);
245
+ assert.match(renderMergedPlan(merged), /Refused — omp/);
246
+ assert.ok(merged.reports.some((report) => report.includes('Grok')));
247
+ });
248
+
249
+ test('markers-unparsed: duplicated or unbalanced markers leave the file untouched', () => {
250
+ const repo = copyRepo(fixture, 'markers');
251
+ const agentsFile = path.join(repo, 'AGENTS.md');
252
+ fs.appendFileSync(agentsFile, '\n<!-- CAFEKIT CORE START -->\n');
253
+ const claudeFile = path.join(repo, 'CLAUDE.md');
254
+ fs.writeFileSync(claudeFile, fs.readFileSync(claudeFile, 'utf8').replace('<!-- CAFEKIT CLAUDE END -->', ''));
255
+ const { merged } = plan(repo);
256
+ for (const rel of ['AGENTS.md', 'CLAUDE.md']) {
257
+ const file = fileOf(merged, rel);
258
+ assert.equal(file.action, 'unparsed', rel);
259
+ assert.equal(file.after, file.before, rel);
260
+ }
261
+
262
+ const broken = copyRepo(fixture, 'broken-json');
263
+ fs.writeFileSync(path.join(broken, '.claude/settings.json'), '{ "hooks": ');
264
+ const result = plan(broken);
265
+ assert.equal(fileOf(result.merged, '.claude/settings.json').action, 'unparsed');
266
+ // An unparsable settings file may register any hook script or the status line, so none of them goes.
267
+ assert.ok(result.manifest.entries.filter((entry) => entry.path.startsWith('.claude/hooks/')).every((entry) => entry.action !== 'remove'));
268
+ assert.equal(result.manifest.entries.find((entry) => entry.path === '.claude/status.cjs').action, 'keep');
269
+ });
@@ -0,0 +1,271 @@
1
+ 'use strict';
2
+
3
+ const { test, before, after } = require('node:test');
4
+ const assert = require('node:assert/strict');
5
+ const crypto = require('node:crypto');
6
+ const fs = require('node:fs');
7
+ const os = require('node:os');
8
+ const path = require('node:path');
9
+ const { spawnSync } = require('node:child_process');
10
+
11
+ const { planManifestFiles, renderManifestPlan } = require('../lib/migrate-plan');
12
+ const { EXCLUDED_SKILLS, EXCLUDED_AGENTS } = require('../lib/plugin-inventory');
13
+ const { codexAgentName } = require('../lib/codex-install');
14
+ const { sha256 } = require('../lib/manifest');
15
+
16
+ const INSTALLER = path.join(__dirname, '../install.js');
17
+ const DOCUMENT_SKILLS = require('../../src/claude/migration-manifest.json').skills.bundles.documentSkills;
18
+ const NPX_ONLY_SKILLS = [...EXCLUDED_SKILLS, ...DOCUMENT_SKILLS];
19
+
20
+ let work;
21
+ let pristine;
22
+
23
+ function copyFixture(name) {
24
+ const target = path.join(work, name, 'repo');
25
+ fs.cpSync(pristine, target, { recursive: true, verbatimSymlinks: true });
26
+ return target;
27
+ }
28
+
29
+ function treeHash(root) {
30
+ const hash = crypto.createHash('sha256');
31
+ const visit = (rel) => {
32
+ const abs = path.join(root, rel);
33
+ const stat = fs.lstatSync(abs);
34
+ if (stat.isSymbolicLink()) hash.update(`L ${rel} ${fs.readlinkSync(abs)}\n`);
35
+ else if (stat.isDirectory()) for (const name of fs.readdirSync(abs).sort()) visit(path.join(rel, name));
36
+ else hash.update(`F ${rel} ${sha256(fs.readFileSync(abs))}\n`);
37
+ };
38
+ visit('.');
39
+ return hash.digest('hex');
40
+ }
41
+
42
+ const byPath = (plan) => new Map(plan.entries.map((entry) => [entry.path, entry]));
43
+ const ofPlatform = (plan, platform) => plan.entries.filter((entry) => entry.platform === platform);
44
+
45
+ before(() => {
46
+ work = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'ck-migrate-plan-')));
47
+ pristine = path.join(work, 'pristine', 'repo');
48
+ fs.mkdirSync(pristine, { recursive: true });
49
+ fs.mkdirSync(path.join(work, 'home'));
50
+ spawnSync('git', ['init', '-q'], { cwd: pristine });
51
+ // The installer writes into its cwd, so it must run inside the temp repository.
52
+ const run = spawnSync(process.execPath, [INSTALLER, '--platform', 'claude,codex', '--with-document-skills', '-y'], {
53
+ cwd: pristine,
54
+ env: { ...process.env, HOME: path.join(work, 'home'), CI: '1' },
55
+ encoding: 'utf8'
56
+ });
57
+ assert.equal(run.status, 0, run.stderr || run.stdout);
58
+ });
59
+
60
+ after(() => fs.rmSync(work, { recursive: true, force: true }));
61
+
62
+ test('pristine-removed-count: every Codex key is removed except runtime, npx-only, required and ignore files', () => {
63
+ const plan = planManifestFiles(pristine);
64
+ const keys = Object.keys(JSON.parse(fs.readFileSync(path.join(pristine, '.codex/cafekit-manifest.json'), 'utf8')).files);
65
+ const codex = ofPlatform(plan, 'codex');
66
+ assert.equal(codex.length, keys.length);
67
+ // Codex keys are relative to the repository root, not to .codex/.
68
+ assert.deepEqual(codex.map((entry) => entry.path).sort(), [...keys].sort());
69
+
70
+ const npxOnly = keys.filter((key) => NPX_ONLY_SKILLS.some((name) => key.startsWith(`.agents/skills/${name}/`))
71
+ || EXCLUDED_AGENTS.some((name) => key === `.codex/agents/${codexAgentName(name)}.toml`));
72
+ const runtime = keys.filter((key) => key === '.codex/runtime.json' || key === '.codex/runtime.schema.json');
73
+ const counted = (reason) => codex.filter((entry) => entry.reason === reason).length;
74
+ assert.equal(counted('npx-only'), npxOnly.length);
75
+ assert.equal(counted('runtime'), runtime.length);
76
+ for (const entry of codex.filter((item) => item.action !== 'remove')) {
77
+ assert.ok(['runtime', 'npx-only', 'required-by-kept', 'protects-kept'].includes(entry.reason), `${entry.path}: ${entry.reason}`);
78
+ }
79
+ const removed = codex.filter((entry) => entry.action === 'remove').length;
80
+ assert.equal(removed, keys.length - runtime.length - npxOnly.length - counted('required-by-kept') - counted('protects-kept'));
81
+ assert.ok(removed > 100, `only ${removed} Codex files removed`);
82
+
83
+ for (const entry of ofPlatform(plan, 'claude')) {
84
+ if (entry.action === 'remove') continue;
85
+ assert.ok(['runtime', 'npx-only', 'required-by-kept', 'protects-kept'].includes(entry.reason), `${entry.path}: ${entry.reason}`);
86
+ }
87
+ assert.deepEqual(plan.untracked, []);
88
+ });
89
+
90
+ test('runtime-kept: runtime.json and its schema stay for both platforms', () => {
91
+ const entries = byPath(planManifestFiles(pristine));
92
+ for (const rel of ['.claude/runtime.json', '.claude/runtime.schema.json', '.codex/runtime.json', '.codex/runtime.schema.json']) {
93
+ assert.equal(entries.get(rel).action, 'keep', rel);
94
+ assert.equal(entries.get(rel).reason, 'runtime', rel);
95
+ }
96
+ });
97
+
98
+ test('npx-only-kept: skills and agents no plugin ships stay whole', () => {
99
+ const plan = planManifestFiles(pristine);
100
+ for (const name of ['chrome-devtools', 'ui-ux', 'ui-ux-pro-max']) {
101
+ for (const prefix of [`.claude/skills/${name}/`, `.agents/skills/${name}/`]) {
102
+ const members = plan.entries.filter((entry) => entry.path.startsWith(prefix));
103
+ assert.ok(members.length > 0, prefix);
104
+ assert.ok(members.every((entry) => entry.reason === 'npx-only'), prefix);
105
+ }
106
+ }
107
+ const entries = byPath(plan);
108
+ assert.equal(entries.get('.claude/agents/ui-ux-designer.md').reason, 'npx-only');
109
+ assert.equal(entries.get('.codex/agents/ui_ux_designer.toml').reason, 'npx-only');
110
+ assert.equal(entries.get('.claude/agents/debugger.md').action, 'remove');
111
+ });
112
+
113
+ test('kept-skill-deps-kept: shared files a kept skill names stay, transitively', () => {
114
+ const entries = byPath(planManifestFiles(pristine));
115
+ for (const rel of ['.claude/scripts/validate-docs.cjs', '.claude/scripts/validate-docs-reconstruct.cjs', '.claude/hooks/docs-sync.cjs',
116
+ '.codex/scripts/validate-docs.cjs', '.codex/hooks/docs-sync.cjs']) {
117
+ assert.equal(entries.get(rel).reason, 'required-by-kept', rel);
118
+ }
119
+ // docs-sync.cjs requires ./lib/config.cjs, which requires ./runtime-dir.cjs.
120
+ assert.equal(entries.get('.claude/hooks/lib/config.cjs').reason, 'required-by-kept');
121
+ assert.equal(entries.get('.claude/hooks/lib/runtime-dir.cjs').reason, 'required-by-kept');
122
+ assert.equal(entries.get('.claude/scripts/provenance.cjs').action, 'remove');
123
+
124
+ // A user hook or skill the plugins do not replace keeps what it requires or runs.
125
+ const repo = copyFixture('user-deps');
126
+ fs.writeFileSync(path.join(repo, '.claude/hooks/my-hook.cjs'), "require('./lib/project-root.cjs');\n");
127
+ fs.mkdirSync(path.join(repo, '.claude/skills/my-skill'));
128
+ fs.writeFileSync(path.join(repo, '.claude/skills/my-skill/SKILL.md'), 'Run `node .claude\\scripts\\spec-receipt.cjs`.\n');
129
+ fs.mkdirSync(path.join(repo, '.claude/hooks/.logs'));
130
+ fs.writeFileSync(path.join(repo, '.claude/hooks/.logs/run.log'), 'ran .claude/scripts/workflow-policy.cjs\n');
131
+ const linkedSkill = path.join(work, 'user-deps', 'linked-skill');
132
+ fs.mkdirSync(linkedSkill);
133
+ fs.writeFileSync(path.join(linkedSkill, 'SKILL.md'), 'Run `node .claude/scripts/scan-staged-secrets.cjs`.\n');
134
+ fs.symlinkSync(linkedSkill, path.join(repo, '.claude/skills/linked-skill'), 'dir');
135
+ fs.writeFileSync(path.join(repo, '.claude/settings.local.json'),
136
+ JSON.stringify({ hooks: { Stop: [{ hooks: [{ type: 'command', command: 'node .claude\\hooks\\spec-gate.cjs' }] }] } }));
137
+ fs.writeFileSync(path.join(linkedSkill, 'agent.md'), 'Uses `.claude/scripts/setup-statusline.cjs`.\n');
138
+ fs.symlinkSync(path.join(linkedSkill, 'agent.md'), path.join(repo, '.claude/agents/my-agent.md'));
139
+ fs.writeFileSync(path.join(repo, '.claude/skills/scout/.DS_Store'), '');
140
+ const user = byPath(planManifestFiles(repo));
141
+ assert.equal(user.get('.claude/scripts/scan-staged-secrets.cjs').reason, 'required-by-kept');
142
+ assert.equal(user.get('.claude/hooks/spec-gate.cjs').reason, 'required-by-kept');
143
+ assert.equal(user.get('.claude/scripts/setup-statusline.cjs').reason, 'required-by-kept');
144
+ assert.equal(user.get('.claude/skills/scout/SKILL.md').action, 'remove');
145
+ assert.equal(user.get('.claude/hooks/lib/project-root.cjs').reason, 'required-by-kept');
146
+ assert.equal(user.get('.claude/scripts/spec-receipt.cjs').reason, 'required-by-kept');
147
+ assert.equal(user.get('.claude/scripts/workflow-policy.cjs').action, 'remove');
148
+ });
149
+
150
+ test('selected-style-kept: the output style chosen in settings.json stays', () => {
151
+ const settings = JSON.parse(fs.readFileSync(path.join(pristine, '.claude/settings.json'), 'utf8'));
152
+ assert.ok(settings.outputStyle, 'fixture selects an output style');
153
+ const plan = planManifestFiles(pristine);
154
+ const styles = plan.entries.filter((entry) => entry.path.startsWith('.claude/output-styles/'));
155
+ const kept = styles.filter((entry) => entry.action === 'keep');
156
+ assert.equal(kept.length, 1);
157
+ assert.equal(kept[0].reason, 'required-by-kept');
158
+ assert.match(fs.readFileSync(path.join(pristine, kept[0].path), 'utf8'), new RegExp(`^name: ${settings.outputStyle.replace(/[()]/g, '\\$&')}$`, 'm'));
159
+ assert.ok(styles.filter((entry) => entry.action === 'remove').length >= 1);
160
+
161
+ // /output-style writes the choice to settings.local.json, which overrides settings.json.
162
+ const repo = copyFixture('local-style');
163
+ fs.writeFileSync(path.join(repo, '.claude/settings.local.json'), JSON.stringify({ outputStyle: 'ELI5 Mode (Level 0)' }));
164
+ const local = byPath(planManifestFiles(repo));
165
+ assert.equal(local.get('.claude/output-styles/coding-level-0-eli5.md').reason, 'required-by-kept');
166
+ assert.equal(local.get(kept[0].path).reason, 'required-by-kept');
167
+ });
168
+
169
+ test('modified-group-kept: an edited skill stays whole with every shared file it names', () => {
170
+ const repo = copyFixture('modified');
171
+ const skillFile = path.join(repo, '.claude/skills/specs/SKILL.md');
172
+ fs.appendFileSync(skillFile, '\nRun `node .claude/scripts/provenance.cjs` before closing.\n');
173
+ const plan = planManifestFiles(repo);
174
+ const members = plan.entries.filter((entry) => entry.path.startsWith('.claude/skills/specs/'));
175
+ assert.ok(members.length > 1);
176
+ assert.equal(members.find((entry) => entry.path === '.claude/skills/specs/SKILL.md').reason, 'modified');
177
+ for (const entry of members.filter((item) => item.path !== '.claude/skills/specs/SKILL.md')) assert.equal(entry.reason, 'group-kept', entry.path);
178
+ assert.equal(byPath(plan).get('.claude/scripts/provenance.cjs').reason, 'required-by-kept');
179
+ // The Codex copy of the same skill is untouched and still goes.
180
+ assert.ok(plan.entries.filter((entry) => entry.path.startsWith('.agents/skills/specs/')).every((entry) => entry.action === 'remove'));
181
+
182
+ // A user file added inside an installed skill folder keeps that skill whole.
183
+ const notes = copyFixture('notes');
184
+ fs.writeFileSync(path.join(notes, '.claude/skills/debug/my-notes.md'), 'team notes\n');
185
+ const withNotes = planManifestFiles(notes);
186
+ assert.ok(withNotes.untracked.includes('.claude/skills/debug/my-notes.md'));
187
+ assert.ok(withNotes.entries.filter((entry) => entry.path.startsWith('.claude/skills/debug/')).every((entry) => entry.reason === 'group-kept'));
188
+ });
189
+
190
+ test('symlinked-kept: a symlinked skill folder is kept and its target is never planned', () => {
191
+ const repo = copyFixture('symlinked');
192
+ const outside = path.join(work, 'symlinked', 'outside-debug');
193
+ fs.cpSync(path.join(repo, '.agents/skills/debug'), outside, { recursive: true });
194
+ fs.rmSync(path.join(repo, '.agents/skills/debug'), { recursive: true });
195
+ fs.symlinkSync(outside, path.join(repo, '.agents/skills/debug'), 'dir');
196
+ const plan = planManifestFiles(repo);
197
+ const members = plan.entries.filter((entry) => entry.path.startsWith('.agents/skills/debug/'));
198
+ assert.ok(members.length > 0);
199
+ assert.ok(members.every((entry) => entry.action === 'keep' && entry.reason === 'symlinked'));
200
+ for (const entry of plan.entries.filter((item) => item.action === 'remove')) {
201
+ assert.ok(!fs.realpathSync(path.join(repo, entry.path)).startsWith(outside), entry.path);
202
+ }
203
+ assert.ok(!plan.untracked.some((rel) => rel.startsWith('.agents/skills/debug')));
204
+
205
+ // A link back into the repository keeps the files it reaches.
206
+ const inRepo = copyFixture('in-repo-link');
207
+ fs.rmSync(path.join(inRepo, '.agents/skills/debug'), { recursive: true });
208
+ fs.symlinkSync(path.join(inRepo, '.claude/skills/debug'), path.join(inRepo, '.agents/skills/debug'), 'dir');
209
+ const linked = planManifestFiles(inRepo);
210
+ assert.ok(linked.entries.filter((entry) => entry.path.startsWith('.claude/skills/debug/')).every((entry) => entry.action === 'keep'));
211
+
212
+ const linkedFolder = copyFixture('linked-folder');
213
+ const codexCopy = path.join(work, 'linked-folder', 'codex-real');
214
+ fs.renameSync(path.join(linkedFolder, '.codex'), codexCopy);
215
+ fs.symlinkSync(codexCopy, path.join(linkedFolder, '.codex'), 'dir');
216
+ const refused = planManifestFiles(linkedFolder);
217
+ assert.equal(refused.platforms.codex.status, 'refused');
218
+ assert.ok(ofPlatform(refused, 'codex').length === 0);
219
+ });
220
+
221
+ test('outside-root-kept: a manifest key that leaves the platform folders is never removed', () => {
222
+ const repo = copyFixture('outside');
223
+ const outsideFile = path.join(work, 'outside', 'outside.txt');
224
+ fs.writeFileSync(outsideFile, 'not CafeKit\n');
225
+ const manifestFile = path.join(repo, '.claude/cafekit-manifest.json');
226
+ const manifest = JSON.parse(fs.readFileSync(manifestFile, 'utf8'));
227
+ // Claude keys resolve against .claude/, so ../../outside.txt is the repository's parent.
228
+ manifest.files['../../outside.txt'] = { sha256: sha256(fs.readFileSync(outsideFile)), version: '0.18.2' };
229
+ fs.writeFileSync(manifestFile, JSON.stringify(manifest, null, 2));
230
+ const entry = planManifestFiles(repo).entries.find((item) => item.key === '../../outside.txt');
231
+ assert.equal(entry.action, 'keep');
232
+ assert.equal(entry.reason, 'outside-root');
233
+ });
234
+
235
+ test('corrupt-manifest-refused: a manifest that does not parse plans nothing for its platform', () => {
236
+ const repo = copyFixture('corrupt');
237
+ fs.writeFileSync(path.join(repo, '.codex/cafekit-manifest.json'), '{ "files": ');
238
+ const plan = planManifestFiles(repo);
239
+ assert.equal(plan.platforms.codex.status, 'refused');
240
+ assert.equal(ofPlatform(plan, 'codex').length, 0);
241
+ assert.equal(plan.platforms.claude.status, 'planned');
242
+ });
243
+
244
+ test('unmanaged-reported: cafekit.json without a manifest is reported and nothing is planned', () => {
245
+ const repo = copyFixture('unmanaged');
246
+ fs.rmSync(path.join(repo, '.claude/cafekit-manifest.json'));
247
+ const plan = planManifestFiles(repo);
248
+ assert.equal(plan.platforms.claude.status, 'unmanaged');
249
+ assert.equal(ofPlatform(plan, 'claude').length, 0);
250
+ assert.match(renderManifestPlan(plan), /Claude Code \(\.claude\/\): unmanaged/);
251
+ });
252
+
253
+ test('untracked-listed: user files in the platform folders are listed and kept', () => {
254
+ const repo = copyFixture('untracked');
255
+ fs.mkdirSync(path.join(repo, '.claude/skills/my-skill'));
256
+ fs.writeFileSync(path.join(repo, '.claude/skills/my-skill/SKILL.md'), '---\nname: my-skill\n---\n');
257
+ fs.writeFileSync(path.join(repo, '.claude/.env'), 'TOKEN=[redacted]\n');
258
+ const plan = planManifestFiles(repo);
259
+ assert.ok(plan.untracked.includes('.claude/skills/my-skill/'), plan.untracked.join(', '));
260
+ assert.ok(plan.untracked.includes('.claude/.env'));
261
+ assert.equal(byPath(plan).get('.claude/.gitignore').reason, 'protects-kept');
262
+ assert.match(renderManifestPlan(plan), /\.claude\/skills\/my-skill\//);
263
+ });
264
+
265
+ test('writes-nothing: planning leaves the repository bytes unchanged', () => {
266
+ const repo = copyFixture('readonly');
267
+ const beforeHash = treeHash(repo);
268
+ const plan = planManifestFiles(repo);
269
+ renderManifestPlan(plan);
270
+ assert.equal(treeHash(repo), beforeHash);
271
+ });
@@ -14,7 +14,7 @@ const path = require('node:path');
14
14
  const { test } = require('node:test');
15
15
 
16
16
  const PACKAGE_ROOT = path.join(__dirname, '../..');
17
- const BRIDGE = path.join(PACKAGE_ROOT, 'src/omp/extensions/cafekit-bridge.mjs');
17
+ const BRIDGE = path.join(PACKAGE_ROOT, 'src/omp/extensions/cafekit-bridge.js');
18
18
  const SETTINGS = require(path.join(PACKAGE_ROOT, 'src/claude/settings/settings.json'));
19
19
  const MANIFEST = require(path.join(PACKAGE_ROOT, 'src/claude/migration-manifest.json'));
20
20
 
@@ -149,10 +149,14 @@ test('a crashing or missing gate blocks on gating events instead of silently all
149
149
  fs.writeFileSync(path.join(dir, 'privacy-block.cjs'), 'throw new Error("boom");');
150
150
  fs.writeFileSync(path.join(dir, 'inspect-block.cjs'), 'process.exit(0);');
151
151
  const crash = await bridge.runEvent('tool_call', { toolName: 'bash', input: { command: 'ls' } }, { cwd: dir }, { dir });
152
- // A bare exit 1 with no verdict is an allow under the hooks' own contract. Real gates
153
- // never surface a crash this way: the fork's privacy-block catch-all converts its own
154
- // errors into a deny, which the fork tests pin separately.
155
- assert.equal(crash.block, false, 'exit 1 with no verdict is an allow per the hooks\' own contract');
152
+ // A gate that dies without a verdict (a broken node shim exits 1 the same way) must not
153
+ // read as an allow on a gating event.
154
+ assert.equal(crash.block, true, 'exit 1 with no verdict blocks a gating event');
155
+ assert.match(crash.reason, /privacy-block\.cjs exited with code 1/);
156
+ fs.writeFileSync(path.join(dir, 'state.cjs'), 'process.exit(1);');
157
+ fs.writeFileSync(path.join(dir, 'spec-state.cjs'), 'process.exit(1);');
158
+ const passive = await bridge.runEvent('tool_result', { toolName: 'bash', input: {} }, { cwd: dir }, { dir });
159
+ assert.equal(passive.block, false, 'a non-gating event still skips a failing hook');
156
160
  const missing = await bridge.runEvent('tool_call', { toolName: 'bash', input: { command: 'ls' } }, { cwd: dir }, { dir: path.join(dir, 'nowhere') });
157
161
  assert.equal(missing.block, true, 'a gate that is not installed must be loud, not a silent allow');
158
162
  assert.match(missing.reason, /not installed/);
@@ -223,3 +227,75 @@ test('an omp edit inside a packet scopes the tollgate to it', async () => {
223
227
  assert.doesNotMatch(other.inject, /Multiple active specs|Spec state changed/, "another omp session's touch does not leak");
224
228
  } finally { fs.rmSync(root, { recursive: true, force: true }); }
225
229
  });
230
+
231
+ test('under Bun the hooks run with an absolute node outside the project', () => {
232
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'cafekit-omp-node-'));
233
+ try {
234
+ const outside = path.join(root, 'outside');
235
+ const inside = path.join(root, 'project', 'node_modules', '.bin');
236
+ for (const dir of [outside, inside]) {
237
+ fs.mkdirSync(dir, { recursive: true });
238
+ fs.writeFileSync(path.join(dir, 'node'), '#!/bin/sh\n');
239
+ fs.chmodSync(path.join(dir, 'node'), 0o755);
240
+ }
241
+ const cwd = path.join(root, 'project');
242
+ const pick = (PATH) => bridge.hookRunner({ cwd, env: { PATH }, bun: '1.4.3', platform: 'darwin' });
243
+ assert.deepEqual(pick(`${inside}:${outside}`), { command: path.join(outside, 'node') }, 'a node inside the project is skipped');
244
+ assert.match(pick('.:node_modules/.bin').error, /node was not found/, 'relative PATH entries are skipped');
245
+ assert.deepEqual(bridge.hookRunner({ cwd, env: { PATH: '' }, bun: undefined }), { command: process.execPath }, 'under node the running node is used');
246
+ } finally { fs.rmSync(root, { recursive: true, force: true }); }
247
+ });
248
+
249
+ test('without a usable node under Bun a gating event blocks and names node', async () => {
250
+ const saved = { bun: process.versions.bun, PATH: process.env.PATH };
251
+ try {
252
+ Object.defineProperty(process.versions, 'bun', { value: '1.4.3', configurable: true, writable: true });
253
+ process.env.PATH = '';
254
+ const r = await bridge.runEvent('tool_call', { toolName: 'bash', input: { command: 'ls' } }, { cwd: os.tmpdir() }, { dir: HOOKS });
255
+ assert.equal(r.block, true);
256
+ assert.match(r.reason, /node was not found on PATH/);
257
+ } finally {
258
+ if (saved.bun === undefined) delete process.versions.bun;
259
+ else process.versions.bun = saved.bun;
260
+ process.env.PATH = saved.PATH;
261
+ }
262
+ });
263
+
264
+ function touchedIn(hooks, session) {
265
+ const crypto = require('node:crypto');
266
+ const file = path.join(hooks, '.logs', `spec-touched-${crypto.createHash('sha256').update(session).digest('hex')}.json`);
267
+ try { return JSON.parse(fs.readFileSync(file, 'utf8')); } catch { return []; }
268
+ }
269
+
270
+ test('multi-file edits and ast_edit record every packet they changed', async () => {
271
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'cafekit-omp-paths-'));
272
+ try {
273
+ const hooks = composeOmp(root);
274
+ for (const name of ['alpha', 'beta', 'gamma']) ompPacket(root, name);
275
+ const ctx = (id) => ({ cwd: root, sessionManager: { getSessionId: () => id } });
276
+
277
+ // omp adds `paths` for a hashline edit with several file headers and no single `path`.
278
+ await bridge.runEvent('tool_result', {
279
+ toolName: 'edit', isError: false, content: [],
280
+ input: { input: '[specs/alpha/plan.md#AAAA]\nPUT >2:\n+x\n[specs/beta/plan.md#BBBB]\nPUT >2:\n+y', paths: ['specs/alpha/plan.md', 'specs/beta/plan.md'] },
281
+ }, ctx('omp-multi'), { dir: hooks });
282
+ assert.deepEqual(touchedIn(hooks, 'omp-multi'), ['alpha', 'beta']);
283
+
284
+ // ast_edit names globs; only the files it reports changing count, and a glob never does.
285
+ await bridge.runEvent('tool_result', {
286
+ toolName: 'ast_edit', isError: false, content: [],
287
+ input: { ops: [{ pat: 'a', out: 'b' }], paths: ['specs/**'] },
288
+ details: { fileChanges: [{ path: path.join(root, 'specs', 'gamma', 'plan.md'), count: 1 }] },
289
+ }, ctx('omp-ast'), { dir: hooks });
290
+ assert.deepEqual(touchedIn(hooks, 'omp-ast'), ['gamma']);
291
+ } finally { fs.rmSync(root, { recursive: true, force: true }); }
292
+ });
293
+
294
+ test('the bridge passes edit result paths but not the file contents', () => {
295
+ const payload = bridge.shapePayload('tool_result', {
296
+ toolName: 'edit', input: { path: 'a.md' }, content: [], isError: false,
297
+ details: { path: '/abs/a.md', oldText: 'old body', newText: 'new body', perFileResults: [{ path: '/abs/b.md', oldText: 'x' }], fileChanges: [{ path: '/abs/c.md', count: 2 }] },
298
+ }, { cwd: '/tmp/project' });
299
+ assert.deepEqual(payload.tool_response.details, { path: '/abs/a.md', perFileResults: [{ path: '/abs/b.md' }], fileChanges: [{ path: '/abs/c.md' }] });
300
+ assert.doesNotMatch(JSON.stringify(payload), /old body|new body/);
301
+ });