@haposoft/cafekit 0.16.0 → 0.16.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 (66) hide show
  1. package/README.md +5 -3
  2. package/bin/__tests__/codex-hooks.test.js +125 -0
  3. package/bin/__tests__/codex-native.test.js +95 -0
  4. package/bin/__tests__/develop-contract.test.js +43 -1
  5. package/bin/__tests__/grok-envelope.test.js +271 -0
  6. package/bin/__tests__/grok-platform.test.js +133 -0
  7. package/bin/__tests__/hook-payload.test.js +194 -0
  8. package/bin/__tests__/omp-bridge.test.js +168 -0
  9. package/bin/__tests__/omp-hooks.test.js +178 -0
  10. package/bin/__tests__/omp-runtime.test.js +150 -0
  11. package/bin/__tests__/orca-session.test.js +225 -0
  12. package/bin/__tests__/orca-skill.test.js +210 -0
  13. package/bin/__tests__/package-inventory.test.js +1 -0
  14. package/bin/__tests__/runtime-dir.test.js +158 -0
  15. package/bin/__tests__/runtime-schema.test.js +5 -3
  16. package/bin/__tests__/specs-v2-execution-closeout.test.js +28 -1
  17. package/bin/install.js +5 -5
  18. package/bin/lib/context.js +50 -3
  19. package/bin/lib/i18n.js +3 -30
  20. package/bin/phases/codex-hooks.js +90 -17
  21. package/bin/phases/copy-payload.js +7 -0
  22. package/bin/phases/omp-runtime.js +140 -0
  23. package/bin/phases/root-config.js +3 -0
  24. package/bin/phases/select-platform.js +22 -1
  25. package/bin/phases/summary.js +0 -4
  26. package/package.json +1 -1
  27. package/src/claude/hooks/__tests__/semantic-review-authority.test.js +2 -0
  28. package/src/claude/hooks/__tests__/spec-gate.test.js +338 -0
  29. package/src/claude/hooks/__tests__/state.test.js +2 -0
  30. package/src/claude/hooks/agent.cjs +12 -7
  31. package/src/claude/hooks/completion-authority.cjs +10 -4
  32. package/src/claude/hooks/docs-sync.cjs +2 -1
  33. package/src/claude/hooks/inspect-block.cjs +27 -7
  34. package/src/claude/hooks/lib/config.cjs +5 -2
  35. package/src/claude/hooks/lib/counter.cjs +7 -5
  36. package/src/claude/hooks/lib/hook-payload.cjs +160 -0
  37. package/src/claude/hooks/lib/hook-state-dir.cjs +2 -2
  38. package/src/claude/hooks/lib/runtime-dir.cjs +49 -0
  39. package/src/claude/hooks/precompact.cjs +2 -1
  40. package/src/claude/hooks/privacy-block.cjs +4 -2
  41. package/src/claude/hooks/rules.cjs +4 -2
  42. package/src/claude/hooks/secret-output-guardrail.cjs +2 -1
  43. package/src/claude/hooks/semantic-review-authority.cjs +6 -4
  44. package/src/claude/hooks/session.cjs +16 -8
  45. package/src/claude/hooks/spec-gate.cjs +18 -8
  46. package/src/claude/hooks/spec-state.cjs +9 -5
  47. package/src/claude/hooks/state.cjs +6 -4
  48. package/src/claude/hooks/task-scaffold-guard.cjs +29 -8
  49. package/src/claude/hooks/usage.cjs +5 -4
  50. package/src/claude/migration-manifest.json +3 -0
  51. package/src/claude/rules/skill-domain-routing.md +1 -0
  52. package/src/claude/rules/state-sync.md +7 -1
  53. package/src/claude/rules/workflow.md +2 -0
  54. package/src/claude/scripts/provenance.cjs +11 -4
  55. package/src/claude/scripts/spec-receipt.cjs +65 -4
  56. package/src/claude/skills/orca/SKILL.md +51 -0
  57. package/src/claude/skills/specs/SKILL.md +10 -11
  58. package/src/codex/hooks/session.cjs +6 -1
  59. package/src/codex/hooks/spec-state.cjs +1 -1
  60. package/src/codex/hooks.json +18 -18
  61. package/src/codex/rules/state-sync.md +7 -1
  62. package/src/omp/extensions/cafekit-bridge.mjs +233 -0
  63. package/src/omp/hooks/privacy-block.cjs +250 -0
  64. package/src/omp/runtime.json +33 -0
  65. package/bin/lib/rtk-run.js +0 -28
  66. package/bin/phases/setup-rtk.js +0 -159
