@ngockhoale/ukit 2.6.3 → 2.6.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (32) hide show
  1. package/CHANGELOG.md +85 -0
  2. package/package.json +1 -1
  3. package/scripts/bench/parallel-agents.mjs +42 -4
  4. package/scripts/release/verify-release.mjs +43 -6
  5. package/src/core/applyPlan.js +65 -0
  6. package/src/core/compact/threshold.js +33 -30
  7. package/src/core/fileOps.js +9 -2
  8. package/src/core/runInstallPipeline.js +10 -1
  9. package/src/core/status.js +1 -1
  10. package/templates/.claude/hooks/auto-allow-bash.sh +29 -20
  11. package/templates/.claude/hooks/auto-prune-bash.sh +48 -30
  12. package/templates/.claude/hooks/completion-gate.sh +22 -1
  13. package/templates/.claude/hooks/context-hardcap-gate.sh +19 -4
  14. package/templates/.claude/hooks/handoff-model-guard.sh +10 -1
  15. package/templates/.claude/hooks/handoff-resume.sh +19 -17
  16. package/templates/.claude/hooks/post-edit-verify.sh +1 -0
  17. package/templates/.claude/hooks/pre-edit-backup.sh +1 -0
  18. package/templates/.claude/hooks/reset-compact-pressure.sh +39 -22
  19. package/templates/.claude/hooks/stale-spec-guard.sh +1 -0
  20. package/templates/.claude/hooks/verification-guard.sh +17 -1
  21. package/templates/.claude/settings.json +4 -4
  22. package/templates/.claude/ukit/index/extract-image.mjs +49 -0
  23. package/templates/.claude/ukit/runtime/compact-threshold.mjs +62 -40
  24. package/templates/.claude/ukit/runtime/execution-ledger.mjs +18 -5
  25. package/templates/.claude/ukit/runtime/hook-chain-budget.mjs +11 -4
  26. package/templates/.claude/ukit/runtime/hook-telemetry.mjs +7 -3
  27. package/templates/.claude/ukit/runtime/output-compression.mjs +16 -5
  28. package/templates/.claude/ukit/runtime/reinject-context.mjs +2 -2
  29. package/templates/.claude/ukit/runtime/stop-coordinator.mjs +3 -0
  30. package/templates/.claude/ukit/runtime/token-utils.mjs +9 -2
  31. package/templates/.omp/hooks/pre/ukit-bridge.js +72 -4
  32. package/templates/ukit/storage/config.json +2 -0
@@ -65,7 +65,16 @@ PROGRESS_FILE="$PROJECT_ROOT/.claude/ukit/verification-progress.json"
65
65
  INPUT_FILE="$UKIT_INPUT_FILE" STATE_FILE="$STATE_FILE" ROUTE_CACHE_FILE="$ROUTE_CACHE_FILE" PROGRESS_FILE="$PROGRESS_FILE" UKIT_RUNTIME_DIR="$SCRIPT_DIR/../ukit/runtime" node <<'NODE'
66
66
  const HOOK_DEADLINE_MS = Number.parseInt(process.env.UKIT_HOOK_DEADLINE_MS || '', 10) || 3000;
67
67
  const LOCK_STARTED_AT = Date.now();
68
- setTimeout(() => process.exit(0), HOOK_DEADLINE_MS).unref();
68
+ // SPEC §8: a timed-out gate silently passes work it never evaluated — announce the
69
+ // degrade (fail-open posture kept: still exit 0).
70
+ setTimeout(() => {
71
+ try {
72
+ process.stdout.write(JSON.stringify({
73
+ systemMessage: `UKit verification-guard: evaluation exceeded its ${HOOK_DEADLINE_MS}ms deadline; this command was allowed without verification tracking.`,
74
+ }) + '\n');
75
+ } catch {}
76
+ process.exit(0);
77
+ }, HOOK_DEADLINE_MS).unref();
69
78
  // TASK-015 fix round 1: the awaitable fs surface. Every state/progress read and
70
79
  // the atomic progress mutation is awaited so the unref'd self-deadline above can
71
80
  // always preempt it; synchronous fs calls park the event loop instead.
