@ngockhoale/ukit 2.2.2 → 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.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,24 @@
2
2
 
3
3
  All notable changes to UKit are documented here.
4
4
 
5
+ ## 2.2.3 - 2026-08-26
6
+
7
+ Sessions were stalling mid-task — the model would read source, then simply stop, with the work half done and nothing wrong upstream. This wave stops treating that as a prompting problem. Instructions asking a model to "continue until the edit is made" are advisory by nature: every hidden backend behind `unic-lite` / `unic-code` / `unic-smart` / `unic-vision` reads them slightly differently, and the ones that read them loosely stall. UKit now records what actually happened — real Edit/Write receipts, real verification exit codes — and enforces completion mechanically at the point of stopping, identically on Claude Code and omp. Nothing in the enforcement path branches on provider branding.
8
+
9
+ ### Fixed
10
+
11
+ - **Silent stalls were unenforceable, because nothing measured them.** A stop after a read-only pass was indistinguishable from a stop after finished work: both were just the model ending its turn. UKit now keeps a session-scoped execution ledger (`.ukit/storage/cache/exec-ledger/<session>.json`) recording source reads, write attempts and outcomes, and verification commands with their exit codes. A new `Stop` hook (`completion-gate.sh`, and omp's `session_stop`) reads it against the routed contract's `completionEvidence` and blocks a premature stop with the specific missing evidence and a recovery instruction matched to the actual state — no source yet, failed write, no write, failed verification, or no verification each get different guidance. Recovery is capped at 6 continuations, then degrades to a warning, so the gate can never loop.
12
+ - **The omp bridge could exceed omp's own handler budget and freeze the session.** A single `Edit` fired 7 hook scripts as 7 serial `pi.exec` subprocesses at up to 8s each — worst case well past omp's ~30s outer timeout, which surfaces to the user as the session simply hanging. They now run in one bounded `hook-chain-runner.mjs` process: 4s per script, 10s total budget, under a 12s outer timeout. Per-event latency is appended to `.ukit/storage/cache/hook-latency/<session>.jsonl` so hook cost stops being invisible.
13
+ - **omp writes bypassed the safety gates entirely.** omp's `normalizeToolEventInput` emits `path` / `paths`; every UKit hook script reads `file_path`. So `protect-files.sh` and `stale-spec-guard.sh` saw no target on an omp edit and waved it through. The bridge now normalizes `path` / `paths[0]` → `file_path` at the host boundary, without mutating the caller's event.
14
+ - **An unmapped mutation-capable tool failed open and silently.** `mapToolName` returned `null` for any tool not in its table, and the bridge ran an empty script chain — so a host tool that writes files but is not yet mapped skipped every guard with no signal. Such tools are now blocked with a message naming the tool; unmapped read-only tools still pass through.
15
+ - **The ledger CLI silently did nothing under a symlinked project root.** Its main-guard compared `fileURLToPath(import.meta.url)` against `path.resolve(process.argv[1])`. Under macOS `/tmp` → `/private/tmp`, or any symlinked work directory, those differ — so `--record` and `--evaluate-stop` returned exit 0 having done nothing: no receipts, no gate. The whole unit suite stayed green; only a scratch install under `/tmp` exposed it. Both paths are now compared by realpath, covered by `tests/hooks/executionLedgerCli.test.js`.
16
+ - **`.omp/config.yml` documented the model aliases backwards**, calling the UNIC names "LITERAL model names, not aliases". They are stable provider-neutral aliases whose hidden backends change during development — which is precisely why orchestration must not branch on vendor names.
17
+
18
+ ### Added
19
+
20
+ - **`tests/hooks/executionLedgerCli.test.js`** (4 tests) — drives the real CLI through a symlinked root: receipts get written, a premature stop is blocked, a stop with write + passing verification is allowed, and the continuation cap engages at exactly 6. Verified RED against the pre-fix guard (3 of 4 fail).
21
+ - **Manifest entries and dev-mirror parity for the four new files** — `record-execution.sh`, `completion-gate.sh`, `execution-ledger.mjs`, `hook-chain-runner.mjs`. `templates/.claude/` is gitignored and existing template files were force-added, so new ones are invisible to both git and `npm pack` unless added the same way; verified present in the packed tarball, with hook scripts executable after a real `ukit install`.
22
+
5
23
  ## 2.2.2 - 2026-08-22
6
24
 
7
25
  Running Claude Code and omp side by side exposed that they were not, in fact, running the same UKit. `CLAUDE.md` and `AGENTS.md` are hand-maintained forks of one instruction set read by different harnesses, and `AGENTS.md` had fallen three minor versions behind — so omp was never told the model-tier table existed. This wave re-unifies them and adds a test that makes the drift impossible to repeat.
@@ -1037,6 +1037,30 @@ items:
1037
1037
  packs:
1038
1038
  - core
1039
1039
 
1040
+ - id: hook-record-execution
1041
+ type: hook
1042
+ sourceTemplate: .claude/hooks/record-execution.sh
1043
+ targetPath: .claude/hooks/record-execution.sh
1044
+ requires:
1045
+ - ukit-runtime-execution-ledger-script
1046
+ mergeStrategy: overwrite_with_backup
1047
+ variables: []
1048
+ enabledByDefault: true
1049
+ packs:
1050
+ - core
1051
+
1052
+ - id: hook-completion-gate
1053
+ type: hook
1054
+ sourceTemplate: .claude/hooks/completion-gate.sh
1055
+ targetPath: .claude/hooks/completion-gate.sh
1056
+ requires:
1057
+ - ukit-runtime-execution-ledger-script
1058
+ mergeStrategy: overwrite_with_backup
1059
+ variables: []
1060
+ enabledByDefault: true
1061
+ packs:
1062
+ - core
1063
+
1040
1064
  - id: hook-auto-allow-bash
1041
1065
  type: hook
1042
1066
  sourceTemplate: .claude/hooks/auto-allow-bash.sh
@@ -1223,6 +1247,28 @@ items:
1223
1247
  packs:
1224
1248
  - core
1225
1249
 
1250
+ - id: ukit-runtime-execution-ledger-script
1251
+ type: config
1252
+ sourceTemplate: .claude/ukit/runtime/execution-ledger.mjs
1253
+ targetPath: .claude/ukit/runtime/execution-ledger.mjs
1254
+ requires: []
1255
+ mergeStrategy: overwrite_with_backup
1256
+ variables: []
1257
+ enabledByDefault: true
1258
+ packs:
1259
+ - core
1260
+
1261
+ - id: ukit-runtime-hook-chain-runner-script
1262
+ type: config
1263
+ sourceTemplate: .claude/ukit/runtime/hook-chain-runner.mjs
1264
+ targetPath: .claude/ukit/runtime/hook-chain-runner.mjs
1265
+ requires: []
1266
+ mergeStrategy: overwrite_with_backup
1267
+ variables: []
1268
+ enabledByDefault: true
1269
+ packs:
1270
+ - core
1271
+
1226
1272
  - id: ukit-runtime-safe-patch-core-script
1227
1273
  type: config
1228
1274
  sourceTemplate: .claude/ukit/runtime/safe-patch-core.mjs
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ngockhoale/ukit",
3
- "version": "2.2.2",
3
+ "version": "2.2.3",
4
4
  "description": "Install/update an index-first AI workspace for Claude Code, OpenAI Codex, OpenCode, and omp (Oh My Pi).",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -0,0 +1,12 @@
1
+ #!/bin/bash
2
+ # Stop hook: block premature terminal stops while routed completion evidence is missing.
3
+
4
+ INPUT=$(cat)
5
+ PROJECT_ROOT="${CLAUDE_PROJECT_DIR:-$PWD}"
6
+ SCRIPT="$PROJECT_ROOT/.claude/ukit/runtime/execution-ledger.mjs"
7
+
8
+ if [ ! -f "$SCRIPT" ]; then
9
+ exit 0
10
+ fi
11
+
12
+ printf '%s' "$INPUT" | UKIT_HARNESS=claude-code node "$SCRIPT" --evaluate-stop
@@ -0,0 +1,12 @@
1
+ #!/bin/bash
2
+ # PostToolUse hook: persist session-scoped source/write/verification receipts.
3
+
4
+ INPUT=$(cat)
5
+ PROJECT_ROOT="${CLAUDE_PROJECT_DIR:-$PWD}"
6
+ SCRIPT="$PROJECT_ROOT/.claude/ukit/runtime/execution-ledger.mjs"
7
+
8
+ if [ ! -f "$SCRIPT" ]; then
9
+ exit 0
10
+ fi
11
+
12
+ printf '%s' "$INPUT" | UKIT_HARNESS=claude-code node "$SCRIPT" --record
@@ -139,6 +139,16 @@
139
139
  }
