@haposoft/cafekit 0.18.0 → 0.18.2

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 (162) hide show
  1. package/README.md +12 -1
  2. package/bin/__tests__/claude-settings.test.js +123 -0
  3. package/bin/__tests__/codex-ask-guard.test.js +123 -0
  4. package/bin/__tests__/codex-ask-projection.test.js +51 -0
  5. package/bin/__tests__/codex-hooks-ownership.test.js +80 -1
  6. package/bin/__tests__/codex-hooks.test.js +938 -237
  7. package/bin/__tests__/codex-native-hook-boundaries.test.js +136 -0
  8. package/bin/__tests__/codex-native.test.js +222 -149
  9. package/bin/__tests__/codex-projection-parity.test.js +314 -0
  10. package/bin/__tests__/codex-quiet-hooks.test.js +170 -0
  11. package/bin/__tests__/codex-research-projection.test.js +70 -0
  12. package/bin/__tests__/codex-specs-projection.test.js +76 -0
  13. package/bin/__tests__/develop-contract.test.js +102 -1362
  14. package/bin/__tests__/docs-sync-on-demand.test.js +25 -20
  15. package/bin/__tests__/inspect-paths.test.js +183 -0
  16. package/bin/__tests__/installer-safety.test.js +335 -0
  17. package/bin/__tests__/legacy-authority-decoupled.test.js +14 -19
  18. package/bin/__tests__/legacy-hooks-retired.test.js +18 -8
  19. package/bin/__tests__/omp-bridge.test.js +66 -12
  20. package/bin/__tests__/omp-hooks.test.js +11 -5
  21. package/bin/__tests__/orca-session.test.js +7 -0
  22. package/bin/__tests__/package-inventory.test.js +1 -395
  23. package/bin/__tests__/privacy-paths.test.js +317 -0
  24. package/bin/__tests__/spec-narrowing.test.js +30 -134
  25. package/bin/__tests__/specs-v2-execution-closeout.test.js +0 -372
  26. package/bin/__tests__/test-runtime-dependency-closure.cjs +1 -2
  27. package/bin/__tests__/usage-hook-retired.test.js +5 -3
  28. package/bin/__tests__/validator-grounding.test.js +17 -2929
  29. package/bin/install.js +25 -0
  30. package/bin/lib/codex-install.js +134 -12
  31. package/bin/lib/context.js +1 -1
  32. package/bin/lib/managed-writer.js +2 -3
  33. package/bin/phases/claude-runtime.js +91 -25
  34. package/bin/phases/claude-settings.js +22 -1
  35. package/bin/phases/codex-hooks.js +96 -33
  36. package/bin/phases/codex-runtime.js +45 -49
  37. package/bin/phases/copy-payload.js +11 -28
  38. package/bin/phases/omp-runtime.js +6 -3
  39. package/package.json +2 -3
  40. package/src/claude/CLAUDE.md +4 -0
  41. package/src/claude/agents/code-auditor.md +9 -6
  42. package/src/claude/agents/debugger.md +3 -9
  43. package/src/claude/agents/docs-keeper.md +4 -7
  44. package/src/claude/agents/git-ops.md +2 -4
  45. package/src/claude/agents/implementer.md +6 -7
  46. package/src/claude/agents/inspector.md +2 -0
  47. package/src/claude/agents/project-manager.md +2 -2
  48. package/src/claude/agents/spec-maker.md +4 -4
  49. package/src/claude/agents/test-runner.md +8 -11
  50. package/src/claude/agents/ui-ux-designer.md +0 -1
  51. package/src/claude/gitignore +3 -0
  52. package/src/claude/hooks/__tests__/installed-root.test.js +111 -0
  53. package/src/claude/hooks/__tests__/precompact.test.js +206 -10
  54. package/src/claude/hooks/__tests__/privacy-block.test.js +27 -0
  55. package/src/claude/hooks/__tests__/session-writeenv.test.js +25 -0
  56. package/src/claude/hooks/__tests__/spec-gate.test.js +311 -340
  57. package/src/claude/hooks/__tests__/spec-state-touched.test.js +230 -8
  58. package/src/claude/hooks/__tests__/state.test.js +207 -7
  59. package/src/claude/hooks/__tests__/update-check.test.js +88 -0
  60. package/src/claude/hooks/agent.cjs +7 -6
  61. package/src/claude/hooks/inspect-block.cjs +15 -63
  62. package/src/claude/hooks/lib/inspect-paths.cjs +304 -0
  63. package/src/claude/hooks/lib/parser.cjs +1 -1
  64. package/src/claude/hooks/lib/privacy-command-analysis.cjs +191 -17
  65. package/src/claude/hooks/lib/privacy-paths.cjs +313 -0
  66. package/src/claude/hooks/lib/project-root.cjs +15 -0
  67. package/src/claude/hooks/lib/rules-reservation.cjs +36 -0
  68. package/src/claude/hooks/lib/spec-touch.cjs +67 -0
  69. package/src/claude/hooks/lib/update-check.cjs +77 -0
  70. package/src/claude/hooks/precompact.cjs +8 -15
  71. package/src/claude/hooks/privacy-block.cjs +23 -205
  72. package/src/claude/hooks/rules.cjs +6 -28
  73. package/src/claude/hooks/session.cjs +34 -117
  74. package/src/claude/hooks/spec-gate.cjs +17 -89
  75. package/src/claude/hooks/spec-state.cjs +89 -96
  76. package/src/claude/hooks/state.cjs +96 -51
  77. package/src/claude/migration-manifest.json +20 -8
  78. package/src/claude/output-styles/coding-level-0-eli5.md +9 -9
  79. package/src/claude/output-styles/coding-level-1-junior.md +3 -3
  80. package/src/claude/output-styles/coding-level-2-mid.md +3 -3
  81. package/src/claude/output-styles/coding-level-3-senior.md +2 -2
  82. package/src/claude/output-styles/coding-level-4-lead.md +5 -5
  83. package/src/claude/output-styles/coding-level-5-god.md +6 -6
  84. package/src/claude/rules/ai-dev-rules.md +1 -2
  85. package/src/claude/rules/hook-protocols.md +1 -1
  86. package/src/claude/rules/workflow.md +1 -1
  87. package/src/claude/runtime.json +0 -3
  88. package/src/claude/scripts/generate-skill-catalog.cjs +2 -2
  89. package/src/claude/scripts/provenance.cjs +14 -0
  90. package/src/claude/scripts/spec-receipt.cjs +0 -114
  91. package/src/claude/scripts/spec-resolver.cjs +41 -457
  92. package/src/claude/scripts/workflow-policy.cjs +3 -1801
  93. package/src/claude/settings/settings.json +1 -1
  94. package/src/claude/skills/agent-browser/SKILL.md +1 -1
  95. package/src/claude/skills/ai-multimodal/scripts/check_setup.py +8 -68
  96. package/src/claude/skills/ai-multimodal/scripts/gemini_batch_process.py +6 -28
  97. package/src/claude/skills/chrome-devtools/SKILL.md +14 -31
  98. package/src/claude/skills/code-review/SKILL.md +25 -108
  99. package/src/claude/skills/code-review/references/spec-compliance-review.md +4 -7
  100. package/src/claude/skills/develop/SKILL.md +22 -70
  101. package/src/claude/skills/develop/references/subagent-patterns.md +0 -6
  102. package/src/claude/skills/git/SKILL.md +1 -1
  103. package/src/claude/skills/git/references/worktree-blueprint.md +1 -1
  104. package/src/claude/skills/research/SKILL.md +24 -55
  105. package/src/claude/skills/sync/SKILL.md +12 -34
  106. package/src/claude/skills/sync/references/rebind-and-audit.md +2 -2
  107. package/src/claude/skills/test/SKILL.md +39 -123
  108. package/src/claude/skills/test/references/execution-strategy.md +11 -22
  109. package/src/claude/skills/ui-ux/SKILL.md +1 -1
  110. package/src/claude/skills/ui-ux-pro-max/SKILL.md +24 -79
  111. package/src/claude/skills/web-testing/SKILL.md +2 -20
  112. package/src/codex/AGENTS.md +8 -0
  113. package/src/codex/hooks/ask-guard.cjs +54 -0
  114. package/src/codex/hooks/inspect-block.cjs +11 -7
  115. package/src/codex/hooks/lib/inspect-paths.cjs +1 -199
  116. package/src/codex/hooks/lib/privacy-paths.cjs +1 -0
  117. package/src/codex/hooks/lib/privacy-state.cjs +98 -16
  118. package/src/codex/hooks/lib/rules-reservation.cjs +38 -0
  119. package/src/codex/hooks/lib/secret-keywords.cjs +1 -0
  120. package/src/codex/hooks/lib/spec-receipt.cjs +2 -30
  121. package/src/codex/hooks/lib/spec-session-touch.cjs +100 -0
  122. package/src/codex/hooks/lib/spec-touch.cjs +3 -0
  123. package/src/codex/hooks/lib/spec-utils.cjs +6 -45
  124. package/src/codex/hooks/lib/update-check.cjs +1 -0
  125. package/src/codex/hooks/precompact.cjs +39 -0
  126. package/src/codex/hooks/privacy-approval.cjs +28 -17
  127. package/src/codex/hooks/privacy-block.cjs +9 -215
  128. package/src/codex/hooks/rules.cjs +4 -30
  129. package/src/codex/hooks/secret-output-guardrail.cjs +1 -1
  130. package/src/codex/hooks/session.cjs +47 -11
  131. package/src/codex/hooks/spec-gate.cjs +32 -74
  132. package/src/codex/hooks/spec-state.cjs +68 -46
  133. package/src/codex/hooks/state.cjs +25 -4
  134. package/src/codex/hooks.json +39 -7
  135. package/src/codex/rules/hook-protocols.md +13 -6
  136. package/src/common/AGENTS.md +4 -10
  137. package/src/omp/extensions/cafekit-bridge.mjs +12 -4
  138. package/src/omp/hooks/privacy-block.cjs +24 -206
  139. package/bin/__tests__/change-firewall.semantic-firewall.test.js +0 -2133
  140. package/bin/__tests__/finalizer-pass-gate.semantic-firewall.test.js +0 -342
  141. package/bin/__tests__/release-preflight.semantic-firewall.test.js +0 -586
  142. package/bin/__tests__/review-receipt-c2.semantic-firewall.test.js +0 -378
  143. package/bin/__tests__/specs-v2-policy-and-scaffold.test.js +0 -665
  144. package/bin/__tests__/specs-v2-validator-grounder-contract.test.js +0 -1132
  145. package/src/claude/hooks/lib/context.cjs +0 -527
  146. package/src/claude/hooks/lib/detect.cjs +0 -474
  147. package/src/claude/scripts/change-firewall.cjs +0 -1748
  148. package/src/claude/scripts/spec-authoring-digest.cjs +0 -151
  149. package/src/claude/scripts/spec-authoring-validation.cjs +0 -126
  150. package/src/claude/scripts/spec-final-state.cjs +0 -290
  151. package/src/claude/scripts/spec-ground.cjs +0 -650
  152. package/src/claude/scripts/spec-readiness.cjs +0 -425
  153. package/src/claude/scripts/spec-scaffold.cjs +0 -2317
  154. package/src/claude/scripts/spec-semantic-model.cjs +0 -396
  155. package/src/claude/scripts/validate-spec-output.cjs +0 -3914
  156. package/src/claude/skills/code-review/references/verification-gate.md +0 -41
  157. package/src/claude/skills/specs/templates/design.md +0 -82
  158. package/src/claude/skills/specs/templates/requirements-init.md +0 -8
  159. package/src/claude/skills/specs/templates/requirements.md +0 -42
  160. package/src/claude/skills/specs/templates/spec-state.json +0 -56
  161. package/src/claude/skills/specs/templates/task.md +0 -47
  162. package/src/codex/rules/state-sync.md +0 -60
