@ngockhoale/ukit 2.4.2 → 2.5.0

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 (59) hide show
  1. package/CHANGELOG.md +57 -0
  2. package/README.md +20 -0
  3. package/manifests/platform.full.yaml +51 -112
  4. package/package.json +2 -1
  5. package/scripts/index/refresh-index.mjs +47 -22
  6. package/src/cli/commands/doctor.js +132 -2
  7. package/src/cli/commands/uninstall.js +18 -0
  8. package/src/core/applyPlan.js +17 -2
  9. package/src/core/compact/threshold.js +36 -6
  10. package/src/core/diffPlan.js +35 -0
  11. package/src/core/fileOps.js +26 -0
  12. package/src/core/projectImportant.js +430 -0
  13. package/src/core/sensitiveValueScanner.js +118 -0
  14. package/src/core/status.js +55 -1
  15. package/src/core/uninstall.js +183 -3
  16. package/src/diagnostics/classifyHang.js +246 -0
  17. package/src/index/buildIndex.js +1033 -62
  18. package/templates/.claude/hooks/auto-allow-bash.sh +82 -93
  19. package/templates/.claude/hooks/block-dangerous.sh +31 -5
  20. package/templates/.claude/hooks/completion-gate.sh +51 -10
  21. package/templates/.claude/hooks/compress-output.sh +38 -6
  22. package/templates/.claude/hooks/context-hardcap-gate.sh +35 -6
  23. package/templates/.claude/hooks/context-window-guard.sh +128 -18
  24. package/templates/.claude/hooks/handoff-model-guard.sh +31 -5
  25. package/templates/.claude/hooks/handoff-resume.sh +31 -5
  26. package/templates/.claude/hooks/post-edit-verify.sh +31 -5
  27. package/templates/.claude/hooks/pre-edit-backup.sh +31 -5
  28. package/templates/.claude/hooks/project-important.sh +67 -0
  29. package/templates/.claude/hooks/protect-files.sh +31 -5
  30. package/templates/.claude/hooks/record-execution.sh +31 -5
  31. package/templates/.claude/hooks/sensitive-data-guard.sh +124 -56
  32. package/templates/.claude/hooks/skill-router.sh +31 -5
  33. package/templates/.claude/hooks/stale-spec-guard.sh +31 -5
  34. package/templates/.claude/hooks/task-watchdog.sh +108 -123
  35. package/templates/.claude/hooks/verification-guard.sh +107 -112
  36. package/templates/.claude/hooks/vision-router.sh +49 -13
  37. package/templates/.claude/settings.json +5 -5
  38. package/templates/.claude/ukit/index/lib/index-core.mjs +960 -63
  39. package/templates/.claude/ukit/index/refresh-index.mjs +47 -22
  40. package/templates/.claude/ukit/index/route-task.mjs +610 -4
  41. package/templates/.claude/ukit/runtime/async-lock.mjs +340 -0
  42. package/templates/.claude/ukit/runtime/compact-threshold.mjs +73 -24
  43. package/templates/.claude/ukit/runtime/context-capacity.mjs +144 -0
  44. package/templates/.claude/ukit/runtime/execution-ledger.mjs +664 -170
  45. package/templates/.claude/ukit/runtime/hook-chain-budget.mjs +92 -0
  46. package/templates/.claude/ukit/runtime/hook-chain-runner.mjs +84 -29
  47. package/templates/.claude/ukit/runtime/hook-input.sh +85 -5
  48. package/templates/.claude/ukit/runtime/hook-payload-store.mjs +160 -0
  49. package/templates/.claude/ukit/runtime/hook-process.mjs +250 -0
  50. package/templates/.claude/ukit/runtime/hook-telemetry.mjs +255 -0
  51. package/templates/.claude/ukit/runtime/hook-telemetry.sh +60 -0
  52. package/templates/.claude/ukit/runtime/project-important.mjs +381 -0
  53. package/templates/.claude/ukit/runtime/sensitive-value-scanner.mjs +128 -0
  54. package/templates/.claude/ukit/runtime/stop-coordinator.mjs +509 -0
  55. package/templates/.claude/ukit/runtime/task-watchdog.mjs +180 -6
  56. package/templates/.claude/ukit/runtime/transcript-tail.mjs +1 -1
  57. package/templates/.omp/hooks/pre/ukit-bridge.js +178 -61
  58. package/templates/AGENTS.md +8 -0
  59. package/templates/PROJECT_IMPORTANT.md +9 -0
