@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
@@ -29,9 +29,23 @@ node -e '
29
29
  // Deadline must exceed the bounded lock wait in this block (maxWaitMs = 5000) so the
30
30
  // watchdog can never fire while the mutation is still legally waiting for the lock.
31
31
  const HOOK_DEADLINE_MS = Number.parseInt(process.env.UKIT_HOOK_DEADLINE_MS || "", 10) || 8000;
32
- setTimeout(() => process.exit(0), HOOK_DEADLINE_MS).unref();
32
+
33
+ // SPEC §8: this hook is the documented escape hatch for a stuck compact gate, so a
34
+ // failure must be announced — a silent failure leaves the gate bricked with no hint.
35
+ function emitDegrade(reason) {
36
+ try {
37
+ process.stdout.write(JSON.stringify({
38
+ systemMessage: `UKit reset-compact-pressure: ${reason}`,
39
+ }) + "\n");
40
+ } catch {}
41
+ }
42
+
43
+ setTimeout(() => {
44
+ emitDegrade("reset exceeded its deadline; the compact-pressure record may still be stale — restarting the session retries the reset.");
45
+ process.exit(0);
46
+ }, HOOK_DEADLINE_MS).unref();
33
47
  const crypto = require("crypto");
34
- const fs = require("fs");
48
+ const fsp = require("fs").promises;
35
49
  const path = require("path");
36
50
 
37
51
  function isPidAlive(pid) {
@@ -44,9 +58,9 @@ function isPidAlive(pid) {
44
58
  }
45
59
  }
46
60
 
