@ngockhoale/ukit 2.2.1 → 2.2.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,32 +1,19 @@
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
- // omp's own `pi.exec` / `pi.on` argument shapes are UNVERIFIED (omp is not
17
- // installed in this environment, no source/schema vendored — PLAN.md D7).
18
- // The internal contract chosen below (see doc comments on each exported
19
- // function) is a reasonable, internally consistent design; it is exercised
20
- // end-to-end against a fake `pi` in tests/hooks/ompHookBridge.test.js.
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.
21
7
 
22
8
  import path from 'node:path';
23
-
24
- // ---------------------------------------------------------------------------
25
- // Event -> script mapping (hand-derived from templates/.claude/settings.json;
26
- // keep this literally in sync with that file — case 1 in
27
- // tests/hooks/ompHookBridge.test.js re-parses settings.json and asserts
28
- // exact equality against this table).
29
- // ---------------------------------------------------------------------------
9
+ import { fileURLToPath } from 'node:url';
10
+ import {
11
+ evaluateCompletion,
12
+ incrementContinuation,
13
+ readExecutionLedger,
14
+ readRouteState,
15
+ recordExecutionReceipt,
16
+ } from '../../../.claude/ukit/runtime/execution-ledger.mjs';
30
17
 
31
18
  export const HOOK_EVENT_MAP = {
32
19
  tool_call: {
@@ -50,38 +37,32 @@ export const HOOK_EVENT_MAP = {
50
37
  ],
51
38
  },
52
39
  tool_result: {
53
- 'Edit|Write': ['post-edit-verify.sh'],
54
- Bash: ['compress-output.sh'],
40
+ 'Read|Grep|Glob': ['record-execution.sh'],
41
+ 'Edit|Write': ['post-edit-verify.sh', 'record-execution.sh'],
42
+ Bash: ['compress-output.sh', 'record-execution.sh'],
55
43
  },
56
- turn_start: ['skill-router.sh', 'vision-router.sh', 'context-window-guard.sh'],
44
+ before_agent_start: ['skill-router.sh', 'vision-router.sh', 'context-window-guard.sh'],
57
45
  'session.compacting': ['reinject-context.sh'],
58
46
  session_start: ['auto-prune-bash.sh', 'reset-compact-pressure.sh', 'handoff-resume.sh'],
59
47
  };
60
48
 
61
- // ---------------------------------------------------------------------------
62
- // Tool-name mapping (PLAN.md D2).
63
- //
64
- // omp's write surface is `edit`, `write`, AND `ast_edit` — `ast_edit` must
65
- // map to the same `Edit` matcher group as `edit`/`write`, or it silently
66
- // bypasses protect-files.sh / vision-gate.sh. `eval` is omp's shell-capable
67
- // tool and must map to `Bash` to hit block-dangerous.sh / verification-guard.sh.
68
- // Any tool name not in this table maps to `null`, which runs ZERO scripts —
69
- // it must never silently fall back to Bash or Edit.
70
- // ---------------------------------------------------------------------------
71
-
72
49
  const TOOL_NAME_MAP = {
73
50
  read: 'Read',
51
+ read_file: 'Read',
74
52
  grep: 'Grep',
75
53
  glob: 'Glob',
76
54
  edit: 'Edit',
77
55
  write: 'Write',
78
56
  ast_edit: 'Edit',
57
+ apply_patch: 'Edit',
79
58
  eval: 'Bash',
80
59
  bash: 'Bash',
60
+ shell: 'Bash',
81
61
  };
82
62
 
83
63
  export function mapToolName(ompToolName) {
84
- return TOOL_NAME_MAP[ompToolName] ?? null;
64
+ const normalized = String(ompToolName ?? '').trim().toLowerCase().replaceAll('-', '_');
65
+ return TOOL_NAME_MAP[normalized] ?? null;
85
66
  }
86
67
 
