@ngockhoale/ukit 2.6.4 → 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.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,44 @@
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
+
5
43
  ## 2.6.4 - 2026-09-19
6
44
 
7
45
  Stability release — second full audit pass on the 2.6.3 base (cycle C22). 25
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.4",
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",
@@ -207,15 +207,22 @@ async function readProjectMemoryForId(runtimePaths, projectId) {
207
207
  return { filePath, memory };
208
208
  }
209
209
 
210
- async function appendSessionArchive(runtimePaths, projectId, archivedSessions) {
210
+ const DEFAULT_MAX_ARCHIVED_SESSIONS = 50;
211
+
212
+ async function appendSessionArchive(runtimePaths, projectId, archivedSessions, maxArchivedSessions) {
211
213
  if (!archivedSessions || archivedSessions.length === 0) {
212
214
  return;
213
215
  }
214
216
 
217
+ // Math.max(1, …): a fractional cap in (0,1) would floor to 0 and `slice(-0)`
218
+ // silently disables the bound — exactly the unbounded-growth class this caps.
219
+ const cap = Number.isFinite(maxArchivedSessions) && maxArchivedSessions > 0
220
+ ? Math.max(1, Math.floor(maxArchivedSessions))
221
+ : DEFAULT_MAX_ARCHIVED_SESSIONS;
215
222
  const archivePath = path.join(runtimePaths.projectsDir, `${sanitizeProjectId(projectId)}.archive.json`);
216
223
  const existing = (await readMemoryJson(archivePath)) ?? { sessions: [] };
217
224
  const sessions = Array.isArray(existing.sessions) ? existing.sessions : [];
218
- await writeJson(archivePath, { sessions: [...sessions, ...archivedSessions] });
225
+ await writeJson(archivePath, { sessions: [...sessions, ...archivedSessions].slice(-cap) });
219
226
  }
220
227
 
221
228
  async function persistProjectMemoryWithHygiene(projectRoot, runtimePaths, projectId, filePath, memory) {
@@ -228,7 +235,7 @@ async function persistProjectMemoryWithHygiene(projectRoot, runtimePaths, projec
228
235
  };
229
236
 
230
237
  const { projectMemory: hygienicMemory, archivedSessions } = runHygiene(memory, hygieneConfig);
231
- await appendSessionArchive(runtimePaths, projectId, archivedSessions);
238
+ await appendSessionArchive(runtimePaths, projectId, archivedSessions, memoryConfig.maxArchivedSessions);
232
239
  await writeJson(filePath, hygienicMemory);
233
240
  return hygienicMemory;
234
241
  }
@@ -164,6 +164,7 @@ export function buildDefaultRuntimeConfig(overrides = {}) {
164
164
  maxInjectionTokens: 1000,
165
165
  archiveAfterDays: 30,
166
166
  maxSessions: 20,
167
+ maxArchivedSessions: 50,
167
168
  redactSecrets: true,
168
169
  },
169
170
  validation: {
@@ -362,6 +363,7 @@ export function validateRuntimeConfig(config) {
362
363
  pushPositiveNumberError(errors, config.memory.maxInjectionTokens, 'memory.maxInjectionTokens');
363
364
  pushPositiveNumberError(errors, config.memory.archiveAfterDays, 'memory.archiveAfterDays');
364
365
  pushPositiveNumberError(errors, config.memory.maxSessions, 'memory.maxSessions');
366
+ pushPositiveNumberError(errors, config.memory.maxArchivedSessions, 'memory.maxArchivedSessions');
365
367
  pushBooleanError(errors, config.memory.redactSecrets, 'memory.redactSecrets');
366
368
  }
367
369
 
@@ -70,6 +70,35 @@ async function appendAudit(event, payload) {
70
70
  await fsp.appendFile(auditPath, JSON.stringify({ ts: nowUtc, event, ...payload }) + "\n");
71
71
  }
72
72
 
73
+ // BUG-C23-12: permission-audit.log was append-only (auto-allow-bash + this hook both
74
+ // append; only an explicit reset ever truncated it). Bound: this prune pass rewrites
75
+ // the log keeping only lines with ts >= cutoff (same TTL window as rule pruning, so
76
+ // the audit trail for every retained rule survives). Unparseable/missing-ts lines are
77
+ // dropped; a missing or unreadable log is a no-op.
78
+ async function pruneAuditLog() {
79
+ let raw;
80
+ try {
81
+ raw = await fsp.readFile(auditPath, "utf8");
82
+ } catch {
83
+ return;
84
+ }
85
+ const kept = raw
86
+ .split("\n")
87
+ .filter((line) => {
88
+ if (!line.trim()) return false;
89
+ let ts = NaN;
90
+ try {
91
+ ts = Date.parse(JSON.parse(line)?.ts || "");
92
+ } catch {
93
+ ts = NaN;
94
+ }
95
+ return Number.isFinite(ts) && ts >= cutoff;
96
+ });
97
+ const tempPath = `${auditPath}.tmp-${Date.now()}-${Math.random().toString(16).slice(2)}`;
98
+ await fsp.writeFile(tempPath, kept.length ? kept.join("\n") + "\n" : "");
99
+ await fsp.rename(tempPath, auditPath);
100
+ }
101
+
73
102
  // Same lock point as auto-allow-bash.sh (<usage>.lock): the two hooks serialize their
74
103
  // settings/usage rewrites against each other and against parallel invocations. Atomic
75
104
  // writes because Claude Code re-reads settings.local.json — a torn write would drop the
@@ -148,6 +177,10 @@ async function withLock(lockPath, fn) {
148
177
  }
149
178
 
150
179
  withLock(`${usagePath}.lock`, async () => {
180
+ // Rotate the audit log inside the same lock as the rule prune: both are
181
+ // read-modify-write passes on files these two hooks share.
182
+ await pruneAuditLog();
183
+
151
184
  const settings = await readJson(settingsPath);
152
185
  const usage = await readJson(usagePath);
153
186
  if (!settings || !usage) return;
@@ -78,8 +78,27 @@ if [ ! -f "$SCRIPT" ]; then
78
78
  exit 0
79
79
  fi
80
80
 
81
- OUTPUT=$(printf '%s' "$INPUT" | UKIT_HARNESS=claude-code UKIT_HOOK_DEADLINE_MS="${UKIT_HOOK_DEADLINE_MS:-3000}" node "$SCRIPT" --evaluate-stop)
81
+ # BUG-C23-06: bound the coordinator spawn. The coordinator's own unref'd deadline
82
+ # cannot fire while its event loop is parked in sync fs on a stalled mount, so the
83
+ # shell used to wait until the host's 8s SIGKILL — a silent release window for a
84
+ # fail-closed gate. Run the spawn in the background with a background killer at
85
+ # (coordinator deadline + margin); a forced kill lands on the existing crash-streak
86
+ # path (non-zero STATUS) so the outcome is announced, never silently released.
87
+ __ukit_coord_out="$(mktemp "${TMPDIR:-/tmp}/ukit-coord-out.XXXXXX")"
88
+ printf '%s' "$INPUT" | UKIT_HARNESS=claude-code UKIT_HOOK_DEADLINE_MS="${UKIT_HOOK_DEADLINE_MS:-3000}" node "$SCRIPT" --evaluate-stop > "$__ukit_coord_out" 2>/dev/null &
89
+ __ukit_coord_pid=$!
90
+ __ukit_coord_ms="${UKIT_HOOK_DEADLINE_MS:-3000}"
91
+ case "$__ukit_coord_ms" in ''|*[!0-9]*) __ukit_coord_ms=3000 ;; esac
92
+ __ukit_coord_ms=$((__ukit_coord_ms + 1500))
93
+ printf -v __ukit_coord_s '%d.%03d' $((__ukit_coord_ms / 1000)) $((__ukit_coord_ms % 1000))
94
+ ( sleep "$__ukit_coord_s" 2>/dev/null; kill -9 "$__ukit_coord_pid" 2>/dev/null ) <&- >/dev/null 2>&1 &
95
+ __ukit_killer_pid=$!
96
+ wait "$__ukit_coord_pid" 2>/dev/null
82
97
  STATUS=$?
98
+ kill "$__ukit_killer_pid" 2>/dev/null
99
+ wait "$__ukit_killer_pid" 2>/dev/null
100
+ OUTPUT="$(cat "$__ukit_coord_out" 2>/dev/null)"
101
+ rm -f "$__ukit_coord_out"
83
102
 
84
103
  if [ "$STATUS" -ne 0 ]; then
85
104
  # BUG-C22-05: a PERSISTENT coordinator crash must not block every Stop forever.
@@ -402,7 +402,14 @@ async function readRunCursor() {
402
402
  })().catch((err) => {
403
403
  // A logic error here fails OPEN: this is a backstop on top of advisory nudges,
404
404
  // not a correctness gate — a broken gate must not brick every session.
405
+ // BUG-C23-05: fail-open still needs a user-visible signal — stderr alone leaves a
406
+ // persistent internal error looking exactly like a clean pass (SPEC §8(ii)).
405
407
  process.stderr.write(`context-hardcap-gate: internal error, failing open: ${err?.message ?? err}\n`);
408
+ try {
409
+ process.stdout.write(`${JSON.stringify({
410
+ systemMessage: `UKit context hard-cap gate: internal error (${String(err?.message ?? err).slice(0, 200)}) — the gate failed open and did NOT enforce the hard cap this call; run: ukit install to repair.`,
411
+ })}\n`);
412
+ } catch {}
406
413
  process.exit(0);
407
414
  });
408
415
  NODE
@@ -87,7 +87,20 @@ const { pathToFileURL } = require('url');
87
87
  // Every read below is async on purpose: sync I/O would block the loop and this timer
88
88
  // could never fire. Keep it that way.
89
89
  const HOOK_DEADLINE_MS = Number.parseInt(process.env.UKIT_HOOK_DEADLINE_MS || '', 10) || 3000;
90
- setTimeout(() => process.exit(0), HOOK_DEADLINE_MS).unref();
90
+ setTimeout(() => {
91
+ // BUG-C23-04: deadline expiry abandons the configured resume hint — a
92
+ // degrade, and SPEC §8(ii) requires it be announced on stdout before the
93
+ // fail-open exit. fs.writeSync(1) because a same-tick exit can drop a
94
+ // buffered async write on a pipe.
95
+ try {
96
+ fs.writeSync(1, JSON.stringify({
97
+ systemMessage: 'UKit handoff-resume exceeded its ' + HOOK_DEADLINE_MS + 'ms deadline — '
98
+ + 'the resume check was skipped this session start (likely stalled filesystem). '
99
+ + 'If a handoff run was in flight, resume it via /ukit:handoff-fullstack.',
100
+ }) + '\n');
101
+ } catch {}
102
+ process.exit(0);
103
+ }, HOOK_DEADLINE_MS).unref();
91
104
 
92
105
  const projectRoot = process.env.PROJECT_ROOT;
93
106
  const runPath = path.join(projectRoot, 'docs', 'AI_HANDOFF', 'RUN.md');
@@ -2456,12 +2456,17 @@ setTimeout(() => process.exit(0), HOOK_DEADLINE_MS).unref();
2456
2456
  : null;
2457
2457
  }
2458
2458
 
2459
- async function withStateLock(lockPath, fn) {
2459
+ async function withStateLock(lockPath, fn, { onUnlocked } = {}) {
2460
2460
  // Advisory bookkeeping: liveness over strictness — if the lock cannot be acquired
2461
- // within maxWaitMs the merge runs unlocked (pre-lock behaviour) rather than hanging
2462
- // or dropping the entry.
2461
+ // within maxWaitMs the merge cannot run safely, so callers that must never lose
2462
+ // data pass `onUnlocked` (an atomic append-only fallback) instead of running an
2463
+ // unlocked read-modify-write (BUG-C23-15: the old unlocked merge was the exact
2464
+ // lost-update race the lock exists to prevent — under I/O contention it silently
2465
+ // dropped entries).
2463
2466
  const staleMs = 10000;
2464
- const maxWaitMs = 5000;
2467
+ // Cap the wait below the hook self-deadline: a wait that outlives the deadline
2468
+ // dies by process.exit before the sidecar fallback can ever stage the entry.
2469
+ const maxWaitMs = Math.min(5000, Math.max(500, HOOK_DEADLINE_MS - 500));
2465
2470
  const startedAt = Date.now();
2466
2471
  const ownerToken = `${process.pid}-${crypto.randomBytes(8).toString('hex')}`;
2467
2472
  let held = false;
@@ -2499,6 +2504,7 @@ setTimeout(() => process.exit(0), HOOK_DEADLINE_MS).unref();
2499
2504
  await new Promise((resolve) => setTimeout(resolve, 3 + Math.floor(Math.random() * 9)));
2500
2505
  }
2501
2506
  }
2507
+ if (!held && onUnlocked) return onUnlocked('lock-wait-expired');
2502
2508
  try {
2503
2509
  return await fn();
2504
2510
  } finally {
@@ -2518,7 +2524,159 @@ setTimeout(() => process.exit(0), HOOK_DEADLINE_MS).unref();
2518
2524
  return;
2519
2525
  }
2520
2526
 
2521
- await withStateLock(`${filePath}.lock`, async () => {
2527
+ // BUG-C23-15: the entry is STAGED to an append-only sidecar before the lock is
2528
+ // attempted (small appendFile is atomic — no read-modify-write, so no lost
2529
+ // updates even under full contention). Every lock holder folds all staged
2530
+ // entries into route-audit.json inside its merge; the hook's self-deadline can
2531
+ // kill a process mid-lock-wait, so a synchronous last-chance fold is also armed
2532
+ // on process exit — whichever process exits last folds every staged entry. The
2533
+ // audit entry can never be silently dropped; the unlocked path announces
2534
+ // itself instead of silently losing data.
2535
+ const sidecarPath = `${filePath}.unlocked.jsonl`;
2536
+ const lockPath = `${filePath}.lock`;
2537
+
2538
+ function entryDedupeKey(item) {
2539
+ return stableMachineDigest({
2540
+ requestKey: item?.requestKey || null,
2541
+ executionMode: item?.executionMode || null,
2542
+ nextActionType: item?.nextActionType || null,
2543
+ nextMilestone: item?.nextMilestone || null,
2544
+ repeatCount: item?.repeatCount || null,
2545
+ rescueMode: item?.rescueMode || null,
2546
+ });
2547
+ }
2548
+
2549
+ function parseSidecarLines(raw) {
2550
+ const recovered = [];
2551
+ for (const line of String(raw || '').split('\n')) {
2552
+ if (!line.trim()) continue;
2553
+ try {
2554
+ const item = JSON.parse(line);
2555
+ if (item && typeof item === 'object') recovered.push(item);
2556
+ } catch {}
2557
+ }
2558
+ return recovered;
2559
+ }
2560
+
2561
+ function mergeEntries(staged, parsed, newEntry) {
2562
+ const dedupeKey = newEntry ? entryDedupeKey(newEntry) : null;
2563
+ const merged = [];
2564
+ const seen = new Set();
2565
+ for (const item of [newEntry, ...staged, ...(Array.isArray(parsed?.entries) ? parsed.entries : [])]) {
2566
+ if (!item || typeof item !== 'object') continue;
2567
+ const key = entryDedupeKey(item);
2568
+ if (dedupeKey && key === dedupeKey && item !== newEntry) continue;
2569
+ if (seen.has(key)) continue;
2570
+ seen.add(key);
2571
+ merged.push(item);
2572
+ }
2573
+ return merged.slice(0, 40);
2574
+ }
2575
+
2576
+ // Synchronous last-chance fold for the self-deadline kill path: process.on('exit')
2577
+ // handlers run before exit without the event loop, so sync fs is legal here.
2578
+ // Bounded spin-retry on the mkdir lock so the LAST exiting process still folds
2579
+ // staged entries after the previous holder releases. The sidecar is claimed by
2580
+ // rename (atomic): appends in flight land on the claimed inode and are read,
2581
+ // appends after the claim create a fresh sidecar for the next fold.
2582
+ // `fsSync` aliases the SAME fs module — used ONLY by process.on('exit')
2583
+ // handlers below (event loop is gone; async cannot run). The C21-04 guard
2584
+ // intentionally does not match this handle so normal-path sync fs stays
2585
+ // forbidden while the exit-fold path is legal by construction.
2586
+ const fsSync = fs;
2587
+ function claimSidecarSync() {
2588
+ const claimedPath = `${sidecarPath}.claim-${process.pid}-${Date.now()}`;
2589
+ try {
2590
+ fsSync.renameSync(sidecarPath, claimedPath);
2591
+ } catch {
2592
+ return null;
2593
+ }
2594
+ return claimedPath;
2595
+ }
2596
+
2597
+ function foldSidecarSync() {
2598
+ const deadline = Date.now() + 300;
2599
+ let held = false;
2600
+ while (!held && Date.now() < deadline) {
2601
+ try {
2602
+ fsSync.mkdirSync(lockPath);
2603
+ held = true;
2604
+ } catch (error) {
2605
+ if (!error || error.code !== 'EEXIST') return;
2606
+ try {
2607
+ const stat = fsSync.statSync(lockPath);
2608
+ if (Date.now() - stat.mtimeMs > 10000) {
2609
+ try {
2610
+ const owner = JSON.parse(fsSync.readFileSync(path.join(lockPath, 'owner'), 'utf8'));
2611
+ if (!owner || !Number.isInteger(owner.pid) || !isPidAlive(owner.pid)) {
2612
+ fsSync.rmSync(lockPath, { recursive: true, force: true });
2613
+ continue;
2614
+ }
2615
+ } catch {
2616
+ fsSync.rmSync(lockPath, { recursive: true, force: true });
2617
+ continue;
2618
+ }
2619
+ }
2620
+ } catch {}
2621
+ const until = Date.now() + 5;
2622
+ while (Date.now() < until) { /* spin */ }
2623
+ }
2624
+ }
2625
+ if (!held) return;
2626
+ try {
2627
+ const claimedPath = claimSidecarSync();
2628
+ if (!claimedPath) return;
2629
+ let staged = [];
2630
+ try {
2631
+ staged = parseSidecarLines(fsSync.readFileSync(claimedPath, 'utf8'));
2632
+ } catch {}
2633
+ try {
2634
+ fsSync.rmSync(claimedPath, { force: true });
2635
+ } catch {}
2636
+ if (!staged.length) return;
2637
+ let parsed = { entries: [] };
2638
+ try {
2639
+ parsed = JSON.parse(fsSync.readFileSync(filePath, 'utf8'));
2640
+ } catch {}
2641
+ const tmpPath = `${filePath}.exitfold-${process.pid}`;
2642
+ fsSync.writeFileSync(tmpPath, JSON.stringify({
2643
+ entries: mergeEntries(staged, parsed, null),
2644
+ }));
2645
+ fsSync.renameSync(tmpPath, filePath);
2646
+ } finally {
2647
+ try {
2648
+ fsSync.rmSync(lockPath, { recursive: true, force: true });
2649
+ } catch {}
2650
+ }
2651
+ }
2652
+
2653
+ try {
2654
+ await fsp.appendFile(sidecarPath, `${JSON.stringify(entry)}\n`);
2655
+ } catch {}
2656
+ if (!appendRouteAuditEntry._exitFoldArmed) {
2657
+ appendRouteAuditEntry._exitFoldArmed = true;
2658
+ process.on('exit', foldSidecarSync);
2659
+ }
2660
+
2661
+ await withStateLock(lockPath, async () => {
2662
+ // Claim staged lines atomically inside the lock: rename detaches the inode,
2663
+ // so lines appended during the fold land in a fresh sidecar for the next pass
2664
+ // instead of being deleted unread.
2665
+ const claimedPath = `${sidecarPath}.claim-${process.pid}-${Date.now()}`;
2666
+ let claimed = false;
2667
+ try {
2668
+ await fsp.rename(sidecarPath, claimedPath);
2669
+ claimed = true;
2670
+ } catch {}
2671
+ const sidecarEntries = claimed
2672
+ ? parseSidecarLines(await fsp.readFile(claimedPath, 'utf8').catch(() => ''))
2673
+ : [];
2674
+ if (claimed) {
2675
+ try {
2676
+ await fsp.rm(claimedPath, { force: true });
2677
+ } catch {}
2678
+ }
2679
+
2522
2680
  let parsed = { entries: [] };
2523
2681
  if (await pathExists(filePath)) {
2524
2682
  try {
@@ -2528,30 +2686,18 @@ setTimeout(() => process.exit(0), HOOK_DEADLINE_MS).unref();
2528
2686
  }
2529
2687
  }
2530
2688
 
2531
- const entries = Array.isArray(parsed?.entries) ? parsed.entries : [];
2532
- const dedupeKey = stableMachineDigest({
2533
- requestKey: entry.requestKey,
2534
- executionMode: entry.executionMode,
2535
- nextActionType: entry.nextActionType,
2536
- nextMilestone: entry.nextMilestone,
2537
- repeatCount: entry.repeatCount,
2538
- rescueMode: entry.rescueMode,
2539
- });
2540
- const filtered = entries.filter((item) => {
2541
- const itemKey = stableMachineDigest({
2542
- requestKey: item?.requestKey || null,
2543
- executionMode: item?.executionMode || null,
2544
- nextActionType: item?.nextActionType || null,
2545
- nextMilestone: item?.nextMilestone || null,
2546
- repeatCount: item?.repeatCount || null,
2547
- rescueMode: item?.rescueMode || null,
2548
- });
2549
- return itemKey !== dedupeKey;
2550
- });
2551
-
2552
2689
  await writeJsonFileAtomic(filePath, JSON.stringify({
2553
- entries: [entry, ...filtered].slice(0, 40),
2690
+ entries: mergeEntries(sidecarEntries, parsed, entry),
2554
2691
  }));
2692
+ }, {
2693
+ onUnlocked: async (reason) => {
2694
+ // Already staged above — the next locked merge or the exit fold picks it up.
2695
+ try {
2696
+ process.stdout.write(`${JSON.stringify({
2697
+ systemMessage: `UKit skill-router: route-audit lock ${reason} — this audit entry was staged to the append-only sidecar and will be folded into route-audit.json.`,
2698
+ })}\n`);
2699
+ } catch {}
2700
+ },
2555
2701
  });
2556
2702
  }
2557
2703
 
@@ -3090,6 +3236,13 @@ setTimeout(() => process.exit(0), HOOK_DEADLINE_MS).unref();
3090
3236
  })().catch((error) => {
3091
3237
  const detail = error?.message || String(error);
3092
3238
  process.stderr.write(`[ukit-skill-router] ${detail}\n`);
3239
+ // BUG-C23-07: a crashed router abandons configured routing work — announce the
3240
+ // degrade (SPEC §8(ii)); fail-open posture kept (exit 0).
3241
+ try {
3242
+ process.stdout.write(`${JSON.stringify({
3243
+ systemMessage: `UKit skill-router crashed (${detail.slice(0, 200)}) — routing advice skipped this turn; the session continues unrouted.`,
3244
+ })}\n`);
3245
+ } catch {}
3093
3246
  process.exit(0);
3094
3247
  });
