@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
package/CHANGELOG.md CHANGED
@@ -2,6 +2,129 @@
2
2
 
3
3
  All notable changes to UKit are documented here.
4
4
 
5
+ ## 2.6.5 - 2026-09-19
6
+
7
+ Stability release — third full audit pass on the 2.6.4 base (cycle C23). 15
8
+ confirmed hang / freeze / silent-idle / silent-hook defects fixed with root
9
+ causes (full register: `docs/AI_HANDOFF/BUGS-2.6.5.md`). Headline: the
10
+ cycle-lockout mechanism observed live — stall-breaker streak erased on every
11
+ Stop (BUG-C23-08) — is fixed, plus every blocking/degrading hook exit now
12
+ announces on stdout per the §8 contract.
13
+
14
+ - **Handoff stall-breaker streak erased every Stop (BUG-C23-08)**: the
15
+ stop-coordinator dedupe write dropped the `handoff` streak slot, so a wedged
16
+ RUN.md blocked forever; the merge now preserves the slot.
17
+ - **Silent non-zero hook failures (BUG-C23-01, -03, -07)**: stderr-only crash
18
+ reasons in `output-compression`/`reinject-context`/`post-edit-verify`/
19
+ `stale-spec-check` + safe-patch exit-2 blocks + skill-router/vision-router/
20
+ task-watchdog crash paths now emit a stdout `systemMessage` before exit —
21
+ stderr reaches the model, not reliably the user.
22
+ - **Lost/silent deadline emissions (BUG-C23-02, -04)**: compact-threshold's
23
+ same-tick `stdout.write` + `process.exit(0)` discarded the announce on pipes
24
+ (now `writeSync(1)`); deadline-expiry `exit 0` in four called `.mjs` modules
25
+ and `handoff-resume.sh` now announces the abandoned work.
26
+ - **Fail-open/crash-posture defects (BUG-C23-05, -06)**: context-hardcap-gate's
27
+ internal-error catch now announces its fail-open degrade on stdout;
28
+ completion-gate's stop-coordinator spawn is deadline-bounded with forced
29
+ kill routed to the crash-streak path.
30
+ - **Silent data loss + hot-path waste (BUG-C23-09, -10, -13, -15)**:
31
+ compact-threshold double capacity read collapsed to one; sweep failures
32
+ announce (bounded) instead of `.catch(() => {})` swallow; unguarded `main()`
33
+ no longer runs on `import()`; route-audit merges stage to an append-only
34
+ sidecar with a locked + exit fold instead of silently dropping entries.
35
+ - **Unbounded growth + unbounded stalls (BUG-C23-11, -12, -14)**: memory
36
+ project archives capped; `permission-audit.log` rotates inside the prune
37
+ pass; `bin/ukit` runCli got an overall deadline watchdog.
38
+
39
+ Regression gate: 138 files / 2360 tests green, liveness 120/120, §8 sweep
40
+ clean, template↔installed parity byte-identical, async-caller census clean,
41
+ all 25 C22 verdicts re-confirmed.
42
+
43
+ ## 2.6.4 - 2026-09-19
44
+
45
+ Stability release — second full audit pass on the 2.6.3 base (cycle C22). 25
46
+ confirmed hang / freeze / silent-idle / silent-hook defects fixed with root causes
47
+ (full register: `docs/AI_HANDOFF/BUGS-2.6.4.md`), including the two carried C21
48
+ residual lineages: **R1** (sync fs inside the hardcap-gate deadline → BUG-C22-01)
49
+ and **R2** (sync fs inside handoff-resume deadline → BUG-C22-02).
50
+
51
+ - **Stalled-mount freeze in the compact/hardcap chain (R1, BUG-C22-01)**: the entire
52
+ compact-threshold fs path (`readNegotiatedCapacity`, `buildCompactThresholds`,
53
+ `resolveCompactPhase`, `buildCompactPressureState`, pressure registration, compact
54
+ plan) converted to async `fsp`; every caller in hooks, `src/core/compact`, and
55
+ `src/core/status.js` now awaits; `isDirectRun` in `execution-ledger.mjs` /
56
+ `stop-coordinator.mjs` guards `realpathSync` behind a basename check so module
57
+ import no longer does sync fs on the deadline path.
58
+ - **handoff-resume sync fs inside its deadline (R2, BUG-C22-02)** plus the same
59
+ sync-fs-under-deadline class in `reset-compact-pressure` (BUG-C22-03),
60
+ `auto-prune-bash` (BUG-C22-04) and `auto-allow-bash` (BUG-C22-06) — sync calls
61
+ inside armed unref'd hook deadlines parked the event loop so the deadline itself
62
+ could never fire.
63
+ - **Stop-gate crash → block-forever (BUG-C22-05)**: a `completion-gate` coordinator
64
+ crash used to produce an unconditional `decision:block` on every subsequent stop.
65
+ New crash-streak breaker (`.ukit/storage/cache/completion-gate-crash.streak`,
66
+ byte-append counter, cap `handoff.fullstack.stopGateMaxCrashStreaks` default 3)
67
+ fails closed up to the cap then releases *loud*; omp bridge got the identical
68
+ try/catch + streak parity.
69
+ - **Silent `exit 0` / `exit(0)` clusters now announce (§8 contract)**: missing-script
70
+ silent exits in 4 hooks (BUG-C22-07), deadline-expiry silent exits on 3 gated
71
+ hooks plus the hardcap-gate sites (BUG-C22-08), and `verification-guard`
72
+ stderr-only catch-all (BUG-C22-09) all emit the fail-open §8 JSON + stderr
73
+ announcement instead of going quiet.
74
+ - **Unbounded spawned children get self-deadlines**: `compact-threshold.mjs`
75
+ `runCli` (BUG-C22-15, `UKIT_COMPACT_CLI_DEADLINE_MS` default 4s — bounds the
76
+ skill-router spawn too), `verify-release.mjs` spawn watchdog + tree-kill
77
+ (BUG-C22-20), `parallel-agents.mjs` per-level `UKIT_BENCH_LEVEL_TIMEOUT_MS`
78
+ 30 min deadline with detached process-group SIGKILL (BUG-C22-21).
79
+ - **`withFileLock` busy-spin on persistent stat errors (BUG-C22-16 runtime twin /
80
+ BUG-C22-17 core twin)**: a stat error inside the retry loop skipped both the
81
+ `maxWaitMs` check and the backoff sleep → tight spin for the process lifetime.
82
+ The budget check now lives inside the catch path in both copies.
83
+ - **Sweep caps that could never fire (BUG-C22-10/11/12/13)**: tee sweep,
84
+ hook-telemetry sweep, exec-ledger sweep and omp hook-errors sweep computed the
85
+ overflow from the scanned *sample* instead of the eligible count, so configured
86
+ max-file caps (e.g. `TEE_MAX_FILES=500`) were unreachable.
87
+ - **Vision receipts eaten / orphaned (BUG-C22-14/18)**: the vision marker-dir sweep
88
+ deleted `analyzed-*` receipts early → vision gate re-fired on every image; legacy
89
+ top-level `vision/analyzed-*.json` orphans had no pruner at all. Both prunes are
90
+ now TTL-coordinated with `isAlreadyAnalyzed`.
91
+ - **Unbounded `.bak` backup writer (BUG-C22-19)**: `.claude/ukit/.ukit/backups/`
92
+ had no pruner; `pruneOldBackups` is now bounded.
93
+ - **Registered timeout < internal worst case (BUG-C22-22/23/24/25)**:
94
+ `auto-allow-bash` 12→15s (internal worst 13s), `skill-router` 8→20s (worst
95
+ ~18s), `stale-spec-guard` 8→11s, and omp chain child budget 4000→12000ms with
96
+ ceiling 16000→52000ms — a hook can no longer be killed mid-work by a budget
97
+ smaller than its own declared worst case.
98
+
99
+ Full fixed-bug enumeration (register `docs/AI_HANDOFF/BUGS-2.6.4.md`, all rows
100
+ `verified`):
101
+
102
+ - BUG-C22-01 (R1) — sync fs inside hardcap-gate deadline (compact-threshold + execution-ledger)
103
+ - BUG-C22-02 (R2) — sync fs inside handoff-resume deadline
104
+ - BUG-C22-03 — reset-compact-pressure: swallow-all + sync fs inside deadline
105
+ - BUG-C22-04 — auto-prune-bash: invisible prune failure + sync fs inside deadline
106
+ - BUG-C22-05 — completion-gate: persistent coordinator crash → block-forever (crash-streak breaker)
107
+ - BUG-C22-06 — auto-allow-bash: sync fs inside deadline
108
+ - BUG-C22-07 — missing-script silent `exit 0` cluster (4 hooks)
109
+ - BUG-C22-08 — deadline-expiry silent `exit(0)` on gated hooks (+ hardcap sites)
110
+ - BUG-C22-09 — verification-guard catch-all degrade on stderr only
111
+ - BUG-C22-10 — tee sweep count cap unreachable (`TEE_MAX_FILES=500` never enforced)
112
+ - BUG-C22-11 — hook-telemetry sweep count cap unreachable
113
+ - BUG-C22-12 — exec-ledger sweep count cap unreachable
114
+ - BUG-C22-13 — omp hook-errors sweep count cap unreachable
115
+ - BUG-C22-14 — vision marker-dir sweep deletes `analyzed-*` receipts → vision gate re-fires
116
+ - BUG-C22-15 — skill-router spawns `node compact-threshold.mjs prompt` unbounded (CLI self-deadline)
117
+ - BUG-C22-16 — `withFileLock` busy-spin, runtime twin (token-utils.mjs)
118
+ - BUG-C22-17 — `withFileLock` busy-spin, core twin (fileOps.js)
119
+ - BUG-C22-18 — legacy top-level `vision/analyzed-*.json` orphans never pruned
120
+ - BUG-C22-19 — `.claude/ukit/.ukit/backups/` `.bak` writer had no pruner
121
+ - BUG-C22-20 — `verify-release.mjs` spawns with no timeout (watchdog + tree-kill)
122
+ - BUG-C22-21 — `parallel-agents.mjs` N children, no timeout, partial-settle hang
123
+ - BUG-C22-22 — auto-allow-bash registered timeout < internal worst case (12→15s)
124
+ - BUG-C22-23 — skill-router registered timeout < internal worst case (8→20s)
125
+ - BUG-C22-24 — stale-spec-guard registered timeout zero margin (8→11s)
126
+ - BUG-C22-25 — omp chain child budget < every staged hook's worst case (4000→12000ms, ceiling 16000→52000ms)
127
+
5
128
  ## 2.6.3 - 2026-09-18
