@haposoft/cafekit 0.16.0 → 0.16.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 (57) hide show
  1. package/README.md +1 -1
  2. package/bin/__tests__/codex-hooks.test.js +125 -0
  3. package/bin/__tests__/develop-contract.test.js +43 -1
  4. package/bin/__tests__/grok-envelope.test.js +271 -0
  5. package/bin/__tests__/grok-platform.test.js +133 -0
  6. package/bin/__tests__/hook-payload.test.js +194 -0
  7. package/bin/__tests__/omp-bridge.test.js +168 -0
  8. package/bin/__tests__/omp-hooks.test.js +178 -0
  9. package/bin/__tests__/omp-runtime.test.js +150 -0
  10. package/bin/__tests__/runtime-dir.test.js +158 -0
  11. package/bin/__tests__/runtime-schema.test.js +5 -3
  12. package/bin/__tests__/specs-v2-execution-closeout.test.js +28 -1
  13. package/bin/install.js +5 -5
  14. package/bin/lib/context.js +50 -3
  15. package/bin/lib/i18n.js +3 -30
  16. package/bin/phases/copy-payload.js +7 -0
  17. package/bin/phases/omp-runtime.js +140 -0
  18. package/bin/phases/root-config.js +3 -0
  19. package/bin/phases/select-platform.js +22 -1
  20. package/bin/phases/summary.js +0 -4
  21. package/package.json +1 -1
  22. package/src/claude/hooks/__tests__/semantic-review-authority.test.js +2 -0
  23. package/src/claude/hooks/__tests__/spec-gate.test.js +338 -0
  24. package/src/claude/hooks/__tests__/state.test.js +2 -0
  25. package/src/claude/hooks/agent.cjs +12 -7
  26. package/src/claude/hooks/completion-authority.cjs +10 -4
  27. package/src/claude/hooks/docs-sync.cjs +2 -1
  28. package/src/claude/hooks/inspect-block.cjs +27 -7
  29. package/src/claude/hooks/lib/config.cjs +5 -2
  30. package/src/claude/hooks/lib/counter.cjs +7 -5
  31. package/src/claude/hooks/lib/hook-payload.cjs +160 -0
  32. package/src/claude/hooks/lib/hook-state-dir.cjs +2 -2
  33. package/src/claude/hooks/lib/runtime-dir.cjs +49 -0
  34. package/src/claude/hooks/precompact.cjs +2 -1
  35. package/src/claude/hooks/privacy-block.cjs +4 -2
  36. package/src/claude/hooks/rules.cjs +4 -2
  37. package/src/claude/hooks/secret-output-guardrail.cjs +2 -1
  38. package/src/claude/hooks/semantic-review-authority.cjs +6 -4
  39. package/src/claude/hooks/session.cjs +10 -8
  40. package/src/claude/hooks/spec-gate.cjs +18 -8
  41. package/src/claude/hooks/spec-state.cjs +9 -5
  42. package/src/claude/hooks/state.cjs +6 -4
  43. package/src/claude/hooks/task-scaffold-guard.cjs +29 -8
  44. package/src/claude/hooks/usage.cjs +5 -4
  45. package/src/claude/migration-manifest.json +2 -0
  46. package/src/claude/rules/state-sync.md +7 -1
  47. package/src/claude/rules/workflow.md +2 -0
  48. package/src/claude/scripts/provenance.cjs +11 -4
  49. package/src/claude/scripts/spec-receipt.cjs +65 -4
  50. package/src/claude/skills/specs/SKILL.md +10 -11
  51. package/src/codex/hooks/spec-state.cjs +1 -1
  52. package/src/codex/rules/state-sync.md +7 -1
  53. package/src/omp/extensions/cafekit-bridge.mjs +233 -0
  54. package/src/omp/hooks/privacy-block.cjs +250 -0
  55. package/src/omp/runtime.json +33 -0
  56. package/bin/lib/rtk-run.js +0 -28
  57. package/bin/phases/setup-rtk.js +0 -159