140
140
  ],
141
141
  "PostToolUse": [
142
+ {
143
+ "matcher": "Read|Grep|Glob",
144
+ "hooks": [
145
+ {
146
+ "type": "command",
147
+ "command": "\"$CLAUDE_PROJECT_DIR/.claude/hooks/record-execution.sh\"",
148
+ "timeout": 4
149
+ }
150
+ ]
151
+ },
142
152
  {
143
153
  "matcher": "Edit|Write",
144
154
  "hooks": [
@@ -146,6 +156,11 @@
146
156
  "type": "command",
147
157
  "command": "\"$CLAUDE_PROJECT_DIR/.claude/hooks/post-edit-verify.sh\"",
148
158
  "timeout": 8
159
+ },
160
+ {
161
+ "type": "command",
162
+ "command": "\"$CLAUDE_PROJECT_DIR/.claude/hooks/record-execution.sh\"",
163
+ "timeout": 4
149
164
  }
150
165
  ]
151
166
  },
@@ -156,6 +171,11 @@
156
171
  "type": "command",
157
172
  "command": "\"$CLAUDE_PROJECT_DIR/.claude/hooks/compress-output.sh\"",
158
173
  "timeout": 8
174
+ },
175
+ {
176
+ "type": "command",
177
+ "command": "\"$CLAUDE_PROJECT_DIR/.claude/hooks/record-execution.sh\"",
178
+ "timeout": 4
159
179
  }
160
180
  ]