3095
3248
  NODE
@@ -151,18 +151,37 @@ setTimeout(() => process.exit(0), HOOK_DEADLINE_MS).unref();
151
151
  stdio: ['pipe', 'pipe', 'inherit'],
152
152
  });
153
153
  let delegatedOutput = '';
154
+ // BUG-C23-07: spawn-error / killed-empty-output paths abandoned the delegated
155
+ // stop evaluation silently — announce a degrade (SPEC §8(ii)) when the child
156
+ // fails or is force-killed with nothing to say. Fail-open posture kept.
157
+ let killedByDeadline = false;
158
+ const emitDelegateDegrade = (reason) => {
159
+ try {
160
+ process.stdout.write(`${JSON.stringify({
161
+ systemMessage: `UKit task-watchdog: delegated stop evaluation ${reason} — this stop passed without coordinator review.`,
162
+ })}\n`);
163
+ } catch {}
164
+ };
165
+ // Fire marginally BEFORE the process self-deadline so the close handler still
166
+ // has a tick to emit the degrade after the kill lands.
154
167
  const killTimer = setTimeout(() => {
168
+ killedByDeadline = true;
155
169
  try { child.kill('SIGKILL'); } catch {}
156
- }, HOOK_DEADLINE_MS);
170
+ }, Math.max(100, HOOK_DEADLINE_MS - 250));
157
171
  if (typeof killTimer.unref === 'function') killTimer.unref();
