@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,210 @@
1
+ 'use strict';
2
+
3
+ // The user runs Claude Code, Codex, Grok, and omp inside Orca (onorca.dev) panes across
4
+ // many projects and had to explain Orca to the agent at the start of every session. This
5
+ // packet makes that awareness ship with the install instead: a cf:orca skill that reads
6
+ // Orca's own guide at use time, bounds what it may send to another pane, and never prints
7
+ // the two credentials Orca sets in every pane's environment. Each case here states the
8
+ // failure it prevents.
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 { spawnSync } = require('node:child_process');
15
+ const { test } = require('node:test');
16
+
17
+ const PACKAGE_ROOT = path.join(__dirname, '..', '..');
18
+ const INSTALLER = path.join(PACKAGE_ROOT, 'bin/install.js');
19
+ const SKILL_SOURCE = path.join(PACKAGE_ROOT, 'src/claude/skills/orca/SKILL.md');
20
+ const ROUTING_RULE_SOURCE = path.join(PACKAGE_ROOT, 'src/claude/rules/skill-domain-routing.md');
21
+ const MANIFEST_PATH = path.join(PACKAGE_ROOT, 'src/claude/migration-manifest.json');
22
+
23
+ const ROUTING_ROW = '| terminal/pane control in the surrounding agent host | reading, waiting on, or sending to another agent pane, or spawning an agent into a host-managed worktree |';
24
+
25
+ function inTempProject(run) {
26
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'cafekit-orca-skill-'));
27
+ try {
28
+ const init = spawnSync('git', ['-C', root, 'init', '-q'], { encoding: 'utf8' });
29
+ assert.equal(init.status, 0, init.stderr);
30
+ return run(root);
31
+ } finally {
32
+ fs.rmSync(root, { recursive: true, force: true });
33
+ }
34
+ }
35
+
36
+ function install(root, platforms, extra = []) {
37
+ return spawnSync(process.execPath, [INSTALLER, '--platform', platforms.join(','), '--yes', ...extra],
38
+ { cwd: root, encoding: 'utf8', env: { ...process.env, PATH: '/usr/bin:/bin' } });
39
+ }
40
+
41
+ function installedCatalog(root, runtime) {
42
+ const script = path.join(root, runtime === 'codex' ? '.codex' : '.claude', 'scripts', 'generate-skill-catalog.cjs');
43
+ const result = spawnSync(process.execPath, [script, '--json'], { cwd: root, encoding: 'utf8' });
44
+ assert.equal(result.status, 0, result.stderr);
45
+ return JSON.parse(result.stdout);
46
+ }
47
+
48
+ /** The body is everything after the closing frontmatter fence; forbidden-string and
49
+ * authority checks apply there, not to the required `name: cf:orca` line above it. */
50
+ function bodyOf(content) {
51
+ const match = content.match(/^---\r?\n[\s\S]*?\r?\n---\r?\n([\s\S]*)$/);
52
+ assert.ok(match, 'SKILL.md must have a closing frontmatter fence');
53
+ return match[1];
54
+ }
55
+
56
+ function frontmatterDescription(content) {
57
+ const fm = content.match(/^---\r?\n([\s\S]*?)\r?\n---/)[1];
58
+ const line = fm.split(/\r?\n/).find((entry) => entry.startsWith('description:'));
59
+ assert.ok(line, 'frontmatter must declare description');
60
+ const raw = line.slice('description:'.length).trim();
61
+ assert.ok(raw.length >= 2 && raw[0] === raw[raw.length - 1] && /['"]/.test(raw[0]),
62
+ 'description must be a single quoted scalar');
63
+ return raw.slice(1, -1);
64
+ }
65
+
66
+ const SOURCE = fs.readFileSync(SKILL_SOURCE, 'utf8');
67
+ const BODY = bodyOf(SOURCE);
68
+ const DESCRIPTION = frontmatterDescription(SOURCE);
69
+
70
+ test('cf:orca installs for claude and codex', () => {
71
+ inTempProject((root) => {
72
+ const result = install(root, ['claude', 'codex']);
73
+ assert.equal(result.status, 0, `${result.stdout}\n${result.stderr}`);
74
+
75
+ const claudeSkill = path.join(root, '.claude', 'skills', 'orca', 'SKILL.md');
76
+ const codexSkill = path.join(root, '.agents', 'skills', 'orca', 'SKILL.md');
77
+ assert.equal(fs.existsSync(claudeSkill), true, 'the Claude skill directory must exist');
78
+ assert.equal(fs.existsSync(codexSkill), true, 'the Codex skill directory must exist');
79
+
80
+ const claudeContent = fs.readFileSync(claudeSkill, 'utf8');
81
+ const codexContent = fs.readFileSync(codexSkill, 'utf8');
82
+ assert.match(claudeContent, /^name: cf:orca$/m, 'Claude keeps the colon identity');
83
+ assert.match(codexContent, /^name: cf-orca$/m, 'Codex rewrites the colon to a hyphen');
84
+ assert.doesNotMatch(codexContent, /^name: cf:orca$/m);
85
+ });
86
+ });
87
+
88
+ test('an omp-only install ships no skill', () => {
89
+ inTempProject((root) => {
90
+ const result = install(root, ['omp']);
91
+ assert.equal(result.status, 0, `${result.stdout}\n${result.stderr}`);
92
+
93
+ // omp discovers .claude/skills and .agents/skills itself; CafeKit copies skills to
94
+ // neither for an omp-only install, so orca must be absent from both, not merely from
95
+ // some third omp-owned skills directory this packet never touches.
96
+ assert.equal(fs.existsSync(path.join(root, '.claude', 'skills', 'orca')), false);
97
+ assert.equal(fs.existsSync(path.join(root, '.agents', 'skills', 'orca')), false);
98
+ });
99
+ });
100
+
101
+ test('the catalog resolves cf:orca on both hosts with no diagnostic', () => {
102
+ inTempProject((root) => {
103
+ const result = install(root, ['claude', 'codex']);
104
+ assert.equal(result.status, 0, `${result.stdout}\n${result.stderr}`);
105
+
106
+ for (const runtime of ['claude', 'codex']) {
107
+ const catalog = installedCatalog(root, runtime);
108
+ const entry = catalog.skills.find((skill) => skill.directory === 'orca');
109
+ assert.ok(entry, `${runtime} catalog must list the orca directory`);
110
+ assert.equal(entry.public_id, 'cf:orca', `${runtime} public identity`);
111
+ const diagnostics = catalog.diagnostics.filter((item) => item.directory === 'orca');
112
+ assert.deepEqual(diagnostics, [], `${runtime} must report no diagnostic for orca`);
113
+ }
114
+ });
115
+ });
116
+
117
+ test('the description triggers only on Orca context', () => {
118
+ // Every trigger phrase carries "Orca", a pane's agent name, or Vietnamese context, and
119
+ // is quoted. Stripping the quoted phrases must leave no bare panel/terminal/worktree —
120
+ // otherwise "add a settings panel", "run this in a terminal", or "my worktree is stuck"
121
+ // would all pull this skill into requests that have nothing to do with Orca.
122
+ for (const phrase of ['pane Orca', 'panel Codex', 'theo dõi pane', 'gửi terminal bên kia', 'Orca worktree', 'spawn codex vào worktree']) {
123
+ assert.ok(DESCRIPTION.includes(`"${phrase}"`), `description must quote "${phrase}"`);
124
+ }
125
+ const stripped = DESCRIPTION.replace(/"[^"]*"/g, '');
126
+ for (const bare of ['panel', 'terminal', 'worktree']) {
127
+ assert.doesNotMatch(stripped, new RegExp(`\\b${bare}\\b`, 'i'),
128
+ `${bare} must not appear as a bare trigger outside a quoted phrase`);
129
+ }
130
+ assert.match(DESCRIPTION, /ORCA_PANE_KEY/);
131
+ });
132
+
133
+ test('the body points at orca skills get and stops when the CLI is missing', () => {
134
+ assert.match(BODY, /ORCA_PANE_KEY/);
135
+ assert.match(BODY, /orca skills get orca-cli/);
136
+ assert.match(BODY, /orca skills get orchestration/);
137
+ assert.match(BODY, /--references/);
138
+ assert.match(BODY, /--reference <name>/);
139
+ assert.match(BODY, /orca skills list/);
140
+ // The guide changes between CLI releases (observed between 1.4.198 and 1.4.200), so the
141
+ // skill must route to it rather than repeat any of its commands as a fact of its own.
142
+ assert.doesNotMatch(BODY, /ORCA_CLI_COMMAND/);
143
+ assert.match(BODY, /command -v orca/);
144
+ assert.match(BODY, /orca-dev/);
145
+ assert.match(BODY, /not available in this pane/i);
146
+ });
147
+
148
+ test('the body bounds terminal send to the current worktree and distrusts terminal output', () => {
149
+ assert.match(BODY, /ORCA_WORKTREE_ID/);
150
+ const sentences = BODY.split(/(?<=[.!?])\s+/);
151
+ const authoritySentence = sentences.find((sentence) => sentence.includes('terminal send'));
152
+ assert.ok(authoritySentence, 'one sentence must name terminal send');
153
+ assert.match(authoritySentence, /ORCA_WORKTREE_ID/);
154
+ assert.match(authoritySentence, /\bask\b/i);
155
+ assert.match(authoritySentence, /\bbefore\b/i);
156
+
157
+ assert.match(BODY, /terminal.{0,40}(read|show|list)/is);
158
+ assert.match(BODY, /untrusted/i);
159
+ assert.match(BODY, /never follow/i);
160
+ assert.match(BODY, /never echo/i);
161
+ });
162
+
163
+ test('the body draws the Herdr boundary and forbids printing the tokens', () => {
164
+ assert.match(BODY, /Herdr/);
165
+ assert.match(BODY, /HERDR_ENV=1/);
166
+ assert.match(BODY, /herdr-orchestrator/);
167
+ assert.match(BODY, /ORCA_AGENT_HOOK_TOKEN/);
168
+ assert.match(BODY, /ORCA_AGENT_LAUNCH_TOKEN/);
169
+ assert.match(BODY, /Never print/i);
170
+ });
171
+
172
+ test('the body carries nothing the Codex projection rewrites or forbids', () => {
173
+ // normalizeCodexBody rewrites "Claude Code" to "Codex CLI" and codex-native.test.js
174
+ // keeps a fixed four-file allowlist for AskUserQuestion and bans /cf:, cf:, and
175
+ // "Claude Code" across .agents/skills/**. A body written against one host only would
176
+ // read wrong, or fail installed, on the other.
177
+ assert.doesNotMatch(BODY, /Claude Code/);
178
+ assert.doesNotMatch(BODY, /AskUserQuestion/);
179
+ assert.doesNotMatch(BODY, /\/cf:/);
180
+ assert.doesNotMatch(BODY, /\bcf:/);
181
+ assert.doesNotMatch(BODY, /\bAgent\(/);
182
+ assert.doesNotMatch(BODY, /subagent_type/);
183
+ assert.doesNotMatch(BODY, /`Agent`/);
184
+ assert.doesNotMatch(BODY, /\bSendMessage\b/);
185
+ });
186
+
187
+ test('the manifest lists orca as a core skill', () => {
188
+ const manifest = JSON.parse(fs.readFileSync(MANIFEST_PATH, 'utf8'));
189
+ assert.equal(manifest.skills.required.includes('orca'), true);
190
+ assert.equal(manifest.skills.bundles.documentSkills.includes('orca'), false);
191
+ });
192
+
193
+ test('the routing row is a neutral slot on every host', () => {
194
+ const source = fs.readFileSync(ROUTING_RULE_SOURCE, 'utf8');
195
+ assert.ok(source.includes(ROUTING_ROW), 'the source rule must carry the exact row');
196
+ // The existing negative regex in skill-routing-source.test.js:91 covers only the six
197
+ // document skills, so a stray /cf:orca or "orchestration" in this row would slip past
198
+ // every existing guard.
199
+ assert.doesNotMatch(source, /\/cf:orca/);
200
+ assert.doesNotMatch(source, /orchestration/);
201
+
202
+ inTempProject((root) => {
203
+ const result = install(root, ['claude', 'codex', 'omp']);
204
+ assert.equal(result.status, 0, `${result.stdout}\n${result.stderr}`);
205
+ for (const relative of ['.claude/rules/skill-domain-routing.md', '.codex/rules/skill-domain-routing.md', '.omp/rules/skill-domain-routing.md']) {
206
+ const installed = fs.readFileSync(path.join(root, relative), 'utf8');
207
+ assert.ok(installed.includes(ROUTING_ROW), `${relative} must carry the row`);
208
+ }
209
+ });
210
+ });
@@ -130,6 +130,7 @@ const REQUIRED_PAYLOAD = [
130
130
  'src/claude/skills/route/references/task-taxonomy.md',
131
131
  'src/claude/skills/route/references/chaining-patterns.md',
132
132
  'src/claude/skills/route/references/agent-timing.md',
133
+ 'src/claude/skills/orca/SKILL.md',
133
134
  'src/claude/skills/brainstorm/SKILL.md',
134
135
  'src/claude/skills/brainstorm/references/question-framework.md',
135
136
  'src/claude/agents/brainstormer.md',
@@ -0,0 +1,158 @@
1
+ 'use strict';
2
+
3
+ // The gate hooks derive their runtime directory from their own location instead of a
4
+ // literal `.claude`. Each case copies the helper into a directory shaped like a real
5
+ // layout and requires it from there, so the derivation is exercised against the path the
6
+ // module actually sees — not a mocked one.
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 { test } = require('node:test');
13
+
14
+ const PACKAGE_ROOT = path.join(__dirname, '../..');
15
+ const HELPER = path.join(PACKAGE_ROOT, 'src/claude/hooks/lib/runtime-dir.cjs');
16
+ const { spawnSync } = require('node:child_process');
17
+
18
+ /** Place the helper at <root>/<layout>/hooks/lib/ and return what it derives. */
19
+ function deriveAt(root, layout) {
20
+ const lib = path.join(root, layout, 'hooks', 'lib');
21
+ fs.mkdirSync(lib, { recursive: true });
22
+ const target = path.join(lib, 'runtime-dir.cjs');
23
+ fs.copyFileSync(HELPER, target);
24
+ // Each copy is a distinct module path, so require caches never collide.
25
+ return require(target);
26
+ }
27
+
28
+ function withTemp(run) {
29
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'cafekit-runtime-dir-'));
30
+ try { return run(root); } finally { fs.rmSync(root, { recursive: true, force: true }); }
31
+ }
32
+
33
+ test('installed layouts derive their own folder', () => {
34
+ withTemp((root) => {
35
+ for (const folder of ['.claude', '.codex', '.omp']) {
36
+ const m = deriveAt(root, path.join('project-' + folder.slice(1), folder));
37
+ assert.equal(m.runtimeDirName(), folder);
38
+ assert.equal(m.runtimeDir('/p'), path.join('/p', folder));
39
+ assert.equal(m.runtimePath('/p', 'runtime.json'), path.join('/p', folder, 'runtime.json'));
40
+ }
41
+ });
42
+ });
43
+
44
+ test('the source tree maps platform to dotted name', () => {
45
+ withTemp((root) => {
46
+ for (const platform of ['claude', 'omp', 'codex']) {
47
+ const m = deriveAt(root, path.join('packages', 'spec', 'src', platform));
48
+ assert.equal(m.runtimeDirName(), `.${platform}`, `source tree for ${platform}`);
49
+ }
50
+ });
51
+ });
52
+
53
+ test('a dotted folder wins over the source marker', () => {
54
+ withTemp((root) => {
55
+ // An install whose absolute path happens to contain /packages/spec/src/ must still
56
+ // report its real folder, not a doubled-dot name derived from the marker.
57
+ const m = deriveAt(root, path.join('packages', 'spec', 'src', 'fixture', '.codex'));
58
+ assert.equal(m.runtimeDirName(), '.codex');
59
+ });
60
+ });
61
+
62
+ test('an unknown layout falls back to .claude', () => {
63
+ withTemp((root) => {
64
+ const m = deriveAt(root, path.join('somewhere', 'else'));
65
+ assert.equal(m.runtimeDirName(), '.claude', 'the pre-helper behaviour is the fallback');
66
+ });
67
+ });
68
+
69
+ test('the helper in the real source tree derives .claude', () => {
70
+ const m = require(HELPER);
71
+ assert.equal(m.runtimeDirName(), '.claude');
72
+ });
73
+
74
+ /** Copy the whole hook tree and the scripts it requires under <root>/<folder>/. */
75
+ function installTreeAs(root, folder) {
76
+ const rt = path.join(root, folder);
77
+ fs.cpSync(path.join(PACKAGE_ROOT, 'src/claude/hooks'), path.join(rt, 'hooks'), {
78
+ recursive: true, filter: (src) => !src.includes(`${path.sep}__tests__`),
79
+ });
80
+ fs.cpSync(path.join(PACKAGE_ROOT, 'src/claude/scripts'), path.join(rt, 'scripts'), { recursive: true });
81
+ return rt;
82
+ }
83
+
84
+ /** Provenance derives Base/Head with git, so the fixture must be a repository (one empty commit). */
85
+ function gitInit(root) {
86
+ for (const args of [['init', '-q'], ['config', 'user.email', 'cafekit@example.invalid'], ['config', 'user.name', 'CafeKit Test'], ['commit', '--allow-empty', '-qm', 'fixture']]) {
87
+ const result = spawnSync('git', ['-C', root, ...args], { encoding: 'utf8' });
88
+ assert.equal(result.status, 0, `git ${args[0]} failed: ${result.stderr}`);
89
+ }
90
+ }
91
+
92
+ function runHook(rt, name, payload) {
93
+ const r = spawnSync(process.execPath, [path.join(rt, 'hooks', name)], {
94
+ cwd: path.dirname(rt), input: JSON.stringify(payload), encoding: 'utf8',
95
+ env: { ...process.env, CLAUDE_PROJECT_DIR: '', PROJECT_ROOT: '' },
96
+ });
97
+ return { code: r.status, out: (r.stdout || '') + (r.stderr || '') };
98
+ }
99
+
100
+ test('a hook tree copied under .omp reads .omp/runtime.json', () => {
101
+ withTemp((root) => {
102
+ const rt = installTreeAs(root, '.omp');
103
+ gitInit(root);
104
+ fs.mkdirSync(path.join(root, 'specs'), { recursive: true });
105
+ fs.writeFileSync(path.join(root, '.env'), 'TOKEN=redacted\n');
106
+ assert.equal(fs.existsSync(path.join(root, '.claude')), false, 'the fixture must hold no .claude path at all');
107
+
108
+ // rules.cjs: docs path from the sentinel config
109
+ fs.writeFileSync(path.join(rt, 'runtime.json'), JSON.stringify({ paths: { docs: 'SENTINEL-DOCS', plans: 'plans' } }));
110
+ const rules = runHook(rt, 'rules.cjs', { session_id: `s-${Date.now()}`, cwd: root });
111
+ assert.match(rules.out, /SENTINEL-DOCS/, 'rules.cjs must read .omp/runtime.json');
112
+
113
+ // inspect-block.cjs: disabled through the sentinel allows a broad search
114
+ fs.writeFileSync(path.join(rt, 'runtime.json'), JSON.stringify({ inspect: { enabled: false } }));
115
+ const inspect = runHook(rt, 'inspect-block.cjs', { tool_name: 'Bash', tool_input: { command: 'grep -rn TODO .' }, cwd: root });
116
+ assert.equal(inspect.code, 0, 'inspect.enabled:false in .omp/runtime.json must allow');
117
+
118
+ // privacy-block.cjs: kill switch through the sentinel allows a .env read
119
+ fs.writeFileSync(path.join(rt, 'runtime.json'), JSON.stringify({ privacyBlock: false }));
120
+ const privacy = runHook(rt, 'privacy-block.cjs', { tool_name: 'Read', tool_input: { file_path: path.join(root, '.env') }, cwd: root });
121
+ assert.doesNotMatch(privacy.out, /permissionDecision/, 'privacyBlock:false in .omp/runtime.json must allow');
122
+
123
+ // spec-gate.cjs exits silently unless a packet resolves, and only then reads the
124
+ // completion_gate flag. Resolving a done task derives provenance through git, so the
125
+ // fixture is a repository (gitInit above) and holds one done task to look at.
126
+ const feature = path.join(root, 'specs', 'demo'); fs.mkdirSync(feature, { recursive: true });
127
+ fs.writeFileSync(path.join(feature, 'plan.md'), '# Demo plan\n');
128
+ fs.writeFileSync(path.join(feature, 'task-01-demo.md'), [
129
+ '# Task 01: demo', '', 'Status: done', '', '## Dependencies', '', '- none', '',
130
+ '## Verification Plan', '', '- Command: node --test', '',
131
+ ].join('\n'));
132
+ // the completion_gate flag is read from the sentinel and answered as a block
133
+ fs.writeFileSync(path.join(rt, 'runtime.json'), JSON.stringify({ spec: { completion_gate: false } }));
134
+ const gate = runHook(rt, 'spec-gate.cjs', { session_id: 's', cwd: root, hook_event_name: 'Stop', stop_hook_active: false });
135
+ assert.match(gate.out, /worker-writable flag/, 'spec-gate.cjs must read .omp/runtime.json');
136
+ });
137
+ });
138
+
139
+ test('advice names the directory the hook lives in', () => {
140
+ withTemp((root) => {
141
+ for (const [folder, skills] of [['.omp', '.agents/skills'], ['.claude', '.claude/skills']]) {
142
+ const project = path.join(root, `p${folder}`); fs.mkdirSync(project, { recursive: true });
143
+ const rt = installTreeAs(project, folder);
144
+ fs.mkdirSync(path.join(project, 'specs', 'demo'), { recursive: true });
145
+ fs.writeFileSync(path.join(rt, 'runtime.json'), '{}');
146
+ // task-scaffold-guard: a raw Write into a nested task path is refused with scaffold advice
147
+ const guard = runHook(rt, 'task-scaffold-guard.cjs', { tool_name: 'Write', tool_input: { file_path: path.join(project, 'specs', 'demo', 'tasks', 'task-R0-01-x.md'), content: '# x' }, cwd: project });
148
+ assert.match(guard.out, new RegExp(`node ${folder.replace('.', '\\.')}/scripts/spec-scaffold\\.cjs`), `scaffold advice must name ${folder}`);
149
+ // inspect-block: header hint names the runtime file (static check on the installed copy)
150
+ const src = fs.readFileSync(path.join(rt, 'hooks', 'inspect-block.cjs'), 'utf8');
151
+ assert.match(src, /<runtime>\/runtime\.json/, 'the disable hint no longer hardcodes a platform');
152
+ // agent.cjs: skills advice follows the platform registry, not the runtime dir
153
+ const agentSrc = fs.readFileSync(path.join(rt, 'hooks', 'agent.cjs'), 'utf8');
154
+ assert.match(agentSrc, /'\.agents\/skills'/, 'non-Claude platforms read .agents/skills');
155
+ void skills;
156
+ }
157
+ });
158
+ });
@@ -12,7 +12,7 @@ const { test } = require('node:test');
12
12
  const PACKAGE_ROOT = path.join(__dirname, '../..');
13
13
  const SCHEMA_PATH = path.join(PACKAGE_ROOT, 'src/claude/runtime.schema.json');
14
14
  const SCHEMA = JSON.parse(fs.readFileSync(SCHEMA_PATH, 'utf8'));
15
- const RUNTIMES = ['src/claude/runtime.json', 'src/codex/runtime.json'];
15
+ const RUNTIMES = ['src/claude/runtime.json', 'src/codex/runtime.json', 'src/omp/runtime.json'];
16
16
  const MANIFEST = require(path.join(PACKAGE_ROOT, 'src/claude/migration-manifest.json'));
17
17
 
18
18
  function readRuntime(relative) {
@@ -54,14 +54,14 @@ test('the schema is a valid, self-describing JSON Schema document', () => {
54
54
  assert.equal(SCHEMA.additionalProperties, false, 'an unknown top-level key must be reported, not accepted silently');
55
55
  });
56
56
 
57
- test('every key in both shipped runtime.json files is described by the schema', () => {
57
+ test('every key in each shipped runtime.json is described by the schema', () => {
58
58
  for (const relative of RUNTIMES) {
59
59
  const missing = undescribedKeys(readRuntime(relative), SCHEMA);
60
60
  assert.deepEqual(missing, [], `${relative} ships keys the schema does not describe: ${missing.join(', ')}`);
61
61
  }
62
62
  });
63
63
 
64
- test('both runtimes point at the schema that installs beside them', () => {
64
+ test('every runtime points at the schema that installs beside it', () => {
65
65
  for (const relative of RUNTIMES) {
66
66
  assert.equal(readRuntime(relative).$schema, './runtime.schema.json', `${relative} must reference the schema`);
67
67
  }
@@ -71,6 +71,8 @@ test('both runtimes point at the schema that installs beside them', () => {
71
71
  );
72
72
  const codexPhase = fs.readFileSync(path.join(PACKAGE_ROOT, 'bin/phases/codex-runtime.js'), 'utf8');
73
73
  assert.match(codexPhase, /runtime\.schema\.json/, 'the schema must also be installed for Codex');
74
+ const ompPhase = fs.readFileSync(path.join(PACKAGE_ROOT, 'bin/phases/omp-runtime.js'), 'utf8');
75
+ assert.match(ompPhase, /runtime\.schema\.json/, 'the schema must also be installed for omp');
74
76
  });
75
77
 
76
78
  test('every documented property carries a description a reader can act on', () => {
@@ -263,7 +263,8 @@ function installGate(root, kind) {
263
263
  'scripts/workflow-policy.cjs', 'scripts/provenance.cjs', 'scripts/spec-resolver.cjs',
264
264
  'scripts/spec-receipt.cjs', 'scripts/validate-spec-output.cjs', 'scripts/spec-ground.cjs',
265
265
  'scripts/spec-semantic-model.cjs', 'scripts/spec-final-state.cjs',
266
- 'hooks/lib/runtime-path-safety.cjs', 'hooks/lib/hook-state-dir.cjs',
266
+ 'hooks/lib/runtime-path-safety.cjs', 'hooks/lib/hook-state-dir.cjs', 'hooks/lib/runtime-dir.cjs',
267
+ 'hooks/lib/hook-payload.cjs',
267
268
  ]);
268
269
  if (kind === 'claude') {
269
270
  for (const name of [
@@ -466,3 +467,29 @@ test('scaffold creates planning artifacts but no task or feature receipts', () =
466
467
  assert.equal(files.some((file) => file.includes('receipt')), false);
467
468
  });
468
469
  });
470
+
471
+ test('legacy receipt still requires provenance under a clean committed tree', () => {
472
+ // The workflow task path may validate a committed, unchanged receipt on
473
+ // structure alone; the legacy task and feature receipt paths must not.
474
+ withTempResources((resources) => {
475
+ const root = gitRoot(resources);
476
+ const { featureDir } = prepare(root);
477
+ const stale = { base: 'f'.repeat(40), head: 'e'.repeat(40) };
478
+ fs.writeFileSync(
479
+ path.join(featureDir, 'receipts', path.basename(TASK)),
480
+ receiptBody(stale, `Task: ${path.basename(TASK)}\nTask path: ${TASK}`),
481
+ );
482
+ fs.writeFileSync(path.join(featureDir, 'feature-receipt.md'), receiptBody(stale, `Feature: ${FEATURE}`));
483
+ for (const args of [['add', '-A'], ['commit', '-qm', 'committed legacy receipts']]) {
484
+ const result = spawnSync('git', ['-C', root, ...args], { encoding: 'utf8' });
485
+ assert.equal(result.status, 0, result.stderr);
486
+ }
487
+ const clean = spawnSync('git', ['-C', root, 'status', '--porcelain'], { encoding: 'utf8' });
488
+ assert.equal(clean.stdout.trim(), '', 'fixture tree must be clean before the check');
489
+ const ctx = runtime(root);
490
+ const taskFailures = RECEIPT.checkTaskReceipt(featureDir, TASK, { completed_at: '2026-08-13T00:00:00.000Z' }, ctx, POLICY).failures;
491
+ assert.ok(taskFailures.includes('provenance'), `legacy task receipts keep provenance binding when committed and clean; got ${JSON.stringify(taskFailures)}`);
492
+ const featureFailures = RECEIPT.checkFeatureReceipt(featureDir, ctx, POLICY).failures;
493
+ assert.ok(featureFailures.includes('provenance'), `feature receipts keep provenance binding when committed and clean; got ${JSON.stringify(featureFailures)}`);
494
+ });
495
+ });
package/bin/install.js CHANGED
@@ -37,12 +37,12 @@ const {
37
37
  const { reconcileSkillInventory } = require('./phases/skill-inventory');
38
38
  const { mergeClaudeSettings } = require('./phases/claude-settings');
39
39
  const { installCodexRuntime } = require('./phases/codex-runtime');
40
+ const { installOmpRuntime } = require('./phases/omp-runtime');
40
41
  const { writePlatformVersionMetadata } = require('./phases/write-metadata');
41
42
  const { checkVersions } = require('./lib/version-check');
42
43
  const { ensureGitignore } = require('./phases/root-config');
43
44
  const { runPostInstall } = require('./phases/post-install');
44
45
  const { setupSkillDeps } = require('./phases/skills-setup');
45
- const { setupRtk } = require('./phases/setup-rtk');
46
46
  const { printSummary } = require('./phases/summary');
47
47
 
48
48
  /** Install a single platform: payload + runtime + metadata, under one spinner. */
@@ -78,6 +78,10 @@ function installPlatform(ctx, platformKey) {
78
78
  installCodexRuntime(ctx, platformKey);
79
79
  }
80
80
 
81
+ if (platformKey === 'omp') {
82
+ installOmpRuntime(ctx, platformKey);
83
+ }
84
+
81
85
  writePlatformVersionMetadata(ctx, platformKey);
82
86
 
83
87
  const wrote = (ctx.results.copied - before.copied) + (ctx.results.updated - before.updated);
@@ -105,7 +109,6 @@ Options:
105
109
  -u, --upgrade, -f, --force Alias of --force-overwrite
106
110
  --with-skills-deps Install skill dependencies (Python venv + pip, npm,
107
111
  Chromium/Playwright). Otherwise prompted interactively.
108
- --with-rtk Install the rtk token-saver (binary + Claude Code hook).
109
112
  Otherwise prompted interactively.
110
113
  --with-document-skills Install docs/DOCX/PDF/PPTX/XLSX/multimodal skills
111
114
  --without-document-skills Skip or remove CafeKit-owned document skills
@@ -190,9 +193,6 @@ async function main() {
190
193
  ensureGitignore(ctx);
191
194
  await runPostInstall(ctx);
192
195
  await setupSkillDeps(ctx);
193
- if (ctx.platforms.includes('claude')) {
194
- await setupRtk(ctx);
195
- }
196
196
 
197
197
  // Phase handlers may report recoverable-looking errors through counters
198
198
  // instead of throwing. Treat them as transactional failure so the snapshot
@@ -119,6 +119,35 @@ const PLATFORMS = {
119
119
  rules: true,
120
120
  commands: false
121
121
  }
122
+ },
123
+ omp: {
124
+ id: 'omp',
125
+ name: 'Oh My Pi',
126
+ description: 'Oh My Pi coding agent',
127
+ folder: '.omp',
128
+ detectFiles: ['.omp'],
129
+ commandsDir: null,
130
+ // omp discovers .claude/skills and .agents/skills itself, so CafeKit copies
131
+ // no skill payload for it. See specs/omp-runtime-support/plan.md.
132
+ skillsDir: '.agents/skills',
133
+ agentsDir: null,
134
+ skillsRef: '.agents/skills',
135
+ commandPrefix: '$',
136
+ sourceDir: 'claude',
137
+ sourceSubdir: null,
138
+ backupTargets: ['.omp', 'AGENTS.md'],
139
+ ownership: {
140
+ recordRoot: '.',
141
+ allowedRoots: ['.omp']
142
+ },
143
+ capabilities: {
144
+ skills: false,
145
+ agents: false,
146
+ references: true,
147
+ scripts: true,
148
+ rules: true,
149
+ commands: false
150
+ }
122
151
  }
123
152
  // Add new platforms here:
124
153
  // cursor: {
@@ -132,6 +161,25 @@ const PLATFORMS = {
132
161
  // }
133
162
  };
134
163
 
164
+ /**
165
+ * Hosts that run another platform's install rather than one of their own.
166
+ *
167
+ * Grok CLI discovers `.claude/skills`, `.claude/rules`, `CLAUDE*.md` and the hooks in
168
+ * `.claude/settings.json` through its own Claude-compatibility layer, and the gate hooks
169
+ * normalize its envelope, so `--platform grok` provisions the Claude runtime. There is no
170
+ * `.grok/` payload and deliberately no detection marker: a repository that merely holds
171
+ * `.grok/` has not asked for a CafeKit install.
172
+ */
173
+ const PLATFORM_ALIASES = Object.freeze({
174
+ grok: 'claude'
175
+ });
176
+
177
+ /** Resolve host aliases and drop duplicates, preserving the requested order. */
178
+ function resolvePlatformAliases(keys = []) {
179
+ const resolved = keys.map((key) => PLATFORM_ALIASES[key] || key);
180
+ return { platforms: [...new Set(resolved)], aliased: keys.some((key) => Boolean(PLATFORM_ALIASES[key])) };
181
+ }
182
+
135
183
  // ═══════════════════════════════════════════════════════════
136
184
  // DETECTION + PLATFORM HELPERS
137
185
  // ═══════════════════════════════════════════════════════════
@@ -207,7 +255,6 @@ function parseInstallerArgs(argv) {
207
255
  dryRun: false,
208
256
  yes: false,
209
257
  withSkillsDeps: false,
210
- withRtk: false,
211
258
  withDocumentSkills: false,
212
259
  withoutDocumentSkills: false,
213
260
  platforms: []
@@ -224,8 +271,6 @@ function parseInstallerArgs(argv) {
224
271
  args.yes = true;
225
272
  } else if (arg === '--with-skills-deps') {
226
273
  args.withSkillsDeps = true;
227
- } else if (arg === '--with-rtk') {
228
- args.withRtk = true;
229
274
  } else if (arg === '--with-document-skills') {
230
275
  args.withDocumentSkills = true;
231
276
  } else if (arg === '--without-document-skills') {
@@ -321,6 +366,8 @@ module.exports = {
321
366
  INSTALL_COMMAND,
322
367
  DEPENDENCY_TEMPLATES,
323
368
  PLATFORMS,
369
+ PLATFORM_ALIASES,
370
+ resolvePlatformAliases,
324
371
  validateManifestV2,
325
372
  loadClaudeMigrationManifest,
326
373
  detectPlatforms,