@ngockhoale/ukit 2.2.2 → 2.2.4
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/CHANGELOG.md +32 -0
- package/manifests/platform.full.yaml +46 -0
- package/package.json +1 -1
- package/templates/.claude/hooks/completion-gate.sh +12 -0
- package/templates/.claude/hooks/record-execution.sh +12 -0
- package/templates/.claude/settings.json +31 -0
- package/templates/.claude/ukit/runtime/execution-ledger.mjs +354 -0
- package/templates/.claude/ukit/runtime/hook-chain-runner.mjs +115 -0
- package/templates/.omp/config.yml +3 -2
- package/templates/.omp/hooks/pre/ukit-bridge.js +346 -212
|
@@ -1,35 +1,20 @@
|
|
|
1
|
-
// ukit-bridge.js — omp
|
|
1
|
+
// ukit-bridge.js — omp extension bridge for UKit.
|
|
2
2
|
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
// 1. Maps an omp event + tool name onto the ordered list of scripts that
|
|
8
|
-
// `.claude/settings.json` would have run for the equivalent Claude Code
|
|
9
|
-
// event (see HOOK_EVENT_MAP, generated by hand from
|
|
10
|
-
// `templates/.claude/settings.json` — keep them in sync).
|
|
11
|
-
// 2. Builds the same stdin JSON payload the scripts already expect
|
|
12
|
-
// (hook_event_name, tool_name, tool_input, session_id, cwd, ...).
|
|
13
|
-
// 3. Executes each script via `pi.exec()` (never spawns/copies the script
|
|
14
|
-
// body) and translates the exit code back into an omp-shaped result.
|
|
15
|
-
//
|
|
16
|
-
// Event NAMES are verified against omp v17.4.2 (2026-08-22): its extension API
|
|
17
|
-
// registers `tool_call`, `tool_result`, `turn_start`, `session_start`,
|
|
18
|
-
// `session.compacting` and `session_compact` exactly as spelled below.
|
|
19
|
-
//
|
|
20
|
-
// `pi.exec`'s argument shape is still unverified — the contract chosen below (see
|
|
21
|
-
// the doc comments on each exported function) is internally consistent and is
|
|
22
|
-
// exercised end-to-end against a fake `pi` in tests/hooks/ompHookBridge.test.js,
|
|
23
|
-
// but nothing here has observed omp actually invoking it.
|
|
3
|
+
// The scripts under `.claude/hooks/` remain the single source of hook behavior.
|
|
4
|
+
// This adapter normalizes omp's v17.4.2 extension events into Claude Code hook
|
|
5
|
+
// payloads, executes the same ordered script chains, and translates only the
|
|
6
|
+
// result fields that omp consumes.
|
|
24
7
|
|
|
8
|
+
import fs from 'node:fs';
|
|
25
9
|
import path from 'node:path';
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
10
|
+
import { fileURLToPath } from 'node:url';
|
|
11
|
+
import {
|
|
12
|
+
evaluateCompletion,
|
|
13
|
+
incrementContinuation,
|
|
14
|
+
readExecutionLedger,
|
|
15
|
+
readRouteState,
|
|
16
|
+
recordExecutionReceipt,
|
|
17
|
+
} from '../../../.claude/ukit/runtime/execution-ledger.mjs';
|
|
33
18
|
|
|
34
19
|
export const HOOK_EVENT_MAP = {
|
|
35
20
|
tool_call: {
|
|
@@ -53,38 +38,32 @@ export const HOOK_EVENT_MAP = {
|
|
|
53
38
|
],
|
|
54
39
|
},
|
|
55
40
|
tool_result: {
|
|
56
|
-
'
|
|
57
|
-
|
|
41
|
+
'Read|Grep|Glob': ['record-execution.sh'],
|
|
42
|
+
'Edit|Write': ['post-edit-verify.sh', 'record-execution.sh'],
|
|
43
|
+
Bash: ['compress-output.sh', 'record-execution.sh'],
|
|
58
44
|
},
|
|
59
|
-
|
|
45
|
+
before_agent_start: ['skill-router.sh', 'vision-router.sh', 'context-window-guard.sh'],
|
|
60
46
|
'session.compacting': ['reinject-context.sh'],
|
|
61
47
|
session_start: ['auto-prune-bash.sh', 'reset-compact-pressure.sh', 'handoff-resume.sh'],
|
|
62
48
|
};
|
|
63
49
|
|
|
64
|
-
// ---------------------------------------------------------------------------
|
|
65
|
-
// Tool-name mapping (PLAN.md D2).
|
|
66
|
-
//
|
|
67
|
-
// omp's write surface is `edit`, `write`, AND `ast_edit` — `ast_edit` must
|
|
68
|
-
// map to the same `Edit` matcher group as `edit`/`write`, or it silently
|
|
69
|
-
// bypasses protect-files.sh / vision-gate.sh. `eval` is omp's shell-capable
|
|
70
|
-
// tool and must map to `Bash` to hit block-dangerous.sh / verification-guard.sh.
|
|
71
|
-
// Any tool name not in this table maps to `null`, which runs ZERO scripts —
|
|
72
|
-
// it must never silently fall back to Bash or Edit.
|
|
73
|
-
// ---------------------------------------------------------------------------
|
|
74
|
-
|
|
75
50
|
const TOOL_NAME_MAP = {
|
|
76
51
|
read: 'Read',
|
|
52
|
+
read_file: 'Read',
|
|
77
53
|
grep: 'Grep',
|
|
78
54
|
glob: 'Glob',
|
|
79
55
|
edit: 'Edit',
|
|
80
56
|
write: 'Write',
|
|
81
57
|
ast_edit: 'Edit',
|
|
58
|
+
apply_patch: 'Edit',
|
|
82
59
|
eval: 'Bash',
|
|
83
60
|
bash: 'Bash',
|
|
61
|
+
shell: 'Bash',
|
|
84
62
|
};
|
|
85
63
|
|
|
86
64
|
export function mapToolName(ompToolName) {
|
|
87
|
-
|
|
65
|
+
const normalized = String(ompToolName ?? '').trim().toLowerCase().replaceAll('-', '_');
|
|
66
|
+
return TOOL_NAME_MAP[normalized] ?? null;
|
|
88
67
|
}
|
|
89
68
|
|
|
90
69
|
function matcherGroupFor(claudeToolName) {
|
|
@@ -100,14 +79,6 @@ function matcherGroupFor(claudeToolName) {
|
|
|
100
79
|
return null;
|
|
101
80
|
}
|
|
102
81
|
|
|
103
|
-
// ---------------------------------------------------------------------------
|
|
104
|
-
// Fail-direction classification (PLAN.md D3). Not a blanket rule -- each
|
|
105
|
-
// script's own header documents its own fail direction; this table is a
|
|
106
|
-
// transcription of those 18 headers, not an invented policy. Gate scripts
|
|
107
|
-
// fail CLOSED (non-zero/throw => block). Advisory scripts fail OPEN
|
|
108
|
-
// (non-zero/throw => log a warning, never block).
|
|
109
|
-
// ---------------------------------------------------------------------------
|
|
110
|
-
|
|
111
82
|
export const FAIL_CLOSED_SCRIPTS = new Set([
|
|
112
83
|
'protect-files.sh',
|
|
113
84
|
'stale-spec-guard.sh',
|
|
@@ -120,6 +91,7 @@ export const FAIL_CLOSED_SCRIPTS = new Set([
|
|
|
120
91
|
|
|
121
92
|
export const ADVISORY_SCRIPTS = new Set([
|
|
122
93
|
'skill-router.sh',
|
|
94
|
+
'record-execution.sh',
|
|
123
95
|
'auto-allow-bash.sh',
|
|
124
96
|
'pre-edit-backup.sh',
|
|
125
97
|
'vision-router.sh',
|
|
@@ -133,46 +105,47 @@ export const ADVISORY_SCRIPTS = new Set([
|
|
|
133
105
|
]);
|
|
134
106
|
|
|
135
107
|
function classifyFailure(scriptName) {
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
108
|
+
return FAIL_CLOSED_SCRIPTS.has(scriptName) ? 'closed' : 'open';
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function runtimeMetadata(event = {}, context = {}) {
|
|
112
|
+
const sessionManager = context?.sessionManager;
|
|
113
|
+
return {
|
|
114
|
+
sessionId: event.sessionId ?? event.session_id ?? sessionManager?.getSessionId?.(),
|
|
115
|
+
cwd: context?.cwd ?? event.cwd,
|
|
116
|
+
transcriptPath: event.transcriptPath ?? event.transcript_path ?? sessionManager?.getSessionFile?.(),
|
|
117
|
+
};
|
|
141
118
|
}
|
|
142
119
|
|
|
143
|
-
|
|
144
|
-
// Payload + result translation.
|
|
145
|
-
// ---------------------------------------------------------------------------
|
|
146
|
-
|
|
147
|
-
/**
|
|
148
|
-
* Builds the same stdin JSON shape the `.sh` scripts already read via
|
|
149
|
-
* `INPUT=$(cat)`. Fields the caller does not supply are simply omitted --
|
|
150
|
-
* the scripts already degrade gracefully when optional fields (e.g.
|
|
151
|
-
* transcript_path, prompt) are absent (see context-window-guard.sh, which
|
|
152
|
-
* exits 0 immediately when transcript_path is missing).
|
|
153
|
-
*/
|
|
154
|
-
function buildHookPayload(hookEventName, { toolName, toolInput, sessionId, cwd, prompt, transcriptPath } = {}) {
|
|
120
|
+
function buildHookPayload(hookEventName, fields = {}) {
|
|
155
121
|
const payload = { hook_event_name: hookEventName };
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
122
|
+
const mappings = [
|
|
123
|
+
['toolName', 'tool_name'],
|
|
124
|
+
['toolInput', 'tool_input'],
|
|
125
|
+
['toolOutput', 'tool_output'],
|
|
126
|
+
['toolResult', 'tool_result'],
|
|
127
|
+
['toolUseId', 'tool_use_id'],
|
|
128
|
+
['sessionId', 'session_id'],
|
|
129
|
+
['cwd', 'cwd'],
|
|
130
|
+
['prompt', 'prompt'],
|
|
131
|
+
['transcriptPath', 'transcript_path'],
|
|
132
|
+
['source', 'source'],
|
|
133
|
+
];
|
|
134
|
+
for (const [field, payloadField] of mappings) {
|
|
135
|
+
if (fields[field] !== undefined && fields[field] !== null) {
|
|
136
|
+
payload[payloadField] = fields[field];
|
|
137
|
+
}
|
|
138
|
+
}
|
|
162
139
|
return payload;
|
|
163
140
|
}
|
|
164
141
|
|
|
165
|
-
/**
|
|
166
|
-
* Translates a single script's `pi.exec` outcome into a bridge-internal
|
|
167
|
-
* verdict. Exit 0 => pass. Exit 2 => this script's own explicit "block"
|
|
168
|
-
* signal, regardless of gate/advisory class (mirrors Claude Code's own
|
|
169
|
-
* "exit 2 = block, stderr = reason" contract). Any other non-zero exit, or
|
|
170
|
-
* a thrown error, is resolved via the script's fail-direction classification.
|
|
171
|
-
*/
|
|
172
142
|
function translateExecResult(scriptName, execResult) {
|
|
173
|
-
const
|
|
143
|
+
const killed = Boolean(execResult?.killed);
|
|
144
|
+
const code = killed ? 1 : (execResult?.code ?? 0);
|
|
174
145
|
const stdout = execResult?.stdout ?? '';
|
|
175
|
-
const stderr =
|
|
146
|
+
const stderr = killed
|
|
147
|
+
? (execResult?.stderr || `${scriptName} was killed before it completed`)
|
|
148
|
+
: (execResult?.stderr ?? '');
|
|
176
149
|
|
|
177
150
|
if (code === 0) {
|
|
178
151
|
return { block: false, stdout, stderr };
|
|
@@ -180,9 +153,7 @@ function translateExecResult(scriptName, execResult) {
|
|
|
180
153
|
if (code === 2) {
|
|
181
154
|
return { block: true, reason: stderr || `${scriptName} exited 2 (blocked)`, stdout, stderr };
|
|
182
155
|
}
|
|
183
|
-
|
|
184
|
-
const direction = classifyFailure(scriptName);
|
|
185
|
-
if (direction === 'closed') {
|
|
156
|
+
if (classifyFailure(scriptName) === 'closed') {
|
|
186
157
|
return {
|
|
187
158
|
block: true,
|
|
188
159
|
reason: stderr || `${scriptName} exited ${code} (failing closed)`,
|
|
@@ -190,179 +161,342 @@ function translateExecResult(scriptName, execResult) {
|
|
|
190
161
|
stderr,
|
|
191
162
|
};
|
|
192
163
|
}
|
|
193
|
-
return {
|
|
164
|
+
return {
|
|
165
|
+
block: false,
|
|
166
|
+
warning: `${scriptName} exited ${code} (failing open): ${stderr || 'no stderr'}`,
|
|
167
|
+
stdout,
|
|
168
|
+
stderr,
|
|
169
|
+
};
|
|
194
170
|
}
|
|
195
171
|
|
|
196
172
|
export { translateExecResult };
|
|
197
173
|
|
|
198
|
-
|
|
199
|
-
// Script chain runner -- shared by every event handler below. Keeps the
|
|
200
|
-
// fail-direction / short-circuit / context-accumulation logic in exactly
|
|
201
|
-
// one place.
|
|
202
|
-
// ---------------------------------------------------------------------------
|
|
203
|
-
|
|
204
|
-
/**
|
|
205
|
-
* Runs `scripts` (basenames under `.claude/hooks/`) in order via
|
|
206
|
-
* `pi.exec(absoluteScriptPath, { input: JSON.stringify(payload) })`,
|
|
207
|
-
* short-circuiting on the first block. Returns
|
|
208
|
-
* { block, reason?, context, invoked }
|
|
209
|
-
* where `context` is the concatenation of each script's trimmed, non-empty
|
|
210
|
-
* stdout (used by session.compacting / session_start to surface
|
|
211
|
-
* reinject-context.sh / handoff-resume.sh output back to omp).
|
|
212
|
-
*/
|
|
213
|
-
export async function runScriptChain(pi, scripts, payload, { projectRoot }) {
|
|
214
|
-
const invoked = [];
|
|
215
|
-
const contextParts = [];
|
|
174
|
+
const HOOK_CHAIN_TIMEOUT_MS = 12000;
|
|
216
175
|
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
176
|
+
function recordHookErrorDiagnostic(projectRoot, sessionId, diagnostic) {
|
|
177
|
+
try {
|
|
178
|
+
const dir = path.join(projectRoot, '.ukit', 'storage', 'cache', 'hook-errors');
|
|
179
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
180
|
+
const safeSession = String(sessionId || 'unknown').replace(/[^a-zA-Z0-9._-]/g, '_').slice(0, 96) || 'unknown';
|
|
181
|
+
fs.appendFileSync(path.join(dir, `${safeSession}.jsonl`), `${JSON.stringify(diagnostic)}\n`, 'utf8');
|
|
182
|
+
} catch {
|
|
183
|
+
// Diagnostics are advisory and must never block or throw.
|
|
184
|
+
}
|
|
185
|
+
}
|
|
220
186
|
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
187
|
+
export async function runScriptChain(
|
|
188
|
+
pi,
|
|
189
|
+
scripts,
|
|
190
|
+
payload,
|
|
191
|
+
{ projectRoot, failClosedOnTransportError = false },
|
|
192
|
+
) {
|
|
193
|
+
const invoked = [];
|
|
194
|
+
const context = [];
|
|
195
|
+
if (scripts.length === 0) {
|
|
196
|
+
return { block: false, context, invoked };
|
|
197
|
+
}
|
|
227
198
|
|
|
228
|
-
|
|
199
|
+
const runnerPath = path.join(projectRoot, '.claude', 'ukit', 'runtime', 'hook-chain-runner.mjs');
|
|
200
|
+
const scriptPaths = scripts.map((scriptName) => path.join(projectRoot, '.claude', 'hooks', scriptName));
|
|
201
|
+
// Do not blindly trust process.execPath: under a Bun-hosted omp, it points at bun, not node.
|
|
202
|
+
const nodeExecutable = process.env.UKIT_NODE_PATH || process.execPath;
|
|
203
|
+
const startedAt = Date.now();
|
|
204
|
+
let execResult;
|
|
205
|
+
try {
|
|
206
|
+
execResult = await pi.exec(
|
|
207
|
+
nodeExecutable,
|
|
208
|
+
[runnerPath, JSON.stringify(payload), ...scriptPaths],
|
|
209
|
+
{ cwd: projectRoot, timeout: HOOK_CHAIN_TIMEOUT_MS },
|
|
210
|
+
);
|
|
211
|
+
} catch (error) {
|
|
212
|
+
execResult = { code: 1, stdout: '', stderr: error?.message ?? String(error), killed: false };
|
|
213
|
+
}
|
|
214
|
+
const elapsedMs = Date.now() - startedAt;
|
|
229
215
|
|
|
230
|
-
|
|
231
|
-
|
|
216
|
+
let chainResult = null;
|
|
217
|
+
let parseError = null;
|
|
218
|
+
if (!execResult?.killed) {
|
|
219
|
+
try {
|
|
220
|
+
chainResult = JSON.parse(execResult?.stdout || '{}');
|
|
221
|
+
} catch (error) {
|
|
222
|
+
parseError = error;
|
|
232
223
|
}
|
|
224
|
+
}
|
|
233
225
|
|
|
234
|
-
|
|
235
|
-
|
|
226
|
+
const hasUsableResults = Boolean(chainResult) && Array.isArray(chainResult.results) && chainResult.results.length > 0;
|
|
227
|
+
const transportFailed = Boolean(execResult?.killed) || Boolean(parseError) || Boolean(chainResult?.wrapperError) || !hasUsableResults;
|
|
228
|
+
|
|
229
|
+
if (transportFailed) {
|
|
230
|
+
// The aggregate runner produced no verifiable per-script verdict. Never relabel this as a
|
|
231
|
+
// specific fail-closed script's decision -- that script may never have run.
|
|
232
|
+
const diagnostic = {
|
|
233
|
+
ts: Date.now(),
|
|
234
|
+
scripts,
|
|
235
|
+
killed: Boolean(execResult?.killed),
|
|
236
|
+
code: execResult?.code ?? null,
|
|
237
|
+
stdoutLength: (execResult?.stdout || '').length,
|
|
238
|
+
stderr: execResult?.stderr || '',
|
|
239
|
+
elapsedMs,
|
|
240
|
+
nodeExecutable,
|
|
241
|
+
nodeVersion: process.version,
|
|
242
|
+
runnerPath,
|
|
243
|
+
parseError: parseError?.message || null,
|
|
244
|
+
wrapperError: chainResult?.wrapperError || null,
|
|
245
|
+
};
|
|
246
|
+
recordHookErrorDiagnostic(projectRoot, payload.session_id, diagnostic);
|
|
247
|
+
const reason = `UKit OMP hook runner failed before producing a valid result `
|
|
248
|
+
+ `(killed=${diagnostic.killed}, code=${diagnostic.code}, elapsedMs=${diagnostic.elapsedMs}, `
|
|
249
|
+
+ `runtime=${diagnostic.nodeExecutable}). No safety-gate verdict was available for [${scripts.join(', ')}]. `
|
|
250
|
+
+ `See .ukit/storage/cache/hook-errors/.`;
|
|
251
|
+
if (failClosedOnTransportError) {
|
|
252
|
+
return { block: true, reason, context, invoked };
|
|
236
253
|
}
|
|
254
|
+
pi.logger?.warn?.(`[UKit] ${reason}`);
|
|
255
|
+
return { block: false, context, invoked };
|
|
256
|
+
}
|
|
237
257
|
|
|
258
|
+
for (const item of chainResult.results) {
|
|
259
|
+
const scriptName = item?.scriptName;
|
|
260
|
+
if (!scriptName || !scripts.includes(scriptName)) continue;
|
|
261
|
+
invoked.push(scriptName);
|
|
262
|
+
const verdict = translateExecResult(scriptName, item);
|
|
263
|
+
if (verdict.stdout?.trim()) context.push(verdict.stdout.trim());
|
|
264
|
+
if (verdict.warning) pi.logger?.warn?.(`[UKit] ${verdict.warning}`);
|
|
238
265
|
if (verdict.block) {
|
|
239
|
-
return { block: true, reason: verdict.reason, context
|
|
266
|
+
return { block: true, reason: verdict.reason, context, invoked };
|
|
240
267
|
}
|
|
241
268
|
}
|
|
242
269
|
|
|
243
|
-
return { block: false, context
|
|
270
|
+
return { block: false, context, invoked };
|
|
244
271
|
}
|
|
245
272
|
|
|
246
|
-
// ---------------------------------------------------------------------------
|
|
247
|
-
// Event handlers (all exported directly for test import; wired onto `pi.on`
|
|
248
|
-
// by the default export below).
|
|
249
|
-
// ---------------------------------------------------------------------------
|
|
250
|
-
|
|
251
273
|
function scriptsForToolCall(claudeToolName) {
|
|
252
274
|
const matcherGroup = matcherGroupFor(claudeToolName);
|
|
253
|
-
|
|
254
|
-
return HOOK_EVENT_MAP.tool_call[matcherGroup] ?? [];
|
|
275
|
+
return matcherGroup ? (HOOK_EVENT_MAP.tool_call[matcherGroup] ?? []) : [];
|
|
255
276
|
}
|
|
256
277
|
|
|
257
278
|
function scriptsForToolResult(claudeToolName) {
|
|
258
279
|
const matcherGroup = matcherGroupFor(claudeToolName);
|
|
259
|
-
|
|
260
|
-
if (matcherGroup !== 'Edit|Write' && matcherGroup !== 'Bash') return [];
|
|
261
|
-
return HOOK_EVENT_MAP.tool_result[matcherGroup] ?? [];
|
|
280
|
+
return matcherGroup ? (HOOK_EVENT_MAP.tool_result[matcherGroup] ?? []) : [];
|
|
262
281
|
}
|
|
263
282
|
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
283
|
+
function normalizeToolInput(toolName, input) {
|
|
284
|
+
if (!input || typeof input !== 'object' || Array.isArray(input)) return input ?? {};
|
|
285
|
+
const normalized = { ...input };
|
|
286
|
+
if ((toolName === 'Edit' || toolName === 'Write') && !normalized.file_path) {
|
|
287
|
+
const paths = Array.isArray(normalized.paths) ? normalized.paths.filter(Boolean) : [];
|
|
288
|
+
normalized.file_path = normalized.path || paths[0] || undefined;
|
|
289
|
+
}
|
|
290
|
+
return normalized;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
function looksMutationCapable(input) {
|
|
294
|
+
if (!input || typeof input !== 'object' || Array.isArray(input)) return false;
|
|
295
|
+
const hasTarget = Boolean(input.file_path || input.path || (Array.isArray(input.paths) && input.paths.length));
|
|
296
|
+
const hasMutation = [
|
|
297
|
+
'content',
|
|
298
|
+
'new_string',
|
|
299
|
+
'old_string',
|
|
300
|
+
'patch',
|
|
301
|
+
'replacement',
|
|
302
|
+
'edits',
|
|
303
|
+
].some((key) => input[key] !== undefined);
|
|
304
|
+
return hasTarget && hasMutation;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
function textFromContent(content) {
|
|
308
|
+
return (Array.isArray(content) ? content : [])
|
|
309
|
+
.filter((item) => item?.type === 'text' && typeof item.text === 'string')
|
|
310
|
+
.map((item) => item.text)
|
|
311
|
+
.join('\n');
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
function hookContextMessage(content) {
|
|
315
|
+
return {
|
|
316
|
+
customType: 'ukit-hook-context',
|
|
317
|
+
content,
|
|
318
|
+
display: false,
|
|
319
|
+
};
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
function sendContext(pi, context, deliverAs) {
|
|
323
|
+
const content = context.filter(Boolean).join('\n').trim();
|
|
324
|
+
if (!content || typeof pi.sendMessage !== 'function') return;
|
|
325
|
+
pi.sendMessage(hookContextMessage(content), { deliverAs });
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
export async function runToolCall(pi, event, { projectRoot, context: extensionContext = {} }) {
|
|
329
|
+
const rawToolName = event.toolName ?? event.tool;
|
|
330
|
+
const toolName = mapToolName(rawToolName);
|
|
331
|
+
if (!toolName && looksMutationCapable(event.input)) {
|
|
332
|
+
return {
|
|
333
|
+
block: true,
|
|
334
|
+
reason: `UKit blocked unmapped mutation-capable tool "${rawToolName}"; add a host adapter mapping before retrying.`,
|
|
335
|
+
context: [],
|
|
336
|
+
invoked: [],
|
|
337
|
+
toolName,
|
|
338
|
+
};
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
const metadata = runtimeMetadata(event, extensionContext);
|
|
270
342
|
const payload = buildHookPayload('PreToolUse', {
|
|
271
343
|
toolName,
|
|
272
|
-
toolInput: event.input,
|
|
273
|
-
|
|
274
|
-
|
|
344
|
+
toolInput: normalizeToolInput(toolName, event.input),
|
|
345
|
+
toolUseId: event.toolCallId,
|
|
346
|
+
...metadata,
|
|
347
|
+
});
|
|
348
|
+
// Only Edit|Write stays fail-closed on a bridge/runner transport failure; Bash and
|
|
349
|
+
// Read|Grep|Glob fail open so diagnosis/recovery tools are never bricked by a runner outage.
|
|
350
|
+
const failClosedOnTransportError = matcherGroupFor(toolName) === 'Edit|Write';
|
|
351
|
+
const result = await runScriptChain(pi, scriptsForToolCall(toolName), payload, {
|
|
352
|
+
projectRoot,
|
|
353
|
+
failClosedOnTransportError,
|
|
275
354
|
});
|
|
276
|
-
|
|
355
|
+
if (!result.block) sendContext(pi, result.context, 'steer');
|
|
277
356
|
return { ...result, toolName };
|
|
278
357
|
}
|
|
279
358
|
|
|
280
|
-
export async function runToolResult(pi, event, { projectRoot }) {
|
|
281
|
-
const toolName = mapToolName(event.tool);
|
|
282
|
-
const
|
|
359
|
+
export async function runToolResult(pi, event, { projectRoot, context: extensionContext = {} }) {
|
|
360
|
+
const toolName = mapToolName(event.toolName ?? event.tool);
|
|
361
|
+
const metadata = runtimeMetadata(event, extensionContext);
|
|
362
|
+
const outputText = textFromContent(event.content);
|
|
363
|
+
const toolOutput = {
|
|
364
|
+
...(event.details && typeof event.details === 'object' ? event.details : {}),
|
|
365
|
+
output: outputText,
|
|
366
|
+
stdout: outputText,
|
|
367
|
+
exitCode: event.details?.exitCode,
|
|
368
|
+
isError: Boolean(event.isError),
|
|
369
|
+
details: event.details,
|
|
370
|
+
};
|
|
283
371
|
const payload = buildHookPayload('PostToolUse', {
|
|
284
372
|
toolName,
|
|
285
|
-
toolInput: event.input,
|
|
286
|
-
|
|
287
|
-
|
|
373
|
+
toolInput: normalizeToolInput(toolName, event.input),
|
|
374
|
+
toolOutput,
|
|
375
|
+
toolResult: {
|
|
376
|
+
content: event.content,
|
|
377
|
+
details: event.details,
|
|
378
|
+
isError: Boolean(event.isError),
|
|
379
|
+
},
|
|
380
|
+
toolUseId: event.toolCallId,
|
|
381
|
+
...metadata,
|
|
288
382
|
});
|
|
289
|
-
const result = await runScriptChain(pi,
|
|
290
|
-
|
|
383
|
+
const result = await runScriptChain(pi, scriptsForToolResult(toolName), payload, { projectRoot });
|
|
384
|
+
try {
|
|
385
|
+
await recordExecutionReceipt({
|
|
386
|
+
projectRoot,
|
|
387
|
+
payload,
|
|
388
|
+
toolName,
|
|
389
|
+
harness: 'omp',
|
|
390
|
+
});
|
|
391
|
+
} catch (error) {
|
|
392
|
+
pi.logger?.warn?.(`[UKit] execution receipt failed open: ${error?.message || error}`);
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
const hookOutput = result.context.join('\n').trim();
|
|
396
|
+
if (!hookOutput) return undefined;
|
|
397
|
+
if (toolName === 'Bash') {
|
|
398
|
+
return {
|
|
399
|
+
content: [{ type: 'text', text: hookOutput }],
|
|
400
|
+
details: event.details,
|
|
401
|
+
isError: Boolean(event.isError),
|
|
402
|
+
};
|
|
403
|
+
}
|
|
404
|
+
return {
|
|
405
|
+
content: [
|
|
406
|
+
...(Array.isArray(event.content) ? event.content : []),
|
|
407
|
+
{ type: 'text', text: hookOutput },
|
|
408
|
+
],
|
|
409
|
+
details: event.details,
|
|
410
|
+
isError: Boolean(event.isError),
|
|
411
|
+
};
|
|
291
412
|
}
|
|
292
413
|
|
|
293
|
-
export async function
|
|
414
|
+
export async function runBeforeAgentStart(pi, event, { projectRoot, context: extensionContext = {} }) {
|
|
415
|
+
const metadata = runtimeMetadata(event, extensionContext);
|
|
294
416
|
const payload = buildHookPayload('UserPromptSubmit', {
|
|
295
|
-
sessionId: event.sessionId,
|
|
296
|
-
cwd: event.cwd,
|
|
297
417
|
prompt: event.prompt,
|
|
418
|
+
...metadata,
|
|
298
419
|
});
|
|
299
|
-
|
|
420
|
+
const result = await runScriptChain(pi, HOOK_EVENT_MAP.before_agent_start, payload, { projectRoot });
|
|
421
|
+
const parts = [...result.context];
|
|
422
|
+
if (result.block && result.reason) parts.push(result.reason);
|
|
423
|
+
const content = parts.join('\n').trim();
|
|
424
|
+
return content ? { message: hookContextMessage(content) } : undefined;
|
|
300
425
|
}
|
|
301
426
|
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
const
|
|
306
|
-
|
|
307
|
-
cwd: event.cwd,
|
|
308
|
-
transcriptPath: event.transcriptPath,
|
|
309
|
-
});
|
|
310
|
-
return runScriptChain(pi, HOOK_EVENT_MAP['session.compacting'], payload, { projectRoot });
|
|
427
|
+
export async function runSessionCompacting(pi, event, { projectRoot, context: extensionContext = {} }) {
|
|
428
|
+
const metadata = runtimeMetadata(event, extensionContext);
|
|
429
|
+
const payload = buildHookPayload('PreCompact', metadata);
|
|
430
|
+
const result = await runScriptChain(pi, HOOK_EVENT_MAP['session.compacting'], payload, { projectRoot });
|
|
431
|
+
return result.context.length > 0 ? { context: result.context } : undefined;
|
|
311
432
|
}
|
|
312
433
|
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
// more than once in a session is safe (PLAN.md D11).
|
|
320
|
-
export async function runSessionStart(pi, event, { projectRoot }) {
|
|
321
|
-
const payload = buildHookPayload('SessionStart', {
|
|
322
|
-
sessionId: event.sessionId,
|
|
323
|
-
cwd: event.cwd,
|
|
324
|
-
});
|
|
325
|
-
return runScriptChain(pi, HOOK_EVENT_MAP.session_start, payload, { projectRoot });
|
|
434
|
+
export async function runSessionStart(pi, event, { projectRoot, context: extensionContext = {} }) {
|
|
435
|
+
const metadata = runtimeMetadata(event, extensionContext);
|
|
436
|
+
const payload = buildHookPayload('SessionStart', metadata);
|
|
437
|
+
const result = await runScriptChain(pi, HOOK_EVENT_MAP.session_start, payload, { projectRoot });
|
|
438
|
+
sendContext(pi, result.context, 'nextTurn');
|
|
439
|
+
return result;
|
|
326
440
|
}
|
|
327
441
|
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
const
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
});
|
|
351
|
-
|
|
442
|
+
export async function runSessionCompact(pi, event, { projectRoot, context: extensionContext = {} }) {
|
|
443
|
+
const metadata = runtimeMetadata(event, extensionContext);
|
|
444
|
+
const payload = buildHookPayload('SessionStart', { ...metadata, source: 'compact' });
|
|
445
|
+
const result = await runScriptChain(pi, HOOK_EVENT_MAP.session_start, payload, { projectRoot });
|
|
446
|
+
sendContext(pi, result.context, 'nextTurn');
|
|
447
|
+
return undefined;
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
export async function runSessionStop(
|
|
451
|
+
pi,
|
|
452
|
+
event,
|
|
453
|
+
{
|
|
454
|
+
projectRoot,
|
|
455
|
+
context: extensionContext = {},
|
|
456
|
+
state: suppliedState,
|
|
457
|
+
ledger: suppliedLedger,
|
|
458
|
+
},
|
|
459
|
+
) {
|
|
460
|
+
const metadata = runtimeMetadata(event, extensionContext);
|
|
461
|
+
const payload = buildHookPayload('Stop', metadata);
|
|
462
|
+
const state = suppliedState ?? await readRouteState(projectRoot);
|
|
463
|
+
const ledger = suppliedLedger ?? await readExecutionLedger(projectRoot, payload) ?? {};
|
|
464
|
+
const evaluation = evaluateCompletion({ state, ledger });
|
|
465
|
+
if (!evaluation.continue) {
|
|
466
|
+
if (evaluation.capped) pi.logger?.warn?.(`[UKit] ${evaluation.reason}`);
|
|
467
|
+
return undefined;
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
if (suppliedLedger === undefined) {
|
|
471
|
+
try {
|
|
472
|
+
await incrementContinuation(projectRoot, payload, ledger);
|
|
473
|
+
} catch (error) {
|
|
474
|
+
pi.logger?.warn?.(`[UKit] continuation bookkeeping failed open: ${error?.message || error}`);
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
return {
|
|
478
|
+
continue: true,
|
|
479
|
+
additionalContext: evaluation.reason,
|
|
480
|
+
};
|
|
352
481
|
}
|
|
353
482
|
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
483
|
+
function installedProjectRoot() {
|
|
484
|
+
const bridgeDir = path.dirname(fileURLToPath(import.meta.url));
|
|
485
|
+
return path.resolve(bridgeDir, '../../..');
|
|
486
|
+
}
|
|
358
487
|
|
|
359
488
|
export default function hook(pi) {
|
|
360
|
-
const projectRoot = process.env.CLAUDE_PROJECT_DIR ||
|
|
361
|
-
|
|
362
|
-
pi.on('tool_call', (event) =>
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
pi.on('
|
|
489
|
+
const projectRoot = process.env.CLAUDE_PROJECT_DIR || installedProjectRoot();
|
|
490
|
+
|
|
491
|
+
pi.on('tool_call', (event, context) => {
|
|
492
|
+
return runToolCall(pi, event, { projectRoot, context }).then((result) => (
|
|
493
|
+
result.block ? { block: true, reason: result.reason } : undefined
|
|
494
|
+
));
|
|
495
|
+
});
|
|
496
|
+
pi.on('tool_result', (event, context) => runToolResult(pi, event, { projectRoot, context }));
|
|
497
|
+
pi.on('before_agent_start', (event, context) => runBeforeAgentStart(pi, event, { projectRoot, context }));
|
|
498
|
+
pi.on('session.compacting', (event, context) => runSessionCompacting(pi, event, { projectRoot, context }));
|
|
499
|
+
pi.on('session_start', (event, context) => runSessionStart(pi, event, { projectRoot, context }));
|
|
500
|
+
pi.on('session_compact', (event, context) => runSessionCompact(pi, event, { projectRoot, context }));
|
|
501
|
+
pi.on('session_stop', (event, context) => runSessionStop(pi, event, { projectRoot, context }));
|
|
368
502
|
}
|