@ngockhoale/ukit 3.4.20 → 3.4.22

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.
@@ -160,6 +160,7 @@ export async function sweepExecLedgerDir(dir, {
160
160
  maxFiles = LEDGER_MAX_FILES,
161
161
  maxEntries = LEDGER_SWEEP_MAX_ENTRIES,
162
162
  maxRemovals = LEDGER_SWEEP_MAX_REMOVALS,
163
+ random = Math.random,
163
164
  } = {}) {
164
165
  let dirents;
165
166
  try {
@@ -178,9 +179,15 @@ export async function sweepExecLedgerDir(dir, {
178
179
  if (dirent.isFile()) eligibleNames.push(dirent.name);
179
180
  }
180
181
  const entries = [];
182
+ // FR-002 (BUG-2): scan a circular window of at most maxEntries names from a
183
+ // random start so files past the first maxEntries are eventually pruned.
184
+ const total = eligibleNames.length;
185
+ const rawStart = total > maxEntries ? Math.floor(random() * total) : 0;
186
+ const start = Number.isFinite(rawStart) ? Math.min(total - 1, Math.max(0, rawStart)) : 0;
187
+ const limit = Math.min(maxEntries, total);
181
188
  let scanned = 0;
182
- for (const name of eligibleNames) {
183
- if (scanned >= maxEntries) break;
189
+ for (let i = 0; i < limit; i += 1) {
190
+ const name = eligibleNames[(start + i) % total];
184
191
  scanned += 1;
185
192
  try {
186
193
  const stats = await fs.stat(path.join(dir, name));
@@ -258,7 +265,7 @@ export async function maybeSweepExecLedgerDir(dir, {
258
265
  ? Math.min(1, Math.max(0, probability))
259
266
  : ledgerSweepProbabilityFromEnv();
260
267
  if (random() >= p) return { sampled: false, scanned: 0, removed: 0 };
261
- return { sampled: true, ...(await sweepExecLedgerDir(dir, options)) };
268
+ return { sampled: true, ...(await sweepExecLedgerDir(dir, { ...options, random })) };
262
269
  }
263
270
 
264
271
  function resumeIntentPath(projectRoot, sessionId) {
@@ -6,7 +6,7 @@ import { pathToFileURL } from 'node:url';
6
6
  // TASK-016: children run through the process-tree runner instead of bare
7
7
  // spawnSync — a timed-out child is TERM→KILLed as a whole POSIX process group,
8
8
  // so a bash wrapper can no longer orphan the Node grandchildren it spawned.
9
- import { runHookProcess } from './hook-process.mjs';
9
+ import { runHookProcess, resolveHookCommand, SPAWN_UNAVAILABLE_CODES } from './hook-process.mjs';
10
10
  // Repeated identical blocks (a model respawning an agent into the same refusal) get a
11
11
  // "stop and report verbatim" directive appended. Advisory + fail-open; see block-breaker.mjs.
12
12
  // Loaded dynamically so a partially-updated runtime (module missing) degrades to "no advice"
@@ -88,6 +88,7 @@ async function readStdinBounded() {
88
88
  // 'signal' — the child died from a signal this runner did not send
89
89
  // 'error' — the child never spawned (ENOENT and friends)
90
90
  // 'budget-exhausted' — the script never ran: the chain total budget was spent
91
+ // 'spawn-unavailable' — win32 bash launcher not found or could not start the .sh script (no process ran); warned, never blocks
91
92
  function chainFailureKind(processResult) {
92
93
  switch (processResult?.failureKind) {
93
94
  case 'overflow':
@@ -169,6 +170,18 @@ function parseScriptArg(arg) {
169
170
  // 'timeout'. The deadline is enforced by Promise.race + AbortSignal; the
170
171
  // rejection race keeps running in the background (in-proc code cannot be
171
172
  // SIGKILLed) but its late return value is discarded.
173
+ // FR-004: warning for a step whose win32 bash launcher never started. Single
174
+ // Windows fixText — this classification is unreachable off win32 (`viaBash`).
175
+ const SPAWN_UNAVAILABLE_FIX =
176
+ 'Fix: install Git for Windows (https://git-scm.com/download/win) or set UKIT_BASH_BIN to the full path of bash.exe.';
177
+
178
+ function spawnUnavailableWarning(scriptName, cause) {
179
+ const gateText = FAIL_CLOSED_SCRIPTS.has(scriptName)
180
+ ? 'fail-closed gate NOT enforced'
181
+ : 'advisory hook skipped';
182
+ return `UKit WARNING: hook ${scriptName} could not be launched (${cause}) and was SKIPPED — ${gateText}. ${SPAWN_UNAVAILABLE_FIX}\n`;
183
+ }
184
+
172
185
  async function runModuleStep({ scriptPath, payload, payloadText, projectRoot, env, deadlineMs }) {
173
186
  const controller = new AbortController();
174
187
  let timer;
@@ -296,11 +309,25 @@ async function run(payloadText, scriptArgs, { chainMarker = true, decisionShortC
296
309
  // (ask + exit 2). Under --emit-verdict the runner replays the direct
297
310
  // Claude contract (deny + exit 0), so children must NOT see it.
298
311
  ...(chainMarker ? { UKIT_HOOK_CHAIN_RUNNER: '1' } : {}),
312
+ // BUG-3 (TASK-003): the runner owns hook-latency rows for every chained
313
+ // step (see recordHookTiming below). This marker keeps hook-telemetry.sh
314
+ // unarmed in `.sh` children so they spawn no `--finish` node/sleep.
315
+ // Distinct from UKIT_HOOK_CHAIN_RUNNER, which is withheld under
316
+ // --emit-verdict; this one is set in both modes. Direct invocations
317
+ // (no runner) never see it and stay armed.
318
+ UKIT_HOOK_TELEMETRY_BY_RUNNER: '1',
299
319
  };
300
320
  const stepDeadlineMs = Math.min(childBudgetsMs[scriptIndex], remainingMs);
301
321
  // TASK-001: `.mjs` args run in-proc via runHook(ctx); `.sh` stays a child
302
322
  // process. Both paths share deadline/budget, results[] shape, and break
303
323
  // conditions.
324
+ // FR-003/FR-004: non-.mjs steps launch through the resolver (absolute Git
325
+ // Bash on win32 for `.sh`, the script itself everywhere else). `viaBash`
326
+ // is the ONLY thing that can turn a launch failure into spawn-unavailable.
327
+ const launch = scriptPath.endsWith('.mjs')
328
+ ? { command: scriptPath, args: [], viaBash: false }
329
+ : resolveHookCommand(scriptPath);
330
+ const { viaBash } = launch;
304
331
  const result = scriptPath.endsWith('.mjs')
305
332
  ? await runModuleStep({
306
333
  scriptPath,
@@ -310,10 +337,13 @@ async function run(payloadText, scriptArgs, { chainMarker = true, decisionShortC
310
337
  env: stepEnv,
311
338
  deadlineMs: stepDeadlineMs,
312
339
  })
313
- : await runHookProcess({
314
- command: scriptPath,
340
+ : launch.command === null
341
+ // No bash found on win32: nothing is spawned (never a bare `bash`).
342
+ ? { code: 1, stdout: '', stderr: '', failureKind: 'error', spawnFailed: true, errorCode: null }
343
+ : await runHookProcess({
344
+ command: launch.command,
315
345
  deadlineMs: stepDeadlineMs,
316
- args: [],
346
+ args: launch.args,
317
347
  input: payloadText,
318
348
  maxBuffer: MAX_BUFFER_BYTES,
319
349
  cwd: projectRoot,
@@ -324,6 +354,38 @@ async function run(payloadText, scriptArgs, { chainMarker = true, decisionShortC
324
354
  // decision JSON.
325
355
  env: stepEnv,
326
356
  });
357
+ const launchFailed = viaBash && (
358
+ launch.command === null
359
+ || (result.spawnFailed === true && SPAWN_UNAVAILABLE_CODES.has(result.errorCode))
360
+ );
361
+ if (launchFailed && scriptName !== 'block-dangerous.sh' && fs.existsSync(scriptPath)) {
362
+ const stepElapsedMs = Date.now() - childStartedAt;
363
+ results.push({
364
+ scriptName,
365
+ code: 0,
366
+ stdout: '',
367
+ stderr: spawnUnavailableWarning(scriptName, launch.command === null ? 'bash.exe not found' : result.errorCode),
368
+ failureKind: 'spawn-unavailable',
369
+ killed: false,
370
+ elapsedMs: stepElapsedMs,
371
+ });
372
+ recordHookTiming({
373
+ projectRoot,
374
+ sessionId: payload?.session_id,
375
+ event: payload?.hook_event_name || null,
376
+ tool: payload?.tool_name || null,
377
+ toolUseId: payload?.tool_use_id || null,
378
+ hook: scriptName,
379
+ elapsedMs: stepElapsedMs,
380
+ outcome: 'spawn-unavailable',
381
+ });
382
+ continue;
383
+ }
384
+ if (launch.command === null) {
385
+ // Resolver found no bash but the step does not qualify for the degrade
386
+ // (script missing / block-dangerous.sh): fail closed exactly like a spawn error.
387
+ result.stderr = `UKit hook ${scriptName} could not be launched: bash.exe not found\n`;
388
+ }
327
389
  const failureKind = chainFailureKind(result);
328
390
  // FR-001: on infra failure (error/timeout — no module verdict) the result
329
391
  // code is 2 when the step is fail-closed, else 1.
@@ -350,21 +412,21 @@ async function run(payloadText, scriptArgs, { chainMarker = true, decisionShortC
350
412
  elapsedMs: Date.now() - childStartedAt,
351
413
  });
352
414
 
353
- // TASK-001 (FR-003): `.sh` children write their own hook-latency row via
354
- // hook-telemetry.sh --finish; in-proc `.mjs` steps cannot — the runner
355
- // emits the equivalent row per module step (hook = basename incl. `.mjs`).
356
- if (scriptPath.endsWith('.mjs')) {
357
- recordHookTiming({
358
- projectRoot,
359
- sessionId: payload?.session_id,
360
- event: payload?.hook_event_name || null,
361
- tool: payload?.tool_name || null,
362
- toolUseId: payload?.tool_use_id || null,
363
- hook: scriptName,
364
- elapsedMs: Date.now() - childStartedAt,
365
- outcome: failureKind,
366
- });
367
- }
415
+ // TASK-001 (FR-003) / TASK-003 (BUG-3): the runner is the single telemetry
416
+ // owner for every chained step. `.mjs` steps run in-proc and cannot write
417
+ // their own row; `.sh` children are told via UKIT_HOOK_TELEMETRY_BY_RUNNER
418
+ // not to spawn hook-telemetry.mjs --finish. One row per step, hook =
419
+ // basename incl. extension; elapsedMs includes bash start-up.
420
+ recordHookTiming({
421
+ projectRoot,
422
+ sessionId: payload?.session_id,
423
+ event: payload?.hook_event_name || null,
424
+ tool: payload?.tool_name || null,
425
+ toolUseId: payload?.tool_use_id || null,
426
+ hook: scriptName,
427
+ elapsedMs: Date.now() - childStartedAt,
428
+ outcome: failureKind,
429
+ });
368
430
 
369
431
  // B1 (TASK-001, SPEC §FR-001): under `--emit-verdict` a child that emits a
370
432
  // `hookSpecificOutput` permission decision owns the verdict even at exit 0
@@ -602,18 +664,34 @@ try {
602
664
  .filter((r) => r !== decisionResult && r !== last && typeof r.stdout === 'string' && r.stdout.length > 0)
603
665
  .map((r) => r.stdout)
604
666
  .join('');
667
+ // FR-005: spawn-unavailable steps never block, but must stay visible. Their
668
+ // warnings go to stderr exactly ONCE (here) and, where stdout is a clean
669
+ // JSON document the runner owns, into `systemMessage`.
670
+ const warnings = chain.results
671
+ .filter((r) => r.failureKind === 'spawn-unavailable')
672
+ .map((r) => r.stderr)
673
+ .join('');
674
+ if (warnings) process.stderr.write(warnings);
605
675
  if (decisionOwnsVerdict) {
606
676
  let verdictOut = decisionResult.stdout;
607
677
  try {
608
678
  const doc = JSON.parse(verdictOut);
609
679
  const hso = doc?.hookSpecificOutput;
680
+ let changed = false;
610
681
  if (hso?.permissionDecision === 'deny') {
611
682
  const advice = breakerAdvice(hso.permissionDecisionReason);
612
683
  if (advice) {
613
684
  hso.permissionDecisionReason = `${hso.permissionDecisionReason}\n${advice}`;
614
- verdictOut = JSON.stringify(doc);
685
+ changed = true;
615
686
  }
616
687
  }
688
+ if (warnings && doc && typeof doc === 'object' && !Array.isArray(doc)) {
689
+ doc.systemMessage = typeof doc.systemMessage === 'string' && doc.systemMessage
690
+ ? `${doc.systemMessage}\n${warnings.trimEnd()}`
691
+ : warnings.trimEnd();
692
+ changed = true;
693
+ }
694
+ if (changed) verdictOut = JSON.stringify(doc);
617
695
  } catch { /* unparseable verdict: replay it untouched */ }
618
696
  process.stdout.write(verdictOut);
619
697
  if (decisionResult.stderr) process.stderr.write(decisionResult.stderr);
@@ -662,8 +740,14 @@ try {
662
740
  } else {
663
741
  if (contextStdout) process.stdout.write(contextStdout);
664
742
  if (last.stdout) process.stdout.write(last.stdout);
665
- if (last.stderr) process.stderr.write(last.stderr);
743
+ // A spawn-unavailable `last` carries only the warning already written above.
744
+ if (last.stderr && last.failureKind !== 'spawn-unavailable') process.stderr.write(last.stderr);
666
745
  process.exitCode = last.code === 0 ? 0 : (lastIsFailClosed ? 2 : 0);
746
+ // FR-005(c): exiting 0 with an otherwise empty stdout — surface the warning
747
+ // to the host as a systemMessage. Plain context stdout is left untouched.
748
+ if (warnings && process.exitCode === 0 && !contextStdout && !last.stdout) {
749
+ process.stdout.write(JSON.stringify({ systemMessage: warnings.trimEnd() }));
750
+ }
667
751
  }
668
752
  }
669
753
  }
@@ -195,6 +195,7 @@ export async function sweepStalePayloadsAsync(dir, {
195
195
  now = Date.now,
196
196
  maxAgeMs = SWEEP_MAX_AGE_MS,
197
197
  maxEntries = SWEEP_MAX_ENTRIES,
198
+ random = Math.random,
198
199
  } = {}) {
199
200
  let names;
200
201
  try {
@@ -203,12 +204,18 @@ export async function sweepStalePayloadsAsync(dir, {
203
204
  return { sampled: true, scanned: 0, removed: 0 };
204
205
  }
205
206
  const cutoff = now() - maxAgeMs;
207
+ const eligibleNames = names.filter((name) => name.endsWith('.json') || name.endsWith('.tmp'));
208
+ // FR-002 (BUG-2): scan a circular window of at most maxEntries names from a
209
+ // random start so files past the first maxEntries are eventually pruned.
210
+ const total = eligibleNames.length;
211
+ const rawStart = total > maxEntries ? Math.floor(random() * total) : 0;
212
+ const start = Number.isFinite(rawStart) ? Math.min(total - 1, Math.max(0, rawStart)) : 0;
213
+ const limit = Math.min(maxEntries, total);
206
214
  let scanned = 0;
207
215
  let removed = 0;
208
- for (const name of names) {
209
- if (scanned >= maxEntries) break;
216
+ for (let i = 0; i < limit; i += 1) {
217
+ const name = eligibleNames[(start + i) % total];
210
218
  scanned += 1;
211
- if (!name.endsWith('.json') && !name.endsWith('.tmp')) continue;
212
219
  const filePath = path.join(dir, name);
213
220
  try {
214
221
  if ((await fsp.stat(filePath)).mtimeMs < cutoff) {
@@ -234,7 +241,7 @@ export async function maybeSweepStalePayloadsAsync(dir, {
234
241
  } = {}) {
235
242
  const p = Number.isFinite(probability) ? Math.min(1, Math.max(0, probability)) : sweepProbabilityFromEnv();
236
243
  if (random() >= p) return { sampled: false, scanned: 0, removed: 0 };
237
- return sweepStalePayloadsAsync(dir, { now, maxAgeMs, maxEntries });
244
+ return sweepStalePayloadsAsync(dir, { now, maxAgeMs, maxEntries, random });
238
245
  }
239
246
 
240
247
  // sweepStalePayloads(dir, {now, maxAgeMs, maxEntries}) -> {sampled, scanned, removed}
@@ -247,6 +254,7 @@ export function sweepStalePayloads(dir, {
247
254
  now = Date.now,
248
255
  maxAgeMs = SWEEP_MAX_AGE_MS,
249
256
  maxEntries = SWEEP_MAX_ENTRIES,
257
+ random = Math.random,
250
258
  } = {}) {
251
259
  let names;
252
260
  try {
@@ -255,12 +263,18 @@ export function sweepStalePayloads(dir, {
255
263
  return { sampled: true, scanned: 0, removed: 0 };
256
264
  }
257
265
  const cutoff = now() - maxAgeMs;
266
+ const eligibleNames = names.filter((name) => name.endsWith('.json') || name.endsWith('.tmp'));
267
+ // FR-002 (BUG-2): scan a circular window of at most maxEntries names from a
268
+ // random start so files past the first maxEntries are eventually pruned.
269
+ const total = eligibleNames.length;
270
+ const rawStart = total > maxEntries ? Math.floor(random() * total) : 0;
271
+ const start = Number.isFinite(rawStart) ? Math.min(total - 1, Math.max(0, rawStart)) : 0;
272
+ const limit = Math.min(maxEntries, total);
258
273
  let scanned = 0;
259
274
  let removed = 0;
260
- for (const name of names) {
261
- if (scanned >= maxEntries) break;
275
+ for (let i = 0; i < limit; i += 1) {
276
+ const name = eligibleNames[(start + i) % total];
262
277
  scanned += 1;
263
- if (!name.endsWith('.json') && !name.endsWith('.tmp')) continue;
264
278
  const filePath = path.join(dir, name);
265
279
  try {
266
280
  if (fs.statSync(filePath).mtimeMs < cutoff) {
@@ -287,5 +301,5 @@ export function maybeSweepStalePayloads(dir, {
287
301
  } = {}) {
288
302
  const p = Number.isFinite(probability) ? Math.min(1, Math.max(0, probability)) : sweepProbabilityFromEnv();
289
303
  if (random() >= p) return { sampled: false, scanned: 0, removed: 0 };
290
- return sweepStalePayloads(dir, { now, maxAgeMs, maxEntries });
304
+ return sweepStalePayloads(dir, { now, maxAgeMs, maxEntries, random });
291
305
  }
@@ -18,15 +18,29 @@
18
18
  // assumes group semantics.
19
19
  //
20
20
  // Contract (TASK-016 Interfaces):
21
- // runHookProcess({command, args, input, deadlineMs, maxBuffer, ...}) ->
22
- // Promise<{code, signal, stdout, stderr, failureKind, elapsedMs}>
21
+ // runHookProcess({command, args, input, deadlineMs, maxBuffer, spawnImpl, ...}) ->
22
+ // Promise<{code, signal, stdout, stderr, failureKind, elapsedMs, spawnFailed, errorCode}>
23
23
  // failureKind: 'none' | 'deadline' | 'signal' | 'overflow' | 'error'
24
24
  // 'deadline' — this runner killed the child because the deadline expired
25
25
  // 'signal' — the child died from a signal this runner did not send
26
26
  // 'overflow' — captured output exceeded maxBuffer; the tree was culled
27
27
  // 'error' — the child never spawned (ENOENT and friends)
28
+ // spawnFailed: true only when the process was NEVER started — derived from child
29
+ // event order (an 'error' with no preceding 'spawn' event, or spawn() throwing
30
+ // synchronously), never from timing. An 'error' after 'spawn' is not a spawn failure.
31
+ // errorCode: the error's `code` (ENOENT, EINVAL, EPIPE, ...) or null.
32
+ // spawnImpl: injectable spawn function (default child_process.spawn) for tests.
33
+ //
34
+ // Windows .sh launching (resolveHookCommand / resolveWindowsBash):
35
+ // Windows cannot exec a `.sh` file directly, so on win32 a `.sh` hook is launched
36
+ // through an ABSOLUTE Git Bash path (never a bare `bash`, which Windows would look up
37
+ // in the child cwd first). Script paths are passed with forward slashes. POSIX spawns
38
+ // the script directly, unchanged. UKIT_HOOK_SHELL_PLATFORM overrides the platform seen
39
+ // by resolveHookCommand only (tests/diagnostics); UKIT_BASH_BIN overrides the bash path.
28
40
 
29
41
  import { spawn } from 'node:child_process';
42
+ import fs from 'node:fs';
43
+ import path from 'node:path';
30
44
 
31
45
  // POSIX only. Windows kill() targets the direct child; process groups do not
32
46
  // exist there, so pretending otherwise would be a silent, wrong assumption.
@@ -41,6 +55,93 @@ export const KILL_GRACE_MS = 500;
41
55
  // runner past its own budget.
42
56
  const HARD_SETTLE_SLACK_MS = 2000;
43
57
 
58
+ // Error codes meaning "the launcher itself could not be started".
59
+ export const SPAWN_UNAVAILABLE_CODES = new Set([
60
+ 'ENOENT',
61
+ 'EINVAL',
62
+ 'EACCES',
63
+ 'EPERM',
64
+ 'ENOEXEC',
65
+ 'EFTYPE',
66
+ ]);
67
+
68
+ /** Windows path -> forward slashes, so bash's dirname/$0 logic works. */
69
+ export function toBashPath(p) {
70
+ return String(p).replace(/\\/g, '/');
71
+ }
72
+
73
+ const win32 = path.win32;
74
+
75
+ function safeExists(exists, candidate) {
76
+ try {
77
+ return Boolean(exists(candidate));
78
+ } catch {
79
+ return false;
80
+ }
81
+ }
82
+
83
+ /**
84
+ * Locate Git Bash on Windows. Returns an absolute path or null — never a bare
85
+ * or relative name (Windows resolves bare names in the child cwd first, which
86
+ * would let a planted bash.exe in a cloned repo run).
87
+ */
88
+ export function resolveWindowsBash({ env = process.env, exists = fs.existsSync } = {}) {
89
+ const override = env.UKIT_BASH_BIN;
90
+ if (typeof override === 'string' && override && win32.isAbsolute(override)) return override;
91
+
92
+ const probe = (candidate) =>
93
+ typeof candidate === 'string' &&
94
+ candidate !== '' &&
95
+ win32.isAbsolute(candidate) &&
96
+ safeExists(exists, candidate);
97
+
98
+ const claude = env.CLAUDE_CODE_GIT_BASH_PATH;
99
+ if (probe(claude)) return claude;
100
+
101
+ const defaults = [
102
+ 'C:\\Program Files\\Git\\bin\\bash.exe',
103
+ 'C:\\Program Files (x86)\\Git\\bin\\bash.exe',
104
+ ];
105
+ if (typeof env.LOCALAPPDATA === 'string' && env.LOCALAPPDATA) {
106
+ defaults.push(win32.join(env.LOCALAPPDATA, 'Programs', 'Git', 'bin', 'bash.exe'));
107
+ }
108
+ for (const candidate of defaults) {
109
+ if (probe(candidate)) return candidate;
110
+ }
111
+
112
+ const trimSlash = (p) => p.replace(/\\+$/, '').toLowerCase();
113
+ const system32 = trimSlash(win32.join(env.SystemRoot || env.SYSTEMROOT || 'C:\\Windows', 'System32'));
114
+ const rawPath = env.PATH ?? env.Path ?? '';
115
+ for (const rawEntry of String(rawPath).split(';')) {
116
+ const entry = rawEntry.trim().replace(/^"+|"+$/g, '').trim();
117
+ if (!entry || !win32.isAbsolute(entry)) continue;
118
+ const norm = trimSlash(win32.normalize(entry));
119
+ if (norm === system32 || norm.endsWith('\\microsoft\\windowsapps')) continue;
120
+ const candidate = win32.join(entry, 'bash.exe');
121
+ if (safeExists(exists, candidate)) return candidate;
122
+ }
123
+ return null;
124
+ }
125
+
126
+ /**
127
+ * Decide how to launch a hook script. win32 + `.sh` -> Git Bash (command may be
128
+ * null when none was found); everything else spawns the script directly.
129
+ */
130
+ export function resolveHookCommand(
131
+ scriptPath,
132
+ { env = process.env, platform = env.UKIT_HOOK_SHELL_PLATFORM || process.platform, exists = fs.existsSync } = {},
133
+ ) {
134
+ if (typeof scriptPath !== 'string') return { command: '', args: [], viaBash: false };
135
+ if (platform === 'win32' && /\.sh$/i.test(scriptPath)) {
136
+ return {
137
+ command: resolveWindowsBash({ env, exists }),
138
+ args: [toBashPath(scriptPath)],
139
+ viaBash: true,
140
+ };
141
+ }
142
+ return { command: scriptPath, args: [], viaBash: false };
143
+ }
144
+
44
145
  /**
45
146
  * Signal a process GROUP by pid (negative-pid kill). Swallows ESRCH — the
46
147
  * child may have exited between arming the timer and this call, or the pid may
@@ -73,18 +174,20 @@ export function runHookProcess({
73
174
  cwd,
74
175
  env = process.env,
75
176
  graceMs = KILL_GRACE_MS,
177
+ spawnImpl = spawn,
76
178
  }) {
77
179
  return new Promise((resolve) => {
78
180
  const startedAt = Date.now();
79
181
  let child;
80
182
  try {
81
- child = spawn(command, args, {
183
+ child = spawnImpl(command, args, {
82
184
  cwd,
83
185
  env,
84
186
  // Detached ONLY on POSIX: the child becomes a group leader so the whole
85
187
  // tree dies together. Windows never gets a group assumption.
86
188
  detached: GROUP_KILL_SUPPORTED,
87
189
  stdio: ['pipe', 'pipe', 'pipe'],
190
+ windowsHide: true,
88
191
  });
89
192
  } catch (err) {
90
193
  resolve({
@@ -94,6 +197,8 @@ export function runHookProcess({
94
197
  stderr: String(err?.message || err),
95
198
  failureKind: 'error',
96
199
  elapsedMs: Date.now() - startedAt,
200
+ spawnFailed: true,
201
+ errorCode: err?.code ?? null,
97
202
  });
98
203
  return;
99
204
  }
@@ -104,6 +209,8 @@ export function runHookProcess({
104
209
  let hardSettled = false;
105
210
  let overflowed = false;
106
211
  let spawnError = null;
212
+ let spawned = false;
213
+ let spawnFailed = false;
107
214
  let stdoutBuf = Buffer.alloc(0);
108
215
  let stderrBuf = Buffer.alloc(0);
109
216
  const stdoutRef = { value: stdoutBuf };
@@ -146,6 +253,8 @@ export function runHookProcess({
146
253
  stderr: stderrRef.value.toString('utf8'),
147
254
  failureKind,
148
255
  elapsedMs: Date.now() - startedAt,
256
+ spawnFailed,
257
+ errorCode: spawnError?.code ?? null,
149
258
  });
150
259
  };
151
260
 
@@ -238,9 +347,16 @@ export function runHookProcess({
238
347
  }, deadlineMs + Math.max(0, graceMs) + HARD_SETTLE_SLACK_MS);
239
348
  }
240
349
 
350
+ // Event order, not timing, tells "never started" from "failed after start".
351
+ child.once('spawn', () => {
352
+ spawned = true;
353
+ });
241
354
  child.on('error', (err) => {
242
355
  // Spawn failures (ENOENT and friends) emit 'error' without 'close'.
243
- if (!spawnError) spawnError = err;
356
+ if (!spawnError) {
357
+ spawnError = err;
358
+ spawnFailed = !spawned;
359
+ }
244
360
  finish();
245
361
  });
246
362
  child.on('close', () => {
@@ -231,6 +231,7 @@ export function sweepTelemetryDir(dir, {
231
231
  maxFiles = TELEMETRY_MAX_FILES,
232
232
  maxEntries = TELEMETRY_SWEEP_MAX_ENTRIES,
233
233
  maxRemovals = TELEMETRY_SWEEP_MAX_REMOVALS,
234
+ random = Math.random,
234
235
  } = {}) {
235
236
  let names;
236
237
  try {
@@ -245,9 +246,15 @@ export function sweepTelemetryDir(dir, {
245
246
  // `entries.length` an under-count and `overflow` was always 0.
246
247
  const eligibleNames = names.filter((name) => name.endsWith('.jsonl'));
247
248
  const entries = [];
249
+ // FR-002 (BUG-2): scan a circular window of at most maxEntries names from a
250
+ // random start so files past the first maxEntries are eventually pruned.
251
+ const total = eligibleNames.length;
252
+ const rawStart = total > maxEntries ? Math.floor(random() * total) : 0;
253
+ const start = Number.isFinite(rawStart) ? Math.min(total - 1, Math.max(0, rawStart)) : 0;
254
+ const limit = Math.min(maxEntries, total);
248
255
  let scanned = 0;
249
- for (const name of eligibleNames) {
250
- if (scanned >= maxEntries) break;
256
+ for (let i = 0; i < limit; i += 1) {
257
+ const name = eligibleNames[(start + i) % total];
251
258
  scanned += 1;
252
259
  try {
253
260
  const stats = fs.statSync(path.join(dir, name));
@@ -283,7 +290,7 @@ export function maybeSweepTelemetryDir(dir, {
283
290
  ? Math.min(1, Math.max(0, probability))
284
291
  : telemetrySweepProbabilityFromEnv();
285
292
  if (random() >= p) return { sampled: false, scanned: 0, removed: 0 };
286
- return { sampled: true, ...sweepTelemetryDir(dir, options) };
293
+ return { sampled: true, ...sweepTelemetryDir(dir, { ...options, random }) };
287
294
  }
288
295
 
289
296
  // BUG-C23-10: a bare `catch {}` around the sweep swallowed every failure — a
@@ -17,10 +17,21 @@
17
17
  # without adding a node spawn to the hot path — and trap
18
18
  # ukit_hook_telemetry_finish on EXIT themselves.
19
19
  #
20
+ # BUG-3 (TASK-003): inside hook-chain-runner.mjs the runner is the single
21
+ # telemetry owner — it times every step and writes the hook-latency row itself.
22
+ # It exports UKIT_HOOK_TELEMETRY_BY_RUNNER=1 to each child; when that marker is
23
+ # 1 this file stays unarmed (UKIT_TEL_ARMED=0) and arm/finish return at once —
24
+ # no mktemp, no node, no sleep watchdog. Direct invocations (Codex direct hooks,
25
+ # debug runs) carry no marker and behave exactly as before.
26
+ #
20
27
  # Missing runtime (pre-install tree) simply leaves telemetry off: wrappers
21
28
  # source this file with `|| true` and the armed flag stays unset.
22
29
 
23
- UKIT_TEL_ARMED=1
30
+ if [ "${UKIT_HOOK_TELEMETRY_BY_RUNNER:-}" = "1" ]; then
31
+ UKIT_TEL_ARMED=0
32
+ else
33
+ UKIT_TEL_ARMED=1
34
+ fi
24
35
  UKIT_TEL_HOOK="$(basename "${BASH_SOURCE[1]:-$0}")"
25
36
  UKIT_TEL_RUNTIME_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
26
37
  UKIT_TEL_START_FILE=""
@@ -52,6 +63,7 @@ ukit_tel_file_ms() {
52
63
  # one mktemp; a `node -e` clock read would cost ~30ms to measure milliseconds.
53
64
  # Always returns 0 — an unarmed hook must behave exactly like an unmeasured one.
54
65
  ukit_hook_telemetry_arm() {
66
+ [ "${UKIT_TEL_ARMED:-}" = "1" ] || return 0
55
67
  UKIT_TEL_START_FILE="$(mktemp "${TMPDIR:-/tmp}/ukit-tel-start.XXXXXX" 2>/dev/null)" || {
56
68
  UKIT_TEL_START_FILE=""
57
69
  return 0
@@ -65,6 +77,7 @@ ukit_hook_telemetry_finish() {
65
77
  # captured it before its own conditionals could reset $?); $? is only the
66
78
  # fallback for direct debug invocation.
67
79
  local rc="${UKIT_TEL_RC:-$?}"
80
+ [ "${UKIT_TEL_ARMED:-}" = "1" ] || return 0
68
81
  command -v node >/dev/null 2>&1 || return 0
69
82
  # UKIT_INPUT_FILE is a plain shell variable (never exported), so the staged
70
83
  # payload path — and through it the envelope start mtime — is passed here
@@ -455,6 +455,7 @@ export async function sweepTeeCache(dir, {
455
455
  maxFiles = TEE_MAX_FILES,
456
456
  maxEntries = TEE_SWEEP_MAX_ENTRIES,
457
457
  maxRemovals = TEE_SWEEP_MAX_REMOVALS,
458
+ random = Math.random,
458
459
  } = {}) {
459
460
  let dirents;
460
461
  try {
@@ -478,8 +479,17 @@ export async function sweepTeeCache(dir, {
478
479
  const cutoff = now() - maxAgeMs;
479
480
  const entries = [];
480
481
  let scanned = 0;
481
- for (const name of eligibleNames) {
482
- if (scanned >= maxEntries) break;
482
+ // Circular window at a random start so expired files beyond the first
483
+ // maxEntries readdir names are still reached (BUG-1: the head slice never
484
+ // age-pruned the tail). Non-finite random() falls back to start 0.
485
+ const L = eligibleNames.length;
486
+ const r = random();
487
+ const start = L > maxEntries
488
+ ? Math.min(L - 1, Math.max(0, Math.floor((Number.isFinite(r) ? r : 0) * L)))
489
+ : 0;
490
+ const windowSize = Math.min(maxEntries, L);
491
+ for (let i = 0; i < windowSize; i += 1) {
492
+ const name = eligibleNames[(start + i) % L];
483
493
  scanned += 1;
484
494
  try {
485
495
  const stats = await fs.stat(path.join(dir, name));
@@ -516,7 +526,7 @@ export async function maybeSweepTeeCache(dir, {
516
526
  ? Math.min(1, Math.max(0, probability))
517
527
  : teeSweepProbabilityFromEnv();
518
528
  if (random() >= p) return { sampled: false, scanned: 0, removed: 0 };
519
- return sweepTeeCache(dir, options);
529
+ return sweepTeeCache(dir, { ...options, random });
520
530
  }
521
531
 
522
532
  async function persistRawOutput(projectRoot, {
@@ -273,6 +273,18 @@ function parseStructuredDecision(stdout) {
273
273
 
274
274
  function translateExecResult(scriptName, execResult) {
275
275
  const failureKind = typeof execResult?.failureKind === 'string' ? execResult.failureKind : null;
276
+ // The runner could not launch a .sh hook at all (win32 without bash): a loud warning,
277
+ // never a block, and no captured content travels with it.
278
+ if (failureKind === 'spawn-unavailable') {
279
+ const launchStderr = typeof execResult?.stderr === 'string' ? execResult.stderr : '';
280
+ return {
281
+ block: false,
282
+ warning: launchStderr.trim()
283
+ || `${scriptName} could not be launched and was skipped (spawn-unavailable) — install Git for Windows or set UKIT_BASH_BIN.`,
284
+ stdout: '',
285
+ stderr: launchStderr,
286
+ };
287
+ }
276
288
  // Infrastructure outcomes produce no verdict: no captured content travels with
277
289
  // them (overflowed stdout is truncated mid-stream and may embed secrets).
278
290
  const infrastructure = failureKind
@@ -454,6 +466,7 @@ async function sweepHookErrorsDir(dir, {
454
466
  maxFiles = HOOK_ERROR_MAX_FILES,
455
467
  maxEntries = HOOK_ERROR_SWEEP_MAX_ENTRIES,
456
468
  maxRemovals = HOOK_ERROR_SWEEP_MAX_REMOVALS,
469
+ random = Math.random,
457
470
  } = {}) {
458
471
  let names;
459
472
  try {
@@ -468,9 +481,15 @@ async function sweepHookErrorsDir(dir, {
468
481
  // `entries.length` an under-count and `overflow` was always 0.
469
482
  const eligibleNames = names.filter((name) => name.endsWith('.jsonl'));
470
483
  const entries = [];
484
+ // FR-002 (BUG-2): scan a circular window of at most maxEntries names from a
485
+ // random start so files past the first maxEntries are eventually pruned.
486
+ const total = eligibleNames.length;
487
+ const rawStart = total > maxEntries ? Math.floor(random() * total) : 0;
488
+ const start = Number.isFinite(rawStart) ? Math.min(total - 1, Math.max(0, rawStart)) : 0;
489
+ const limit = Math.min(maxEntries, total);
471
490
  let scanned = 0;
472
- for (const name of eligibleNames) {
473
- if (scanned >= maxEntries) break;
491
+ for (let i = 0; i < limit; i += 1) {
492
+ const name = eligibleNames[(start + i) % total];
474
493
  scanned += 1;
475
494
  try {
476
495
  const stats = await fs.promises.stat(path.join(dir, name));