@haposoft/cafekit 0.16.7 → 0.18.1

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 (231) hide show
  1. package/README.md +14 -22
  2. package/bin/__tests__/codex-hooks-ownership.test.js +2 -2
  3. package/bin/__tests__/codex-hooks.test.js +169 -272
  4. package/bin/__tests__/codex-native.test.js +100 -202
  5. package/bin/__tests__/develop-contract.test.js +122 -1413
  6. package/bin/__tests__/docs-sync-on-demand.test.js +93 -0
  7. package/bin/__tests__/grok-envelope.test.js +0 -56
  8. package/bin/__tests__/hook-payload.test.js +4 -6
  9. package/bin/__tests__/legacy-authority-decoupled.test.js +154 -0
  10. package/bin/__tests__/legacy-hooks-retired.test.js +221 -0
  11. package/bin/__tests__/omp-bridge.test.js +20 -17
  12. package/bin/__tests__/omp-hooks.test.js +0 -19
  13. package/bin/__tests__/optional-skill-inventory.test.js +112 -4
  14. package/bin/__tests__/orca-session.test.js +0 -2
  15. package/bin/__tests__/package-inventory.test.js +90 -925
  16. package/bin/__tests__/runtime-dir.test.js +0 -3
  17. package/bin/__tests__/runtime-schema.test.js +3 -2
  18. package/bin/__tests__/skill-routing-source.test.js +27 -15
  19. package/bin/__tests__/spec-narrowing.test.js +30 -161
  20. package/bin/__tests__/specs-v2-execution-closeout.test.js +0 -437
  21. package/bin/__tests__/test-runtime-dependency-closure.cjs +1 -2
  22. package/bin/__tests__/ui-ux-skill.test.js +101 -0
  23. package/bin/__tests__/usage-hook-retired.test.js +114 -0
  24. package/bin/__tests__/validator-grounding.test.js +18 -2949
  25. package/bin/lib/managed-writer.js +2 -3
  26. package/bin/phases/codex-runtime.js +27 -0
  27. package/bin/phases/copy-payload.js +13 -28
  28. package/bin/phases/omp-runtime.js +25 -0
  29. package/package.json +2 -3
  30. package/src/claude/CLAUDE.md +3 -10
  31. package/src/claude/agents/brainstormer.md +7 -3
  32. package/src/claude/agents/code-auditor.md +44 -56
  33. package/src/claude/agents/debugger.md +8 -88
  34. package/src/claude/agents/deployer.md +3 -5
  35. package/src/claude/agents/docs-keeper.md +6 -14
  36. package/src/claude/agents/git-ops.md +2 -4
  37. package/src/claude/agents/implementer.md +12 -16
  38. package/src/claude/agents/inspector.md +4 -3
  39. package/src/claude/agents/project-manager.md +9 -17
  40. package/src/claude/agents/researcher.md +9 -3
  41. package/src/claude/agents/spec-maker.md +15 -15
  42. package/src/claude/agents/test-runner.md +8 -11
  43. package/src/claude/agents/ui-ux-designer.md +0 -1
  44. package/src/claude/hooks/__tests__/fixtures/statusline-default.golden +1 -1
  45. package/src/claude/hooks/__tests__/spec-gate.test.js +244 -333
  46. package/src/claude/hooks/__tests__/spec-state-touched.test.js +160 -0
  47. package/src/claude/hooks/__tests__/state.test.js +68 -0
  48. package/src/claude/hooks/__tests__/statusline.test.js +140 -11
  49. package/src/claude/hooks/agent.cjs +2 -1
  50. package/src/claude/hooks/lib/hook-payload.cjs +2 -2
  51. package/src/claude/hooks/rules.cjs +2 -1
  52. package/src/claude/hooks/session.cjs +0 -1
  53. package/src/claude/hooks/spec-gate.cjs +17 -83
  54. package/src/claude/hooks/spec-state.cjs +142 -39
  55. package/src/claude/hooks/state.cjs +21 -2
  56. package/src/claude/migration-manifest.json +42 -22
  57. package/src/claude/output-styles/coding-level-0-eli5.md +9 -9
  58. package/src/claude/output-styles/coding-level-1-junior.md +3 -3
  59. package/src/claude/output-styles/coding-level-2-mid.md +3 -3
  60. package/src/claude/output-styles/coding-level-3-senior.md +2 -2
  61. package/src/claude/output-styles/coding-level-4-lead.md +5 -5
  62. package/src/claude/output-styles/coding-level-5-god.md +6 -6
  63. package/src/claude/rules/ai-dev-rules.md +2 -3
  64. package/src/claude/rules/hook-protocols.md +8 -15
  65. package/src/claude/rules/manage-docs.md +9 -23
  66. package/src/claude/rules/orchestrator.md +3 -4
  67. package/src/claude/rules/review-audit-self-decision.md +3 -3
  68. package/src/claude/rules/skill-domain-routing.md +1 -3
  69. package/src/claude/rules/skill-workflow-routing.md +4 -5
  70. package/src/claude/rules/state-sync.md +3 -12
  71. package/src/claude/rules/workflow.md +11 -19
  72. package/src/claude/runtime.schema.json +5 -3
  73. package/src/claude/scripts/generate-skill-catalog.cjs +7 -6
  74. package/src/claude/scripts/provenance.cjs +23 -5
  75. package/src/claude/scripts/spec-receipt.cjs +24 -121
  76. package/src/claude/scripts/spec-resolver.cjs +41 -458
  77. package/src/claude/scripts/workflow-policy.cjs +3 -1801
  78. package/src/claude/settings/settings.json +1 -32
  79. package/src/claude/skills/agent-browser/SKILL.md +1 -1
  80. package/src/claude/skills/{question → ask}/SKILL.md +9 -75
  81. package/src/claude/skills/brainstorm/SKILL.md +43 -131
  82. package/src/claude/skills/chrome-devtools/SKILL.md +14 -31
  83. package/src/claude/skills/code-review/SKILL.md +29 -81
  84. package/src/claude/skills/code-review/references/spec-compliance-review.md +4 -8
  85. package/src/claude/skills/debug/SKILL.md +29 -149
  86. package/src/claude/skills/develop/SKILL.md +26 -83
  87. package/src/claude/skills/develop/references/parallel-waves.md +0 -8
  88. package/src/claude/skills/develop/references/quality-gate.md +15 -13
  89. package/src/claude/skills/develop/references/subagent-patterns.md +0 -6
  90. package/src/claude/skills/docs/SKILL.md +2 -2
  91. package/src/claude/skills/docs/references/update-workflow.md +1 -1
  92. package/src/claude/skills/fix/SKILL.md +153 -0
  93. package/src/claude/skills/{hotfix → fix}/references/diagnosis-protocol.md +2 -2
  94. package/src/claude/skills/{hotfix → fix}/references/parallel-patterns.md +2 -2
  95. package/src/claude/skills/{hotfix → fix}/references/prevention-gate.md +3 -3
  96. package/src/claude/skills/{hotfix → fix}/references/review-cycle.md +1 -1
  97. package/src/claude/skills/git/SKILL.md +48 -9
  98. package/src/claude/skills/git/references/commit-protocols.md +35 -16
  99. package/src/claude/skills/git/references/worktree-blueprint.md +42 -6
  100. package/src/claude/skills/orca/SKILL.md +9 -8
  101. package/src/claude/skills/research/SKILL.md +17 -46
  102. package/src/claude/skills/scout/SKILL.md +61 -0
  103. package/src/claude/skills/{inspect → scout}/references/internal-inspection.md +26 -10
  104. package/src/claude/skills/specs/SKILL.md +22 -29
  105. package/src/claude/skills/specs/references/review.md +8 -6
  106. package/src/claude/skills/specs/references/templates.md +34 -41
  107. package/src/claude/skills/sync/SKILL.md +16 -45
  108. package/src/claude/skills/sync/references/rebind-and-audit.md +59 -0
  109. package/src/claude/skills/sync/references/sync-protocols.md +4 -13
  110. package/src/claude/skills/test/SKILL.md +39 -104
  111. package/src/claude/skills/test/references/execution-strategy.md +93 -21
  112. package/src/claude/skills/test/references/failure-triage.md +1 -1
  113. package/src/claude/skills/ui-ux/LICENSE +21 -0
  114. package/src/claude/skills/ui-ux/SKILL.md +497 -0
  115. package/src/claude/skills/ui-ux/references/brand-tokens.md +90 -0
  116. package/src/claude/skills/ui-ux/references/budgets.md +113 -0
  117. package/src/claude/skills/ui-ux/references/checklist.md +271 -0
  118. package/src/claude/skills/ui-ux/references/components/accordion.md +246 -0
  119. package/src/claude/skills/ui-ux/references/components/avatar.md +153 -0
  120. package/src/claude/skills/ui-ux/references/components/banner.md +43 -0
  121. package/src/claude/skills/ui-ux/references/components/breadcrumb.md +201 -0
  122. package/src/claude/skills/ui-ux/references/components/button.md +142 -0
  123. package/src/claude/skills/ui-ux/references/components/card.md +113 -0
  124. package/src/claude/skills/ui-ux/references/components/charts.md +376 -0
  125. package/src/claude/skills/ui-ux/references/components/chat.md +132 -0
  126. package/src/claude/skills/ui-ux/references/components/choice-controls.md +369 -0
  127. package/src/claude/skills/ui-ux/references/components/comment-thread.md +17 -0
  128. package/src/claude/skills/ui-ux/references/components/description-list.md +116 -0
  129. package/src/claude/skills/ui-ux/references/components/empty-state.md +98 -0
  130. package/src/claude/skills/ui-ux/references/components/file-upload.md +107 -0
  131. package/src/claude/skills/ui-ux/references/components/inline-edit.md +128 -0
  132. package/src/claude/skills/ui-ux/references/components/input.md +161 -0
  133. package/src/claude/skills/ui-ux/references/components/list-row.md +56 -0
  134. package/src/claude/skills/ui-ux/references/components/loading.md +198 -0
  135. package/src/claude/skills/ui-ux/references/components/logo.md +147 -0
  136. package/src/claude/skills/ui-ux/references/components/otp-input.md +43 -0
  137. package/src/claude/skills/ui-ux/references/components/quantity-input.md +100 -0
  138. package/src/claude/skills/ui-ux/references/components/range-slider.md +16 -0
  139. package/src/claude/skills/ui-ux/references/components/small-controls.md +340 -0
  140. package/src/claude/skills/ui-ux/references/components/sortable-header.md +86 -0
  141. package/src/claude/skills/ui-ux/references/components/tag-input.md +14 -0
  142. package/src/claude/skills/ui-ux/references/components/timeline.md +38 -0
  143. package/src/claude/skills/ui-ux/references/components/tree.md +30 -0
  144. package/src/claude/skills/ui-ux/references/design-process.md +650 -0
  145. package/src/claude/skills/ui-ux/references/layouts/app-kanban.html +303 -0
  146. package/src/claude/skills/ui-ux/references/layouts/app.md +1369 -0
  147. package/src/claude/skills/ui-ux/references/layouts/form.md +430 -0
  148. package/src/claude/skills/ui-ux/references/layouts/overlay.md +552 -0
  149. package/src/claude/skills/ui-ux/references/layouts/pricing.md +204 -0
  150. package/src/claude/skills/ui-ux/references/locked-rules.md +35 -0
  151. package/src/claude/skills/ui-ux/references/principles.md +408 -0
  152. package/src/claude/skills/ui-ux/references/refactor.md +245 -0
  153. package/src/claude/skills/ui-ux/references/responsive.md +195 -0
  154. package/src/claude/skills/ui-ux/references/review.md +528 -0
  155. package/src/claude/skills/ui-ux/references/rules-color.md +623 -0
  156. package/src/claude/skills/ui-ux/references/rules-form.md +235 -0
  157. package/src/claude/skills/ui-ux/references/rules-state.md +680 -0
  158. package/src/claude/skills/ui-ux/references/rules-type.md +369 -0
  159. package/src/claude/skills/ui-ux/references/styles.md +311 -0
  160. package/src/claude/skills/ui-ux/references/system.md +219 -0
  161. package/src/claude/skills/ui-ux/references/tailwind-v4-traps.md +237 -0
  162. package/src/claude/skills/ui-ux/references/tokens.css +362 -0
  163. package/src/claude/skills/ui-ux/scripts/lint-skill.mjs +216 -0
  164. package/src/claude/skills/ui-ux/scripts/package.json +9 -0
  165. package/src/claude/skills/ui-ux/scripts/probe.mjs +4447 -0
  166. package/src/claude/skills/ui-ux-pro-max/SKILL.md +25 -80
  167. package/src/claude/skills/web-testing/SKILL.md +2 -20
  168. package/src/claude/status.cjs +149 -144
  169. package/src/codex/AGENTS.md +8 -13
  170. package/src/codex/hooks/agent.cjs +2 -1
  171. package/src/codex/hooks/lib/spec-receipt.cjs +4 -32
  172. package/src/codex/hooks/lib/spec-utils.cjs +6 -52
  173. package/src/codex/hooks/rules.cjs +2 -1
  174. package/src/codex/hooks/session.cjs +0 -2
  175. package/src/codex/hooks/spec-gate.cjs +13 -81
  176. package/src/codex/hooks/spec-state.cjs +37 -41
  177. package/src/codex/hooks.json +0 -20
  178. package/src/codex/rules/state-sync.md +3 -12
  179. package/src/common/AGENTS.md +6 -12
  180. package/src/omp/extensions/cafekit-bridge.mjs +6 -6
  181. package/bin/__tests__/change-firewall.semantic-firewall.test.js +0 -2133
  182. package/bin/__tests__/finalizer-pass-gate.semantic-firewall.test.js +0 -342
  183. package/bin/__tests__/release-preflight.semantic-firewall.test.js +0 -586
  184. package/bin/__tests__/review-receipt-c2.semantic-firewall.test.js +0 -378
  185. package/bin/__tests__/specs-v2-policy-and-scaffold.test.js +0 -665
  186. package/bin/__tests__/specs-v2-validator-grounder-contract.test.js +0 -1132
  187. package/src/claude/hooks/__tests__/completion-authority.test.js +0 -990
  188. package/src/claude/hooks/__tests__/semantic-review-authority.test.js +0 -259
  189. package/src/claude/hooks/completion-authority-check.cjs +0 -56
  190. package/src/claude/hooks/completion-authority-state.cjs +0 -527
  191. package/src/claude/hooks/completion-authority.cjs +0 -163
  192. package/src/claude/hooks/lib/context.cjs +0 -616
  193. package/src/claude/hooks/lib/detect.cjs +0 -474
  194. package/src/claude/hooks/semantic-review-authority.cjs +0 -154
  195. package/src/claude/hooks/task-scaffold-guard.cjs +0 -114
  196. package/src/claude/hooks/usage.cjs +0 -197
  197. package/src/claude/scripts/change-firewall.cjs +0 -1748
  198. package/src/claude/scripts/spec-authoring-digest.cjs +0 -151
  199. package/src/claude/scripts/spec-authoring-validation.cjs +0 -126
  200. package/src/claude/scripts/spec-final-state.cjs +0 -290
  201. package/src/claude/scripts/spec-ground.cjs +0 -650
  202. package/src/claude/scripts/spec-readiness.cjs +0 -425
  203. package/src/claude/scripts/spec-scaffold.cjs +0 -2317
  204. package/src/claude/scripts/spec-semantic-model.cjs +0 -396
  205. package/src/claude/scripts/validate-spec-output.cjs +0 -3941
  206. package/src/claude/skills/code-review/references/verification-gate.md +0 -46
  207. package/src/claude/skills/delegate/SKILL.md +0 -124
  208. package/src/claude/skills/delegate/references/codex-delegation.md +0 -71
  209. package/src/claude/skills/delegate/references/grok-delegation.md +0 -84
  210. package/src/claude/skills/hotfix/SKILL.md +0 -289
  211. package/src/claude/skills/inspect/SKILL.md +0 -142
  212. package/src/claude/skills/loop/SKILL.md +0 -114
  213. package/src/claude/skills/loop/references/bounded-loop-protocol.md +0 -104
  214. package/src/claude/skills/loop/references/metric-and-guard-contract.md +0 -76
  215. package/src/claude/skills/route/SKILL.md +0 -94
  216. package/src/claude/skills/route/references/agent-timing.md +0 -46
  217. package/src/claude/skills/route/references/chaining-patterns.md +0 -53
  218. package/src/claude/skills/route/references/task-taxonomy.md +0 -48
  219. package/src/claude/skills/specs/templates/design.md +0 -82
  220. package/src/claude/skills/specs/templates/requirements-init.md +0 -8
  221. package/src/claude/skills/specs/templates/requirements.md +0 -42
  222. package/src/claude/skills/specs/templates/spec-state.json +0 -56
  223. package/src/claude/skills/specs/templates/task.md +0 -47
  224. package/src/codex/hooks/completion-authority-check.cjs +0 -67
  225. package/src/codex/hooks/completion-authority-state.cjs +0 -527
  226. package/src/codex/hooks/completion-authority.cjs +0 -109
  227. package/src/codex/hooks/semantic-review-authority.cjs +0 -130
  228. package/src/codex/hooks/task-scaffold-guard.cjs +0 -63
  229. /package/src/claude/skills/{question → ask}/templates/question.md +0 -0
  230. /package/src/claude/skills/{hotfix → fix}/references/escalation-tactics.md +0 -0
  231. /package/src/claude/skills/{hotfix → fix}/references/workflow-specialized.md +0 -0
