@ngockhoale/ukit 2.7.6 → 2.7.7

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.
@@ -97,6 +97,20 @@ function chainFailureKind(processResult) {
97
97
  }
98
98
  }
99
99
 
100
+ // B1 (TASK-001, SPEC §FR-001): a child that emits a `hookSpecificOutput`
101
+ // permission decision owns the verdict. `hookSpecificOutput` alone is NOT
102
+ // enough — a child killed mid-write can carry the marker in a truncated
103
+ // capture, and calling that a decision would exempt a genuinely-skipped gate
104
+ // from the fail-closed verdict. A real decision is a clean exit-0 verdict.
105
+ function emitsPermissionDecision(entry) {
106
+ return Boolean(entry)
107
+ && !entry.killed
108
+ && entry.code === 0
109
+ && entry.failureKind === 'ok'
110
+ && typeof entry.stdout === 'string'
111
+ && entry.stdout.includes('"hookSpecificOutput"');
112
+ }
113
+
100
114
  function recordTiming(projectRoot, payload, timing) {
101
115
  // Timing telemetry is advisory and must never delay or block a tool call;
102
116
  // appendTelemetryRow carries the same posture (and the per-session cap).
@@ -109,12 +123,37 @@ function recordTiming(projectRoot, payload, timing) {
109
123
  // A `:0` or negative suffix is rejected (falls back) — zero would mean "no
110
124
  // budget", which silently disables the deadline; that is never a valid hook
111
125
  // contract.
126
+ // TASK-001 (C8 / SPEC §FR-006): "rejected silently" was the bug. A `:0`/`: -1`
127
+ // typo used to leave the suffix on the path, so the child never spawned and the
128
+ // hook silently vanished from the chain. It now warns (never hard-fails — a
129
+ // config typo must not block every tool call in a fail-closed chain) and runs
130
+ // the script under the DEFAULT child budget.
131
+ const TIMEOUT_SUFFIX_RE = /^(.*):(-?\d+(?:\.\d+)?)$/;
132
+
133
+ // The ONE suffix stripper. Identity checks that need a script's declared name
134
+ // (fail-closed lookups, skipped-gate reporting) go through it too: a `:-1` arg
135
+ // must not look like an advisory script there while `parseScriptArg` already
136
+ // normalized it to a gate for execution.
137
+ function stripTimeoutSuffix(arg) {
138
+ const match = TIMEOUT_SUFFIX_RE.exec(String(arg ?? ''));
139
+ return match ? match[1] : String(arg ?? '');
140
+ }
141
+
142
+ function failClosedName(arg) {
143
+ return path.basename(stripTimeoutSuffix(arg));
144
+ }
145
+
112
146
  function parseScriptArg(arg) {
113
- const match = /^(.*):(\d+(?:\.\d+)?)$/.exec(arg || '');
114
- if (!match) return { scriptPath: arg, timeoutMs: null };
115
- const seconds = Number(match[2]);
116
- if (!Number.isFinite(seconds) || seconds <= 0) return { scriptPath: arg, timeoutMs: null };
117
- return { scriptPath: match[1], timeoutMs: Math.round(seconds * 1000) };
147
+ const suffix = TIMEOUT_SUFFIX_RE.exec(String(arg ?? ''));
148
+ if (!suffix) return { scriptPath: arg, timeoutMs: null };
149
+ const seconds = Number(suffix[2]);
150
+ if (!Number.isFinite(seconds) || seconds <= 0) {
151
+ process.stderr.write(
152
+ `[ukit] ignoring invalid timeout suffix "${arg}" — using default child budget\n`,
153
+ );
154
+ return { scriptPath: suffix[1], timeoutMs: null };
155
+ }
156
+ return { scriptPath: suffix[1], timeoutMs: Math.round(seconds * 1000) };
118
157
  }
119
158
 
120
159
  // TASK-001 (SPEC §FR-001, §8): a chain arg ending in `.mjs` is an IN-PROC step —
@@ -178,7 +217,7 @@ async function runModuleStep({ scriptPath, payload, payloadText, projectRoot, en
178
217
  }
179
218
  }
180
219
 
181
- async function run(payloadText, scriptArgs, { chainMarker = true } = {}) {
220
+ async function run(payloadText, scriptArgs, { chainMarker = true, decisionShortCircuit = false, stdinStageMs = null } = {}) {
182
221
  const parsedArgs = scriptArgs.map(parseScriptArg);
183
222
  const scriptPaths = parsedArgs.map((a) => a.scriptPath);
184
223
  const payload = JSON.parse(payloadText || '{}');
@@ -224,7 +263,7 @@ async function run(payloadText, scriptArgs, { chainMarker = true } = {}) {
224
263
  const deadline = startedAt + totalBudgetMs;
225
264
  const results = [];
226
265
  let budgetExhausted = false;
227
-
266
+ let decisionEmitted = false;
228
267
  for (let scriptIndex = 0; scriptIndex < scriptPaths.length; scriptIndex++) {
229
268
  const scriptPath = scriptPaths[scriptIndex];
230
269
  const scriptName = path.basename(scriptPath);
@@ -321,7 +360,20 @@ async function run(payloadText, scriptArgs, { chainMarker = true } = {}) {
321
360
  });
322
361
  }
323
362
 
324
- if (code === 2 || killed || (code !== 0 && FAIL_CLOSED_SCRIPTS.has(scriptName))) {
363
+ // B1 (TASK-001, SPEC §FR-001): under `--emit-verdict` a child that emits a
364
+ // `hookSpecificOutput` permission decision owns the verdict even at exit 0
365
+ // (the direct Claude contract: deny + exit 0 still blocks). The chain stops
366
+ // HERE, before the next child spawns — filtering the decision at emit time
367
+ // instead would still let the next script's side effects run (e.g.
368
+ // pre-edit-backup.sh backing up a file whose edit was just denied) and its
369
+ // stdout would be concatenated in front of the decision JSON, which Claude
370
+ // Code rejects as invalid JSON. The predicate is shared with the emit-time
371
+ // lookup so a truncated capture can never count as a decision here.
372
+
373
+ if (decisionShortCircuit && emitsPermissionDecision(results[results.length - 1])) {
374
+ decisionEmitted = true;
375
+ }
376
+ if (decisionEmitted || code === 2 || killed || (code !== 0 && FAIL_CLOSED_SCRIPTS.has(scriptName))) {
325
377
  break;
326
378
  }
327
379
  }
@@ -331,9 +383,12 @@ async function run(payloadText, scriptArgs, { chainMarker = true } = {}) {
331
383
  // of those unrun scripts is fail-closed, the chain must fail CLOSED — the old
332
384
  // per-script path ran every hook independently, so a timed-out advisory never
333
385
  // skipped a gate. `skippedFailClosed` carries that signal to the verdict.
334
- const skippedFailClosed = scriptPaths
386
+ // ... unless this break WAS the decision: a decision owns the verdict, so the
387
+ // unrun gates are not a fail-closed gap (SPEC §FR-001 — the verdict must stay
388
+ // exit 0 with the decision JSON).
389
+ const skippedFailClosed = !decisionEmitted && scriptPaths
335
390
  .slice(results.length)
336
- .some((p) => FAIL_CLOSED_SCRIPTS.has(path.basename(p)));
391
+ .some((p) => FAIL_CLOSED_SCRIPTS.has(failClosedName(p)));
337
392
 
338
393
  const elapsedMs = Date.now() - startedAt;
339
394
  // TASK-019: versioned rows shared with direct hooks. `outcome` reuses this
@@ -350,6 +405,18 @@ async function run(payloadText, scriptArgs, { chainMarker = true } = {}) {
350
405
  toolName: payload?.tool_name || null,
351
406
  toolUseId: payload?.tool_use_id || null,
352
407
  elapsedMs,
408
+ // O3 (SPEC §FR-009): the row's wall time split into the stdin stage — the
409
+ // bounded read this process waits on while the producer writes — and the
410
+ // chain's own execution. A producer holding the pipe open is then
411
+ // attributable instead of looking like a slow hook (the reported artifact:
412
+ // project-important.sh p95 = 1539ms with no row doing real work ≥ 1s).
413
+ // Clamped to `elapsedMs` because the split describes the row it sits on
414
+ // (the two are measured from different origins). Absent — not 0 and not
415
+ // null — when this invocation never read stdin, so a v1 reader sees the
416
+ // row it always saw.
417
+ ...(stdinStageMs === null
418
+ ? {}
419
+ : { stdinStageMs: Math.min(Math.max(0, Math.round(stdinStageMs)), elapsedMs) }),
353
420
  budgetMs: totalBudgetMs,
354
421
  budgetExhausted,
355
422
  scripts: results.map(({ scriptName, code, killed, failureKind, elapsedMs: scriptElapsedMs }) => ({
@@ -358,6 +425,12 @@ async function run(payloadText, scriptArgs, { chainMarker = true } = {}) {
358
425
  killed,
359
426
  failureKind,
360
427
  elapsedMs: scriptElapsedMs,
428
+ // TASK-007 (SPEC §FR-012): which steps the runner treated as gates. The
429
+ // doctor reads this flag instead of importing FAIL_CLOSED_SCRIPTS — the
430
+ // runner is the only component that knows which paths it gated, and a
431
+ // second copy of a security-relevant list is a drift hazard. Additive and
432
+ // optional: v1 readers that read the five original keys keep working.
433
+ failClosed: FAIL_CLOSED_SCRIPTS.has(scriptName),
361
434
  })),
362
435
  });
363
436
 
@@ -384,8 +457,14 @@ try {
384
457
  // '-' reads the payload from stdin — the form Claude Code hook commands use.
385
458
  let payloadText = payloadArg;
386
459
  let stdinTruncated = false;
460
+ // O3: the stage window is measured HERE, around the bounded read, and not
461
+ // inside run() — run() starts after the payload is already in hand, and a
462
+ // truncated read exits before run() is ever called.
463
+ let stdinStageMs = null;
387
464
  if (payloadArg === '-') {
465
+ const stageStartedAt = Date.now();
388
466
  const staged = await readStdinBounded();
467
+ stdinStageMs = Date.now() - stageStartedAt;
389
468
  payloadText = staged.text;
390
469
  stdinTruncated = staged.truncated;
391
470
  } else if (payloadArg.startsWith('@')) {
@@ -402,8 +481,7 @@ try {
402
481
  // chain carries a fail-closed gate, emit deny now instead of letting gates
403
482
  // pass on a payload they never fully received.
404
483
  if (stdinTruncated) {
405
- const hasFailClosed = scriptPaths.some((p) =>
406
- FAIL_CLOSED_SCRIPTS.has(path.basename(String(p).replace(/:\d+(?:\.\d+)?$/, ''))));
484
+ const hasFailClosed = scriptPaths.some((p) => FAIL_CLOSED_SCRIPTS.has(failClosedName(p)));
407
485
  if (hasFailClosed) {
408
486
  const deny = JSON.stringify({
409
487
  hookSpecificOutput: {
@@ -427,7 +505,13 @@ try {
427
505
  process.exit(emitVerdict ? 0 : 2);
428
506
  }
429
507
  }
430
- const chain = await run(payloadText, scriptPaths, { chainMarker: !emitVerdict });
508
+ const chain = await run(payloadText, scriptPaths, {
509
+ chainMarker: !emitVerdict,
510
+ // B1: only the --emit-verdict contract short-circuits on a decision; the
511
+ // omp bridge parses the JSON envelope and needs every step's context stdout.
512
+ decisionShortCircuit: emitVerdict,
513
+ stdinStageMs,
514
+ });
431
515
  if (!emitVerdict) {
432
516
  process.stdout.write(JSON.stringify(chain));
433
517
  } else {
@@ -435,10 +519,21 @@ try {
435
519
  // verdict even when it exits 0 (the direct Claude contract: deny + exit 0
436
520
  // still blocks). The first decision wins, matching per-script semantics
437
521
  // where each hook's output is its own verdict and a deny short-circuits.
522
+ // The loose lookup decides which entry is REPORTED as the verdict owner (a
523
+ // non-zero decision emitter stays routed through the code-2/fail-closed
524
+ // paths below, per SPEC §FR-001); the strict predicate decides whether that
525
+ // entry may claim stdout outright.
438
526
  const decisionResult = chain.results.find((r) =>
439
527
  typeof r.stdout === 'string' && r.stdout.includes('"hookSpecificOutput"'));
528
+ const decisionOwnsVerdict = emitsPermissionDecision(decisionResult);
440
529
  const last = decisionResult ?? chain.results[chain.results.length - 1];
441
530
 
531
+ // B1 (SPEC §FR-001): a clean decision owns the ENTIRE stdout — no context
532
+ // text before or after it. Claude Code parses stdout as one JSON document,
533
+ // so leading context turns a valid deny into "Hook JSON output validation
534
+ // failed"; the chain already stopped at the decision, so no later script's
535
+ // output can be concatenated in.
536
+
442
537
  // TASK-234 review fix (critical): context stdout must be REPLAYED, not
443
538
  // dropped. SessionStart/UserPromptSubmit hooks emit plain-text context
444
539
  // (PROJECT_IMPORTANT mandate, skill-router guidance) — replaying only the
@@ -449,15 +544,18 @@ try {
449
544
  .filter((r) => r !== decisionResult && r !== last && typeof r.stdout === 'string' && r.stdout.length > 0)
450
545
  .map((r) => r.stdout)
451
546
  .join('');
452
-
453
- // TASK-234 review fix (critical): a mid-chain break that skipped a
454
- // fail-closed gate must fail CLOSED — the old per-script path ran every
455
- // hook independently, so a killed advisory never skipped a gate.
456
- if (chain.skippedFailClosed) {
547
+ if (decisionOwnsVerdict) {
548
+ process.stdout.write(decisionResult.stdout);
549
+ if (decisionResult.stderr) process.stderr.write(decisionResult.stderr);
550
+ process.exitCode = 0;
551
+ } else if (chain.skippedFailClosed) {
552
+ // TASK-234 review fix (critical): a mid-chain break that skipped a
553
+ // fail-closed gate must fail CLOSED — the old per-script path ran every
554
+ // hook independently, so a killed advisory never skipped a gate.
457
555
  if (contextStdout) process.stdout.write(contextStdout);
458
556
  const skipped = scriptPaths
459
557
  .slice(chain.results.length)
460
- .map((p) => path.basename(String(p).replace(/:\d+(?:\.\d+)?$/, '')))
558
+ .map((p) => failClosedName(p))
461
559
  .filter((name) => FAIL_CLOSED_SCRIPTS.has(name))
462
560
  .join(', ');
463
561
  process.stderr.write(`UKit hook chain broke before fail-closed gate(s) ran: ${skipped}\n`);
@@ -466,7 +564,7 @@ try {
466
564
  // No script ran at all (empty chain or budget spent before the first
467
565
  // child). With fail-closed scripts declared in the chain this must not
468
566
  // fail open.
469
- const hasFailClosed = scriptPaths.some((p) => FAIL_CLOSED_SCRIPTS.has(path.basename(String(p).replace(/:\d+(?:\.\d+)?$/, ''))));
567
+ const hasFailClosed = scriptPaths.some((p) => FAIL_CLOSED_SCRIPTS.has(failClosedName(p)));
470
568
  if (hasFailClosed) {
471
569
  process.stderr.write('UKit hook chain produced no verdict — fail-closed gate did not run\n');
472
570
  process.exitCode = 2;
@@ -69,7 +69,13 @@ function normalizeElapsedMs(value) {
69
69
  // Allowlist builder — the ONLY place row fields are chosen. `elapsedMs` may be
70
70
  // null when the caller genuinely could not measure it (e.g. the hook exited
71
71
  // before its stdin envelope was staged); unknown stays unknown, never zero.
72
+ //
73
+ // O3 (SPEC §FR-009): `stageMs` splits that elapsed window into the part the hook
74
+ // spent waiting on stdin staging and the part it spent executing. It is OPTIONAL
75
+ // — omitted entirely when unmeasurable — so a v1 reader's key set is unchanged
76
+ // for a row that has no stage data, and `TELEMETRY_VERSION` stays 1.
72
77
  export function buildTimingRow(entry = {}) {
78
+ const stageMs = normalizeElapsedMs(entry.stageMs);
73
79
  return {
74
80
  v: TELEMETRY_VERSION,
75
81
  ts: Number.isFinite(entry.ts) ? entry.ts : Date.now(),
@@ -79,6 +85,7 @@ export function buildTimingRow(entry = {}) {
79
85
  hook: shortString(entry.hook, 128),
80
86
  elapsedMs: normalizeElapsedMs(entry.elapsedMs),
81
87
  outcome: shortString(entry.outcome, 32) || 'ok',
88
+ ...(stageMs === null ? {} : { stageMs }),
82
89
  };
83
90
  }
84
91
 
@@ -242,13 +249,14 @@ export function recordHookTiming({
242
249
  tool,
243
250
  hook,
244
251
  elapsedMs,
252
+ stageMs,
245
253
  outcome,
246
254
  toolUseId,
247
255
  ts,
248
256
  projectRoot,
249
257
  maxBytes,
250
258
  } = {}) {
251
- const row = buildTimingRow({ ts, event, tool, toolUseId, hook, elapsedMs, outcome });
259
+ const row = buildTimingRow({ ts, event, tool, toolUseId, hook, elapsedMs, outcome, stageMs });
252
260
  const root = projectRoot || process.env.CLAUDE_PROJECT_DIR || process.cwd();
253
261
  return appendTelemetryRow(root, sessionId, row, { maxBytes });
254
262
  }
@@ -256,6 +264,13 @@ export function recordHookTiming({
256
264
  // --- `--finish` CLI: one short-lived node process per direct hook exit ------
257
265
  // Invoked by ukit_hook_telemetry_finish (hook-telemetry.sh) from the shared
258
266
  // cleanup path in hook-input.sh, while the staged payload file still exists.
267
+ //
268
+ // O1 (TASK-008): the same CLI serves hooks that deliberately never stage stdin
269
+ // (gate-first fast paths). Those have no payload mtime to measure from, so the
270
+ // arm helper hands over an explicit start plus the envelope's identifying
271
+ // fields: UKIT_TEL_START_MS (epoch ms), UKIT_TEL_SESSION_ID, UKIT_TEL_EVENT.
272
+ // Every override is optional and falsy-safe, so the 18 staging hooks that set
273
+ // none of them keep byte-for-byte today's behaviour.
259
274
 
260
275
  const FINISH_PARSE_LIMIT = 2 * 1024 * 1024; // all hooks except record-execution stage <= 2 MiB
261
276
  const FINISH_HEAD_BYTES = 256 * 1024; // bounded head scan for oversized payloads
@@ -318,32 +333,89 @@ function readEnvelope(inputFile) {
318
333
  }
319
334
  }
320
335
 
336
+ // Explicit arm start. Non-finite, zero, or negative values are ignored: a
337
+ // malformed marker must degrade to the staged-payload mtime (or to the honest
338
+ // null), never to a bogus "0ms" measurement.
339
+ function explicitStartMs(raw) {
340
+ const parsed = Number.parseInt(String(raw ?? ''), 10);
341
+ if (!Number.isFinite(parsed) || parsed <= 0) return null;
342
+ return parsed;
343
+ }
344
+
345
+ // Start reference, most explicit first: the arm helper's epoch-ms reading, the
346
+ // arm marker's mtime (the shell could not express sub-second precision on this
347
+ // host), then the staged payload's mtime. None present stays null — an armed
348
+ // hook that lost its marker must not be reported as a zero-millisecond hook.
349
+ //
350
+ // O3 (SPEC §FR-009) needs the two ends SEPARATELY, not just their difference:
351
+ // when the payload's own mtime is the start, `elapsedMs` already begins where
352
+ // staging ended, so `source` is what tells the stage split that no window is
353
+ // left to measure.
354
+ function resolveStartRef() {
355
+ const explicit = explicitStartMs(process.env.UKIT_TEL_START_MS);
356
+ if (explicit !== null) return { startMs: explicit, source: 'explicit' };
357
+ const marker = process.env.UKIT_TEL_START_FILE || '';
358
+ if (marker) {
359
+ try {
360
+ return { startMs: fs.statSync(marker).mtimeMs, source: 'marker' };
361
+ } catch {
362
+ // Marker already reaped — fall through to the staged payload.
363
+ }
364
+ }
365
+ const inputFile = process.env.UKIT_INPUT_FILE || '';
366
+ if (!inputFile) return { startMs: null, source: null };
367
+ try {
368
+ return { startMs: fs.statSync(inputFile).mtimeMs, source: 'payload' };
369
+ } catch {
370
+ return { startMs: null, source: null }; // never staged (refuse path) — unknown, not zero
371
+ }
372
+ }
373
+
374
+ // O3 (SPEC §FR-009): the stage window = the staged payload's mtime minus the arm
375
+ // start, and only the two ARM sources qualify. A hook that deliberately skips
376
+ // stdin staging (gate-first fast paths, SPEC §14) has no payload at all, and an
377
+ // unarmed hook's start IS the payload mtime — reporting a stage from either
378
+ // would invent a window that never existed, so unmeasurable stays ABSENT.
379
+ // Clamped to the row's own elapsed window: the split attributes time inside that
380
+ // whole, it never exceeds it.
381
+ function resolveStageMs(startRef, elapsedMs) {
382
+ if (startRef.source !== 'explicit' && startRef.source !== 'marker') return null;
383
+ const inputFile = process.env.UKIT_INPUT_FILE || '';
384
+ if (!inputFile) return null;
385
+ let stagedAt;
386
+ try {
387
+ stagedAt = fs.statSync(inputFile).mtimeMs;
388
+ } catch {
389
+ return null;
390
+ }
391
+ const raw = stagedAt - startRef.startMs;
392
+ if (!Number.isFinite(raw) || raw < 0) return null;
393
+ return Math.min(raw, normalizeElapsedMs(elapsedMs) ?? raw);
394
+ }
395
+
321
396
  function finishMain() {
322
397
  try {
323
398
  // process.uptime() covers this spawn itself so the row measures the hook,
324
399
  // not the telemetry process startup.
325
400
  const spawnOverheadMs = process.uptime() * 1000;
326
401
  const inputFile = process.env.UKIT_INPUT_FILE || '';
327
- let startMs = null;
328
- if (inputFile) {
329
- try {
330
- startMs = fs.statSync(inputFile).mtimeMs;
331
- } catch {
332
- startMs = null; // envelope never staged (refuse path) — unknown, not zero
333
- }
334
- }
402
+ const startRef = resolveStartRef();
403
+ const elapsedMs = startRef.startMs === null
404
+ ? null
405
+ : Date.now() - startRef.startMs - spawnOverheadMs;
335
406
  const envelope = inputFile ? readEnvelope(inputFile) : {};
336
407
  const rc = Number.parseInt(process.env.UKIT_TEL_RC ?? '', 10);
337
408
  // The wrapper resolves PROJECT_ROOT before arming; fall back to the hook
338
409
  // env's CLAUDE_PROJECT_DIR, then cwd (same resolution as the wrappers).
339
410
  recordHookTiming({
340
411
  projectRoot: process.env.PROJECT_ROOT || process.env.CLAUDE_PROJECT_DIR || undefined,
341
- sessionId: envelope.session_id,
342
- event: envelope.hook_event_name,
412
+ sessionId: process.env.UKIT_TEL_SESSION_ID || envelope.session_id,
413
+ event: process.env.UKIT_TEL_EVENT || envelope.hook_event_name,
343
414
  tool: envelope.tool_name,
344
415
  toolUseId: envelope.tool_use_id,
345
416
  hook: process.env.UKIT_TEL_HOOK,
346
- elapsedMs: startMs === null ? null : Date.now() - startMs - spawnOverheadMs,
417
+ elapsedMs,
418
+ stageMs: resolveStageMs(startRef, elapsedMs),
347
419
  // Direct rows reuse the chain taxonomy (TASK-018): a non-zero exit is a
348
420
  // verdict ('exit-code'), not a guess. Timeout/overflow kills bypass the
349
421
  // EXIT trap, so those kinds stay chain-runner-reported.
@@ -11,12 +11,54 @@
11
11
  # payload. Finish spawns at most one bounded node process, discards all output,
12
12
  # and never changes the hook's exit status or verdict.
13
13
  #
14
+ # O1 (TASK-008): hooks that never stage stdin (the gate-first fast paths wired
15
+ # in TASK-009) have no payload mtime to measure from. They call
16
+ # ukit_hook_telemetry_arm instead, which creates the same kind of start marker
17
+ # without adding a node spawn to the hot path — and trap
18
+ # ukit_hook_telemetry_finish on EXIT themselves.
19
+ #
14
20
  # Missing runtime (pre-install tree) simply leaves telemetry off: wrappers
15
21
  # source this file with `|| true` and the armed flag stays unset.
16
22
 
17
23
  UKIT_TEL_ARMED=1
18
24
  UKIT_TEL_HOOK="$(basename "${BASH_SOURCE[1]:-$0}")"
19
25
  UKIT_TEL_RUNTIME_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
26
+ UKIT_TEL_START_FILE=""
27
+ UKIT_TEL_START_MS=""
28
+
29
+ # Epoch milliseconds from a file's mtime, or empty when this host's stat cannot
30
+ # express sub-second precision. GNU coreutils is probed first: it rejects the
31
+ # BSD format string outright, while BSD stat would read a GNU format string as
32
+ # a list of directives and print something plausible-but-wrong.
33
+ ukit_tel_file_ms() {
34
+ local raw seconds frac
35
+ raw="$(/usr/bin/stat -c '%.3Y' "$1" 2>/dev/null)" || raw=""
36
+ case "$raw" in
37
+ ''|*[!0-9.]*) raw="$(/usr/bin/stat -f '%Fm' "$1" 2>/dev/null)" || raw="" ;;
38
+ esac
39
+ case "$raw" in
40
+ *.*) seconds="${raw%%.*}"; frac="${raw#*.}" ;;
41
+ *) seconds="$raw"; frac="" ;;
42
+ esac
43
+ # Unknown stays unknown: an unparseable mtime yields empty, never a bogus 0.
44
+ case "$seconds" in
45
+ ''|*[!0-9]*) return 0 ;;
46
+ esac
47
+ frac="${frac}000"
48
+ printf '%s' "$(( seconds * 1000 + 10#${frac:0:3} ))"
49
+ }
50
+
51
+ # No-staging arm path. A marker file whose mtime is the start reference costs
52
+ # one mktemp; a `node -e` clock read would cost ~30ms to measure milliseconds.
53
+ # Always returns 0 — an unarmed hook must behave exactly like an unmeasured one.
54
+ ukit_hook_telemetry_arm() {
55
+ UKIT_TEL_START_FILE="$(mktemp "${TMPDIR:-/tmp}/ukit-tel-start.XXXXXX" 2>/dev/null)" || {
56
+ UKIT_TEL_START_FILE=""
57
+ return 0
58
+ }
59
+ UKIT_TEL_START_MS="$(ukit_tel_file_ms "$UKIT_TEL_START_FILE")"
60
+ return 0
61
+ }
20
62
 
21
63
  ukit_hook_telemetry_finish() {
22
64
  # The hook's exit status arrives via UKIT_TEL_RC (ukit_cleanup_hook_input
@@ -44,6 +86,8 @@ ukit_hook_telemetry_finish() {
44
86
 
45
87
  UKIT_TEL_RC="$rc" \
46
88
  UKIT_TEL_HOOK="$UKIT_TEL_HOOK" \
89
+ UKIT_TEL_START_MS="${UKIT_TEL_START_MS:-}" \
90
+ UKIT_TEL_START_FILE="${UKIT_TEL_START_FILE:-}" \
47
91
  UKIT_INPUT_FILE="${UKIT_INPUT_FILE:-}" \
48
92
  PROJECT_ROOT="${PROJECT_ROOT:-${CLAUDE_PROJECT_DIR:-}}" \
49
93
  node "$UKIT_TEL_RUNTIME_DIR/hook-telemetry.mjs" --finish </dev/null >/dev/null 2>&1 &
@@ -56,5 +100,11 @@ ukit_hook_telemetry_finish() {
56
100
  if wait "$child" 2>/dev/null; then :; fi
57
101
  kill "$watchdog" 2>/dev/null || true
58
102
  if wait "$watchdog" 2>/dev/null; then :; fi
103
+ # The marker existed only to be stat'd by the child above; dropping it here
104
+ # keeps a long session from accumulating one temp file per hooked event.
105
+ if [ -n "${UKIT_TEL_START_FILE:-}" ]; then
106
+ rm -f "$UKIT_TEL_START_FILE" 2>/dev/null || true
107
+ UKIT_TEL_START_FILE=""
108
+ fi
59
109
  return 0
60
110
  }