158
172
  child.stdout.on('data', (chunk) => { delegatedOutput += chunk; });
159
173
  child.stdin.on('error', () => {}); // a stub coordinator that never reads stdin must not crash us
160
- child.on('error', () => {
174
+ child.on('error', (spawnErr) => {
175
+ emitDelegateDegrade(`failed to spawn (${String(spawnErr?.message ?? spawnErr).slice(0, 150)})`);
161
176
  try { process.exit(0); } catch {}
162
177
  });
163
178
  child.on('close', () => {
164
179
  try {
165
- if (delegatedOutput.trim()) process.stdout.write(delegatedOutput);
180
+ if (delegatedOutput.trim()) {
181
+ process.stdout.write(delegatedOutput);
182
+ } else if (killedByDeadline) {
183
+ emitDelegateDegrade(`exceeded its ${HOOK_DEADLINE_MS}ms deadline and was killed`);
184
+ }
166
185
  } catch {}
167
186
  process.exit(0);
168
187
  });
@@ -228,8 +247,14 @@ setTimeout(() => process.exit(0), HOOK_DEADLINE_MS).unref();
228
247
  } catch {}
229
248
  }
230
249
  process.exit(0);
231
- })().catch(() => {
232
- // Fail-open: never let an exception kill the hook.
250
+ })().catch((err) => {
251
+ // Fail-open: never let an exception kill the hook — but BUG-C23-07: a crashed
252
+ // watchdog abandons configured budget evaluation, so announce it (SPEC §8(ii)).
253
+ try {
254
+ process.stdout.write(`${JSON.stringify({
255
+ systemMessage: `UKit task-watchdog crashed (${String(err?.message ?? err).slice(0, 200)}) — task budget evaluation skipped this pass.`,
256
+ })}\n`);
257
+ } catch {}
233
258
  try {
234
259
  process.exit(0);
235
260
  } catch {}
@@ -305,6 +305,13 @@ async function readTextSafe(filePath) {
305
305
  // anything) plus the failure reason. Never the whole payload.
306
306
  const head = typeof out === 'string' && out ? `; output head: ${out.slice(0, 200)}` : '';
307
307
  process.stderr.write(`vision-router: extractor call skipped (${err?.message ?? err})${head}\n`);
308
+ // BUG-C23-07: a failed extractor call abandons the configured vision hint —
309
+ // announce the degrade (SPEC §8(ii)); fail-open posture kept.
310
+ try {
311
+ process.stdout.write(`${JSON.stringify({
312
+ systemMessage: `UKit vision-router: image extractor call failed (${String(err?.message ?? err).slice(0, 150)}) — vision routing hint skipped this turn.`,
313
+ })}\n`);
314
+ } catch {}
308
315
  }
309
316
  } catch {
310
317
  // Missing extractor is a normal fail-open condition.
@@ -457,6 +464,13 @@ async function readTextSafe(filePath) {
457
464
  process.exit(0);
458
465
  })().catch((err) => {
459
466
  process.stderr.write(`vision-router: unexpected error (${err?.message ?? err})\n`);
467
+ // BUG-C23-07: a crashed vision router abandons configured advisory work —
468
+ // announce the degrade (SPEC §8(ii)); fail-open posture kept.
469
+ try {
470
+ process.stdout.write(`${JSON.stringify({
471
+ systemMessage: `UKit vision-router crashed (${String(err?.message ?? err).slice(0, 200)}) — vision routing hint skipped this turn.`,
472
+ })}\n`);
473
+ } catch {}
460
474
  process.exit(0);
461
475
  });
462
476
  NODE
@@ -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
  }