@@ -0,0 +1,93 @@
1
+ 'use strict';
2
+
3
+ // docs-sync printed "Docs sync needed" at every Claude SessionStart, and nothing ever
4
+ // cleared it. It now runs only when the docs skill asks for it. The skill text is shared
5
+ // with Codex, whose copy crashes silently on an empty stdin, so the command feeds `{}`.
6
+ // Each case names the failure it prevents.
7
+
8
+ const assert = require('node:assert/strict');
9
+ const fs = require('node:fs');
10
+ const os = require('node:os');
11
+ const path = require('node:path');
12
+ const { spawnSync, execSync } = require('node:child_process');
13
+ const { test } = require('node:test');
14
+
15
+ const PACKAGE_ROOT = path.join(__dirname, '..', '..');
16
+ const INSTALLER = path.join(PACKAGE_ROOT, 'bin/install.js');
17
+ const DOCS_SKILL = path.join(PACKAGE_ROOT, 'src/claude/skills/docs');
18
+ const ON_DEMAND = "echo '{}' | node .claude/hooks/docs-sync.cjs";
19
+
20
+ function inTempProject(run) {
21
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'cafekit-docs-sync-'));
22
+ try {
23
+ const init = spawnSync('git', ['-C', root, 'init', '-q'], { encoding: 'utf8' });
24
+ assert.equal(init.status, 0, init.stderr);
25
+ return run(root);
26
+ } finally {
27
+ fs.rmSync(root, { recursive: true, force: true });
28
+ }
29
+ }
30
+
31
+ function install(root, platforms) {
32
+ const result = spawnSync(process.execPath, [INSTALLER, '--platform', platforms.join(','), '--yes'],
33
+ { cwd: root, encoding: 'utf8', env: { ...process.env, PATH: '/usr/bin:/bin' } });
34
+ assert.equal(result.status, 0, `${result.stdout}\n${result.stderr}`);
35
+ }
36
+
37
+ function commands(settings, event) {
38
+ return (settings.hooks[event] || []).flatMap((group) => (group.hooks || []).map((hook) => hook.command));
39
+ }
40
+
41
+ /** A git project with code, docs and a `.sync_hash` that no longer matches the source. */
42
+ function makeStale(root) {
43
+ fs.writeFileSync(path.join(root, 'package.json'), '{"name":"fixture"}\n');
44
+ fs.mkdirSync(path.join(root, 'docs'), { recursive: true });
45
+ fs.writeFileSync(path.join(root, 'docs', 'README.md'), '# Docs\n');
46
+ fs.writeFileSync(path.join(root, 'docs', '.sync_hash'), '0000000000000000000000000000000000000000\n');
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
+ }
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');
56
+ });
57
+ });
58
+
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}`);
71
+ });
72
+ });
73
+
74
+ test('the docs skill runs docs-sync on demand', () => {
75
+ for (const rel of ['SKILL.md', 'references/update-workflow.md']) {
76
+ assert.ok(fs.readFileSync(path.join(DOCS_SKILL, rel), 'utf8').includes(ON_DEMAND), `${rel} names the on-demand command`);
77
+ }
78
+ const files = fs.readdirSync(DOCS_SKILL, { recursive: true }).filter((f) => f.endsWith('.md'));
79
+ for (const rel of files) {
80
+ assert.doesNotMatch(fs.readFileSync(path.join(DOCS_SKILL, rel), 'utf8'), /SessionStart docs-sync/, rel);
81
+ }
82
+ });
83
+
84
+ for (const [runtime, folder] of [['claude', '.claude'], ['codex', '.codex']]) {
85
+ test(`the ${runtime === 'claude' ? 'Claude' : 'Codex'} copy prints its signal on demand`, () => {
86
+ inTempProject((root) => {
87
+ install(root, [runtime]);
88
+ makeStale(root);
89
+ const out = execSync(`echo '{}' | node ${folder}/hooks/docs-sync.cjs`, { cwd: root, encoding: 'utf8' });
90
+ assert.match(out, /Docs sync needed/);
91
+ });
92
+ });
93
+ }
@@ -134,36 +134,6 @@ test('a broad glob is denied with its full reason in JSON', () => {
134
134
  });
135
135
  });
136
136
 
137
- test('a nested task write is denied under the grok write tool', () => {
138
- withInstall((root) => {
139
- const target = path.join(root, 'specs', 'demo', 'tasks', 'task-R0-01-x.md');
140
- fs.mkdirSync(path.dirname(target), { recursive: true });
141
- // grok creates files with `write`; `search_replace` edits an existing one. The guard
142
- // fires on Write only, so the alias has to keep those two apart.
143
- const r = runHook(root, 'task-scaffold-guard.cjs', {
144
- hookEventName: 'pre_tool_use',
145
- cwd: root,
146
- toolName: 'write',
147
- toolInput: { path: target, content: '# x' },
148
- });
149
- const { decision, reason } = decisionOf(r.stdout);
150
- assert.equal(decision, 'deny');
151
- assert.match(reason, /TASK SCAFFOLD REQUIRED/);
152
- assert.match(reason, /spec-scaffold\.cjs/, 'the command the model must run has to reach it');
153
- assert.equal(r.code, 2);
154
-
155
- // The escape route the message names must stay open: an edit is not a create.
156
- const edit = runHook(root, 'task-scaffold-guard.cjs', {
157
- hookEventName: 'pre_tool_use',
158
- cwd: root,
159
- toolName: 'search_replace',
160
- toolInput: { path: target, old_string: 'a', new_string: 'b' },
161
- });
162
- assert.equal(decisionOf(edit.stdout).decision, null, 'search_replace must not be treated as a create');
163
- assert.equal(edit.code, 0);
164
- });
165
- });
166
-
167
137
  /** A packet the Stop gate will block: one done task with no receipt. */
168
138
  function unprovenPacket(root) {
169
139
  const feature = path.join(root, 'specs', 'demo');
@@ -206,32 +176,6 @@ test('the Stop gate still blocks an unproven done task', () => {
206
176
  }, { git: true });
207
177
  });
208
178
 
209
- test('the approval hook takes its approve path on a grok prompt', () => {
210
- withInstall((root) => {
211
- // Registered without an argv mode, this hook picks its mode from the event name, and
212
- // approve() returns immediately unless that name is exactly 'UserPromptSubmit'. An
213
- // approval phrase with no pending request is therefore the oracle: only the approve
214
- // path can print a rejection line, so the line proves the event name was translated.
215
- // Under an unreadable envelope the mode fell back to the stop path on every prompt.
216
- const nonce = 'a'.repeat(24);
217
- const r = runHook(root, 'completion-authority.cjs', {
218
- hookEventName: 'user_prompt_submit',
219
- sessionId: 'grok-1',
220
- cwd: root,
221
- prompt: `APPROVE CAFEKIT COMPLETION ${nonce}`,
222
- });
223
- assert.match(r.stdout, /CafeKit completion approval rejected/,
224
- 'the approve path must run and refuse an approval that matches no pending request');
225
- assert.equal(decisionOf(r.stdout).decision, null, 'and it must not block the prompt');
226
-
227
- // An ordinary prompt stays silent on both counts.
228
- const plain = runHook(root, 'completion-authority.cjs', {
229
- hookEventName: 'user_prompt_submit', sessionId: 'grok-1', cwd: root, prompt: 'please continue',
230
- });
231
- assert.equal(plain.stdout.trim(), '', 'an ordinary prompt produces nothing');
232
- }, { git: true });
233
- });
234
-
235
179
  test('the secret guardrail sees a grok prompt', () => {
236
180
  withInstall((root) => {
237
181
  // grok's spelling for the prompt field is unverified, so the reader accepts the Claude
@@ -49,8 +49,8 @@ test('read_file path becomes file_path', () => {
49
49
  toolInput: { path: '/p/.env' },
50
50
  });
51
51
  assert.equal(out.tool_name, 'Read');
52
- // privacy-block reads file_path and path, but task-scaffold-guard reads file_path first
53
- // and inspect-block's path list is keyed the same way.
52
+ // privacy-block and inspect-block both read file_path first, so the normalized key
53
+ // must be file_path.
54
54
  assert.equal(out.tool_input.file_path, '/p/.env');
55
55
  assert.equal(out.tool_input.path, '/p/.env', 'the original key stays for hooks that read it');
56
56
  });
@@ -83,7 +83,6 @@ test('Stop and subagent fields map', () => {
83
83
  subagentId: 'agent-7',
84
84
  lastAssistantMessage: 'REVIEW: PASS',
85
85
  });
86
- // semantic-review-authority refuses to record an attestation without agent_id.
87
86
  assert.equal(sub.hook_event_name, 'SubagentStop');
88
87
  assert.equal(sub.agent_type, 'code-auditor');
89
88
  assert.equal(sub.agent_id, 'agent-7');
@@ -102,9 +101,8 @@ test('every name reaching a Bash matcher becomes Bash', () => {
102
101
  });
103
102
 
104
103
  test('write and search_replace map to different Claude tools', () => {
105
- // The scaffold guard only fires on Write. Grok creates files with `write` and edits
106
- // them with `search_replace`; conflating them would either disable the guard or make it
107
- // reject the Edit-fill step the guard's own message tells the model to run.
104
+ // Grok creates files with `write` and edits them with `search_replace`; they must stay
105
+ // distinct Claude tools so a hook matcher on Write or on Edit sees the event it names.
108
106
  assert.equal(normalizeToolName('write'), 'Write');
109
107
  assert.equal(normalizeToolName('delete_file'), 'Write');
110
108
  assert.equal(normalizeToolName('search_replace'), 'Edit');
@@ -0,0 +1,154 @@
1
+ 'use strict';
2
+
3
+ // The Stop gates and the session hooks must not depend on the completion-authority hook files,
4
+ // so those files can be deleted without blocking every Stop or crashing SessionStart. Each case
5
+ // runs against a real install (bin/install.js), with and without the five legacy files.
6
+
7
+ const assert = require('node:assert/strict');
8
+ const fs = require('node:fs');
9
+ const os = require('node:os');
10
+ const path = require('node:path');
11
+ const { spawnSync } = require('node:child_process');
12
+ const { test } = require('node:test');
13
+
14
+ const INSTALLER = path.join(__dirname, '..', 'install.js');
15
+ const LEGACY_FILES = [
16
+ 'completion-authority.cjs',
17
+ 'completion-authority-check.cjs',
18
+ 'completion-authority-state.cjs',
19
+ 'semantic-review-authority.cjs',
20
+ 'task-scaffold-guard.cjs',
21
+ ];
22
+
23
+ function inTempProject(run) {
24
+ const root = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'cafekit-decoupled-')));
25
+ try {
26
+ for (const args of [['init', '-q'], ['config', 'user.email', 'test@example.invalid'], ['config', 'user.name', 'Test'], ['commit', '--allow-empty', '-qm', 'base']]) {
27
+ const result = spawnSync('git', ['-C', root, ...args], { encoding: 'utf8' });
28
+ assert.equal(result.status, 0, result.stderr);
29
+ }
30
+ const installed = spawnSync(process.execPath, [INSTALLER, '--platform', 'claude,codex', '--yes'],
31
+ { cwd: root, encoding: 'utf8', env: { ...process.env, PATH: '/usr/bin:/bin' } });
32
+ assert.equal(installed.status, 0, `${installed.stdout}\n${installed.stderr}`);
33
+ return run(root);
34
+ } finally {
35
+ fs.rmSync(root, { recursive: true, force: true });
36
+ }
37
+ }
38
+
39
+ function removeLegacyFiles(root) {
40
+ for (const runtime of ['.claude', '.codex']) {
41
+ for (const file of LEGACY_FILES) {
42
+ const target = path.join(root, runtime, 'hooks', file);
43
+ fs.rmSync(target, { force: true });
44
+ }
45
+ }
46
+ }
47
+
48
+ function runHook(root, kind, script, event, extra = {}) {
49
+ const hook = path.join(root, kind === 'claude' ? '.claude' : '.codex', 'hooks', script);
50
+ const result = spawnSync(process.execPath, [hook], {
51
+ cwd: root,
52
+ input: JSON.stringify({ hook_event_name: event, session_id: 's', stop_hook_active: false, cwd: root, ...extra }),
53
+ env: { ...process.env, PATH: '/usr/bin:/bin', ...(kind === 'claude' ? { CLAUDE_PROJECT_DIR: root } : {}) },
54
+ encoding: 'utf8',
55
+ });
56
+ assert.equal(result.status, 0, `${result.stdout}\n${result.stderr}`);
57
+ return result.stdout.trim();
58
+ }
59
+
60
+ function stopGate(root, kind) {
61
+ return runHook(root, kind, 'spec-gate.cjs', 'Stop');
62
+ }
63
+
64
+ function isBlocked(stdout) {
65
+ return stdout !== '' && JSON.parse(stdout.split('\n').pop()).decision === 'block';
66
+ }
67
+
68
+ function blockReason(stdout) {
69
+ assert.equal(isBlocked(stdout), true, `expected a block, got: ${stdout}`);
70
+ return JSON.parse(stdout.split('\n').pop()).reason;
71
+ }
72
+
73
+ function provenance(root, taskFile) {
74
+ const result = spawnSync(process.execPath, [
75
+ path.join(root, '.claude', 'scripts', 'provenance.cjs'),
76
+ '--project-root', root, '--specs-root', 'specs', '--spec-file', taskFile,
77
+ '--feature-name', 'demo', '--session', 's', '--json',
78
+ ], { cwd: root, encoding: 'utf8' });
79
+ assert.equal(result.status, 0, `${result.stdout}\n${result.stderr}`);
80
+ const parsed = JSON.parse(result.stdout);
81
+ return { base: parsed.Base, head: parsed.Head };
82
+ }
83
+
84
+ function writeProcessFirst(root, { receipt }) {
85
+ const featureDir = path.join(root, 'specs', 'demo');
86
+ fs.mkdirSync(featureDir, { recursive: true });
87
+ fs.writeFileSync(path.join(featureDir, 'plan.md'), '# Demo plan\n');
88
+ const taskFile = path.join(featureDir, 'task-01-demo.md');
89
+ const body = ['# Task 01: demo', '', 'Status: done', '', '## Dependencies', '', '- none', '', '## Verification Plan', '', '- Command: node --test', ''];
90
+ fs.writeFileSync(taskFile, body.join('\n'));
91
+ if (!receipt) return;
92
+ const { base, head } = provenance(root, taskFile);
93
+ fs.writeFileSync(taskFile, [...body, '## Receipt', '', 'Verification: PASS', 'Command: node --test', 'Exit: 0',
94
+ `Base: ${base}`, `Head: ${head}`, '```text', '$ node --test', 'pass: 1', '```', ''].join('\n'));
95
+ }
96
+
97
+ // A 2.1 packet keeps its machine state in spec.json and its tasks in a nested tasks/ directory.
98
+ function writeLegacy(root, { status, doneTask = false }) {
99
+ const featureDir = path.join(root, 'specs', 'legacy');
100
+ fs.mkdirSync(path.join(featureDir, 'tasks'), { recursive: true });
101
+ const task = 'tasks/task-R1-01-legacy.md';
102
+ const spec = { schema_version: '2.1', feature_name: 'legacy', status, current_phase: 'implementation', task_registry: {} };
103
+ if (doneTask) spec.task_registry[task] = { status: 'done', completed_at: '2026-08-13T00:00:00.000Z', dependencies: [] };
104
+ fs.writeFileSync(path.join(featureDir, 'spec.json'), JSON.stringify(spec));
105
+ if (doneTask) {
106
+ fs.writeFileSync(path.join(featureDir, task), ['# Task R1-01: legacy', '**Status:** done', '## Outcome', 'Legacy work.',
107
+ '## Verification Plan', '- **Command:** `node --test`', '- **Expected:** passes', ''].join('\n'));
108
+ }
109
+ }
110
+
111
+ function checkProcessFirst(kind) {
112
+ inTempProject((root) => {
113
+ writeProcessFirst(root, { receipt: true });
114
+ assert.equal(stopGate(root, kind), '', 'valid receipt passes with the legacy files');
115
+ writeProcessFirst(root, { receipt: false });
116
+ assert.match(blockReason(stopGate(root, kind)), /missing_receipt/, 'no receipt blocks with the legacy files');
117
+ removeLegacyFiles(root);
118
+ assert.match(blockReason(stopGate(root, kind)), /missing_receipt/, 'no receipt blocks without the legacy files');
119
+ writeProcessFirst(root, { receipt: true });
120
+ assert.equal(stopGate(root, kind), '', 'valid receipt passes without the legacy files');
121
+ });
122
+ }
123
+
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 }]) {
128
+ inTempProject((root) => {
129
+ writeLegacy(root, legacy);
130
+ removeLegacyFiles(root);
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`);
138
+ });
139
+ }
140
+ }
141
+
142
+ function checkSession(kind) {
143
+ inTempProject((root) => {
144
+ removeLegacyFiles(root);
145
+ runHook(root, kind, 'session.cjs', 'SessionStart', { source: 'startup' });
146
+ });
147
+ }
148
+
149
+ test('claude gate passes and blocks process-first with and without the legacy files', () => checkProcessFirst('claude'));
150
+ test('codex gate passes and blocks process-first with and without the legacy files', () => checkProcessFirst('codex'));
151
+ test('claude gate ignores a leftover legacy packet', () => checkLegacyIgnored('claude'));
152
+ test('codex gate ignores a leftover legacy packet', () => checkLegacyIgnored('codex'));
153
+ test('claude session starts without the legacy files', () => checkSession('claude'));
154
+ test('codex session starts without the legacy files', () => checkSession('codex'));
@@ -0,0 +1,221 @@
1
+ 'use strict';
2
+
3
+ // The five spec.json-era hooks (completion authority, its check and state modules, the semantic
4
+ // review authority and the scaffold guard) are gone from every runtime. Each case pins one way
5
+ // they could survive: a fresh install, an upgrade on Claude (settings prune matches by substring,
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
8
+ // that still lists the removed gates, and the source lists themselves.
9
+
10
+ const assert = require('node:assert/strict');
11
+ const crypto = require('node:crypto');
12
+ const fs = require('node:fs');
13
+ const os = require('node:os');
14
+ const path = require('node:path');
15
+ const { spawnSync } = require('node:child_process');
16
+ const { test } = require('node:test');
17
+
18
+ const PACKAGE_ROOT = path.join(__dirname, '..', '..');
19
+ const INSTALLER = path.join(PACKAGE_ROOT, 'bin/install.js');
20
+ const SRC = path.join(PACKAGE_ROOT, 'src');
21
+ const readJson = (file) => JSON.parse(fs.readFileSync(file, 'utf8'));
22
+ const MANIFEST = readJson(path.join(SRC, 'claude/migration-manifest.json'));
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');
25
+
26
+ const NAMES = [
27
+ 'completion-authority.cjs',
28
+ 'completion-authority-check.cjs',
29
+ 'completion-authority-state.cjs',
30
+ 'semantic-review-authority.cjs',
31
+ 'task-scaffold-guard.cjs',
32
+ ];
33
+ const REGISTERED = ['completion-authority.cjs', 'semantic-review-authority.cjs', 'task-scaffold-guard.cjs'];
34
+ const FOREIGN = REGISTERED.map((name) => `node ~/my-hooks/${name}`);
35
+
36
+ function inTempProject(run) {
37
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'cafekit-legacy-retired-'));
38
+ try {
39
+ const init = spawnSync('git', ['-C', root, 'init', '-q'], { encoding: 'utf8' });
40
+ assert.equal(init.status, 0, init.stderr);
41
+ return run(root);
42
+ } finally {
43
+ fs.rmSync(root, { recursive: true, force: true });
44
+ }
45
+ }
46
+
47
+ function install(root, platforms) {
48
+ const result = spawnSync(process.execPath, [INSTALLER, '--platform', platforms.join(','), '--yes'],
49
+ { cwd: root, encoding: 'utf8', env: { ...process.env, PATH: '/usr/bin:/bin' } });
50
+ assert.equal(result.status, 0, `${result.stdout}\n${result.stderr}`);
51
+ }
52
+
53
+ function commands(settings) {
54
+ return Object.values(settings.hooks || {})
55
+ .flat()
56
+ .flatMap((group) => (group.hooks || []).map((hook) => hook.command));
57
+ }
58
+
59
+ function mentionsName(text) {
60
+ return NAMES.filter((name) => text.includes(name));
61
+ }
62
+
63
+ test('fresh installs ship and register none of the five legacy hooks', () => {
64
+ inTempProject((root) => {
65
+ install(root, ['claude', 'codex', 'omp']);
66
+ for (const dir of ['.claude', '.codex', '.omp']) {
67
+ for (const name of NAMES) assert.equal(fs.existsSync(path.join(root, dir, 'hooks', name)), false, `${dir}/hooks/${name}`);
68
+ }
69
+ const claude = commands(readJson(path.join(root, '.claude', 'settings.json')));
70
+ const codex = commands(readJson(path.join(root, '.codex', 'hooks.json')));
71
+ 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
+ assert.deepEqual(mentionsName(bridge), []);
74
+ });
75
+ });
76
+
77
+ test('claude upgrade removes the legacy hooks and their CafeKit entries but keeps foreign ones', () => {
78
+ inTempProject((root) => {
79
+ install(root, ['claude']);
80
+ const settingsPath = path.join(root, '.claude', 'settings.json');
81
+ const manifestPath = path.join(root, '.claude', 'cafekit-manifest.json');
82
+ const settings = readJson(settingsPath);
83
+ const kept = commands(settings);
84
+
85
+ const manifest = readJson(manifestPath);
86
+ for (const name of NAMES) {
87
+ fs.writeFileSync(path.join(root, '.claude', 'hooks', name), '// user edit\n');
88
+ manifest.files[`hooks/${name}`] = { sha256: '0'.repeat(64) };
89
+ }
90
+ fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2));
91
+
92
+ const cmd = (name) => `node "$CLAUDE_PROJECT_DIR/.claude/hooks/${name}"`;
93
+ settings.hooks.UserPromptSubmit[0].hooks.push(
94
+ { type: 'command', command: cmd('completion-authority.cjs') },
95
+ ...FOREIGN.map((command) => ({ type: 'command', command })),
96
+ );
97
+ settings.hooks.Stop[0].hooks.push({ type: 'command', command: cmd('completion-authority.cjs') });
98
+ settings.hooks.PreToolUse.push({ matcher: 'Write', hooks: [{ type: 'command', command: cmd('task-scaffold-guard.cjs') }] });
99
+ settings.hooks.SubagentStop[0].hooks.push({ type: 'command', command: cmd('semantic-review-authority.cjs') });
100
+ fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2));
101
+
102
+ install(root, ['claude']);
103
+ for (const name of NAMES) assert.equal(fs.existsSync(path.join(root, '.claude', 'hooks', name)), false, name);
104
+ const after = readJson(manifestPath);
105
+ for (const name of NAMES) assert.equal(Object.prototype.hasOwnProperty.call(after.files, `hooks/${name}`), false, `record ${name}`);
106
+ const afterCommands = commands(readJson(settingsPath));
107
+ for (const name of REGISTERED) {
108
+ assert.equal(afterCommands.some((command) => command.includes(`.claude/hooks/${name}`)), false, name);
109
+ }
110
+ for (const command of FOREIGN) assert.ok(afterCommands.includes(command), `foreign: ${command}`);
111
+ for (const command of kept) assert.ok(afterCommands.includes(command), `kept: ${command}`);
112
+ });
113
+ });
114
+
115
+ test('codex upgrade removes the legacy hooks, their records and their CafeKit entries but keeps foreign ones', () => {
116
+ inTempProject((root) => {
117
+ install(root, ['codex']);
118
+ const hooksPath = path.join(root, '.codex', 'hooks.json');
119
+ const manifestPath = path.join(root, '.codex', 'cafekit-manifest.json');
120
+ const config = readJson(hooksPath);
121
+ const kept = commands(config);
122
+
123
+ // A record whose file is already gone must leave the manifest too.
124
+ const manifest = readJson(manifestPath);
125
+ for (const name of NAMES) {
126
+ if (name !== 'completion-authority-state.cjs') fs.writeFileSync(path.join(root, '.codex', 'hooks', name), '// user edit\n');
127
+ manifest.files[`.codex/hooks/${name}`] = { sha256: '0'.repeat(64) };
128
+ }
129
+ fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2));
130
+
131
+ const posix = kept.find((command) => command.includes('spec-gate.cjs'));
132
+ assert.ok(posix, 'the installed spec-gate launcher is the template for the POSIX form');
133
+ const add = (event, command) => config.hooks[event].push({ matcher: '*', hooks: [{ type: 'command', command }] });
134
+ add('Stop', posix.split('spec-gate.cjs').join('completion-authority.cjs'));
135
+ add('PreToolUse', 'node ".codex/hooks/task-scaffold-guard.cjs"');
136
+ add('SubagentStop', "node 'C:\\work\\proj\\.codex\\hooks\\semantic-review-authority.cjs'");
137
+ add('UserPromptSubmit', FOREIGN[0]);
138
+ fs.writeFileSync(hooksPath, JSON.stringify(config, null, 2));
139
+
140
+ install(root, ['codex']);
141
+ for (const name of NAMES) assert.equal(fs.existsSync(path.join(root, '.codex', 'hooks', name)), false, name);
142
+ const after = readJson(manifestPath);
143
+ for (const name of NAMES) assert.equal(Object.prototype.hasOwnProperty.call(after.files, `.codex/hooks/${name}`), false, `record ${name}`);
144
+ const afterCommands = commands(readJson(hooksPath));
145
+ for (const name of REGISTERED) {
146
+ assert.equal(afterCommands.some((command) => command.includes(`.codex/hooks/${name}`) || command.includes(`.codex\\hooks\\${name}`)), false, name);
147
+ }
148
+ assert.ok(afterCommands.includes(FOREIGN[0]), 'a foreign hook with a legacy name survives');
149
+ for (const command of kept) assert.ok(afterCommands.includes(command), `kept: ${command}`);
150
+ });
151
+ });
152
+
153
+ test('omp upgrade removes the legacy hooks and their records', () => {
154
+ inTempProject((root) => {
155
+ install(root, ['omp']);
156
+ const manifestPath = path.join(root, '.omp', 'cafekit-manifest.json');
157
+ const manifest = readJson(manifestPath);
158
+ // omp records keys relative to the project root (recordRoot '.').
159
+ 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) };
162
+ }
163
+ fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2));
164
+
165
+ install(root, ['omp']);
166
+ const after = readJson(manifestPath);
167
+ for (const name of NAMES) {
168
+ assert.equal(fs.existsSync(path.join(root, '.omp', 'hooks', name)), false, name);
169
+ assert.equal(Object.prototype.hasOwnProperty.call(after.files, `.omp/hooks/${name}`), false, `record ${name}`);
170
+ }
171
+ });
172
+ });
173
+
174
+ test('omp upgrade replaces a pristine old bridge that still lists the removed gates', () => {
175
+ inTempProject((root) => {
176
+ install(root, ['omp']);
177
+ const bridgePath = path.join(root, '.omp', 'extensions', 'cafekit-bridge.mjs');
178
+ const manifestPath = path.join(root, '.omp', 'cafekit-manifest.json');
179
+ 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);
185
+ const manifest = readJson(manifestPath);
186
+ manifest.files['.omp/extensions/cafekit-bridge.mjs'] = { sha256: crypto.createHash('sha256').update(old).digest('hex') };
187
+ fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2));
188
+
189
+ install(root, ['omp']);
190
+ const installed = fs.readFileSync(bridgePath, 'utf8');
191
+ assert.deepEqual(mentionsName(installed), []);
192
+ assert.equal(installed, fs.readFileSync(SOURCE_BRIDGE_PATH, 'utf8'));
193
+ });
194
+ });
195
+
196
+ test('no source ships, registers or bridges the five legacy hooks', () => {
197
+ for (const name of NAMES) {
198
+ assert.equal(fs.existsSync(path.join(SRC, 'claude/hooks', name)), false, `claude ${name}`);
199
+ assert.equal(fs.existsSync(path.join(SRC, 'codex/hooks', name)), false, `codex ${name}`);
200
+ }
201
+ for (const test of ['completion-authority.test.js', 'semantic-review-authority.test.js']) {
202
+ assert.equal(fs.existsSync(path.join(SRC, 'claude/hooks/__tests__', test)), false, test);
203
+ }
204
+ const runtimeFiles = MANIFEST.runtime.files.join('\n');
205
+ assert.deepEqual(mentionsName(runtimeFiles), []);
206
+ assert.deepEqual(mentionsName(JSON.stringify(SOURCE_SETTINGS)), []);
207
+ assert.deepEqual(mentionsName(fs.readFileSync(path.join(SRC, 'codex/hooks.json'), 'utf8')), []);
208
+ assert.deepEqual(mentionsName(fs.readFileSync(SOURCE_BRIDGE_PATH, 'utf8')), []);
209
+ for (const name of NAMES) assert.ok(MANIFEST.obsolete.runtimeFiles.includes(`hooks/${name}`), name);
210
+ const substrings = MANIFEST.obsolete.settingsHookCommandSubstrings;
211
+ assert.equal(substrings[0], 'hooks/skill-router.cjs');
212
+ for (const name of REGISTERED) {
213
+ for (const entry of [`.claude/hooks/${name}`, `.codex/hooks/${name}`, `.codex\\hooks\\${name}`]) {
214
+ assert.ok(substrings.includes(entry), entry);
215
+ }
216
+ }
217
+ // The installer's merge drops an empty group, so only the source can show one.
218
+ for (const [event, groups] of Object.entries(SOURCE_SETTINGS.hooks)) {
219
+ for (const group of groups) assert.ok(group.hooks.length > 0, `${event} ${group.matcher} has no hooks`);
220
+ }
221
+ });
@@ -17,7 +17,6 @@ const PACKAGE_ROOT = path.join(__dirname, '../..');
17
17
  const BRIDGE = path.join(PACKAGE_ROOT, 'src/omp/extensions/cafekit-bridge.mjs');
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
- const { PREFIX } = require(path.join(PACKAGE_ROOT, 'src/claude/hooks/completion-authority-state.cjs'));
21
20
 
