@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
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
|
|
4
4
|
const fs = require('fs');
|
|
5
5
|
const path = require('path');
|
|
6
|
+
const { runtimeDirName, runtimeDir, runtimePath } = require('./lib/runtime-dir.cjs');
|
|
6
7
|
let STATE;
|
|
7
8
|
let evaluateCloseout;
|
|
8
9
|
|
|
@@ -27,7 +28,7 @@ function projectRoot(payload = {}) {
|
|
|
27
28
|
}
|
|
28
29
|
|
|
29
30
|
const installedRoot = path.resolve(__dirname, '..', '..');
|
|
30
|
-
const installedHook =
|
|
31
|
+
const installedHook = runtimePath(installedRoot, 'hooks', path.basename(__filename));
|
|
31
32
|
if (fs.existsSync(installedHook)) return installedRoot;
|
|
32
33
|
|
|
33
34
|
const sourceFixture = typeof process.env.PROJECT_ROOT === 'string'
|
|
@@ -50,7 +51,7 @@ function sessionId(payload) {
|
|
|
50
51
|
|
|
51
52
|
function readRuntime(root) {
|
|
52
53
|
try {
|
|
53
|
-
const file =
|
|
54
|
+
const file = runtimePath(root, 'runtime.json');
|
|
54
55
|
return fs.existsSync(file) ? JSON.parse(fs.readFileSync(file, 'utf8')) : {};
|
|
55
56
|
} catch { return {}; }
|
|
56
57
|
}
|
|
@@ -135,9 +136,14 @@ function stop(payload) {
|
|
|
135
136
|
|
|
136
137
|
let mode = process.argv[2] || null;
|
|
137
138
|
try {
|
|
138
|
-
const
|
|
139
|
+
const raw = readPayload();
|
|
140
|
+
// Read the loop guard before normalization: the catch below answers a block, so a
|
|
141
|
+
// reader failure must not be able to reach it. Both spellings are accepted here.
|
|
142
|
+
const hostLoop = raw.stop_hook_active === true || raw.stopHookActive === true;
|
|
143
|
+
const { normalizeHookPayload } = require('./lib/hook-payload.cjs');
|
|
144
|
+
const payload = normalizeHookPayload(raw);
|
|
139
145
|
mode = mode || (payload.hook_event_name === 'UserPromptSubmit' ? '--approve' : '--stop');
|
|
140
|
-
if (mode === '--stop' &&
|
|
146
|
+
if (mode === '--stop' && hostLoop) {
|
|
141
147
|
// Host loop prevention is always silent, including when dependencies are unavailable.
|
|
142
148
|
} else {
|
|
143
149
|
const dependencyError = loadAuthority();
|
|
@@ -19,7 +19,8 @@ try {
|
|
|
19
19
|
|
|
20
20
|
// Đọc stdin theo chuẩn hook
|
|
21
21
|
const stdin = fs.readFileSync(0, 'utf8').trim();
|
|
22
|
-
const
|
|
22
|
+
const { normalizeHookPayload } = require('./lib/hook-payload.cjs');
|
|
23
|
+
const payload = stdin ? normalizeHookPayload(JSON.parse(stdin)) : {};
|
|
23
24
|
const cwd = payload.cwd || process.cwd();
|
|
24
25
|
const config = loadConfig({ cwd, includeProject: false, includeAssertions: false, includeLocale: false });
|
|
25
26
|
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
* Blocks access to heavy directories that would flood the LLM context window.
|
|
9
9
|
* Also warns on overly-broad glob patterns.
|
|
10
10
|
*
|
|
11
|
-
* Disable: set "inspect": { "enabled": false } in
|
|
11
|
+
* Disable: set "inspect": { "enabled": false } in <runtime>/runtime.json
|
|
12
12
|
*
|
|
13
13
|
* Exit: 0 = allow, 2 = block
|
|
14
14
|
*/
|
|
@@ -60,19 +60,41 @@ try {
|
|
|
60
60
|
return out.filter(Boolean);
|
|
61
61
|
}
|
|
62
62
|
|
|
63
|
+
const { runtimeDirName, runtimeDir, runtimePath } = require('./lib/runtime-dir.cjs');
|
|
63
64
|
function readRuntime(cwd) {
|
|
64
65
|
try {
|
|
65
|
-
const p =
|
|
66
|
+
const p = runtimePath(cwd, 'runtime.json');
|
|
66
67
|
return fs.existsSync(p) ? JSON.parse(fs.readFileSync(p, 'utf8')) : {};
|
|
67
68
|
} catch { return {}; }
|
|
68
69
|
}
|
|
69
70
|
|
|
70
71
|
// ── Main ──────────────────────────────────────────────────────────────────
|
|
71
72
|
|
|
73
|
+
/**
|
|
74
|
+
* Deny the call with a reason every host can read.
|
|
75
|
+
*
|
|
76
|
+
* The reason is the JSON `permissionDecisionReason`, which Claude Code reads natively and
|
|
77
|
+
* grok honours "regardless of exit code"; the same text also goes to stderr, because on
|
|
78
|
+
* an exit-2 denial that is the channel Claude Code feeds back, and grok reads only the
|
|
79
|
+
* first stderr line. Exit 2 stays so a host that reads neither still blocks.
|
|
80
|
+
*/
|
|
81
|
+
function denyWithReason(reason) {
|
|
82
|
+
process.stdout.write(JSON.stringify({
|
|
83
|
+
hookSpecificOutput: {
|
|
84
|
+
hookEventName: 'PreToolUse',
|
|
85
|
+
permissionDecision: 'deny',
|
|
86
|
+
permissionDecisionReason: reason
|
|
87
|
+
}
|
|
88
|
+
}) + '\n');
|
|
89
|
+
process.stderr.write(reason + '\n');
|
|
90
|
+
process.exit(2);
|
|
91
|
+
}
|
|
92
|
+
|
|
72
93
|
const stdin = fs.readFileSync(0, 'utf8').trim();
|
|
73
94
|
if (!stdin) process.exit(0);
|
|
74
95
|
|
|
75
|
-
const
|
|
96
|
+
const { normalizeHookPayload } = require('./lib/hook-payload.cjs');
|
|
97
|
+
const data = normalizeHookPayload(JSON.parse(stdin));
|
|
76
98
|
const toolName = data.tool_name || '';
|
|
77
99
|
const toolInput = data.tool_input || {};
|
|
78
100
|
const cwd = data.cwd || process.cwd();
|
|
@@ -88,12 +110,11 @@ try {
|
|
|
88
110
|
|
|
89
111
|
// Broad glob check
|
|
90
112
|
if (toolInput.pattern && isBroadGlob(toolInput.pattern)) {
|
|
91
|
-
|
|
113
|
+
denyWithReason(
|
|
92
114
|
`SCOPE LIMIT EXCEEDED: Glob pattern is excessively broad\n` +
|
|
93
115
|
`Requested Pattern: ${toolInput.pattern}\n\n` +
|
|
94
116
|
`Please narrow your scope (e.g., src/**/*.ts rather than **/*.ts).`
|
|
95
117
|
);
|
|
96
|
-
process.exit(2);
|
|
97
118
|
}
|
|
98
119
|
|
|
99
120
|
// Blocked directory check
|
|
@@ -101,12 +122,11 @@ try {
|
|
|
101
122
|
for (const p of paths) {
|
|
102
123
|
if (isBlockedPath(p)) {
|
|
103
124
|
const blocked = p.replace(/\\/g, '/').split('/').find(s => BLOCKED_DIRS.includes(s));
|
|
104
|
-
|
|
125
|
+
denyWithReason(
|
|
105
126
|
`SCOPE LIMIT EXCEEDED: Directory "${blocked}/" is explicitly forbidden\n` +
|
|
106
127
|
`Requested Path: ${p}\n` +
|
|
107
128
|
`Restricted zones: ${BLOCKED_DIRS.join(', ')}`
|
|
108
129
|
);
|
|
109
|
-
process.exit(2);
|
|
110
130
|
}
|
|
111
131
|
}
|
|
112
132
|
|
|
@@ -9,7 +9,10 @@ const fs = require('fs');
|
|
|
9
9
|
const path = require('path');
|
|
10
10
|
const os = require('os');
|
|
11
11
|
|
|
12
|
-
const
|
|
12
|
+
const { runtimeDirName } = require('./runtime-dir.cjs');
|
|
13
|
+
|
|
14
|
+
// Derived from this library's own location so an install under .omp/ or .codex/ reads its own file.
|
|
15
|
+
const RUNTIME_CONFIG_PATH = `${runtimeDirName()}/runtime.json`;
|
|
13
16
|
|
|
14
17
|
const CONFIG_PATH = RUNTIME_CONFIG_PATH;
|
|
15
18
|
|
|
@@ -461,7 +464,7 @@ function sanitizeConfig(config, projectRoot) {
|
|
|
461
464
|
*
|
|
462
465
|
* Resolution order (each layer overrides the previous):
|
|
463
466
|
* 1. DEFAULT_CONFIG (hardcoded defaults)
|
|
464
|
-
* 2. Runtime config (
|
|
467
|
+
* 2. Runtime config (<runtime-dir>/runtime.json, derived by lib/runtime-dir.cjs) - installed CafeKit runtime config
|
|
465
468
|
*
|
|
466
469
|
* @param {Object} options - Options for config loading
|
|
467
470
|
* @param {boolean} options.includeProject - Include project section (default: true)
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
const fs = require('fs');
|
|
7
7
|
const path = require('path');
|
|
8
8
|
const os = require('os');
|
|
9
|
+
const { runtimeDir } = require('./runtime-dir.cjs');
|
|
9
10
|
|
|
10
11
|
function getMcpServerNames(filePath) {
|
|
11
12
|
if (!fs.existsSync(filePath)) return new Set();
|
|
@@ -83,16 +84,17 @@ function countConfigs(cwd) {
|
|
|
83
84
|
|
|
84
85
|
// Project scope
|
|
85
86
|
if (cwd) {
|
|
87
|
+
const projectRuntime = runtimeDir(cwd);
|
|
86
88
|
if (fs.existsSync(path.join(cwd, 'CLAUDE.md'))) claudeMdCount++;
|
|
87
89
|
if (fs.existsSync(path.join(cwd, 'CLAUDE.local.md'))) claudeMdCount++;
|
|
88
|
-
if (fs.existsSync(path.join(
|
|
89
|
-
if (fs.existsSync(path.join(
|
|
90
|
-
rulesCount += countRulesInDir(path.join(
|
|
90
|
+
if (fs.existsSync(path.join(projectRuntime, 'CLAUDE.md'))) claudeMdCount++;
|
|
91
|
+
if (fs.existsSync(path.join(projectRuntime, 'CLAUDE.local.md'))) claudeMdCount++;
|
|
92
|
+
rulesCount += countRulesInDir(path.join(projectRuntime, 'rules'));
|
|
91
93
|
mcpCount += countMcpServersInFile(path.join(cwd, '.mcp.json'));
|
|
92
|
-
const projectSettings = path.join(
|
|
94
|
+
const projectSettings = path.join(projectRuntime, 'settings.json');
|
|
93
95
|
mcpCount += countMcpServersInFile(projectSettings);
|
|
94
96
|
hooksCount += countHooksInFile(projectSettings);
|
|
95
|
-
const localSettings = path.join(
|
|
97
|
+
const localSettings = path.join(projectRuntime, 'settings.local.json');
|
|
96
98
|
mcpCount += countMcpServersInFile(localSettings);
|
|
97
99
|
hooksCount += countHooksInFile(localSettings);
|
|
98
100
|
}
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Copyright (c) 2026 Haposoft. MIT License.
|
|
3
|
+
*
|
|
4
|
+
* Hook payload normalization.
|
|
5
|
+
*
|
|
6
|
+
* The gate hooks were written against Claude Code's stdin envelope: snake_case keys and
|
|
7
|
+
* capitalised tool names. Two other hosts run the very same scripts with a different
|
|
8
|
+
* spelling. Grok CLI loads `.claude/settings.json` through its Claude-compatibility layer
|
|
9
|
+
* but sends camelCase keys (`toolName`, `stopHookActive`) and its own tool names
|
|
10
|
+
* (`run_terminal_command`, `read_file`, `search_replace`). Oh My Pi's bridge sends
|
|
11
|
+
* Claude-shaped keys with lowercase tool names (`bash`, `read`).
|
|
12
|
+
*
|
|
13
|
+
* A hook that reads `data.tool_name` on a grok payload gets `undefined`, extracts no
|
|
14
|
+
* paths, and allows the call: the gate looks installed and is open. This module is the
|
|
15
|
+
* one place that reconciles those spellings, so every hook keeps a single code path.
|
|
16
|
+
*
|
|
17
|
+
* Two rules earn their keep:
|
|
18
|
+
*
|
|
19
|
+
* 1. The tool name is aliased by VALUE, under any key. omp already sends `tool_name`,
|
|
20
|
+
* so a key-only translation would silently skip its lowercase names.
|
|
21
|
+
* 2. Every name that can reach a `Bash` matcher must map to `Bash`. Grok expands
|
|
22
|
+
* matchers by alias, so a shell call can arrive under a name the hook does not
|
|
23
|
+
* recognise; an unmapped name skips the command scan and a secret-bearing command
|
|
24
|
+
* is never examined.
|
|
25
|
+
*
|
|
26
|
+
* The reader is total: it never throws. Callers include two fail-closed hooks whose final
|
|
27
|
+
* catch emits a block, so an exception here would block the Stop gate on Claude too.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
'use strict';
|
|
31
|
+
|
|
32
|
+
/** Grok's event values are snake_case; the hooks compare against Claude's PascalCase. */
|
|
33
|
+
const EVENT_NAMES = Object.freeze({
|
|
34
|
+
pre_tool_use: 'PreToolUse',
|
|
35
|
+
post_tool_use: 'PostToolUse',
|
|
36
|
+
post_tool_use_failure: 'PostToolUseFailure',
|
|
37
|
+
permission_denied: 'PermissionDenied',
|
|
38
|
+
user_prompt_submit: 'UserPromptSubmit',
|
|
39
|
+
session_start: 'SessionStart',
|
|
40
|
+
session_end: 'SessionEnd',
|
|
41
|
+
stop: 'Stop',
|
|
42
|
+
stop_failure: 'StopFailure',
|
|
43
|
+
stop_cancelled: 'StopCancelled',
|
|
44
|
+
subagent_start: 'SubagentStart',
|
|
45
|
+
subagent_stop: 'SubagentStop',
|
|
46
|
+
pre_compact: 'PreCompact',
|
|
47
|
+
post_compact: 'PostCompact',
|
|
48
|
+
notification: 'Notification',
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Foreign tool names to the Claude names the rules were authored against.
|
|
53
|
+
*
|
|
54
|
+
* Grok's registry and matcher aliases come first, then omp's lowercase registry. Where
|
|
55
|
+
* both hosts use a spelling (`write`, `grep`) they agree on the destination. `write` is
|
|
56
|
+
* grok's file-creating tool and maps to `Write`, while `search_replace` edits an existing
|
|
57
|
+
* file and maps to `Edit`; conflating them would make the scaffold guard reject ordinary
|
|
58
|
+
* edits. `delete_file` maps to `Write` so a delete is gated like any other file write.
|
|
59
|
+
*/
|
|
60
|
+
const TOOL_ALIASES = Object.freeze({
|
|
61
|
+
// grok — every spelling that reaches a Bash matcher
|
|
62
|
+
run_terminal_command: 'Bash',
|
|
63
|
+
run_terminal_cmd: 'Bash',
|
|
64
|
+
PowerShell: 'Bash',
|
|
65
|
+
// grok — file and search tools
|
|
66
|
+
read_file: 'Read',
|
|
67
|
+
hashline_read: 'Read',
|
|
68
|
+
search_replace: 'Edit',
|
|
69
|
+
hashline_edit: 'Edit',
|
|
70
|
+
write: 'Write',
|
|
71
|
+
delete_file: 'Write',
|
|
72
|
+
grep: 'Grep',
|
|
73
|
+
grep_search: 'Grep',
|
|
74
|
+
hashline_grep: 'Grep',
|
|
75
|
+
list_dir: 'Glob',
|
|
76
|
+
web_search: 'WebSearch',
|
|
77
|
+
web_fetch: 'WebFetch',
|
|
78
|
+
spawn_subagent: 'Task',
|
|
79
|
+
todo_write: 'TodoWrite',
|
|
80
|
+
// omp — lowercase registry
|
|
81
|
+
bash: 'Bash',
|
|
82
|
+
read: 'Read',
|
|
83
|
+
edit: 'Edit',
|
|
84
|
+
glob: 'Glob',
|
|
85
|
+
find: 'Glob',
|
|
86
|
+
ls: 'Glob',
|
|
87
|
+
webfetch: 'WebFetch',
|
|
88
|
+
websearch: 'WebSearch',
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Camel-cased foreign key to the snake_case key the hooks read.
|
|
93
|
+
*
|
|
94
|
+
* `transcriptPath` is deliberately absent: grok stores sessions in its own format, and
|
|
95
|
+
* mapping it would make `state.cjs` stream a file it cannot parse. The last three pairs
|
|
96
|
+
* cover fields whose grok spelling is unverified, so both spellings are accepted.
|
|
97
|
+
*/
|
|
98
|
+
const KEY_ALIASES = Object.freeze({
|
|
99
|
+
hookEventName: 'hook_event_name',
|
|
100
|
+
sessionId: 'session_id',
|
|
101
|
+
stopHookActive: 'stop_hook_active',
|
|
102
|
+
lastAssistantMessage: 'last_assistant_message',
|
|
103
|
+
toolName: 'tool_name',
|
|
104
|
+
toolInput: 'tool_input',
|
|
105
|
+
toolUseId: 'tool_use_id',
|
|
106
|
+
toolResult: 'tool_response',
|
|
107
|
+
subagentType: 'agent_type',
|
|
108
|
+
subagentId: 'agent_id',
|
|
109
|
+
permissionMode: 'permission_mode',
|
|
110
|
+
workspaceRoot: 'workspace_root',
|
|
111
|
+
userPrompt: 'prompt',
|
|
112
|
+
startSource: 'source',
|
|
113
|
+
compactTrigger: 'trigger',
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
const owns = (object, key) => Object.prototype.hasOwnProperty.call(object, key);
|
|
117
|
+
|
|
118
|
+
/** The Claude name for a foreign tool, or the input unchanged when unknown. */
|
|
119
|
+
function normalizeToolName(name) {
|
|
120
|
+
if (typeof name !== 'string' || name === '') return name;
|
|
121
|
+
return owns(TOOL_ALIASES, name) ? TOOL_ALIASES[name] : name;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** The Claude name for an event value, or the input unchanged (already PascalCase). */
|
|
125
|
+
function normalizeEventName(value) {
|
|
126
|
+
if (typeof value !== 'string' || value === '') return value;
|
|
127
|
+
return owns(EVENT_NAMES, value) ? EVENT_NAMES[value] : value;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* A hook payload in the shape the gate scripts read, whatever host sent it.
|
|
132
|
+
*
|
|
133
|
+
* Anything that is not a plain object is returned as-is rather than coerced, so a caller
|
|
134
|
+
* that already guards on shape keeps its own behaviour. The input is never mutated.
|
|
135
|
+
*/
|
|
136
|
+
function normalizeHookPayload(payload) {
|
|
137
|
+
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) return payload;
|
|
138
|
+
|
|
139
|
+
const out = { ...payload };
|
|
140
|
+
|
|
141
|
+
// A snake_case key already present wins: the host that sent it meant it.
|
|
142
|
+
for (const [foreign, native] of Object.entries(KEY_ALIASES)) {
|
|
143
|
+
if (owns(out, foreign) && !owns(out, native)) out[native] = out[foreign];
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
if (owns(out, 'hook_event_name')) out.hook_event_name = normalizeEventName(out.hook_event_name);
|
|
147
|
+
// By value, under whichever key carried it — omp sends `tool_name` with a lowercase value.
|
|
148
|
+
if (owns(out, 'tool_name')) out.tool_name = normalizeToolName(out.tool_name);
|
|
149
|
+
|
|
150
|
+
// grok's `read_file` carries its argument as `path`; the hooks read `file_path`.
|
|
151
|
+
const input = out.tool_input;
|
|
152
|
+
if (input && typeof input === 'object' && !Array.isArray(input)
|
|
153
|
+
&& owns(input, 'path') && !owns(input, 'file_path')) {
|
|
154
|
+
out.tool_input = { ...input, file_path: input.path };
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
return out;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
module.exports = { normalizeHookPayload, normalizeToolName, TOOL_ALIASES, KEY_ALIASES, EVENT_NAMES };
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* gate/tollgate caches.
|
|
6
6
|
*
|
|
7
7
|
* Hooks run from two places. In an installed project they live in
|
|
8
|
-
*
|
|
8
|
+
* `<runtime-dir>/hooks/`, whose `.logs/` is gitignored, so writing beside the hook is
|
|
9
9
|
* correct. During this package's tests they run straight from
|
|
10
10
|
* `packages/spec/src/claude/hooks/`, where the same write lands inside the
|
|
11
11
|
* source tree as an untracked directory. That changes the worktree digest the
|
|
@@ -32,7 +32,7 @@ function isSourceTree(dir) {
|
|
|
32
32
|
/** Absolute directory for hook-generated state; create it before writing. */
|
|
33
33
|
function hookStateDir() {
|
|
34
34
|
return isSourceTree(HOOKS_DIR)
|
|
35
|
-
? path.join(os.tmpdir(), 'cafekit-hook-logs', '
|
|
35
|
+
? path.join(os.tmpdir(), 'cafekit-hook-logs', require('./runtime-dir.cjs').runtimeDirName().slice(1))
|
|
36
36
|
: path.join(HOOKS_DIR, '.logs');
|
|
37
37
|
}
|
|
38
38
|
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Copyright (c) 2026 Haposoft. MIT License.
|
|
3
|
+
*
|
|
4
|
+
* Runtime-directory derivation for the gate hooks.
|
|
5
|
+
*
|
|
6
|
+
* The hooks used to hardcode `.claude` in every project-runtime path, so a copy installed
|
|
7
|
+
* under `.omp/hooks/` or `.codex/hooks/` read another platform's config, never matched
|
|
8
|
+
* installed-root detection, and wrote session state into a directory that did not exist.
|
|
9
|
+
* This module ships in `hooks/lib/`, so the runtime root is always two levels up from
|
|
10
|
+
* here and its basename is the answer: `.claude`, `.codex`, or `.omp`.
|
|
11
|
+
*
|
|
12
|
+
* In the source tree the same files live at `packages/spec/src/<platform>/hooks/lib/`, so
|
|
13
|
+
* the basename is the undotted platform name; it is mapped to its dotted form so tests
|
|
14
|
+
* that run hooks from source see the same directory an install would. A dotted basename
|
|
15
|
+
* always wins over the source-tree check, because an install can legitimately sit under a
|
|
16
|
+
* path that happens to contain `/packages/spec/src/`.
|
|
17
|
+
*
|
|
18
|
+
* Anything else yields `.claude`, which is exactly the behaviour every caller had before
|
|
19
|
+
* this module existed. There is deliberately no "derived" flag: nobody would read it.
|
|
20
|
+
*
|
|
21
|
+
* The source-tree marker is inlined rather than required from `hook-state-dir.cjs` so a
|
|
22
|
+
* fixture that copies a hook by hand needs to copy only this one library.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
const path = require('path');
|
|
26
|
+
|
|
27
|
+
const SOURCE_MARKER = `${path.sep}packages${path.sep}spec${path.sep}src${path.sep}`;
|
|
28
|
+
const HOOKS_DIR = path.resolve(__dirname, '..');
|
|
29
|
+
const RUNTIME_ROOT = path.resolve(HOOKS_DIR, '..');
|
|
30
|
+
|
|
31
|
+
/** `.claude`, `.codex`, `.omp`, or whatever dotted folder this hook tree is installed under. */
|
|
32
|
+
function runtimeDirName() {
|
|
33
|
+
const base = path.basename(RUNTIME_ROOT);
|
|
34
|
+
if (base.startsWith('.')) return base;
|
|
35
|
+
if (HOOKS_DIR.includes(SOURCE_MARKER)) return `.${base}`;
|
|
36
|
+
return '.claude';
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Absolute runtime directory for a project, e.g. `<cwd>/.omp`. */
|
|
40
|
+
function runtimeDir(cwd) {
|
|
41
|
+
return path.join(cwd, runtimeDirName());
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Absolute path under the runtime directory, e.g. `runtimePath(cwd, 'runtime.json')`. */
|
|
45
|
+
function runtimePath(cwd, ...segments) {
|
|
46
|
+
return path.join(runtimeDir(cwd), ...segments);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
module.exports = { runtimeDirName, runtimeDir, runtimePath };
|
|
@@ -69,7 +69,8 @@ function activeTask(root) {
|
|
|
69
69
|
|
|
70
70
|
try {
|
|
71
71
|
const stdin = fs.readFileSync(0, 'utf8').trim();
|
|
72
|
-
const
|
|
72
|
+
const { normalizeHookPayload } = require('./lib/hook-payload.cjs');
|
|
73
|
+
const payload = stdin ? normalizeHookPayload(JSON.parse(stdin)) : {};
|
|
73
74
|
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) process.exit(0);
|
|
74
75
|
|
|
75
76
|
const root = projectRoot(payload);
|
|
@@ -41,9 +41,10 @@ try {
|
|
|
41
41
|
/\.env\.(example|sample|template|test)$/i
|
|
42
42
|
];
|
|
43
43
|
|
|
44
|
+
const { runtimeDirName, runtimeDir, runtimePath } = require('./lib/runtime-dir.cjs');
|
|
44
45
|
function readRuntime(cwd) {
|
|
45
46
|
try {
|
|
46
|
-
const file =
|
|
47
|
+
const file = runtimePath(cwd, 'runtime.json');
|
|
47
48
|
return fs.existsSync(file) ? JSON.parse(fs.readFileSync(file, 'utf8')) : {};
|
|
48
49
|
} catch {
|
|
49
50
|
return {};
|
|
@@ -200,7 +201,8 @@ try {
|
|
|
200
201
|
const stdin = fs.readFileSync(0, 'utf8').trim();
|
|
201
202
|
if (!stdin) process.exit(0);
|
|
202
203
|
|
|
203
|
-
const
|
|
204
|
+
const { normalizeHookPayload } = require('./lib/hook-payload.cjs');
|
|
205
|
+
const data = normalizeHookPayload(JSON.parse(stdin));
|
|
204
206
|
const toolName = data.tool_name || '';
|
|
205
207
|
const toolInput = data.tool_input || {};
|
|
206
208
|
const cwd = typeof data.cwd === 'string' && data.cwd.trim() ? data.cwd : process.cwd();
|
|
@@ -16,9 +16,10 @@ try {
|
|
|
16
16
|
const os = require('os');
|
|
17
17
|
const path = require('path');
|
|
18
18
|
|
|
19
|
+
const { runtimeDirName, runtimeDir, runtimePath } = require('./lib/runtime-dir.cjs');
|
|
19
20
|
function readRuntime(cwd) {
|
|
20
21
|
try {
|
|
21
|
-
const p =
|
|
22
|
+
const p = runtimePath(cwd, 'runtime.json');
|
|
22
23
|
return fs.existsSync(p) ? JSON.parse(fs.readFileSync(p, 'utf8')) : null;
|
|
23
24
|
} catch { return null; }
|
|
24
25
|
}
|
|
@@ -48,7 +49,8 @@ try {
|
|
|
48
49
|
const stdin = fs.readFileSync(0, 'utf8').trim();
|
|
49
50
|
if (!stdin) process.exit(0);
|
|
50
51
|
|
|
51
|
-
const
|
|
52
|
+
const { normalizeHookPayload } = require('./lib/hook-payload.cjs');
|
|
53
|
+
const payload = normalizeHookPayload(JSON.parse(stdin));
|
|
52
54
|
const sessionId = payload.session_id || null;
|
|
53
55
|
const payloadCwd = typeof payload.cwd === 'string' ? payload.cwd.trim() : '';
|
|
54
56
|
const cwd = payloadCwd || process.env.PROJECT_ROOT || process.cwd();
|
|
@@ -24,7 +24,8 @@ try {
|
|
|
24
24
|
const stdin = fs.readFileSync(0, 'utf8').trim();
|
|
25
25
|
if (!stdin) process.exit(0);
|
|
26
26
|
|
|
27
|
-
const
|
|
27
|
+
const { normalizeHookPayload } = require('./lib/hook-payload.cjs');
|
|
28
|
+
const payload = normalizeHookPayload(JSON.parse(stdin));
|
|
28
29
|
const prompt = String(payload.prompt || payload.user_prompt || '');
|
|
29
30
|
if (!containsSecretKeyword(prompt)) process.exit(0);
|
|
30
31
|
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
|
|
4
4
|
const fs = require('fs');
|
|
5
5
|
const path = require('path');
|
|
6
|
+
const { runtimeDirName, runtimeDir, runtimePath } = require('./lib/runtime-dir.cjs');
|
|
6
7
|
const { spawnSync } = require('child_process');
|
|
7
8
|
const STATE = require('./completion-authority-state.cjs');
|
|
8
9
|
const PATH_SAFETY = require('./lib/runtime-path-safety.cjs');
|
|
@@ -28,7 +29,7 @@ function inside(root, target) {
|
|
|
28
29
|
|
|
29
30
|
function projectRoot(payload = {}) {
|
|
30
31
|
const installedRoot = path.resolve(__dirname, '..', '..');
|
|
31
|
-
if (fs.existsSync(
|
|
32
|
+
if (fs.existsSync(runtimePath(installedRoot, 'hooks', path.basename(__filename)))) {
|
|
32
33
|
try { return STATE.canonicalProjectRoot(installedRoot); } catch {}
|
|
33
34
|
}
|
|
34
35
|
for (const candidate of [process.env.CLAUDE_PROJECT_DIR, process.env.PROJECT_ROOT]) {
|
|
@@ -70,7 +71,7 @@ function parseClaim(message) {
|
|
|
70
71
|
}
|
|
71
72
|
|
|
72
73
|
function validatorFor(root) {
|
|
73
|
-
const installedRoot =
|
|
74
|
+
const installedRoot = runtimeDir(root);
|
|
74
75
|
const installedHook = path.join(installedRoot, 'hooks', path.basename(__filename));
|
|
75
76
|
if (fs.existsSync(installedHook)) {
|
|
76
77
|
const canonicalHook = PATH_SAFETY.canonicalRegularFile(root, installedHook, 'installed semantic hook');
|
|
@@ -82,7 +83,7 @@ function validatorFor(root) {
|
|
|
82
83
|
|
|
83
84
|
const sourceRuntime = path.resolve(__dirname, '..');
|
|
84
85
|
const sourceRoot = path.resolve(sourceRuntime, '..');
|
|
85
|
-
if (path.basename(sourceRuntime) !==
|
|
86
|
+
if (`.${path.basename(sourceRuntime)}` !== runtimeDirName() || path.basename(sourceRoot) !== 'src') {
|
|
86
87
|
throw new Error('semantic validator runtime root is not recognized');
|
|
87
88
|
}
|
|
88
89
|
const validator = path.join(sourceRuntime, 'scripts', 'validate-spec-output.cjs');
|
|
@@ -143,7 +144,8 @@ function verifyAttestation(root, specFile, featureName, semanticDigest) {
|
|
|
143
144
|
if (require.main === module) {
|
|
144
145
|
try {
|
|
145
146
|
const raw = fs.readFileSync(0, 'utf8').trim();
|
|
146
|
-
|
|
147
|
+
const { normalizeHookPayload } = require('./lib/hook-payload.cjs');
|
|
148
|
+
if (raw) recordFromSubagentStop(normalizeHookPayload(JSON.parse(raw)));
|
|
147
149
|
} catch (error) {
|
|
148
150
|
process.stderr.write(`CafeKit semantic review SubagentStop claim rejected: ${error.message}\n`);
|
|
149
151
|
}
|
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
try {
|
|
15
15
|
const fs = require('fs');
|
|
16
16
|
const path = require('path');
|
|
17
|
+
const { runtimeDirName, runtimeDir, runtimePath } = require('./lib/runtime-dir.cjs');
|
|
17
18
|
const os = require('os');
|
|
18
19
|
const { execSync } = require('child_process');
|
|
19
20
|
|
|
@@ -49,16 +50,16 @@ try {
|
|
|
49
50
|
}
|
|
50
51
|
|
|
51
52
|
const installedRoot = path.resolve(__dirname, '..', '..');
|
|
52
|
-
const installedHook =
|
|
53
|
+
const installedHook = runtimePath(installedRoot, 'hooks', path.basename(__filename));
|
|
53
54
|
if (fs.existsSync(installedHook)) return installedRoot;
|
|
54
55
|
|
|
55
56
|
try { return fs.realpathSync(process.cwd()); } catch { return path.resolve(process.cwd()); }
|
|
56
57
|
}
|
|
57
58
|
|
|
58
|
-
/** Read
|
|
59
|
+
/** Read <runtime>/runtime.json config */
|
|
59
60
|
function readRuntime(cwd) {
|
|
60
61
|
try {
|
|
61
|
-
const p =
|
|
62
|
+
const p = runtimePath(cwd, 'runtime.json');
|
|
62
63
|
return fs.existsSync(p) ? JSON.parse(fs.readFileSync(p, 'utf8')) : {};
|
|
63
64
|
} catch { return {}; }
|
|
64
65
|
}
|
|
@@ -143,12 +144,12 @@ try {
|
|
|
143
144
|
}
|
|
144
145
|
|
|
145
146
|
/**
|
|
146
|
-
* Read the installed CafeKit version from
|
|
147
|
+
* Read the installed CafeKit version from <runtime>/cafekit.json.
|
|
147
148
|
* Returns null if not found.
|
|
148
149
|
*/
|
|
149
150
|
function getInstalledVersion(cwd) {
|
|
150
151
|
try {
|
|
151
|
-
const p =
|
|
152
|
+
const p = runtimePath(cwd, 'cafekit.json');
|
|
152
153
|
if (!fs.existsSync(p)) return null;
|
|
153
154
|
const meta = JSON.parse(fs.readFileSync(p, 'utf8'));
|
|
154
155
|
return typeof meta.version === 'string' ? meta.version : null;
|
|
@@ -158,14 +159,14 @@ try {
|
|
|
158
159
|
/**
|
|
159
160
|
* Check for a newer CafeKit version and return an update notice string,
|
|
160
161
|
* or an empty string if already up to date or check fails.
|
|
161
|
-
* Result is cached in
|
|
162
|
+
* Result is cached in <runtime>/.cafekit-update-cache.json for 12 hours.
|
|
162
163
|
*/
|
|
163
164
|
async function checkCafeKitUpdate(cwd) {
|
|
164
165
|
const installed = getInstalledVersion(cwd);
|
|
165
166
|
if (!installed) return '';
|
|
166
167
|
|
|
167
168
|
const CACHE_TTL = 12 * 60 * 60 * 1000; // 12 hours
|
|
168
|
-
const cachePath =
|
|
169
|
+
const cachePath = runtimePath(cwd, '.cafekit-update-cache.json');
|
|
169
170
|
|
|
170
171
|
// Return cached result if still fresh.
|
|
171
172
|
try {
|
|
@@ -195,7 +196,8 @@ try {
|
|
|
195
196
|
|
|
196
197
|
(async () => {
|
|
197
198
|
const stdin = fs.readFileSync(0, 'utf8').trim();
|
|
198
|
-
const
|
|
199
|
+
const { normalizeHookPayload } = require('./lib/hook-payload.cjs');
|
|
200
|
+
const payload = stdin ? normalizeHookPayload(JSON.parse(stdin)) : {};
|
|
199
201
|
const source = payload.source || 'unknown';
|
|
200
202
|
const envFile = process.env.CLAUDE_ENV_FILE;
|
|
201
203
|
const cwd = projectRoot();
|
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
|
|
16
16
|
const fs = require('fs');
|
|
17
17
|
const path = require('path');
|
|
18
|
+
const { runtimeDirName, runtimeDir, runtimePath } = require('./lib/runtime-dir.cjs');
|
|
18
19
|
|
|
19
20
|
function projectRoot(payload = {}) {
|
|
20
21
|
const configured = typeof process.env.CLAUDE_PROJECT_DIR === 'string'
|
|
@@ -25,7 +26,7 @@ function projectRoot(payload = {}) {
|
|
|
25
26
|
}
|
|
26
27
|
|
|
27
28
|
const installedRoot = path.resolve(__dirname, '..', '..');
|
|
28
|
-
const installedHook =
|
|
29
|
+
const installedHook = runtimePath(installedRoot, 'hooks', path.basename(__filename));
|
|
29
30
|
if (fs.existsSync(installedHook)) return installedRoot;
|
|
30
31
|
|
|
31
32
|
const sourceFixture = typeof process.env.PROJECT_ROOT === 'string'
|
|
@@ -78,8 +79,13 @@ try {
|
|
|
78
79
|
throw new Error('hook payload must be a JSON object');
|
|
79
80
|
}
|
|
80
81
|
|
|
81
|
-
// Never re-block a continuation caused by our own block (infinite-loop guard).
|
|
82
|
-
|
|
82
|
+
// Never re-block a continuation caused by our own block (infinite-loop guard). Grok
|
|
83
|
+
// spells this `stopHookActive`; both are accepted here rather than through the payload
|
|
84
|
+
// reader, because the catch at the end of this file answers a block, so a reader that
|
|
85
|
+
// threw above the guard would block every completion instead of only a foreign one.
|
|
86
|
+
// The rest of this hook is already host-neutral: sessionIdentity() accepts sessionId
|
|
87
|
+
// and sessionID, the resolver reads camelCase targets, and projectRoot reads cwd.
|
|
88
|
+
if (payload.stop_hook_active === true || payload.stopHookActive === true) process.exit(0);
|
|
83
89
|
|
|
84
90
|
let POLICY;
|
|
85
91
|
let RESOLVER;
|
|
@@ -108,7 +114,7 @@ try {
|
|
|
108
114
|
// Missing/malformed runtime.json keeps the gate ON (fail-closed, valve 3 style).
|
|
109
115
|
let runtime = {};
|
|
110
116
|
try {
|
|
111
|
-
const rp =
|
|
117
|
+
const rp = runtimePath(cwd, 'runtime.json');
|
|
112
118
|
if (fs.existsSync(rp)) runtime = JSON.parse(fs.readFileSync(rp, 'utf8'));
|
|
113
119
|
} catch { /* gate stays on */ }
|
|
114
120
|
// Active-spec discovery via shared resolver — explicit target if present, else fail on ambiguity.
|
|
@@ -225,10 +231,14 @@ try {
|
|
|
225
231
|
process.exit(0);
|
|
226
232
|
}
|
|
227
233
|
|
|
228
|
-
// Cache hardening: every Stop
|
|
229
|
-
//
|
|
230
|
-
//
|
|
231
|
-
//
|
|
234
|
+
// Cache hardening: every Stop re-reads and re-checks the receipt of every
|
|
235
|
+
// task currently marked done, so a cached PASS cannot hide later mutations
|
|
236
|
+
// (deletion, placeholder, changed Verification/Command/Exit, missing
|
|
237
|
+
// output, edited artifact bytes). Base/Head are compared against the live
|
|
238
|
+
// runtime only while the task file differs from its committed bytes or the
|
|
239
|
+
// tree outside the specs root is dirty; a committed, unchanged receipt on a
|
|
240
|
+
// clean tree is checked on structure alone. Cache is an optimization, not
|
|
241
|
+
// truth.
|
|
232
242
|
const featureCache = cacheExists ? (cache[featureName] || {}) : {};
|
|
233
243
|
const allDoneTasks = Object.keys(taskRegistry).filter((tp) =>
|
|
234
244
|
(taskRegistry[tp]?.status || 'pending') === 'done'
|