@@ -5,16 +5,42 @@
5
5
  SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
6
6
  # shellcheck source=/dev/null
7
7
  if source "$SCRIPT_DIR/../ukit/runtime/hook-input.sh" 2>/dev/null; then
8
+ # TASK-019: arm advisory latency telemetry; the finish row is written by the
9
+ # shared cleanup path before the staged payload is removed. A missing
10
+ # runtime (pre-install tree) simply leaves telemetry off.
11
+ source "$SCRIPT_DIR/../ukit/runtime/hook-telemetry.sh" 2>/dev/null || true
8
12
  trap ukit_cleanup_hook_input EXIT
9
13
  # Bounded stdin (H01): stage before anything reads it; Node gets a path, never the payload.
10
14
  ukit_stage_hook_input 2097152 truncate || exit 0
11
15
  else
12
- # Runtime helper missing (pre-install tree): bounded inline staging keeps the
13
- # advisory/fail-open behavior below alive with a capped payload file.
16
+ # Runtime helper missing (pre-install tree): the SAME bounded staging,
17
+ # inline - `cat >/dev/null` used to block forever on a producer that never
18
+ # closes the pipe (R4.5). Read cap+1 in the background, bound the wait.
14
19
  UKIT_INPUT_FILE="$(mktemp "${TMPDIR:-/tmp}/ukit-hook-in.XXXXXX")"
15
- head -c 2097153 > "$UKIT_INPUT_FILE" 2>/dev/null
16
- cat >/dev/null 2>&1
17
- if [ "$(wc -c < "$UKIT_INPUT_FILE" | tr -d '[:space:]')" -gt 2097152 ]; then
20
+ if [ -e /dev/fd/0 ]; then
21
+ exec 8<&0
22
+ head -c 2097153 <&8 > "$UKIT_INPUT_FILE" 2>/dev/null &
23
+ else
24
+ head -c 2097153 > "$UKIT_INPUT_FILE" 2>/dev/null &
25
+ fi
26
+ __ukit_reader=$!
27
+ __ukit_stage_ms="${UKIT_HOOK_STAGE_MS:-2000}"
28
+ case "$__ukit_stage_ms" in
29
+ ''|*[!0-9]*) __ukit_stage_ms=2000 ;;
30
+ esac
31
+ [ "$__ukit_stage_ms" -gt 0 ] 2>/dev/null || __ukit_stage_ms=2000
32
+ printf -v __ukit_stage_s '%d.%03d' $((__ukit_stage_ms / 1000)) $((__ukit_stage_ms % 1000))
33
+ ( sleep "$__ukit_stage_s" 2>/dev/null; kill -9 "$__ukit_reader" 2>/dev/null ) <&- >/dev/null 2>&1 &
34
+ __ukit_waiter=$!
35
+ wait "$__ukit_reader" 2>/dev/null
36
+ __ukit_stage_rc=$?
37
+ kill "$__ukit_waiter" 2>/dev/null
38
+ wait "$__ukit_waiter" 2>/dev/null
39
+ exec 8<&-
40
+ __ukit_size="$(wc -c < "$UKIT_INPUT_FILE" 2>/dev/null | tr -d '[:space:]')"
41
+ # A bounded cut (rc != 0) leaves a short payload that the JSON consumer
42
+ # already degrades on - the shared helper's truncate posture (R4.5).
43
+ if [ "$__ukit_size" -gt 2097152 ]; then
18
44
  truncate -s 2097152 "$UKIT_INPUT_FILE" 2>/dev/null