6
129
 
7
130
  - **Informational side-questions no longer self-block at the Stop gate.** A no-route
package/bin/ukit CHANGED
@@ -11,17 +11,37 @@ async function readPackageVersion(packageRoot) {
11
11
  return packageJson.version;
12
12
  }
13
13
 
14
+ const DEFAULT_CLI_DEADLINE_MS = 120_000;
15
+
16
+ function resolveCliDeadlineMs() {
17
+ const raw = Number(process.env.UKIT_CLI_DEADLINE_MS);
18
+ return Number.isFinite(raw) && raw > 0 ? raw : DEFAULT_CLI_DEADLINE_MS;
19
+ }
20
+
14
21
  async function main() {
15
- const currentFile = fileURLToPath(import.meta.url);
16
- const packageRoot = path.resolve(path.dirname(currentFile), '..');
17
- const packageVersion = await readPackageVersion(packageRoot);
18
-
19
- await runCli({
20
- argv: process.argv.slice(2),
21
- packageRoot,
22
- projectRoot: process.cwd(),
23
- packageVersion,
24
- });
22
+ // Overall wall-clock watchdog: individual fs/readline awaits inside runCli are
23
+ // unbounded, so a wedged .ukit/external mount would park the CLI forever.
24
+ const deadlineMs = resolveCliDeadlineMs();
25
+ const watchdog = setTimeout(() => {
26
+ console.error(`[UKit] timed out after ${deadlineMs}ms — likely wedged filesystem; retry`);
27
+ process.exit(1);
28
+ }, deadlineMs);
29
+ watchdog.unref();
30
+
31
+ try {
32
+ const currentFile = fileURLToPath(import.meta.url);
33
+ const packageRoot = path.resolve(path.dirname(currentFile), '..');
34
+ const packageVersion = await readPackageVersion(packageRoot);
35
+
36
+ await runCli({
37
+ argv: process.argv.slice(2),
38
+ packageRoot,
39
+ projectRoot: process.cwd(),
40
+ packageVersion,
41
+ });
42
+ } finally {
43
+ clearTimeout(watchdog);
44
+ }
25
45
  }
