@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.
- package/README.md +5 -3
- package/bin/__tests__/codex-hooks.test.js +125 -0
- package/bin/__tests__/codex-native.test.js +95 -0
- package/bin/__tests__/develop-contract.test.js +43 -1
- package/bin/__tests__/grok-envelope.test.js +271 -0
- package/bin/__tests__/grok-platform.test.js +133 -0
- package/bin/__tests__/hook-payload.test.js +194 -0
- package/bin/__tests__/omp-bridge.test.js +168 -0
- package/bin/__tests__/omp-hooks.test.js +178 -0
- package/bin/__tests__/omp-runtime.test.js +150 -0
- package/bin/__tests__/orca-session.test.js +225 -0
- package/bin/__tests__/orca-skill.test.js +210 -0
- package/bin/__tests__/package-inventory.test.js +1 -0
- package/bin/__tests__/runtime-dir.test.js +158 -0
- package/bin/__tests__/runtime-schema.test.js +5 -3
- package/bin/__tests__/specs-v2-execution-closeout.test.js +28 -1
- package/bin/install.js +5 -5
- package/bin/lib/context.js +50 -3
- package/bin/lib/i18n.js +3 -30
- package/bin/phases/codex-hooks.js +90 -17
- package/bin/phases/copy-payload.js +7 -0
- package/bin/phases/omp-runtime.js +140 -0
- package/bin/phases/root-config.js +3 -0
- package/bin/phases/select-platform.js +22 -1
- package/bin/phases/summary.js +0 -4
- package/package.json +1 -1
- package/src/claude/hooks/__tests__/semantic-review-authority.test.js +2 -0
- package/src/claude/hooks/__tests__/spec-gate.test.js +338 -0
- package/src/claude/hooks/__tests__/state.test.js +2 -0
- package/src/claude/hooks/agent.cjs +12 -7
- package/src/claude/hooks/completion-authority.cjs +10 -4
- package/src/claude/hooks/docs-sync.cjs +2 -1
- package/src/claude/hooks/inspect-block.cjs +27 -7
- package/src/claude/hooks/lib/config.cjs +5 -2
- package/src/claude/hooks/lib/counter.cjs +7 -5
- package/src/claude/hooks/lib/hook-payload.cjs +160 -0
- package/src/claude/hooks/lib/hook-state-dir.cjs +2 -2
- package/src/claude/hooks/lib/runtime-dir.cjs +49 -0
- package/src/claude/hooks/precompact.cjs +2 -1
- package/src/claude/hooks/privacy-block.cjs +4 -2
- package/src/claude/hooks/rules.cjs +4 -2
- package/src/claude/hooks/secret-output-guardrail.cjs +2 -1
- package/src/claude/hooks/semantic-review-authority.cjs +6 -4
- package/src/claude/hooks/session.cjs +16 -8
- package/src/claude/hooks/spec-gate.cjs +18 -8
- package/src/claude/hooks/spec-state.cjs +9 -5
- package/src/claude/hooks/state.cjs +6 -4
- package/src/claude/hooks/task-scaffold-guard.cjs +29 -8
- package/src/claude/hooks/usage.cjs +5 -4
- package/src/claude/migration-manifest.json +3 -0
- package/src/claude/rules/skill-domain-routing.md +1 -0
- package/src/claude/rules/state-sync.md +7 -1
- package/src/claude/rules/workflow.md +2 -0
- package/src/claude/scripts/provenance.cjs +11 -4
- package/src/claude/scripts/spec-receipt.cjs +65 -4
- package/src/claude/skills/orca/SKILL.md +51 -0
- package/src/claude/skills/specs/SKILL.md +10 -11
- package/src/codex/hooks/session.cjs +6 -1
- package/src/codex/hooks/spec-state.cjs +1 -1
- package/src/codex/hooks.json +18 -18
- package/src/codex/rules/state-sync.md +7 -1
- package/src/omp/extensions/cafekit-bridge.mjs +233 -0
- package/src/omp/hooks/privacy-block.cjs +250 -0
- package/src/omp/runtime.json +33 -0
- package/bin/lib/rtk-run.js +0 -28
- 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
|
|
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('
|
|
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
|
package/bin/lib/context.js
CHANGED
|
@@ -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,
|