@ngockhoale/ukit 2.6.4 → 2.6.6

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.
@@ -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