@haposoft/cafekit 0.18.1 → 0.18.3
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 +12 -1
- package/bin/__tests__/adv-skill.test.js +371 -0
- package/bin/__tests__/agent-authority.test.js +38 -0
- package/bin/__tests__/agent-instructions.test.js +54 -0
- package/bin/__tests__/claude-plugin-build.test.js +151 -0
- package/bin/__tests__/claude-settings.test.js +123 -0
- package/bin/__tests__/codex-ask-guard.test.js +123 -0
- package/bin/__tests__/codex-ask-projection.test.js +51 -0
- package/bin/__tests__/codex-hooks-ownership.test.js +80 -1
- package/bin/__tests__/codex-hooks.test.js +812 -12
- package/bin/__tests__/codex-implicit-policy.test.js +160 -0
- package/bin/__tests__/codex-native-hook-boundaries.test.js +136 -0
- package/bin/__tests__/codex-native.test.js +198 -58
- package/bin/__tests__/codex-plugin-build.test.js +230 -0
- package/bin/__tests__/codex-plugin-context.test.js +135 -0
- package/bin/__tests__/codex-plugin-hooks.test.js +220 -0
- package/bin/__tests__/codex-projection-parity.test.js +314 -0
- package/bin/__tests__/codex-quiet-hooks.test.js +170 -0
- package/bin/__tests__/codex-research-projection.test.js +70 -0
- package/bin/__tests__/codex-runtime-layout.test.js +81 -0
- package/bin/__tests__/codex-setup-agents.test.js +216 -0
- package/bin/__tests__/codex-specs-projection.test.js +76 -0
- package/bin/__tests__/develop-contract.test.js +1 -1
- package/bin/__tests__/docs-sync-on-demand.test.js +25 -20
- package/bin/__tests__/inspect-paths.test.js +183 -0
- package/bin/__tests__/installer-safety.test.js +335 -0
- package/bin/__tests__/legacy-hooks-retired.test.js +81 -21
- package/bin/__tests__/migrate-apply.test.js +373 -0
- package/bin/__tests__/migrate-cli.test.js +152 -0
- package/bin/__tests__/migrate-merged.test.js +269 -0
- package/bin/__tests__/migrate-plan.test.js +271 -0
- package/bin/__tests__/omp-bridge.test.js +147 -17
- package/bin/__tests__/omp-hooks.test.js +35 -5
- package/bin/__tests__/orca-session.test.js +7 -0
- package/bin/__tests__/plugin-safety-keys.test.js +121 -0
- package/bin/__tests__/privacy-paths.test.js +501 -0
- package/bin/__tests__/usage-hook-retired.test.js +29 -9
- package/bin/__tests__/vibe-run.test.js +215 -0
- package/bin/__tests__/vibe-skill.test.js +549 -0
- package/bin/install.js +124 -1
- package/bin/lib/backup.js +43 -24
- package/bin/lib/codex-install.js +141 -12
- package/bin/lib/context.js +6 -1
- package/bin/lib/lock.js +98 -18
- package/bin/lib/managed-writer.js +16 -10
- package/bin/lib/migrate-apply.js +202 -0
- package/bin/lib/migrate-merged.js +342 -0
- package/bin/lib/migrate-plan.js +419 -0
- package/bin/lib/plugin-inventory.js +33 -0
- package/bin/phases/claude-runtime.js +93 -25
- package/bin/phases/claude-settings.js +22 -1
- package/bin/phases/codex-hooks.js +134 -33
- package/bin/phases/codex-runtime.js +110 -85
- package/bin/phases/copy-payload.js +50 -4
- package/bin/phases/omp-runtime.js +4 -26
- package/bin/phases/root-config.js +5 -2
- package/package.json +1 -1
- package/src/claude/CLAUDE.md +5 -1
- package/src/claude/agents/advisor.md +55 -0
- package/src/claude/agents/debugger.md +2 -3
- package/src/claude/agents/deployer.md +2 -1
- package/src/claude/agents/docs-keeper.md +12 -0
- package/src/claude/agents/inspector.md +1 -1
- package/src/claude/agents/project-manager.md +2 -2
- package/src/claude/agents/researcher.md +0 -1
- package/src/claude/agents/spec-maker.md +4 -1
- package/src/claude/gitignore +3 -0
- package/src/claude/hooks/__tests__/installed-root.test.js +111 -0
- package/src/claude/hooks/__tests__/plugin-context.test.js +96 -0
- package/src/claude/hooks/__tests__/plugin-options.test.js +141 -0
- package/src/claude/hooks/__tests__/plugin-state-isolation.test.js +125 -0
- package/src/claude/hooks/__tests__/precompact.test.js +213 -11
- package/src/claude/hooks/__tests__/privacy-block.test.js +60 -9
- package/src/claude/hooks/__tests__/session-writeenv.test.js +25 -0
- package/src/claude/hooks/__tests__/setup-statusline.test.js +179 -0
- package/src/claude/hooks/__tests__/spec-gate.test.js +227 -11
- package/src/claude/hooks/__tests__/spec-state-touched.test.js +204 -8
- package/src/claude/hooks/__tests__/state.test.js +207 -7
- package/src/claude/hooks/__tests__/update-check.test.js +88 -0
- package/src/claude/hooks/agent.cjs +11 -7
- package/src/claude/hooks/inspect-block.cjs +20 -66
- package/src/claude/hooks/lib/hook-state-dir.cjs +19 -1
- package/src/claude/hooks/lib/inspect-paths.cjs +304 -0
- package/src/claude/hooks/lib/parser.cjs +1 -1
- package/src/claude/hooks/lib/plugin-options.cjs +47 -0
- package/src/claude/hooks/lib/privacy-command-analysis.cjs +317 -24
- package/src/claude/hooks/lib/privacy-paths.cjs +673 -0
- package/src/claude/hooks/lib/project-root.cjs +15 -0
- package/src/claude/hooks/lib/rules-reservation.cjs +36 -0
- package/src/claude/hooks/lib/spec-touch.cjs +80 -0
- package/src/claude/hooks/lib/update-check.cjs +77 -0
- package/src/claude/hooks/plugin-context.cjs +80 -0
- package/src/claude/hooks/precompact.cjs +8 -15
- package/src/claude/hooks/privacy-block.cjs +25 -206
- package/src/claude/hooks/rules.cjs +10 -29
- package/src/claude/hooks/session.cjs +37 -117
- package/src/claude/hooks/spec-gate.cjs +9 -26
- package/src/claude/hooks/spec-state.cjs +44 -59
- package/src/claude/hooks/state.cjs +101 -52
- package/src/claude/migration-manifest.json +14 -2
- package/src/claude/plugin/commands/setup-statusline.md +12 -0
- package/src/claude/plugin/core.md +71 -0
- package/src/claude/rules/hook-protocols.md +1 -1
- package/src/claude/rules/manage-docs.md +1 -2
- package/src/claude/rules/review-audit-self-decision.md +1 -1
- package/src/claude/rules/skill-workflow-routing.md +1 -1
- package/src/claude/rules/state-sync.md +14 -0
- package/src/claude/rules/workflow.md +1 -1
- package/src/claude/runtime.json +0 -3
- package/src/claude/scripts/generate-skill-catalog.cjs +7 -2
- package/src/claude/scripts/provenance.cjs +54 -16
- package/src/claude/scripts/setup-statusline.cjs +88 -0
- package/src/claude/settings/settings.json +1 -1
- package/src/claude/skills/adv/SKILL.md +139 -0
- package/src/claude/skills/adv/references/optional-modes.md +41 -0
- package/src/claude/skills/adv/references/ultra-mode.md +29 -0
- package/src/claude/skills/ai-multimodal/scripts/check_setup.py +8 -68
- package/src/claude/skills/ai-multimodal/scripts/gemini_batch_process.py +6 -28
- package/src/claude/skills/brainstorm/SKILL.md +1 -1
- package/src/claude/skills/code-review/SKILL.md +4 -1
- package/src/claude/skills/code-review/references/pr-fix-reply.md +42 -0
- package/src/claude/skills/develop/SKILL.md +5 -3
- package/src/claude/skills/develop/references/quality-gate.md +1 -1
- package/src/claude/skills/fix/SKILL.md +2 -2
- package/src/claude/skills/git/SKILL.md +5 -3
- package/src/claude/skills/git/references/merge-pr.md +125 -0
- package/src/claude/skills/git/references/worktree-blueprint.md +1 -1
- package/src/claude/skills/specs/SKILL.md +2 -2
- package/src/claude/skills/specs/references/review.md +1 -1
- package/src/claude/skills/specs/references/templates.md +2 -0
- package/src/claude/skills/vibe/SKILL.md +69 -0
- package/src/claude/skills/vibe/references/pipeline-skill-integration.md +34 -0
- package/src/claude/skills/vibe/references/report-templates.md +84 -0
- package/src/claude/skills/vibe/references/subagent-orchestration.md +23 -0
- package/src/codex/AGENTS.md +9 -1
- package/src/codex/hooks/ask-guard.cjs +54 -0
- package/src/codex/hooks/inspect-block.cjs +15 -8
- package/src/codex/hooks/lib/hook-context.cjs +118 -4
- package/src/codex/hooks/lib/inspect-paths.cjs +1 -199
- package/src/codex/hooks/lib/plugin-options.cjs +2 -0
- package/src/codex/hooks/lib/privacy-paths.cjs +1 -0
- package/src/codex/hooks/lib/privacy-state.cjs +116 -17
- package/src/codex/hooks/lib/rules-reservation.cjs +38 -0
- package/src/codex/hooks/lib/secret-keywords.cjs +1 -0
- package/src/codex/hooks/lib/spec-session-touch.cjs +100 -0
- package/src/codex/hooks/lib/spec-touch.cjs +3 -0
- package/src/codex/hooks/lib/state-store.cjs +5 -4
- package/src/codex/hooks/lib/update-check.cjs +1 -0
- package/src/codex/hooks/precompact.cjs +40 -0
- package/src/codex/hooks/privacy-approval.cjs +28 -17
- package/src/codex/hooks/privacy-block.cjs +11 -216
- package/src/codex/hooks/rules.cjs +14 -34
- package/src/codex/hooks/secret-output-guardrail.cjs +1 -1
- package/src/codex/hooks/session.cjs +59 -11
- package/src/codex/hooks/spec-gate.cjs +21 -6
- package/src/codex/hooks/spec-state.cjs +32 -6
- package/src/codex/hooks/state.cjs +25 -4
- package/src/codex/hooks.json +39 -7
- package/src/codex/plugin/core.md +76 -0
- package/src/codex/plugin/hooks/plugin-context.cjs +63 -0
- package/src/codex/plugin/skills/setup-agents/SKILL.md +17 -0
- package/src/codex/rules/hook-protocols.md +13 -6
- package/src/codex/scripts/setup-agents.cjs +124 -0
- package/src/common/AGENTS.md +1 -1
- package/src/omp/extensions/{cafekit-bridge.mjs → cafekit-bridge.js} +85 -9
- package/src/omp/hooks/privacy-block.cjs +26 -207
- package/src/codex/rules/state-sync.md +0 -60
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const assert = require('node:assert/strict');
|
|
4
|
+
const fs = require('node:fs');
|
|
5
|
+
const os = require('node:os');
|
|
6
|
+
const path = require('node:path');
|
|
7
|
+
const { test } = require('node:test');
|
|
8
|
+
const { mergeClaudeSettings } = require('../phases/claude-settings');
|
|
9
|
+
const manifest = require('../../src/claude/migration-manifest.json');
|
|
10
|
+
const template = require('../../src/claude/settings/settings.json');
|
|
11
|
+
|
|
12
|
+
const MATCHER = 'Read|Write|Edit|MultiEdit|NotebookEdit|Bash|Glob|Grep|mcp__.*';
|
|
13
|
+
const OLD = 'Read|Write|Edit|MultiEdit|Bash|Glob|Grep';
|
|
14
|
+
const templateGroup = template.hooks.PreToolUse[0];
|
|
15
|
+
const privacy = templateGroup.hooks.find(hook => hook.command.includes('/privacy-block.cjs'));
|
|
16
|
+
const inspect = templateGroup.hooks.find(hook => hook.command.includes('/inspect-block.cjs'));
|
|
17
|
+
const group = (matcher, hooks = [privacy, inspect]) => ({ matcher, hooks });
|
|
18
|
+
|
|
19
|
+
function withSettings(groups, run, dryRun = false) {
|
|
20
|
+
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'cafekit-claude-settings-'));
|
|
21
|
+
const previousCwd = process.cwd();
|
|
22
|
+
try {
|
|
23
|
+
process.chdir(root);
|
|
24
|
+
fs.mkdirSync('.claude');
|
|
25
|
+
const settingsPath = path.join(root, '.claude/settings.json');
|
|
26
|
+
if (groups) fs.writeFileSync(settingsPath, JSON.stringify({ hooks: { PreToolUse: groups } }, null, 2));
|
|
27
|
+
const before = fs.existsSync(settingsPath) ? fs.readFileSync(settingsPath, 'utf8') : null;
|
|
28
|
+
const messages = [];
|
|
29
|
+
const ctx = {
|
|
30
|
+
manifest, dryRun, options: {}, results: { updated: 0, errors: 0 },
|
|
31
|
+
ui: { detail: message => messages.push(message), warn: message => messages.push(message) },
|
|
32
|
+
};
|
|
33
|
+
mergeClaudeSettings(ctx, 'claude');
|
|
34
|
+
const after = fs.readFileSync(settingsPath, 'utf8');
|
|
35
|
+
run({ before, after, groups: JSON.parse(after).hooks.PreToolUse, messages });
|
|
36
|
+
} finally {
|
|
37
|
+
process.chdir(previousCwd);
|
|
38
|
+
fs.rmSync(root, { recursive: true, force: true });
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function assertNoDuplicateCommands(groups) {
|
|
43
|
+
const commands = groups.flatMap(entry => entry.hooks.map(hook => hook.command));
|
|
44
|
+
assert.equal(commands.length, new Set(commands).size);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
test('(a) claude-settings updates the former matcher for managed privacy and inspect', () => {
|
|
48
|
+
withSettings([group(OLD)], ({ groups, messages }) => {
|
|
49
|
+
assert.equal(groups[0].matcher, MATCHER);
|
|
50
|
+
assert.deepEqual(groups[0].hooks, [privacy, inspect]);
|
|
51
|
+
assert.ok(messages.some(message => message.includes('matcher updated')));
|
|
52
|
+
assertNoDuplicateCommands(groups);
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test('(b) claude-settings updates the former matcher without Grep', () => {
|
|
57
|
+
withSettings([group('Read|Write|Edit|MultiEdit|Bash|Glob')], ({ groups, messages }) => {
|
|
58
|
+
assert.equal(groups[0].matcher, MATCHER);
|
|
59
|
+
assert.ok(messages.some(message => message.includes('matcher updated')));
|
|
60
|
+
assertNoDuplicateCommands(groups);
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test('(c) claude-settings preserves a mixed entry with a user command', () => {
|
|
65
|
+
const mixed = group(OLD, [privacy, inspect, { type: 'command', command: 'my-linter' }]);
|
|
66
|
+
withSettings([mixed], ({ groups, messages }) => {
|
|
67
|
+
assert.deepEqual(groups[0], mixed);
|
|
68
|
+
assert.equal(messages.some(message => message.includes('matcher updated')), false);
|
|
69
|
+
assertNoDuplicateCommands(groups);
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test('(d) claude-settings preserves a custom matcher for managed commands', () => {
|
|
74
|
+
const custom = group('Read|Bash');
|
|
75
|
+
withSettings([custom], ({ groups, messages }) => {
|
|
76
|
+
assert.deepEqual(groups[0], custom);
|
|
77
|
+
assert.equal(messages.some(message => message.includes('matcher updated')), false);
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test('(e) claude-settings installs the template matcher without duplicate entries', () => {
|
|
82
|
+
withSettings(null, ({ groups }) => {
|
|
83
|
+
assert.deepEqual(groups, template.hooks.PreToolUse);
|
|
84
|
+
assertNoDuplicateCommands(groups);
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test('(f) claude-settings updates a split privacy entry without duplicate commands', () => {
|
|
89
|
+
withSettings([group(OLD, [privacy]), group(MATCHER, [inspect])], ({ groups, messages }) => {
|
|
90
|
+
assert.equal(groups.length, 2);
|
|
91
|
+
assert.ok(groups.every(entry => entry.matcher === MATCHER));
|
|
92
|
+
assert.deepEqual(groups.map(entry => entry.hooks), [[privacy], [inspect]]);
|
|
93
|
+
assert.ok(messages.some(message => message.includes('matcher updated')));
|
|
94
|
+
assertNoDuplicateCommands(groups);
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test('(g) claude-settings prunes the retired inspector before updating privacy', () => {
|
|
99
|
+
const retired = { ...inspect, command: inspect.command.replace('inspect-block.cjs', 'inspector-block.cjs') };
|
|
100
|
+
withSettings([group(OLD, [retired, privacy])], ({ groups, messages }) => {
|
|
101
|
+
assert.equal(groups[0].matcher, MATCHER);
|
|
102
|
+
assert.deepEqual(groups[0].hooks, [privacy, inspect]);
|
|
103
|
+
assert.ok(messages.some(message => message.includes('matcher updated')));
|
|
104
|
+
assertNoDuplicateCommands(groups);
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test('(h) claude-settings dry-run reports the matcher update without writing', () => {
|
|
109
|
+
withSettings([group(OLD)], ({ before, after, messages }) => {
|
|
110
|
+
assert.equal(after, before);
|
|
111
|
+
assert.ok(messages.some(message => message.includes('[dry-run] Settings: hook PreToolUse matcher updated')));
|
|
112
|
+
}, true);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test('(i) claude-settings preserves a null handler without updating the matcher', () => {
|
|
116
|
+
const entry = group(OLD, [null]);
|
|
117
|
+
assert.doesNotThrow(() => {
|
|
118
|
+
withSettings([entry], ({ groups, messages }) => {
|
|
119
|
+
assert.deepEqual(groups[0], entry);
|
|
120
|
+
assert.equal(messages.some(message => message.includes('matcher updated')), false);
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
});
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const assert = require('node:assert/strict');
|
|
4
|
+
const crypto = require('node:crypto');
|
|
5
|
+
const fs = require('node:fs');
|
|
6
|
+
const os = require('node:os');
|
|
7
|
+
const path = require('node:path');
|
|
8
|
+
const { spawnSync } = require('node:child_process');
|
|
9
|
+
const { test } = require('node:test');
|
|
10
|
+
|
|
11
|
+
const sha256 = (value) => crypto.createHash('sha256').update(value).digest('hex');
|
|
12
|
+
|
|
13
|
+
test('installed Codex ask guard follows the observed hook payloads', async (t) => {
|
|
14
|
+
const temp = fs.mkdtempSync(path.join(os.tmpdir(), 'cafekit-ask-guard-test-'));
|
|
15
|
+
t.after(() => fs.rmSync(temp, { recursive: true, force: true }));
|
|
16
|
+
const project = path.join(temp, 'project');
|
|
17
|
+
fs.mkdirSync(project);
|
|
18
|
+
const install = spawnSync(process.execPath, [path.join(__dirname, '../install.js'),
|
|
19
|
+
'--platform', 'codex', '--yes'], { cwd: project, encoding: 'utf8' });
|
|
20
|
+
assert.equal(install.status, 0, install.stderr + install.stdout);
|
|
21
|
+
const script = path.join(project, '.codex/hooks/ask-guard.cjs');
|
|
22
|
+
const config = JSON.parse(fs.readFileSync(path.join(project, '.codex/hooks.json'), 'utf8'));
|
|
23
|
+
|
|
24
|
+
function fixture() {
|
|
25
|
+
const stateRoot = fs.mkdtempSync(path.join(temp, 'state-'));
|
|
26
|
+
const session = crypto.randomUUID();
|
|
27
|
+
const sessionDir = (id = session) => path.join(stateRoot, sha256(id));
|
|
28
|
+
function call(event, extra = {}, id = session, override = stateRoot) {
|
|
29
|
+
return spawnSync(process.execPath, [script], {
|
|
30
|
+
cwd: project, encoding: 'utf8',
|
|
31
|
+
env: { ...process.env, CAFEKIT_ASK_GUARD_DIR: override },
|
|
32
|
+
input: JSON.stringify({ hook_event_name: event, cwd: project, session_id: id, ...extra })
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
const prompt = (text, id = session) => call('UserPromptSubmit', { prompt: text }, id);
|
|
36
|
+
const tool = (name = 'apply_patch', id = session) => call('PreToolUse', {
|
|
37
|
+
tool_name: name, tool_input: { command: '*** Begin Patch\n*** Update File: src/greet.js\n*** End Patch' }
|
|
38
|
+
}, id);
|
|
39
|
+
return { call, prompt, tool, session, stateRoot, sessionDir };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
await t.test('real installer registers both events and a separate apply_patch group', () => {
|
|
43
|
+
assert.ok(fs.existsSync(script));
|
|
44
|
+
const isGuard = (handler) => /ask-guard\.cjs/.test(handler.command || '');
|
|
45
|
+
assert.ok(config.hooks.UserPromptSubmit.some((group) => group.hooks.some(isGuard)));
|
|
46
|
+
const group = config.hooks.PreToolUse.find((entry) => entry.hooks.some(isGuard));
|
|
47
|
+
assert.ok(group);
|
|
48
|
+
assert.equal(group.matcher, 'apply_patch');
|
|
49
|
+
assert.equal(group.hooks.length, 1);
|
|
50
|
+
const windowsPath = Buffer.from(group.hooks[0].commandWindows.split(' ').at(-1), 'base64url').toString('utf8');
|
|
51
|
+
assert.equal(fs.realpathSync(windowsPath), fs.realpathSync(script));
|
|
52
|
+
});
|
|
53
|
+
await t.test('explicit ask denies only its session and logs exactly one private row per denial', () => {
|
|
54
|
+
const f = fixture();
|
|
55
|
+
assert.equal(f.prompt(' $cf-ask "Sửa giúp luôn."\n').status, 0);
|
|
56
|
+
assert.equal(f.tool('apply_patch', crypto.randomUUID()).status, 0);
|
|
57
|
+
for (let count = 1; count <= 2; count++) {
|
|
58
|
+
const result = f.tool();
|
|
59
|
+
assert.equal(result.status, 2, result.stderr);
|
|
60
|
+
assert.match(result.stderr, /\$cf-fix/);
|
|
61
|
+
assert.doesNotMatch(result.stderr, /greet|Sửa giúp|Begin Patch/);
|
|
62
|
+
const rows = fs.readFileSync(path.join(f.sessionDir(), 'denials.jsonl'), 'utf8').trim().split('\n').map(JSON.parse);
|
|
63
|
+
assert.equal(rows.length, count);
|
|
64
|
+
for (const row of rows) {
|
|
65
|
+
assert.deepEqual(Object.keys(row).sort(), ['session_id_sha256', 'timestamp', 'tool_name']);
|
|
66
|
+
assert.equal(row.session_id_sha256, sha256(f.session));
|
|
67
|
+
assert.equal(row.tool_name, 'apply_patch');
|
|
68
|
+
assert.ok(Number.isFinite(Date.parse(row.timestamp)));
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
assert.equal(fs.existsSync(path.join(project, '.codex/hooks/.logs')), false);
|
|
72
|
+
});
|
|
73
|
+
await t.test('next non-ask prompt disables a previously persisted ask flag', () => {
|
|
74
|
+
const f = fixture();
|
|
75
|
+
assert.equal(f.prompt('$cf-ask reason').status, 0);
|
|
76
|
+
assert.equal(f.tool().status, 2);
|
|
77
|
+
assert.equal(f.prompt('Please fix it now').status, 0);
|
|
78
|
+
assert.equal(f.tool().status, 0);
|
|
79
|
+
});
|
|
80
|
+
await t.test('/cf:ask activates the guard', () => {
|
|
81
|
+
const f = fixture();
|
|
82
|
+
assert.equal(f.prompt('/cf:ask reason').status, 0);
|
|
83
|
+
assert.equal(f.tool().status, 2);
|
|
84
|
+
});
|
|
85
|
+
for (const prompt of ['Đừng dùng $cf-ask để sửa', '```\n$cf-ask reason\n```', '$cf-asker reason', '$CF-ASK reason']) {
|
|
86
|
+
await t.test(`non-invocation stays inactive: ${JSON.stringify(prompt)}`, () => {
|
|
87
|
+
const f = fixture();
|
|
88
|
+
assert.equal(f.prompt(prompt).status, 0);
|
|
89
|
+
assert.equal(f.tool().status, 0);
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
await t.test('Bash and Read are allowed during ask', () => {
|
|
93
|
+
const f = fixture();
|
|
94
|
+
assert.equal(f.prompt('$cf-ask').status, 0);
|
|
95
|
+
for (const name of ['Bash', 'Read', 'exec']) assert.equal(f.tool(name).status, 0);
|
|
96
|
+
assert.equal(f.tool().status, 2);
|
|
97
|
+
});
|
|
98
|
+
await t.test('path-like session IDs stay hashed within the state directory', () => {
|
|
99
|
+
const f = fixture();
|
|
100
|
+
assert.equal(f.prompt('$cf-ask reason', '../x').status, 0);
|
|
101
|
+
assert.equal(f.tool('apply_patch', '../x').status, 2);
|
|
102
|
+
assert.deepEqual(fs.readdirSync(f.stateRoot), [sha256('../x')]);
|
|
103
|
+
assert.equal(fs.existsSync(path.join(temp, 'x')), false);
|
|
104
|
+
});
|
|
105
|
+
await t.test('missing session IDs fail open and record a crash', () => {
|
|
106
|
+
const f = fixture();
|
|
107
|
+
assert.equal(f.call('UserPromptSubmit', { prompt: '$cf-ask' }, null).status, 0);
|
|
108
|
+
assert.equal(f.tool('apply_patch', null).status, 0);
|
|
109
|
+
const rows = fs.readFileSync(path.join(project, '.codex/hooks/.logs/hook-log.jsonl'), 'utf8').trim().split('\n').map(JSON.parse);
|
|
110
|
+
assert.ok(rows.some((row) => row.hook === 'ask-guard' && row.status === 'crash'));
|
|
111
|
+
assert.deepEqual(fs.readdirSync(f.stateRoot), []);
|
|
112
|
+
});
|
|
113
|
+
await t.test('state I/O errors fail open and record a crash without leaking input', () => {
|
|
114
|
+
const f = fixture();
|
|
115
|
+
const invalidRoot = path.join(temp, 'not-a-directory');
|
|
116
|
+
fs.writeFileSync(invalidRoot, 'file');
|
|
117
|
+
const result = f.call('PreToolUse', { tool_name: 'apply_patch', tool_input: { command: 'PRIVATE-INPUT' } }, f.session, invalidRoot);
|
|
118
|
+
assert.equal(result.status, 0);
|
|
119
|
+
const log = fs.readFileSync(path.join(project, '.codex/hooks/.logs/hook-log.jsonl'), 'utf8');
|
|
120
|
+
assert.doesNotMatch(log, /PRIVATE-INPUT/);
|
|
121
|
+
assert.equal(JSON.parse(log.trim().split('\n').at(-1)).hook, 'ask-guard');
|
|
122
|
+
});
|
|
123
|
+
});
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const assert = require('node:assert/strict');
|
|
4
|
+
const fs = require('node:fs');
|
|
5
|
+
const path = require('node:path');
|
|
6
|
+
const { test } = require('node:test');
|
|
7
|
+
const { normalizeCodexBody } = require('../lib/codex-install');
|
|
8
|
+
const { splitFrontmatter } = require('../lib/codex-frontmatter');
|
|
9
|
+
|
|
10
|
+
const ROOT = path.resolve(__dirname, '../..');
|
|
11
|
+
const skillPath = name => path.join(ROOT, 'src/claude/skills', name, 'SKILL.md');
|
|
12
|
+
const readSkill = name => fs.readFileSync(skillPath(name), 'utf8');
|
|
13
|
+
|
|
14
|
+
test('codex ask projection carries the answer-only instruction after frontmatter', () => {
|
|
15
|
+
const source = readSkill('ask');
|
|
16
|
+
const projected = normalizeCodexBody(source, skillPath('ask'));
|
|
17
|
+
assert.match(projected, /^---\r?\n/);
|
|
18
|
+
const { frontmatter, body } = splitFrontmatter(projected);
|
|
19
|
+
assert.equal(frontmatter.name, 'cf-ask');
|
|
20
|
+
assert.match(projected.slice(0, projected.length - body.length), /^name:\s*cf-ask$/m);
|
|
21
|
+
const boundary = 'A request to fix or change something is still answered without editing any file; point the user to $cf-fix.';
|
|
22
|
+
const originalDescription = splitFrontmatter(normalizeCodexBody(source)).frontmatter.description;
|
|
23
|
+
// A JSON-quoted scalar is valid YAML; verify the serialized value as well as the split header.
|
|
24
|
+
const description = JSON.parse(projected.match(/^description: (.+)$/m)[1]);
|
|
25
|
+
assert.equal(description, `${originalDescription} ${boundary}`);
|
|
26
|
+
assert.equal(frontmatter.description, description);
|
|
27
|
+
assert.ok(description.length <= 1024);
|
|
28
|
+
assert.match(body, /^\s*Rule 1 \(Codex\): never edit, create, or delete files in this skill,/);
|
|
29
|
+
const note = body.trimStart().split('\n\n')[0];
|
|
30
|
+
assert.match(note, /even when the user says fix it or sửa giúp luôn/);
|
|
31
|
+
assert.match(note, /Do not run commands that write files/);
|
|
32
|
+
assert.match(note, /Answer the cause with evidence and tell the user to run \$cf-fix\./);
|
|
33
|
+
assert.doesNotMatch(`${description}\n${note}`, /\b(?:Write|Edit|Read|Bash|WebSearch|WebFetch|AskUserQuestion)\b/);
|
|
34
|
+
assert.match(body, /\n\n# Ask Skill\n/);
|
|
35
|
+
|
|
36
|
+
for (const name of ['fix', 'develop']) {
|
|
37
|
+
const otherSource = readSkill(name);
|
|
38
|
+
const other = normalizeCodexBody(otherSource, skillPath(name));
|
|
39
|
+
assert.doesNotMatch(other, /Rule 1 \(Codex\):/);
|
|
40
|
+
assert.equal(splitFrontmatter(other).frontmatter.description,
|
|
41
|
+
splitFrontmatter(normalizeCodexBody(otherSource)).frontmatter.description);
|
|
42
|
+
assert.ok(!other.includes(boundary));
|
|
43
|
+
}
|
|
44
|
+
for (const sourcePath of ['', 'skills/ask/templates/question.md', 'other-skills/ask/SKILL.md']) {
|
|
45
|
+
const prompt = normalizeCodexBody(source, sourcePath);
|
|
46
|
+
assert.doesNotMatch(prompt, /Rule 1 \(Codex\):/);
|
|
47
|
+
assert.ok(!prompt.includes(boundary));
|
|
48
|
+
assert.equal(splitFrontmatter(prompt).frontmatter.description, originalDescription);
|
|
49
|
+
}
|
|
50
|
+
assert.match(normalizeCodexBody(source, 'C:\\project\\skills\\ask\\SKILL.md'), /Rule 1 \(Codex\):/);
|
|
51
|
+
});
|
|
@@ -17,7 +17,7 @@ const PACKAGE_ROOT = path.join(__dirname, '../..');
|
|
|
17
17
|
const INSTALLER = path.join(PACKAGE_ROOT, 'bin/install.js');
|
|
18
18
|
const TEMPLATE = path.join(PACKAGE_ROOT, 'src/codex/hooks.json');
|
|
19
19
|
const MANIFEST = require(path.join(PACKAGE_ROOT, 'src/claude/migration-manifest.json'));
|
|
20
|
-
const { hookScript } = require(path.join(PACKAGE_ROOT, 'bin/phases/codex-hooks.js'));
|
|
20
|
+
const { hookScript, repairLegacyCodexLaunchers } = require(path.join(PACKAGE_ROOT, 'bin/phases/codex-hooks.js'));
|
|
21
21
|
|
|
22
22
|
const USER_STOP_COMMAND = 'node "$(git rev-parse --show-toplevel)/scripts/my-own-stop.js"';
|
|
23
23
|
const USER_PRETOOL_COMMAND = 'echo user-owned-pretooluse';
|
|
@@ -79,6 +79,17 @@ function addUserHooks(root) {
|
|
|
79
79
|
fs.writeFileSync(hooksPath(root), `${JSON.stringify(config, null, 2)}\n`);
|
|
80
80
|
}
|
|
81
81
|
|
|
82
|
+
test('a fresh install registers the precompact capture hook', () => {
|
|
83
|
+
inTempProject((root) => {
|
|
84
|
+
assert.ok(managedScripts().PreCompact?.includes('precompact.cjs'));
|
|
85
|
+
const result = install(root);
|
|
86
|
+
assert.equal(result.status, 0, result.stderr);
|
|
87
|
+
const scripts = (readHooks(root).hooks.PreCompact || []).flatMap(group => group.hooks || []).map(handler => hookScript(handler.command));
|
|
88
|
+
assert.ok(scripts.includes('precompact.cjs'));
|
|
89
|
+
assert.ok(fs.existsSync(path.join(root, '.codex/hooks/precompact.cjs')));
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
|
|
82
93
|
test('a fresh install registers every CafeKit hook under its own event', () => {
|
|
83
94
|
inTempProject((root) => {
|
|
84
95
|
const result = install(root);
|
|
@@ -237,3 +248,71 @@ test('hooks.json stays outside the ownership manifest, like settings.json', () =
|
|
|
237
248
|
);
|
|
238
249
|
});
|
|
239
250
|
});
|
|
251
|
+
|
|
252
|
+
test('parity regression: foreign hooks with managed basenames stay exact and cannot suppress registration', () => {
|
|
253
|
+
inTempProject((root) => {
|
|
254
|
+
fs.mkdirSync(path.join(root, '.codex'));
|
|
255
|
+
const foreign = { type: 'command', command: `node '${root}/tools/hooks/session.cjs'`, timeout: 17 };
|
|
256
|
+
const custom = { type: 'command', command: `node '${root}/.codex/hooks/session.cjs' --custom`, timeout: 19 };
|
|
257
|
+
fs.writeFileSync(hooksPath(root), JSON.stringify({ hooks: { SessionStart: [{ matcher: '*', hooks: [foreign, custom] }] } }));
|
|
258
|
+
const result = install(root);
|
|
259
|
+
assert.equal(result.status, 0, `${result.stdout}\n${result.stderr}`);
|
|
260
|
+
const handlers = readHooks(root).hooks.SessionStart.flatMap((group) => group.hooks);
|
|
261
|
+
assert.ok(handlers.some((entry) => JSON.stringify(entry) === JSON.stringify(foreign)), 'foreign hook was rewritten');
|
|
262
|
+
assert.ok(handlers.some((entry) => JSON.stringify(entry) === JSON.stringify(custom)), 'custom hook was rewritten');
|
|
263
|
+
assert.equal(handlers.filter((entry) => entry.command?.startsWith('PATH=') && hookScript(entry.command) === 'session.cjs').length, 1);
|
|
264
|
+
});
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
test('parity regression: repairing launchers preserves unknown and foreign command shapes', () => {
|
|
268
|
+
inTempProject((root) => {
|
|
269
|
+
const commands = [
|
|
270
|
+
`node '${root}/tools/hooks/check.cjs'`,
|
|
271
|
+
`node '${root}/tools/hooks/session.cjs'`,
|
|
272
|
+
`node '${root}/.codex/hooks/user-guard.cjs'`,
|
|
273
|
+
`node '${root}/.codex/hooks/session.cjs' --custom`,
|
|
274
|
+
];
|
|
275
|
+
const config = { hooks: { Stop: [{ hooks: commands.map((command) => ({ type: 'command', command })) }] } };
|
|
276
|
+
assert.deepEqual(repairLegacyCodexLaunchers(config, root).config, config);
|
|
277
|
+
const windowsPrefix = 'node -e "process.argv[1]=Buffer.from(process.argv[1],\'base64url\').toString(\'utf8\');require(\'module\').runMain()" ';
|
|
278
|
+
const foreignWindows = windowsPrefix + Buffer.from(`${root}/tools/hooks/session.cjs`).toString('base64url');
|
|
279
|
+
const mixed = { type: 'command', command: 'node ".codex/hooks/session.cjs"', commandWindows: foreignWindows, timeout: 7 };
|
|
280
|
+
const next = repairLegacyCodexLaunchers({ hooks: { Stop: [{ hooks: [mixed] }] } }, root).config.hooks.Stop[0].hooks[0];
|
|
281
|
+
assert.equal(next.commandWindows, foreignWindows, 'foreign platform field was rebound');
|
|
282
|
+
assert.equal(next.timeout, 7);
|
|
283
|
+
assert.match(next.command, /^PATH=/);
|
|
284
|
+
for (const commandWindows of [windowsPrefix + 'bad-base64!', foreignWindows + ' --custom']) {
|
|
285
|
+
const unknown = { hooks: { Stop: [{ hooks: [{ type: 'command', commandWindows }] }] } };
|
|
286
|
+
assert.deepEqual(repairLegacyCodexLaunchers(unknown, root).config, unknown);
|
|
287
|
+
}
|
|
288
|
+
});
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
test('parity regression: upgrade removes only managed docs startup handlers', () => {
|
|
292
|
+
inTempProject((root) => {
|
|
293
|
+
assert.equal(install(root).status, 0);
|
|
294
|
+
const config = readHooks(root);
|
|
295
|
+
const managed = { type: 'command', command: `node '${root}/.codex/hooks/docs-sync.cjs'`, timeout: 23 };
|
|
296
|
+
const foreign = { type: 'command', command: `node '${root}/tools/hooks/docs-sync.cjs'` };
|
|
297
|
+
const custom = { type: 'command', command: `node '${root}/.codex/hooks/docs-sync.cjs' --custom` };
|
|
298
|
+
const hybrid = { ...managed, commandWindows: 'echo user-owned-windows-docs' };
|
|
299
|
+
config.hooks.SessionStart.push({ matcher: '*', hooks: [managed, foreign, custom, hybrid] });
|
|
300
|
+
config.hooks.Stop.push({ hooks: [{ ...managed }] });
|
|
301
|
+
fs.writeFileSync(hooksPath(root), JSON.stringify(config));
|
|
302
|
+
const before = fs.readFileSync(hooksPath(root), 'utf8');
|
|
303
|
+
const preview = install(root, ['--dry-run']);
|
|
304
|
+
assert.equal(preview.status, 0, preview.stderr);
|
|
305
|
+
assert.equal(fs.readFileSync(hooksPath(root), 'utf8'), before);
|
|
306
|
+
const result = install(root);
|
|
307
|
+
assert.equal(result.status, 0, `${result.stdout}\n${result.stderr}`);
|
|
308
|
+
const upgraded = readHooks(root);
|
|
309
|
+
const startup = upgraded.hooks.SessionStart.flatMap((group) => group.hooks);
|
|
310
|
+
assert.ok(!startup.some((entry) => entry.command === managed.command), 'managed docs startup hook survived');
|
|
311
|
+
assert.ok(!startup.some((entry) => entry.command?.startsWith('PATH=') && hookScript(entry.command) === 'docs-sync.cjs' && !entry.commandWindows), 'managed docs startup launcher survived');
|
|
312
|
+
assert.ok(startup.some((entry) => entry.command === foreign.command));
|
|
313
|
+
assert.ok(startup.some((entry) => entry.command === custom.command));
|
|
314
|
+
assert.ok(startup.some((entry) => entry.commandWindows === hybrid.commandWindows), 'migration removed a foreign platform field');
|
|
315
|
+
assert.ok(upgraded.hooks.Stop.flatMap((group) => group.hooks).some((entry) => hookScript(entry.command) === 'docs-sync.cjs'));
|
|
316
|
+
assert.ok(fs.existsSync(path.join(root, '.codex/hooks/docs-sync.cjs')), 'on-demand docs hook was deleted');
|
|
317
|
+
});
|
|
318
|
+
});
|