@@ -47,29 +47,34 @@ function makeStale(root) {
47
47
  execSync('git -c user.email=t@t -c user.name=t add -A && git -c user.email=t@t -c user.name=t commit -qm fixture', { cwd: root });
48
48
  }
49
49
 
50
- test('fresh claude install runs no docs-sync at SessionStart', () => {
51
- inTempProject((root) => {
52
- install(root, ['claude']);
53
- const settings = JSON.parse(fs.readFileSync(path.join(root, '.claude', 'settings.json'), 'utf8'));
54
- assert.equal(commands(settings, 'SessionStart').some((c) => c.includes('docs-sync.cjs')), false);
55
- assert.equal(fs.existsSync(path.join(root, '.claude', 'hooks', 'docs-sync.cjs')), true, 'the hook stays installed for on-demand use');
50
+ for (const [runtime, folder, config] of [['claude', '.claude', 'settings.json'], ['codex', '.codex', 'hooks.json']]) {
51
+ test(`fresh ${runtime} install runs no docs-sync at SessionStart`, () => {
52
+ inTempProject((root) => {
53
+ install(root, [runtime]);
54
+ const settings = JSON.parse(fs.readFileSync(path.join(root, folder, config), 'utf8'));
55
+ assert.equal(commands(settings, 'SessionStart').some((c) => c.includes('docs-sync.cjs')), false);
56
+ assert.equal(fs.existsSync(path.join(root, folder, 'hooks', 'docs-sync.cjs')), true, 'the hook stays installed for on-demand use');
57
+ });
56
58
  });
57
- });
58
59
 
59
- test('upgrade drops the SessionStart docs-sync entry', () => {
60
- inTempProject((root) => {
61
- install(root, ['claude']);
62
- const settingsPath = path.join(root, '.claude', 'settings.json');
63
- const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
64
- const kept = commands(settings, 'SessionStart');
65
- settings.hooks.SessionStart[0].hooks.push({ type: 'command', command: 'node "$CLAUDE_PROJECT_DIR/.claude/hooks/docs-sync.cjs"' });
66
- fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2));
67
- install(root, ['claude']);
68
- const after = commands(JSON.parse(fs.readFileSync(settingsPath, 'utf8')), 'SessionStart');
69
- assert.equal(after.some((c) => c.includes('docs-sync.cjs')), false);
70
- for (const command of kept) assert.ok(after.includes(command), `kept: ${command}`);
60
+ test(`${runtime} upgrade drops the managed SessionStart docs-sync entry`, () => {
61
+ inTempProject((root) => {
62
+ install(root, [runtime]);
63
+ const settingsPath = path.join(root, folder, config);
64
+ const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
65
+ const kept = commands(settings, 'SessionStart');
66
+ const command = runtime === 'claude'
67
+ ? 'node "$CLAUDE_PROJECT_DIR/.claude/hooks/docs-sync.cjs"'
68
+ : 'node ".codex/hooks/docs-sync.cjs"';
69
+ settings.hooks.SessionStart[0].hooks.push({ type: 'command', command });
70
+ fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2));
71
+ install(root, [runtime]);
72
+ const after = commands(JSON.parse(fs.readFileSync(settingsPath, 'utf8')), 'SessionStart');
73
+ assert.equal(after.some((c) => c.includes('docs-sync.cjs')), false);
74
+ for (const command of kept) assert.ok(after.includes(command), `kept: ${command}`);
75
+ });
71
76
  });
72
- });
77
+ }
73
78
 
