@haposoft/cafekit 0.18.1 → 0.18.3

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 (167) hide show
  1. package/README.md +12 -1
  2. package/bin/__tests__/adv-skill.test.js +371 -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__/claude-settings.test.js +123 -0
  7. package/bin/__tests__/codex-ask-guard.test.js +123 -0
  8. package/bin/__tests__/codex-ask-projection.test.js +51 -0
  9. package/bin/__tests__/codex-hooks-ownership.test.js +80 -1
  10. package/bin/__tests__/codex-hooks.test.js +812 -12
  11. package/bin/__tests__/codex-implicit-policy.test.js +160 -0
  12. package/bin/__tests__/codex-native-hook-boundaries.test.js +136 -0
  13. package/bin/__tests__/codex-native.test.js +198 -58
  14. package/bin/__tests__/codex-plugin-build.test.js +230 -0
  15. package/bin/__tests__/codex-plugin-context.test.js +135 -0
  16. package/bin/__tests__/codex-plugin-hooks.test.js +220 -0
  17. package/bin/__tests__/codex-projection-parity.test.js +314 -0
  18. package/bin/__tests__/codex-quiet-hooks.test.js +170 -0
  19. package/bin/__tests__/codex-research-projection.test.js +70 -0
  20. package/bin/__tests__/codex-runtime-layout.test.js +81 -0
  21. package/bin/__tests__/codex-setup-agents.test.js +216 -0
  22. package/bin/__tests__/codex-specs-projection.test.js +76 -0
  23. package/bin/__tests__/develop-contract.test.js +1 -1
  24. package/bin/__tests__/docs-sync-on-demand.test.js +25 -20
  25. package/bin/__tests__/inspect-paths.test.js +183 -0
  26. package/bin/__tests__/installer-safety.test.js +335 -0
  27. package/bin/__tests__/legacy-hooks-retired.test.js +81 -21
  28. package/bin/__tests__/migrate-apply.test.js +373 -0
  29. package/bin/__tests__/migrate-cli.test.js +152 -0
  30. package/bin/__tests__/migrate-merged.test.js +269 -0
  31. package/bin/__tests__/migrate-plan.test.js +271 -0
  32. package/bin/__tests__/omp-bridge.test.js +147 -17
  33. package/bin/__tests__/omp-hooks.test.js +35 -5
  34. package/bin/__tests__/orca-session.test.js +7 -0
  35. package/bin/__tests__/plugin-safety-keys.test.js +121 -0
  36. package/bin/__tests__/privacy-paths.test.js +501 -0
  37. package/bin/__tests__/usage-hook-retired.test.js +29 -9
  38. package/bin/__tests__/vibe-run.test.js +215 -0
  39. package/bin/__tests__/vibe-skill.test.js +549 -0
  40. package/bin/install.js +124 -1
  41. package/bin/lib/backup.js +43 -24
  42. package/bin/lib/codex-install.js +141 -12
  43. package/bin/lib/context.js +6 -1
  44. package/bin/lib/lock.js +98 -18
  45. package/bin/lib/managed-writer.js +16 -10
  46. package/bin/lib/migrate-apply.js +202 -0
  47. package/bin/lib/migrate-merged.js +342 -0
  48. package/bin/lib/migrate-plan.js +419 -0
  49. package/bin/lib/plugin-inventory.js +33 -0
  50. package/bin/phases/claude-runtime.js +93 -25
  51. package/bin/phases/claude-settings.js +22 -1
  52. package/bin/phases/codex-hooks.js +134 -33
  53. package/bin/phases/codex-runtime.js +110 -85
  54. package/bin/phases/copy-payload.js +50 -4
  55. package/bin/phases/omp-runtime.js +4 -26
  56. package/bin/phases/root-config.js +5 -2
  57. package/package.json +1 -1
  58. package/src/claude/CLAUDE.md +5 -1
  59. package/src/claude/agents/advisor.md +55 -0
  60. package/src/claude/agents/debugger.md +2 -3
  61. package/src/claude/agents/deployer.md +2 -1
  62. package/src/claude/agents/docs-keeper.md +12 -0
  63. package/src/claude/agents/inspector.md +1 -1
  64. package/src/claude/agents/project-manager.md +2 -2
  65. package/src/claude/agents/researcher.md +0 -1
  66. package/src/claude/agents/spec-maker.md +4 -1
  67. package/src/claude/gitignore +3 -0
  68. package/src/claude/hooks/__tests__/installed-root.test.js +111 -0
  69. package/src/claude/hooks/__tests__/plugin-context.test.js +96 -0
  70. package/src/claude/hooks/__tests__/plugin-options.test.js +141 -0
  71. package/src/claude/hooks/__tests__/plugin-state-isolation.test.js +125 -0
  72. package/src/claude/hooks/__tests__/precompact.test.js +213 -11
  73. package/src/claude/hooks/__tests__/privacy-block.test.js +60 -9
  74. package/src/claude/hooks/__tests__/session-writeenv.test.js +25 -0
  75. package/src/claude/hooks/__tests__/setup-statusline.test.js +179 -0
  76. package/src/claude/hooks/__tests__/spec-gate.test.js +227 -11
  77. package/src/claude/hooks/__tests__/spec-state-touched.test.js +204 -8
  78. package/src/claude/hooks/__tests__/state.test.js +207 -7
  79. package/src/claude/hooks/__tests__/update-check.test.js +88 -0
  80. package/src/claude/hooks/agent.cjs +11 -7
  81. package/src/claude/hooks/inspect-block.cjs +20 -66
  82. package/src/claude/hooks/lib/hook-state-dir.cjs +19 -1
  83. package/src/claude/hooks/lib/inspect-paths.cjs +304 -0
  84. package/src/claude/hooks/lib/parser.cjs +1 -1
  85. package/src/claude/hooks/lib/plugin-options.cjs +47 -0
  86. package/src/claude/hooks/lib/privacy-command-analysis.cjs +317 -24
  87. package/src/claude/hooks/lib/privacy-paths.cjs +673 -0
  88. package/src/claude/hooks/lib/project-root.cjs +15 -0
  89. package/src/claude/hooks/lib/rules-reservation.cjs +36 -0
  90. package/src/claude/hooks/lib/spec-touch.cjs +80 -0
  91. package/src/claude/hooks/lib/update-check.cjs +77 -0
  92. package/src/claude/hooks/plugin-context.cjs +80 -0
  93. package/src/claude/hooks/precompact.cjs +8 -15
  94. package/src/claude/hooks/privacy-block.cjs +25 -206
  95. package/src/claude/hooks/rules.cjs +10 -29
  96. package/src/claude/hooks/session.cjs +37 -117
  97. package/src/claude/hooks/spec-gate.cjs +9 -26
  98. package/src/claude/hooks/spec-state.cjs +44 -59
  99. package/src/claude/hooks/state.cjs +101 -52
  100. package/src/claude/migration-manifest.json +14 -2
  101. package/src/claude/plugin/commands/setup-statusline.md +12 -0
  102. package/src/claude/plugin/core.md +71 -0
  103. package/src/claude/rules/hook-protocols.md +1 -1
  104. package/src/claude/rules/manage-docs.md +1 -2
  105. package/src/claude/rules/review-audit-self-decision.md +1 -1
  106. package/src/claude/rules/skill-workflow-routing.md +1 -1
  107. package/src/claude/rules/state-sync.md +14 -0
  108. package/src/claude/rules/workflow.md +1 -1
  109. package/src/claude/runtime.json +0 -3
  110. package/src/claude/scripts/generate-skill-catalog.cjs +7 -2
  111. package/src/claude/scripts/provenance.cjs +54 -16
  112. package/src/claude/scripts/setup-statusline.cjs +88 -0
  113. package/src/claude/settings/settings.json +1 -1
  114. package/src/claude/skills/adv/SKILL.md +139 -0
  115. package/src/claude/skills/adv/references/optional-modes.md +41 -0
  116. package/src/claude/skills/adv/references/ultra-mode.md +29 -0
  117. package/src/claude/skills/ai-multimodal/scripts/check_setup.py +8 -68
  118. package/src/claude/skills/ai-multimodal/scripts/gemini_batch_process.py +6 -28
  119. package/src/claude/skills/brainstorm/SKILL.md +1 -1
  120. package/src/claude/skills/code-review/SKILL.md +4 -1
  121. package/src/claude/skills/code-review/references/pr-fix-reply.md +42 -0
  122. package/src/claude/skills/develop/SKILL.md +5 -3
  123. package/src/claude/skills/develop/references/quality-gate.md +1 -1
  124. package/src/claude/skills/fix/SKILL.md +2 -2
  125. package/src/claude/skills/git/SKILL.md +5 -3
  126. package/src/claude/skills/git/references/merge-pr.md +125 -0
  127. package/src/claude/skills/git/references/worktree-blueprint.md +1 -1
  128. package/src/claude/skills/specs/SKILL.md +2 -2
  129. package/src/claude/skills/specs/references/review.md +1 -1
  130. package/src/claude/skills/specs/references/templates.md +2 -0
  131. package/src/claude/skills/vibe/SKILL.md +69 -0
  132. package/src/claude/skills/vibe/references/pipeline-skill-integration.md +34 -0
  133. package/src/claude/skills/vibe/references/report-templates.md +84 -0
  134. package/src/claude/skills/vibe/references/subagent-orchestration.md +23 -0
  135. package/src/codex/AGENTS.md +9 -1
  136. package/src/codex/hooks/ask-guard.cjs +54 -0
  137. package/src/codex/hooks/inspect-block.cjs +15 -8
  138. package/src/codex/hooks/lib/hook-context.cjs +118 -4
  139. package/src/codex/hooks/lib/inspect-paths.cjs +1 -199
  140. package/src/codex/hooks/lib/plugin-options.cjs +2 -0
  141. package/src/codex/hooks/lib/privacy-paths.cjs +1 -0
  142. package/src/codex/hooks/lib/privacy-state.cjs +116 -17
  143. package/src/codex/hooks/lib/rules-reservation.cjs +38 -0
  144. package/src/codex/hooks/lib/secret-keywords.cjs +1 -0
  145. package/src/codex/hooks/lib/spec-session-touch.cjs +100 -0
  146. package/src/codex/hooks/lib/spec-touch.cjs +3 -0
  147. package/src/codex/hooks/lib/state-store.cjs +5 -4
  148. package/src/codex/hooks/lib/update-check.cjs +1 -0
  149. package/src/codex/hooks/precompact.cjs +40 -0
  150. package/src/codex/hooks/privacy-approval.cjs +28 -17
  151. package/src/codex/hooks/privacy-block.cjs +11 -216
  152. package/src/codex/hooks/rules.cjs +14 -34
  153. package/src/codex/hooks/secret-output-guardrail.cjs +1 -1
  154. package/src/codex/hooks/session.cjs +59 -11
  155. package/src/codex/hooks/spec-gate.cjs +21 -6
  156. package/src/codex/hooks/spec-state.cjs +32 -6
  157. package/src/codex/hooks/state.cjs +25 -4
  158. package/src/codex/hooks.json +39 -7
  159. package/src/codex/plugin/core.md +76 -0
  160. package/src/codex/plugin/hooks/plugin-context.cjs +63 -0
  161. package/src/codex/plugin/skills/setup-agents/SKILL.md +17 -0
  162. package/src/codex/rules/hook-protocols.md +13 -6
  163. package/src/codex/scripts/setup-agents.cjs +124 -0
  164. package/src/common/AGENTS.md +1 -1
  165. package/src/omp/extensions/{cafekit-bridge.mjs → cafekit-bridge.js} +85 -9
  166. package/src/omp/hooks/privacy-block.cjs +26 -207
  167. package/src/codex/rules/state-sync.md +0 -60