19
45
  fi
20
46
  trap '[ -n "$UKIT_INPUT_FILE" ] && rm -f "$UKIT_INPUT_FILE"' EXIT
@@ -24,30 +50,34 @@ STATE_FILE="$PROJECT_ROOT/.claude/ukit/skill-router-state.json"
24
50
  ROUTE_CACHE_FILE="$PROJECT_ROOT/.claude/ukit/route-cache.json"
25
51
  PROGRESS_FILE="$PROJECT_ROOT/.claude/ukit/verification-progress.json"
26
52
 
27
- INPUT_FILE="$UKIT_INPUT_FILE" STATE_FILE="$STATE_FILE" ROUTE_CACHE_FILE="$ROUTE_CACHE_FILE" PROGRESS_FILE="$PROGRESS_FILE" node <<'NODE'
53
+ 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'
28
54
  const HOOK_DEADLINE_MS = Number.parseInt(process.env.UKIT_HOOK_DEADLINE_MS || '', 10) || 3000;
55
+ const LOCK_STARTED_AT = Date.now();
29
56
  setTimeout(() => process.exit(0), HOOK_DEADLINE_MS).unref();
30
- const crypto = require('crypto');
31
- const fs = require('fs');
57
+ // TASK-015 fix round 1: the awaitable fs surface. Every state/progress read and
58
+ // the atomic progress mutation is awaited so the unref'd self-deadline above can
59
+ // always preempt it; synchronous fs calls park the event loop instead.
60
+ const fs = require('fs').promises;
32
61
  const path = require('path');
62
+ const { pathToFileURL } = require('url');
33
63
 
