@ngockhoale/ukit 2.4.2 → 2.5.0
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 +57 -0
- package/README.md +20 -0
- package/manifests/platform.full.yaml +51 -112
- package/package.json +2 -1
- package/scripts/index/refresh-index.mjs +47 -22
- package/src/cli/commands/doctor.js +132 -2
- package/src/cli/commands/uninstall.js +18 -0
- package/src/core/applyPlan.js +17 -2
- package/src/core/compact/threshold.js +36 -6
- package/src/core/diffPlan.js +35 -0
- package/src/core/fileOps.js +26 -0
- package/src/core/projectImportant.js +430 -0
- package/src/core/sensitiveValueScanner.js +118 -0
- package/src/core/status.js +55 -1
- package/src/core/uninstall.js +183 -3
- package/src/diagnostics/classifyHang.js +246 -0
- package/src/index/buildIndex.js +1033 -62
- package/templates/.claude/hooks/auto-allow-bash.sh +82 -93
- package/templates/.claude/hooks/block-dangerous.sh +31 -5
- package/templates/.claude/hooks/completion-gate.sh +51 -10
- package/templates/.claude/hooks/compress-output.sh +38 -6
- package/templates/.claude/hooks/context-hardcap-gate.sh +35 -6
- package/templates/.claude/hooks/context-window-guard.sh +128 -18
- package/templates/.claude/hooks/handoff-model-guard.sh +31 -5
- package/templates/.claude/hooks/handoff-resume.sh +31 -5
- package/templates/.claude/hooks/post-edit-verify.sh +31 -5
- package/templates/.claude/hooks/pre-edit-backup.sh +31 -5
- package/templates/.claude/hooks/project-important.sh +67 -0
- package/templates/.claude/hooks/protect-files.sh +31 -5
- package/templates/.claude/hooks/record-execution.sh +31 -5
- package/templates/.claude/hooks/sensitive-data-guard.sh +124 -56
- package/templates/.claude/hooks/skill-router.sh +31 -5
- package/templates/.claude/hooks/stale-spec-guard.sh +31 -5
- package/templates/.claude/hooks/task-watchdog.sh +108 -123
- package/templates/.claude/hooks/verification-guard.sh +107 -112
- package/templates/.claude/hooks/vision-router.sh +49 -13
- package/templates/.claude/settings.json +5 -5
- package/templates/.claude/ukit/index/lib/index-core.mjs +960 -63
- package/templates/.claude/ukit/index/refresh-index.mjs +47 -22
- package/templates/.claude/ukit/index/route-task.mjs +610 -4
- package/templates/.claude/ukit/runtime/async-lock.mjs +340 -0
- package/templates/.claude/ukit/runtime/compact-threshold.mjs +73 -24
- package/templates/.claude/ukit/runtime/context-capacity.mjs +144 -0
- package/templates/.claude/ukit/runtime/execution-ledger.mjs +664 -170
- package/templates/.claude/ukit/runtime/hook-chain-budget.mjs +92 -0
- package/templates/.claude/ukit/runtime/hook-chain-runner.mjs +84 -29
- package/templates/.claude/ukit/runtime/hook-input.sh +85 -5
- package/templates/.claude/ukit/runtime/hook-payload-store.mjs +160 -0
- package/templates/.claude/ukit/runtime/hook-process.mjs +250 -0
- package/templates/.claude/ukit/runtime/hook-telemetry.mjs +255 -0
- package/templates/.claude/ukit/runtime/hook-telemetry.sh +60 -0
- package/templates/.claude/ukit/runtime/project-important.mjs +381 -0
- package/templates/.claude/ukit/runtime/sensitive-value-scanner.mjs +128 -0
- package/templates/.claude/ukit/runtime/stop-coordinator.mjs +509 -0
- package/templates/.claude/ukit/runtime/task-watchdog.mjs +180 -6
- package/templates/.claude/ukit/runtime/transcript-tail.mjs +1 -1
- package/templates/.omp/hooks/pre/ukit-bridge.js +178 -61
- package/templates/AGENTS.md +8 -0
- package/templates/PROJECT_IMPORTANT.md +9 -0
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
// hook-process.mjs — process-tree-aware child runner for UKit hook chains.
|
|
2
|
+
//
|
|
3
|
+
// TASK-016 (PLAN §2 H03): `spawnSync(..., { timeout })` only signals the DIRECT
|
|
4
|
+
// child. A bash wrapper that timed out that way left every Node grandchild it
|
|
5
|
+
// had spawned alive; the recorded orphan had to be reaped by the harness. This
|
|
6
|
+
// runner closes that gap:
|
|
7
|
+
// - On POSIX the child is spawned DETACHED, so it leads its own process
|
|
8
|
+
// group and `kill(-pid)` reaches every descendant, no matter how deep.
|
|
9
|
+
// - The deadline is a bounded TERM → KILL sequence: group SIGTERM first
|
|
10
|
+
// (graceful hooks can flush), then — after a bounded grace — group SIGKILL.
|
|
11
|
+
// A descendant that escapes its group cannot be signalled by this runner, so
|
|
12
|
+
// the hard-settle backstop destroys the runner-owned streams before resolving;
|
|
13
|
+
// an escapee can never extend this runner's wall-clock bound.
|
|
14
|
+
// - The child exiting exactly as the escalation fires is the expected race:
|
|
15
|
+
// every group signal swallows ESRCH and never turns it into a failure.
|
|
16
|
+
// - On Windows there are no POSIX process groups; the degradation is
|
|
17
|
+
// explicit (GROUP_KILL_SUPPORTED=false, direct-child kill only) and never
|
|
18
|
+
// assumes group semantics.
|
|
19
|
+
//
|
|
20
|
+
// Contract (TASK-016 Interfaces):
|
|
21
|
+
// runHookProcess({command, args, input, deadlineMs, maxBuffer, ...}) ->
|
|
22
|
+
// Promise<{code, signal, stdout, stderr, failureKind, elapsedMs}>
|
|
23
|
+
// failureKind: 'none' | 'deadline' | 'signal' | 'overflow' | 'error'
|
|
24
|
+
// 'deadline' — this runner killed the child because the deadline expired
|
|
25
|
+
// 'signal' — the child died from a signal this runner did not send
|
|
26
|
+
// 'overflow' — captured output exceeded maxBuffer; the tree was culled
|
|
27
|
+
// 'error' — the child never spawned (ENOENT and friends)
|
|
28
|
+
|
|
29
|
+
import { spawn } from 'node:child_process';
|
|
30
|
+
|
|
31
|
+
// POSIX only. Windows kill() targets the direct child; process groups do not
|
|
32
|
+
// exist there, so pretending otherwise would be a silent, wrong assumption.
|
|
33
|
+
export const GROUP_KILL_SUPPORTED = process.platform !== 'win32';
|
|
34
|
+
|
|
35
|
+
// Bounded TERM → KILL grace. Long enough for a flush, short enough that a
|
|
36
|
+
// hook chain budget stays predictable.
|
|
37
|
+
export const KILL_GRACE_MS = 500;
|
|
38
|
+
|
|
39
|
+
// After the KILL escalation the child is expected to die promptly; this hard
|
|
40
|
+
// cap only exists so an unkillable (D-state) child can never hang the chain
|
|
41
|
+
// runner past its own budget.
|
|
42
|
+
const HARD_SETTLE_SLACK_MS = 2000;
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Signal a process GROUP by pid (negative-pid kill). Swallows ESRCH — the
|
|
46
|
+
* child may have exited between arming the timer and this call, or the pid may
|
|
47
|
+
* simply not be a group leader — and never assumes pid === pgid.
|
|
48
|
+
* Returns true only when the group signal was delivered.
|
|
49
|
+
*/
|
|
50
|
+
export function signalProcessTree(pid, signal) {
|
|
51
|
+
if (!GROUP_KILL_SUPPORTED || !Number.isInteger(pid) || pid <= 0) return false;
|
|
52
|
+
try {
|
|
53
|
+
process.kill(-pid, signal);
|
|
54
|
+
return true;
|
|
55
|
+
} catch (err) {
|
|
56
|
+
if (err && err.code === 'ESRCH') return false; // tree already gone — the race is harmless
|
|
57
|
+
// EPERM / sandbox oddities: fall back to the direct child, still never throw.
|
|
58
|
+
try {
|
|
59
|
+
process.kill(pid, signal);
|
|
60
|
+
return true;
|
|
61
|
+
} catch {
|
|
62
|
+
return false;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function runHookProcess({
|
|
68
|
+
command,
|
|
69
|
+
args = [],
|
|
70
|
+
input = '',
|
|
71
|
+
deadlineMs = 0,
|
|
72
|
+
maxBuffer = 2 * 1024 * 1024,
|
|
73
|
+
cwd,
|
|
74
|
+
env = process.env,
|
|
75
|
+
graceMs = KILL_GRACE_MS,
|
|
76
|
+
}) {
|
|
77
|
+
return new Promise((resolve) => {
|
|
78
|
+
const startedAt = Date.now();
|
|
79
|
+
let child;
|
|
80
|
+
try {
|
|
81
|
+
child = spawn(command, args, {
|
|
82
|
+
cwd,
|
|
83
|
+
env,
|
|
84
|
+
// Detached ONLY on POSIX: the child becomes a group leader so the whole
|
|
85
|
+
// tree dies together. Windows never gets a group assumption.
|
|
86
|
+
detached: GROUP_KILL_SUPPORTED,
|
|
87
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
88
|
+
});
|
|
89
|
+
} catch (err) {
|
|
90
|
+
resolve({
|
|
91
|
+
code: 1,
|
|
92
|
+
signal: null,
|
|
93
|
+
stdout: '',
|
|
94
|
+
stderr: String(err?.message || err),
|
|
95
|
+
failureKind: 'error',
|
|
96
|
+
elapsedMs: Date.now() - startedAt,
|
|
97
|
+
});
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const pid = child.pid;
|
|
102
|
+
let settled = false;
|
|
103
|
+
let deadlineFired = false;
|
|
104
|
+
let hardSettled = false;
|
|
105
|
+
let overflowed = false;
|
|
106
|
+
let spawnError = null;
|
|
107
|
+
let stdoutBuf = Buffer.alloc(0);
|
|
108
|
+
let stderrBuf = Buffer.alloc(0);
|
|
109
|
+
const stdoutRef = { value: stdoutBuf };
|
|
110
|
+
const stderrRef = { value: stderrBuf };
|
|
111
|
+
let deadlineTimer = null;
|
|
112
|
+
let killTimer = null;
|
|
113
|
+
let hardTimer = null;
|
|
114
|
+
|
|
115
|
+
const clearTimers = () => {
|
|
116
|
+
for (const timer of [deadlineTimer, killTimer, hardTimer]) {
|
|
117
|
+
if (timer) clearTimeout(timer);
|
|
118
|
+
}
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
const finish = () => {
|
|
122
|
+
if (settled) return;
|
|
123
|
+
settled = true;
|
|
124
|
+
clearTimers();
|
|
125
|
+
let code = child.exitCode ?? null;
|
|
126
|
+
let signal = child.signalCode ?? null;
|
|
127
|
+
if (spawnError) {
|
|
128
|
+
code = 1;
|
|
129
|
+
signal = null;
|
|
130
|
+
}
|
|
131
|
+
const failureKind = spawnError
|
|
132
|
+
? 'error'
|
|
133
|
+
: overflowed
|
|
134
|
+
? 'overflow'
|
|
135
|
+
: hardSettled
|
|
136
|
+
? 'deadline'
|
|
137
|
+
: code === null && deadlineFired
|
|
138
|
+
? 'deadline'
|
|
139
|
+
: code === null && signal
|
|
140
|
+
? 'signal'
|
|
141
|
+
: 'none';
|
|
142
|
+
resolve({
|
|
143
|
+
code,
|
|
144
|
+
signal,
|
|
145
|
+
stdout: stdoutRef.value.toString('utf8'),
|
|
146
|
+
stderr: stderrRef.value.toString('utf8'),
|
|
147
|
+
failureKind,
|
|
148
|
+
elapsedMs: Date.now() - startedAt,
|
|
149
|
+
});
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
const termTree = () => {
|
|
153
|
+
if (!signalProcessTree(pid, 'SIGTERM')) {
|
|
154
|
+
// No group to signal (non-POSIX, or the group is already gone).
|
|
155
|
+
try {
|
|
156
|
+
child.kill('SIGTERM');
|
|
157
|
+
} catch {
|
|
158
|
+
// already gone
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
};
|
|
162
|
+
const killTree = () => {
|
|
163
|
+
if (!signalProcessTree(pid, 'SIGKILL')) {
|
|
164
|
+
try {
|
|
165
|
+
child.kill('SIGKILL');
|
|
166
|
+
} catch {
|
|
167
|
+
// already gone
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
// Cull the tree the moment output can no longer be honored (spawnSync's
|
|
173
|
+
// ENOBUFS analogue): overflow is the root cause and wins the classification.
|
|
174
|
+
const cullOnOverflow = () => {
|
|
175
|
+
if (overflowed) return;
|
|
176
|
+
overflowed = true;
|
|
177
|
+
killTree();
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
const capture = (bufRef, chunk) => {
|
|
181
|
+
if (overflowed) return;
|
|
182
|
+
bufRef.value = Buffer.concat([bufRef.value, chunk]);
|
|
183
|
+
if (bufRef.value.length > maxBuffer) {
|
|
184
|
+
bufRef.value = bufRef.value.subarray(0, maxBuffer);
|
|
185
|
+
cullOnOverflow();
|
|
186
|
+
}
|
|
187
|
+
};
|
|
188
|
+
|
|
189
|
+
child.stdout.on('error', () => {});
|
|
190
|
+
child.stderr.on('error', () => {});
|
|
191
|
+
child.stdout.on('data', (chunk) => capture(stdoutRef, chunk));
|
|
192
|
+
child.stderr.on('data', (chunk) => capture(stderrRef, chunk));
|
|
193
|
+
|
|
194
|
+
// A hook may exit without draining stdin (EPIPE) — that is the hook's
|
|
195
|
+
// choice, never a runner failure.
|
|
196
|
+
child.stdin.on('error', () => {});
|
|
197
|
+
if (input) {
|
|
198
|
+
try {
|
|
199
|
+
child.stdin.write(input);
|
|
200
|
+
} catch {
|
|
201
|
+
// swallowed: mirrored by the stdin 'error' handler above
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
child.stdin.end();
|
|
205
|
+
|
|
206
|
+
// Bounded TERM → KILL. TERM first so graceful hooks can flush; KILL after
|
|
207
|
+
// the grace so nothing, however stubborn, survives the deadline. A
|
|
208
|
+
// non-positive deadlineMs means "no deadline" — the caller opted out, and
|
|
209
|
+
// no timer is armed at all.
|
|
210
|
+
const hasDeadline = Number.isFinite(deadlineMs) && deadlineMs > 0;
|
|
211
|
+
|
|
212
|
+
if (hasDeadline) {
|
|
213
|
+
deadlineTimer = setTimeout(() => {
|
|
214
|
+
deadlineFired = true;
|
|
215
|
+
termTree();
|
|
216
|
+
killTimer = setTimeout(killTree, Math.max(0, graceMs));
|
|
217
|
+
}, deadlineMs);
|
|
218
|
+
|
|
219
|
+
// Expected never to fire: close settles first. Kept as the bound that
|
|
220
|
+
// guarantees the chain runner itself can never hang.
|
|
221
|
+
hardTimer = setTimeout(() => {
|
|
222
|
+
// A descendant may have escaped the process group (for example by calling
|
|
223
|
+
// setsid()) and still hold an inherited stdout/stderr pipe open. SIGKILL on
|
|
224
|
+
// the original group cannot reach that process, and waiting for `close` would
|
|
225
|
+
// therefore defeat the wall-clock bound. The runner owns these stream objects:
|
|
226
|
+
// destroy them before settling so an escaped writer cannot keep this process
|
|
227
|
+
// alive. The result is deliberately still a deadline failure, not a clean exit.
|
|
228
|
+
hardSettled = true;
|
|
229
|
+
killTree();
|
|
230
|
+
for (const stream of [child.stdin, child.stdout, child.stderr]) {
|
|
231
|
+
try {
|
|
232
|
+
stream?.destroy();
|
|
233
|
+
} catch {
|
|
234
|
+
// Stream teardown is best-effort; the bounded result must still resolve.
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
finish();
|
|
238
|
+
}, deadlineMs + Math.max(0, graceMs) + HARD_SETTLE_SLACK_MS);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
child.on('error', (err) => {
|
|
242
|
+
// Spawn failures (ENOENT and friends) emit 'error' without 'close'.
|
|
243
|
+
if (!spawnError) spawnError = err;
|
|
244
|
+
finish();
|
|
245
|
+
});
|
|
246
|
+
child.on('close', () => {
|
|
247
|
+
finish();
|
|
248
|
+
});
|
|
249
|
+
});
|
|
250
|
+
}
|
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// hook-telemetry.mjs — shared, versioned, redacted hook latency telemetry.
|
|
3
|
+
//
|
|
4
|
+
// TASK-019: direct Claude hooks previously had no per-hook latency rows, so a
|
|
5
|
+
// slow or silent hook was indistinguishable from a gateway stall. This module
|
|
6
|
+
// is the ONE append API for both hook paths:
|
|
7
|
+
// - OMP rows: hook-chain-runner.mjs (per chain, taxonomy outcomes)
|
|
8
|
+
// - direct rows: hook-telemetry.sh + the `--finish` CLI below (per hook)
|
|
9
|
+
// Both write `.ukit/storage/cache/hook-latency/<safeName(session)>.jsonl`.
|
|
10
|
+
//
|
|
11
|
+
// Redaction contract: rows are METADATA ONLY. buildTimingRow copies a fixed
|
|
12
|
+
// allowlist of fields — payload bodies, tool_input, stdout/stderr, detected
|
|
13
|
+
// secrets, or any unknown key can never reach a row. Session ids pass through
|
|
14
|
+
// safeName (same sanitizer the chain runner has always used) so a hostile id
|
|
15
|
+
// cannot escape the telemetry directory.
|
|
16
|
+
//
|
|
17
|
+
// Advisory contract: every entry point swallows failures and returns a
|
|
18
|
+
// boolean. Telemetry must never delay, block, or change a hook verdict.
|
|
19
|
+
|
|
20
|
+
import fs from 'node:fs';
|
|
21
|
+
import path from 'node:path';
|
|
22
|
+
import { pathToFileURL } from 'node:url';
|
|
23
|
+
|
|
24
|
+
// Installed trees are frequently reached through a symlink (macOS /var ->
|
|
25
|
+
// /private/var, /tmp -> /private/tmp, versioned install dirs). Node realpathes
|
|
26
|
+
// import.meta.url but not process.argv[1], so BOTH spellings must match or the
|
|
27
|
+
// --finish CLI silently becomes a no-op — exactly what main-detection is for.
|
|
28
|
+
function invokedAsMain() {
|
|
29
|
+
if (!process.argv[1]) return false;
|
|
30
|
+
const argvPath = path.resolve(process.argv[1]);
|
|
31
|
+
if (import.meta.url === pathToFileURL(argvPath).href) return true;
|
|
32
|
+
try {
|
|
33
|
+
return import.meta.url === pathToFileURL(fs.realpathSync(argvPath)).href;
|
|
34
|
+
} catch {
|
|
35
|
+
return false;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export const TELEMETRY_VERSION = 1;
|
|
40
|
+
|
|
41
|
+
// Per-session file cap (TASK-019 boundary): rotation keeps roughly the newest
|
|
42
|
+
// half whenever an append would overflow. Env-tunable and clamped like the
|
|
43
|
+
// chain budgets (TASK-018) so operators and tests can scale it without
|
|
44
|
+
// editing code; the per-call override wins over the env value.
|
|
45
|
+
const DEFAULT_MAX_BYTES = 512 * 1024;
|
|
46
|
+
const MIN_MAX_BYTES = 1024;
|
|
47
|
+
const MAX_MAX_BYTES = 8 * 1024 * 1024;
|
|
48
|
+
|
|
49
|
+
function capBytes(override) {
|
|
50
|
+
const raw = Number(override ?? process.env.UKIT_HOOK_TELEMETRY_MAX_BYTES);
|
|
51
|
+
if (!Number.isFinite(raw) || raw <= 0) return DEFAULT_MAX_BYTES;
|
|
52
|
+
return Math.min(MAX_MAX_BYTES, Math.max(MIN_MAX_BYTES, raw));
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function safeName(value) {
|
|
56
|
+
return String(value || 'unknown').replace(/[^a-zA-Z0-9._-]/g, '_').slice(0, 96) || 'unknown';
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function shortString(value, limit) {
|
|
60
|
+
const s = String(value ?? '');
|
|
61
|
+
return s ? s.slice(0, limit) : null;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function normalizeElapsedMs(value) {
|
|
65
|
+
if (!Number.isFinite(value)) return null;
|
|
66
|
+
return Math.max(0, Math.round(value));
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Allowlist builder — the ONLY place row fields are chosen. `elapsedMs` may be
|
|
70
|
+
// null when the caller genuinely could not measure it (e.g. the hook exited
|
|
71
|
+
// before its stdin envelope was staged); unknown stays unknown, never zero.
|
|
72
|
+
export function buildTimingRow(entry = {}) {
|
|
73
|
+
return {
|
|
74
|
+
v: TELEMETRY_VERSION,
|
|
75
|
+
ts: Number.isFinite(entry.ts) ? entry.ts : Date.now(),
|
|
76
|
+
hookEvent: shortString(entry.event ?? entry.hookEvent, 64),
|
|
77
|
+
toolName: shortString(entry.tool ?? entry.toolName, 96),
|
|
78
|
+
toolUseId: shortString(entry.toolUseId, 128),
|
|
79
|
+
hook: shortString(entry.hook, 128),
|
|
80
|
+
elapsedMs: normalizeElapsedMs(entry.elapsedMs),
|
|
81
|
+
outcome: shortString(entry.outcome, 32) || 'ok',
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function telemetryDirFor(projectRoot) {
|
|
86
|
+
return path.join(projectRoot, '.ukit', 'storage', 'cache', 'hook-latency');
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Bounded work on THIS session's file only: stat, at most one read of a file
|
|
90
|
+
// already capped at maxBytes, one rewrite of the kept half. The append path
|
|
91
|
+
// never lists or sweeps the telemetry directory.
|
|
92
|
+
function rotateIfNeeded(filePath, incomingBytes, maxBytes) {
|
|
93
|
+
let size = 0;
|
|
94
|
+
try {
|
|
95
|
+
size = fs.statSync(filePath).size;
|
|
96
|
+
} catch {
|
|
97
|
+
return; // first row for this session
|
|
98
|
+
}
|
|
99
|
+
if (size + incomingBytes <= maxBytes) return;
|
|
100
|
+
try {
|
|
101
|
+
const lines = fs.readFileSync(filePath, 'utf8').split('\n');
|
|
102
|
+
if (lines.length && lines[lines.length - 1] === '') lines.pop();
|
|
103
|
+
const keepBudget = Math.floor(maxBytes / 2);
|
|
104
|
+
const keep = [];
|
|
105
|
+
let kept = 0;
|
|
106
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
107
|
+
const lineBytes = Buffer.byteLength(lines[i], 'utf8') + 1;
|
|
108
|
+
if (kept + lineBytes > keepBudget) break;
|
|
109
|
+
keep.unshift(lines[i]);
|
|
110
|
+
kept += lineBytes;
|
|
111
|
+
}
|
|
112
|
+
fs.writeFileSync(filePath, keep.length ? `${keep.join('\n')}\n` : '', 'utf8');
|
|
113
|
+
} catch {
|
|
114
|
+
// Rotation failed; drop this row rather than grow past the cap.
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export function appendTelemetryRow(projectRoot, sessionId, row, options = {}) {
|
|
119
|
+
try {
|
|
120
|
+
if (!row || typeof row !== 'object') return false;
|
|
121
|
+
const dir = telemetryDirFor(projectRoot);
|
|
122
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
123
|
+
const filePath = path.join(dir, `${safeName(sessionId)}.jsonl`);
|
|
124
|
+
const line = `${JSON.stringify(row)}\n`;
|
|
125
|
+
rotateIfNeeded(filePath, Buffer.byteLength(line, 'utf8'), capBytes(options.maxBytes));
|
|
126
|
+
fs.appendFileSync(filePath, line, 'utf8');
|
|
127
|
+
return true;
|
|
128
|
+
} catch {
|
|
129
|
+
// Advisory: an unwritable or corrupt telemetry target must never alter a
|
|
130
|
+
// hook's verdict, output, or exit status.
|
|
131
|
+
return false;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export function recordHookTiming({
|
|
136
|
+
sessionId,
|
|
137
|
+
event,
|
|
138
|
+
tool,
|
|
139
|
+
hook,
|
|
140
|
+
elapsedMs,
|
|
141
|
+
outcome,
|
|
142
|
+
toolUseId,
|
|
143
|
+
ts,
|
|
144
|
+
projectRoot,
|
|
145
|
+
maxBytes,
|
|
146
|
+
} = {}) {
|
|
147
|
+
const row = buildTimingRow({ ts, event, tool, toolUseId, hook, elapsedMs, outcome });
|
|
148
|
+
const root = projectRoot || process.env.CLAUDE_PROJECT_DIR || process.cwd();
|
|
149
|
+
return appendTelemetryRow(root, sessionId, row, { maxBytes });
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// --- `--finish` CLI: one short-lived node process per direct hook exit ------
|
|
153
|
+
// Invoked by ukit_hook_telemetry_finish (hook-telemetry.sh) from the shared
|
|
154
|
+
// cleanup path in hook-input.sh, while the staged payload file still exists.
|
|
155
|
+
|
|
156
|
+
const FINISH_PARSE_LIMIT = 2 * 1024 * 1024; // all hooks except record-execution stage <= 2 MiB
|
|
157
|
+
const FINISH_HEAD_BYTES = 256 * 1024; // bounded head scan for oversized payloads
|
|
158
|
+
|
|
159
|
+
function pickEnvelope(payload) {
|
|
160
|
+
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) return {};
|
|
161
|
+
return {
|
|
162
|
+
session_id: payload.session_id,
|
|
163
|
+
hook_event_name: payload.hook_event_name,
|
|
164
|
+
tool_name: payload.tool_name,
|
|
165
|
+
tool_use_id: payload.tool_use_id,
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// Strict shape: a value containing escapes or quotes never partially matches.
|
|
170
|
+
function envelopeField(head, name) {
|
|
171
|
+
const match = head.match(new RegExp(`"${name}"\\s*:\\s*"([^"\\\\]{1,128})"`));
|
|
172
|
+
return match ? match[1] : undefined;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function envelopeFromHead(inputFile, bytes) {
|
|
176
|
+
let head = '';
|
|
177
|
+
try {
|
|
178
|
+
const fd = fs.openSync(inputFile, 'r');
|
|
179
|
+
try {
|
|
180
|
+
const buf = Buffer.alloc(Math.min(bytes, fs.fstatSync(fd).size));
|
|
181
|
+
if (buf.length > 0) {
|
|
182
|
+
const read = fs.readSync(fd, buf, 0, buf.length, 0);
|
|
183
|
+
head = buf.subarray(0, read).toString('utf8');
|
|
184
|
+
}
|
|
185
|
+
} finally {
|
|
186
|
+
fs.closeSync(fd);
|
|
187
|
+
}
|
|
188
|
+
} catch {
|
|
189
|
+
return {};
|
|
190
|
+
}
|
|
191
|
+
return {
|
|
192
|
+
session_id: envelopeField(head, 'session_id'),
|
|
193
|
+
hook_event_name: envelopeField(head, 'hook_event_name'),
|
|
194
|
+
tool_name: envelopeField(head, 'tool_name'),
|
|
195
|
+
tool_use_id: envelopeField(head, 'tool_use_id'),
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// Full parse keeps metadata exact for normal payloads; oversized payloads
|
|
200
|
+
// (record-execution stages up to 32 MiB) stay off the finish path via the
|
|
201
|
+
// bounded head scan. Both paths feed the same allowlisted fields.
|
|
202
|
+
function readEnvelope(inputFile) {
|
|
203
|
+
let size = 0;
|
|
204
|
+
try {
|
|
205
|
+
size = fs.statSync(inputFile).size;
|
|
206
|
+
} catch {
|
|
207
|
+
return {};
|
|
208
|
+
}
|
|
209
|
+
if (size > FINISH_PARSE_LIMIT) return envelopeFromHead(inputFile, FINISH_HEAD_BYTES);
|
|
210
|
+
try {
|
|
211
|
+
return pickEnvelope(JSON.parse(fs.readFileSync(inputFile, 'utf8')));
|
|
212
|
+
} catch {
|
|
213
|
+
return envelopeFromHead(inputFile, FINISH_HEAD_BYTES);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function finishMain() {
|
|
218
|
+
try {
|
|
219
|
+
// process.uptime() covers this spawn itself so the row measures the hook,
|
|
220
|
+
// not the telemetry process startup.
|
|
221
|
+
const spawnOverheadMs = process.uptime() * 1000;
|
|
222
|
+
const inputFile = process.env.UKIT_INPUT_FILE || '';
|
|
223
|
+
let startMs = null;
|
|
224
|
+
if (inputFile) {
|
|
225
|
+
try {
|
|
226
|
+
startMs = fs.statSync(inputFile).mtimeMs;
|
|
227
|
+
} catch {
|
|
228
|
+
startMs = null; // envelope never staged (refuse path) — unknown, not zero
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
const envelope = inputFile ? readEnvelope(inputFile) : {};
|
|
232
|
+
const rc = Number.parseInt(process.env.UKIT_TEL_RC ?? '', 10);
|
|
233
|
+
// The wrapper resolves PROJECT_ROOT before arming; fall back to the hook
|
|
234
|
+
// env's CLAUDE_PROJECT_DIR, then cwd (same resolution as the wrappers).
|
|
235
|
+
recordHookTiming({
|
|
236
|
+
projectRoot: process.env.PROJECT_ROOT || process.env.CLAUDE_PROJECT_DIR || undefined,
|
|
237
|
+
sessionId: envelope.session_id,
|
|
238
|
+
event: envelope.hook_event_name,
|
|
239
|
+
tool: envelope.tool_name,
|
|
240
|
+
toolUseId: envelope.tool_use_id,
|
|
241
|
+
hook: process.env.UKIT_TEL_HOOK,
|
|
242
|
+
elapsedMs: startMs === null ? null : Date.now() - startMs - spawnOverheadMs,
|
|
243
|
+
// Direct rows reuse the chain taxonomy (TASK-018): a non-zero exit is a
|
|
244
|
+
// verdict ('exit-code'), not a guess. Timeout/overflow kills bypass the
|
|
245
|
+
// EXIT trap, so those kinds stay chain-runner-reported.
|
|
246
|
+
outcome: rc === 0 ? 'ok' : 'exit-code',
|
|
247
|
+
});
|
|
248
|
+
} catch {
|
|
249
|
+
// Advisory — never surface anything on the hook's stderr.
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
if (invokedAsMain()) {
|
|
254
|
+
if (process.argv[2] === '--finish') finishMain();
|
|
255
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# hook-telemetry.sh — advisory direct-hook latency telemetry (TASK-019).
|
|
2
|
+
#
|
|
3
|
+
# Sourced by hook wrappers right after hook-input.sh, INSIDE the successful
|
|
4
|
+
# source branch. Arming is free: no clock is read here — the staged payload
|
|
5
|
+
# file's mtime is the envelope start marker, and hook-telemetry.mjs derives
|
|
6
|
+
# elapsedMs from it at finish time.
|
|
7
|
+
#
|
|
8
|
+
# The finish marker flows through the shared runner exit path: hook-input.sh's
|
|
9
|
+
# ukit_cleanup_hook_input (already the single EXIT trap target in every
|
|
10
|
+
# wrapper) calls ukit_hook_telemetry_finish BEFORE deleting the staged
|
|
11
|
+
# payload. Finish spawns at most one bounded node process, discards all output,
|
|
12
|
+
# and never changes the hook's exit status or verdict.
|
|
13
|
+
#
|
|
14
|
+
# Missing runtime (pre-install tree) simply leaves telemetry off: wrappers
|
|
15
|
+
# source this file with `|| true` and the armed flag stays unset.
|
|
16
|
+
|
|
17
|
+
UKIT_TEL_ARMED=1
|
|
18
|
+
UKIT_TEL_HOOK="$(basename "${BASH_SOURCE[1]:-$0}")"
|
|
19
|
+
UKIT_TEL_RUNTIME_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
20
|
+
|
|
21
|
+
ukit_hook_telemetry_finish() {
|
|
22
|
+
# The hook's exit status arrives via UKIT_TEL_RC (ukit_cleanup_hook_input
|
|
23
|
+
# captured it before its own conditionals could reset $?); $? is only the
|
|
24
|
+
# fallback for direct debug invocation.
|
|
25
|
+
local rc="${UKIT_TEL_RC:-$?}"
|
|
26
|
+
command -v node >/dev/null 2>&1 || return 0
|
|
27
|
+
# UKIT_INPUT_FILE is a plain shell variable (never exported), so the staged
|
|
28
|
+
# payload path — and through it the envelope start mtime — is passed here
|
|
29
|
+
# explicitly, exactly like the wrappers pass it to their own node heredocs.
|
|
30
|
+
# The telemetry child is advisory and must never make the hook's EXIT trap
|
|
31
|
+
# unbounded. Use a portable background watchdog rather than `timeout`, which
|
|
32
|
+
# is not available on a stock macOS install. SIGKILL is intentional: the
|
|
33
|
+
# child has no hook verdict to preserve, and a wedged filesystem call may not
|
|
34
|
+
# yield to a graceful signal. The child only writes telemetry, so killing it
|
|
35
|
+
# after the deadline loses at most this row.
|
|
36
|
+
local timeout_ms timeout_s child watchdog
|
|
37
|
+
timeout_ms="${UKIT_HOOK_TELEMETRY_TIMEOUT_MS:-250}"
|
|
38
|
+
case "$timeout_ms" in
|
|
39
|
+
''|*[!0-9]*) timeout_ms=250 ;;
|
|
40
|
+
esac
|
|
41
|
+
[ "$timeout_ms" -gt 0 ] 2>/dev/null || timeout_ms=250
|
|
42
|
+
[ "$timeout_ms" -le 1000 ] 2>/dev/null || timeout_ms=1000
|
|
43
|
+
printf -v timeout_s '%d.%03d' $((timeout_ms / 1000)) $((timeout_ms % 1000))
|
|
44
|
+
|
|
45
|
+
UKIT_TEL_RC="$rc" \
|
|
46
|
+
UKIT_TEL_HOOK="$UKIT_TEL_HOOK" \
|
|
47
|
+
UKIT_INPUT_FILE="${UKIT_INPUT_FILE:-}" \
|
|
48
|
+
PROJECT_ROOT="${PROJECT_ROOT:-${CLAUDE_PROJECT_DIR:-}}" \
|
|
49
|
+
node "$UKIT_TEL_RUNTIME_DIR/hook-telemetry.mjs" --finish </dev/null >/dev/null 2>&1 &
|
|
50
|
+
child=$!
|
|
51
|
+
(
|
|
52
|
+
sleep "$timeout_s" 2>/dev/null
|
|
53
|
+
kill -9 "$child" 2>/dev/null
|
|
54
|
+
) <&- >/dev/null 2>&1 &
|
|
55
|
+
watchdog=$!
|
|
56
|
+
if wait "$child" 2>/dev/null; then :; fi
|
|
57
|
+
kill "$watchdog" 2>/dev/null || true
|
|
58
|
+
if wait "$watchdog" 2>/dev/null; then :; fi
|
|
59
|
+
return 0
|
|
60
|
+
}
|