@@ -97,6 +97,341 @@ function inTempProject(run) {
97
97
  }
98
98
  }
99
99
 
100
+ function runCodexInstaller(root) {
101
+ return spawnSync(process.execPath, [INSTALLER, '--platform', 'codex', '--yes'], {
102
+ cwd: root, encoding: 'utf8', env: { ...process.env, PATH: '/usr/bin:/bin' }
103
+ });
104
+ }
105
+
106
+ function rootFileGit(root, args) {
107
+ const result = spawnSync('git', args, { cwd: root, encoding: 'utf8' });
108
+ assert.equal(result.status, 0, `${result.stdout}\n${result.stderr}`);
109
+ return result.stdout;
110
+ }
111
+
112
+ function commitRootFile(root, file) {
113
+ rootFileGit(root, ['init', '-q']);
114
+ const content = file === 'AGENTS.md' ? upsertManagedCoreBlock('', SHARED_TEMPLATE) : TEMPLATE;
115
+ fs.writeFileSync(path.join(root, file), `${content}\n## Project notes\nKeep these project notes.\n`);
116
+ rootFileGit(root, ['add', '--', file]);
117
+ rootFileGit(root, ['-c', 'user.name=CafeKit Fixture', '-c', 'user.email=cafekit-fixture@example.invalid',
118
+ 'commit', '--no-gpg-sign', '-qm', 'root file fixture']);
119
+ }
120
+
121
+ function runRootFileInstaller(root, platform, args = [], env = {}) {
122
+ return spawnSync(process.execPath,
123
+ [INSTALLER, '--platform', platform, '--yes', '--without-document-skills', ...args],
124
+ { cwd: root, encoding: 'utf8', env: { ...process.env, PATH: '/usr/bin:/bin', ...env } });
125
+ }
126
+
127
+ for (const [file, platform, args] of [
128
+ ['AGENTS.md', 'claude', []], ['CLAUDE.md', 'claude', []],
129
+ ['AGENTS.md', 'codex', []], ['AGENTS.md', 'omp', []],
130
+ ['AGENTS.md', 'claude', ['--dry-run']],
131
+ ]) {
132
+ test(`missing tracked ${file} stops ${platform} installation${args.length ? ' in dry-run' : ''} before writes`, () => {
133
+ inTempProject(root => {
134
+ commitRootFile(root, file);
135
+ fs.unlinkSync(path.join(root, file));
136
+ const before = fs.readdirSync(root).filter(name => name !== '.cafekit.lock');
137
+ const result = runRootFileInstaller(root, platform, args);
138
+ const output = `${result.stdout}\n${result.stderr}`;
139
+ assert.notEqual(result.status, 0, output);
140
+ assert.ok(output.includes(`git checkout -- ${file}`), output);
141
+ assert.ok(output.includes(`git rm ${file}`), output);
142
+ assert.deepEqual(fs.readdirSync(root).filter(name => name !== '.cafekit.lock'), before);
143
+ for (const name of ['AGENTS.md', 'CLAUDE.md', '.claude', '.codex', '.agents', '.omp', '.cafekit-backup']) {
144
+ assert.equal(fs.existsSync(path.join(root, name)), false, name);
145
+ }
146
+ });
147
+ });
148
+ }
149
+
150
+ for (const assumeUnchanged of [false, true]) {
151
+ test(`missing skip-worktree AGENTS.md permits installation${assumeUnchanged ? ' with assume-unchanged' : ''}`, () => {
152
+ inTempProject(root => {
153
+ commitRootFile(root, 'AGENTS.md');
154
+ rootFileGit(root, ['update-index', '--skip-worktree', '--', 'AGENTS.md']);
155
+ if (assumeUnchanged) rootFileGit(root, ['update-index', '--assume-unchanged', '--', 'AGENTS.md']);
156
+ assert.match(rootFileGit(root, ['ls-files', '-v', '--', 'AGENTS.md']), assumeUnchanged ? /^s / : /^S /);
157
+ fs.unlinkSync(path.join(root, 'AGENTS.md'));
158
+ const result = runRootFileInstaller(root, 'claude');
159
+ assert.equal(result.status, 0, `${result.stdout}\n${result.stderr}`);
160
+ assert.ok(fs.existsSync(path.join(root, 'AGENTS.md')));
161
+ });
162
+ });
163
+ }
164
+
165
+ test('a tracked dangling CLAUDE.md symlink is present for the root preflight', () => {
166
+ inTempProject(root => {
167
+ rootFileGit(root, ['init', '-q']);
168
+ fs.mkdirSync(path.join(root, 'notes'));
169
+ fs.symlinkSync('notes/claude.md', path.join(root, 'CLAUDE.md'));
170
+ rootFileGit(root, ['add', '--', 'CLAUDE.md']);
171
+ rootFileGit(root, ['-c', 'user.name=CafeKit Fixture', '-c', 'user.email=cafekit-fixture@example.invalid',
172
+ 'commit', '--no-gpg-sign', '-qm', 'root symlink fixture']);
173
+ const result = runRootFileInstaller(root, 'claude');
174
+ const output = `${result.stdout}\n${result.stderr}`;
175
+ assert.notEqual(result.status, 0, output);
176
+ assert.doesNotMatch(output, /tracked by git but missing/);
177
+ assert.match(output, /Refusing to follow symlinked managed path/);
178
+ assert.equal(fs.readlinkSync(path.join(root, 'CLAUDE.md')), 'notes/claude.md');
179
+ assert.equal(fs.existsSync(path.join(root, 'notes/claude.md')), false);
180
+ assert.deepEqual(fs.readdirSync(path.join(root, 'notes')), []);
181
+ });
182
+ });
183
+
184
+ test('uncertain git ownership warns and permits a fresh root file', () => {
185
+ inTempProject(root => {
186
+ commitRootFile(root, 'AGENTS.md');
187
+ fs.unlinkSync(path.join(root, 'AGENTS.md'));
188
+ const result = runRootFileInstaller(root, 'claude', [], {
189
+ GIT_TEST_ASSUME_DIFFERENT_OWNER: '1', GIT_CONFIG_GLOBAL: '/dev/null', GIT_CONFIG_NOSYSTEM: '1',
190
+ });
191
+ assert.equal(result.status, 0, `${result.stdout}\n${result.stderr}`);
192
+ assert.match(`${result.stdout}\n${result.stderr}`, /Could not confirm git state for AGENTS\.md; installing a fresh copy\./);
193
+ assert.ok(fs.existsSync(path.join(root, 'AGENTS.md')));
194
+ });
195
+ });
196
+
197
+ test('Codex installation leaves a missing tracked CLAUDE.md alone', () => {
198
+ inTempProject(root => {
199
+ commitRootFile(root, 'CLAUDE.md');
200
+ fs.unlinkSync(path.join(root, 'CLAUDE.md'));
201
+ const result = runRootFileInstaller(root, 'codex');
202
+ assert.equal(result.status, 0, `${result.stdout}\n${result.stderr}`);
203
+ assert.equal(fs.existsSync(path.join(root, 'CLAUDE.md')), false);
204
+ });
205
+ });
206
+
207
+ test('a root file removed from the git index can be freshly installed', () => {
208
+ inTempProject(root => {
209
+ commitRootFile(root, 'AGENTS.md');
210
+ rootFileGit(root, ['rm', '--', 'AGENTS.md']);
211
+ const result = runRootFileInstaller(root, 'claude');
212
+ assert.equal(result.status, 0, `${result.stdout}\n${result.stderr}`);
213
+ assert.ok(fs.existsSync(path.join(root, 'AGENTS.md')));
214
+ assert.doesNotMatch(`${result.stdout}\n${result.stderr}`, /Could not confirm git state/);
215
+ });
216
+ });
217
+
218
+ test('a project outside git installs root files without a git-state warning', () => {
219
+ inTempProject(root => {
220
+ const result = runRootFileInstaller(root, 'claude');
221
+ assert.equal(result.status, 0, `${result.stdout}\n${result.stderr}`);
222
+ assert.ok(fs.existsSync(path.join(root, 'AGENTS.md')));
223
+ assert.doesNotMatch(`${result.stdout}\n${result.stderr}`, /Could not confirm git state/);
224
+ });
225
+ });
226
+
227
+ test('an existing tracked root file retains notes after its managed marker', () => {
228
+ inTempProject(root => {
229
+ commitRootFile(root, 'AGENTS.md');
230
+ const result = runRootFileInstaller(root, 'claude');
231
+ assert.equal(result.status, 0, `${result.stdout}\n${result.stderr}`);
232
+ assert.match(fs.readFileSync(path.join(root, 'AGENTS.md'), 'utf8'), /CAFEKIT CORE END -->[\s\S]*Keep these project notes\./);
233
+ });
234
+ });
235
+
236
+ const NOTES_HOME = 'Project notes (commands, files to leave unchanged, slow or expensive operations) belong in the root AGENTS.md after the CAFEKIT CORE END marker';
237
+ const normalizeWhitespace = text => text.replace(/\s+/g, ' ').trim();
238
+
239
+ test('the installed Claude block points project notes to the shared root with a quoted import', () => {
240
+ inTempProject(root => {
241
+ const result = runRootFileInstaller(root, 'claude');
242
+ assert.equal(result.status, 0, `${result.stdout}\n${result.stderr}`);
243
+ const body = normalizeWhitespace(managedBody(fs.readFileSync(path.join(root, 'CLAUDE.md'), 'utf8'), START, END));
244
+ assert.ok(body.includes(`${NOTES_HOME}; Claude Code reads them through \`@AGENTS.md\`.`), body);
245
+ assert.doesNotMatch(body, /@AGENTS\.md\./);
246
+ assert.doesNotMatch(body, /Codex|\.codex|\$cf-/i);
247
+ });
248
+ });
249
+
250
+ test('the installed Codex block points project notes to the shared root directly', () => {
251
+ inTempProject(root => {
252
+ const result = runRootFileInstaller(root, 'codex');
253
+ assert.equal(result.status, 0, `${result.stdout}\n${result.stderr}`);
254
+ const body = normalizeWhitespace(managedBody(fs.readFileSync(path.join(root, 'AGENTS.md'), 'utf8'), CODEX_START, CODEX_END));
255
+ assert.ok(body.includes(`${NOTES_HOME}; Codex reads AGENTS.md directly.`), body);
256
+ });
257
+ });
258
+
259
+ test('the package README preserves root ownership and names one project-notes home for both hosts', () => {
260
+ const readme = normalizeWhitespace(fs.readFileSync(path.join(__dirname, '../../README.md'), 'utf8'));
261
+ const note = `${NOTES_HOME}; Claude Code reads them through \`@AGENTS.md\` and Codex reads AGENTS.md directly.`;
262
+ const ownership = 'CafeKit owns only its marked block inside the project-root `CLAUDE.md`; project instructions outside that block are preserved across installs and upgrades.';
263
+ assert.ok(readme.includes(`${ownership} ${note}`));
264
+ assert.ok(readme.includes(`Claude statusline. ${note}`));
265
+ });
266
+
267
+ for (const host of ['claude', 'codex']) {
268
+ for (const state of ['pristine file', 'modified file', 'untracked file', 'external symlink', 'dangling symlink', 'pristine directory', 'directory with an extra file', 'directory with a modified file', 'directory with a symlink']) {
269
+ test(`${host} obsolete cleanup safely handles a ${state}`, () => {
270
+ const outside = fs.mkdtempSync(path.join(os.tmpdir(), 'cafekit-obsolete-outside-'));
271
+ try {
272
+ inTempProject(root => {
273
+ const run = () => spawnSync(process.execPath, [INSTALLER, '--platform', host, '--yes', '--force-overwrite'], {
274
+ cwd: root, encoding: 'utf8', env: { ...process.env, PATH: '/usr/bin:/bin' }
275
+ });
276
+ const first = run();
277
+ assert.equal(first.status, 0, first.stderr);
278
+ const folder = `.${host}`;
279
+ const manifestPath = path.join(root, folder, 'cafekit-manifest.json');
280
+ const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
281
+ const directory = state.includes('directory');
282
+ const relative = directory
283
+ ? (host === 'codex' ? '.agents/skills/generate-graph' : '.claude/skills/generate-graph')
284
+ : `${folder}/hooks/usage.cjs`;
285
+ const target = path.join(root, relative);
286
+ const keyFor = relative => host === 'codex' ? relative : relative.slice('.claude/'.length);
287
+ const records = {};
288
+ const record = (relative, bytes) => {
289
+ const key = keyFor(relative);
290
+ records[key] = { sha256: sha256(bytes) };
291
+ manifest.files[key] = records[key];
292
+ };
293
+ const external = path.join(outside, 'target.txt');
294
+ if (state !== 'dangling symlink') fs.writeFileSync(external, 'outside bytes\n');
295
+ fs.mkdirSync(path.dirname(target), { recursive: true });
296
+ if (directory) {
297
+ fs.mkdirSync(path.join(target, 'nested'), { recursive: true });
298
+ fs.writeFileSync(path.join(target, 'tracked.txt'), state.includes('modified') ? 'changed\n' : 'installed\n');
299
+ fs.writeFileSync(path.join(target, 'nested/second.txt'), 'second\n');
300
+ record(`${relative}/tracked.txt`, 'installed\n');
301
+ record(`${relative}/nested/second.txt`, 'second\n');
302
+ if (state.includes('extra')) fs.writeFileSync(path.join(target, 'foreign.txt'), 'user bytes\n');
303
+ if (state.includes('symlink')) {
304
+ fs.symlinkSync(external, path.join(target, 'linked.txt'));
305
+ record(`${relative}/linked.txt`, 'outside bytes\n');
306
+ }
307
+ } else if (state.includes('symlink')) {
308
+ fs.symlinkSync(external, target);
309
+ record(relative, 'outside bytes\n');
310
+ } else {
311
+ fs.writeFileSync(target, state === 'modified file' ? 'changed\n' : 'installed\n');
312
+ if (state !== 'untracked file') record(relative, 'installed\n');
313
+ }
314
+ fs.writeFileSync(manifestPath, JSON.stringify(manifest));
315
+ const again = run();
316
+ assert.equal(again.status, 0, `${again.stdout}\n${again.stderr}`);
317
+ const preserved = !state.startsWith('pristine');
318
+ assert.equal(fs.existsSync(target) || state === 'dangling symlink' && fs.lstatSync(target).isSymbolicLink(), preserved);
319
+ const after = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
320
+ for (const [key, value] of Object.entries(records)) {
321
+ if (preserved) assert.deepEqual(after.files[key], value, key);
322
+ else assert.equal(after.files[key], undefined, key);
323
+ }
324
+ if (state.includes('symlink')) {
325
+ const link = directory ? path.join(target, 'linked.txt') : target;
326
+ assert.ok(fs.lstatSync(link).isSymbolicLink());
327
+ assert.equal(fs.readlinkSync(link), external);
328
+ }
329
+ if (state !== 'dangling symlink') assert.equal(fs.readFileSync(external, 'utf8'), 'outside bytes\n');
330
+ else assert.equal(fs.existsSync(external), false);
331
+ const warnings = `${again.stdout}\n${again.stderr}`.split('\n').filter(line => /Preserved \d+ obsolete runtime/.test(line));
332
+ assert.equal(warnings.length, preserved ? 1 : 0);
333
+ if (preserved) {
334
+ assert.match(warnings[0], /Preserved 1 obsolete runtime/);
335
+ assert.match(warnings[0], /manually/i);
336
+ }
337
+ });
338
+ } finally {
339
+ fs.rmSync(outside, { recursive: true, force: true });
340
+ }
341
+ });
342
+ }
343
+ }
344
+
345
+ test('combined install emits one aggregate warning for preserved obsolete runtime items', () => {
346
+ inTempProject(root => {
347
+ assert.equal(runCombinedInstaller(root).status, 0);
348
+ for (const host of ['claude', 'codex']) {
349
+ fs.writeFileSync(path.join(root, `.${host}/hooks/usage.cjs`), 'user bytes\n');
350
+ }
351
+ const again = runCombinedInstaller(root);
352
+ assert.equal(again.status, 0, again.stderr);
353
+ const warnings = `${again.stdout}\n${again.stderr}`.split('\n').filter(line => /Preserved \d+ obsolete runtime/.test(line));
354
+ assert.equal(warnings.length, 1);
355
+ assert.match(warnings[0], /Preserved 2 obsolete runtime/);
356
+ assert.match(warnings[0], /manually/i);
357
+ for (const host of ['claude', 'codex']) assert.equal(fs.readFileSync(path.join(root, `.${host}/hooks/usage.cjs`), 'utf8'), 'user bytes\n');
358
+ });
359
+ });
360
+
361
+ test('combined install removes pristine obsolete files after each host tracker is available', () => {
362
+ inTempProject(root => {
363
+ assert.equal(runCombinedInstaller(root).status, 0);
364
+ for (const host of ['claude', 'codex']) {
365
+ const target = path.join(root, `.${host}/hooks/usage.cjs`);
366
+ fs.writeFileSync(target, 'installed bytes\n');
367
+ const manifestPath = path.join(root, `.${host}/cafekit-manifest.json`);
368
+ const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
369
+ const key = host === 'codex' ? '.codex/hooks/usage.cjs' : 'hooks/usage.cjs';
370
+ manifest.files[key] = { sha256: sha256('installed bytes\n') };
371
+ fs.writeFileSync(manifestPath, JSON.stringify(manifest));
372
+ }
373
+ const again = runCombinedInstaller(root);
374
+ assert.equal(again.status, 0, again.stderr);
375
+ for (const host of ['claude', 'codex']) {
376
+ assert.equal(fs.existsSync(path.join(root, `.${host}/hooks/usage.cjs`)), false, host);
377
+ const manifest = JSON.parse(fs.readFileSync(path.join(root, `.${host}/cafekit-manifest.json`), 'utf8'));
378
+ assert.equal(manifest.files[host === 'codex' ? '.codex/hooks/usage.cjs' : 'hooks/usage.cjs'], undefined, host);
379
+ }
380
+ assert.doesNotMatch(again.stdout + again.stderr, /Preserved \d+ obsolete runtime/);
381
+ });
382
+ });
383
+
384
+ test('identical Codex reinstall does not report hook helpers or rules updated', () => {
385
+ inTempProject(root => {
386
+ const first = runCodexInstaller(root);
387
+ assert.equal(first.status, 0, first.stderr);
388
+ const second = runCodexInstaller(root);
389
+ assert.equal(second.status, 0, second.stderr);
390
+ const labels = ['Codex native hooks', 'Codex privacy command analyzer', 'Codex privacy path analyzer',
391
+ 'Codex secret keyword matcher', 'Codex runtime path safety', 'Codex inspection path analyzer',
392
+ 'Codex spec touch helpers', 'Codex update check', 'Codex native rule overrides', 'rules/ directory'];
393
+ const updates = `${second.stdout}\n${second.stderr}`.split('\n')
394
+ .filter(line => line.includes('↻') && labels.some(label => line.includes(label)));
395
+ assert.deepEqual(updates, []);
396
+ });
397
+ });
398
+
399
+ test('installed Codex hook libraries match canonical bytes and contain no source shims', () => {
400
+ inTempProject(root => {
401
+ const result = runCodexInstaller(root);
402
+ assert.equal(result.status, 0, result.stderr);
403
+ const sourceRoot = path.join(__dirname, '../../src');
404
+ const libs = path.join(sourceRoot, 'codex/hooks/lib');
405
+ const shim = /^(?:['"]use strict['"];?\s*)?module\.exports\s*=\s*require\(['"]\.\.\/\.\.\/\.\.\/claude\/hooks\/lib\/([^'"]+)['"]\);?\s*$/;
406
+ for (const name of fs.readdirSync(libs)) {
407
+ const source = fs.readFileSync(path.join(libs, name), 'utf8');
408
+ const match = source.match(shim);
409
+ if (match) assert.equal(match[1], name);
410
+ const expected = path.join(sourceRoot, match ? 'claude/hooks/lib' : 'codex/hooks/lib', name);
411
+ const installed = fs.readFileSync(path.join(root, '.codex/hooks/lib', name));
412
+ assert.doesNotMatch(installed.toString(), shim, name);
413
+ assert.deepEqual(installed, fs.readFileSync(expected), name);
414
+ }
415
+ });
416
+ });
417
+
418
+ test('installed Claude runtime omits retired usage configuration', () => {
419
+ inTempProject(root => {
420
+ const result = runCombinedInstaller(root);
421
+ assert.equal(result.status, 0, result.stderr);
422
+ const runtime = JSON.parse(fs.readFileSync(path.join(root, '.claude/runtime.json'), 'utf8'));
423
+ assert.equal(Object.hasOwn(runtime, 'usage'), false);
424
+ });
425
+ });
426
+
427
+ test('multimodal setup scripts keep local resolution without the missing resolver', () => {
428
+ for (const name of ['check_setup.py', 'gemini_batch_process.py']) {
429
+ const content = fs.readFileSync(path.join(__dirname, '../../src/claude/skills/ai-multimodal/scripts', name), 'utf8');
430
+ assert.doesNotMatch(content, /resolve_env|CENTRALIZED_RESOLVER_AVAILABLE/, name);
431
+ assert.ok(content.includes("os.getenv('GEMINI_API_KEY')"), name);
432
+ }
433
+ });
434
+
100
435
  test('legacy managed root gitignore migrates A13 plan patterns and preserves user content', () => {
101
436
  inTempProject((root) => {
102
437
  fs.writeFileSync('.gitignore', [
@@ -4,7 +4,7 @@
4
4
  // review authority and the scaffold guard) are gone from every runtime. Each case pins one way
5
5
  // they could survive: a fresh install, an upgrade on Claude (settings prune matches by substring,
6
6
  // so a user's own hook with the same file name must stay), an upgrade on Codex (whose tracker
7
- // keys are `.codex/…` and whose handlers come in three shapes), an upgrade on omp, an omp bridge
7
+ // keys are `.codex/…` and whose handlers come in three shapes), an upgrade on omp (hash-checked like Codex), an omp bridge
8
8
  // that still lists the removed gates, and the source lists themselves.
9
9
 
10
10
  const assert = require('node:assert/strict');
@@ -21,7 +21,7 @@ const SRC = path.join(PACKAGE_ROOT, 'src');
21
21
  const readJson = (file) => JSON.parse(fs.readFileSync(file, 'utf8'));
22
22
  const MANIFEST = readJson(path.join(SRC, 'claude/migration-manifest.json'));
23
23
  const SOURCE_SETTINGS = readJson(path.join(SRC, 'claude/settings/settings.json'));
24
- const SOURCE_BRIDGE_PATH = path.join(SRC, 'omp/extensions/cafekit-bridge.mjs');
24
+ const SOURCE_BRIDGE_PATH = path.join(SRC, 'omp/extensions/cafekit-bridge.js');
25
25
 
26
26
  const NAMES = [
27
27
  'completion-authority.cjs',
@@ -48,6 +48,7 @@ function install(root, platforms) {
48
48
  const result = spawnSync(process.execPath, [INSTALLER, '--platform', platforms.join(','), '--yes'],
49
49
  { cwd: root, encoding: 'utf8', env: { ...process.env, PATH: '/usr/bin:/bin' } });
50
50
  assert.equal(result.status, 0, `${result.stdout}\n${result.stderr}`);
51
+ return result;
51
52
  }
52
53
 
53
54
  function commands(settings) {
@@ -69,12 +70,12 @@ test('fresh installs ship and register none of the five legacy hooks', () => {
69
70
  const claude = commands(readJson(path.join(root, '.claude', 'settings.json')));
70
71
  const codex = commands(readJson(path.join(root, '.codex', 'hooks.json')));
71
72
  for (const command of [...claude, ...codex]) assert.deepEqual(mentionsName(command), [], command);
72
- const bridge = fs.readFileSync(path.join(root, '.omp', 'extensions', 'cafekit-bridge.mjs'), 'utf8');
73
+ const bridge = fs.readFileSync(path.join(root, '.omp', 'extensions', 'cafekit-bridge.js'), 'utf8');
73
74
  assert.deepEqual(mentionsName(bridge), []);
74
75
  });
75
76
  });
76
77
 
77
- test('claude upgrade removes the legacy hooks and their CafeKit entries but keeps foreign ones', () => {
78
+ test('claude upgrade preserves edited legacy hooks while removing their CafeKit entries', () => {
78
79
  inTempProject((root) => {
79
80
  install(root, ['claude']);
80
81
  const settingsPath = path.join(root, '.claude', 'settings.json');
@@ -99,10 +100,11 @@ test('claude upgrade removes the legacy hooks and their CafeKit entries but keep
99
100
  settings.hooks.SubagentStop[0].hooks.push({ type: 'command', command: cmd('semantic-review-authority.cjs') });
100
101
  fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2));
101
102
 
102
- install(root, ['claude']);
103
- for (const name of NAMES) assert.equal(fs.existsSync(path.join(root, '.claude', 'hooks', name)), false, name);
103
+ const upgrade = install(root, ['claude']);
104
+ assert.match(upgrade.stdout + upgrade.stderr, /Preserved 5 obsolete runtime/);
105
+ for (const name of NAMES) assert.equal(fs.readFileSync(path.join(root, '.claude', 'hooks', name), 'utf8'), '// user edit\n', name);
104
106
  const after = readJson(manifestPath);
105
- for (const name of NAMES) assert.equal(Object.prototype.hasOwnProperty.call(after.files, `hooks/${name}`), false, `record ${name}`);
107
+ for (const name of NAMES) assert.equal(after.files[`hooks/${name}`].sha256, '0'.repeat(64), `record ${name}`);
106
108
  const afterCommands = commands(readJson(settingsPath));
107
109
  for (const name of REGISTERED) {
108
110
  assert.equal(afterCommands.some((command) => command.includes(`.claude/hooks/${name}`)), false, name);
@@ -112,7 +114,7 @@ test('claude upgrade removes the legacy hooks and their CafeKit entries but keep
112
114
  });
113
115
  });
114
116
 
115
- test('codex upgrade removes the legacy hooks, their records and their CafeKit entries but keeps foreign ones', () => {
117
+ test('codex upgrade preserves edited legacy hooks and prunes only absent records', () => {
116
118
  inTempProject((root) => {
117
119
  install(root, ['codex']);
118
120
  const hooksPath = path.join(root, '.codex', 'hooks.json');
@@ -137,10 +139,18 @@ test('codex upgrade removes the legacy hooks, their records and their CafeKit en
137
139
  add('UserPromptSubmit', FOREIGN[0]);
138
140
  fs.writeFileSync(hooksPath, JSON.stringify(config, null, 2));
139
141
 
140
- install(root, ['codex']);
141
- for (const name of NAMES) assert.equal(fs.existsSync(path.join(root, '.codex', 'hooks', name)), false, name);
142
+ const upgrade = install(root, ['codex']);
143
+ assert.match(upgrade.stdout + upgrade.stderr, /Preserved 4 obsolete runtime/);
144
+ for (const name of NAMES) {
145
+ const file = path.join(root, '.codex', 'hooks', name);
146
+ if (name === 'completion-authority-state.cjs') assert.equal(fs.existsSync(file), false, name);
147
+ else assert.equal(fs.readFileSync(file, 'utf8'), '// user edit\n', name);
148
+ }
142
149
  const after = readJson(manifestPath);
143
- for (const name of NAMES) assert.equal(Object.prototype.hasOwnProperty.call(after.files, `.codex/hooks/${name}`), false, `record ${name}`);
150
+ for (const name of NAMES) {
151
+ if (name === 'completion-authority-state.cjs') assert.equal(after.files[`.codex/hooks/${name}`], undefined, `record ${name}`);
152
+ else assert.equal(after.files[`.codex/hooks/${name}`].sha256, '0'.repeat(64), `record ${name}`);
153
+ }
144
154
  const afterCommands = commands(readJson(hooksPath));
145
155
  for (const name of REGISTERED) {
146
156
  assert.equal(afterCommands.some((command) => command.includes(`.codex/hooks/${name}`) || command.includes(`.codex\\hooks\\${name}`)), false, name);
@@ -156,9 +166,10 @@ test('omp upgrade removes the legacy hooks and their records', () => {
156
166
  const manifestPath = path.join(root, '.omp', 'cafekit-manifest.json');
157
167
  const manifest = readJson(manifestPath);
158
168
  // omp records keys relative to the project root (recordRoot '.').
169
+ const shipped = '// shipped by an older CafeKit\n';
159
170
  for (const name of NAMES) {
160
- fs.writeFileSync(path.join(root, '.omp', 'hooks', name), '// shipped by an older CafeKit\n');
161
- manifest.files[`.omp/hooks/${name}`] = { sha256: '0'.repeat(64) };
171
+ fs.writeFileSync(path.join(root, '.omp', 'hooks', name), shipped);
172
+ manifest.files[`.omp/hooks/${name}`] = { sha256: crypto.createHash('sha256').update(shipped).digest('hex') };
162
173
  }
163
174
  fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2));
164
175
 
@@ -171,28 +182,77 @@ test('omp upgrade removes the legacy hooks and their records', () => {
171
182
  });
172
183
  });
173
184
 
174
- test('omp upgrade replaces a pristine old bridge that still lists the removed gates', () => {
185
+ test('omp upgrade preserves edited legacy hooks and prunes only absent records', () => {
175
186
  inTempProject((root) => {
176
187
  install(root, ['omp']);
177
- const bridgePath = path.join(root, '.omp', 'extensions', 'cafekit-bridge.mjs');
188
+ const manifestPath = path.join(root, '.omp', 'cafekit-manifest.json');
189
+ const manifest = readJson(manifestPath);
190
+ // A record whose file is already gone must leave the manifest too.
191
+ for (const name of NAMES) {
192
+ if (name !== 'completion-authority-state.cjs') fs.writeFileSync(path.join(root, '.omp', 'hooks', name), '// user edit\n');
193
+ manifest.files[`.omp/hooks/${name}`] = { sha256: '0'.repeat(64) };
194
+ }
195
+ fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2));
196
+
197
+ const upgrade = install(root, ['omp']);
198
+ assert.match(`${upgrade.stdout}\n${upgrade.stderr}`, new RegExp(`Preserved ${NAMES.length - 1} obsolete runtime item`));
199
+ const after = readJson(manifestPath);
200
+ for (const name of NAMES) {
201
+ if (name === 'completion-authority-state.cjs') {
202
+ assert.equal(after.files[`.omp/hooks/${name}`], undefined, `record ${name}`);
203
+ continue;
204
+ }
205
+ assert.equal(fs.readFileSync(path.join(root, '.omp', 'hooks', name), 'utf8'), '// user edit\n', name);
206
+ assert.equal(after.files[`.omp/hooks/${name}`].sha256, '0'.repeat(64), `record ${name}`);
207
+ }
208
+ });
209
+ });
210
+
211
+ test('omp upgrade removes a pristine old .mjs bridge, which omp never loads', () => {
212
+ inTempProject((root) => {
213
+ install(root, ['omp']);
214
+ const oldPath = path.join(root, '.omp', 'extensions', 'cafekit-bridge.mjs');
178
215
  const manifestPath = path.join(root, '.omp', 'cafekit-manifest.json');
179
216
  const old = fs.readFileSync(SOURCE_BRIDGE_PATH, 'utf8')
180
- .replace("'inspect-block.cjs']", "'inspect-block.cjs', 'task-scaffold-guard.cjs']")
181
- .replace("'rules.cjs', 'spec-state.cjs'", "'rules.cjs', 'completion-authority.cjs', 'spec-state.cjs'")
182
- .replace("'spec-gate.cjs', 'state.cjs'", "'spec-gate.cjs', 'completion-authority.cjs', 'state.cjs'");
183
- assert.deepEqual(mentionsName(old).sort(), ['completion-authority.cjs', 'task-scaffold-guard.cjs'], 'the fixture lists the removed gates');
184
- fs.writeFileSync(bridgePath, old);
217
+ .replace("'inspect-block.cjs']", "'inspect-block.cjs', 'task-scaffold-guard.cjs']");
218
+ fs.writeFileSync(oldPath, old);
185
219
  const manifest = readJson(manifestPath);
186
220
  manifest.files['.omp/extensions/cafekit-bridge.mjs'] = { sha256: crypto.createHash('sha256').update(old).digest('hex') };
187
221
  fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2));
188
222
 
189
223
  install(root, ['omp']);
190
- const installed = fs.readFileSync(bridgePath, 'utf8');
224
+ assert.equal(fs.existsSync(oldPath), false, 'the pristine .mjs bridge is removed');
225
+ assert.equal(readJson(manifestPath).files['.omp/extensions/cafekit-bridge.mjs'], undefined, 'and its record');
226
+ const installed = fs.readFileSync(path.join(root, '.omp', 'extensions', 'cafekit-bridge.js'), 'utf8');
191
227
  assert.deepEqual(mentionsName(installed), []);
192
228
  assert.equal(installed, fs.readFileSync(SOURCE_BRIDGE_PATH, 'utf8'));
193
229
  });
194
230
  });
195
231
 
232
+ test('omp upgrade keeps an edited, untracked or symlinked old .mjs bridge and says so', () => {
233
+ for (const shape of ['edited', 'untracked', 'symlink']) {
234
+ inTempProject((root) => {
235
+ install(root, ['omp']);
236
+ const oldPath = path.join(root, '.omp', 'extensions', 'cafekit-bridge.mjs');
237
+ const manifestPath = path.join(root, '.omp', 'cafekit-manifest.json');
238
+ const manifest = readJson(manifestPath);
239
+ if (shape === 'symlink') {
240
+ const target = path.join(root, 'my-bridge.mjs');
241
+ fs.writeFileSync(target, '// mine\n');
242
+ fs.symlinkSync(target, oldPath);
243
+ } else {
244
+ fs.writeFileSync(oldPath, '// edited by the user\n');
245
+ if (shape === 'edited') manifest.files['.omp/extensions/cafekit-bridge.mjs'] = { sha256: '0'.repeat(64) };
246
+ }
247
+ fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2));
248
+
249
+ const result = install(root, ['omp']);
250
+ assert.equal(fs.lstatSync(oldPath).isFile() || fs.lstatSync(oldPath).isSymbolicLink(), true, `${shape} .mjs is kept`);
251
+ assert.match(`${result.stdout}\n${result.stderr}`, /Preserved \d+ obsolete runtime item/, `${shape} is reported`);
252
+ });
253
+ }
254
+ });
255
+
196
256
  test('no source ships, registers or bridges the five legacy hooks', () => {
197
257
  for (const name of NAMES) {
198
258
  assert.equal(fs.existsSync(path.join(SRC, 'claude/hooks', name)), false, `claude ${name}`);