34
- function readJson(filePath, fallback = null) {
64
+ async function readJson(filePath, fallback = null) {
35
65
  try {
36
- return JSON.parse(fs.readFileSync(filePath, 'utf8'));
66
+ return JSON.parse(await fs.readFile(filePath, 'utf8'));
37
67
  } catch {
38
68
  return fallback;
39
69
  }
40
70
  }
41
71
 
42
- function writeJsonAtomic(filePath, value) {
43
- fs.mkdirSync(path.dirname(filePath), { recursive: true });
72
+ async function writeJsonAtomic(filePath, value) {
73
+ await fs.mkdir(path.dirname(filePath), { recursive: true });
44
74
  const tempPath = `${filePath}.tmp-${Date.now()}-${Math.random().toString(16).slice(2)}`;
45
75
  try {
46
- fs.writeFileSync(tempPath, JSON.stringify(value, null, 2) + '\n');
47
- fs.renameSync(tempPath, filePath);
76
+ await fs.writeFile(tempPath, JSON.stringify(value, null, 2) + '\n');
77
+ await fs.rename(tempPath, filePath);
48
78
  } catch (error) {
49
79
  try {
50
- fs.rmSync(tempPath, { force: true });
80
+ await fs.rm(tempPath, { force: true });
51
81
  } catch {}
52
82
  throw error;
53
83
  }
@@ -56,86 +86,26 @@ function writeJsonAtomic(filePath, value) {
56
86
  // TASK-015: lock waits poll asynchronously. The old blocking shared-memory
57
87
  // sleep froze the event loop, so the unref'd deadline timer above could never
58
88
  // fire while waiting — a contended lock held the hook past its own 3s
59
- // self-deadline. setTimeout-based sleeps keep the loop live.
60
- function sleep(ms) {
61
- return new Promise((resolve) => setTimeout(resolve, ms));
62
- }
63
-
64
- function isPidAlive(pid) {
65
- if (!Number.isInteger(pid) || pid <= 0) return false;
66
- try {
67
- process.kill(pid, 0);
68
- return true;
69
- } catch (error) {
70
- return error?.code === 'EPERM';
71
- }
72
- }
73
-
74
- function readLockOwner(lockPath) {
75
- const owner = readJson(path.join(lockPath, 'owner'), null);
76
- return owner && Number.isInteger(owner.pid) && typeof owner.token === 'string'
77
- ? owner
78
- : null;
79
- }
80
-
81
- // mkdir-based lock, protocol-compatible with runtime token-utils withFileLock.
82
- // Async now (see sleep above): the single-syscall mkdir/stat/write critical
83
- // section stays synchronous, only the contention backoff awaits. Fail-open
84
- // after 5s: this hook must never hang a PreToolUse chain, and a lost progress
85
- // entry only costs one repeated advisory.
86
- async function withLock(lockPath, fn) {
87
- const staleMs = 10000;
88
- const maxWaitMs = 5000;
89
- const startedAt = Date.now();
90
- const ownerToken = `${process.pid}-${crypto.randomBytes(8).toString('hex')}`;
91
- let held = false;
92
- while (!held) {
93
- try {
94
- fs.mkdirSync(path.dirname(lockPath), { recursive: true });
95
- fs.mkdirSync(lockPath);
89
+ // self-deadline. TASK-028: the lock itself is the shared runtime async-lock
90
+ // utility (same mkdir/owner protocol as token-utils withFileLock, jittered
91
+ // async backoff, no blocking shared-memory wait), so this hook carries no
92
+ // inline lock copy.
93
+ let lockModulePromise = null;
94
+ function loadLockModule() {
95
+ if (!lockModulePromise) {
96
+ lockModulePromise = (async () => {
97
+ const runtimeDir = process.env.UKIT_RUNTIME_DIR || '';
98
+ const modulePath = runtimeDir ? path.join(runtimeDir, 'async-lock.mjs') : '';
99
+ if (!modulePath) return null;
96
100
  try {
97
- fs.writeFileSync(path.join(lockPath, 'owner'), JSON.stringify({
98
- pid: process.pid,
99
- token: ownerToken,
100
- ts: Date.now(),
101
- }));
102
- } catch (error) {
103
- try {
104
- fs.rmSync(lockPath, { recursive: true, force: true });
105
- } catch {}
106
- throw error;
101
+ await fs.access(modulePath);
102
+ return await import(pathToFileURL(modulePath).href);
103
+ } catch {
104
+ return null;
107
105
  }
108
- held = true;
109
- } catch (error) {
110
- if (!error || error.code !== 'EEXIST') return fn();
111
- try {
112
- const stat = fs.statSync(lockPath);
113
- if (Date.now() - stat.mtimeMs > staleMs) {
114
- const owner = readLockOwner(lockPath);
115
- if (!owner || !isPidAlive(owner.pid)) {
116
- try {
117
- fs.rmSync(lockPath, { recursive: true, force: true });
118
- continue;
119
- } catch {}
120
- }
121
- }
122
- } catch {}
123
- if (Date.now() - startedAt > maxWaitMs) break;
124
- await sleep(3 + Math.floor(Math.random() * 9));
125
- }
126
- }
127
- try {
128
- return await fn();
129
- } finally {
130
- if (held) {
131
- const owner = readLockOwner(lockPath);
132
- if (owner?.token === ownerToken) {
133
- try {
134
- fs.rmSync(lockPath, { recursive: true, force: true });
135
- } catch {}
136
- }
137
- }
106
+ })();
138
107
  }
108
+ return lockModulePromise;
139
109
  }
140
110
 
141
111
  function normalizeCommand(value) {
@@ -351,23 +321,30 @@ function advise(message) {
351
321
  // ledger, context hard-cap, protected-file, and destructive-command guards; turning
352
322
  // a verification-order recommendation into an exit-2 refusal gave a stale opt-in env
353
323
  // variable a way to freeze a run before it could diagnose or land its current change.
354
- fs.writeSync(1, `${message.replace(/^BLOCKED:/, 'ADVISORY:')}\n`);
324
+ process.stdout.write(`${message.replace(/^BLOCKED:/, 'ADVISORY:')}\n`);
355
325
  process.exit(0);
356
326
  }
357
327
 
358
- // TASK-015: the whole flow runs in an async IIFE so the lock acquire can be
359
- // awaited (async polling) before the policy checks; every path still ends in
360
- // exit 0 — this hook is advisory by design.
328
+ // TASK-015 fix round 1: the whole flow runs in an async IIFE and every
329
+ // deadline-scoped filesystem operation yields to the event loop. Synchronous
330
+ // reads/writes park the loop and make the self-deadline un-fireable.
361
331
  (async () => {
362
- let rawInput = '';
363
- try { rawInput = fs.readFileSync(process.env.INPUT_FILE || '', 'utf8'); } catch {}
364
- const payload = (() => {
365
- try {
366
- return JSON.parse(rawInput || '{}');
367
- } catch {
368
- return {};
332
+ const rawInput = await readTextSafe(process.env.INPUT_FILE || '');
333
+ const payload = (() => {
334
+ try {
335
+ return JSON.parse(rawInput || '{}');
336
+ } catch {
337
+ return {};
338
+ }
339
+ })();
340
+
341
+ async function readTextSafe(filePath) {
342
+ try {
343
+ return await fs.readFile(filePath, 'utf8');
344
+ } catch {
345
+ return '';
346
+ }
369
347
  }
370
- })();
371
348
  const command = normalizeCommand(payload?.tool_input?.command || payload?.command || '');
372
349
  if (!command) {
373
350
  process.exit(0);
@@ -375,8 +352,8 @@ if (!command) {
375
352
 
376
353
  const statePath = process.env.STATE_FILE;
377
354
  const progressPath = process.env.PROGRESS_FILE;
378
- const state = readJson(statePath, null);
379
- const routeCache = readJson(process.env.ROUTE_CACHE_FILE, null);
355
+ const state = await readJson(statePath, null);
356
+ const routeCache = await readJson(process.env.ROUTE_CACHE_FILE, null);
380
357
  const recommendation = state?.verificationRecommendation ?? null;
381
358
  const routeSummary = state?.routeSummary ?? null;
382
359
  const helpers = state?.helpers ?? null;
@@ -423,7 +400,7 @@ if (!policyMode && primaryCommands.length === 0 && fallbackCommands.length === 0
423
400
  process.exit(0);
424
401
  }
425
402
 
426
- const progress = readJson(progressPath, {});
403
+ const progress = await readJson(progressPath, {});
427
404
  const attemptedCommands = collectAttemptedCommands(progress, {
428
405
  currentFingerprint: state?.fingerprint || null,
429
406
  maxAgeMs: STATE_FRESH_MS,
@@ -436,8 +413,8 @@ async function persistAttempt(commandText) {
436
413
  // Locked re-read + merge: parallel subagents record attempts concurrently, and a
437
414
  // stale-snapshot rewrite would silently drop their entries — the guard would then keep
438
415
  // advising instead of allowing, re-litigating verification order forever.
439
- await withLock(`${progressPath}.lock`, async () => {
440
- const current = readJson(progressPath, {});
416
+ const mutation = async () => {
417
+ const current = await readJson(progressPath, {});
441
418
  const mergedAttempts = new Set(collectAttemptedCommands(current, {
442
419
  currentFingerprint: state?.fingerprint || null,
443
420
  maxAgeMs: STATE_FRESH_MS,
@@ -457,13 +434,31 @@ async function persistAttempt(commandText) {
457
434
  .filter((entry, index, list) => (
458
435
  list.findLastIndex((candidate) => candidate.command === entry.command) === index
459
436
  ));
460
- writeJsonAtomic(progressPath, {
437
+ await writeJsonAtomic(progressPath, {
461
438
  fingerprint: state?.fingerprint || null,
462
439
  updatedAt: now,
463
440
  attemptedCommands: [...mergedAttempts],
464
441
  recentAttempts,
465
442
  });
466
- });
443
+ };
444
+ const lockModule = await loadLockModule();
445
+ if (lockModule && typeof lockModule.withAsyncLock === 'function') {
446
+ // TASK-028: the acquisition budget derives from the remaining hook deadline minus
447
+ // a cleanup reserve, capped at a short slice — a contended lock can never hold
448
+ // this hook near its own 3s deadline. On busy/abort the persist is SKIPPED, not
449
+ // written unlocked: the progress file is advisory and the next uncontended run
450
+ // re-records the attempt.
451
+ const deadlineMs = lockModule.lockBudgetMs({
452
+ hookDeadlineMs: HOOK_DEADLINE_MS,
453
+ startedAt: LOCK_STARTED_AT,
454
+ });
455
+ const outcome = await lockModule.withAsyncLock(progressPath, { deadlineMs }, mutation);
456
+ if (!outcome.ok) return;
457
+ return;
458
+ }
459
+ // Degraded install (runtime module missing): advisory progress file — keep the
460
+ // pre-utility unlocked persist so the advisory lane stays lively.
461
+ await mutation();
467
462
  }
468
463
 
469
464
  const isPrimaryCommand = primaryCommands.includes(command);
@@ -538,7 +533,7 @@ process.exit(0);
538
533
  // Advisory hook: an unexpected error (including a rejected lock) must never
539
534
  // turn into a non-zero exit — degrade to a bounded diagnostic and exit 0.
540
535
  try {
541
- fs.writeSync(2, `verification-guard: unexpected error (${err?.message ?? err})\n`);
536
+ process.stderr.write(`verification-guard: unexpected error (${err?.message ?? err})\n`);
542
537
  } catch {}
543
538
  process.exit(0);
544
539
  });
@@ -22,16 +22,42 @@
22
22
  SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
23
23
  # shellcheck source=/dev/null
24
24
  if source "$SCRIPT_DIR/../ukit/runtime/hook-input.sh" 2>/dev/null; then
25
+ # TASK-019: arm advisory latency telemetry; the finish row is written by the
26
+ # shared cleanup path before the staged payload is removed. A missing
27
+ # runtime (pre-install tree) simply leaves telemetry off.
28
+ source "$SCRIPT_DIR/../ukit/runtime/hook-telemetry.sh" 2>/dev/null || true
25
29
  trap ukit_cleanup_hook_input EXIT
26
30
  # Bounded stdin (H01): stage before anything reads it; Node gets a path, never the payload.
27
31
  ukit_stage_hook_input 2097152 truncate || exit 0
28
32
  else
29
- # Runtime helper missing (pre-install tree): bounded inline staging keeps the
30
- # advisory/fail-open behavior below alive with a capped payload file.
33
+ # Runtime helper missing (pre-install tree): the SAME bounded staging,
34
+ # inline - `cat >/dev/null` used to block forever on a producer that never
35
+ # closes the pipe (R4.5). Read cap+1 in the background, bound the wait.
31
36
  UKIT_INPUT_FILE="$(mktemp "${TMPDIR:-/tmp}/ukit-hook-in.XXXXXX")"
32
- head -c 2097153 > "$UKIT_INPUT_FILE" 2>/dev/null
33
- cat >/dev/null 2>&1
34
- if [ "$(wc -c < "$UKIT_INPUT_FILE" | tr -d '[:space:]')" -gt 2097152 ]; then
37
+ if [ -e /dev/fd/0 ]; then
38
+ exec 8<&0
39
+ head -c 2097153 <&8 > "$UKIT_INPUT_FILE" 2>/dev/null &
40
+ else
41
+ head -c 2097153 > "$UKIT_INPUT_FILE" 2>/dev/null &
42
+ fi
43
+ __ukit_reader=$!
44
+ __ukit_stage_ms="${UKIT_HOOK_STAGE_MS:-2000}"
45
+ case "$__ukit_stage_ms" in
46
+ ''|*[!0-9]*) __ukit_stage_ms=2000 ;;
47
+ esac
48
+ [ "$__ukit_stage_ms" -gt 0 ] 2>/dev/null || __ukit_stage_ms=2000
49
+ printf -v __ukit_stage_s '%d.%03d' $((__ukit_stage_ms / 1000)) $((__ukit_stage_ms % 1000))
50
+ ( sleep "$__ukit_stage_s" 2>/dev/null; kill -9 "$__ukit_reader" 2>/dev/null ) <&- >/dev/null 2>&1 &
51
+ __ukit_waiter=$!
52
+ wait "$__ukit_reader" 2>/dev/null
53
+ __ukit_stage_rc=$?
54
+ kill "$__ukit_waiter" 2>/dev/null
55
+ wait "$__ukit_waiter" 2>/dev/null
56
+ exec 8<&-
57
+ __ukit_size="$(wc -c < "$UKIT_INPUT_FILE" 2>/dev/null | tr -d '[:space:]')"
58
+ # A bounded cut (rc != 0) leaves a short payload that the JSON consumer
59
+ # already degrades on - the shared helper's truncate posture (R4.5).
60
+ if [ "$__ukit_size" -gt 2097152 ]; then
35
61
  truncate -s 2097152 "$UKIT_INPUT_FILE" 2>/dev/null
36
62
  fi
37
63
  trap '[ -n "$UKIT_INPUT_FILE" ] && rm -f "$UKIT_INPUT_FILE"' EXIT
@@ -41,7 +67,7 @@ PROJECT_ROOT="${CLAUDE_PROJECT_DIR:-$PWD}"
41
67
  INPUT_FILE="$UKIT_INPUT_FILE" PROJECT_ROOT="$PROJECT_ROOT" node <<'NODE' || true
42
68
  const HOOK_DEADLINE_MS = Number.parseInt(process.env.UKIT_HOOK_DEADLINE_MS || '', 10) || 8000;
43
69
  setTimeout(() => process.exit(0), HOOK_DEADLINE_MS).unref();
44
- const fs = require('fs');
70
+ const fs = require('fs').promises;
45
71
  const path = require('path');
46
72
  const { spawn } = require('child_process');
47
73
  const { pathToFileURL } = require('url');
@@ -59,9 +85,15 @@ process.on('exit', () => {
59
85
  }
60
86
  });
61
87
 
62
- let rawInput = '';
63
- try { rawInput = fs.readFileSync(process.env.INPUT_FILE || '', 'utf8'); } catch {}
88
+ // TASK-015 fix round 1: every hot-path fs call is awaited. A synchronous read
89
+ // parks the event loop for the whole syscall, so the unref'd self-deadline above
90
+ // could never fire while a project filesystem stalled.
91
+ async function readTextSafe(filePath) {
92
+ try { return await fs.readFile(filePath, 'utf8'); } catch { return ''; }
93
+ }
94
+
64
95
  (async () => {
96
+ const rawInput = await readTextSafe(process.env.INPUT_FILE || '');
65
97
  const payload = (() => {
66
98
  try {
67
99
  const parsed = JSON.parse(rawInput);
@@ -86,7 +118,7 @@ try { rawInput = fs.readFileSync(process.env.INPUT_FILE || '', 'utf8'); } catch
86
118
  async function detectUnicModeSafe() {
87
119
  try {
88
120
  const gatewayPath = path.join(projectRoot, '.claude', 'ukit', 'index', 'unic-gateway.mjs');
89
- if (!fs.existsSync(gatewayPath)) return null;
121
+ try { await fs.access(gatewayPath); } catch { return null; }
90
122
  const mod = await import(pathToFileURL(gatewayPath).href);
91
123
  if (typeof mod.detectUnicGateway !== 'function') return null;
92
124
  return !!mod.detectUnicGateway({ rootDir: projectRoot })?.unicMode;
@@ -135,7 +167,7 @@ try { rawInput = fs.readFileSync(process.env.INPUT_FILE || '', 'utf8'); } catch
135
167
  // pair) and silently skips it. Kept here as a small inline copy rather than
136
168
  // a cross-file import — the contract is a few lines, the cost of drift is
137
169
  // small, and the hook must stay self-contained (no module resolution).
138
- function stripPathNoise(raw) {
170
+ async function stripPathNoise(raw) {
139
171
  const value = String(raw ?? '').trim();
140
172
  if (!value) return value;
141
173
  const pairs = { '(': ')', '[': ']', '{': '}', '"': '"', "'": "'" };
@@ -168,7 +200,8 @@ try { rawInput = fs.readFileSync(process.env.INPUT_FILE || '', 'utf8'); } catch
168
200
  for (const c of candidates) {
169
201
  if (!c) continue;
170
202
  try {
171
- if (fs.existsSync(path.resolve(c))) return c;
203
+ await fs.access(path.resolve(c));
204
+ return c;
172
205
  } catch {
173
206
  // ignore
174
207
  }
@@ -236,7 +269,8 @@ try { rawInput = fs.readFileSync(process.env.INPUT_FILE || '', 'utf8'); } catch
236
269
  // string inside the extractor.
237
270
  let extractorJson = null;
238
271
  const extractorPath = path.join(projectRoot, '.claude', 'ukit', 'index', 'extract-image.mjs');
239
- if (fs.existsSync(extractorPath)) {
272
+ try {
273
+ await fs.access(extractorPath);
240
274
  const args = [extractorPath, '--detect', '--mark-pending', '--json'];
241
275
  if (typeof payload?.transcript_path === 'string' && payload.transcript_path.trim()) {
242
276
  args.push('--session', payload.transcript_path.trim());
@@ -248,7 +282,7 @@ try { rawInput = fs.readFileSync(process.env.INPUT_FILE || '', 'utf8'); } catch
248
282
  // Mirror extractor's resolveRefs input contract: the LLM sometimes leaves
249
283
  // trailing punctuation/quotes on a path named in a prompt. Clean before
250
284
  // handing off so the hook and the extractor agree on the ref string.
251
- args.push('--ref', stripPathNoise(ref));
285
+ args.push('--ref', await stripPathNoise(ref));
252
286
  }
253
287
  let out = null;
254
288
  try {
@@ -260,6 +294,8 @@ try { rawInput = fs.readFileSync(process.env.INPUT_FILE || '', 'utf8'); } catch
260
294
  const head = typeof out === 'string' && out ? `; output head: ${out.slice(0, 200)}` : '';
261
295
  process.stderr.write(`vision-router: extractor call skipped (${err?.message ?? err})${head}\n`);
262
296
  }
297
+ } catch {
298
+ // Missing extractor is a normal fail-open condition.
263
299
  }
264
300
 
265
301
  // TASK-006 contract: in --mark-pending --json mode `images[]` is the
@@ -208,11 +208,6 @@
208
208
  "type": "command",
209
209
  "command": "\"$CLAUDE_PROJECT_DIR/.claude/hooks/completion-gate.sh\"",
210
210
  "timeout": 4
211
- },
212
- {
213
- "type": "command",
214
- "command": "\"$CLAUDE_PROJECT_DIR/.claude/hooks/task-watchdog.sh\"",
215
- "timeout": 4
216
211
  }
217
212
  ]
218
213
  }
@@ -231,6 +226,11 @@
231
226
  "SessionStart": [
232
227
  {
233
228
  "hooks": [
229
+ {
230
+ "type": "command",
231
+ "command": "\"$CLAUDE_PROJECT_DIR/.claude/hooks/project-important.sh\"",
232
+ "timeout": 8
233
+ },
234
234
  {
235
235
  "type": "command",
236
236
  "command": "\"$CLAUDE_PROJECT_DIR/.claude/hooks/auto-prune-bash.sh\"",