22
21
  /**
23
22
  * The hooks an omp install runs: the Claude set the manifest ships, then `src/omp/hooks/`
@@ -50,6 +49,11 @@ function registered(eventName) {
50
49
  .filter(Boolean);
51
50
  }
52
51
 
52
+ // Deliberate, named divergences while omp is not yet updated. Each must still be a real
53
+ // difference, so the test fails (and the entry must go) once omp catches up.
54
+ const CLAUDE_ONLY = { PostToolUse: ['spec-state.cjs'] };
55
+ const OMP_ONLY = { SessionStart: ['docs-sync.cjs'] };
56
+
53
57
  test('the dispatch table mirrors settings.json for every event omp can deliver', () => {
54
58
  const { DISPATCH } = bridge;
55
59
  const pairs = [
@@ -58,8 +62,18 @@ test('the dispatch table mirrors settings.json for every event omp can deliver',
58
62
  ['tool_result', 'PostToolUse'], ['session_stop', 'Stop'],
59
63
  ];
60
64
  for (const [omp, claude] of pairs) {
61
- assert.deepEqual([...DISPATCH[omp].hooks].sort(), [...new Set(registered(claude))].sort(),
62
- `${omp} must dispatch exactly the hooks settings.json registers for ${claude}`);
65
+ const claudeOnly = CLAUDE_ONLY[claude] || [];
66
+ const ompOnly = OMP_ONLY[claude] || [];
67
+ const claudeHooks = [...new Set(registered(claude))];
68
+ for (const hook of claudeOnly) {
69
+ assert.ok(claudeHooks.includes(hook) && !DISPATCH[omp].hooks.includes(hook), `${hook} is no longer Claude-only for ${claude}; drop it from CLAUDE_ONLY`);
70
+ }
71
+ for (const hook of ompOnly) {
72
+ assert.ok(!claudeHooks.includes(hook) && DISPATCH[omp].hooks.includes(hook), `${hook} is no longer omp-only for ${claude}; drop it from OMP_ONLY`);
73
+ }
74
+ const expected = [...claudeHooks.filter((hook) => !claudeOnly.includes(hook)), ...ompOnly].sort();
75
+ assert.deepEqual([...DISPATCH[omp].hooks].sort(), expected,
76
+ `${omp} must dispatch the hooks settings.json registers for ${claude}, minus CLAUDE_ONLY, plus OMP_ONLY`);
63
77
  }
64
78
  // omp has no subagent events; the gap is deliberate and documented, not forgotten.
65
79
  assert.ok(!Object.values(DISPATCH).some((e) => e.event.startsWith('Subagent')));
@@ -83,7 +97,7 @@ test('each denial mechanism becomes an omp block carrying the hook reason', () =
83
97
  assert.deepEqual([stop.block, stop.reason], [true, 'R1']);
84
98
  const deny = interpretVerdict({ code: 0, stdout: JSON.stringify({ hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'deny', permissionDecisionReason: 'R2' } }) }, 'privacy-block.cjs');
85
99
  assert.deepEqual([deny.block, deny.reason], [true, 'R2']);
86
- // inspect-block / task-scaffold-guard exit 2 and write the reason on STDOUT, not stderr.
100
+ // inspect-block exits 2 and writes the reason on STDOUT, not stderr.
87
101
  const exit2 = interpretVerdict({ code: 2, stdout: 'SCOPE LIMIT EXCEEDED\nRestricted zones: node_modules', stderr: '' }, 'inspect-block.cjs');
88
102
  assert.equal(exit2.block, true);
89
103
  assert.match(exit2.reason, /SCOPE LIMIT EXCEEDED/, 'a stderr-only reader would drop this reason');
@@ -127,10 +141,10 @@ test('a slow hook blocks with its own name before omp cuts it off', async () =>
127
141
  test('a crashing or missing gate blocks on gating events instead of silently allowing', async () => {
128
142
  const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cafekit-omp-crash-'));
129
143
  try {
130
- // Only the crashing gate is under test; the other two PreToolUse gates get allow stubs,
144
+ // Only the crashing gate is under test; the other PreToolUse gate gets an allow stub,
131
145
  // otherwise their absence is what blocks (see the missing-gate case below).
132
146
  fs.writeFileSync(path.join(dir, 'privacy-block.cjs'), 'throw new Error("boom");');
133
- for (const stub of ['inspect-block.cjs', 'task-scaffold-guard.cjs']) fs.writeFileSync(path.join(dir, stub), 'process.exit(0);');
147
+ fs.writeFileSync(path.join(dir, 'inspect-block.cjs'), 'process.exit(0);');
134
148
  const crash = await bridge.runEvent('tool_call', { toolName: 'bash', input: { command: 'ls' } }, { cwd: dir }, { dir });
135
149
  // A bare exit 1 with no verdict is an allow under the hooks' own contract. Real gates
136
150
  // never surface a crash this way: the fork's privacy-block catch-all converts its own
@@ -147,17 +161,6 @@ test('stop_hook_active short-circuits the gate so a blocked turn cannot loop for
147
161
  assert.deepEqual([r.block, r.skipped], [false, 'stop_hook_active']);
148
162
  });
149
163
 
150
- test('the approval phrase reaches completion-authority through the input event', async () => {
151
- const { DISPATCH, shapePayload } = bridge;
152
- assert.ok(DISPATCH.input.hooks.includes('completion-authority.cjs'),
153
- 'the --approve path only runs on UserPromptSubmit; without this routing a blocked turn has no exit');
154
- const phrase = `${PREFIX}${'a'.repeat(24)}`;
155
- const p = shapePayload('input', { text: phrase, source: 'user' }, { cwd: '/tmp/p' });
156
- assert.equal(p.hook_event_name, 'UserPromptSubmit', 'completion-authority selects --approve from this field');
157
- assert.equal(p.prompt, phrase, 'the phrase must arrive byte-exact; the hook compares it exactly');
158
- assert.ok(p.session_id, 'approve() returns early without a session id');
159
- });
160
-
161
164
  test('input injections flow back as a transform that keeps the user text intact', async () => {
162
165
  const root = tempProject(false);
163
166
  try {