@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.
- package/README.md +1 -1
- package/bin/__tests__/codex-hooks.test.js +125 -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__/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/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 +10 -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 +2 -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/specs/SKILL.md +10 -11
- package/src/codex/hooks/spec-state.cjs +1 -1
- 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,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
|
|
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,
|
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');
|