@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 +18 -0
- package/manifests/platform.full.yaml +46 -0
- package/package.json +1 -1
- package/templates/.claude/hooks/completion-gate.sh +12 -0
- package/templates/.claude/hooks/record-execution.sh +12 -0
- package/templates/.claude/settings.json +31 -0
- package/templates/.claude/ukit/runtime/execution-ledger.mjs +354 -0
- package/templates/.claude/ukit/runtime/hook-chain-runner.mjs +115 -0
- package/templates/.omp/config.yml +3 -2
- package/templates/.omp/hooks/pre/ukit-bridge.js +309 -212
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
|
@@ -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
|
|
2
|
-
#
|
|
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
|
|
1
|
+
// ukit-bridge.js — omp extension bridge for UKit.
|
|
2
2
|
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
// 1. Maps an omp event + tool name onto the ordered list of scripts that
|
|
8
|
-
// `.claude/settings.json` would have run for the equivalent Claude Code
|
|
9
|
-
// event (see HOOK_EVENT_MAP, generated by hand from
|
|
10
|
-
// `templates/.claude/settings.json` — keep them in sync).
|
|
11
|
-
// 2. Builds the same stdin JSON payload the scripts already expect
|
|
12
|
-
// (hook_event_name, tool_name, tool_input, session_id, cwd, ...).
|
|
13
|
-
// 3. Executes each script via `pi.exec()` (never spawns/copies the script
|
|
14
|
-
// body) and translates the exit code back into an omp-shaped result.
|
|
15
|
-
//
|
|
16
|
-
// Event NAMES are verified against omp v17.4.2 (2026-08-22): its extension API
|
|
17
|
-
// registers `tool_call`, `tool_result`, `turn_start`, `session_start`,
|
|
18
|
-
// `session.compacting` and `session_compact` exactly as spelled below.
|
|
19
|
-
//
|
|
20
|
-
// `pi.exec`'s argument shape is still unverified — the contract chosen below (see
|
|
21
|
-
// the doc comments on each exported function) is internally consistent and is
|
|
22
|
-
// exercised end-to-end against a fake `pi` in tests/hooks/ompHookBridge.test.js,
|
|
23
|
-
// but nothing here has observed omp actually invoking it.
|
|
3
|
+
// The scripts under `.claude/hooks/` remain the single source of hook behavior.
|
|
4
|
+
// This adapter normalizes omp's v17.4.2 extension events into Claude Code hook
|
|
5
|
+
// payloads, executes the same ordered script chains, and translates only the
|
|
6
|
+
// result fields that omp consumes.
|
|
24
7
|
|
|
25
8
|
import path from 'node:path';
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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
|
-
'
|
|
57
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
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
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
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
|
|
142
|
+
const killed = Boolean(execResult?.killed);
|
|
143
|
+
const code = killed ? 1 : (execResult?.code ?? 0);
|
|
174
144
|
const stdout = execResult?.stdout ?? '';
|
|
175
|
-
const 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 {
|
|
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
|
-
|
|
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
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
invoked.push(scriptName);
|
|
177
|
+
const context = [];
|
|
178
|
+
if (scripts.length === 0) {
|
|
179
|
+
return { block: false, context, invoked };
|
|
180
|
+
}
|
|
220
181
|
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
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
|
-
|
|
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
|
-
|
|
231
|
-
|
|
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
|
-
|
|
235
|
-
|
|
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
|
|
235
|
+
return { block: true, reason: verdict.reason, context, invoked };
|
|
240
236
|
}
|
|
241
237
|
}
|
|
242
238
|
|
|
243
|
-
return { block: false, context
|
|
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
|
-
|
|
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
|
-
|
|
260
|
-
|
|
261
|
-
|
|
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
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
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
|
-
|
|
274
|
-
|
|
313
|
+
toolInput: normalizeToolInput(toolName, event.input),
|
|
314
|
+
toolUseId: event.toolCallId,
|
|
315
|
+
...metadata,
|
|
275
316
|
});
|
|
276
|
-
const result = await runScriptChain(pi,
|
|
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
|
|
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
|
-
|
|
287
|
-
|
|
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,
|
|
290
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
const
|
|
306
|
-
|
|
307
|
-
cwd: event.cwd,
|
|
308
|
-
transcriptPath: event.transcriptPath,
|
|
309
|
-
});
|
|
310
|
-
return runScriptChain(pi, HOOK_EVENT_MAP['session.compacting'], payload, { projectRoot });
|
|
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
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
// more than once in a session is safe (PLAN.md D11).
|
|
320
|
-
export async function runSessionStart(pi, event, { projectRoot }) {
|
|
321
|
-
const payload = buildHookPayload('SessionStart', {
|
|
322
|
-
sessionId: event.sessionId,
|
|
323
|
-
cwd: event.cwd,
|
|
324
|
-
});
|
|
325
|
-
return runScriptChain(pi, HOOK_EVENT_MAP.session_start, payload, { projectRoot });
|
|
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
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
const
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
});
|
|
351
|
-
|
|
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
|
-
|
|
356
|
-
|
|
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 ||
|
|
361
|
-
|
|
362
|
-
pi.on('tool_call', (event) =>
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
pi.on('
|
|
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
|
}
|