161
181
  }
@@ -181,6 +201,17 @@
181
201
  ]
182
202
  }
183
203
  ],
204
+ "Stop": [
205
+ {
206
+ "hooks": [
207
+ {
208
+ "type": "command",
209
+ "command": "\"$CLAUDE_PROJECT_DIR/.claude/hooks/completion-gate.sh\"",
210
+ "timeout": 4
211
+ }
212
+ ]
213
+ }
214
+ ],
184
215
  "PreCompact": [
185
216
  {
186
217
  "hooks": [
@@ -0,0 +1,354 @@
1
+ #!/usr/bin/env node
2
+
3
+ import crypto from 'node:crypto';
4
+ import fs from 'node:fs/promises';
5
+ import fsSync from 'node:fs';
6
+ import path from 'node:path';
7
+ import { fileURLToPath } from 'node:url';
8
+
9
+ const LEDGER_VERSION = 1;
10
+ const MAX_RECEIPTS = 24;
11
+ const MAX_CONTINUATIONS = 6;
12
+ const IMPLEMENT_MODES = new Set([
13
+ 'tiny-fix',
14
+ 'local-fix',
15
+ 'local-build',
16
+ 'shared-edit',
17
+ 'find-cause',
18
+ ]);
19
+
20
+ function safeSegment(value) {
21
+ return String(value || 'default')
22
+ .trim()
23
+ .replace(/[^a-zA-Z0-9._-]/g, '_')
24
+ .slice(0, 96) || 'default';
25
+ }
26
+
27
+ function firstDefined(values) {
28
+ return values.find((value) => value !== undefined && value !== null);
29
+ }
30
+
31
+ function sessionIdentity(payload = {}) {
32
+ const sessionId = firstDefined([
33
+ payload.session_id,
34
+ payload.sessionId,
35
+ ]);
36
+ if (sessionId) return safeSegment(sessionId);
37
+
38
+ const transcriptPath = firstDefined([
39
+ payload.transcript_path,
40
+ payload.transcriptPath,
41
+ ]);
42
+ if (transcriptPath) {
43
+ return `transcript-${crypto.createHash('sha256').update(String(transcriptPath)).digest('hex').slice(0, 20)}`;
44
+ }
45
+ return 'default';
46
+ }
47
+
48
+ function ledgerPath(projectRoot, payload = {}) {
49
+ return path.join(
50
+ projectRoot,
51
+ '.ukit',
52
+ 'storage',
53
+ 'cache',
54
+ 'exec-ledger',
55
+ `${sessionIdentity(payload)}.json`,
56
+ );
57
+ }
58
+
59
+ async function readJson(filePath, fallback = null) {
60
+ try {
61
+ return JSON.parse(await fs.readFile(filePath, 'utf8'));
62
+ } catch {
63
+ return fallback;
64
+ }
65
+ }
66
+
67
+ async function writeJsonAtomic(filePath, value) {
68
+ await fs.mkdir(path.dirname(filePath), { recursive: true });
69
+ const tempPath = `${filePath}.${process.pid}.tmp`;
70
+ await fs.writeFile(tempPath, `${JSON.stringify(value, null, 2)}\n`, 'utf8');
71
+ await fs.rename(tempPath, filePath);
72
+ }
73
+
74
+ export async function readRouteState(projectRoot) {
75
+ return readJson(path.join(projectRoot, '.claude', 'ukit', 'skill-router-state.json'), null);
76
+ }
77
+
78
+ export async function readExecutionLedger(projectRoot, payload = {}) {
79
+ return readJson(ledgerPath(projectRoot, payload), null);
80
+ }
81
+
82
+ function explicitError(payload = {}) {
83
+ return [
84
+ payload.isError,
85
+ payload.is_error,
86
+ payload.tool_output?.isError,
87
+ payload.tool_output?.is_error,
88
+ payload.tool_result?.isError,
89
+ payload.tool_result?.is_error,
90
+ payload.tool_response?.isError,
91
+ payload.tool_response?.is_error,
92
+ ].some((value) => value === true);
93
+ }
94
+
95
+ function extractExitCode(payload = {}) {
96
+ const candidates = [
97
+ payload.tool_output?.exitCode,
98
+ payload.tool_output?.exit_code,
99
+ payload.tool_result?.details?.exitCode,
100
+ payload.tool_result?.details?.exit_code,
101
+ payload.tool_result?.exitCode,
102
+ payload.tool_result?.exit_code,
103
+ payload.tool_response?.exitCode,
104
+ payload.tool_response?.exit_code,
105
+ payload.exitCode,
106
+ payload.exit_code,
107
+ ];
108
+ for (const value of candidates) {
109
+ const number = Number(value);
110
+ if (Number.isFinite(number)) return number;
111
+ }
112
+ return null;
113
+ }
114
+
115
+ function isVerificationCommand(command) {
116
+ const text = String(command || '').trim();
117
+ if (!text) return false;
118
+ return [
119
+ /(?:^|\s)(?:vitest|jest|mocha|ava|pytest|py\.test)(?:\s|$)/i,
120
+ /(?:^|\s)(?:npm|pnpm|yarn|bun)(?:\s+run)?\s+(?:test|lint|typecheck|check|build)(?:\s|$)/i,
121
+ /(?:^|\s)(?:tsc|eslint|biome|ruff|mypy)(?:\s|$)/i,
122
+ /(?:^|\s)node\s+--check(?:\s|$)/i,
123
+ ].some((pattern) => pattern.test(text));
124
+ }
125
+
126
+ function compactReceipt(receipt) {
127
+ const compact = {
128
+ ts: receipt.ts,
129
+ kind: receipt.kind,
130
+ success: receipt.success,
131
+ };
132
+ for (const key of ['toolName', 'toolUseId', 'file', 'command', 'exitCode', 'error']) {
133
+ if (receipt[key] !== undefined && receipt[key] !== null && receipt[key] !== '') {
134
+ compact[key] = receipt[key];
135
+ }
136
+ }
137
+ return compact;
138
+ }
139
+
140
+ function appendReceipt(receipts, receipt) {
141
+ return [...(receipts || []), compactReceipt(receipt)].slice(-MAX_RECEIPTS);
142
+ }
143
+
144
+ function freshLedger(payload, routeState, harness) {
145
+ return {
146
+ version: LEDGER_VERSION,
147
+ sessionKey: sessionIdentity(payload),
148
+ sessionId: payload.session_id || payload.sessionId || null,
149
+ transcriptPath: payload.transcript_path || payload.transcriptPath || null,
150
+ harness,
151
+ requestKey: routeState?.requestKey || null,
152
+ routeFingerprint: routeState?.fingerprint || null,
153
+ sourceSucceeded: false,
154
+ writeAttempted: false,
155
+ writeSucceeded: false,
156
+ verificationAttempted: false,
157
+ verificationSucceeded: false,
158
+ verificationFailed: false,
159
+ receipts: [],
160
+ blocker: null,
161
+ continuationCount: 0,
162
+ updatedAt: Date.now(),
163
+ };
164
+ }
165
+
166
+ export async function recordExecutionReceipt({
167
+ projectRoot,
168
+ payload = {},
169
+ toolName = payload.tool_name,
170
+ harness = 'unknown',
171
+ } = {}) {
172
+ if (!projectRoot || !toolName) return null;
173
+ const routeState = await readRouteState(projectRoot);
174
+ const current = await readExecutionLedger(projectRoot, payload);
175
+ const ledger = !current || current.requestKey !== (routeState?.requestKey || null)
176
+ ? freshLedger(payload, routeState, harness)
177
+ : { ...current, harness: current.harness || harness };
178
+
179
+ const failed = explicitError(payload);
180
+ const exitCode = extractExitCode(payload);
181
+ const success = !failed && (exitCode === null || exitCode === 0);
182
+ const toolInput = payload.tool_input || {};
183
+ const receipt = {
184
+ ts: Date.now(),
185
+ toolName,
186
+ toolUseId: payload.tool_use_id || null,
187
+ success,
188
+ exitCode,
189
+ };
190
+
191
+ if (toolName === 'Read' || toolName === 'Grep' || toolName === 'Glob') {
192
+ receipt.kind = 'source';
193
+ receipt.file = toolInput.file_path || toolInput.path || null;
194
+ ledger.sourceSucceeded ||= success;
195
+ } else if (toolName === 'Edit' || toolName === 'Write') {
196
+ receipt.kind = 'write';
197
+ receipt.file = toolInput.file_path || toolInput.path || toolInput.paths?.[0] || null;
198
+ ledger.writeAttempted = true;
199
+ ledger.writeSucceeded ||= success;
200
+ } else if (toolName === 'Bash' && isVerificationCommand(toolInput.command)) {
201
+ receipt.kind = 'verification';
202
+ receipt.command = String(toolInput.command || '').trim();
203
+ ledger.verificationAttempted = true;
204
+ ledger.verificationSucceeded ||= success;
205
+ ledger.verificationFailed ||= !success;
206
+ } else {
207
+ return ledger;
208
+ }
209
+
210
+ ledger.receipts = appendReceipt(ledger.receipts, receipt);
211
+ ledger.updatedAt = Date.now();
212
+ await writeJsonAtomic(ledgerPath(projectRoot, payload), ledger);
213
+ return ledger;
214
+ }
215
+
216
+ function requiredEvidence(state = {}) {
217
+ const routeSummary = state?.routeSummary || {};
218
+ const contractEvidence = routeSummary.executionContract?.completionEvidence;
219
+ if (Array.isArray(contractEvidence) && contractEvidence.length > 0) {
220
+ return [...new Set(contractEvidence)];
221
+ }
222
+ return [...new Set(routeSummary.completionState?.missingEvidence || [])];
223
+ }
224
+
225
+ function evidenceSatisfied(evidence, ledger = {}) {
226
+ if (evidence === 'write-evidence') return ledger.writeSucceeded === true;
227
+ if (evidence === 'verification-evidence') return ledger.verificationSucceeded === true;
228
+ if (evidence === 'impact-evidence') return ledger.sourceSucceeded === true;
229
+ return false;
230
+ }
231
+
232
+ function recoveryInstruction(missingEvidence, ledger = {}) {
233
+ if (missingEvidence.includes('write-evidence')) {
234
+ if (!ledger.sourceSucceeded) {
235
+ return 'Pull one bounded indexed source slice, then make the requested Edit/Write in this continuation.';
236
+ }
237
+ if (ledger.writeAttempted && !ledger.writeSucceeded) {
238
+ return 'Recover from the failed mutation using the current error, then retry the smallest correct Edit/Write.';
239
+ }
240
+ return 'Make the smallest correct Edit/Write now; do not end after more read-only analysis.';
241
+ }
242
+ if (missingEvidence.includes('verification-evidence')) {
243
+ if (ledger.verificationFailed) {
244
+ return 'Use the latest failed verification output, fix the failure, and rerun targeted verification.';
245
+ }
246
+ return 'Run the routed targeted verification now and inspect its result before stopping.';
247
+ }
248
+ return 'Complete the current routed milestone before stopping.';
249
+ }
250
+
251
+ export function evaluateCompletion({ state = {}, ledger = {} } = {}) {
252
+ const routeSummary = state?.routeSummary || {};
253
+ const mode = routeSummary.executionMode || routeSummary.approachSelector?.executionMode || null;
254
+ const evidence = requiredEvidence(state);
255
+ if (!IMPLEMENT_MODES.has(mode) || evidence.length === 0 || ledger?.blocker) {
256
+ return { continue: false, missingEvidence: [] };
257
+ }
258
+
259
+ const sameRequest = !ledger?.requestKey || !state?.requestKey || ledger.requestKey === state.requestKey;
260
+ const effectiveLedger = sameRequest ? ledger : {};
261
+ const missingEvidence = evidence.filter((item) => !evidenceSatisfied(item, effectiveLedger));
262
+ if (missingEvidence.length === 0) {
263
+ return { continue: false, missingEvidence: [] };
264
+ }
265
+
266
+ const continuationCount = Number(effectiveLedger?.continuationCount || 0);
267
+ if (continuationCount >= MAX_CONTINUATIONS) {
268
+ return {
269
+ continue: false,
270
+ capped: true,
271
+ missingEvidence,
272
+ reason: `UKit continuation cap reached with missing evidence: ${missingEvidence.join(', ')}.`,
273
+ };
274
+ }
275
+
276
+ const finalAttempt = continuationCount === MAX_CONTINUATIONS - 1;
277
+ const instruction = recoveryInstruction(missingEvidence, effectiveLedger);
278
+ return {
279
+ continue: true,
280
+ missingEvidence,
281
+ reason: [
282
+ `UKit completion gate: missing ${missingEvidence.join(', ')}.`,
283
+ instruction,
284
+ finalAttempt ? 'Final automatic recovery attempt: finish now or report a concrete blocker with its evidence.' : null,
285
+ ].filter(Boolean).join(' '),
286
+ };
287
+ }
288
+
289
+ export async function incrementContinuation(projectRoot, payload = {}, ledger = null) {
290
+ const current = ledger || await readExecutionLedger(projectRoot, payload) || freshLedger(payload, null, 'unknown');
291
+ const next = {
292
+ ...current,
293
+ continuationCount: Number(current.continuationCount || 0) + 1,
294
+ lastContinuationAt: Date.now(),
295
+ updatedAt: Date.now(),
296
+ };
297
+ await writeJsonAtomic(ledgerPath(projectRoot, payload), next);
298
+ return next;
299
+ }
300
+
301
+ async function readStdin() {
302
+ if (process.stdin.isTTY) return '';
303
+ const chunks = [];
304
+ for await (const chunk of process.stdin) chunks.push(String(chunk));
305
+ return chunks.join('');
306
+ }
307
+
308
+ async function main() {
309
+ const payload = JSON.parse((await readStdin()) || '{}');
310
+ const projectRoot = process.env.CLAUDE_PROJECT_DIR || payload.cwd || process.cwd();
311
+ if (process.argv.includes('--record')) {
312
+ await recordExecutionReceipt({
313
+ projectRoot,
314
+ payload,
315
+ toolName: payload.tool_name,
316
+ harness: process.env.UKIT_HARNESS || 'claude-code',
317
+ });
318
+ return;
319
+ }
320
+ if (process.argv.includes('--evaluate-stop')) {
321
+ const state = await readRouteState(projectRoot);
322
+ const ledger = await readExecutionLedger(projectRoot, payload) || {};
323
+ const result = evaluateCompletion({ state, ledger });
324
+ if (result.continue) {
325
+ await incrementContinuation(projectRoot, payload, ledger);
326
+ process.stdout.write(`${JSON.stringify({ decision: 'block', reason: result.reason })}\n`);
327
+ } else if (result.capped) {
328
+ process.stderr.write(`[ukit-completion] ${result.reason}\n`);
329
+ }
330
+ }
331
+ }
332
+
333
+ // Compare real paths: a project under a symlinked root (macOS /tmp -> /private/tmp, or a
334
+ // symlinked work directory) makes import.meta.url resolve to the real path while argv[1]
335
+ // keeps the symlinked spelling. Without realpath the guard silently never runs the CLI.
336
+ function isDirectRun() {
337
+ const argvPath = process.argv[1];
338
+ if (!argvPath) return false;
339
+ const selfPath = fileURLToPath(import.meta.url);
340
+ const resolved = path.resolve(argvPath);
341
+ if (selfPath === resolved) return true;
342
+ try {
343
+ return fsSync.realpathSync(selfPath) === fsSync.realpathSync(resolved);
344
+ } catch {
345
+ return false;
346
+ }
347
+ }
348
+
349
+ if (isDirectRun()) {
350
+ main().catch((error) => {
351
+ process.stderr.write(`[ukit-execution-ledger] ${error?.message || error}\n`);
352
+ process.exitCode = 1;
353
+ });
354
+ }
@@ -0,0 +1,115 @@
1
+ #!/usr/bin/env node
2
+
3
+ import fs from 'node:fs';
4
+ import path from 'node:path';
5
+ import { spawnSync } from 'node:child_process';
6
+
7
+ const FAIL_CLOSED_SCRIPTS = new Set([
8
+ 'protect-files.sh',
9
+ 'stale-spec-guard.sh',
10
+ 'handoff-model-guard.sh',
11
+ 'vision-gate.sh',
12
+ 'context-hardcap-gate.sh',
13
+ 'block-dangerous.sh',
14
+ 'verification-guard.sh',
15
+ ]);
16
+
17
+ const TOTAL_BUDGET_MS = 10000;
18
+ const CHILD_BUDGET_MS = 4000;
19
+ const MAX_BUFFER_BYTES = 2 * 1024 * 1024;
20
+
21
+ function safeName(value) {
22
+ return String(value || 'unknown').replace(/[^a-zA-Z0-9._-]/g, '_').slice(0, 96) || 'unknown';
23
+ }
24
+
25
+ function recordTiming(projectRoot, payload, timing) {
26
+ try {
27
+ const dir = path.join(projectRoot, '.ukit', 'storage', 'cache', 'hook-latency');
28
+ fs.mkdirSync(dir, { recursive: true });
29
+ const filePath = path.join(dir, `${safeName(payload?.session_id)}.jsonl`);
30
+ fs.appendFileSync(filePath, `${JSON.stringify(timing)}\n`, 'utf8');
31
+ } catch {
32
+ // Timing telemetry is advisory and must never delay or block a tool call.
33
+ }
34
+ }
35
+
36
+ function run(payloadText, scriptPaths) {
37
+ const payload = JSON.parse(payloadText || '{}');
38
+ const firstScript = scriptPaths[0] || '';
39
+ const projectRoot = firstScript
40
+ ? path.resolve(path.dirname(firstScript), '../..')
41
+ : (payload.cwd || process.cwd());
42
+ const startedAt = Date.now();
43
+ const deadline = startedAt + TOTAL_BUDGET_MS;
44
+ const results = [];
45
+
46
+ for (const scriptPath of scriptPaths) {
47
+ const scriptName = path.basename(scriptPath);
48
+ const remainingMs = deadline - Date.now();
49
+ if (remainingMs <= 0) {
50
+ results.push({
51
+ scriptName,
52
+ code: 1,
53
+ stdout: '',
54
+ stderr: `hook chain exceeded its ${TOTAL_BUDGET_MS}ms total budget`,
55
+ killed: true,
56
+ elapsedMs: 0,
57
+ });
58
+ break;
59
+ }
60
+
61
+ const childStartedAt = Date.now();
62
+ const result = spawnSync(scriptPath, [], {
63
+ cwd: projectRoot,
64
+ env: { ...process.env, CLAUDE_PROJECT_DIR: projectRoot },
65
+ input: payloadText,
66
+ encoding: 'utf8',
67
+ timeout: Math.min(CHILD_BUDGET_MS, remainingMs),
68
+ maxBuffer: MAX_BUFFER_BYTES,
69
+ });
70
+ const code = Number.isFinite(result.status) ? result.status : 1;
71
+ const killed = Boolean(result.signal || result.error?.code === 'ETIMEDOUT');
72
+ const stderr = [result.stderr, result.error?.message].filter(Boolean).join('\n');
73
+ results.push({
74
+ scriptName,
75
+ code,
76
+ stdout: result.stdout || '',
77
+ stderr,
78
+ killed,
79
+ elapsedMs: Date.now() - childStartedAt,
80
+ });
81
+
82
+ if (code === 2 || killed || (code !== 0 && FAIL_CLOSED_SCRIPTS.has(scriptName))) {
83
+ break;
84
+ }
85
+ }
86
+
87
+ const elapsedMs = Date.now() - startedAt;
88
+ recordTiming(projectRoot, payload, {
89
+ ts: Date.now(),
90
+ hookEvent: payload?.hook_event_name || null,
91
+ toolName: payload?.tool_name || null,
92
+ toolUseId: payload?.tool_use_id || null,
93
+ elapsedMs,
94
+ budgetMs: TOTAL_BUDGET_MS,
95
+ scripts: results.map(({ scriptName, code, killed, elapsedMs: scriptElapsedMs }) => ({
96
+ scriptName,
97
+ code,
98
+ killed,
99
+ elapsedMs: scriptElapsedMs,
100
+ })),
101
+ });
102
+
103
+ return { results, elapsedMs, budgetMs: TOTAL_BUDGET_MS };
104
+ }
105
+
106
+ try {
107
+ const [, , payloadText = '{}', ...scriptPaths] = process.argv;
108
+ process.stdout.write(JSON.stringify(run(payloadText, scriptPaths)));
109
+ } catch (error) {
110
+ process.stdout.write(JSON.stringify({
111
+ results: [],
112
+ wrapperError: error?.message || String(error),
113
+ }));
114
+ process.exitCode = 1;
115
+ }
@@ -1,5 +1,6 @@
1
- # UNIC gateway model names are LITERAL model names, not aliases, and they ship as the
2
- # installed default. Do NOT substitute sonnet/opus/haiku here. See PLAN.md §3 D15.
1
+ # UNIC gateway model names are stable provider-neutral aliases. Their hidden backend
2
+ # models may change during development; orchestration must not branch on vendor names.
3
+ # Do NOT substitute sonnet/opus/haiku here. See PLAN.md §3 D15.
3
4
  #
4
5
  # Non-UNIC omp provider? A maintainer edits the three cost tiers to the values in
5
6
  # orchestration.modelTiers[*].claudeModel (.ukit/storage/config.json) — currently
@@ -1,35 +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
- // 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
 
25
8
  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
- // ---------------------------------------------------------------------------
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';
33
17
 
34
18
  export const HOOK_EVENT_MAP = {
35
19
  tool_call: {
@@ -53,38 +37,32 @@ export const HOOK_EVENT_MAP = {
53
37
  ],
54
38
  },
55
39
  tool_result: {
56
- 'Edit|Write': ['post-edit-verify.sh'],
57
- 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'],
58
43
  },
59
- 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'],
60
45
  'session.compacting': ['reinject-context.sh'],
61
46
  session_start: ['auto-prune-bash.sh', 'reset-compact-pressure.sh', 'handoff-resume.sh'],
62
47
  };
63
48
 
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
49
  const TOOL_NAME_MAP = {
76
50
  read: 'Read',
51
+ read_file: 'Read',
77
52
  grep: 'Grep',
78
53
  glob: 'Glob',
79
54
  edit: 'Edit',
80
55
  write: 'Write',
81
56
  ast_edit: 'Edit',
57
+ apply_patch: 'Edit',
82
58
  eval: 'Bash',
83
59
  bash: 'Bash',
60
+ shell: 'Bash',
84
61
  };
85
62
 
86
63
  export function mapToolName(ompToolName) {
87
- return TOOL_NAME_MAP[ompToolName] ?? null;
64
+ const normalized = String(ompToolName ?? '').trim().toLowerCase().replaceAll('-', '_');
65
+ return TOOL_NAME_MAP[normalized] ?? null;
88
66
  }
89
67
 
90
68
  function matcherGroupFor(claudeToolName) {
@@ -100,14 +78,6 @@ function matcherGroupFor(claudeToolName) {
100
78
  return null;
101
79
  }
102
80
 
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
81
  export const FAIL_CLOSED_SCRIPTS = new Set([
112
82
  'protect-files.sh',
113
83
  'stale-spec-guard.sh',
@@ -120,6 +90,7 @@ export const FAIL_CLOSED_SCRIPTS = new Set([
120
90
 
121
91
  export const ADVISORY_SCRIPTS = new Set([
122
92
  'skill-router.sh',
93
+ 'record-execution.sh',
123
94
  'auto-allow-bash.sh',
124
95
  'pre-edit-backup.sh',
125
96
  'vision-router.sh',
@@ -133,46 +104,47 @@ export const ADVISORY_SCRIPTS = new Set([
133
104
  ]);
134
105
 
135
106
  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';
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
+ };
141
117
  }
142
118
 
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 } = {}) {
119
+ function buildHookPayload(hookEventName, fields = {}) {
155
120
  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;
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
+ }
162
138
  return payload;
163
139
  }
164
140
 
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
141
  function translateExecResult(scriptName, execResult) {
173
- const code = execResult?.code ?? 0;
142
+ const killed = Boolean(execResult?.killed);
143
+ const code = killed ? 1 : (execResult?.code ?? 0);
174
144
  const stdout = execResult?.stdout ?? '';
175
- const stderr = execResult?.stderr ?? '';
145
+ const stderr = killed
146
+ ? (execResult?.stderr || `${scriptName} was killed before it completed`)
147
+ : (execResult?.stderr ?? '');
176
148
 
177
149
  if (code === 0) {
178
150
  return { block: false, stdout, stderr };
@@ -180,9 +152,7 @@ function translateExecResult(scriptName, execResult) {
180
152
  if (code === 2) {
181
153
  return { block: true, reason: stderr || `${scriptName} exited 2 (blocked)`, stdout, stderr };
182
154
  }
183
-
184
- const direction = classifyFailure(scriptName);
185
- if (direction === 'closed') {
155
+ if (classifyFailure(scriptName) === 'closed') {
186
156
  return {
187
157
  block: true,
188
158
  reason: stderr || `${scriptName} exited ${code} (failing closed)`,
@@ -190,179 +160,306 @@ function translateExecResult(scriptName, execResult) {
190
160
  stderr,
191
161
  };
192
162
  }
193
- 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
+ };
194
169
  }
195
170
 
196
171
  export { translateExecResult };
197
172
 
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
- */
173
+ const HOOK_CHAIN_TIMEOUT_MS = 12000;
174
+
213
175
  export async function runScriptChain(pi, scripts, payload, { projectRoot }) {
214
176
  const invoked = [];
215
- const contextParts = [];
216
-
217
- for (const scriptName of scripts) {
218
- const scriptPath = path.join(projectRoot, '.claude', 'hooks', scriptName);
219
- invoked.push(scriptName);
177
+ const context = [];
178
+ if (scripts.length === 0) {
179
+ return { block: false, context, invoked };
180
+ }
220
181
 
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
- }
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
+ }
227
194
 
228
- 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
+ }
229
206
 
230
- if (verdict.stdout && verdict.stdout.trim()) {
231
- contextParts.push(verdict.stdout.trim());
232
- }
207
+ let chainResult;
208
+ try {
209
+ chainResult = JSON.parse(execResult?.stdout || '{}');
210
+ } catch {
211
+ chainResult = { results: [], wrapperError: execResult?.stderr || 'invalid hook-chain output' };
212
+ }
233
213
 
234
- if (verdict.warning) {
235
- 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
+ };
236
223
  }
224
+ pi.logger?.warn?.(`[UKit] hook chain failed open: ${chainResult.wrapperError || execResult?.stderr || 'unknown error'}`);
225
+ }
237
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}`);
238
234
  if (verdict.block) {
239
- return { block: true, reason: verdict.reason, context: contextParts.join('\n'), invoked };
235
+ return { block: true, reason: verdict.reason, context, invoked };
240
236
  }
241
237
  }
242
238
 
243
- return { block: false, context: contextParts.join('\n'), invoked };
239
+ return { block: false, context, invoked };
244
240
  }
245
241
 
246
- // ---------------------------------------------------------------------------
247
- // Event handlers (all exported directly for test import; wired onto `pi.on`
248
- // by the default export below).
249
- // ---------------------------------------------------------------------------
250
-
251
242
  function scriptsForToolCall(claudeToolName) {
252
243
  const matcherGroup = matcherGroupFor(claudeToolName);
253
- if (!matcherGroup) return [];
254
- return HOOK_EVENT_MAP.tool_call[matcherGroup] ?? [];
244
+ return matcherGroup ? (HOOK_EVENT_MAP.tool_call[matcherGroup] ?? []) : [];
255
245
  }
256
246
 
257
247
  function scriptsForToolResult(claudeToolName) {
258
248
  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] ?? [];
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');
281
+ }
282
+
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 });
262
295
  }
263
296
 
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);
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);
270
311
  const payload = buildHookPayload('PreToolUse', {
271
312
  toolName,
272
- toolInput: event.input,
273
- sessionId: event.sessionId,
274
- cwd: event.cwd,
313
+ toolInput: normalizeToolInput(toolName, event.input),
314
+ toolUseId: event.toolCallId,
315
+ ...metadata,
275
316
  });
276
- 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');
277
319
  return { ...result, toolName };
278
320
  }
279
321
 
280
- export async function runToolResult(pi, event, { projectRoot }) {
281
- const toolName = mapToolName(event.tool);
282
- 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
+ };
283
334
  const payload = buildHookPayload('PostToolUse', {
284
335
  toolName,
285
- toolInput: event.input,
286
- sessionId: event.sessionId,
287
- 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,
288
345
  });
289
- const result = await runScriptChain(pi, scripts, payload, { projectRoot });
290
- 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
+ };
291
375
  }
292
376
 
293
- export async function runTurnStart(pi, event, { projectRoot }) {
377
+ export async function runBeforeAgentStart(pi, event, { projectRoot, context: extensionContext = {} }) {
378
+ const metadata = runtimeMetadata(event, extensionContext);
294
379
  const payload = buildHookPayload('UserPromptSubmit', {
295
- sessionId: event.sessionId,
296
- cwd: event.cwd,
297
380
  prompt: event.prompt,
381
+ ...metadata,
298
382
  });
299
- 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;
300
388
  }
301
389
 
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 });
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;
311
395
  }
312
396
 
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 });
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;
326
403
  }
327
404
 
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 });
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;
411
+ }
412
+
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
+ };
352
444
  }
353
445
 
354
- // ---------------------------------------------------------------------------
355
- // Default export -- omp hook factory contract: `export default function
356
- // hook(pi) { pi.on(event, handler) }`.
357
- // ---------------------------------------------------------------------------
446
+ function installedProjectRoot() {
447
+ const bridgeDir = path.dirname(fileURLToPath(import.meta.url));
448
+ return path.resolve(bridgeDir, '../../..');
449
+ }
358
450
 
359
451
  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 }));
452
+ const projectRoot = process.env.CLAUDE_PROJECT_DIR || installedProjectRoot();
453
+
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 }));
368
465
  }