@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.
Files changed (59) hide show
  1. package/CHANGELOG.md +57 -0
  2. package/README.md +20 -0
  3. package/manifests/platform.full.yaml +51 -112
  4. package/package.json +2 -1
  5. package/scripts/index/refresh-index.mjs +47 -22
  6. package/src/cli/commands/doctor.js +132 -2
  7. package/src/cli/commands/uninstall.js +18 -0
  8. package/src/core/applyPlan.js +17 -2
  9. package/src/core/compact/threshold.js +36 -6
  10. package/src/core/diffPlan.js +35 -0
  11. package/src/core/fileOps.js +26 -0
  12. package/src/core/projectImportant.js +430 -0
  13. package/src/core/sensitiveValueScanner.js +118 -0
  14. package/src/core/status.js +55 -1
  15. package/src/core/uninstall.js +183 -3
  16. package/src/diagnostics/classifyHang.js +246 -0
  17. package/src/index/buildIndex.js +1033 -62
  18. package/templates/.claude/hooks/auto-allow-bash.sh +82 -93
  19. package/templates/.claude/hooks/block-dangerous.sh +31 -5
  20. package/templates/.claude/hooks/completion-gate.sh +51 -10
  21. package/templates/.claude/hooks/compress-output.sh +38 -6
  22. package/templates/.claude/hooks/context-hardcap-gate.sh +35 -6
  23. package/templates/.claude/hooks/context-window-guard.sh +128 -18
  24. package/templates/.claude/hooks/handoff-model-guard.sh +31 -5
  25. package/templates/.claude/hooks/handoff-resume.sh +31 -5
  26. package/templates/.claude/hooks/post-edit-verify.sh +31 -5
  27. package/templates/.claude/hooks/pre-edit-backup.sh +31 -5
  28. package/templates/.claude/hooks/project-important.sh +67 -0
  29. package/templates/.claude/hooks/protect-files.sh +31 -5
  30. package/templates/.claude/hooks/record-execution.sh +31 -5
  31. package/templates/.claude/hooks/sensitive-data-guard.sh +124 -56
  32. package/templates/.claude/hooks/skill-router.sh +31 -5
  33. package/templates/.claude/hooks/stale-spec-guard.sh +31 -5
  34. package/templates/.claude/hooks/task-watchdog.sh +108 -123
  35. package/templates/.claude/hooks/verification-guard.sh +107 -112
  36. package/templates/.claude/hooks/vision-router.sh +49 -13
  37. package/templates/.claude/settings.json +5 -5
  38. package/templates/.claude/ukit/index/lib/index-core.mjs +960 -63
  39. package/templates/.claude/ukit/index/refresh-index.mjs +47 -22
  40. package/templates/.claude/ukit/index/route-task.mjs +610 -4
  41. package/templates/.claude/ukit/runtime/async-lock.mjs +340 -0
  42. package/templates/.claude/ukit/runtime/compact-threshold.mjs +73 -24
  43. package/templates/.claude/ukit/runtime/context-capacity.mjs +144 -0
  44. package/templates/.claude/ukit/runtime/execution-ledger.mjs +664 -170
  45. package/templates/.claude/ukit/runtime/hook-chain-budget.mjs +92 -0
  46. package/templates/.claude/ukit/runtime/hook-chain-runner.mjs +84 -29
  47. package/templates/.claude/ukit/runtime/hook-input.sh +85 -5
  48. package/templates/.claude/ukit/runtime/hook-payload-store.mjs +160 -0
  49. package/templates/.claude/ukit/runtime/hook-process.mjs +250 -0
  50. package/templates/.claude/ukit/runtime/hook-telemetry.mjs +255 -0
  51. package/templates/.claude/ukit/runtime/hook-telemetry.sh +60 -0
  52. package/templates/.claude/ukit/runtime/project-important.mjs +381 -0
  53. package/templates/.claude/ukit/runtime/sensitive-value-scanner.mjs +128 -0
  54. package/templates/.claude/ukit/runtime/stop-coordinator.mjs +509 -0
  55. package/templates/.claude/ukit/runtime/task-watchdog.mjs +180 -6
  56. package/templates/.claude/ukit/runtime/transcript-tail.mjs +1 -1
  57. package/templates/.omp/hooks/pre/ukit-bridge.js +178 -61
  58. package/templates/AGENTS.md +8 -0
  59. 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
+ }