74
79
  test('the docs skill runs docs-sync on demand', () => {
75
80
  for (const rel of ['SKILL.md', 'references/update-workflow.md']) {
@@ -0,0 +1,183 @@
1
+ 'use strict';
2
+
3
+ const assert = require('node:assert/strict');
4
+ const fs = require('node:fs');
5
+ const os = require('node:os');
6
+ const path = require('node:path');
7
+ const { spawnSync } = require('node:child_process');
8
+ const { test } = require('node:test');
9
+ const SOURCE = path.resolve(__dirname, '../../src');
10
+
11
+ // Invoke installed-layout hooks with stdin JSON: denial must be real, not a crash.
12
+ function fixture(host, run, variant = '') {
13
+ const temp = fs.mkdtempSync(path.join(os.tmpdir(), 'ck-inspect-'));
14
+ const root = path.join(temp, 'build/app');
15
+ const link = path.join(temp, 'project-link');
16
+ const hooks = path.join(root, `.${host}/hooks`);
17
+ try {
18
+ fs.mkdirSync(root, { recursive: true });
19
+ fs.symlinkSync(root, link, 'dir');
20
+ fs.cpSync(path.join(SOURCE, `${host}/hooks`), hooks, { recursive: true });
21
+ if (host === 'codex') {
22
+ for (const name of ['runtime-path-safety', 'privacy-command-analysis', 'privacy-paths', 'secret-keywords', 'inspect-paths']) {
23
+ const source = path.join(SOURCE, `claude/hooks/lib/${name}.cjs`);
24
+ // Before the shared lib exists, exercise the original native implementation.
25
+ if (fs.existsSync(source)) fs.copyFileSync(source, path.join(hooks, `lib/${name}.cjs`));
26
+ }
27
+ }
28
+ const cwd = variant === 'nested' ? path.join(root, 'packages/x') : root;
29
+ fs.mkdirSync(cwd, { recursive: true });
30
+ const projectRoot = variant === 'symlink' ? link : root;
31
+ run({ root, temp, cwd, hook(tool_name, tool_input, payload = {}) {
32
+ const log = path.join(hooks, '.logs/hook-log.jsonl');
33
+ const before = fs.existsSync(log) ? fs.readFileSync(log, 'utf8') : '';
34
+ const result = spawnSync(process.execPath, [path.join(hooks, 'inspect-block.cjs')], {
35
+ cwd, encoding: 'utf8', input: JSON.stringify({ cwd, tool_name, tool_input, ...payload }),
36
+ env: { ...process.env, CLAUDE_PROJECT_DIR: projectRoot },
37
+ });
38
+ const after = fs.existsSync(log) ? fs.readFileSync(log, 'utf8') : '';
39
+ assert.doesNotMatch(after.slice(before.length), /"status":"crash"/, result.stderr);
40
+ return result;
41
+ } });
42
+ } finally { fs.rmSync(temp, { recursive: true, force: true }); }
43
+ }
44
+
45
+ const cases = [];
46
+ function add(name, tool, input, deny = false, variant = '', payload = {}) {
47
+ cases.push({ name, tool, input, deny, variant, payload });
48
+ }
49
+ function shell(command, deny = false) { add(command, 'Bash', { command }, deny); }
50
+ add('Read node_modules', 'Read', { file_path: 'node_modules/x/index.js' }, true);
51
+ add('Read absolute dist', 'Read', ({ root }) => ({ file_path: `${root}/dist/a.js` }), true);
52
+ add('Glob broad', 'Glob', { pattern: '**/*' }, true);
53
+ add('Glob dist', 'Glob', { pattern: '*.js', path: 'dist' }, true);
54
+ add('Grep path', 'Grep', { pattern: 'foo', path: 'node_modules' }, true);
55
+ add('Grep glob', 'Grep', { pattern: 'foo', glob: 'dist/**' }, true);
56
+ for (const command of [
57
+ 'cat dist/index.js', 'nl -ba dist/a.js', 'sed -n 1,5p build/x',
58
+ 'grep -rn foo node_modules', 'rg foo dist', 'find ./dist', 'ls node_modules',
59
+ 'npm exec cat ./dist/index.js', 'npx cat dist/a.js', 'npm exec -- cat dist/a',
60
+ 'FOO=1 cat dist/a', 'env FOO=1 cat dist/a', 'sudo -u root cat dist/a',
61
+ 'vite build --outDir ./dist $(cat dist/index.js)', 'echo `cat dist/a`', 'echo "$(cat dist/a)"',
62
+ ]) shell(command, true);
63
+ add('Bash tilde path', 'Bash', ({ root }) => ({ command: `cat ~/${path.relative(os.homedir(), root)}/dist/a` }), true);
64
+ add('missing tool name', undefined, { path: './dist/index.js' }, true);
65
+ add('exec_command reader', 'exec_command', { cmd: 'cat dist/index.js' }, true);
66
+ for (const tool of ['Edit', 'Write', 'MultiEdit']) {
67
+ add(`${tool} write`, tool, ({ root }) => ({ file_path: `${root}/scripts/build/release.js` }));
68
+ }
69
+ add('NotebookEdit write', 'NotebookEdit', { notebook_path: 'dist/a.ipynb' });
70
+ add('patch package main', 'apply_patch', { command: '*** Begin Patch\n*** Update File: package.json\n+"main": "./dist/index.js"\n*** End Patch' });
71
+ add('patch Add File', 'apply_patch', { command: '*** Begin Patch\n*** Add File: src/build/config.ts\n+x\n*** End Patch' });
72
+ for (const pattern of ['target', 'node_modules']) add(`Grep regex ${pattern}`, 'Grep', { pattern });
73
+ for (const command of [
74
+ '.agents/skills/.venv/bin/python3 scripts/search.py q', '.claude/skills/.venv/bin/python3 scripts/x.py',
75
+ 'python3 -m venv .venv', 'source .venv/bin/activate && pytest',
76
+ 'node_modules/.bin/eslint src', 'npx eslint dist/', 'python3 scripts/x.py build/out',
77
+ 'tsc --outDir ./build', 'vite build --outDir ./dist', 'npm run build -- --outDir ./dist',
78
+ 'cargo build --target-dir ./target', 'cd build && cmake ..', 'tree -I node_modules',
79
+ 'grep -rn foo . --exclude-dir node_modules', "rg --glob '!**/node_modules/**' foo",
80
+ 'find . -path ./node_modules -prune -o -print', 'echo concat vendor', "echo '$(cat dist/a)'",
81
+ "git commit -F - <<'EOF'\nls node_modules cleanup\nEOF",
82
+ ]) shell(command);
83
+ add('Grep excluded glob', 'Grep', { pattern: 'foo', glob: '!dist/**' });
84
+ for (const variant of ['', 'nested', 'symlink']) {
85
+ add(`project above blocked segment ${variant || 'root'}`, 'Read', ({ root }) => ({ file_path: `${fs.realpathSync(root)}/src/a.js` }), false, variant);
86
+ }
87
+ add('Read ordinary', 'Read', { file_path: 'src/a.js' });
88
+ shell('cat src/a.js');
89
+
90
+ // Wrapper options, nested substitutions, and structured paths.
91
+ for (const command of [
92
+ 'time -p env -u FOO BAR=1 command -- cat dist/a', 'nice -n 5 nohup cat dist/a',
93
+ 'xargs -I {} cat dist/a', 'pnpm exec -- cat dist/a', 'pnpx --yes cat dist/a',
94
+ 'yarn exec -- cat dist/a', 'bunx --bun cat dist/a', 'npx -p cat -- cat dist/a',
95
+ 'echo "$(echo $(cat dist/a))"', 'echo ok; cat dist/a', 'echo ok\ncat dist/a',
96
+ 'echo ok | cat dist/a', 'rg -e foo dist/a', 'awk \'{print $1}\' dist/a',
97
+ 'less dist/a', 'more dist/a', 'bat dist/a', 'wc dist/a', 'du dist', 'file dist/a', 'stat dist/a',
98
+ 'cat -n dist/a', 'wc -c dist/a', 'du -s dist', 'ls -L dist',
99
+ 'echo $( (cat dist/a) )', 'env -S "cat dist/a"', 'env --split-string="cat" dist/a',
100
+ 'npm exec -c "cat dist/a"', 'npx --call="cat dist/a"',
101
+ 'cat src/a <<\\EOF\nprose\nEOF\ncat dist/a', "cat src/a <<E'OF'\nprose\nEOF\ncat dist/a",
102
+ ]) shell(command, true);
103
+ for (const command of [
104
+ 'grep --include node_modules foo src', 'rg -e node_modules src',
105
+ 'rg --exclude-dir=node_modules foo src', 'tree -P dist',
106
+ 'echo "cat dist/a"', "echo '`cat dist/a`'", 'cat src/a.js <<EOF\ncat dist/a\nEOF',
107
+ ]) shell(command);
108
+ add('nested paths', 'mcp__files__read', { operations: [{ paths: ['dist/a'] }] }, true);
109
+ add('notebook path reader', 'Read', { notebook_path: 'dist/a.ipynb' }, true);
110
+ add('unknown command reader', 'mcp__shell', { command: 'cat dist/a' }, true);
111
+ add('missing name command reader', undefined, { command: 'cat dist/a' }, true);
112
+ add('write skips command too', 'Write', { file_path: 'dist/a', command: 'cat dist/a' });
113
+
114
+ // Output redirections are writes; search option values are not patterns.
115
+ for (const command of [
116
+ "cat > dist/index.html <<'EOF'\n<html></html>\nEOF",
117
+ 'ls src > build/files.txt', 'wc -l src/a.ts > coverage/summary.txt',
118
+ 'grep x src 2> build/err', 'ls src >> build/x', 'ls src 2>&1', 'ls src>build/x',
119
+ 'rg --max-depth 2 target src', 'rg -j 4 build src', 'rg -M 200 dist src',
120
+ 'rg --sort path target src', 'grep -rn -d skip build src',
121
+ ]) shell(command);
122
+ for (const command of ['cat < dist/a', 'cat <dist/a']) shell(command, true);
123
+
124
+ add('Grep glob:"**/*.ts", path:"src"', 'Grep', { pattern: 'x', glob: '**/*.ts', path: 'src' });
125
+ add('Glob pattern:"**/*.ts", path:"src"', 'Glob', { pattern: '**/*.ts', path: 'src' });
126
+ shell("rg -g '**/*.ts' x src");
127
+ add('Glob broad without path', 'Glob', { pattern: '**/*' }, true);
128
+ add('Grep glob:"**/*.ts", path:"."', 'Grep', { pattern: 'x', glob: '**/*.ts', path: '.' }, true);
129
+ add('Glob broad absolute project root', 'Glob', ({ root }) => ({ pattern: '**/*.ts', path: root }), true);
130
+ add('Glob broad path:".."', 'Glob', { pattern: '**/*', path: '..' }, true);
131
+ add('Glob broad filesystem root', 'Glob', { pattern: '**/*', path: '/' }, true);
132
+ add('Glob broad home', 'Glob', { pattern: '**/*', path: '~' }, true);
133
+ add('Glob broad normalized project root', 'Glob', { pattern: '**/*', path: 'src/..' }, true);
134
+ add('Glob broad blocked directory', 'Glob', { pattern: '**/*.ts', path: 'node_modules' }, true);
135
+ shell("rg --glob '**/*' symbol .", true);
136
+ shell("rg --glob '**/*' symbol; cat src/file.ts", true);
137
+ shell("cd ..; rg --glob '**/*' symbol src", true);
138
+ shell("env -C .. rg --glob '**/*' symbol src", true);
139
+ for (const command of [
140
+ "rg -E utf8 -g '**/*' x", "rg --ignore-file .gitignore -g '**/*' x",
141
+ "rg --engine pcre2 -g '**/*' x", "rg --type-add 'web:*.html' -g '**/*' x",
142
+ "echo . | xargs rg -g '**/*' x src", "env -C.. rg -g '**/*' x src",
143
+ "builtin cd .. && rg -g '**/*' x src",
144
+ ]) shell(command, true);
145
+ add('Bash broad glob with non-string session cwd', 'Bash', { command: "rg -g '**/*' x src" }, true, '', { cwd: 1 });
146
+ add('Bash broad glob with non-string input cwd', 'Bash', { command: "rg -g '**/*' x src", cwd: 1 }, true);
147
+ for (const key of ['cwd', 'workdir']) {
148
+ add(`Bash broad glob with ${key}:".."`, 'Bash', { command: "rg -g '**/*' x src", [key]: '..' }, true);
149
+ }
150
+ for (const flag of [
151
+ '--json', '-S', '--smart-case', '-N', '--no-heading', '--heading', '--column', '--vimgrep',
152
+ '--follow', '--no-ignore', '--no-ignore-dot', '--no-ignore-exclude', '--no-ignore-files',
153
+ '--no-ignore-global', '--no-ignore-parent', '--no-ignore-vcs', '--no-ignore-messages',
154
+ '--no-ignore-file-case-insensitive', '--count-matches', '-b', '--trim',
155
+ '-C3', '-A2', '-m5', '-tts',
156
+ ]) shell(`rg -g '**/*.ts' ${flag} x src`);
157
+ for (const command of [
158
+ "eval 'cd ..'; rg -g '**/*' x src", "source setup.sh; rg -g '**/*' x src",
159
+ ". setup.sh; rg -g '**/*' x src", "env -iC.. rg -g '**/*' x src",
160
+ ]) shell(command, true);
161
+
162
+ for (const host of ['claude', 'codex']) {
163
+ for (const entry of cases) {
164
+ if (host === 'codex' && entry.payload.cwd === 1) continue;
165
+ test(`inspect-paths ${host}: ${entry.name}`, () => fixture(host, (context) => {
166
+ const input = typeof entry.input === 'function' ? entry.input(context) : entry.input;
167
+ const result = context.hook(entry.tool, input, entry.payload);
168
+ assert.equal(result.status, entry.deny ? 2 : 0, result.stderr || result.stdout);
169
+ if (entry.deny) {
170
+ if (host === 'claude') assert.equal(JSON.parse(result.stdout).hookSpecificOutput.permissionDecision, 'deny');
171
+ assert.match(result.stderr, /SCOPE LIMIT EXCEEDED/);
172
+ } else assert.equal(result.stdout, '');
173
+ }, entry.variant));
174
+ }
175
+ }
176
+
177
+ test('inspect-paths lib blocks broad glob with non-string context.cwd', () => {
178
+ const { inspectInput } = require('../../src/claude/hooks/lib/inspect-paths.cjs');
179
+ const result = inspectInput('Bash', { command: "rg -g '**/*' x src" }, {
180
+ cwd: 1, projectRoot: process.cwd(),
181
+ });
182
+ assert.equal(result.broadGlob, '**/*');
183
+ });
@@ -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', [
@@ -19,7 +19,6 @@ const LEGACY_FILES = [
19
19
  'semantic-review-authority.cjs',
20
20
  'task-scaffold-guard.cjs',
21
21
  ];
22
- const MIGRATION = /Legacy spec\.json closeout is no longer supported/;
23
22
 
24
23
  function inTempProject(run) {
25
24
  const root = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'cafekit-decoupled-')));
@@ -122,26 +121,24 @@ function checkProcessFirst(kind) {
122
121
  });