@@ -544,6 +553,13 @@ process.exit(0);
544
553
  })().catch((err) => {
545
554
  // Advisory hook: an unexpected error (including a rejected lock) must never
546
555
  // turn into a non-zero exit — degrade to a bounded diagnostic and exit 0.
556
+ // SPEC §8: the degrade is emitted on stdout too — stderr alone may never reach
557
+ // the user, and a wedged gate must not look identical to a clean pass.
558
+ try {
559
+ process.stdout.write(JSON.stringify({
560
+ systemMessage: `UKit verification-guard: unexpected error (${err?.message ?? err}); command allowed without verification tracking.`,
561
+ }) + '\n');
562
+ } catch {}
547
563
  try {
548
564
  process.stderr.write(`verification-guard: unexpected error (${err?.message ?? err})\n`);
549
565
  } catch {}
@@ -73,7 +73,7 @@
73
73
  {
74
74
  "type": "command",
75
75
  "command": "\"$CLAUDE_PROJECT_DIR/.claude/hooks/stale-spec-guard.sh\"",
76
- "timeout": 8
76
+ "timeout": 11
77
77
  },
78
78
  {
79
79
  "type": "command",
@@ -83,7 +83,7 @@
83
83
  {
84
84
  "type": "command",
85
85
  "command": "\"$CLAUDE_PROJECT_DIR/.claude/hooks/skill-router.sh\"",
86
- "timeout": 8
86
+ "timeout": 20
87
87
  },
88
88
  {
89
89
  "type": "command",
@@ -103,7 +103,7 @@
103
103
  {
104
104
  "type": "command",
105
105
  "command": "\"$CLAUDE_PROJECT_DIR/.claude/hooks/auto-allow-bash.sh\"",
106
- "timeout": 12
106
+ "timeout": 15
107
107
  },
108
108
  {
109
109
  "type": "command",
@@ -191,7 +191,7 @@
191
191
  {
192
192
  "type": "command",
193
193
  "command": "\"$CLAUDE_PROJECT_DIR/.claude/hooks/skill-router.sh\"",
194
- "timeout": 8
194
+ "timeout": 20
195
195
  },
196
196
  {
197
197
  "type": "command",
@@ -46,6 +46,12 @@ const DEFAULT_LIMIT = 3;
46
46
  const MAX_DECODED_BYTES = 10 * 1024 * 1024; // 10 MB
47
47
  const RETENTION_MAX_IMAGE_FILES = 20;
48
48
  const MARKER_MAX_AGE_MS = 24 * 60 * 60 * 1000; // 24h
49
+ // analyzed-<sha>.json receipts outlive their marker dir: deleting the dir at
50
+ // 24h also deletes the receipt isAlreadyAnalyzed() reads, which re-arms
51
+ // pending-<sha>.json for an already-analyzed image and re-fires the vision
52
+ // gate every sweep (BUG-C22-14). Receipts get their own longer TTL, shared
53
+ // with the legacy top-level analyzed-*.json pruner (BUG-C22-18).
54
+ const ANALYZED_RECEIPT_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000; // 30d
49
55
 
50
56
  /**
51
57
  * Single shared hash function for both the image filename sha and the
@@ -491,6 +497,23 @@ function pruneImageFiles(outDir) {
491
497
  // ignore
492
498
  }
493
499
  }
500
+
501
+ // BUG-C22-18: legacy top-level analyzed-<sha>.json receipts match neither
502
+ // pruner and never converge — remove them once past the shared receipt TTL.
503
+ // Safe: isAlreadyAnalyzed() only reads receipts INSIDE marker dirs; nothing
504
+ // consults the top-level orphans.
505
+ const now = Date.now();
506
+ for (const e of entries) {
507
+ if (!e.isFile() || !e.name.startsWith('analyzed-') || !e.name.endsWith('.json')) continue;
508
+ try {
509
+ const full = path.join(outDir, e.name);
510
+ if (now - fs.statSync(full).mtimeMs > ANALYZED_RECEIPT_MAX_AGE_MS) {
511
+ fs.unlinkSync(full);
512
+ }
513
+ } catch {
514
+ // ignore
515
+ }
516
+ }
494
517
  }
495
518
 
496
519
  function pruneMarkerDirs(outDir) {
@@ -511,6 +534,10 @@ function pruneMarkerDirs(outDir) {
511
534
  continue;
512
535
  }
513
536
  if (now - mtimeMs > MARKER_MAX_AGE_MS) {
537
+ // BUG-C22-14: a dir holding an analyzed-<sha>.json receipt younger than
538
+ // the receipt TTL is kept — deleting it would erase the only record
539
+ // isAlreadyAnalyzed() reads and re-arm the vision gate every sweep.
540
+ if (hasLiveAnalyzedReceipt(full, now)) continue;
514
541
  try {
515
542
  fs.rmSync(full, { recursive: true, force: true });
516
543
  } catch {
@@ -520,6 +547,28 @@ function pruneMarkerDirs(outDir) {
520
547
  }
521
548
  }
522
549
 
550
+ // Returns true when the marker dir still carries an analyzed-<sha>.json receipt
551
+ // inside its TTL — the receipt outlives the 24h dir sweep so the analyzed-sha
552
+ // filter keeps suppressing re-arms until the receipt itself ages out.
553
+ function hasLiveAnalyzedReceipt(markerDir, now) {
554
+ let names;
555
+ try {
556
+ names = fs.readdirSync(markerDir);
557
+ } catch {
558
+ return false;
559
+ }
560
+ for (const name of names) {
561
+ if (!name.startsWith('analyzed-') || !name.endsWith('.json')) continue;
562
+ try {
563
+ const mtimeMs = fs.statSync(path.join(markerDir, name)).mtimeMs;
564
+ if (now - mtimeMs <= ANALYZED_RECEIPT_MAX_AGE_MS) return true;
565
+ } catch {
566
+ // ignore
567
+ }
568
+ }
569
+ return false;
570
+ }
571
+
523
572
  function printResult(opts, result) {
524
573
  if (opts.json) {
525
574
  console.log(JSON.stringify(result));
@@ -1,5 +1,4 @@
1
1
  import fs from 'node:fs/promises';
2
- import fsSync from 'node:fs';
3
2
  import path from 'node:path';
4
3
  import { pathToFileURL } from 'node:url';
5
4
  import {
@@ -456,13 +455,17 @@ export const CAPACITY_RECORD_SEGMENTS = ['.ukit', 'storage', 'cache', 'context-c
456
455
  // smaller verified window moves the advisory phases down with the cap they precede.
457
456
  export const SOFT_TO_CAP_RATIO = 0.3;
458
457
 
459
- export function readContextCapacityRecord(projectRoot) {
458
+ // BUG-C22-01: async on purpose — every consumer of this module runs inside an
459
+ // unref'd hook deadline (context-hardcap-gate.sh, skill-router's prompt CLI). A
460
+ // synchronous readFileSync on a stalled .ukit mount parks the event loop so the
461
+ // armed deadline can never fire (C21-04 class). Callers must await.
462
+ export async function readContextCapacityRecord(projectRoot) {
460
463
  const root = projectRoot
461
464
  || process.env.CLAUDE_PROJECT_DIR
462
465
  || process.env.PROJECT_ROOT
463
466
  || process.cwd();
464
467
  try {
465
- const parsed = JSON.parse(fsSync.readFileSync(path.join(root, ...CAPACITY_RECORD_SEGMENTS), 'utf8'));
468
+ const parsed = JSON.parse(await fs.readFile(path.join(root, ...CAPACITY_RECORD_SEGMENTS), 'utf8'));
466
469
  return parsed && typeof parsed === 'object' ? parsed : null;
467
470
  } catch {
468
471
  return null;
@@ -471,8 +474,8 @@ export function readContextCapacityRecord(projectRoot) {
471
474
 
472
475
  // Negotiated capacity: env override > config override > the model the guard last observed >
473
476
  // conservative fallback. Exported so the source twin and diagnostics read the same number.
474
- export function resolveCompactCapacity(config = {}) {
475
- const record = readContextCapacityRecord(config?.projectRoot);
477
+ export async function resolveCompactCapacity(config = {}) {
478
+ const record = await readContextCapacityRecord(config?.projectRoot);
476
479
  return resolveContextCapTokens({
477
480
  env: process.env,
478
481
  config,
@@ -480,13 +483,13 @@ export function resolveCompactCapacity(config = {}) {
480
483
  });
481
484
  }
482
485
 
483
- export function buildCompactThresholds(config = {}) {
486
+ export async function buildCompactThresholds(config = {}) {
484
487
  const explicitSoftThreshold = finiteNumber(config?.compact?.tokenThreshold, 0);
485
488
  const shippedHardCap = positiveInteger(config?.compact?.hardCapTokens, 500_000);
486
489
  // Only tighten once a record exists: an install with no evidence yet (and every caller
487
490
  // that passes no project root) keeps the shipped tuning unchanged.
488
- const record = readContextCapacityRecord(config?.projectRoot);
489
- const negotiated = record ? resolveCompactCapacity(config) : null;
491
+ const record = await readContextCapacityRecord(config?.projectRoot);
492
+ const negotiated = record ? await resolveCompactCapacity(config) : null;
490
493
  const hardCapTokens = negotiated
491
494
  ? Math.max(1, Math.min(shippedHardCap, negotiated.capTokens))
492
495
  : shippedHardCap;
@@ -511,16 +514,16 @@ export function buildCompactThresholds(config = {}) {
511
514
 
512
515
  // Absolute, non-negotiable ceiling — separate from the soft/hard advisory phases above.
513
516
  // Crossing this means the advisory nudges were not followed; real compaction is mandatory.
514
- export function isHardCapExceeded(estimatedTotalTokens, config = {}) {
515
- const { hardCapTokens } = buildCompactThresholds(config);
517
+ export async function isHardCapExceeded(estimatedTotalTokens, config = {}) {
518
+ const { hardCapTokens } = await buildCompactThresholds(config);
516
519
  return finiteNumber(estimatedTotalTokens, 0) >= hardCapTokens;
517
520
  }
518
521
 
519
522
  // Called from the PreCompact hook path once real compaction has happened, so the
520
523
  // hard-cap gate unblocks on the next tool call instead of staying tripped forever.
521
- export function resetCompactPressureState(rawState = {}, config = {}) {
522
- const current = buildCompactPressureState(rawState, config);
523
- return buildCompactPressureState({
524
+ export async function resetCompactPressureState(rawState = {}, config = {}) {
525
+ const current = await buildCompactPressureState(rawState, config);
526
+ return await buildCompactPressureState({
524
527
  ...current,
525
528
  updatedAt: Date.now(),
526
529
  sessionTokens: 0,
@@ -638,7 +641,7 @@ export async function syncCompactPressureStateWithTranscript(
638
641
  }
639
642
  return {
640
643
  state: {
641
- ...resetCompactPressureState(rawState ?? {}, config),
644
+ ...(await resetCompactPressureState(rawState ?? {}, config)),
642
645
  lastCompactBoundaryAt: boundaryAt,
643
646
  transcriptBoundaryProbe: probe,
644
647
  },
@@ -647,7 +650,7 @@ export async function syncCompactPressureStateWithTranscript(
647
650
  };
648
651
  }
649
652
 
650
- function buildReleaseThresholds(thresholds = buildCompactThresholds()) {
653
+ function buildReleaseThresholds(thresholds) {
651
654
  const softReleaseThreshold = Math.max(
652
655
  Math.min(thresholds.softThreshold, Math.round(thresholds.softThreshold * SOFT_RELEASE_RATIO)),
653
656
  1,
@@ -663,11 +666,12 @@ function buildReleaseThresholds(thresholds = buildCompactThresholds()) {
663
666
  };
664
667
  }
665
668
 
666
- export function resolveCompactPhase(
669
+ export async function resolveCompactPhase(
667
670
  estimatedTotalTokens,
668
- thresholds = buildCompactThresholds(),
671
+ thresholds = null,
669
672
  previousState = {},
670
673
  ) {
674
+ thresholds = thresholds ?? await buildCompactThresholds();
671
675
  const previousPhase = normalizePhase(previousState?.phase ?? previousState);
672
676
  const releaseThresholds = buildReleaseThresholds(thresholds);
673
677
  if (estimatedTotalTokens >= thresholds.hardThreshold) {
@@ -689,7 +693,7 @@ function resolvePhaseCooldownMs(phase) {
689
693
  return PHASE_COOLDOWN_MS[normalizePhase(phase)] ?? 0;
690
694
  }
691
695
 
692
- function resolvePlanDeltaThreshold(phase, thresholds = buildCompactThresholds()) {
696
+ function resolvePlanDeltaThreshold(phase, thresholds) {
693
697
  return normalizePhase(phase) === 'hard'
694
698
  ? Math.max(60, Math.round(thresholds.hardThreshold * 0.14))
695
699
  : Math.max(40, Math.round(thresholds.softThreshold * 0.12));
@@ -821,9 +825,9 @@ function projectPressureDocument(sessions) {
821
825
  };
822
826
  }
823
827
 
824
- export function buildCompactPressureState(rawState = null, config = {}) {
828
+ export async function buildCompactPressureState(rawState = null, config = {}) {
825
829
  rawState = resolvePressureRecord(rawState, config);
826
- const thresholds = buildCompactThresholds(config);
830
+ const thresholds = await buildCompactThresholds(config);
827
831
  const rawSoftThreshold = finiteNumber(rawState?.softThreshold, 0);
828
832
  const rawHardThreshold = finiteNumber(rawState?.hardThreshold, 0);
829
833
  if (!finiteNumber(config?.compact?.tokenThreshold, 0) && rawSoftThreshold > 0) {
@@ -872,7 +876,7 @@ export function buildCompactPressureState(rawState = null, config = {}) {
872
876
  estimatedContextTokens,
873
877
  sessionTokens,
874
878
  estimatedTotalTokens,
875
- phase: resolveCompactPhase(estimatedTotalTokens, thresholds, rawState),
879
+ phase: await resolveCompactPhase(estimatedTotalTokens, thresholds, rawState),
876
880
  taskMode,
877
881
  cooldownUntil: finiteNumber(rawState?.cooldownUntil, 0),
878
882
  lastCompactBoundaryAt: finiteNumber(rawState?.lastCompactBoundaryAt, 0),
@@ -888,14 +892,14 @@ export function buildCompactPressureState(rawState = null, config = {}) {
888
892
  };
889
893
  }
890
894
 
891
- export function registerPromptPressure(state, {
895
+ export async function registerPromptPressure(state, {
892
896
  promptText = '',
893
897
  routingContext = {},
894
898
  previousContext = null,
895
899
  routeSummary = null,
896
900
  timestamp = Date.now(),
897
901
  } = {}, config = {}) {
898
- const current = buildCompactPressureState(state, config);
902
+ const current = await buildCompactPressureState(state, config);
899
903
  const normalizedPrompt = String(promptText || routingContext?.lastExplicitUserPromptText || '').trim();
900
904
  if (!normalizedPrompt) {
901
905
  return current;
@@ -916,7 +920,7 @@ export function registerPromptPressure(state, {
916
920
  })
917
921
  : current.recentPrompts;
918
922
 
919
- return buildCompactPressureState({
923
+ return await buildCompactPressureState({
920
924
  ...current,
921
925
  updatedAt: timestamp,
922
926
  routingContext,
@@ -936,7 +940,7 @@ export function registerPromptPressure(state, {
936
940
  }, config);
937
941
  }
938
942
 
939
- export function registerOutputPressure(state, {
943
+ export async function registerOutputPressure(state, {
940
944
  command = '',
941
945
  profile = 'generic',
942
946
  summary = '',
@@ -946,7 +950,7 @@ export function registerOutputPressure(state, {
946
950
  exitCode = null,
947
951
  timestamp = Date.now(),
948
952
  } = {}, config = {}) {
949
- const current = buildCompactPressureState(state, config);
953
+ const current = await buildCompactPressureState(state, config);
950
954
  const normalizedSummary = summarizeOutputText(summary, command);
951
955
  if (!normalizedSummary) {
952
956
  return current;
@@ -968,7 +972,7 @@ export function registerOutputPressure(state, {
968
972
  keyBuilder: (candidate) => `${candidate.command ?? ''}|${candidate.summary ?? ''}`,
969
973
  });
970
974
 
971
- return buildCompactPressureState({
975
+ return await buildCompactPressureState({
972
976
  ...current,
973
977
  updatedAt: timestamp,
974
978
  recentOutputs,
@@ -982,8 +986,8 @@ export function registerOutputPressure(state, {
982
986
  }, config);
983
987
  }
984
988
 
985
- export function registerThresholdCompactPlan(state, plan, config = {}) {
986
- const current = buildCompactPressureState(state, config);
989
+ export async function registerThresholdCompactPlan(state, plan, config = {}) {
990
+ const current = await buildCompactPressureState(state, config);
987
991
  if (!plan?.active) {
988
992
  return current;
989
993
  }
@@ -999,7 +1003,7 @@ export function registerThresholdCompactPlan(state, plan, config = {}) {
999
1003
  lines: plan.lines,
1000
1004
  });
1001
1005
  if (current.latestPlan?.fingerprint === fingerprint) {
1002
- return buildCompactPressureState({
1006
+ return await buildCompactPressureState({
1003
1007
  ...current,
1004
1008
  updatedAt: now,
1005
1009
  latestPlan: {
@@ -1017,7 +1021,7 @@ export function registerThresholdCompactPlan(state, plan, config = {}) {
1017
1021
  const materialChange = isMaterialThresholdPlanChange(current.latestPlan, plan, thresholds);
1018
1022
 
1019
1023
  if (withinCooldown && !materialChange) {
1020
- return buildCompactPressureState({
1024
+ return await buildCompactPressureState({
1021
1025
  ...current,
1022
1026
  updatedAt: now,
1023
1027
  latestPlan: {
@@ -1052,7 +1056,7 @@ export function registerThresholdCompactPlan(state, plan, config = {}) {
1052
1056
  ? Math.min(current.sessionTokens, decayTarget)
1053
1057
  : current.sessionTokens;
1054
1058
 
1055
- return buildCompactPressureState({
1059
+ return await buildCompactPressureState({
1056
1060
  ...current,
1057
1061
  updatedAt: now,
1058
1062
  sessionTokens: decayedSessionTokens,
@@ -1073,7 +1077,7 @@ export function registerThresholdCompactPlan(state, plan, config = {}) {
1073
1077
  }, config);
1074
1078
  }
1075
1079
 
1076
- export function buildThresholdCompactPlan({
1080
+ export async function buildThresholdCompactPlan({
1077
1081
  state,
1078
1082
  config = {},
1079
1083
  routeState = {},
@@ -1081,8 +1085,8 @@ export function buildThresholdCompactPlan({
1081
1085
  dynamicLines = [],
1082
1086
  maxTokens = 280,
1083
1087
  } = {}) {
1084
- const normalizedState = buildCompactPressureState(state, config);
1085
- const thresholds = buildCompactThresholds(config);
1088
+ const normalizedState = await buildCompactPressureState(state, config);
1089
+ const thresholds = await buildCompactThresholds(config);
1086
1090
  const phase = normalizedState.phase;
1087
1091
  if (phase === 'monitor') {
1088
1092
  return {
@@ -1205,7 +1209,7 @@ export function buildThresholdCompactPlan({
1205
1209
 
1206
1210
  export async function readCompactPressureState(projectRoot, config = {}) {
1207
1211
  const runtimePaths = buildRuntimePaths(projectRoot);
1208
- return buildCompactPressureState(await readJson(runtimePaths.compactPressurePath, null), config);
1212
+ return await buildCompactPressureState(await readJson(runtimePaths.compactPressurePath, null), config);
1209
1213
  }
1210
1214
 
1211
1215
  // All compact-pressure mutations share one lock on the state file: without it,
@@ -1225,9 +1229,9 @@ async function mutateCompactPressureState(projectRoot, mutator, config = {}) {
1225
1229
  return withFileLock(runtimePaths.compactPressurePath, async () => {
1226
1230
  const sessions = readPressureDocument(await readJson(runtimePaths.compactPressurePath, null));
1227
1231
  const { id, record } = pickPressureSession(sessions, normalizeSessionId(config?.sessionId));
1228
- const current = buildCompactPressureState(record, scopedConfig);
1229
- const next = mutator(current);
1230
- const normalized = buildCompactPressureState(next, scopedConfig);
1232
+ const current = await buildCompactPressureState(record, scopedConfig);
1233
+ const next = await mutator(current);
1234
+ const normalized = await buildCompactPressureState(next, scopedConfig);
1231
1235
  sessions[id] = { ...normalized, updatedAt: Date.now() };
1232
1236
  await writeJson(runtimePaths.compactPressurePath, projectPressureDocument(sessions));
1233
1237
  return normalized;
@@ -1240,7 +1244,7 @@ export async function writeCompactPressureState(projectRoot, state, config = {})
1240
1244
  return withFileLock(runtimePaths.compactPressurePath, async () => {
1241
1245
  const sessions = readPressureDocument(await readJson(runtimePaths.compactPressurePath, null));
1242
1246
  const { id } = pickPressureSession(sessions, normalizeSessionId(config?.sessionId));
1243
- const normalized = buildCompactPressureState(state, scopedConfig);
1247
+ const normalized = await buildCompactPressureState(state, scopedConfig);
1244
1248
  sessions[id] = { ...normalized, updatedAt: Date.now() };
1245
1249
  await writeJson(runtimePaths.compactPressurePath, projectPressureDocument(sessions));
1246
1250
  return normalized;
@@ -1306,6 +1310,24 @@ function extractPrompt(payload = {}) {
1306
1310
  }
1307
1311
 
1308
1312
  async function runCli() {
1313
+ // BUG-C22-15: this CLI is spawned unbounded by skill-router.sh — every read it
1314
+ // does (config, pressure record, transcript tail, capacity record) sits on the
1315
+ // .ukit/transcript filesystem, so a stalled mount used to hang the child forever
1316
+ // (the shell `|| true` swallows exit codes, not time, and the host SIGKILLs
1317
+ // silently). Arm a self-deadline so ALL invokers get a bounded, announced exit.
1318
+ const CLI_DEADLINE_MS = Number.parseInt(process.env.UKIT_COMPACT_CLI_DEADLINE_MS || '', 10) || 4000;
1319
+ const cliDeadline = setTimeout(() => {
1320
+ try {
1321
+ process.stdout.write(
1322
+ `[ukit-skill-router] compact-threshold prompt CLI exceeded its ${CLI_DEADLINE_MS}ms deadline — `
1323
+ + 'pressure bookkeeping skipped this pass (likely stalled filesystem). The hook continues unharmed; '
1324
+ + 'if this repeats, check the .ukit volume or raise UKIT_COMPACT_CLI_DEADLINE_MS.\n',
1325
+ );
1326
+ } catch { /* stdout already gone */ }
1327
+ process.exit(0);
1328
+ }, CLI_DEADLINE_MS);
1329
+ cliDeadline.unref();
1330
+
1309
1331
  const mode = String(process.argv[2] ?? '').trim();
1310
1332
  if (!mode) {
1311
1333
  return;
@@ -149,17 +149,25 @@ export async function sweepExecLedgerDir(dir, {
149
149
  maxEntries = LEDGER_SWEEP_MAX_ENTRIES,
150
150
  maxRemovals = LEDGER_SWEEP_MAX_REMOVALS,
151
151
  } = {}) {
152
- let names;
152
+ let dirents;
153
153
  try {
154
- names = await fs.readdir(dir);
154
+ dirents = await fs.readdir(dir, { withFileTypes: true });
155
155
  } catch {
156
156
  return { scanned: 0, removed: 0 };
157
157
  }
158
158
  const cutoff = now() - maxAgeMs;
159
159
  const countCapCutoff = now() - LEDGER_COUNT_CAP_MIN_AGE_MS;
160
+ // BUG-C22-12: eligible count comes from the dirents (one bounded readdir, no
161
+ // per-name stat) so the count cap is enforceable even though
162
+ // maxFiles > maxEntries — capping `scanned` first made `entries.length` an
163
+ // under-count and `overflow` was always 0.
164
+ const eligibleNames = [];
165
+ for (const dirent of dirents) {
166
+ if (dirent.isFile()) eligibleNames.push(dirent.name);
167
+ }
160
168
  const entries = [];
161
169
  let scanned = 0;
162
- for (const name of names) {
170
+ for (const name of eligibleNames) {
163
171
  if (scanned >= maxEntries) break;
164
172
  scanned += 1;
165
173
  try {
@@ -169,9 +177,9 @@ export async function sweepExecLedgerDir(dir, {
169
177
  }
170
178
  entries.sort((a, b) => a.mtimeMs - b.mtimeMs); // oldest first
171
179
  // Overflow counts eligible FILES only — foreign entries (`.lock` dirs, other
172
- // state files sharing the dir) inflate `names.length` and would evict real
180
+ // state files sharing the dir) inflate the dir listing and would evict real
173
181
  // ledgers while the dir is actually under the cap.
174
- const overflow = Math.max(0, entries.length - maxFiles);
182
+ const overflow = Math.max(0, eligibleNames.length - maxFiles);
175
183
  let removed = 0;
176
184
  for (const entry of entries) {
177
185
  if (removed >= maxRemovals) break;
@@ -1701,6 +1709,11 @@ function isDirectRun() {
1701
1709
  const selfPath = fileURLToPath(import.meta.url);
1702
1710
  const resolved = path.resolve(argvPath);
1703
1711
  if (selfPath === resolved) return true;
1712
+ // BUG-C22-01: a stalled mount must not park the event loop during `await import`
1713
+ // — importing this module inside an armed hook deadline used to run realpathSync
1714
+ // here for EVERY importer. Skip fs entirely when the basename cannot match (any
1715
+ // importer argv like reinject-context.mjs differs from execution-ledger.mjs).
1716
+ if (path.basename(selfPath) !== path.basename(resolved)) return false;
1704
1717
  try {
1705
1718
  return fsSync.realpathSync(selfPath) === fsSync.realpathSync(resolved);
1706
1719
  } catch {
@@ -17,13 +17,20 @@
17
17
  //
18
18
  // The clamps are intentionally generous (up to 120s) because they are an
19
19
  // explicit operator/test affordance, not a default: an unconfigured chain still
20
- // resolves to the 10s/4s/16s defaults that keep a PreToolUse call inside omp's
21
- // own handler budget.
20
+ // resolves to the 10s/12s/52s defaults documented below.
21
+ //
22
+ // BUG-C22-25: the child fallback MUST cover a child's own internal worst case —
23
+ // every staged .sh hook stages stdin (UKIT_HOOK_STAGE_MS default 2000) then arms
24
+ // a node deadline of 3000-8000ms, so a child legitimately needs up to ~10s. The
25
+ // old 4000ms fallback killed legal hooks mid-evaluation (recorded
26
+ // failureKind:'timeout'; on tool_call chains the killed script can be a
27
+ // FAIL_CLOSED gate). 12000 = 2000 stage + 8000 deadline + 2000 margin. The
28
+ // ceiling fallback scales with it: 4 chained children × 12000 + 4000 slack.
22
29
 
23
30
  export const CHAIN_BUDGET_SPECS = {
24
31
  base: { name: 'UKIT_HOOK_CHAIN_BASE_MS', fallback: 10000, min: 500, max: 60000 },
25
- child: { name: 'UKIT_HOOK_CHILD_BUDGET_MS', fallback: 4000, min: 100, max: 60000 },
26
- ceiling: { name: 'UKIT_HOOK_CHAIN_CEILING_MS', fallback: 16000, min: 500, max: 120000 },
32
+ child: { name: 'UKIT_HOOK_CHILD_BUDGET_MS', fallback: 12000, min: 100, max: 60000 },
33
+ ceiling: { name: 'UKIT_HOOK_CHAIN_CEILING_MS', fallback: 52000, min: 500, max: 120000 },
27
34
  };
28
35
 
29
36
  // Margin the outer timeout adds on top of the runner's own budget: enough to
@@ -153,12 +153,16 @@ export function sweepTelemetryDir(dir, {
153
153
  return { scanned: 0, removed: 0 };
154
154
  }
155
155
  const cutoff = now() - maxAgeMs;
156
+ // BUG-C22-11: eligible count is computed from the full name listing (bounded
157
+ // by the single readdir, no per-name stat) so the count cap is enforceable
158
+ // even though maxFiles > maxEntries — capping `scanned` first made
159
+ // `entries.length` an under-count and `overflow` was always 0.
160
+ const eligibleNames = names.filter((name) => name.endsWith('.jsonl'));
156
161
  const entries = [];
157
162
  let scanned = 0;
158
- for (const name of names) {
163
+ for (const name of eligibleNames) {
159
164
  if (scanned >= maxEntries) break;
160
165
  scanned += 1;
161
- if (!name.endsWith('.jsonl')) continue;
162
166
  try {
163
167
  const stats = fs.statSync(path.join(dir, name));
164
168
  if (stats.isFile()) entries.push({ name, mtimeMs: stats.mtimeMs });
@@ -168,7 +172,7 @@ export function sweepTelemetryDir(dir, {
168
172
  // Overflow counts eligible FILES only — foreign entries (non-matching names,
169
173
  // dirs) inflate `names.length` and would evict real entries while the dir is
170
174
  // actually under the cap.
171
- const overflow = Math.max(0, entries.length - maxFiles);
175
+ const overflow = Math.max(0, eligibleNames.length - maxFiles);
172
176
  let removed = 0;
173
177
  for (const entry of entries) {
174
178
  if (removed >= maxRemovals) break;
@@ -410,16 +410,27 @@ export async function sweepTeeCache(dir, {
410
410
  maxEntries = TEE_SWEEP_MAX_ENTRIES,
411
411
  maxRemovals = TEE_SWEEP_MAX_REMOVALS,
412
412
  } = {}) {
413
- let names;
413
+ let dirents;
414
414
  try {
415
- names = await fs.readdir(dir);
415
+ dirents = await fs.readdir(dir, { withFileTypes: true });
416
416
  } catch {
417
417
  return { sampled: true, scanned: 0, removed: 0 };
418
418
  }
419
+ // Eligible-file count comes from the dirents (one bounded readdir, no per-name
420
+ // stat) so the count cap is enforceable even when maxFiles > maxEntries —
421
+ // BUG-C22-10: capping `scanned` at maxEntries made `entries.length` an
422
+ // under-count and `overflow` was always 0. Expected drain: each sampled sweep
423
+ // removes up to maxRemovals, so a backlog of B files converges after
424
+ // ~ceil(B/maxRemovals) sweeps (~16x that many writes at the default 1/16
425
+ // sampling); the 7d age cutoff drains the rest even without new writes.
426
+ const eligibleNames = [];
427
+ for (const dirent of dirents) {
428
+ if (dirent.isFile()) eligibleNames.push(dirent.name);
429
+ }
419
430
  const cutoff = now() - maxAgeMs;
420
431
  const entries = [];
421
432
  let scanned = 0;
422
- for (const name of names) {
433
+ for (const name of eligibleNames) {
423
434
  if (scanned >= maxEntries) break;
424
435
  scanned += 1;
425
436
  try {
@@ -429,9 +440,9 @@ export async function sweepTeeCache(dir, {
429
440
  }
430
441
  entries.sort((a, b) => a.mtimeMs - b.mtimeMs); // oldest first
431
442
  // Overflow counts eligible FILES only — foreign entries (non-matching names,
432
- // dirs) inflate `names.length` and would evict real entries while the dir is
443
+ // dirs) inflate the dir listing and would evict real entries while the dir is
433
444
  // actually under the cap.
434
- const overflow = Math.max(0, entries.length - maxFiles);
445
+ const overflow = Math.max(0, eligibleNames.length - maxFiles);
435
446
  let removed = 0;
436
447
  for (const entry of entries) {
437
448
  if (removed >= maxRemovals) break;
@@ -108,7 +108,7 @@ async function main() {
108
108
  // (writeThresholdCompactPlan) re-read from disk, so they build on the zeroed state and
109
109
  // keep their own stats increments intact. Session-scoped: sibling sessions keep their
110
110
  // own records.
111
- await writeCompactPressureState(projectRoot, resetCompactPressureState(pressureState, sessionConfig), sessionConfig);
111
+ await writeCompactPressureState(projectRoot, await resetCompactPressureState(pressureState, sessionConfig), sessionConfig);
112
112
  const anchorLines = dynamicLines.filter((line) => (
113
113
  line.startsWith('- Recent routed lane:')
114
114
  || line.startsWith('- Current approach:')
@@ -124,7 +124,7 @@ async function main() {
124
124
  || line.startsWith('- Recent delegation hint:')
125
125
  ));
126
126
  const maxTokens = Math.max(160, Math.min(320, Number(config.memory?.maxInjectionTokens) || 320));
127
- const thresholdPlan = buildThresholdCompactPlan({
127
+ const thresholdPlan = await buildThresholdCompactPlan({
128
128
  state: pressureState,
129
129
  config,
130
130
  routeState: state,
@@ -683,6 +683,9 @@ function isDirectRun() {
683
683
  selfUrlPath = null;
684
684
  }
685
685
  if (selfUrlPath && selfUrlPath === selfPath) return true;
686
+ // BUG-C22-01: skip fs entirely for non-matching importers — a stalled mount
687
+ // must not park the event loop during `await import` inside an armed deadline.
688
+ if (selfUrlPath && path.basename(selfUrlPath) !== path.basename(selfPath)) return false;
686
689
  try {
687
690
  return fsSync.realpathSync(selfUrlPath) === fsSync.realpathSync(selfPath);
688
691
  } catch {
@@ -175,8 +175,15 @@ export async function withFileLock(filePath, fn, { staleMs = LOCK_STALE_MS, maxW
175
175
  continue; // the slot is free now — retry immediately
176
176
  }
177
177
  }
178
- } catch {
179
- continue; // lock vanished between mkdir and stat — retry immediately
178
+ } catch (statError) {
179
+ // BUG-C22-16: a persistent stat error (EPERM/ENOTDIR/EIO on a failing
180
+ // mount, or ELOOP/ENOENT on a dangling symlink where mkdir still reports
181
+ // EEXIST) must not busy-spin — a bare `continue` skipped both the
182
+ // maxWait break and the backoff sleep, looping mkdir→stat→throw forever.
183
+ if (Date.now() - startedAt >= maxWaitMs) break; // fail open — run unlocked
184
+ if (statError?.code === 'ENOENT') continue; // lock vanished — retry immediately
185
+ await sleep(lockBackoffDelayMs());
186
+ continue;
180
187
  }
181
188
 
182
189
  if (Date.now() - startedAt >= maxWaitMs) break; // fail open — run unlocked