@ngockhoale/ukit 3.4.20 → 3.4.21

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 CHANGED
@@ -2,6 +2,19 @@
2
2
 
3
3
  All notable changes to UKit are documented here.
4
4
 
5
+ ## 3.4.21 - 2026-10-07
6
+
7
+ **Hook runtime no longer burns CPU/disk on every tool call** (five measured bugs, C95).
8
+ - Retention sweeps (tee cache, hook-latency telemetry, execution ledger, hook payloads, omp hook errors) only
9
+ looked at the first 128 directory entries, so old files past that window were never pruned and the
10
+ over-cap rule deleted newer files instead. The scan window now rotates.
11
+ - Chained `.sh` hooks no longer each spawn their own `node hook-telemetry.mjs --finish`; `hook-chain-runner.mjs`
12
+ writes the per-step rows (an Edit chain went from 11 node spawns to 5, same telemetry rows).
13
+ - `unic-decision` now honours its persisted circuit breaker (closed/open/half-open, fail-open): a rejected key
14
+ or dead endpoint no longer costs a failed call on every prompt.
15
+ - The context hard-cap gate probes the transcript incrementally instead of re-reading the last 4 MB and
16
+ rewriting `compact-pressure.json` on ~95% of calls; invalid persisted probe state falls back to a full scan.
17
+
5
18
  ## 3.4.20 - 2026-10-06
6
19
 
