@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.
@@ -1,35 +1,20 @@
1
- // ukit-bridge.js — omp hook bridge for UKit.
1
+ // ukit-bridge.js — omp extension bridge for UKit.
2
2
  //
3
- // Bridge, don't fork (PLAN.md D1): the 18 `.sh` scripts under
4
- // `.claude/hooks/` are the single source of truth for hook behaviour across
5
- // both Claude Code and omp. This module never re-implements their logic in
6
- // JS. It only:
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
- // Event -> script mapping (hand-derived from templates/.claude/settings.json;
29
- // keep this literally in sync with that file — case 1 in
30
- // tests/hooks/ompHookBridge.test.js re-parses settings.json and asserts
31
- // exact equality against this table).
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
- 'Edit|Write': ['post-edit-verify.sh'],
57
- Bash: ['compress-output.sh'],
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
- turn_start: ['skill-router.sh', 'vision-router.sh', 'context-window-guard.sh'],
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
- return TOOL_NAME_MAP[ompToolName] ?? null;
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
- if (FAIL_CLOSED_SCRIPTS.has(scriptName)) return 'closed';
137
- if (ADVISORY_SCRIPTS.has(scriptName)) return 'open';
138
- // Unclassified script (should not happen for the 18 known scripts): fail
139
- // open by default rather than blocking on an unknown quantity.
140
- return 'open';
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
- if (toolName !== undefined) payload.tool_name = toolName;
157
- if (toolInput !== undefined) payload.tool_input = toolInput;
158
- if (sessionId !== undefined) payload.session_id = sessionId;
159
- if (cwd !== undefined) payload.cwd = cwd;
160
- if (prompt !== undefined) payload.prompt = prompt;
161
- if (transcriptPath !== undefined) payload.transcript_path = transcriptPath;
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 code = execResult?.code ?? 0;
143
+ const killed = Boolean(execResult?.killed);
144
+ const code = killed ? 1 : (execResult?.code ?? 0);
174
145
  const stdout = execResult?.stdout ?? '';
175
- const stderr = execResult?.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 { block: false, warning: `${scriptName} exited ${code} (failing open): ${stderr || 'no stderr'}`, stdout, stderr };
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
- for (const scriptName of scripts) {
218
- const scriptPath = path.join(projectRoot, '.claude', 'hooks', scriptName);
219
- invoked.push(scriptName);
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
- let execResult;
222
- try {
223
- execResult = await pi.exec(scriptPath, { input: JSON.stringify(payload) });
224
- } catch (err) {
225
- execResult = { code: 1, stdout: '', stderr: err?.message ?? String(err) };
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
- const verdict = translateExecResult(scriptName, execResult);
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
- if (verdict.stdout && verdict.stdout.trim()) {
231
- contextParts.push(verdict.stdout.trim());
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
- if (verdict.warning) {
235
- pi.logger?.warn?.(verdict.warning);
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: contextParts.join('\n'), invoked };
266
+ return { block: true, reason: verdict.reason, context, invoked };
240
267
  }
241
268
  }
242
269
 
243
- return { block: false, context: contextParts.join('\n'), invoked };
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
- if (!matcherGroup) return [];
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
- // tool_result only has script chains for Edit|Write and Bash.
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
- * @param {object} event - { tool, input, sessionId, cwd }
266
- */
267
- export async function runToolCall(pi, event, { projectRoot }) {
268
- const toolName = mapToolName(event.tool);
269
- const scripts = scriptsForToolCall(toolName);
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
- sessionId: event.sessionId,
274
- cwd: event.cwd,
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
- const result = await runScriptChain(pi, scripts, payload, { projectRoot });
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 scripts = scriptsForToolResult(toolName);
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
- sessionId: event.sessionId,
287
- cwd: event.cwd,
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, scripts, payload, { projectRoot });
290
- return { ...result, toolName };
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 runTurnStart(pi, event, { projectRoot }) {
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
- return runScriptChain(pi, HOOK_EVENT_MAP.turn_start, payload, { projectRoot });
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
- // `session_before_compact`'s CANCEL capability is explicitly out of scope
303
- // this cycle (PLAN.md Known gaps) -- this bridge does not wire that event.
304
- export async function runSessionCompacting(pi, event, { projectRoot }) {
305
- const payload = buildHookPayload('PreCompact', {
306
- sessionId: event.sessionId,
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
- // Claude Code's `SessionStart` entry in settings.json carries NO matcher, so it
314
- // fires on every source including `compact`. Reaching that same behaviour on omp
315
- // takes two events, not one -- see runSessionCompact below.
316
- //
317
- // handoff-resume.sh is idempotent by design (reads + prints the
318
- // docs/AI_HANDOFF/RUN.md cursor, never advances state), so running this chain
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
- // RESOLVED 2026-08-22 against a real omp v17.4.2 install. PLAN.md D11 carried this
329
- // as a `TODO(verify)`: "does omp emit `session_start` on a post-compact
330
- // continuation?" It does not -- and it does not need to, because omp exposes a
331
- // dedicated post-compaction event instead. Its extension API registers
332
- // `session_before_compact` -> `session.compacting` -> `session_compact`, the last
333
- // firing after compaction settles and carrying the resulting `compactionEntry`.
334
- //
335
- // Without this handler the omp side lost the whole post-compact chain: the run
336
- // cursor stayed un-surfaced, compact pressure was never reset, and a mid-cycle
337
- // handoff silently failed to resume after a compaction -- the exact failure the
338
- // plan named. Mapping `session_compact` onto the SAME script chain restores
339
- // parity with Claude Code's matcher-less SessionStart.
340
- //
341
- // The payload keeps `hook_event_name: 'SessionStart'` deliberately: the scripts
342
- // are shared with Claude Code and know that name, and Claude Code reaches them by
343
- // the same event with `source: "compact"`. Inventing an omp-only event name here
344
- // would mean forking the scripts, which is what this bridge exists to avoid.
345
- export async function runSessionCompact(pi, event, { projectRoot }) {
346
- const payload = buildHookPayload('SessionStart', {
347
- sessionId: event.sessionId,
348
- cwd: event.cwd,
349
- source: 'compact',
350
- });
351
- return runScriptChain(pi, HOOK_EVENT_MAP.session_start, payload, { projectRoot });
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
- // Default export -- omp hook factory contract: `export default function
356
- // hook(pi) { pi.on(event, handler) }`.
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 || process.cwd();
361
-
362
- pi.on('tool_call', (event) => runToolCall(pi, event, { projectRoot }));
363
- pi.on('tool_result', (event) => runToolResult(pi, event, { projectRoot }));
364
- pi.on('turn_start', (event) => runTurnStart(pi, event, { projectRoot }));
365
- pi.on('session.compacting', (event) => runSessionCompacting(pi, event, { projectRoot }));
366
- pi.on('session_start', (event) => runSessionStart(pi, event, { projectRoot }));
367
- pi.on('session_compact', (event) => runSessionCompact(pi, event, { projectRoot }));
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
  }