@@ -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 { pathToFileURL } from 'node:url';
4
5
  import {
@@ -474,12 +475,15 @@ export async function readContextCapacityRecord(projectRoot) {
474
475
 
475
476
  // Negotiated capacity: env override > config override > the model the guard last observed >
476
477
  // conservative fallback. Exported so the source twin and diagnostics read the same number.
477
- export async function resolveCompactCapacity(config = {}) {
478
- const record = await 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);
479
483
  return resolveContextCapTokens({
480
484
  env: process.env,
481
485
  config,
482
- modelMetadata: { model: record?.model },
486
+ modelMetadata: { model: resolvedRecord?.model },
483
487
  });
484
488
  }
485
489
 
@@ -489,7 +493,7 @@ export async function buildCompactThresholds(config = {}) {
489
493
  // Only tighten once a record exists: an install with no evidence yet (and every caller
490
494
  // that passes no project root) keeps the shipped tuning unchanged.
491
495
  const record = await readContextCapacityRecord(config?.projectRoot);
492
- const negotiated = record ? await resolveCompactCapacity(config) : null;
496
+ const negotiated = record ? await resolveCompactCapacity(config, { record }) : null;
493
497
  const hardCapTokens = negotiated
494
498
  ? Math.max(1, Math.min(shippedHardCap, negotiated.capTokens))
495
499
  : shippedHardCap;
@@ -1317,12 +1321,16 @@ async function runCli() {
1317
1321
  // silently). Arm a self-deadline so ALL invokers get a bounded, announced exit.
1318
1322
  const CLI_DEADLINE_MS = Number.parseInt(process.env.UKIT_COMPACT_CLI_DEADLINE_MS || '', 10) || 4000;
1319
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.
1320
1327
  try {
1321
- process.stdout.write(
1322
- `[ukit-skill-router] compact-threshold prompt CLI exceeded its ${CLI_DEADLINE_MS}ms deadline — `
1328
+ fsSync.writeSync(1, `${JSON.stringify({
1329
+ systemMessage:
1330
+ `[ukit-skill-router] compact-threshold prompt CLI exceeded its ${CLI_DEADLINE_MS}ms deadline — `
1323
1331
  + '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
- );
1332
+ + 'if this repeats, check the .ukit volume or raise UKIT_COMPACT_CLI_DEADLINE_MS.',
1333
+ })}\n`);
1326
1334
  } catch { /* stdout already gone */ }
1327
1335
  process.exit(0);
1328
1336
  }, CLI_DEADLINE_MS);
@@ -204,6 +204,23 @@ export async function sweepExecLedgerDir(dir, {
204
204
 
205
205
  // Sampled gate — by default only ~1 in 16 ledger mutations pays for a bounded
206
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
+
207
224
  export async function maybeSweepExecLedgerDir(dir, {
208
225
  probability,
209
226
  random = Math.random,
@@ -1186,7 +1203,8 @@ export async function recordLedgerEvent(event, {
1186
1203
  if (outcome.ok) {
1187
1204
  // Sampled bounded dir sweep (BUG-C21-11): runs outside the ledger lock so a
1188
1205
  // contended acquisition never pays sweep latency. Advisory — never throws.
1189
- 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); }
1190
1208
  return outcome.value;
1191
1209
  }
1192
1210
 
@@ -200,6 +200,22 @@ export function maybeSweepTelemetryDir(dir, {
200
200
  return { sampled: true, ...sweepTelemetryDir(dir, options) };
201
201
  }
202
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
+
203
219
  export function appendTelemetryRow(projectRoot, sessionId, row, options = {}) {
204
220
  try {
205
221
  if (!row || typeof row !== 'object') return false;
@@ -211,7 +227,7 @@ export function appendTelemetryRow(projectRoot, sessionId, row, options = {}) {
211
227
  fs.appendFileSync(filePath, line, 'utf8');
212
228
  // Sampled bounded dir sweep (BUG-C21-10): advisory — a sweep failure must
213
229
  // never alter the row this call just appended.
214
- try { maybeSweepTelemetryDir(dir); } catch { /* advisory */ }
230
+ try { maybeSweepTelemetryDir(dir); } catch (error) { noteSweepFailure(error); }
215
231
  return true;
216
232
  } catch {
217
233
  // Advisory: an unwritable or corrupt telemetry target must never alter a
@@ -1,6 +1,8 @@
1
1
  #!/usr/bin/env node
2
2
  import fs from 'node:fs/promises';
3
+ import fsSync from 'node:fs';
3
4
  import path from 'node:path';
5
+ import { pathToFileURL } from 'node:url';
4
6
  import {
5
7
  buildCompactMachineKey,
6
8
  buildRuntimePaths,
@@ -19,9 +21,42 @@ import { updateCompactPressureFromOutput } from './compact-threshold.mjs';
19
21
  // Hook-context self-deadline (2.4.1 orphan-leak class): the compress-output hook passes
20
22
  // UKIT_HOOK_DEADLINE_MS so a wedged read can never orphan this process past the hook
21
23
  // budget. Non-hook usage never sets it and is never self-killed.
24
+ // SPEC §8 degrade channel: stdout is the only surface the hook consumer reliably
25
+ // shows the user; stderr is model-facing. writeSync(1) because a same-tick
26
+ // process.exit() can discard a buffered async write on a pipe.
27
+ function emitSystemMessage(text) {
28
+ try {
29
+ fsSync.writeSync(1, `${JSON.stringify({ systemMessage: text })}\n`);
30
+ } catch { /* stdout already gone */ }
31
+ }
32
+
33
+ // BUG-C23-10: bare `.catch(() => {})` swallowed every sweep failure — a
34
+ // persistent EACCES/ENOSPC on .ukit meant silent non-convergence of the tee/
35
+ // backlog bound (live dir observed at 2,417 files). Announce the first failure
36
+ // and then every 25th, C22 rate-limit posture.
37
+ let sweepFailureCount = 0;
38
+ export function noteSweepFailure(error) {
39
+ sweepFailureCount += 1;
40
+ if (sweepFailureCount !== 1 && sweepFailureCount % 25 !== 0) return null;
41
+ const message =
42
+ `UKit output-compression: tee cache sweep failed (${sweepFailureCount} failure(s) so far) — `
43
+ + `the preserved output is safe but the backlog bound may lag. Cause: ${error?.message || error}`;
44
+ emitSystemMessage(message);
45
+ return message;
46
+ }
47
+
22
48
  const HOOK_DEADLINE_MS = Number.parseInt(process.env.UKIT_HOOK_DEADLINE_MS || '', 10);
23
49
  if (Number.isFinite(HOOK_DEADLINE_MS) && HOOK_DEADLINE_MS > 0) {
24
- setTimeout(() => process.exit(0), HOOK_DEADLINE_MS).unref();
50
+ setTimeout(() => {
51
+ // BUG-C23-04: deadline expiry abandons configured compression work — a
52
+ // degrade, and §8(ii) requires it be announced before the exit.
53
+ emitSystemMessage(
54
+ `UKit output-compression exceeded its ${HOOK_DEADLINE_MS}ms hook deadline — `
55
+ + 'tool output passed through uncompressed this call (likely stalled filesystem). '
56
+ + 'If this repeats, check the .ukit volume or raise UKIT_HOOK_DEADLINE_MS.',
57
+ );
58
+ process.exit(0);
59
+ }, HOOK_DEADLINE_MS).unref();
25
60
  }
26
61
 
27
62
  const ANSI_RE = /\u001b\[[0-9;]*m/g;
@@ -422,7 +457,9 @@ export async function sweepTeeCache(dir, {
422
457
  // under-count and `overflow` was always 0. Expected drain: each sampled sweep
423
458
  // removes up to maxRemovals, so a backlog of B files converges after
424
459
  // ~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.
460
+ // sampling); the 7d age cutoff drains the remainder only when new writes keep
461
+ // arriving — a stopped writer converges at ~ceil(B/maxRemovals) sweeps and no
462
+ // faster, since sweeps are sampled off the write path (BUG-C23-10, FR-006).
426
463
  const eligibleNames = [];
427
464
  for (const dirent of dirents) {
428
465
  if (dirent.isFile()) eligibleNames.push(dirent.name);
@@ -498,8 +535,9 @@ async function persistRawOutput(projectRoot, {
498
535
  await fs.mkdir(teeCacheDir, { recursive: true });
499
536
  await fs.writeFile(absolutePath, rawOutputText, 'utf8');
500
537
  // Bounded, sampled tee/ prune (BUG-C21-07): advisory only — a sweep failure
501
- // must never lose the preserved output this call just wrote.
502
- await maybeSweepTeeCache(teeCacheDir).catch(() => {});
538
+ // must never lose the preserved output this call just wrote. BUG-C23-10: a
539
+ // persistent failure (EACCES/ENOSPC) still surfaces as a bounded degrade.
540
+ await maybeSweepTeeCache(teeCacheDir).catch(noteSweepFailure);
503
541
 
504
542
  return {
505
543
  rawSaved: true,
@@ -1486,7 +1524,19 @@ async function main() {
1486
1524
  process.stdout.write(result.summary);
1487
1525
  }
1488
1526
 
1489
- main().catch((error) => {
1490
- console.error('[UKit] Failed to compress tool output:', error?.message || String(error));
1491
- process.exitCode = 1;
1492
- });
1527
+ // BUG-C23-13: only run main() on direct CLI invocation — an unguarded bottom
1528
+ // call executes on ANY import(), reading the importer's stdin and exiting its
1529
+ // process. Guard shape matches compact-threshold.mjs.
1530
+ const invokedPath = process.argv[1] ? path.resolve(process.argv[1]) : null;
1531
+ if (invokedPath && pathToFileURL(invokedPath).href === import.meta.url) {
1532
+ main().catch((error) => {
1533
+ console.error('[UKit] Failed to compress tool output:', error?.message || String(error));
1534
+ // BUG-C23-01: stderr alone is invisible to the hook consumer — the §8
1535
+ // announce contract requires a stdout systemMessage on the crash path too.
1536
+ emitSystemMessage(
1537
+ 'UKit output-compression failed before it could compress this tool output; '
1538
+ + 'the raw output was passed through uncompressed. Run `ukit doctor` if this repeats.',
1539
+ );
1540
+ process.exitCode = 1;
1541
+ });
1542
+ }
@@ -1,6 +1,8 @@
1
1
  #!/usr/bin/env node
2
2
  import fs from 'node:fs/promises';
3
+ import fsSync from 'node:fs';
3
4
  import path from 'node:path';
5
+ import { pathToFileURL } from 'node:url';
4
6
  import {
5
7
  buildCompactMachineKey,
6
8
  buildRuntimePaths,
@@ -22,9 +24,27 @@ import {
22
24
  // Hook-context self-deadline (2.4.1 orphan-leak class): the reinject-context hook passes
23
25
  // UKIT_HOOK_DEADLINE_MS so a wedged read can never orphan this process past the hook
24
26
  // budget. Non-hook usage never sets it and is never self-killed.
27
+ // SPEC §8 degrade channel: stdout is the only surface the hook consumer reliably
28
+ // shows the user; stderr is model-facing. writeSync(1) because a same-tick
29
+ // process.exit() can discard a buffered async write on a pipe.
30
+ function emitSystemMessage(text) {
31
+ try {
32
+ fsSync.writeSync(1, `${JSON.stringify({ systemMessage: text })}\n`);
33
+ } catch { /* stdout already gone */ }
34
+ }
35
+
25
36
  const HOOK_DEADLINE_MS = Number.parseInt(process.env.UKIT_HOOK_DEADLINE_MS || '', 10);
26
37
  if (Number.isFinite(HOOK_DEADLINE_MS) && HOOK_DEADLINE_MS > 0) {
27
- setTimeout(() => process.exit(0), HOOK_DEADLINE_MS).unref();
38
+ setTimeout(() => {
39
+ // BUG-C23-04: deadline expiry abandons the configured context reinjection —
40
+ // a degrade, and §8(ii) requires it be announced before the exit.
41
+ emitSystemMessage(
42
+ `UKit reinject-context exceeded its ${HOOK_DEADLINE_MS}ms hook deadline — `
43
+ + 'post-compaction context reinjection skipped this pass (likely stalled filesystem). '
44
+ + 'If this repeats, check the .ukit volume or raise UKIT_HOOK_DEADLINE_MS.',
45
+ );
46
+ process.exit(0);
47
+ }, HOOK_DEADLINE_MS).unref();
28
48
  }
29
49
 
30
50
  const STATE_TTL_MS = 30 * 60 * 1000;
@@ -1047,7 +1067,19 @@ async function buildPreviousContextLines(projectRoot, state, config) {
1047
1067
  return [`- Previous context: ${rankedItems.map((item) => buildPreviousContextSnippet(item)).join(' | ')}`];
1048
1068
  }
1049
1069
 
1050
- main().catch((error) => {
1051
- console.error('[UKit] Failed to build compact reinject context:', error?.message || String(error));
1052
- process.exitCode = 1;
1053
- });
1070
+ // BUG-C23-13: only run main() on direct CLI invocation — an unguarded bottom
1071
+ // call executes on ANY import(), reading the importer's stdin and exiting its
1072
+ // process. Guard shape matches compact-threshold.mjs.
1073
+ const invokedPath = process.argv[1] ? path.resolve(process.argv[1]) : null;
1074
+ if (invokedPath && pathToFileURL(invokedPath).href === import.meta.url) {
1075
+ main().catch((error) => {
1076
+ console.error('[UKit] Failed to build compact reinject context:', error?.message || String(error));
1077
+ // BUG-C23-01: stderr alone is invisible to the hook consumer — the §8
1078
+ // announce contract requires a stdout systemMessage on the crash path too.
1079
+ emitSystemMessage(
1080
+ 'UKit reinject-context failed before it could rebuild the compact context block; '
1081
+ + 'the session continues without reinjected context this pass. Run `ukit doctor` if this repeats.',
1082
+ );
1083
+ process.exitCode = 1;
1084
+ });
1085
+ }
@@ -561,7 +561,11 @@ async function alreadyCoordinatedThisStop({ projectRoot, sessionKey, now, dedupe
561
561
  return true;
562
562
  }
563
563
  await fs.mkdir(path.dirname(statePath), { recursive: true });
564
- await fs.writeFile(statePath, `${JSON.stringify({ lastStop: { key: sessionKey, ts: now } }, null, 1)}\n`, 'utf8');
564
+ // BUG-C23-08: spread `current` — a bare { lastStop } write erased the
565
+ // `handoff` stall-streak slot written by bumpHandoffStallCount, which ran
566
+ // AFTER this dedupe write each invocation → count always read 1 and the
567
+ // FR-004 liveness breaker could never fire.
568
+ await fs.writeFile(statePath, `${JSON.stringify({ ...current, lastStop: { key: sessionKey, ts: now } }, null, 1)}\n`, 'utf8');
565
569
  return false;
566
570
  });
567
571
  return outcome.ok ? outcome.value === true : false;