123
122
  }
124
123
 
125
- function checkLegacyCloseout(kind) {
126
- for (const status of ['done', 'completed']) {
124
+ // CafeKit no longer reads spec.json packets: a leftover one, closed or mid-execution, never
125
+ // blocks Stop on its own, and beside a process-first packet the decision is that packet's alone.
126
+ function checkLegacyIgnored(kind) {
127
+ for (const legacy of [{ status: 'done' }, { status: 'completed' }, { status: 'in_progress', doneTask: true }]) {
127
128
  inTempProject((root) => {
128
- writeLegacy(root, { status });
129
+ writeLegacy(root, legacy);
129
130
  removeLegacyFiles(root);
130
- assert.match(blockReason(stopGate(root, kind)), MIGRATION, `${kind} ${status}`);
131
+ assert.equal(stopGate(root, kind), '', `${kind} ${legacy.status} legacy packet alone`);
132
+ writeProcessFirst(root, { receipt: false });
133
+ const reason = blockReason(stopGate(root, kind));
134
+ assert.match(reason, /missing_receipt/);
135
+ assert.doesNotMatch(reason, /task-R1-01-legacy\.md/);
136
+ writeProcessFirst(root, { receipt: true });
137
+ assert.equal(stopGate(root, kind), '', `${kind} ${legacy.status} legacy packet beside a proven process-first packet`);
131
138
  });
132
139
  }
133
140
  }
134
141
 
135
- function checkLegacyMidExecution(kind) {
136
- inTempProject((root) => {
137
- writeLegacy(root, { status: 'in_progress', doneTask: true });
138
- removeLegacyFiles(root);
139
- const reason = blockReason(stopGate(root, kind));
140
- assert.doesNotMatch(reason, MIGRATION);
141
- assert.match(reason, /task-R1-01-legacy\.md/);
142
- });
143
- }
144
-
145
142
  function checkSession(kind) {
146
143
  inTempProject((root) => {
147
144
  removeLegacyFiles(root);
@@ -151,9 +148,7 @@ function checkSession(kind) {
151
148
 
152
149
  test('claude gate passes and blocks process-first with and without the legacy files', () => checkProcessFirst('claude'));
153
150
  test('codex gate passes and blocks process-first with and without the legacy files', () => checkProcessFirst('codex'));
154
- test('claude gate blocks legacy 2.1 closeout with the migration message', () => checkLegacyCloseout('claude'));
155
- test('codex gate blocks legacy 2.1 closeout with the migration message', () => checkLegacyCloseout('codex'));
156
- test('claude gate still receipt-checks a legacy 2.1 packet mid-execution', () => checkLegacyMidExecution('claude'));
157
- test('codex gate still receipt-checks a legacy 2.1 packet mid-execution', () => checkLegacyMidExecution('codex'));
151
+ test('claude gate ignores a leftover legacy packet', () => checkLegacyIgnored('claude'));
152
+ test('codex gate ignores a leftover legacy packet', () => checkLegacyIgnored('codex'));
158
153
  test('claude session starts without the legacy files', () => checkSession('claude'));
159
154
  test('codex session starts without the legacy files', () => checkSession('codex'));