@@ -0,0 +1,133 @@
1
+ 'use strict';
2
+
3
+ // Grok CLI has no runtime directory of its own: it reads the Claude install through its
4
+ // compatibility layer. `--platform grok` therefore provisions `.claude/` and prints the
5
+ // one thing a user cannot infer, which is that project hooks stay inert until the folder
6
+ // is trusted. These cases run the real installer into a temp git repository.
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 } = 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 { PLATFORMS, resolvePlatformAliases, detectPlatforms } = require(path.join(PACKAGE_ROOT, 'bin/lib/context'));
18
+ const I18N = require(path.join(PACKAGE_ROOT, 'bin/lib/i18n'));
19
+
20
+ function inTempProject(run) {
21
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'cafekit-grok-platform-'));
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, platform, extra = []) {
32
+ return spawnSync(process.execPath, [INSTALLER, '--platform', platform, '--yes', ...extra],
33
+ { cwd: root, encoding: 'utf8', env: { ...process.env, HOME: path.join(root, 'home'), PATH: '/usr/bin:/bin' } });
34
+ }
35
+
36
+ test('grok is an alias, not a platform of its own', () => {
37
+ assert.equal(PLATFORMS.grok, undefined, 'a registry entry would create a .grok payload');
38
+ assert.deepEqual(resolvePlatformAliases(['grok']).platforms, ['claude']);
39
+ assert.equal(resolvePlatformAliases(['grok']).aliased, true);
40
+ assert.equal(resolvePlatformAliases(['claude']).aliased, false);
41
+ });
42
+
43
+ test('--platform grok installs the Claude runtime and no .grok directory', () => {
44
+ inTempProject((root) => {
45
+ const result = install(root, 'grok');
46
+ assert.equal(result.status, 0, `${result.stdout}\n${result.stderr}`);
47
+
48
+ assert.equal(fs.existsSync(path.join(root, '.claude', 'hooks', 'privacy-block.cjs')), true,
49
+ 'grok runs the Claude gate scripts, so they have to be installed');
50
+ assert.equal(fs.existsSync(path.join(root, '.claude', 'settings.json')), true,
51
+ 'grok discovers the hooks through this file');
52
+ assert.equal(fs.existsSync(path.join(root, '.grok')), false, 'CafeKit ships nothing under .grok');
53
+
54
+ const metadata = JSON.parse(fs.readFileSync(path.join(root, '.claude', 'cafekit.json'), 'utf8'));
55
+ assert.equal(metadata.platform, 'claude', 'the install is a Claude install and must record itself as one');
56
+ });
57
+ });
58
+
59
+ test('the trust reminder is printed once in the selected language', () => {
60
+ inTempProject((root) => {
61
+ const en = install(root, 'grok', ['--lang', 'en']);
62
+ assert.equal(en.status, 0, en.stderr);
63
+ const occurrences = en.stdout.split('grok --trust').length - 1;
64
+ assert.equal(occurrences, 1, 'the notice must appear exactly once');
65
+ assert.match(en.stdout, /\/hooks-trust/, 'both ways to grant trust are named');
66
+ });
67
+
68
+ inTempProject((root) => {
69
+ const vi = install(root, 'grok', ['--lang', 'vi']);
70
+ assert.equal(vi.status, 0, vi.stderr);
71
+ // The Vietnamese string, not the English one: the notice follows --lang like every
72
+ // other installer line.
73
+ assert.match(vi.stdout, /lớp tương thích Claude/);
74
+ });
75
+ });
76
+
77
+ test('grok and claude together install once', () => {
78
+ inTempProject((root) => {
79
+ const result = install(root, 'grok,claude');
80
+ assert.equal(result.status, 0, `${result.stdout}\n${result.stderr}`);
81
+ const lines = result.stdout.split('\n').filter((l) => l.includes('Claude Code'));
82
+ assert.ok(lines.length > 0, 'the Claude install must be reported');
83
+ const metadata = JSON.parse(fs.readFileSync(path.join(root, '.claude', 'cafekit.json'), 'utf8'));
84
+ assert.equal(metadata.platform, 'claude');
85
+ assert.equal(fs.existsSync(path.join(root, '.grok')), false);
86
+ });
87
+ });
88
+
89
+ test('a plain claude install prints no grok notice', () => {
90
+ inTempProject((root) => {
91
+ const result = install(root, 'claude');
92
+ assert.equal(result.status, 0, result.stderr);
93
+ assert.doesNotMatch(result.stdout, /grok --trust/, 'the alias must leave the Claude path unchanged');
94
+ });
95
+ });
96
+
97
+ test('.grok is not a detection marker', () => {
98
+ // A repository that merely holds `.grok/` has not asked for CafeKit. Adding `.grok` to
99
+ // Claude's detection markers would make a reinstall in a Codex project silently grow a
100
+ // `.claude/` tree beside it, because detected platforms are unioned with saved ones.
101
+ // (An install with no platform at all still defaults to Claude; that is separate, and
102
+ // predates this alias.)
103
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'cafekit-grok-detect-'));
104
+ const cwd = process.cwd();
105
+ try {
106
+ fs.mkdirSync(path.join(root, '.grok', 'agents'), { recursive: true });
107
+ fs.mkdirSync(path.join(root, '.codex'), { recursive: true });
108
+ process.chdir(root);
109
+ const detected = detectPlatforms();
110
+ assert.deepEqual(detected, ['codex'], 'only the platform with a real payload may be detected');
111
+ } finally {
112
+ process.chdir(cwd);
113
+ fs.rmSync(root, { recursive: true, force: true });
114
+ }
115
+ });
116
+
117
+ test('an unknown platform is still rejected', () => {
118
+ inTempProject((root) => {
119
+ const result = install(root, 'nonesuch');
120
+ assert.notEqual(result.status, 0, 'the alias table must not swallow a typo');
121
+ assert.match(`${result.stdout}${result.stderr}`, /Unknown platform/);
122
+ });
123
+ });
124
+
125
+ test('the notice exists in every shipped locale', () => {
126
+ // A missing key renders as the key name, which is how a translation gap ships silently.
127
+ for (const code of I18N.SUPPORTED) {
128
+ const text = I18N.MESSAGES?.[code]?.grokCompatNotice ?? I18N[code]?.grokCompatNotice;
129
+ assert.ok(typeof text === 'string' && text.length > 0, `missing grokCompatNotice for ${code}`);
130
+ assert.match(text, /grok --trust/, `${code} must name the command`);
131
+ assert.match(text, /hooks-trust/, `${code} must name the slash command`);
132
+ }
133
+ });
@@ -0,0 +1,194 @@
1
+ 'use strict';
2
+
3
+ // The gate hooks read Claude Code's envelope. Grok CLI runs the same scripts through its
4
+ // Claude-compatibility layer but sends camelCase keys and its own tool names, and omp's
5
+ // bridge sends Claude keys with lowercase tool names. Each case below states the failure
6
+ // it prevents, because every one of them is a gate that looks installed and answers
7
+ // nothing when the reader gets a spelling wrong.
8
+ //
9
+ // Envelope samples follow grok 1.0.13's embedded hooks documentation; regenerate it with
10
+ // `strings -n 6 ~/.grok/downloads/grok-1.0.13-macos-aarch64`.
11
+
12
+ const assert = require('node:assert/strict');
13
+ const path = require('node:path');
14
+ const { test } = require('node:test');
15
+
16
+ const PACKAGE_ROOT = path.join(__dirname, '../..');
17
+ const { normalizeHookPayload, normalizeToolName } = require(
18
+ path.join(PACKAGE_ROOT, 'src/claude/hooks/lib/hook-payload.cjs'));
19
+
20
+ test('a grok PreToolUse envelope becomes the Claude payload', () => {
21
+ const grok = {
22
+ hookEventName: 'pre_tool_use',
23
+ sessionId: 'abc-123',
24
+ cwd: '/Users/you/project',
25
+ workspaceRoot: '/Users/you/project',
26
+ permissionMode: 'default',
27
+ toolName: 'run_terminal_command',
28
+ toolInput: { command: 'npm test' },
29
+ toolUseId: 'call_1',
30
+ timestamp: '2026-04-14T12:00:00Z',
31
+ };
32
+ const out = normalizeHookPayload(grok);
33
+ // The four keys every gate reads first. Without them privacy-block sees no tool and
34
+ // no command, and allows a call it was installed to examine.
35
+ assert.equal(out.hook_event_name, 'PreToolUse');
36
+ assert.equal(out.session_id, 'abc-123');
37
+ assert.equal(out.tool_name, 'Bash');
38
+ assert.deepEqual(out.tool_input, { command: 'npm test' });
39
+ assert.equal(out.tool_use_id, 'call_1');
40
+ assert.equal(out.permission_mode, 'default');
41
+ assert.equal(out.workspace_root, '/Users/you/project');
42
+ assert.equal(out.cwd, '/Users/you/project', 'cwd is already Claude-shaped and must survive');
43
+ });
44
+
45
+ test('read_file path becomes file_path', () => {
46
+ const out = normalizeHookPayload({
47
+ hookEventName: 'pre_tool_use',
48
+ toolName: 'read_file',
49
+ toolInput: { path: '/p/.env' },
50
+ });
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.
54
+ assert.equal(out.tool_input.file_path, '/p/.env');
55
+ assert.equal(out.tool_input.path, '/p/.env', 'the original key stays for hooks that read it');
56
+ });
57
+
58
+ test('an existing file_path is never overwritten by path', () => {
59
+ const out = normalizeHookPayload({
60
+ toolName: 'read_file',
61
+ toolInput: { file_path: '/p/real.txt', path: '/p/other.txt' },
62
+ });
63
+ assert.equal(out.tool_input.file_path, '/p/real.txt');
64
+ });
65
+
66
+ test('Stop and subagent fields map', () => {
67
+ const stop = normalizeHookPayload({
68
+ hookEventName: 'stop',
69
+ sessionId: 's1',
70
+ stopHookActive: true,
71
+ lastAssistantMessage: 'done',
72
+ reason: 'end_turn',
73
+ });
74
+ // Without stop_hook_active the Stop gate loses its loop guard and blocks every
75
+ // continuation round until the host's own cap ends the turn.
76
+ assert.equal(stop.hook_event_name, 'Stop');
77
+ assert.equal(stop.stop_hook_active, true);
78
+ assert.equal(stop.last_assistant_message, 'done');
79
+
80
+ const sub = normalizeHookPayload({
81
+ hookEventName: 'subagent_stop',
82
+ subagentType: 'code-auditor',
83
+ subagentId: 'agent-7',
84
+ lastAssistantMessage: 'REVIEW: PASS',
85
+ });
86
+ // semantic-review-authority refuses to record an attestation without agent_id.
87
+ assert.equal(sub.hook_event_name, 'SubagentStop');
88
+ assert.equal(sub.agent_type, 'code-auditor');
89
+ assert.equal(sub.agent_id, 'agent-7');
90
+
91
+ const post = normalizeHookPayload({ hookEventName: 'post_tool_use', toolResult: { ok: true } });
92
+ assert.deepEqual(post.tool_response, { ok: true });
93
+ });
94
+
95
+ test('every name reaching a Bash matcher becomes Bash', () => {
96
+ // Grok expands the Bash matcher to its own shell tools, so a call can arrive under any
97
+ // of these names. One unmapped name means privacy-block skips its command scan and a
98
+ // secret-bearing command is never examined.
99
+ for (const name of ['run_terminal_command', 'run_terminal_cmd', 'PowerShell', 'bash']) {
100
+ assert.equal(normalizeToolName(name), 'Bash', `${name} must reach the Bash rule`);
101
+ }
102
+ });
103
+
104
+ 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.
108
+ assert.equal(normalizeToolName('write'), 'Write');
109
+ assert.equal(normalizeToolName('delete_file'), 'Write');
110
+ assert.equal(normalizeToolName('search_replace'), 'Edit');
111
+ assert.equal(normalizeToolName('read_file'), 'Read');
112
+ assert.equal(normalizeToolName('grep'), 'Grep');
113
+ assert.equal(normalizeToolName('grep_search'), 'Grep');
114
+ assert.equal(normalizeToolName('list_dir'), 'Glob');
115
+ assert.equal(normalizeToolName('spawn_subagent'), 'Task');
116
+ });
117
+
118
+ test('omp lowercase names are aliased even under a snake_case key', () => {
119
+ // omp's bridge sends Claude-shaped keys with lowercase values, so a reader that only
120
+ // translated camelCase keys would leave `bash` unmapped and reopen the exact gap the
121
+ // omp tool-name table was written to close.
122
+ const out = normalizeHookPayload({
123
+ hook_event_name: 'PreToolUse',
124
+ session_id: 'omp-1',
125
+ tool_name: 'bash',
126
+ tool_input: { command: 'cat .env' },
127
+ });
128
+ assert.equal(out.tool_name, 'Bash');
129
+ for (const [omp, claude] of [['read', 'Read'], ['edit', 'Edit'], ['write', 'Write'], ['grep', 'Grep'], ['ls', 'Glob']]) {
130
+ assert.equal(normalizeToolName(omp), claude);
131
+ }
132
+ });
133
+
134
+ test('a Claude payload keeps its keys', () => {
135
+ const claude = {
136
+ hook_event_name: 'PreToolUse',
137
+ session_id: 's1',
138
+ cwd: '/p',
139
+ tool_name: 'Bash',
140
+ tool_input: { command: 'ls' },
141
+ transcript_path: '/p/t.jsonl',
142
+ };
143
+ const before = JSON.stringify(claude);
144
+ const out = normalizeHookPayload(claude);
145
+ assert.deepEqual(out, claude, 'a Claude payload must come back with the same keys and values');
146
+ assert.notEqual(out, claude, 'the reader returns a copy');
147
+ assert.equal(JSON.stringify(claude), before, 'the input must not be mutated');
148
+ });
149
+
150
+ test('an unknown tool name is left alone', () => {
151
+ // A future host tool degrades to "not specially handled" rather than being renamed into
152
+ // a privileged rule it does not belong to.
153
+ assert.equal(normalizeToolName('some_future_tool'), 'some_future_tool');
154
+ assert.equal(normalizeToolName('Bash'), 'Bash');
155
+ const out = normalizeHookPayload({ toolName: 'some_future_tool', toolInput: {} });
156
+ assert.equal(out.tool_name, 'some_future_tool');
157
+ });
158
+
159
+ test('hostile input never throws', () => {
160
+ // The Stop gate and the approval hook end in a catch that emits a block, so an
161
+ // exception raised here would block completion on Claude, not just under grok.
162
+ const hostile = [
163
+ null, undefined, 42, 'string', true, [],
164
+ { toolName: 42, toolInput: 'not-an-object' },
165
+ { toolInput: [] },
166
+ { hookEventName: {} },
167
+ JSON.parse('{"__proto__": {"polluted": true}, "toolName": "read_file"}'),
168
+ Object.create(null),
169
+ ];
170
+ for (const input of hostile) {
171
+ assert.doesNotThrow(() => normalizeHookPayload(input), `threw on ${JSON.stringify(input)}`);
172
+ }
173
+ assert.equal(normalizeHookPayload(null), null);
174
+ assert.equal(normalizeHookPayload(42), 42);
175
+ assert.deepEqual(normalizeHookPayload([]), []);
176
+ assert.equal({}.polluted, undefined, 'a __proto__ key must not reach Object.prototype');
177
+ });
178
+
179
+ test('the event map covers every event grok emits', () => {
180
+ const expected = {
181
+ pre_tool_use: 'PreToolUse', post_tool_use: 'PostToolUse',
182
+ post_tool_use_failure: 'PostToolUseFailure', permission_denied: 'PermissionDenied',
183
+ user_prompt_submit: 'UserPromptSubmit', session_start: 'SessionStart',
184
+ session_end: 'SessionEnd', stop: 'Stop', stop_failure: 'StopFailure',
185
+ stop_cancelled: 'StopCancelled', subagent_start: 'SubagentStart',
186
+ subagent_stop: 'SubagentStop', pre_compact: 'PreCompact', post_compact: 'PostCompact',
187
+ notification: 'Notification',
188
+ };
189
+ for (const [snake, pascal] of Object.entries(expected)) {
190
+ assert.equal(normalizeHookPayload({ hookEventName: snake }).hook_event_name, pascal);
191
+ }
192
+ assert.equal(normalizeHookPayload({ hook_event_name: 'Stop' }).hook_event_name, 'Stop',
193
+ 'an already-PascalCase value is kept');
194
+ });
@@ -0,0 +1,168 @@
1
+ 'use strict';
2
+
3
+ // omp has no hooks.json. The bridge is the only thing between omp's lifecycle events and
4
+ // CafeKit's gate scripts, so a mistranslation disables every gate while looking installed.
5
+ // These tests drive the bridge's exported functions against real child hook processes from
6
+ // the tree an omp install produces: the Claude hook set with the omp overlay written over
7
+ // it. They do not launch omp, which needs provider credentials; the contract rows they rely
8
+ // on were read from the installed omp binary and are recorded in plan.md.
9
+
10
+ const assert = require('node:assert/strict');
11
+ const fs = require('node:fs');
12
+ const os = require('node:os');
13
+ const path = require('node:path');
14
+ const { test } = require('node:test');
15
+
16
+ const PACKAGE_ROOT = path.join(__dirname, '../..');
17
+ const BRIDGE = path.join(PACKAGE_ROOT, 'src/omp/extensions/cafekit-bridge.mjs');
18
+ const SETTINGS = require(path.join(PACKAGE_ROOT, 'src/claude/settings/settings.json'));
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
+
22
+ /**
23
+ * The hooks an omp install runs: the Claude set the manifest ships, then `src/omp/hooks/`
24
+ * written over it. Composed once under a dotted folder so runtime-dir.cjs derives `.omp`.
25
+ */
26
+ const COMPOSED_ROOT = fs.mkdtempSync(path.join(os.tmpdir(), 'cafekit-omp-composed-'));
27
+ const HOOKS = path.join(COMPOSED_ROOT, '.omp', 'hooks');
28
+ for (const rel of MANIFEST.runtime.files.filter((f) => f.startsWith('hooks/'))) {
29
+ const target = path.join(COMPOSED_ROOT, '.omp', rel);
30
+ fs.mkdirSync(path.dirname(target), { recursive: true });
31
+ fs.copyFileSync(path.join(PACKAGE_ROOT, 'src/claude', rel), target);
32
+ }
33
+ fs.cpSync(path.join(PACKAGE_ROOT, 'src/omp/hooks'), HOOKS, { recursive: true });
34
+ fs.cpSync(path.join(PACKAGE_ROOT, 'src/claude/scripts'), path.join(COMPOSED_ROOT, '.omp', 'scripts'), { recursive: true });
35
+
36
+ let bridge;
37
+ test.before(async () => { bridge = await import(BRIDGE); });
38
+ test.after(() => { fs.rmSync(COMPOSED_ROOT, { recursive: true, force: true }); });
39
+
40
+ function tempProject(withSecret = true) {
41
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'cafekit-omp-bridge-'));
42
+ if (withSecret) fs.writeFileSync(path.join(root, '.env'), 'TOKEN=redacted\n');
43
+ return root;
44
+ }
45
+
46
+ function registered(eventName) {
47
+ return (SETTINGS.hooks[eventName] || [])
48
+ .flatMap((g) => g.hooks || [])
49
+ .map((h) => (h.command.match(/hooks\/([a-z-]+\.cjs)/) || [])[1])
50
+ .filter(Boolean);
51
+ }
52
+
53
+ test('the dispatch table mirrors settings.json for every event omp can deliver', () => {
54
+ const { DISPATCH } = bridge;
55
+ const pairs = [
56
+ ['session_start', 'SessionStart'], ['session_before_compact', 'PreCompact'],
57
+ ['input', 'UserPromptSubmit'], ['tool_call', 'PreToolUse'],
58
+ ['tool_result', 'PostToolUse'], ['session_stop', 'Stop'],
59
+ ];
60
+ 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}`);
63
+ }
64
+ // omp has no subagent events; the gap is deliberate and documented, not forgotten.
65
+ assert.ok(!Object.values(DISPATCH).some((e) => e.event.startsWith('Subagent')));
66
+ });
67
+
68
+ test('every dispatch carries a stable session id and the project cwd', () => {
69
+ const { shapePayload } = bridge;
70
+ const ctx = { cwd: '/tmp/project' };
71
+ const a = shapePayload('input', { text: 'hi', source: 'user' }, ctx);
72
+ const b = shapePayload('tool_call', { toolName: 'bash', input: { command: 'ls' } }, ctx);
73
+ assert.ok(a.session_id && a.session_id === b.session_id,
74
+ 'omp input carries no session id; rules.cjs exits without one, so the bridge must mint and reuse it');
75
+ assert.equal(a.cwd, '/tmp/project', 'spec-gate.cjs falls back to payload.cwd to find the project');
76
+ assert.equal(shapePayload('session_stop', { session_id: 'omp-real' }, ctx).session_id, 'omp-real',
77
+ 'a session id omp does provide must win over the minted one');
78
+ });
79
+
80
+ test('each denial mechanism becomes an omp block carrying the hook reason', () => {
81
+ const { interpretVerdict } = bridge;
82
+ const stop = interpretVerdict({ code: 0, stdout: '{"decision":"block","reason":"R1"}\n' }, 'spec-gate.cjs');
83
+ assert.deepEqual([stop.block, stop.reason], [true, 'R1']);
84
+ const deny = interpretVerdict({ code: 0, stdout: JSON.stringify({ hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'deny', permissionDecisionReason: 'R2' } }) }, 'privacy-block.cjs');
85
+ assert.deepEqual([deny.block, deny.reason], [true, 'R2']);
86
+ // inspect-block / task-scaffold-guard exit 2 and write the reason on STDOUT, not stderr.
87
+ const exit2 = interpretVerdict({ code: 2, stdout: 'SCOPE LIMIT EXCEEDED\nRestricted zones: node_modules', stderr: '' }, 'inspect-block.cjs');
88
+ assert.equal(exit2.block, true);
89
+ assert.match(exit2.reason, /SCOPE LIMIT EXCEEDED/, 'a stderr-only reader would drop this reason');
90
+ // An ask under Claude has no omp equivalent; it must not fall through to allow.
91
+ const ask = interpretVerdict({ code: 0, stdout: JSON.stringify({ hookSpecificOutput: { permissionDecision: 'ask', permissionDecisionReason: 'R3' } }) }, 'x');
92
+ assert.equal(ask.block, true);
93
+ // Plain text that is not a verdict is context to inject, not a block.
94
+ const inject = interpretVerdict({ code: 0, stdout: '## Rules\n- keep docs under 800 lines' }, 'rules.cjs');
95
+ assert.deepEqual([inject.block, inject.inject.startsWith('## Rules')], [false, true]);
96
+ });
97
+
98
+ test('a lowercase bash command on a secret file is blocked end to end through the real fork', async () => {
99
+ const root = tempProject();
100
+ try {
101
+ const r = await bridge.runEvent('tool_call',
102
+ { toolName: 'bash', toolCallId: 'c1', input: { command: `cat ${path.join(root, '.env')}` } },
103
+ { cwd: root }, { dir: HOOKS });
104
+ assert.equal(r.block, true);
105
+ assert.equal(r.hook, 'privacy-block.cjs');
106
+ assert.match(r.reason, /\.env/, 'the block must name what it protected');
107
+ const ok = await bridge.runEvent('tool_call',
108
+ { toolName: 'read', toolCallId: 'c2', input: { file_path: path.join(root, 'README.md') } },
109
+ { cwd: root }, { dir: HOOKS });
110
+ assert.equal(ok.block, false, 'an ordinary read must not be blocked');
111
+ } finally { fs.rmSync(root, { recursive: true, force: true }); }
112
+ });
113
+
114
+ test('a slow hook blocks with its own name before omp cuts it off', async () => {
115
+ const slow = fs.mkdtempSync(path.join(os.tmpdir(), 'cafekit-omp-slow-'));
116
+ try {
117
+ fs.writeFileSync(path.join(slow, 'privacy-block.cjs'), 'setTimeout(() => {}, 60000);');
118
+ assert.ok(bridge.BRIDGE_TIMEOUT_MS < 30000, 'omp substitutes a reasonless block at 30000 ms');
119
+ const t0 = Date.now();
120
+ const r = await bridge.runEvent('tool_call', { toolName: 'bash', input: { command: 'ls' } }, { cwd: slow }, { dir: slow, timeoutMs: 300 });
121
+ assert.equal(r.block, true);
122
+ assert.match(r.reason, /privacy-block\.cjs exceeded 300 ms/);
123
+ assert.ok(Date.now() - t0 < 5000, 'the budget must actually fire');
124
+ } finally { fs.rmSync(slow, { recursive: true, force: true }); }
125
+ });
126
+
127
+ test('a crashing or missing gate blocks on gating events instead of silently allowing', async () => {
128
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cafekit-omp-crash-'));
129
+ try {
130
+ // Only the crashing gate is under test; the other two PreToolUse gates get allow stubs,
131
+ // otherwise their absence is what blocks (see the missing-gate case below).
132
+ 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);');
134
+ const crash = await bridge.runEvent('tool_call', { toolName: 'bash', input: { command: 'ls' } }, { cwd: dir }, { dir });
135
+ // A bare exit 1 with no verdict is an allow under the hooks' own contract. Real gates
136
+ // never surface a crash this way: the fork's privacy-block catch-all converts its own
137
+ // errors into a deny, which the fork tests pin separately.
138
+ assert.equal(crash.block, false, 'exit 1 with no verdict is an allow per the hooks\' own contract');
139
+ const missing = await bridge.runEvent('tool_call', { toolName: 'bash', input: { command: 'ls' } }, { cwd: dir }, { dir: path.join(dir, 'nowhere') });
140
+ assert.equal(missing.block, true, 'a gate that is not installed must be loud, not a silent allow');
141
+ assert.match(missing.reason, /not installed/);
142
+ } finally { fs.rmSync(dir, { recursive: true, force: true }); }
143
+ });
144
+
145
+ test('stop_hook_active short-circuits the gate so a blocked turn cannot loop forever', async () => {
146
+ const r = await bridge.runEvent('session_stop', { stop_hook_active: true, session_id: 's1' }, { cwd: os.tmpdir() }, { dir: HOOKS });
147
+ assert.deepEqual([r.block, r.skipped], [false, 'stop_hook_active']);
148
+ });
149
+
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
+ test('input injections flow back as a transform that keeps the user text intact', async () => {
162
+ const root = tempProject(false);
163
+ try {
164
+ const r = await bridge.runEvent('input', { text: 'please read .env for me', source: 'user' }, { cwd: root }, { dir: HOOKS });
165
+ assert.equal(r.block, false);
166
+ assert.match(r.inject, /Secret handling/, 'secret-output-guardrail must inject on a credential prompt');
167
+ } finally { fs.rmSync(root, { recursive: true, force: true }); }
168
+ });