87
68
  function matcherGroupFor(claudeToolName) {
@@ -97,14 +78,6 @@ function matcherGroupFor(claudeToolName) {
97
78
  return null;
98
79
  }
99
80
 
100
- // ---------------------------------------------------------------------------
101
- // Fail-direction classification (PLAN.md D3). Not a blanket rule -- each
102
- // script's own header documents its own fail direction; this table is a
103
- // transcription of those 18 headers, not an invented policy. Gate scripts
104
- // fail CLOSED (non-zero/throw => block). Advisory scripts fail OPEN
105
- // (non-zero/throw => log a warning, never block).
106
- // ---------------------------------------------------------------------------
107
-
108
81
  export const FAIL_CLOSED_SCRIPTS = new Set([
109
82
  'protect-files.sh',
110
83
  'stale-spec-guard.sh',
@@ -117,6 +90,7 @@ export const FAIL_CLOSED_SCRIPTS = new Set([
117
90
 
118
91
  export const ADVISORY_SCRIPTS = new Set([
119
92
  'skill-router.sh',
93
+ 'record-execution.sh',
120
94
  'auto-allow-bash.sh',
121
95
  'pre-edit-backup.sh',
122
96
  'vision-router.sh',
@@ -130,46 +104,47 @@ export const ADVISORY_SCRIPTS = new Set([
130
104
  ]);
131
105
 
132
106
  function classifyFailure(scriptName) {
133
- if (FAIL_CLOSED_SCRIPTS.has(scriptName)) return 'closed';
134
- if (ADVISORY_SCRIPTS.has(scriptName)) return 'open';
135
- // Unclassified script (should not happen for the 18 known scripts): fail
136
- // open by default rather than blocking on an unknown quantity.
137
- return 'open';
107
+ return FAIL_CLOSED_SCRIPTS.has(scriptName) ? 'closed' : 'open';
108
+ }
109
+
110
+ function runtimeMetadata(event = {}, context = {}) {
111
+ const sessionManager = context?.sessionManager;
112
+ return {
113
+ sessionId: event.sessionId ?? event.session_id ?? sessionManager?.getSessionId?.(),
114
+ cwd: context?.cwd ?? event.cwd,
115
+ transcriptPath: event.transcriptPath ?? event.transcript_path ?? sessionManager?.getSessionFile?.(),
116
+ };
138
117
  }
139
118
 
140
- // ---------------------------------------------------------------------------
141
- // Payload + result translation.
142
- // ---------------------------------------------------------------------------
143
-
144
- /**
145
- * Builds the same stdin JSON shape the `.sh` scripts already read via
146
- * `INPUT=$(cat)`. Fields the caller does not supply are simply omitted --
147
- * the scripts already degrade gracefully when optional fields (e.g.
148
- * transcript_path, prompt) are absent (see context-window-guard.sh, which
149
- * exits 0 immediately when transcript_path is missing).
150
- */
151
- function buildHookPayload(hookEventName, { toolName, toolInput, sessionId, cwd, prompt, transcriptPath } = {}) {
119
+ function buildHookPayload(hookEventName, fields = {}) {
152
120
  const payload = { hook_event_name: hookEventName };
153
- if (toolName !== undefined) payload.tool_name = toolName;
154
- if (toolInput !== undefined) payload.tool_input = toolInput;
155
- if (sessionId !== undefined) payload.session_id = sessionId;
156
- if (cwd !== undefined) payload.cwd = cwd;
157
- if (prompt !== undefined) payload.prompt = prompt;
158
- if (transcriptPath !== undefined) payload.transcript_path = transcriptPath;
121
+ const mappings = [
122
+ ['toolName', 'tool_name'],
123
+ ['toolInput', 'tool_input'],
124
+ ['toolOutput', 'tool_output'],
125
+ ['toolResult', 'tool_result'],
126
+ ['toolUseId', 'tool_use_id'],
127
+ ['sessionId', 'session_id'],
128
+ ['cwd', 'cwd'],
129
+ ['prompt', 'prompt'],
130
+ ['transcriptPath', 'transcript_path'],
131
+ ['source', 'source'],
132
+ ];
133
+ for (const [field, payloadField] of mappings) {
134
+ if (fields[field] !== undefined && fields[field] !== null) {
135
+ payload[payloadField] = fields[field];
136
+ }
137
+ }
159
138
  return payload;
160
139
  }
161
140
 
162
- /**
163
- * Translates a single script's `pi.exec` outcome into a bridge-internal
164
- * verdict. Exit 0 => pass. Exit 2 => this script's own explicit "block"
165
- * signal, regardless of gate/advisory class (mirrors Claude Code's own
166
- * "exit 2 = block, stderr = reason" contract). Any other non-zero exit, or
167
- * a thrown error, is resolved via the script's fail-direction classification.
168
- */
169
141
  function translateExecResult(scriptName, execResult) {
170
- const code = execResult?.code ?? 0;
142
+ const killed = Boolean(execResult?.killed);
143
+ const code = killed ? 1 : (execResult?.code ?? 0);
171
144
  const stdout = execResult?.stdout ?? '';
172
- const stderr = execResult?.stderr ?? '';
145
+ const stderr = killed
146
+ ? (execResult?.stderr || `${scriptName} was killed before it completed`)
147
+ : (execResult?.stderr ?? '');
173
148
 
174
149
  if (code === 0) {
175
150
  return { block: false, stdout, stderr };
@@ -177,9 +152,7 @@ function translateExecResult(scriptName, execResult) {
177
152
  if (code === 2) {
178
153
  return { block: true, reason: stderr || `${scriptName} exited 2 (blocked)`, stdout, stderr };
179
154
  }
180
-
181
- const direction = classifyFailure(scriptName);
182
- if (direction === 'closed') {
155
+ if (classifyFailure(scriptName) === 'closed') {
183
156
  return {
184
157
  block: true,
185
158
  reason: stderr || `${scriptName} exited ${code} (failing closed)`,
@@ -187,150 +160,306 @@ function translateExecResult(scriptName, execResult) {
187
160
  stderr,
188
161
  };
189
162
  }
190
- return { block: false, warning: `${scriptName} exited ${code} (failing open): ${stderr || 'no stderr'}`, stdout, stderr };
163
+ return {
164
+ block: false,
165
+ warning: `${scriptName} exited ${code} (failing open): ${stderr || 'no stderr'}`,
166
+ stdout,
167
+ stderr,
168
+ };
191
169
  }
192
170
 
193
171
  export { translateExecResult };
194
172
 
195
- // ---------------------------------------------------------------------------
196
- // Script chain runner -- shared by every event handler below. Keeps the
197
- // fail-direction / short-circuit / context-accumulation logic in exactly
198
- // one place.
199
- // ---------------------------------------------------------------------------
200
-
201
- /**
202
- * Runs `scripts` (basenames under `.claude/hooks/`) in order via
203
- * `pi.exec(absoluteScriptPath, { input: JSON.stringify(payload) })`,
204
- * short-circuiting on the first block. Returns
205
- * { block, reason?, context, invoked }
206
- * where `context` is the concatenation of each script's trimmed, non-empty
207
- * stdout (used by session.compacting / session_start to surface
208
- * reinject-context.sh / handoff-resume.sh output back to omp).
209
- */
173
+ const HOOK_CHAIN_TIMEOUT_MS = 12000;
174
+
210
175
  export async function runScriptChain(pi, scripts, payload, { projectRoot }) {
211
176
  const invoked = [];
212
- const contextParts = [];
213
-
214
- for (const scriptName of scripts) {
215
- const scriptPath = path.join(projectRoot, '.claude', 'hooks', scriptName);
216
- invoked.push(scriptName);
177
+ const context = [];
178
+ if (scripts.length === 0) {
179
+ return { block: false, context, invoked };
180
+ }
217
181
 
218
- let execResult;
219
- try {
220
- execResult = await pi.exec(scriptPath, { input: JSON.stringify(payload) });
221
- } catch (err) {
222
- execResult = { code: 1, stdout: '', stderr: err?.message ?? String(err) };
223
- }
182
+ const runnerPath = path.join(projectRoot, '.claude', 'ukit', 'runtime', 'hook-chain-runner.mjs');
183
+ const scriptPaths = scripts.map((scriptName) => path.join(projectRoot, '.claude', 'hooks', scriptName));
184
+ let execResult;
185
+ try {
186
+ execResult = await pi.exec(
187
+ process.execPath,
188
+ [runnerPath, JSON.stringify(payload), ...scriptPaths],
189
+ { cwd: projectRoot, timeout: HOOK_CHAIN_TIMEOUT_MS },
190
+ );
191
+ } catch (error) {
192
+ execResult = { code: 1, stdout: '', stderr: error?.message ?? String(error), killed: false };
193
+ }
224
194
 
225
- const verdict = translateExecResult(scriptName, execResult);
195
+ if (execResult?.killed) {
196
+ const firstGate = scripts.find((scriptName) => FAIL_CLOSED_SCRIPTS.has(scriptName));
197
+ return firstGate
198
+ ? {
199
+ block: true,
200
+ reason: `${firstGate} hook chain was killed before safety gates completed`,
201
+ context,
202
+ invoked,
203
+ }
204
+ : { block: false, context, invoked };
205
+ }
226
206
 
227
- if (verdict.stdout && verdict.stdout.trim()) {
228
- contextParts.push(verdict.stdout.trim());
229
- }
207
+ let chainResult;
208
+ try {
209
+ chainResult = JSON.parse(execResult?.stdout || '{}');
210
+ } catch {
211
+ chainResult = { results: [], wrapperError: execResult?.stderr || 'invalid hook-chain output' };
212
+ }
230
213
 
231
- if (verdict.warning) {
232
- pi.logger?.warn?.(verdict.warning);
214
+ if (chainResult.wrapperError || execResult?.code) {
215
+ const firstGate = scripts.find((scriptName) => FAIL_CLOSED_SCRIPTS.has(scriptName));
216
+ if (firstGate) {
217
+ return {
218
+ block: true,
219
+ reason: chainResult.wrapperError || execResult?.stderr || `${firstGate} hook chain failed`,
220
+ context,
221
+ invoked,
222
+ };
233
223
  }
224
+ pi.logger?.warn?.(`[UKit] hook chain failed open: ${chainResult.wrapperError || execResult?.stderr || 'unknown error'}`);
225
+ }
234
226
 
227
+ for (const item of chainResult.results ?? []) {
228
+ const scriptName = item?.scriptName;
229
+ if (!scriptName || !scripts.includes(scriptName)) continue;
230
+ invoked.push(scriptName);
231
+ const verdict = translateExecResult(scriptName, item);
232
+ if (verdict.stdout?.trim()) context.push(verdict.stdout.trim());
233
+ if (verdict.warning) pi.logger?.warn?.(`[UKit] ${verdict.warning}`);
235
234
  if (verdict.block) {
236
- return { block: true, reason: verdict.reason, context: contextParts.join('\n'), invoked };
235
+ return { block: true, reason: verdict.reason, context, invoked };
237
236
  }
238
237
  }
239
238
 
240
- return { block: false, context: contextParts.join('\n'), invoked };
239
+ return { block: false, context, invoked };
241
240
  }
242
241
 
243
- // ---------------------------------------------------------------------------
244
- // Event handlers (all exported directly for test import; wired onto `pi.on`
245
- // by the default export below).
246
- // ---------------------------------------------------------------------------
247
-
248
242
  function scriptsForToolCall(claudeToolName) {
249
243
  const matcherGroup = matcherGroupFor(claudeToolName);
250
- if (!matcherGroup) return [];
251
- return HOOK_EVENT_MAP.tool_call[matcherGroup] ?? [];
244
+ return matcherGroup ? (HOOK_EVENT_MAP.tool_call[matcherGroup] ?? []) : [];
252
245
  }
253
246
 
254
247
  function scriptsForToolResult(claudeToolName) {
255
248
  const matcherGroup = matcherGroupFor(claudeToolName);
256
- // tool_result only has script chains for Edit|Write and Bash.
257
- if (matcherGroup !== 'Edit|Write' && matcherGroup !== 'Bash') return [];
258
- return HOOK_EVENT_MAP.tool_result[matcherGroup] ?? [];
249
+ return matcherGroup ? (HOOK_EVENT_MAP.tool_result[matcherGroup] ?? []) : [];
250
+ }
251
+
252
+ function normalizeToolInput(toolName, input) {
253
+ if (!input || typeof input !== 'object' || Array.isArray(input)) return input ?? {};
254
+ const normalized = { ...input };
255
+ if ((toolName === 'Edit' || toolName === 'Write') && !normalized.file_path) {
256
+ const paths = Array.isArray(normalized.paths) ? normalized.paths.filter(Boolean) : [];
257
+ normalized.file_path = normalized.path || paths[0] || undefined;
258
+ }
259
+ return normalized;
260
+ }
261
+
262
+ function looksMutationCapable(input) {
263
+ if (!input || typeof input !== 'object' || Array.isArray(input)) return false;
264
+ const hasTarget = Boolean(input.file_path || input.path || (Array.isArray(input.paths) && input.paths.length));
265
+ const hasMutation = [
266
+ 'content',
267
+ 'new_string',
268
+ 'old_string',
269
+ 'patch',
270
+ 'replacement',
271
+ 'edits',
272
+ ].some((key) => input[key] !== undefined);
273
+ return hasTarget && hasMutation;
274
+ }
275
+
276
+ function textFromContent(content) {
277
+ return (Array.isArray(content) ? content : [])
278
+ .filter((item) => item?.type === 'text' && typeof item.text === 'string')
279
+ .map((item) => item.text)
280
+ .join('\n');
259
281
  }
260
282
 
261
- /**
262
- * @param {object} event - { tool, input, sessionId, cwd }
263
- */
264
- export async function runToolCall(pi, event, { projectRoot }) {
265
- const toolName = mapToolName(event.tool);
266
- const scripts = scriptsForToolCall(toolName);
283
+ function hookContextMessage(content) {
284
+ return {
285
+ customType: 'ukit-hook-context',
286
+ content,
287
+ display: false,
288
+ };
289
+ }
290
+
291
+ function sendContext(pi, context, deliverAs) {
292
+ const content = context.filter(Boolean).join('\n').trim();
293
+ if (!content || typeof pi.sendMessage !== 'function') return;
294
+ pi.sendMessage(hookContextMessage(content), { deliverAs });
295
+ }
296
+
297
+ export async function runToolCall(pi, event, { projectRoot, context: extensionContext = {} }) {
298
+ const rawToolName = event.toolName ?? event.tool;
299
+ const toolName = mapToolName(rawToolName);
300
+ if (!toolName && looksMutationCapable(event.input)) {
301
+ return {
302
+ block: true,
303
+ reason: `UKit blocked unmapped mutation-capable tool "${rawToolName}"; add a host adapter mapping before retrying.`,
304
+ context: [],
305
+ invoked: [],
306
+ toolName,
307
+ };
308
+ }
309
+
310
+ const metadata = runtimeMetadata(event, extensionContext);
267
311
  const payload = buildHookPayload('PreToolUse', {
268
312
  toolName,
269
- toolInput: event.input,
270
- sessionId: event.sessionId,
271
- cwd: event.cwd,
313
+ toolInput: normalizeToolInput(toolName, event.input),
314
+ toolUseId: event.toolCallId,
315
+ ...metadata,
272
316
  });
273
- const result = await runScriptChain(pi, scripts, payload, { projectRoot });
317
+ const result = await runScriptChain(pi, scriptsForToolCall(toolName), payload, { projectRoot });
318
+ if (!result.block) sendContext(pi, result.context, 'steer');
274
319
  return { ...result, toolName };
275
320
  }
276
321
 
277
- export async function runToolResult(pi, event, { projectRoot }) {
278
- const toolName = mapToolName(event.tool);
279
- const scripts = scriptsForToolResult(toolName);
322
+ export async function runToolResult(pi, event, { projectRoot, context: extensionContext = {} }) {
323
+ const toolName = mapToolName(event.toolName ?? event.tool);
324
+ const metadata = runtimeMetadata(event, extensionContext);
325
+ const outputText = textFromContent(event.content);
326
+ const toolOutput = {
327
+ ...(event.details && typeof event.details === 'object' ? event.details : {}),
328
+ output: outputText,
329
+ stdout: outputText,
330
+ exitCode: event.details?.exitCode,
331
+ isError: Boolean(event.isError),
332
+ details: event.details,
333
+ };
280
334
  const payload = buildHookPayload('PostToolUse', {
281
335
  toolName,
282
- toolInput: event.input,
283
- sessionId: event.sessionId,
284
- cwd: event.cwd,
336
+ toolInput: normalizeToolInput(toolName, event.input),
337
+ toolOutput,
338
+ toolResult: {
339
+ content: event.content,
340
+ details: event.details,
341
+ isError: Boolean(event.isError),
342
+ },
343
+ toolUseId: event.toolCallId,
344
+ ...metadata,
285
345
  });
286
- const result = await runScriptChain(pi, scripts, payload, { projectRoot });
287
- return { ...result, toolName };
346
+ const result = await runScriptChain(pi, scriptsForToolResult(toolName), payload, { projectRoot });
347
+ try {
348
+ await recordExecutionReceipt({
349
+ projectRoot,
350
+ payload,
351
+ toolName,
352
+ harness: 'omp',
353
+ });
354
+ } catch (error) {
355
+ pi.logger?.warn?.(`[UKit] execution receipt failed open: ${error?.message || error}`);
356
+ }
357
+
358
+ const hookOutput = result.context.join('\n').trim();
359
+ if (!hookOutput) return undefined;
360
+ if (toolName === 'Bash') {
361
+ return {
362
+ content: [{ type: 'text', text: hookOutput }],
363
+ details: event.details,
364
+ isError: Boolean(event.isError),
365
+ };
366
+ }
367
+ return {
368
+ content: [
369
+ ...(Array.isArray(event.content) ? event.content : []),
370
+ { type: 'text', text: hookOutput },
371
+ ],
372
+ details: event.details,
373
+ isError: Boolean(event.isError),
374
+ };
288
375
  }
289
376
 
290
- export async function runTurnStart(pi, event, { projectRoot }) {
377
+ export async function runBeforeAgentStart(pi, event, { projectRoot, context: extensionContext = {} }) {
378
+ const metadata = runtimeMetadata(event, extensionContext);
291
379
  const payload = buildHookPayload('UserPromptSubmit', {
292
- sessionId: event.sessionId,
293
- cwd: event.cwd,
294
380
  prompt: event.prompt,
381
+ ...metadata,
295
382
  });
296
- return runScriptChain(pi, HOOK_EVENT_MAP.turn_start, payload, { projectRoot });
383
+ const result = await runScriptChain(pi, HOOK_EVENT_MAP.before_agent_start, payload, { projectRoot });
384
+ const parts = [...result.context];
385
+ if (result.block && result.reason) parts.push(result.reason);
386
+ const content = parts.join('\n').trim();
387
+ return content ? { message: hookContextMessage(content) } : undefined;
297
388
  }
298
389
 
299
- // `session_before_compact`'s CANCEL capability is explicitly out of scope
300
- // this cycle (PLAN.md Known gaps) -- this bridge does not wire that event.
301
- export async function runSessionCompacting(pi, event, { projectRoot }) {
302
- const payload = buildHookPayload('PreCompact', {
303
- sessionId: event.sessionId,
304
- cwd: event.cwd,
305
- transcriptPath: event.transcriptPath,
306
- });
307
- return runScriptChain(pi, HOOK_EVENT_MAP['session.compacting'], payload, { projectRoot });
390
+ export async function runSessionCompacting(pi, event, { projectRoot, context: extensionContext = {} }) {
391
+ const metadata = runtimeMetadata(event, extensionContext);
392
+ const payload = buildHookPayload('PreCompact', metadata);
393
+ const result = await runScriptChain(pi, HOOK_EVENT_MAP['session.compacting'], payload, { projectRoot });
394
+ return result.context.length > 0 ? { context: result.context } : undefined;
308
395
  }
309
396
 
310
- // Must run on EVERY session start, including a post-compact continuation --
311
- // no "first start only" guard. handoff-resume.sh is idempotent by design
312
- // (reads + prints the docs/AI_HANDOFF/RUN.md cursor, never advances state),
313
- // so re-running it on every start is safe and required for the mid-cycle
314
- // handoff run to resume correctly after a compaction (PLAN.md D11).
315
- export async function runSessionStart(pi, event, { projectRoot }) {
316
- const payload = buildHookPayload('SessionStart', {
317
- sessionId: event.sessionId,
318
- cwd: event.cwd,
319
- });
320
- return runScriptChain(pi, HOOK_EVENT_MAP.session_start, payload, { projectRoot });
397
+ export async function runSessionStart(pi, event, { projectRoot, context: extensionContext = {} }) {
398
+ const metadata = runtimeMetadata(event, extensionContext);
399
+ const payload = buildHookPayload('SessionStart', metadata);
400
+ const result = await runScriptChain(pi, HOOK_EVENT_MAP.session_start, payload, { projectRoot });
401
+ sendContext(pi, result.context, 'nextTurn');
402
+ return result;
403
+ }
404
+
405
+ export async function runSessionCompact(pi, event, { projectRoot, context: extensionContext = {} }) {
406
+ const metadata = runtimeMetadata(event, extensionContext);
407
+ const payload = buildHookPayload('SessionStart', { ...metadata, source: 'compact' });
408
+ const result = await runScriptChain(pi, HOOK_EVENT_MAP.session_start, payload, { projectRoot });
409
+ sendContext(pi, result.context, 'nextTurn');
410
+ return undefined;
321
411
  }
322
412
 
323
- // ---------------------------------------------------------------------------
324
- // Default export -- omp hook factory contract: `export default function
325
- // hook(pi) { pi.on(event, handler) }`.
326
- // ---------------------------------------------------------------------------
413
+ export async function runSessionStop(
414
+ pi,
415
+ event,
416
+ {
417
+ projectRoot,
418
+ context: extensionContext = {},
419
+ state: suppliedState,
420
+ ledger: suppliedLedger,
421
+ },
422
+ ) {
423
+ const metadata = runtimeMetadata(event, extensionContext);
424
+ const payload = buildHookPayload('Stop', metadata);
425
+ const state = suppliedState ?? await readRouteState(projectRoot);
426
+ const ledger = suppliedLedger ?? await readExecutionLedger(projectRoot, payload) ?? {};
427
+ const evaluation = evaluateCompletion({ state, ledger });
428
+ if (!evaluation.continue) {
429
+ if (evaluation.capped) pi.logger?.warn?.(`[UKit] ${evaluation.reason}`);
430
+ return undefined;
431
+ }
432
+
433
+ if (suppliedLedger === undefined) {
434
+ try {
435
+ await incrementContinuation(projectRoot, payload, ledger);
436
+ } catch (error) {
437
+ pi.logger?.warn?.(`[UKit] continuation bookkeeping failed open: ${error?.message || error}`);
438
+ }
439
+ }
440
+ return {
441
+ continue: true,
442
+ additionalContext: evaluation.reason,
443
+ };
444
+ }
445
+
446
+ function installedProjectRoot() {
447
+ const bridgeDir = path.dirname(fileURLToPath(import.meta.url));
448
+ return path.resolve(bridgeDir, '../../..');
449
+ }
327
450
 
328
451
  export default function hook(pi) {
329
- const projectRoot = process.env.CLAUDE_PROJECT_DIR || process.cwd();
452
+ const projectRoot = process.env.CLAUDE_PROJECT_DIR || installedProjectRoot();
330
453
 
331
- pi.on('tool_call', (event) => runToolCall(pi, event, { projectRoot }));
332
- pi.on('tool_result', (event) => runToolResult(pi, event, { projectRoot }));
333
- pi.on('turn_start', (event) => runTurnStart(pi, event, { projectRoot }));
334
- pi.on('session.compacting', (event) => runSessionCompacting(pi, event, { projectRoot }));
335
- pi.on('session_start', (event) => runSessionStart(pi, event, { projectRoot }));
454
+ pi.on('tool_call', (event, context) => {
455
+ return runToolCall(pi, event, { projectRoot, context }).then((result) => (
456
+ result.block ? { block: true, reason: result.reason } : undefined
457
+ ));
458
+ });
459
+ pi.on('tool_result', (event, context) => runToolResult(pi, event, { projectRoot, context }));
460
+ pi.on('before_agent_start', (event, context) => runBeforeAgentStart(pi, event, { projectRoot, context }));
461
+ pi.on('session.compacting', (event, context) => runSessionCompacting(pi, event, { projectRoot, context }));
462
+ pi.on('session_start', (event, context) => runSessionStart(pi, event, { projectRoot, context }));
463
+ pi.on('session_compact', (event, context) => runSessionCompact(pi, event, { projectRoot, context }));
464
+ pi.on('session_stop', (event, context) => runSessionStop(pi, event, { projectRoot, context }));
336
465
  }