@ngockhoale/ukit 2.6.3 → 2.6.5

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 (40) hide show
  1. package/CHANGELOG.md +123 -0
  2. package/bin/ukit +30 -10
  3. package/package.json +1 -1
  4. package/scripts/bench/parallel-agents.mjs +42 -4
  5. package/scripts/release/verify-release.mjs +43 -6
  6. package/src/core/applyPlan.js +65 -0
  7. package/src/core/compact/threshold.js +33 -30
  8. package/src/core/fileOps.js +9 -2
  9. package/src/core/memory/store.js +10 -3
  10. package/src/core/runInstallPipeline.js +10 -1
  11. package/src/core/runtimeConfig.js +2 -0
  12. package/src/core/status.js +1 -1
  13. package/templates/.claude/hooks/auto-allow-bash.sh +29 -20
  14. package/templates/.claude/hooks/auto-prune-bash.sh +81 -30
  15. package/templates/.claude/hooks/completion-gate.sh +42 -2
  16. package/templates/.claude/hooks/context-hardcap-gate.sh +26 -4
  17. package/templates/.claude/hooks/handoff-model-guard.sh +10 -1
  18. package/templates/.claude/hooks/handoff-resume.sh +32 -17
  19. package/templates/.claude/hooks/post-edit-verify.sh +1 -0
  20. package/templates/.claude/hooks/pre-edit-backup.sh +1 -0
  21. package/templates/.claude/hooks/reset-compact-pressure.sh +39 -22
  22. package/templates/.claude/hooks/skill-router.sh +180 -27
  23. package/templates/.claude/hooks/stale-spec-guard.sh +1 -0
  24. package/templates/.claude/hooks/task-watchdog.sh +30 -5
  25. package/templates/.claude/hooks/verification-guard.sh +17 -1
  26. package/templates/.claude/hooks/vision-router.sh +14 -0
  27. package/templates/.claude/settings.json +4 -4
  28. package/templates/.claude/ukit/index/extract-image.mjs +49 -0
  29. package/templates/.claude/ukit/index/post-edit-verify.mjs +33 -1
  30. package/templates/.claude/ukit/index/stale-spec-check.mjs +32 -1
  31. package/templates/.claude/ukit/runtime/compact-threshold.mjs +70 -40
  32. package/templates/.claude/ukit/runtime/execution-ledger.mjs +37 -6
  33. package/templates/.claude/ukit/runtime/hook-chain-budget.mjs +11 -4
  34. package/templates/.claude/ukit/runtime/hook-telemetry.mjs +24 -4
  35. package/templates/.claude/ukit/runtime/output-compression.mjs +73 -12
  36. package/templates/.claude/ukit/runtime/reinject-context.mjs +39 -7
  37. package/templates/.claude/ukit/runtime/stop-coordinator.mjs +8 -1
  38. package/templates/.claude/ukit/runtime/token-utils.mjs +9 -2
  39. package/templates/.omp/hooks/pre/ukit-bridge.js +72 -4
  40. package/templates/ukit/storage/config.json +2 -0
@@ -1,4 +1,5 @@
1
1
  import fs from 'node:fs/promises';
2
+ import fsSync from 'node:fs';
2
3
  import path from 'node:path';
3
4
  import { fileURLToPath } from 'node:url';
4
5
  import { analyzeTextBuffer, analyzeTextFile, publicTextProfile } from '../runtime/text-profile.mjs';