7
20
  **`ukit update` now displays the NEW version.** The running process is the old code, so the
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ngockhoale/ukit",
3
- "version": "3.4.20",
3
+ "version": "3.4.21",
4
4
  "description": "Install/update an index-first AI workspace for Claude Code, OpenAI Codex and omp (Oh My Pi).",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -386,6 +386,7 @@ export async function sweepTeeCache(dir, {
386
386
  maxFiles = TEE_MAX_FILES,
387
387
  maxEntries = TEE_SWEEP_MAX_ENTRIES,
388
388
  maxRemovals = TEE_SWEEP_MAX_REMOVALS,
389
+ random = Math.random,
389
390
  } = {}) {
390
391
  let dirents;
391
392
  try {
@@ -404,8 +405,17 @@ export async function sweepTeeCache(dir, {
404
405
  const cutoff = now() - maxAgeMs;
405
406
  const entries = [];
406
407
  let scanned = 0;
407
- for (const name of eligibleNames) {
408
- if (scanned >= maxEntries) break;
408
+ // Circular window at a random start so expired files beyond the first
409
+ // maxEntries readdir names are still reached (BUG-1: the head slice never
410
+ // age-pruned the tail). Non-finite random() falls back to start 0.
411
+ const L = eligibleNames.length;
412
+ const r = random();
413
+ const start = L > maxEntries
414
+ ? Math.min(L - 1, Math.max(0, Math.floor((Number.isFinite(r) ? r : 0) * L)))
415
+ : 0;
416
+ const windowSize = Math.min(maxEntries, L);
417
+ for (let i = 0; i < windowSize; i += 1) {
418
+ const name = eligibleNames[(start + i) % L];
409
419
  scanned += 1;
410
420
  try {
411
421
  const stats = await fs.stat(path.join(dir, name));
@@ -439,7 +449,7 @@ export async function maybeSweepTeeCache(dir, {
439
449
  ? Math.min(1, Math.max(0, probability))
440
450
  : teeSweepProbabilityFromEnv();
441
451
  if (random() >= p) return { sampled: false, scanned: 0, removed: 0 };
442
- return sweepTeeCache(dir, options);
452
+ return sweepTeeCache(dir, { ...options, random });
443
453
  }
444
454
 
445
455
  async function persistRawOutput(projectRoot, {
@@ -1771,7 +1771,18 @@ export async function runRouteDecisionShadow({
1771
1771
 
1772
1772
  let result = null;
1773
1773
  const startedAt = now();
1774
+ // FR-004: skip the CLI spawn while the persisted decision circuit is open
1775
+ // (half-open does not skip — the CLI claims the probe). Any failure = closed.
1776
+ let circuitOpen = false;
1774
1777
  try {
1778
+ const { isDecisionCircuitOpen } = await import('./unic-decision.mjs');
1779
+ circuitOpen = isDecisionCircuitOpen(rootDir, { config, now }) === true;
1780
+ } catch {
1781
+ circuitOpen = false;
1782
+ }
1783
+ if (circuitOpen) {
1784
+ result = { status: 'unavailable', fallbackCode: 'circuit-open' };
1785
+ } else try {
1775
1786
  const spawned = spawnImpl(
1776
1787
  process.execPath,
1777
1788
  [cliPath, '--root', rootDir],
@@ -716,6 +716,99 @@ function outcome(status, extra = {}) {
716
716
  return { status, finishReason: null, answers: [], ...extra };
717
717
  }
718
718
 
719
+ // --- Persisted circuit breaker (SPEC FR-004 / BUG-4) -----------------------
720
+ // The hook path spawns a fresh process per prompt, so an in-memory breaker is
721
+ // useless. State lives in <projectRoot>/.ukit/storage/cache/decision-circuit.json.
722
+ // Advisory + best-effort: every read/parse/write error degrades to "closed".
723
+ const CIRCUIT_DEFAULTS = { failureThreshold: 3, cooldownMs: 30000 };
724
+
725
+ function circuitFile(projectRoot) {
726
+ return path.join(projectRoot, '.ukit', 'storage', 'cache', 'decision-circuit.json');
727
+ }
728
+
729
+ function circuitConfig(config) {
730
+ const raw = decisionPlaneConfig(config).circuitBreaker;
731
+ const pick = (value, fallback) =>
732
+ Number.isFinite(value) && value >= 1 ? value : fallback;
733
+ return {
734
+ failureThreshold: pick(raw?.failureThreshold, CIRCUIT_DEFAULTS.failureThreshold),
735
+ cooldownMs: pick(raw?.cooldownMs, CIRCUIT_DEFAULTS.cooldownMs),
736
+ };
737
+ }
738
+
739
+ function writeCircuitState(projectRoot, next, now) {
740
+ const file = circuitFile(projectRoot);
741
+ const tmp = `${file}.${process.pid}.tmp`;
742
+ try {
743
+ fs.mkdirSync(path.dirname(file), { recursive: true });
744
+ fs.writeFileSync(
745
+ tmp,
746
+ JSON.stringify({
747
+ schemaVersion: 1,
748
+ consecutiveFailures: next.consecutiveFailures,
749
+ openedAt: next.openedAt,
750
+ lastFallbackCode: next.lastFallbackCode,
751
+ updatedAt: now,
752
+ }),
753
+ 'utf8',
754
+ );
755
+ fs.renameSync(tmp, file);
756
+ } catch {
757
+ try { fs.rmSync(tmp, { force: true }); } catch { /* best-effort */ }
758
+ }
759
+ }
760
+
761
+ export function readDecisionCircuit(projectRoot, { config = null, now = () => Date.now() } = {}) {
762
+ const closed = { state: 'closed', consecutiveFailures: 0, openedAt: null, lastFallbackCode: null };
763
+ if (typeof projectRoot !== 'string' || projectRoot === '') return closed;
764
+ try {
765
+ const raw = JSON.parse(fs.readFileSync(circuitFile(projectRoot), 'utf8'));
766
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return closed;
767
+ const consecutiveFailures =
768
+ Number.isInteger(raw.consecutiveFailures) && raw.consecutiveFailures >= 0
769
+ ? raw.consecutiveFailures
770
+ : 0;
771
+ const lastFallbackCode =
772
+ typeof raw.lastFallbackCode === 'string' ? raw.lastFallbackCode : null;
773
+ const base = { consecutiveFailures, openedAt: null, lastFallbackCode };
774
+ if (!Number.isFinite(raw.openedAt)) return { state: 'closed', ...base };
775
+ // Negative delta = clock moved backwards / future stamp: never pin open.
776
+ const delta = now() - raw.openedAt;
777
+ if (!(delta >= 0)) return { state: 'closed', ...base };
778
+ const state = delta < circuitConfig(config).cooldownMs ? 'open' : 'half-open';
779
+ return { state, ...base, openedAt: raw.openedAt };
780
+ } catch {
781
+ return closed;
782
+ }
783
+ }
784
+
785
+ export function isDecisionCircuitOpen(projectRoot, opts = {}) {
786
+ return readDecisionCircuit(projectRoot, opts).state === 'open';
787
+ }
788
+
789
+ function recordCircuitOutcome(projectRoot, prior, kind, { config, now, fallbackCode = null }) {
790
+ if (typeof projectRoot !== 'string' || projectRoot === '') return;
791
+ try {
792
+ const at = now();
793
+ if (kind === 'success') {
794
+ if (prior.consecutiveFailures === 0 && prior.openedAt === null) return;
795
+ writeCircuitState(projectRoot, {
796
+ consecutiveFailures: 0, openedAt: null, lastFallbackCode: null,
797
+ }, at);
798
+ return;
799
+ }
800
+ const consecutiveFailures = prior.consecutiveFailures + 1;
801
+ const reopen =
802
+ prior.state === 'half-open' ||
803
+ consecutiveFailures >= circuitConfig(config).failureThreshold;
804
+ writeCircuitState(projectRoot, {
805
+ consecutiveFailures,
806
+ openedAt: reopen ? at : null,
807
+ lastFallbackCode: fallbackCode,
808
+ }, at);
809
+ } catch { /* advisory */ }
810
+ }
811
+
719
812
  /**
720
813
  * Send one C13 batch over the configured gateway. `batch` =
721
814
  * {batchId?, boundary?, statePacket, questions, deadlineMs?}. Returns a typed
@@ -798,6 +891,24 @@ export async function requestBatch(batch, {
798
891
  detail: error?.message ?? String(error),
799
892
  });
800
893
  }
894
+ // Persisted breaker (FR-004): consulted only after all local validation, so
895
+ // those outcomes keep their order and never touch the state file.
896
+ const breakerOn = typeof projectRoot === 'string' && projectRoot !== '';
897
+ const circuit = breakerOn
898
+ ? readDecisionCircuit(projectRoot, { config, now })
899
+ : null;
900
+ if (circuit?.state === 'open') {
901
+ return outcome('unavailable', {
902
+ batchId: batch?.batchId ?? null,
903
+ checkpoint,
904
+ fallbackCode: 'circuit-open',
905
+ circuitCause: circuit.lastFallbackCode,
906
+ });
907
+ }
908
+ if (circuit?.state === 'half-open') {
909
+ // Claim the probe: concurrent processes now see 'open' until the cooldown.
910
+ writeCircuitState(projectRoot, { ...circuit, openedAt: now() }, now());
911
+ }
801
912
  let fingerprint = requestFingerprint(body);
802
913
  const timeoutMs = Number.isFinite(dp.timeoutMs) ? dp.timeoutMs : DEFAULT_TIMEOUT_MS;
803
914
  const deadlineMs = Number.isFinite(batch?.deadlineMs) ? batch.deadlineMs : timeoutMs;
@@ -889,6 +1000,7 @@ export async function requestBatch(batch, {
889
1000
  }
890
1001
 
891
1002
  const result = parseBatchResponse(parsed, questions);
1003
+ if (breakerOn) recordCircuitOutcome(projectRoot, circuit, 'success', { config, now });
892
1004
  return {
893
1005
  batchId: batch?.batchId ?? null,
894
1006
  checkpoint,
@@ -900,11 +1012,18 @@ export async function requestBatch(batch, {
900
1012
  };
901
1013
  }
902
1014
 
903
- return outcome(lastError ?? 'unavailable', {
1015
+ const finalStatus = lastError ?? 'unavailable';
1016
+ const finalCode =
1017
+ lastStatus ?? (lastError === 'timeout' ? 'deadline-exceeded' : 'transport-error');
1018
+ if (breakerOn && (finalStatus === 'timeout' || finalStatus === 'unavailable')) {
1019
+ recordCircuitOutcome(projectRoot, circuit, 'failure', {
1020
+ config, now, fallbackCode: finalCode,
1021
+ });
1022
+ }
1023
+ return outcome(finalStatus, {
904
1024
  batchId: batch?.batchId ?? null,
905
1025
  checkpoint,
906
- fallbackCode:
907
- lastStatus ?? (lastError === 'timeout' ? 'deadline-exceeded' : 'transport-error'),
1026
+ fallbackCode: finalCode,
908
1027
  requestFingerprint: fingerprint,
909
1028
  });
910
1029
  }
@@ -557,12 +557,83 @@ const BOUNDARY_TAIL_BYTES = 4 * 1024 * 1024;
557
557
  // 4s hook budget on a slow volume.
558
558
  const TRANSCRIPT_BOUNDARY_PROBE_TTL_MS = 2_000;
559
559
 
560
+ // Persist a probe update only after this many unpersisted bytes were scanned: transcripts grow
561
+ // on every tool call, but rewriting compact-pressure.json (70-110KB) each time is the cost
562
+ // the incremental probe exists to avoid.
563
+ const PROBE_PERSIST_DELTA_BYTES = 256 * 1024;
564
+
560
565
  function normalizeTranscriptProbe(value) {
561
566
  if (!value || typeof value !== 'object' || typeof value.path !== 'string' || !value.path) return null;
562
567
  const checkedAt = finiteNumber(value.checkedAt, 0);
563
- return checkedAt > 0
564
- ? { path: value.path, checkedAt, boundaryAt: finiteNumber(value.boundaryAt, 0) }
565
- : null;
568
+ if (!(checkedAt > 0)) return null;
569
+ // Only a non-negative safe integer is a usable byte offset; anything else (fractional, NaN,
570
+ // negative, string) is treated as 'no scannedSize' so the next probe does one full scan.
571
+ const scannedSize = Number.isSafeInteger(value.scannedSize) && value.scannedSize >= 0 ? value.scannedSize : null;
572
+ return { path: value.path, checkedAt, boundaryAt: finiteNumber(value.boundaryAt, 0), scannedSize };
573
+ }
574
+
575
+ function boundaryAtOfLine(line) {
576
+ if (!line.includes('compact_boundary')) return 0;
577
+ try {
578
+ const entry = JSON.parse(line);
579
+ if (entry?.type === 'system' && entry?.subtype === 'compact_boundary') {
580
+ const at = Date.parse(entry.timestamp ?? '');
581
+ if (Number.isFinite(at)) return at;
582
+ }
583
+ } catch { /* not a usable boundary line */ }
584
+ return 0;
585
+ }
586
+
587
+ // Scan `[max(fromOffset, size - tailBytes), size)` of the transcript for the newest
588
+ // compact_boundary (epoch ms, 0 if none). A leading partial line is dropped only when the
589
+ // read starts mid-line. `scannedSize` is the offset just past the last `\n` read, so a
590
+ // trailing line still being written is rescanned once it is complete and a delta scan always
591
+ // starts at a line boundary. A fromOffset beyond EOF (truncation/rotation) scans the tail.
592
+ // Throws on stat/open/read failure; callers decide the fallback.
593
+ export async function scanCompactBoundary(transcriptPath, { tailBytes = BOUNDARY_TAIL_BYTES, fromOffset = 0 } = {}) {
594
+ const stat = await fs.stat(transcriptPath);
595
+ const size = stat.size;
596
+ if (!stat.isFile() || size === 0) {
597
+ return { boundaryAt: 0, scannedSize: 0, size };
598
+ }
599
+ const requested = Number.isSafeInteger(fromOffset) && fromOffset >= 0 && fromOffset <= size ? fromOffset : 0;
600
+ const start = Math.max(requested, size - tailBytes);
601
+ // One extra leading byte tells whether `start` sits on a line boundary.
602
+ const readStart = start > 0 ? start - 1 : 0;
603
+ const length = size - readStart;
604
+ const buffer = Buffer.allocUnsafe(length);
605
+ const handle = await fs.open(transcriptPath, 'r');
606
+ let bytesRead;
607
+ try {
608
+ ({ bytesRead } = await handle.read(buffer, 0, length, readStart));
609
+ } finally {
610
+ await handle.close();
611
+ }
612
+ const data = buffer.subarray(0, bytesRead);
613
+ // A delta scan must start on a line boundary (scannedSize sits just past a `\n`). If the
614
+ // byte before it is not `\n`, the file was replaced at the same path: rescan from the top.
615
+ if (requested > 0 && start === requested && data.length > 0 && data[0] !== 0x0a) {
616
+ return scanCompactBoundary(transcriptPath, { tailBytes, fromOffset: 0 });
617
+ }
618
+ let begin = 0;
619
+ let leadingPartialOnly = false;
620
+ if (start > 0 && data.length > 0 && data[0] !== 0x0a) {
621
+ const nl = data.indexOf(0x0a, 1);
622
+ if (nl === -1) { begin = data.length; leadingPartialOnly = true; } else { begin = nl + 1; }
623
+ } else if (start > 0) {
624
+ begin = 1;
625
+ }
626
+ const region = data.subarray(begin);
627
+ const lastNl = region.lastIndexOf(0x0a);
628
+ const scannedSize = lastNl >= 0
629
+ ? readStart + begin + lastNl + 1
630
+ : (leadingPartialOnly ? start : readStart + begin);
631
+ const lines = region.toString('utf8').split('\n');
632
+ for (let i = lines.length - 1; i >= 0; i -= 1) {
633
+ const at = boundaryAtOfLine(lines[i]);
634
+ if (at) return { boundaryAt: at, scannedSize, size };
635
+ }
636
+ return { boundaryAt: 0, scannedSize, size };
566
637
  }
567
638
 
568
639
  // Find the newest compaction recorded in the session transcript, as epoch ms (0 if none).
@@ -576,35 +647,7 @@ export async function detectLatestCompactBoundaryAt(transcriptPath, { tailBytes
576
647
  return 0;
577
648
  }
578
649
  try {
579
- const stat = await fs.stat(transcriptPath);
580
- if (!stat.isFile() || stat.size === 0) {
581
- return 0;
582
- }
583
- const start = Math.max(0, stat.size - tailBytes);
584
- const length = stat.size - start;
585
- const buffer = Buffer.allocUnsafe(length);
586
- const handle = await fs.open(transcriptPath, 'r');
587
- let text;
588
- try {
589
- const { bytesRead } = await handle.read(buffer, 0, length, start);
590
- text = buffer.toString('utf8', 0, bytesRead);
591
- } finally {
592
- await handle.close();
593
- }
594
- const lines = text.split('\n');
595
- if (start > 0) lines.shift(); // a tail read can split the first line
596
- for (let i = lines.length - 1; i >= 0; i -= 1) {
597
- const line = lines[i];
598
- if (!line.includes('compact_boundary')) continue;
599
- try {
600
- const entry = JSON.parse(line);
601
- if (entry?.type === 'system' && entry?.subtype === 'compact_boundary') {
602
- const at = Date.parse(entry.timestamp ?? '');
603
- if (Number.isFinite(at)) return at;
604
- }
605
- } catch { /* not a usable boundary line */ }
606
- }
607
- return 0;
650
+ return (await scanCompactBoundary(transcriptPath, { tailBytes, fromOffset: 0 })).boundaryAt;
608
651
  } catch {
609
652
  return 0;
610
653
  }
@@ -616,28 +659,89 @@ export async function detectLatestCompactBoundaryAt(transcriptPath, { tailBytes
616
659
  // history that was already summarised away and the hard-cap gate bricks a healthy
617
660
  // session. Callers must persist when reset=true, or the reset would re-fire (and
618
661
  // zero the counter) on every call.
662
+ //
663
+ // The probe is incremental: it remembers how many bytes were already scanned
664
+ // (`scannedSize`), so an unchanged transcript costs one stat and a grown one only reads the
665
+ // delta. `probed: true` tells the caller to persist; it is raised only on a meaningful change
666
+ // (new path, new boundary, full rescan, or >= persistDeltaBytes unpersisted bytes scanned).
619
667
  export async function syncCompactPressureStateWithTranscript(
620
668
  rawState = null,
621
669
  config = {},
622
670
  transcriptPath = '',
623
- { now = Date.now(), probeTtlMs = TRANSCRIPT_BOUNDARY_PROBE_TTL_MS } = {},
671
+ { now = Date.now(), probeTtlMs = TRANSCRIPT_BOUNDARY_PROBE_TTL_MS, persistDeltaBytes = PROBE_PERSIST_DELTA_BYTES } = {},
624
672
  ) {
625
673
  const normalizedPath = typeof transcriptPath === 'string' ? transcriptPath.trim() : '';
626
674
  const previousProbe = normalizeTranscriptProbe(rawState?.transcriptBoundaryProbe);
627
- // A process-persistent, session-scoped cache avoids rereading/parsing up to 4MB for every
675
+ const samePath = Boolean(previousProbe) && previousProbe.path === normalizedPath;
676
+ // A process-persistent, session-scoped cache avoids rereading/parsing the transcript for every
628
677
  // mutation during a tool burst. It keys on the path (not just time) so a resumed/new
629
678
  // transcript always scans immediately. The short TTL is deliberately only a performance
630
679
  // coalescing window; PreCompact remains the immediate source of truth.
631
- const probeFresh = previousProbe
632
- && previousProbe.path === normalizedPath
680
+ const probeFresh = samePath
633
681
  && now - previousProbe.checkedAt >= 0
634
682
  && now - previousProbe.checkedAt < probeTtlMs;
635
- const boundaryAt = probeFresh
636
- ? previousProbe.boundaryAt
637
- : await detectLatestCompactBoundaryAt(normalizedPath);
638
- const probe = probeFresh
639
- ? previousProbe
640
- : { path: normalizedPath, checkedAt: now, boundaryAt };
683
+
684
+ let boundaryAt;
685
+ let probe;
686
+ let probed;
687
+ if (probeFresh) {
688
+ boundaryAt = previousProbe.boundaryAt;
689
+ probe = previousProbe;
690
+ probed = false;
691
+ } else {
692
+ // `persistedScannedSize` is the scannedSize loaded from disk via rawState: the gate only
693
+ // persists when probed is true, so this bounds the delta a later process must re-read.
694
+ const persistedScannedSize = samePath ? previousProbe.scannedSize : null;
695
+ const fullScan = async () => {
696
+ const scan = await scanCompactBoundary(normalizedPath, { fromOffset: 0 });
697
+ return {
698
+ boundaryAt: scan.boundaryAt,
699
+ probe: { path: normalizedPath, checkedAt: now, boundaryAt: scan.boundaryAt, scannedSize: scan.scannedSize },
700
+ probed: true,
701
+ };
702
+ };
703
+ const incrementalScan = async () => {
704
+ const { size } = await fs.stat(normalizedPath);
705
+ if (size === persistedScannedSize) {
706
+ // Nothing new since the persisted probe: reuse, no read.
707
+ const reused = previousProbe.boundaryAt;
708
+ return {
709
+ boundaryAt: reused,
710
+ probe: { path: normalizedPath, checkedAt: now, boundaryAt: reused, scannedSize: persistedScannedSize },
711
+ probed: false,
712
+ };
713
+ }
714
+ if (size < persistedScannedSize) return fullScan(); // truncation/rotation
715
+ const scan = await scanCompactBoundary(normalizedPath, { fromOffset: persistedScannedSize });
716
+ const merged = scan.boundaryAt || previousProbe.boundaryAt;
717
+ return {
718
+ boundaryAt: merged,
719
+ probe: { path: normalizedPath, checkedAt: now, boundaryAt: merged, scannedSize: scan.scannedSize },
720
+ probed: merged !== previousProbe.boundaryAt
721
+ || scan.scannedSize - persistedScannedSize >= persistDeltaBytes,
722
+ };
723
+ };
724
+ let result = null;
725
+ if (persistedScannedSize !== null) {
726
+ // Any incremental failure retries one full scan, so the gate decides exactly as a full
727
+ // re-read would; only if that fails too do we fall back to the previous boundary.
728
+ try { result = await incrementalScan(); } catch { result = null; }
729
+ }
730
+ if (!result) {
731
+ try {
732
+ result = await fullScan();
733
+ } catch {
734
+ // stat/open/read failure: keep the previous boundary for the same path, else 0.
735
+ const kept = samePath ? previousProbe.boundaryAt : 0;
736
+ result = {
737
+ boundaryAt: kept,
738
+ probe: { path: normalizedPath, checkedAt: now, boundaryAt: kept, scannedSize: persistedScannedSize },
739
+ probed: !samePath,
740
+ };
741
+ }
742
+ }
743
+ ({ boundaryAt, probe, probed } = result);
744
+ }
641
745
  const accounted = finiteNumber(rawState?.lastCompactBoundaryAt, 0);
642
746
  if (!boundaryAt || boundaryAt <= accounted) {
643
747
  return {
@@ -645,7 +749,7 @@ export async function syncCompactPressureStateWithTranscript(
645
749
  ? { ...rawState, transcriptBoundaryProbe: probe }
646
750
  : rawState,
647
751
  reset: false,
648
- probed: !probeFresh,
752
+ probed,
649
753
  };
650
754
  }
651
755
  return {
@@ -655,7 +759,7 @@ export async function syncCompactPressureStateWithTranscript(
655
759
  transcriptBoundaryProbe: probe,
656
760
  },
657
761
  reset: true,
658
- probed: !probeFresh,
762
+ probed,
659
763
  };
660
764
  }
661
765
 
@@ -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) {
@@ -296,6 +296,13 @@ async function run(payloadText, scriptArgs, { chainMarker = true, decisionShortC
296
296
  // (ask + exit 2). Under --emit-verdict the runner replays the direct
297
297
  // Claude contract (deny + exit 0), so children must NOT see it.
298
298
  ...(chainMarker ? { UKIT_HOOK_CHAIN_RUNNER: '1' } : {}),
299
+ // BUG-3 (TASK-003): the runner owns hook-latency rows for every chained
300
+ // step (see recordHookTiming below). This marker keeps hook-telemetry.sh
301
+ // unarmed in `.sh` children so they spawn no `--finish` node/sleep.
302
+ // Distinct from UKIT_HOOK_CHAIN_RUNNER, which is withheld under
303
+ // --emit-verdict; this one is set in both modes. Direct invocations
304
+ // (no runner) never see it and stay armed.
305
+ UKIT_HOOK_TELEMETRY_BY_RUNNER: '1',
299
306
  };
300
307
  const stepDeadlineMs = Math.min(childBudgetsMs[scriptIndex], remainingMs);
301
308
  // TASK-001: `.mjs` args run in-proc via runHook(ctx); `.sh` stays a child
@@ -350,21 +357,21 @@ async function run(payloadText, scriptArgs, { chainMarker = true, decisionShortC
350
357
  elapsedMs: Date.now() - childStartedAt,
351
358
  });
352
359
 
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
- }
360
+ // TASK-001 (FR-003) / TASK-003 (BUG-3): the runner is the single telemetry
361
+ // owner for every chained step. `.mjs` steps run in-proc and cannot write
362
+ // their own row; `.sh` children are told via UKIT_HOOK_TELEMETRY_BY_RUNNER
363
+ // not to spawn hook-telemetry.mjs --finish. One row per step, hook =
364
+ // basename incl. extension; elapsedMs includes bash start-up.
365
+ recordHookTiming({
366
+ projectRoot,
367
+ sessionId: payload?.session_id,
368
+ event: payload?.hook_event_name || null,
369
+ tool: payload?.tool_name || null,
370
+ toolUseId: payload?.tool_use_id || null,
371
+ hook: scriptName,
372
+ elapsedMs: Date.now() - childStartedAt,
373
+ outcome: failureKind,
374
+ });
368
375
 
369
376
  // B1 (TASK-001, SPEC §FR-001): under `--emit-verdict` a child that emits a
370
377
  // `hookSpecificOutput` permission decision owns the verdict even at exit 0
@@ -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
  }
@@ -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, {
@@ -454,6 +454,7 @@ async function sweepHookErrorsDir(dir, {
454
454
  maxFiles = HOOK_ERROR_MAX_FILES,
455
455
  maxEntries = HOOK_ERROR_SWEEP_MAX_ENTRIES,
456
456
  maxRemovals = HOOK_ERROR_SWEEP_MAX_REMOVALS,
457
+ random = Math.random,
457
458
  } = {}) {
458
459
  let names;
459
460
  try {
@@ -468,9 +469,15 @@ async function sweepHookErrorsDir(dir, {
468
469
  // `entries.length` an under-count and `overflow` was always 0.
469
470
  const eligibleNames = names.filter((name) => name.endsWith('.jsonl'));
470
471
  const entries = [];
472
+ // FR-002 (BUG-2): scan a circular window of at most maxEntries names from a
473
+ // random start so files past the first maxEntries are eventually pruned.
474
+ const total = eligibleNames.length;
475
+ const rawStart = total > maxEntries ? Math.floor(random() * total) : 0;
476
+ const start = Number.isFinite(rawStart) ? Math.min(total - 1, Math.max(0, rawStart)) : 0;
477
+ const limit = Math.min(maxEntries, total);
471
478
  let scanned = 0;
472
- for (const name of eligibleNames) {
473
- if (scanned >= maxEntries) break;
479
+ for (let i = 0; i < limit; i += 1) {
480
+ const name = eligibleNames[(start + i) % total];
474
481
  scanned += 1;
475
482
  try {
476
483
  const stats = await fs.promises.stat(path.join(dir, name));