@@ -0,0 +1,150 @@
1
+ 'use strict';
2
+
3
+ // omp discovers .claude/skills and .agents/skills on its own, so CafeKit ships it no
4
+ // skill payload. What omp lacks is the enforcement chain, so an omp install has to
5
+ // provision the hooks and the extension directory that carries it. These tests pin the
6
+ // install path only; gate behaviour belongs to the tasks that author the fork and bridge.
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 } = require(path.join(PACKAGE_ROOT, 'bin/lib/context'));
18
+
19
+ function inTempProject(run) {
20
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'cafekit-omp-runtime-'));
21
+ try {
22
+ const init = spawnSync('git', ['-C', root, 'init', '-q'], { encoding: 'utf8' });
23
+ assert.equal(init.status, 0, init.stderr);
24
+ return run(root);
25
+ } finally {
26
+ fs.rmSync(root, { recursive: true, force: true });
27
+ }
28
+ }
29
+
30
+ function install(root, platform, extra = []) {
31
+ return spawnSync(process.execPath, [INSTALLER, '--platform', platform, '--yes', ...extra],
32
+ { cwd: root, encoding: 'utf8', env: { ...process.env, PATH: '/usr/bin:/bin' } });
33
+ }
34
+
35
+ test('omp is a registered platform that ships no skill payload of its own', () => {
36
+ const omp = PLATFORMS.omp;
37
+ assert.ok(omp, 'omp must exist in the platform registry');
38
+ assert.equal(omp.folder, '.omp');
39
+ assert.equal(omp.capabilities.skills, false, 'omp reads .claude/skills itself; copying would duplicate');
40
+ assert.equal(omp.capabilities.agents, false);
41
+ });
42
+
43
+ test('an omp install creates hooks, an extensions directory, and platform metadata', () => {
44
+ inTempProject((root) => {
45
+ const result = install(root, 'omp');
46
+ assert.equal(result.status, 0, `${result.stdout}\n${result.stderr}`);
47
+
48
+ const hooks = fs.readdirSync(path.join(root, '.omp', 'hooks')).filter((f) => f.endsWith('.cjs'));
49
+ assert.ok(hooks.length > 0, 'the gate scripts must be installed');
50
+ for (const required of ['spec-gate.cjs', 'privacy-block.cjs', 'rules.cjs']) {
51
+ assert.ok(hooks.includes(required), `missing ${required}`);
52
+ }
53
+
54
+ assert.equal(fs.existsSync(path.join(root, '.omp', 'extensions')), true,
55
+ 'omp auto-loads this directory; a half-provisioned install must be visibly empty, not absent');
56
+
57
+ const metadata = JSON.parse(fs.readFileSync(path.join(root, '.omp', 'cafekit.json'), 'utf8'));
58
+ assert.equal(metadata.platform, 'omp');
59
+ assert.ok(metadata.version, 'the install must record a version');
60
+ });
61
+ });
62
+
63
+ test('a non-omp install creates no .omp directory', () => {
64
+ inTempProject((root) => {
65
+ const result = install(root, 'claude');
66
+ assert.equal(result.status, 0, `${result.stdout}\n${result.stderr}`);
67
+ assert.equal(fs.existsSync(path.join(root, '.omp')), false);
68
+ });
69
+ });
70
+
71
+ test('an omp install ignores its own auto-executed extension directory', () => {
72
+ inTempProject((root) => {
73
+ assert.equal(install(root, 'omp').status, 0);
74
+ const ignore = fs.readFileSync(path.join(root, '.gitignore'), 'utf8');
75
+ // A committed .omp/extensions/ would run arbitrary code on clone, before any gate
76
+ // can act, so the ignore rule is part of the install rather than user hygiene.
77
+ assert.match(ignore, /^\.omp\/$/m, '.omp/ must be ignored like .claude/ and .codex/');
78
+ });
79
+ });
80
+
81
+ test('reinstalling omp is idempotent and preserves a user-added extension', () => {
82
+ inTempProject((root) => {
83
+ assert.equal(install(root, 'omp').status, 0);
84
+ const mine = path.join(root, '.omp', 'extensions', 'my-own.mjs');
85
+ fs.writeFileSync(mine, 'export default function () {}\n');
86
+
87
+ assert.equal(install(root, 'omp').status, 0);
88
+ assert.equal(fs.existsSync(mine), true, 'a reinstall erased an extension the user added');
89
+ });
90
+ });
91
+
92
+ test('an omp install ships the scripts the Stop gate needs', () => {
93
+ inTempProject((root) => {
94
+ fs.mkdirSync(path.join(root, 'specs'), { recursive: true });
95
+ const result = install(root, 'omp');
96
+ assert.equal(result.status, 0, `${result.stdout}\n${result.stderr}`);
97
+
98
+ // Every Stop-time gate requires ../scripts/*.cjs. Without these files the
99
+ // installed gate answers "Completion gate unavailable" and the bridge turns that
100
+ // into a block on every session_stop — the defect this case exists to pin.
101
+ for (const script of ['workflow-policy.cjs', 'spec-receipt.cjs', 'spec-resolver.cjs', 'provenance.cjs']) {
102
+ assert.equal(fs.existsSync(path.join(root, '.omp', 'scripts', script)), true, `missing .omp/scripts/${script}`);
103
+ }
104
+
105
+ const gate = spawnSync(process.execPath, [path.join(root, '.omp', 'hooks', 'spec-gate.cjs')], {
106
+ cwd: root,
107
+ input: JSON.stringify({ session_id: 'gate-probe', cwd: root, hook_event_name: 'Stop', stop_hook_active: false }),
108
+ encoding: 'utf8',
109
+ });
110
+ assert.equal(gate.status, 0, gate.stderr);
111
+ assert.doesNotMatch(gate.stdout, /Completion gate unavailable/, 'the installed Stop gate must load its scripts');
112
+ });
113
+ });
114
+
115
+ test('an omp-only install injects rules', () => {
116
+ inTempProject((root) => {
117
+ const result = install(root, 'omp');
118
+ assert.equal(result.status, 0, `${result.stdout}\n${result.stderr}`);
119
+ assert.equal(fs.existsSync(path.join(root, '.claude')), false, 'this case is about a project with no .claude at all');
120
+
121
+ // The hooks read their configuration from the folder they live in, so omp needs its
122
+ // own runtime.json: no statusline (omp has none), usage off (that hook reads Claude
123
+ // Code's credential file), coding level 1 like Codex.
124
+ const runtime = JSON.parse(fs.readFileSync(path.join(root, '.omp', 'runtime.json'), 'utf8'));
125
+ assert.equal(runtime.codingLevel, 1);
126
+ assert.equal(runtime.usage.enabled, false);
127
+ assert.deepEqual(Object.keys(runtime).filter((k) => k.startsWith('statusline')), []);
128
+ assert.equal(runtime.$schema, './runtime.schema.json');
129
+ assert.equal(fs.existsSync(path.join(root, '.omp', 'runtime.schema.json')), true, 'the schema installs beside the file that references it');
130
+
131
+ // The Claude set arrives whole, the overlay on top, and nothing from the test tree.
132
+ const hooks = fs.readdirSync(path.join(root, '.omp', 'hooks'));
133
+ assert.equal(hooks.includes('__tests__'), false, 'the source tree holds tests that must never install');
134
+ for (const required of ['rules.cjs', 'spec-gate.cjs', 'session.cjs', 'lib/runtime-dir.cjs', 'lib/hook-payload.cjs']) {
135
+ assert.equal(fs.existsSync(path.join(root, '.omp', 'hooks', required)), true, `missing .omp/hooks/${required}`);
136
+ }
137
+ const privacy = fs.readFileSync(path.join(root, '.omp', 'hooks', 'privacy-block.cjs'), 'utf8');
138
+ assert.ok(privacy.includes("permissionDecision: 'deny'") && !privacy.includes("permissionDecision: 'ask'"),
139
+ 'the installed privacy hook must be the omp overlay, not the Claude file');
140
+
141
+ const rules = spawnSync(process.execPath, [path.join(root, '.omp', 'hooks', 'rules.cjs')], {
142
+ cwd: root,
143
+ input: JSON.stringify({ session_id: `omp-rules-${Date.now()}`, cwd: root, prompt: 'hello' }),
144
+ encoding: 'utf8',
145
+ env: { ...process.env, CLAUDE_PROJECT_DIR: '', PROJECT_ROOT: '' },
146
+ });
147
+ assert.equal(rules.status, 0, rules.stderr);
148
+ assert.match(rules.stdout, /## Rules/, 'without .omp/runtime.json the hook exits silently and omp never sees the rules');
149
+ });
150
+ });
@@ -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,
package/bin/lib/i18n.js CHANGED
@@ -62,16 +62,6 @@ const MESSAGES = {
62
62
  documentSkillsMetadataRecovery: 'Could not read {path}; preserving document skills to avoid destructive cleanup.',
63
63
  skillsSkipped: 'Skill dependencies skipped.\n Run later: npx @haposoft/cafekit --with-skills-deps',
64
64
 
65
- // rtk token-saver setup
66
- rtkConfirm: 'Install the rtk token-saver? (compacts git/grep/ls/build/test output for Claude Code Bash commands)',
67
- rtkInstalling: 'Installing rtk via {method}',
68
- rtkInstalled: 'rtk installed',
69
- rtkInstallFailed: 'Could not install rtk — skipping (non-fatal). Install manually later: https://github.com/rtk-ai/rtk',
70
- rtkInitFailed: 'rtk init -g failed: {reason} — hook not registered (non-fatal)',
71
- rtkNeedsJq: 'rtk hook needs jq to run. Install jq, then the hook activates automatically.',
72
- rtkFailed: 'rtk setup error: {reason} — skipped (non-fatal)',
73
- rtkSkipped: 'rtk token-saver skipped.\n Run later: npx @haposoft/cafekit --with-rtk',
74
- rtkSummary: 'rtk token-saver: hook registered for Claude Code Bash commands',
75
65
  venvCreating: 'Setting up Python environment',
76
66
  venvReady: 'Python environment ready',
77
67
  venvFailed: 'Could not create Python environment — check your Python 3 installation.',
@@ -130,6 +120,7 @@ const MESSAGES = {
130
120
 
131
121
  // Platform/language auto-skip when already installed
132
122
  installingFor: 'Installing for: {names}',
123
+ grokCompatNotice: 'Grok CLI reads this install through its Claude compatibility. Trust the project first: run `grok --trust` or `/hooks-trust`, or its project hooks are skipped.',
133
124
  langKept: 'Language: {lang} (saved — skipping prompt)',
134
125
  platformKept: 'Platform: {names} (saved — skipping prompt)',
135
126
 
@@ -186,16 +177,6 @@ const MESSAGES = {
186
177
  documentSkillsMetadataRecovery: '{path} を読み取れないため、破壊的な削除を避けて document skills を保持します。',
187
178
  skillsSkipped: 'スキルの依存関係をスキップしました。\n 後で実行: npx @haposoft/cafekit --with-skills-deps',
188
179
 
189
- // rtk token-saver setup
190
- rtkConfirm: 'rtk トークンセーバーをインストールしますか?(Claude Code の Bash コマンドの git/grep/ls/build/test 出力を圧縮します)',
191
- rtkInstalling: '{method} で rtk をインストール中',
192
- rtkInstalled: 'rtk をインストールしました',
193
- rtkInstallFailed: 'rtk をインストールできませんでした — スキップします(致命的ではありません)。後で手動でインストール: https://github.com/rtk-ai/rtk',
194
- rtkInitFailed: 'rtk init -g が失敗しました: {reason} — フックは未登録です(致命的ではありません)',
195
- rtkNeedsJq: 'rtk フックの実行には jq が必要です。jq をインストールするとフックが自動的に有効になります。',
196
- rtkFailed: 'rtk セットアップエラー: {reason} — スキップしました(致命的ではありません)',
197
- rtkSkipped: 'rtk トークンセーバーをスキップしました。\n 後で実行: npx @haposoft/cafekit --with-rtk',
198
- rtkSummary: 'rtk トークンセーバー: Claude Code の Bash コマンド用にフックを登録しました',
199
180
  venvCreating: 'Python 環境を準備しています',
200
181
  venvReady: 'Python 環境の準備ができました',
201
182
  venvFailed: 'Python 環境を作成できませんでした — Python 3 のインストールを確認してください。',
@@ -251,6 +232,7 @@ const MESSAGES = {
251
232
 
252
233
  // Platform/language auto-skip when already installed
253
234
  installingFor: 'インストール先: {names}',
235
+ grokCompatNotice: 'Grok CLI はこのインストールを Claude 互換として読み込みます。先にプロジェクトを信頼してください: `grok --trust` または `/hooks-trust`。信頼しないとプロジェクトフックはスキップされます。',
254
236
  langKept: '言語: {lang} (保存済み — スキップ)',
255
237
  platformKept: 'プラットフォーム: {names} (保存済み — スキップ)',
256
238
 
@@ -307,16 +289,6 @@ const MESSAGES = {
307
289
  documentSkillsMetadataRecovery: 'Không đọc được {path}; giữ nguyên skill tài liệu để tránh dọn nhầm dữ liệu.',
308
290
  skillsSkipped: 'Đã bỏ qua dependencies skill.\n Cài sau bằng lệnh: npx @haposoft/cafekit --with-skills-deps',
309
291
 
310
- // rtk token-saver setup
311
- rtkConfirm: 'Cài rtk token-saver? (rút gọn output git/grep/ls/build/test cho các lệnh Bash của Claude Code)',
312
- rtkInstalling: 'Đang cài rtk qua {method}',
313
- rtkInstalled: 'Đã cài rtk',
314
- rtkInstallFailed: 'Không thể cài rtk — bỏ qua (không nghiêm trọng). Cài thủ công sau: https://github.com/rtk-ai/rtk',
315
- rtkInitFailed: 'rtk init -g thất bại: {reason} — hook chưa được đăng ký (không nghiêm trọng)',
316
- rtkNeedsJq: 'Hook của rtk cần jq để chạy. Cài jq thì hook sẽ tự kích hoạt.',
317
- rtkFailed: 'Lỗi cài rtk: {reason} — đã bỏ qua (không nghiêm trọng)',
318
- rtkSkipped: 'Đã bỏ qua rtk token-saver.\n Cài sau bằng lệnh: npx @haposoft/cafekit --with-rtk',
319
- rtkSummary: 'rtk token-saver: đã đăng ký hook cho các lệnh Bash của Claude Code',
320
292
  venvCreating: 'Đang thiết lập môi trường Python',
321
293
  venvReady: 'Môi trường Python đã sẵn sàng',
322
294
  venvFailed: 'Không thể tạo môi trường Python — hãy kiểm tra cài đặt Python 3.',
@@ -372,6 +344,7 @@ const MESSAGES = {
372
344
 
373
345
  // Platform/language auto-skip when already installed
374
346
  installingFor: 'Cài cho: {names}',
347
+ grokCompatNotice: 'Grok CLI đọc bản cài này qua lớp tương thích Claude. Hãy trust project trước: chạy `grok --trust` hoặc `/hooks-trust`, nếu không project hooks sẽ bị bỏ qua.',
375
348
  langKept: 'Ngôn ngữ: {lang} (đã lưu — bỏ qua chọn lại)',
376
349
  platformKept: 'Nền tảng: {names} (đã lưu — bỏ qua chọn lại)',
377
350
 
@@ -189,7 +189,14 @@ function copyPlatformFiles(ctx, platformKey) {
189
189
  } else {
190
190
  report(ctx, 'missing', `agents: ${platform.agentsDir}`);
191
191
  }
192
+ }
192
193
 
194
+ // ── Scripts ─────────────────────────────────────────────
195
+ // Gated on its own capability. This block used to sit inside the agents guard,
196
+ // so a platform with agents: false and scripts: true (omp) never received
197
+ // .omp/scripts/, and every Stop-time gate that requires ../scripts/*.cjs answered
198
+ // "Completion gate unavailable" on each session_stop.
199
+ if (hasPlatformCapability(platformKey, 'scripts')) {
193
200
  const scriptsSourceDir = path.join(SRC, 'claude/scripts');
194
201
  if (fs.existsSync(scriptsSourceDir)) {
195
202
  const scriptsDest = getRuntimeSupportTargetDir(platformKey, 'scripts');