@@ -7,9 +8,27 @@ import { classifySafePatchRisk, isSafePatchAdvisoryOnly, parseJsonInput, pathExi
7
8
  // Hook-context self-deadline (2.4.1 orphan-leak class): the post-edit-verify hook passes
8
9
  // UKIT_HOOK_DEADLINE_MS so a wedged read can never orphan this process past the hook
9
10
  // budget. Non-hook usage never sets it and is never self-killed.
11
+ // SPEC §8: stdout is the only surface the hook consumer reliably shows the
12
+ // user (stderr reaches the model). writeSync(1) because a same-tick
13
+ // process.exit() can discard a buffered async write on a pipe.
14
+ function emitSystemMessage(text) {
15
+ try {
16
+ fsSync.writeSync(1, `${JSON.stringify({ systemMessage: text })}\n`);
17
+ } catch { /* stdout already gone */ }
18
+ }
19
+
10
20
  const HOOK_DEADLINE_MS = Number.parseInt(process.env.UKIT_HOOK_DEADLINE_MS || '', 10);
11
21
  if (Number.isFinite(HOOK_DEADLINE_MS) && HOOK_DEADLINE_MS > 0) {
12
- setTimeout(() => process.exit(0), HOOK_DEADLINE_MS).unref();
22
+ setTimeout(() => {
23
+ // BUG-C23-04: deadline expiry abandons the configured post-edit verify — a
24
+ // degrade, and §8(ii) requires it be announced before the exit.
25
+ emitSystemMessage(
26
+ `UKit post-edit-verify exceeded its ${HOOK_DEADLINE_MS}ms hook deadline — `
27
+ + 'Safe Patch delta verification skipped this pass (likely stalled filesystem). '
28
+ + 'If this repeats, check the .ukit volume or raise UKIT_HOOK_DEADLINE_MS.',
29
+ );
30
+ process.exit(0);
31
+ }, HOOK_DEADLINE_MS).unref();
13
32
  }
14
33
 
15
34
  async function readStdin() {
@@ -220,6 +239,13 @@ async function main() {
220
239
  process.stderr.write('[ukit-safe-patch] advisoryOnly=true — change is already written; continue planned follow-up without stopping.\n');
221
240
  return;
222
241
  }
242
+ // BUG-C23-03: exit-2 blocks must pair the stderr reason with a stdout §8
243
+ // emission — stderr reaches the model, not reliably the user
244
+ // (precedent: context-hardcap-gate.sh, handoff-model-guard.sh).
245
+ // --json mode: the verdict JSON already written above IS the structured
246
+ // announcement (status+message fields); a second raw line would corrupt
247
+ // the machine-parseable stdout contract.
248
+ if (!json) emitSystemMessage(`[ukit-safe-patch] ${message}`);
223
249
  process.exit(2);
224
250
  }
225
251
  }