47
- function readLockOwner(lockPath) {
61
+ async function readLockOwner(lockPath) {
48
62
  try {
49
- const owner = JSON.parse(fs.readFileSync(path.join(lockPath, "owner"), "utf8"));
63
+ const owner = JSON.parse(await fsp.readFile(path.join(lockPath, "owner"), "utf8"));
50
64
  return Number.isInteger(owner?.pid) && typeof owner.token === "string" ? owner : null;
51
65
  } catch {
52
66
  return null;
@@ -61,16 +75,16 @@ async function withLock(lockPath, fn) {
61
75
  let held = false;
62
76
  while (!held) {
63
77
  try {
64
- fs.mkdirSync(lockPath);
78
+ await fsp.mkdir(lockPath);
65
79
  try {
66
- fs.writeFileSync(path.join(lockPath, "owner"), JSON.stringify({
80
+ await fsp.writeFile(path.join(lockPath, "owner"), JSON.stringify({
67
81
  pid: process.pid,
68
82
  token: ownerToken,
69
83
  ts: Date.now(),
70
84
  }));
71
85
  } catch (error) {
72
86
  try {
73
- fs.rmSync(lockPath, { recursive: true, force: true });
87
+ await fsp.rm(lockPath, { recursive: true, force: true });
74
88
  } catch {}
75
89
  throw error;
76
90
  }
@@ -78,12 +92,12 @@ async function withLock(lockPath, fn) {
78
92
  } catch (error) {
79
93
  if (!error || error.code !== "EEXIST") return fn();
80
94
  try {
81
- const stat = fs.statSync(lockPath);
95
+ const stat = await fsp.stat(lockPath);
82
96
  if (Date.now() - stat.mtimeMs > staleMs) {
83
- const owner = readLockOwner(lockPath);
97
+ const owner = await readLockOwner(lockPath);
84
98
  if (!owner || !isPidAlive(owner.pid)) {
85
99
  try {
86
- fs.rmSync(lockPath, { recursive: true, force: true });
100
+ await fsp.rm(lockPath, { recursive: true, force: true });
87
101
  continue;
88
102
  } catch {}
89
103
  }
@@ -97,10 +111,10 @@ async function withLock(lockPath, fn) {
97
111
  return await fn();
98
112
  } finally {
99
113
  if (held) {
100
- const owner = readLockOwner(lockPath);
114
+ const owner = await readLockOwner(lockPath);
101
115
  if (owner?.token === ownerToken) {
102
116
  try {
103
- fs.rmSync(lockPath, { recursive: true, force: true });
117
+ await fsp.rm(lockPath, { recursive: true, force: true });
104
118
  } catch {}
105
119
  }
106
120
  }
@@ -120,17 +134,17 @@ process.stdin.on("end", () => {
120
134
  const sessionId = typeof payload.session_id === "string" ? payload.session_id.trim() : "";
121
135
  const pressurePath = process.argv[1];
122
136
 
123
- withLock(`${pressurePath}.lock`, () => {
124
- const raw = (() => {
137
+ withLock(`${pressurePath}.lock`, async () => {
138
+ const raw = await (async () => {
125
139
  try {
126
- return JSON.parse(fs.readFileSync(pressurePath, "utf8"));
140
+ return JSON.parse(await fsp.readFile(pressurePath, "utf8"));
127
141
  } catch {
128
142
  return null;
129
143
  }
130
144
  })();
131
145
  if (!raw || typeof raw !== "object") {
132
146
  try {
133
- fs.rmSync(pressurePath, { force: true });
147
+ await fsp.rm(pressurePath, { force: true });
134
148
  } catch {}
135
149
  return;
136
150
  }
@@ -139,7 +153,7 @@ process.stdin.on("end", () => {
139
153
  // the documented escape hatch for a bricked gate, and it must stay one.
140
154
  if (!sessionId || !raw.sessions || typeof raw.sessions !== "object") {
141
155
  try {
142
- fs.rmSync(pressurePath, { force: true });
156
+ await fsp.rm(pressurePath, { force: true });
143
157
  } catch {}
144
158
  return;
145
159
  }
@@ -150,7 +164,7 @@ process.stdin.on("end", () => {
150
164
  .sort(([, a], [, b]) => (b?.updatedAt ?? 0) - (a?.updatedAt ?? 0))[0]?.[1] ?? null;
151
165
  const tempPath = `${pressurePath}.tmp-${Date.now()}-${Math.random().toString(16).slice(2)}`;
152
166
  try {
153
- fs.writeFileSync(
167
+ await fsp.writeFile(
154
168
  tempPath,
155
169
  JSON.stringify({
156
170
  ...(newest && typeof newest === "object" ? newest : {}),
@@ -159,15 +173,18 @@ process.stdin.on("end", () => {
159
173
  sessions,
160
174
  }, null, 2) + "\n",
161
175
  );
162
- fs.renameSync(tempPath, pressurePath);
176
+ await fsp.rename(tempPath, pressurePath);
163
177
  } catch (error) {
164
178
  try {
165
- fs.rmSync(tempPath, { force: true });
179
+ await fsp.rm(tempPath, { force: true });
166
180
  } catch {}
167
181
  throw error;
168
182
  }
169
- }).catch(() => {});
183
+ }).catch((error) => {
184
+ emitDegrade(`reset failed (${error?.message ?? error}); the compact-pressure record may still be stale — restarting the session retries the reset.`);
185
+ process.exit(0);
186
+ });
170
187
  });
171
- ' "$PRESSURE_FILE" >/dev/null 2>&1 || true
188
+ ' "$PRESSURE_FILE" 2>/dev/null || true
172
189
 
173
190
  exit 0
@@ -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
@@ -62,6 +62,7 @@ PROJECT_ROOT="${CLAUDE_PROJECT_DIR:-$(pwd)}"
62
62
  SCRIPT="$PROJECT_ROOT/.claude/ukit/index/stale-spec-check.mjs"
63
63
 
64
64
  if [ ! -f "$SCRIPT" ]; then
65
+ printf '%s\n' '{"systemMessage":"UKit stale-spec guard: index script missing — run: ukit install"}'
65
66
  exit 0
66
67
  fi
67
68
 
@@ -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 {}
@@ -65,7 +65,16 @@ PROGRESS_FILE="$PROJECT_ROOT/.claude/ukit/verification-progress.json"
65
65
  INPUT_FILE="$UKIT_INPUT_FILE" STATE_FILE="$STATE_FILE" ROUTE_CACHE_FILE="$ROUTE_CACHE_FILE" PROGRESS_FILE="$PROGRESS_FILE" UKIT_RUNTIME_DIR="$SCRIPT_DIR/../ukit/runtime" node <<'NODE'
66
66
  const HOOK_DEADLINE_MS = Number.parseInt(process.env.UKIT_HOOK_DEADLINE_MS || '', 10) || 3000;
67
67
  const LOCK_STARTED_AT = Date.now();
68
- setTimeout(() => process.exit(0), HOOK_DEADLINE_MS).unref();
68
+ // SPEC §8: a timed-out gate silently passes work it never evaluated — announce the
69
+ // degrade (fail-open posture kept: still exit 0).
70
+ setTimeout(() => {
71
+ try {
72
+ process.stdout.write(JSON.stringify({
73
+ systemMessage: `UKit verification-guard: evaluation exceeded its ${HOOK_DEADLINE_MS}ms deadline; this command was allowed without verification tracking.`,
74
+ }) + '\n');
75
+ } catch {}
76
+ process.exit(0);
77
+ }, HOOK_DEADLINE_MS).unref();
69
78
  // TASK-015 fix round 1: the awaitable fs surface. Every state/progress read and
70
79
  // the atomic progress mutation is awaited so the unref'd self-deadline above can
71
80
  // always preempt it; synchronous fs calls park the event loop instead.
@@ -544,6 +553,13 @@ process.exit(0);
544
553
  })().catch((err) => {
545
554
  // Advisory hook: an unexpected error (including a rejected lock) must never
546
555
  // turn into a non-zero exit — degrade to a bounded diagnostic and exit 0.
556
+ // SPEC §8: the degrade is emitted on stdout too — stderr alone may never reach
557
+ // the user, and a wedged gate must not look identical to a clean pass.
558
+ try {
559
+ process.stdout.write(JSON.stringify({
560
+ systemMessage: `UKit verification-guard: unexpected error (${err?.message ?? err}); command allowed without verification tracking.`,
561
+ }) + '\n');
562
+ } catch {}
547
563
  try {
548
564
  process.stderr.write(`verification-guard: unexpected error (${err?.message ?? err})\n`);
549
565
  } catch {}
@@ -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
@@ -73,7 +73,7 @@
73
73
  {
74
74
  "type": "command",
75
75
  "command": "\"$CLAUDE_PROJECT_DIR/.claude/hooks/stale-spec-guard.sh\"",
76
- "timeout": 8
76
+ "timeout": 11
77
77
  },
78
78
  {
79
79
  "type": "command",
@@ -83,7 +83,7 @@
83
83
  {
84
84
  "type": "command",
85
85
  "command": "\"$CLAUDE_PROJECT_DIR/.claude/hooks/skill-router.sh\"",
86
- "timeout": 8
86
+ "timeout": 20
87
87
  },
88
88
  {
89
89
  "type": "command",
@@ -103,7 +103,7 @@
103
103
  {
104
104
  "type": "command",
105
105
  "command": "\"$CLAUDE_PROJECT_DIR/.claude/hooks/auto-allow-bash.sh\"",
106
- "timeout": 12
106
+ "timeout": 15
107
107
  },
108
108
  {
109
109
  "type": "command",
@@ -191,7 +191,7 @@
191
191
  {
192
192
  "type": "command",
193
193
  "command": "\"$CLAUDE_PROJECT_DIR/.claude/hooks/skill-router.sh\"",
194
- "timeout": 8
194
+ "timeout": 20
195
195
  },
196
196
  {
197
197
  "type": "command",
@@ -46,6 +46,12 @@ const DEFAULT_LIMIT = 3;
46
46
  const MAX_DECODED_BYTES = 10 * 1024 * 1024; // 10 MB
47
47
  const RETENTION_MAX_IMAGE_FILES = 20;
48
48
  const MARKER_MAX_AGE_MS = 24 * 60 * 60 * 1000; // 24h
49
+ // analyzed-<sha>.json receipts outlive their marker dir: deleting the dir at
50
+ // 24h also deletes the receipt isAlreadyAnalyzed() reads, which re-arms
51
+ // pending-<sha>.json for an already-analyzed image and re-fires the vision
52
+ // gate every sweep (BUG-C22-14). Receipts get their own longer TTL, shared
53
+ // with the legacy top-level analyzed-*.json pruner (BUG-C22-18).
54
+ const ANALYZED_RECEIPT_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000; // 30d
49
55
 
50
56
  /**
51
57
  * Single shared hash function for both the image filename sha and the
@@ -491,6 +497,23 @@ function pruneImageFiles(outDir) {
491
497
  // ignore
492
498
  }
493
499
  }
500
+
501
+ // BUG-C22-18: legacy top-level analyzed-<sha>.json receipts match neither
502
+ // pruner and never converge — remove them once past the shared receipt TTL.
503
+ // Safe: isAlreadyAnalyzed() only reads receipts INSIDE marker dirs; nothing
504
+ // consults the top-level orphans.
505
+ const now = Date.now();
506
+ for (const e of entries) {
507
+ if (!e.isFile() || !e.name.startsWith('analyzed-') || !e.name.endsWith('.json')) continue;
508
+ try {
509
+ const full = path.join(outDir, e.name);
510
+ if (now - fs.statSync(full).mtimeMs > ANALYZED_RECEIPT_MAX_AGE_MS) {
511
+ fs.unlinkSync(full);
512
+ }
513
+ } catch {
514
+ // ignore
515
+ }
516
+ }
494
517
  }
495
518
 
496
519
  function pruneMarkerDirs(outDir) {
@@ -511,6 +534,10 @@ function pruneMarkerDirs(outDir) {
511
534
  continue;
512
535
  }
513
536
  if (now - mtimeMs > MARKER_MAX_AGE_MS) {
537
+ // BUG-C22-14: a dir holding an analyzed-<sha>.json receipt younger than
538
+ // the receipt TTL is kept — deleting it would erase the only record
539
+ // isAlreadyAnalyzed() reads and re-arm the vision gate every sweep.
540
+ if (hasLiveAnalyzedReceipt(full, now)) continue;
514
541
  try {
515
542
  fs.rmSync(full, { recursive: true, force: true });
516
543
  } catch {
@@ -520,6 +547,28 @@ function pruneMarkerDirs(outDir) {
520
547
  }
521
548
  }
522
549
 
550
+ // Returns true when the marker dir still carries an analyzed-<sha>.json receipt
551
+ // inside its TTL — the receipt outlives the 24h dir sweep so the analyzed-sha
552
+ // filter keeps suppressing re-arms until the receipt itself ages out.
553
+ function hasLiveAnalyzedReceipt(markerDir, now) {
554
+ let names;
555
+ try {
556
+ names = fs.readdirSync(markerDir);
557
+ } catch {
558
+ return false;
559
+ }
560
+ for (const name of names) {
561
+ if (!name.startsWith('analyzed-') || !name.endsWith('.json')) continue;
562
+ try {
563
+ const mtimeMs = fs.statSync(path.join(markerDir, name)).mtimeMs;
564
+ if (now - mtimeMs <= ANALYZED_RECEIPT_MAX_AGE_MS) return true;
565
+ } catch {
566
+ // ignore
567
+ }
568
+ }
569
+ return false;
570
+ }
571
+
523
572
  function printResult(opts, result) {
524
573
  if (opts.json) {
525
574
  console.log(JSON.stringify(result));