26
46
 
27
47
  main().catch((error) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ngockhoale/ukit",
3
- "version": "2.6.3",
3
+ "version": "2.6.5",
4
4
  "description": "Install/update an index-first AI workspace for Claude Code, OpenAI Codex, OpenCode, and omp (Oh My Pi).",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -18,6 +18,14 @@ import { spawn } from 'node:child_process';
18
18
 
19
19
  const SLOWDOWN_LIMIT = 2.0;
20
20
 
21
+ // BUG-C22-21: a level must never wait forever — one wedged `shell:true` child
22
+ // used to leave `settled < n` permanently and hang the whole benchmark
23
+ // mid-level. A per-level deadline SIGKILLs unsettled children (their whole
24
+ // process group — detached:true puts each `sh -c` in its own group so
25
+ // grandchildren die too) and resolves the level marked timedOut.
26
+ // UKIT_BENCH_LEVEL_TIMEOUT_MS overrides the budget for tests.
27
+ const LEVEL_TIMEOUT_MS = Number(process.env.UKIT_BENCH_LEVEL_TIMEOUT_MS) || 30 * 60 * 1000; // 30 min
28
+
21
29
  function parseArgs(argv) {
22
30
  const opts = { levels: '1,3,5,10', cmd: 'yarn test:release-core', out: '.cache/bench/parallel-agents.json' };
23
31
  for (let i = 0; i < argv.length; i += 2) {
@@ -46,19 +54,43 @@ function runLevel(n, cmd) {
46
54
  let highWater = 0;
47
55
  let failures = 0;
48
56
  let settled = 0;
57
+ let timedOut = false;
58
+ let finished = false;
49
59
  const children = [];
60
+ // BUG-C22-21: per-level deadline — kill stragglers and resolve marked-failed.
61
+ const deadline = setTimeout(() => {
62
+ timedOut = true;
63
+ for (const child of children) {
64
+ if (!child.ukitSettled) {
65
+ child.ukitSettled = true;
66
+ settled += 1;
67
+ failures += 1;
68
+ try {
69
+ process.kill(-child.pid, 'SIGKILL'); // kill the whole process tree
70
+ } catch {
71
+ try { child.kill('SIGKILL'); } catch { /* already gone */ }
72
+ }
73
+ }
74
+ }
75
+ maybeFinish();
76
+ }, LEVEL_TIMEOUT_MS);
77
+ deadline.unref();
50
78
  for (let i = 0; i < n; i += 1) {
51
- const child = spawn(cmd, { shell: true, stdio: 'ignore' });
79
+ const child = spawn(cmd, { shell: true, stdio: 'ignore', detached: true });
52
80
  live += 1;
53
81
  highWater = Math.max(highWater, live);
54
82
  children.push(child);
55
83
  child.on('error', () => {
84
+ if (child.ukitSettled) return;
85
+ child.ukitSettled = true;
56
86
  live -= 1;
57
87
  settled += 1;
58
88
  failures += 1;
59
89
  maybeFinish();
60
90
  });
61
91
  child.on('exit', (code) => {
92
+ if (child.ukitSettled) return;
93
+ child.ukitSettled = true;
62
94
  live -= 1;
63
95
  settled += 1;
64
96
  if (code !== 0) failures += 1;
@@ -66,8 +98,14 @@ function runLevel(n, cmd) {
66
98
  });
67
99
  }
68
100
  function maybeFinish() {
101
+ if (finished) return;
69
102
  if (settled === n) {
70
- resolve({ n, wallClockMs: Date.now() - t0, failures, concurrencyHighWaterMark: highWater });
103
+ finished = true;
104
+ clearTimeout(deadline);
105
+ if (timedOut) {
106
+ console.error(`[bench] level n=${n}: deadline ${LEVEL_TIMEOUT_MS}ms exceeded — unsettled children killed and counted as failures`);
107
+ }
108
+ resolve({ n, wallClockMs: Date.now() - t0, failures, concurrencyHighWaterMark: highWater, timedOut });
71
109
  }
72
110
  }
73
111
  });
@@ -110,8 +148,8 @@ async function main() {
110
148
 
111
149
  const result = {
112
150
  cpuCount,
113
- levels: rows.map(({ n, wallClockMs, slowdownFactor, perRunCost, failures, concurrencyHighWaterMark }) => ({
114
- n, wallClockMs, slowdownFactor, perRunCost, failures, concurrencyHighWaterMark,
151
+ levels: rows.map(({ n, wallClockMs, slowdownFactor, perRunCost, failures, concurrencyHighWaterMark, timedOut }) => ({
152
+ n, wallClockMs, slowdownFactor, perRunCost, failures, concurrencyHighWaterMark, timedOut,
115
153
  })),
116
154
  recommended,
117
155
  measurementConditions: { loadAvgStart, loadAvgEnd, startedAt, finishedAt, cpuCount },
@@ -6,6 +6,14 @@ import path from 'node:path';
6
6
  const rootDir = process.cwd();
7
7
  const npmCacheDir = path.join(os.tmpdir(), 'ukit-npm-cache');
8
8
 
9
+ // BUG-C22-20: every spawned child carries an explicit timeout + killSignal so a
10
+ // wedged npm/yarn child (network partition, hung credential helper) fails the
11
+ // step instead of hanging the verifier forever. Per-step budgets: release test
12
+ // steps are the slowest (full suite); the registry read is a single network call.
13
+ // UKIT_VERIFY_STEP_TIMEOUT_MS overrides the step budget for tests.
14
+ const STEP_TIMEOUT_MS = Number(process.env.UKIT_VERIFY_STEP_TIMEOUT_MS) || 10 * 60 * 1000; // 10 min
15
+ const REGISTRY_TIMEOUT_MS = Number(process.env.UKIT_VERIFY_REGISTRY_TIMEOUT_MS) || 60 * 1000; // 1 min
16
+
9
17
  // Opt-in post-publish registry-parity check (Release Policy invariant: npm and git always hold
10
18
  // the same latest version). Only meaningful AFTER `npm publish` — before publish the registry is
11
19
  // behind by definition — so it is gated behind --post-publish and never runs by default.
@@ -67,32 +75,61 @@ for (const step of steps) {
67
75
 
68
76
  console.log('\n[release:verify] All release checks passed.');
69
77
 
78
+ // BUG-C22-20: bounded spawn — an armed watchdog SIGKILLs the child's whole
79
+ // process group on expiry (detached: true puts the child in its own group so
80
+ // grandchildren like `sh -c "sleep …"` can't keep our stdio pipes open).
81
+ function spawnBounded(command, args, opts, timeoutMs) {
82
+ const child = spawn(command, args, { ...opts, detached: true });
83
+ child.ukitTimedOut = false;
84
+ const watchdog = setTimeout(() => {
85
+ child.ukitTimedOut = true;
86
+ try {
87
+ process.kill(-child.pid, 'SIGKILL'); // kill the whole tree
88
+ } catch {
89
+ try { child.kill('SIGKILL'); } catch { /* already gone */ }
90
+ }
91
+ }, timeoutMs);
92
+ watchdog.unref();
93
+ child.on('exit', () => clearTimeout(watchdog));
94
+ return child;
95
+ }
96
+
70
97
  function runStep({ command, args, env }) {
71
98
  return new Promise((resolve) => {
72
- const child = spawn(command, args, {
99
+ const child = spawnBounded(command, args, {
73
100
  cwd: rootDir,
74
101
  env,
75
102
  stdio: 'inherit',
76
- });
103
+ }, STEP_TIMEOUT_MS);
77
104
 
78
- child.on('close', (code) => resolve(code ?? 1));
105
+ child.on('exit', (code) => {
106
+ if (child.ukitTimedOut) {
107
+ console.error(`[release:verify] step timed out after ${STEP_TIMEOUT_MS}ms — child killed (check for a wedged ${command}/network)`);
108
+ }
109
+ resolve(code ?? 1);
110
+ });
79
111
  child.on('error', () => resolve(1));
80
112
  });
81
113
  }
82
114
 
83
115
  function readRegistryVersion() {
84
116
  return new Promise((resolve) => {
85
- const child = spawn('npm', ['view', '@ngockhoale/ukit', 'version'], {
117
+ const child = spawnBounded('npm', ['view', '@ngockhoale/ukit', 'version'], {
86
118
  cwd: rootDir,
87
119
  env: { ...process.env, npm_config_cache: npmCacheDir },
88
120
  stdio: ['ignore', 'pipe', 'inherit'],
89
- });
121
+ }, REGISTRY_TIMEOUT_MS);
90
122
 
91
123
  let stdout = '';
92
124
  child.stdout.on('data', (chunk) => {
93
125
  stdout += chunk;
94
126
  });
95
- child.on('close', () => {
127
+ child.on('exit', () => {
128
+ if (child.ukitTimedOut) {
129
+ console.error(`[release:verify] registry version check timed out after ${REGISTRY_TIMEOUT_MS}ms — child killed (check npm/network)`);
130
+ resolve('<unavailable>');
131
+ return;
132
+ }
96
133
  const version = stdout.trim();
97
134
  resolve(version.length > 0 ? version : '<unavailable>');
98
135
  });
@@ -15,6 +15,71 @@ export function isTccProtectedPath(filePath) {
15
15
  });
16
16
  }
17
17
 
18
+ // BUG-C22-19: every `overwrite_with_backup` write adds a timestamped `.bak` under
19
+ // backupRoot and nothing ever removed them (239 live files at census, still
20
+ // growing). Bound the dir: keep at most BACKUP_MAX_FILES of the newest `.bak`
21
+ // files and nothing older than BACKUP_MAX_AGE_MS. The listing itself is capped
22
+ // (BACKUP_SCAN_LIMIT) so a huge pre-existing dir can't stall install on a slow
23
+ // external mount; a backlog beyond the cap drains over successive installs.
24
+ export const BACKUP_MAX_FILES = 100;
25
+ export const BACKUP_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000; // 30 days
26
+ export const BACKUP_SCAN_LIMIT = 5000;
27
+
28
+ async function collectBackupFiles(dir, out, budget) {
29
+ let entries;
30
+ try {
31
+ entries = await fs.readdir(dir, { withFileTypes: true });
32
+ } catch {
33
+ return; // unreadable/missing dir — nothing to prune here
34
+ }
35
+ for (const entry of entries) {
36
+ if (out.length >= budget) return;
37
+ const entryPath = path.join(dir, entry.name);
38
+ if (entry.isDirectory()) {
39
+ await collectBackupFiles(entryPath, out, budget);
40
+ } else if (entry.isFile() && entry.name.endsWith('.bak')) {
41
+ let mtimeMs = 0;
42
+ try {
43
+ ({ mtimeMs } = await fs.stat(entryPath));
44
+ } catch {
45
+ // Vanished or unstat-able — keep mtimeMs 0 so it is treated as oldest.
46
+ }
47
+ out.push({ path: entryPath, mtimeMs });
48
+ }
49
+ }
50
+ }
51
+
52
+ export async function pruneOldBackups(backupRoot, {
53
+ maxFiles = BACKUP_MAX_FILES,
54
+ maxAgeMs = BACKUP_MAX_AGE_MS,
55
+ scanLimit = BACKUP_SCAN_LIMIT,
56
+ now = Date.now(),
57
+ } = {}) {
58
+ const files = [];
59
+ await collectBackupFiles(backupRoot, files, scanLimit);
60
+
61
+ // Newest first — survivors are the newest maxFiles that are also within the age bound.
62
+ files.sort((a, b) => b.mtimeMs - a.mtimeMs);
63
+ let removed = 0;
64
+ for (let i = 0; i < files.length; i += 1) {
65
+ const file = files[i];
66
+ const overCount = i >= maxFiles;
67
+ const overAge = now - file.mtimeMs > maxAgeMs;
68
+ if (!overCount && !overAge) continue;
69
+ try {
70
+ await fs.rm(file.path, { force: true }); // force:true — ENOENT under a concurrent reader is fine
71
+ removed += 1;
72
+ } catch {
73
+ // Advisory prune — a stubborn file is retried next install, never a hard failure.
74
+ }
75
+ }
76
+
77
+ if (removed > 0) {
78
+ console.log(`[UKit] Pruned ${removed} old backup file(s) under ${backupRoot} (kept ≤${maxFiles}, ≤${Math.round(maxAgeMs / 86400000)}d old).`);
79
+ }
80
+ return { scanned: files.length, removed };
81
+ }
82
+
18
83
  export async function applyDiffResults(diffResults, { backupRoot, projectRoot } = {}) {
19
84
  const writes = [];
20
85
  let skippedUpdates = 0;
@@ -445,8 +445,10 @@ function computeEstimatedTotalTokens({
445
445
 
446
446
  // The record the advisory guard published for this project, or null when nothing has been
447
447
  // negotiated yet (fresh install, non-project caller, tests).
448
- function readNegotiatedCapacity(config = {}) {
449
- const record = readContextCapacityRecord(config?.projectRoot);
448
+ // BUG-C22-01: async on purpose — readContextCapacityRecord now uses fsp so a
449
+ // stalled .ukit mount cannot park the event loop inside an armed hook deadline.
450
+ async function readNegotiatedCapacity(config = {}) {
451
+ const record = await readContextCapacityRecord(config?.projectRoot);
450
452
  if (!record) return null;
451
453
  return resolveContextCapTokens({
452
454
  env: process.env,
@@ -455,7 +457,7 @@ function readNegotiatedCapacity(config = {}) {
455
457
  });
456
458
  }
457
459
 
458
- export function buildCompactThresholds(config = {}) {
460
+ export async function buildCompactThresholds(config = {}) {
459
461
  const shippedHardCap = positiveInteger(
460
462
  config?.compact?.hardCapTokens,
461
463
  loadShippedCompactBudget().hardCapTokens,
@@ -463,7 +465,7 @@ export function buildCompactThresholds(config = {}) {
463
465
  const explicitSoftThreshold = finiteNumber(config?.compact?.tokenThreshold, 0);
464
466
  // Negotiated capacity (H22): the guard publishes what it derived from the live route and
465
467
  // this shared path consumes it; without a record the shipped tuning applies unchanged.
466
- const negotiated = readNegotiatedCapacity(config);
468
+ const negotiated = await readNegotiatedCapacity(config);
467
469
  const hardCapTokens = negotiated
468
470
  ? Math.max(1, Math.min(shippedHardCap, negotiated.capTokens))
469
471
  : shippedHardCap;
@@ -485,7 +487,7 @@ export function buildCompactThresholds(config = {}) {
485
487
  };
486
488
  }
487
489
 
488
- function buildReleaseThresholds(thresholds = buildCompactThresholds()) {
490
+ function buildReleaseThresholds(thresholds) {
489
491
  const softReleaseThreshold = Math.max(
490
492
  Math.min(thresholds.softThreshold, Math.round(thresholds.softThreshold * SOFT_RELEASE_RATIO)),
491
493
  1,
@@ -501,11 +503,12 @@ function buildReleaseThresholds(thresholds = buildCompactThresholds()) {
501
503
  };
502
504
  }
503
505
 
504
- export function resolveCompactPhase(
506
+ export async function resolveCompactPhase(
505
507
  estimatedTotalTokens,
506
- thresholds = buildCompactThresholds(),
508
+ thresholds = null,
507
509
  previousState = {},
508
510
  ) {
511
+ thresholds = thresholds ?? await buildCompactThresholds();
509
512
  const previousPhase = normalizePhase(previousState?.phase ?? previousState);
510
513
  const releaseThresholds = buildReleaseThresholds(thresholds);
511
514
  if (estimatedTotalTokens >= thresholds.hardThreshold) {
@@ -527,7 +530,7 @@ function resolvePhaseCooldownMs(phase) {
527
530
  return PHASE_COOLDOWN_MS[normalizePhase(phase)] ?? 0;
528
531
  }
529
532
 
530
- function resolvePlanDeltaThreshold(phase, thresholds = buildCompactThresholds()) {
533
+ function resolvePlanDeltaThreshold(phase, thresholds) {
531
534
  return normalizePhase(phase) === 'hard'
532
535
  ? Math.max(60, Math.round(thresholds.hardThreshold * 0.14))
533
536
  : Math.max(40, Math.round(thresholds.softThreshold * 0.12));
@@ -660,9 +663,9 @@ function projectPressureDocument(sessions) {
660
663
  };
661
664
  }
662
665
 
663
- export function buildCompactPressureState(rawState = null, config = {}) {
666
+ export async function buildCompactPressureState(rawState = null, config = {}) {
664
667
  rawState = resolvePressureRecord(rawState, config);
665
- const thresholds = buildCompactThresholds(config);
668
+ const thresholds = await buildCompactThresholds(config);
666
669
  const rawSoftThreshold = finiteNumber(rawState?.softThreshold, 0);
667
670
  const rawHardThreshold = finiteNumber(rawState?.hardThreshold, 0);
668
671
  if (!finiteNumber(config?.compact?.tokenThreshold, 0) && rawSoftThreshold > 0) {
@@ -711,7 +714,7 @@ export function buildCompactPressureState(rawState = null, config = {}) {
711
714
  estimatedContextTokens,
712
715
  sessionTokens,
713
716
  estimatedTotalTokens,
714
- phase: resolveCompactPhase(estimatedTotalTokens, thresholds, rawState),
717
+ phase: await resolveCompactPhase(estimatedTotalTokens, thresholds, rawState),
715
718
  taskMode,
716
719
  cooldownUntil: finiteNumber(rawState?.cooldownUntil, 0),
717
720
  routingContext: rawState?.routingContext && typeof rawState.routingContext === 'object'
@@ -725,14 +728,14 @@ export function buildCompactPressureState(rawState = null, config = {}) {
725
728
  };
726
729
  }
727
730
 
728
- export function registerPromptPressure(state, {
731
+ export async function registerPromptPressure(state, {
729
732
  promptText = '',
730
733
  routingContext = {},
731
734
  previousContext = null,
732
735
  routeSummary = null,
733
736
  timestamp = Date.now(),
734
737
  } = {}, config = {}) {
735
- const current = buildCompactPressureState(state, config);
738
+ const current = await buildCompactPressureState(state, config);
736
739
  const normalizedPrompt = String(promptText || routingContext?.lastExplicitUserPromptText || '').trim();
737
740
  if (!normalizedPrompt) {
738
741
  return current;
@@ -753,7 +756,7 @@ export function registerPromptPressure(state, {
753
756
  })
754
757
  : current.recentPrompts;
755
758
 
756
- return buildCompactPressureState({
759
+ return await buildCompactPressureState({
757
760
  ...current,
758
761
  updatedAt: timestamp,
759
762
  routingContext,
@@ -773,7 +776,7 @@ export function registerPromptPressure(state, {
773
776
  }, config);
774
777
  }
775
778
 
776
- export function registerOutputPressure(state, {
779
+ export async function registerOutputPressure(state, {
777
780
  command = '',
778
781
  profile = 'generic',
779
782
  summary = '',
@@ -783,7 +786,7 @@ export function registerOutputPressure(state, {
783
786
  exitCode = null,
784
787
  timestamp = Date.now(),
785
788
  } = {}, config = {}) {
786
- const current = buildCompactPressureState(state, config);
789
+ const current = await buildCompactPressureState(state, config);
787
790
  const normalizedSummary = summarizeOutputText(summary, command);
788
791
  if (!normalizedSummary) {
789
792
  return current;
@@ -805,7 +808,7 @@ export function registerOutputPressure(state, {
805
808
  keyBuilder: (candidate) => `${candidate.command ?? ''}|${candidate.summary ?? ''}`,
806
809
  });
807
810
 
808
- return buildCompactPressureState({
811
+ return await buildCompactPressureState({
809
812
  ...current,
810
813
  updatedAt: timestamp,
811
814
  recentOutputs,
@@ -819,8 +822,8 @@ export function registerOutputPressure(state, {
819
822
  }, config);
820
823
  }
821
824
 
822
- export function registerThresholdCompactPlan(state, plan, config = {}) {
823
- const current = buildCompactPressureState(state, config);
825
+ export async function registerThresholdCompactPlan(state, plan, config = {}) {
826
+ const current = await buildCompactPressureState(state, config);
824
827
  if (!plan?.active) {
825
828
  return current;
826
829
  }
@@ -836,7 +839,7 @@ export function registerThresholdCompactPlan(state, plan, config = {}) {
836
839
  lines: plan.lines,
837
840
  });
838
841
  if (current.latestPlan?.fingerprint === fingerprint) {
839
- return buildCompactPressureState({
842
+ return await buildCompactPressureState({
840
843
  ...current,
841
844
  updatedAt: now,
842
845
  latestPlan: {
@@ -854,7 +857,7 @@ export function registerThresholdCompactPlan(state, plan, config = {}) {
854
857
  const materialChange = isMaterialThresholdPlanChange(current.latestPlan, plan, thresholds);
855
858
 
856
859
  if (withinCooldown && !materialChange) {
857
- return buildCompactPressureState({
860
+ return await buildCompactPressureState({
858
861
  ...current,
859
862
  updatedAt: now,
860
863
  latestPlan: {
@@ -889,7 +892,7 @@ export function registerThresholdCompactPlan(state, plan, config = {}) {
889
892
  ? Math.min(current.sessionTokens, decayTarget)
890
893
  : current.sessionTokens;
891
894
 
892
- return buildCompactPressureState({
895
+ return await buildCompactPressureState({
893
896
  ...current,
894
897
  updatedAt: now,
895
898
  sessionTokens: decayedSessionTokens,
@@ -910,7 +913,7 @@ export function registerThresholdCompactPlan(state, plan, config = {}) {
910
913
  }, config);
911
914
  }
912
915
 
913
- export function buildThresholdCompactPlan({
916
+ export async function buildThresholdCompactPlan({
914
917
  state,
915
918
  config = {},
916
919
  routeState = {},
@@ -918,8 +921,8 @@ export function buildThresholdCompactPlan({
918
921
  dynamicLines = [],
919
922
  maxTokens = 280,
920
923
  } = {}) {
921
- const normalizedState = buildCompactPressureState(state, config);
922
- const thresholds = buildCompactThresholds(config);
924
+ const normalizedState = await buildCompactPressureState(state, config);
925
+ const thresholds = await buildCompactThresholds(config);
923
926
  const phase = normalizedState.phase;
924
927
  if (phase === 'monitor') {
925
928
  return {
@@ -1042,7 +1045,7 @@ export function buildThresholdCompactPlan({
1042
1045
 
1043
1046
  export async function readCompactPressureState(projectRoot, config = {}) {
1044
1047
  const runtimePaths = buildRuntimePaths(projectRoot);
1045
- return buildCompactPressureState(await readJsonIfExists(runtimePaths.compactPressurePath), config);
1048
+ return await buildCompactPressureState(await readJsonIfExists(runtimePaths.compactPressurePath), config);
1046
1049
  }
1047
1050
 
1048
1051
  // All compact-pressure mutations share one lock on the state file: without it,
@@ -1055,9 +1058,9 @@ async function mutateCompactPressureState(projectRoot, mutator, config = {}) {
1055
1058
  return withFileLock(runtimePaths.compactPressurePath, async () => {
1056
1059
  const sessions = readPressureDocument(await readJsonIfExists(runtimePaths.compactPressurePath));
1057
1060
  const { id, record } = pickPressureSession(sessions, normalizeSessionId(config?.sessionId));
1058
- const current = buildCompactPressureState(record, config);
1059
- const next = mutator(current);
1060
- const normalized = buildCompactPressureState(next, config);
1061
+ const current = await buildCompactPressureState(record, config);
1062
+ const next = await mutator(current);
1063
+ const normalized = await buildCompactPressureState(next, config);
1061
1064
  sessions[id] = { ...normalized, updatedAt: Date.now() };
1062
1065
  await writeJson(runtimePaths.compactPressurePath, projectPressureDocument(sessions));
1063
1066
  return normalized;
@@ -1069,7 +1072,7 @@ export async function writeCompactPressureState(projectRoot, state, config = {})
1069
1072
  return withFileLock(runtimePaths.compactPressurePath, async () => {
1070
1073
  const sessions = readPressureDocument(await readJsonIfExists(runtimePaths.compactPressurePath));
1071
1074
  const { id } = pickPressureSession(sessions, normalizeSessionId(config?.sessionId));
1072
- const normalized = buildCompactPressureState(state, config);
1075
+ const normalized = await buildCompactPressureState(state, config);
1073
1076
  sessions[id] = { ...normalized, updatedAt: Date.now() };
1074
1077
  await writeJson(runtimePaths.compactPressurePath, projectPressureDocument(sessions));
1075
1078
  return normalized;
@@ -255,8 +255,15 @@ export async function withFileLock(filePath, fn, { staleMs = LOCK_STALE_MS, maxW
255
255
  continue; // the slot is free now — retry immediately
256
256
  }
257
257
  }
258
- } catch {
259
- continue; // lock vanished between mkdir and stat — retry immediately
258
+ } catch (statError) {
259
+ // BUG-C22-17: a persistent stat error (EPERM/ENOTDIR/EIO on a failing
260
+ // mount, or ELOOP/ENOENT on a dangling symlink where mkdir still reports
261
+ // EEXIST) must not busy-spin — a bare `continue` skipped both the
262
+ // maxWait break and the backoff sleep, looping mkdir→stat→throw forever.
263
+ if (Date.now() - startedAt >= maxWaitMs) break; // fail open — run unlocked
264
+ if (statError?.code === 'ENOENT') continue; // lock vanished — retry immediately
265
+ await sleep(lockBackoffDelayMs());
266
+ continue;
260
267
  }
261
268
 
262
269
  if (Date.now() - startedAt >= maxWaitMs) break; // fail open — run unlocked