@@ -227,6 +253,12 @@ async function main() {
227
253
  if (process.argv[1] && path.basename(fileURLToPath(import.meta.url)) === path.basename(process.argv[1])) {
228
254
  main().catch((error) => {
229
255
  process.stderr.write(`[ukit-safe-patch] post-edit ERROR: ${error?.message || error}\n`);
256
+ // BUG-C23-01: stderr-only crash announces nothing to the user — §8 requires
257
+ // the paired stdout systemMessage before the non-zero exit.
258
+ emitSystemMessage(
259
+ `UKit post-edit-verify crashed before it could verify the edit (${error?.message || error}); `
260
+ + 'the Safe Patch check was skipped this pass. Run `ukit doctor` if this repeats.',
261
+ );
230
262
  process.exit(1);
231
263
  });
232
264
  }
@@ -1,5 +1,6 @@
1
1
  import { analyzeTextFile, publicTextProfile } from '../runtime/text-profile.mjs';
2
2
  import { fileURLToPath } from 'node:url';
3
+ import fsSync from 'node:fs';
3
4
  import path from 'node:path';
4
5
  import {
5
6
  classifySafePatchRisk,
@@ -16,9 +17,27 @@ import {
16
17
  // Hook-context self-deadline (2.4.1 orphan-leak class): the stale-spec-guard hook passes
17
18
  // UKIT_HOOK_DEADLINE_MS so a wedged read can never orphan this process past the hook
18
19
  // budget. Non-hook usage never sets it and is never self-killed.
20
+ // SPEC §8: stdout is the only surface the hook consumer reliably shows the
21
+ // user (stderr reaches the model). writeSync(1) because a same-tick
22
+ // process.exit() can discard a buffered async write on a pipe.
23
+ function emitSystemMessage(text) {
24
+ try {
25
+ fsSync.writeSync(1, `${JSON.stringify({ systemMessage: text })}\n`);
26
+ } catch { /* stdout already gone */ }
27
+ }
28
+
19
29
  const HOOK_DEADLINE_MS = Number.parseInt(process.env.UKIT_HOOK_DEADLINE_MS || '', 10);
20
30
  if (Number.isFinite(HOOK_DEADLINE_MS) && HOOK_DEADLINE_MS > 0) {
21
- setTimeout(() => process.exit(0), HOOK_DEADLINE_MS).unref();
31
+ setTimeout(() => {
32
+ // BUG-C23-04: deadline expiry abandons the configured stale-spec check — a
33
+ // degrade, and §8(ii) requires it be announced before the exit.
34
+ emitSystemMessage(
35
+ `UKit stale-spec-check exceeded its ${HOOK_DEADLINE_MS}ms hook deadline — `
36
+ + 'Safe Patch spec verification skipped this pass (likely stalled filesystem). '
37
+ + 'If this repeats, check the .ukit volume or raise UKIT_HOOK_DEADLINE_MS.',
38
+ );
39
+ process.exit(0);
40
+ }, HOOK_DEADLINE_MS).unref();
22
41
  }
23
42
 
24
43
  function getToolName(payload = {}) {
@@ -198,6 +217,13 @@ async function main() {
198
217
  process.stderr.write('[ukit-safe-patch] advisoryOnly=true — continue with corrected old_string; do not stop and ask the user.\n');
199
218
  return;
200
219
  }
220
+ // BUG-C23-03: exit-2 blocks must pair the stderr reason with a stdout §8
221
+ // emission — stderr reaches the model, not reliably the user
222
+ // (precedent: context-hardcap-gate.sh, handoff-model-guard.sh).
223
+ // --json mode: the verdict JSON above IS the structured announcement —
224
+ // a second raw line would corrupt the parseable stdout contract
225
+ // (sibling parity: post-edit-verify.mjs).
226
+ if (!args.json) emitSystemMessage(`[ukit-safe-patch] ${message}`);
201
227
  process.exit(2);
202
228
  }
203
229
  }
@@ -205,6 +231,11 @@ async function main() {
205
231
  if (process.argv[1] && path.basename(fileURLToPath(import.meta.url)) === path.basename(process.argv[1])) {
206
232
  main().catch((error) => {
207
233
  process.stderr.write(`[ukit-safe-patch] ERROR: ${error?.message || error}\n`);
234
+ // Same stderr-only crash class as BUG-C23-01 — pair the §8 stdout emission.
235
+ emitSystemMessage(
236
+ `UKit stale-spec-check crashed before it could verify the edit (${error?.message || error}); `
237
+ + 'the Safe Patch spec check was skipped this pass. Run `ukit doctor` if this repeats.',
238
+ );
208
239
  process.exit(1);
209
240
  });
210
241
  }
@@ -456,13 +456,17 @@ export const CAPACITY_RECORD_SEGMENTS = ['.ukit', 'storage', 'cache', 'context-c
456
456
  // smaller verified window moves the advisory phases down with the cap they precede.
457
457
  export const SOFT_TO_CAP_RATIO = 0.3;
458
458
 
459
- export function readContextCapacityRecord(projectRoot) {
459
+ // BUG-C22-01: async on purpose — every consumer of this module runs inside an
460
+ // unref'd hook deadline (context-hardcap-gate.sh, skill-router's prompt CLI). A
461
+ // synchronous readFileSync on a stalled .ukit mount parks the event loop so the
462
+ // armed deadline can never fire (C21-04 class). Callers must await.
463
+ export async function readContextCapacityRecord(projectRoot) {
460
464
  const root = projectRoot
461
465
  || process.env.CLAUDE_PROJECT_DIR
462
466
  || process.env.PROJECT_ROOT
463
467
  || process.cwd();
464
468
  try {
465
- const parsed = JSON.parse(fsSync.readFileSync(path.join(root, ...CAPACITY_RECORD_SEGMENTS), 'utf8'));
469
+ const parsed = JSON.parse(await fs.readFile(path.join(root, ...CAPACITY_RECORD_SEGMENTS), 'utf8'));
466
470
  return parsed && typeof parsed === 'object' ? parsed : null;
467
471
  } catch {
468
472
  return null;
@@ -471,22 +475,25 @@ export function readContextCapacityRecord(projectRoot) {
471
475
 
472
476
  // Negotiated capacity: env override > config override > the model the guard last observed >
473
477
  // conservative fallback. Exported so the source twin and diagnostics read the same number.
474
- export function resolveCompactCapacity(config = {}) {
475
- const record = readContextCapacityRecord(config?.projectRoot);
478
+ export async function resolveCompactCapacity(config = {}, { record } = {}) {
479
+ // BUG-C23-09: callers that already read the capacity record (buildCompactThresholds)
480
+ // pass it in — a second serial fs.readFile on .ukit doubles the stalled-mount
481
+ // window on the hardcap hot path.
482
+ const resolvedRecord = record !== undefined ? record : await readContextCapacityRecord(config?.projectRoot);
476
483
  return resolveContextCapTokens({
477
484
  env: process.env,
478
485
  config,
479
- modelMetadata: { model: record?.model },
486
+ modelMetadata: { model: resolvedRecord?.model },
480
487
  });
481
488
  }
482
489
 
483
- export function buildCompactThresholds(config = {}) {
490
+ export async function buildCompactThresholds(config = {}) {
484
491
  const explicitSoftThreshold = finiteNumber(config?.compact?.tokenThreshold, 0);
485
492
  const shippedHardCap = positiveInteger(config?.compact?.hardCapTokens, 500_000);
486
493
  // Only tighten once a record exists: an install with no evidence yet (and every caller
487
494
  // that passes no project root) keeps the shipped tuning unchanged.
488
- const record = readContextCapacityRecord(config?.projectRoot);
489
- const negotiated = record ? resolveCompactCapacity(config) : null;
495
+ const record = await readContextCapacityRecord(config?.projectRoot);
496
+ const negotiated = record ? await resolveCompactCapacity(config, { record }) : null;
490
497
  const hardCapTokens = negotiated
491
498
  ? Math.max(1, Math.min(shippedHardCap, negotiated.capTokens))
492
499
  : shippedHardCap;
@@ -511,16 +518,16 @@ export function buildCompactThresholds(config = {}) {
511
518
 
512
519
  // Absolute, non-negotiable ceiling — separate from the soft/hard advisory phases above.
513
520
  // Crossing this means the advisory nudges were not followed; real compaction is mandatory.
514
- export function isHardCapExceeded(estimatedTotalTokens, config = {}) {
515
- const { hardCapTokens } = buildCompactThresholds(config);
521
+ export async function isHardCapExceeded(estimatedTotalTokens, config = {}) {
522
+ const { hardCapTokens } = await buildCompactThresholds(config);
516
523
  return finiteNumber(estimatedTotalTokens, 0) >= hardCapTokens;
517
524
  }
518
525
 
519
526
  // Called from the PreCompact hook path once real compaction has happened, so the
520
527
  // 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({
528
+ export async function resetCompactPressureState(rawState = {}, config = {}) {
529
+ const current = await buildCompactPressureState(rawState, config);
530
+ return await buildCompactPressureState({
524
531
  ...current,
525
532
  updatedAt: Date.now(),
526
533
  sessionTokens: 0,
@@ -638,7 +645,7 @@ export async function syncCompactPressureStateWithTranscript(
638
645
  }
639
646
  return {
640
647
  state: {
641
- ...resetCompactPressureState(rawState ?? {}, config),
648
+ ...(await resetCompactPressureState(rawState ?? {}, config)),
642
649
  lastCompactBoundaryAt: boundaryAt,
643
650
  transcriptBoundaryProbe: probe,
644
651
  },
@@ -647,7 +654,7 @@ export async function syncCompactPressureStateWithTranscript(
647
654
  };
648
655
  }
649
656
 
650
- function buildReleaseThresholds(thresholds = buildCompactThresholds()) {
657
+ function buildReleaseThresholds(thresholds) {
651
658
  const softReleaseThreshold = Math.max(
652
659
  Math.min(thresholds.softThreshold, Math.round(thresholds.softThreshold * SOFT_RELEASE_RATIO)),
653
660
  1,
@@ -663,11 +670,12 @@ function buildReleaseThresholds(thresholds = buildCompactThresholds()) {
663
670
  };
664
671
  }
665
672
 
666
- export function resolveCompactPhase(
673
+ export async function resolveCompactPhase(
667
674
  estimatedTotalTokens,
668
- thresholds = buildCompactThresholds(),
675
+ thresholds = null,
669
676
  previousState = {},
670
677
  ) {
678
+ thresholds = thresholds ?? await buildCompactThresholds();
671
679
  const previousPhase = normalizePhase(previousState?.phase ?? previousState);
672
680
  const releaseThresholds = buildReleaseThresholds(thresholds);
673
681
  if (estimatedTotalTokens >= thresholds.hardThreshold) {
@@ -689,7 +697,7 @@ function resolvePhaseCooldownMs(phase) {
689
697
  return PHASE_COOLDOWN_MS[normalizePhase(phase)] ?? 0;
690
698
  }
691
699
 
692
- function resolvePlanDeltaThreshold(phase, thresholds = buildCompactThresholds()) {
700
+ function resolvePlanDeltaThreshold(phase, thresholds) {
693
701
  return normalizePhase(phase) === 'hard'
694
702
  ? Math.max(60, Math.round(thresholds.hardThreshold * 0.14))
695
703
  : Math.max(40, Math.round(thresholds.softThreshold * 0.12));
@@ -821,9 +829,9 @@ function projectPressureDocument(sessions) {
821
829
  };
822
830
  }
823
831
 
824
- export function buildCompactPressureState(rawState = null, config = {}) {
832
+ export async function buildCompactPressureState(rawState = null, config = {}) {
825
833
  rawState = resolvePressureRecord(rawState, config);
826
- const thresholds = buildCompactThresholds(config);
834
+ const thresholds = await buildCompactThresholds(config);
827
835
  const rawSoftThreshold = finiteNumber(rawState?.softThreshold, 0);
828
836
  const rawHardThreshold = finiteNumber(rawState?.hardThreshold, 0);
829
837
  if (!finiteNumber(config?.compact?.tokenThreshold, 0) && rawSoftThreshold > 0) {
@@ -872,7 +880,7 @@ export function buildCompactPressureState(rawState = null, config = {}) {
872
880
  estimatedContextTokens,
873
881
  sessionTokens,
874
882
  estimatedTotalTokens,
875
- phase: resolveCompactPhase(estimatedTotalTokens, thresholds, rawState),
883
+ phase: await resolveCompactPhase(estimatedTotalTokens, thresholds, rawState),
876
884
  taskMode,
877
885
  cooldownUntil: finiteNumber(rawState?.cooldownUntil, 0),
878
886
  lastCompactBoundaryAt: finiteNumber(rawState?.lastCompactBoundaryAt, 0),
@@ -888,14 +896,14 @@ export function buildCompactPressureState(rawState = null, config = {}) {
888
896
  };
889
897
  }
890
898
 
891
- export function registerPromptPressure(state, {
899
+ export async function registerPromptPressure(state, {
892
900
  promptText = '',
893
901
  routingContext = {},
894
902
  previousContext = null,
895
903
  routeSummary = null,
896
904
  timestamp = Date.now(),
897
905
  } = {}, config = {}) {
898
- const current = buildCompactPressureState(state, config);
906
+ const current = await buildCompactPressureState(state, config);
899
907
  const normalizedPrompt = String(promptText || routingContext?.lastExplicitUserPromptText || '').trim();
900
908
  if (!normalizedPrompt) {
901
909
  return current;
@@ -916,7 +924,7 @@ export function registerPromptPressure(state, {
916
924
  })
917
925
  : current.recentPrompts;
918
926
 
919
- return buildCompactPressureState({
927
+ return await buildCompactPressureState({
920
928
  ...current,
921
929
  updatedAt: timestamp,
922
930
  routingContext,
@@ -936,7 +944,7 @@ export function registerPromptPressure(state, {
936
944
  }, config);
937
945
  }
938
946
 
939
- export function registerOutputPressure(state, {
947
+ export async function registerOutputPressure(state, {
940
948
  command = '',
941
949
  profile = 'generic',
942
950
  summary = '',
@@ -946,7 +954,7 @@ export function registerOutputPressure(state, {
946
954
  exitCode = null,
947
955
  timestamp = Date.now(),
948
956
  } = {}, config = {}) {
949
- const current = buildCompactPressureState(state, config);
957
+ const current = await buildCompactPressureState(state, config);
950
958
  const normalizedSummary = summarizeOutputText(summary, command);
951
959
  if (!normalizedSummary) {
952
960
  return current;
@@ -968,7 +976,7 @@ export function registerOutputPressure(state, {
968
976
  keyBuilder: (candidate) => `${candidate.command ?? ''}|${candidate.summary ?? ''}`,
969
977
  });
970
978
 
971
- return buildCompactPressureState({
979
+ return await buildCompactPressureState({
972
980
  ...current,
973
981
  updatedAt: timestamp,
974
982
  recentOutputs,
@@ -982,8 +990,8 @@ export function registerOutputPressure(state, {
982
990
  }, config);
983
991
  }
984
992
 
985
- export function registerThresholdCompactPlan(state, plan, config = {}) {
986
- const current = buildCompactPressureState(state, config);
993
+ export async function registerThresholdCompactPlan(state, plan, config = {}) {
994
+ const current = await buildCompactPressureState(state, config);
987
995
  if (!plan?.active) {
988
996
  return current;
989
997
  }
@@ -999,7 +1007,7 @@ export function registerThresholdCompactPlan(state, plan, config = {}) {
999
1007
  lines: plan.lines,
1000
1008
  });
1001
1009
  if (current.latestPlan?.fingerprint === fingerprint) {
1002
- return buildCompactPressureState({
1010
+ return await buildCompactPressureState({
1003
1011
  ...current,
1004
1012
  updatedAt: now,
1005
1013
  latestPlan: {
@@ -1017,7 +1025,7 @@ export function registerThresholdCompactPlan(state, plan, config = {}) {
1017
1025
  const materialChange = isMaterialThresholdPlanChange(current.latestPlan, plan, thresholds);
1018
1026
 
1019
1027
  if (withinCooldown && !materialChange) {
1020
- return buildCompactPressureState({
1028
+ return await buildCompactPressureState({
1021
1029
  ...current,
1022
1030
  updatedAt: now,
1023
1031
  latestPlan: {
@@ -1052,7 +1060,7 @@ export function registerThresholdCompactPlan(state, plan, config = {}) {
1052
1060
  ? Math.min(current.sessionTokens, decayTarget)
1053
1061
  : current.sessionTokens;
1054
1062
 
1055
- return buildCompactPressureState({
1063
+ return await buildCompactPressureState({
1056
1064
  ...current,
1057
1065
  updatedAt: now,
1058
1066
  sessionTokens: decayedSessionTokens,
@@ -1073,7 +1081,7 @@ export function registerThresholdCompactPlan(state, plan, config = {}) {
1073
1081
  }, config);
1074
1082
  }
1075
1083
 
1076
- export function buildThresholdCompactPlan({
1084
+ export async function buildThresholdCompactPlan({
1077
1085
  state,
1078
1086
  config = {},
1079
1087
  routeState = {},
@@ -1081,8 +1089,8 @@ export function buildThresholdCompactPlan({
1081
1089
  dynamicLines = [],
1082
1090
  maxTokens = 280,
1083
1091
  } = {}) {
1084
- const normalizedState = buildCompactPressureState(state, config);
1085
- const thresholds = buildCompactThresholds(config);
1092
+ const normalizedState = await buildCompactPressureState(state, config);
1093
+ const thresholds = await buildCompactThresholds(config);
1086
1094
  const phase = normalizedState.phase;
1087
1095
  if (phase === 'monitor') {
1088
1096
  return {
@@ -1205,7 +1213,7 @@ export function buildThresholdCompactPlan({
1205
1213
 
1206
1214
  export async function readCompactPressureState(projectRoot, config = {}) {
1207
1215
  const runtimePaths = buildRuntimePaths(projectRoot);
1208
- return buildCompactPressureState(await readJson(runtimePaths.compactPressurePath, null), config);
1216
+ return await buildCompactPressureState(await readJson(runtimePaths.compactPressurePath, null), config);
1209
1217
  }
1210
1218
 
1211
1219
  // All compact-pressure mutations share one lock on the state file: without it,
@@ -1225,9 +1233,9 @@ async function mutateCompactPressureState(projectRoot, mutator, config = {}) {
1225
1233
  return withFileLock(runtimePaths.compactPressurePath, async () => {
1226
1234
  const sessions = readPressureDocument(await readJson(runtimePaths.compactPressurePath, null));
1227
1235
  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);
1236
+ const current = await buildCompactPressureState(record, scopedConfig);
1237
+ const next = await mutator(current);
1238
+ const normalized = await buildCompactPressureState(next, scopedConfig);
1231
1239
  sessions[id] = { ...normalized, updatedAt: Date.now() };
1232
1240
  await writeJson(runtimePaths.compactPressurePath, projectPressureDocument(sessions));
1233
1241
  return normalized;
@@ -1240,7 +1248,7 @@ export async function writeCompactPressureState(projectRoot, state, config = {})
1240
1248
  return withFileLock(runtimePaths.compactPressurePath, async () => {
1241
1249
  const sessions = readPressureDocument(await readJson(runtimePaths.compactPressurePath, null));
1242
1250
  const { id } = pickPressureSession(sessions, normalizeSessionId(config?.sessionId));
1243
- const normalized = buildCompactPressureState(state, scopedConfig);
1251
+ const normalized = await buildCompactPressureState(state, scopedConfig);
1244
1252
  sessions[id] = { ...normalized, updatedAt: Date.now() };
1245
1253
  await writeJson(runtimePaths.compactPressurePath, projectPressureDocument(sessions));
1246
1254
  return normalized;
@@ -1306,6 +1314,28 @@ function extractPrompt(payload = {}) {
1306
1314
  }
1307
1315
 
1308
1316
  async function runCli() {
1317
+ // BUG-C22-15: this CLI is spawned unbounded by skill-router.sh — every read it
1318
+ // does (config, pressure record, transcript tail, capacity record) sits on the
1319
+ // .ukit/transcript filesystem, so a stalled mount used to hang the child forever
1320
+ // (the shell `|| true` swallows exit codes, not time, and the host SIGKILLs
1321
+ // silently). Arm a self-deadline so ALL invokers get a bounded, announced exit.
1322
+ const CLI_DEADLINE_MS = Number.parseInt(process.env.UKIT_COMPACT_CLI_DEADLINE_MS || '', 10) || 4000;
1323
+ const cliDeadline = setTimeout(() => {
1324
+ // BUG-C23-02: fsSync.writeSync(1, …), never process.stdout.write — on a pipe
1325
+ // (this CLI is always piped) a same-tick process.exit() can discard the
1326
+ // buffered async write, and the bare text line wasn't §8 JSON anyway.
1327
+ try {
1328
+ fsSync.writeSync(1, `${JSON.stringify({
1329
+ systemMessage:
1330
+ `[ukit-skill-router] compact-threshold prompt CLI exceeded its ${CLI_DEADLINE_MS}ms deadline — `
1331
+ + 'pressure bookkeeping skipped this pass (likely stalled filesystem). The hook continues unharmed; '
1332
+ + 'if this repeats, check the .ukit volume or raise UKIT_COMPACT_CLI_DEADLINE_MS.',
1333
+ })}\n`);
1334
+ } catch { /* stdout already gone */ }
1335
+ process.exit(0);
1336
+ }, CLI_DEADLINE_MS);
1337
+ cliDeadline.unref();
1338
+
1309
1339
  const mode = String(process.argv[2] ?? '').trim();
1310
1340
  if (!mode) {
1311
1341
  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;
@@ -196,6 +204,23 @@ export async function sweepExecLedgerDir(dir, {
196
204
 
197
205
  // Sampled gate — by default only ~1 in 16 ledger mutations pays for a bounded
198
206
  // sweep at all. Advisory: a sweep failure must never lose a ledger event.
207
+ // BUG-C23-10: a bare `catch {}` around the sweep swallowed every failure — a
208
+ // persistent EACCES/ENOSPC meant silent non-convergence of the exec-ledger dir
209
+ // bound. Announce the first failure and then every 25th, C22 rate-limit
210
+ // posture. writeSync(1) because a same-tick exit can drop a buffered write.
211
+ let sweepFailureCount = 0;
212
+ export function noteSweepFailure(error) {
213
+ sweepFailureCount += 1;
214
+ if (sweepFailureCount !== 1 && sweepFailureCount % 25 !== 0) return null;
215
+ const message =
216
+ `UKit execution-ledger: dir sweep failed (${sweepFailureCount} failure(s) so far) — `
217
+ + `the committed event is safe but the backlog bound may lag. Cause: ${error?.message || error}`;
218
+ try {
219
+ fsSync.writeSync(1, `${JSON.stringify({ systemMessage: message })}\n`);
220
+ } catch { /* stdout already gone */ }
221
+ return message;
222
+ }
223
+
199
224
  export async function maybeSweepExecLedgerDir(dir, {
200
225
  probability,
201
226
  random = Math.random,
@@ -1178,7 +1203,8 @@ export async function recordLedgerEvent(event, {
1178
1203
  if (outcome.ok) {
1179
1204
  // Sampled bounded dir sweep (BUG-C21-11): runs outside the ledger lock so a
1180
1205
  // contended acquisition never pays sweep latency. Advisory — never throws.
1181
- try { await maybeSweepExecLedgerDir(path.dirname(target)); } catch { /* advisory */ }
1206
+ // BUG-C23-10: a persistent failure is still surfaced as a bounded degrade.
1207
+ try { await maybeSweepExecLedgerDir(path.dirname(target)); } catch (error) { noteSweepFailure(error); }
1182
1208
  return outcome.value;
1183
1209
  }
1184
1210
 
@@ -1701,6 +1727,11 @@ function isDirectRun() {
1701
1727
  const selfPath = fileURLToPath(import.meta.url);
1702
1728
  const resolved = path.resolve(argvPath);
1703
1729
  if (selfPath === resolved) return true;
1730
+ // BUG-C22-01: a stalled mount must not park the event loop during `await import`
1731
+ // — importing this module inside an armed hook deadline used to run realpathSync
1732
+ // here for EVERY importer. Skip fs entirely when the basename cannot match (any
1733
+ // importer argv like reinject-context.mjs differs from execution-ledger.mjs).
1734
+ if (path.basename(selfPath) !== path.basename(resolved)) return false;
1704
1735
  try {
1705
1736
  return fsSync.realpathSync(selfPath) === fsSync.realpathSync(resolved);
1706
1737
  } 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;
@@ -196,6 +200,22 @@ export function maybeSweepTelemetryDir(dir, {
196
200
  return { sampled: true, ...sweepTelemetryDir(dir, options) };
197
201
  }
198
202
 
203
+ // BUG-C23-10: a bare `catch {}` around the sweep swallowed every failure — a
204
+ // persistent EACCES/ENOSPC meant silent non-convergence of the dir bound.
205
+ // Announce the first failure and then every 25th, C22 rate-limit posture.
206
+ let sweepFailureCount = 0;
207
+ export function noteSweepFailure(error) {
208
+ sweepFailureCount += 1;
209
+ if (sweepFailureCount !== 1 && sweepFailureCount % 25 !== 0) return null;
210
+ const message =
211
+ `UKit hook-telemetry: latency dir sweep failed (${sweepFailureCount} failure(s) so far) — `
212
+ + `the appended row is safe but the backlog bound may lag. Cause: ${error?.message || error}`;
213
+ try {
214
+ fs.writeSync(1, `${JSON.stringify({ systemMessage: message })}\n`);
215
+ } catch { /* stdout already gone */ }
216
+ return message;
217
+ }
218
+
199
219
  export function appendTelemetryRow(projectRoot, sessionId, row, options = {}) {
200
220
  try {
201
221
  if (!row || typeof row !== 'object') return false;
@@ -207,7 +227,7 @@ export function appendTelemetryRow(projectRoot, sessionId, row, options = {}) {
207
227
  fs.appendFileSync(filePath, line, 'utf8');
208
228
  // Sampled bounded dir sweep (BUG-C21-10): advisory — a sweep failure must
209
229
  // never alter the row this call just appended.
210
- try { maybeSweepTelemetryDir(dir); } catch { /* advisory */ }
230
+ try { maybeSweepTelemetryDir(dir); } catch (error) { noteSweepFailure(error); }
211
231
  return true;
212
232
  } catch {
213
233
  // Advisory: an unwritable or corrupt telemetry target must never alter a