@ngockhoale/ukit 2.4.0 → 2.4.2

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 (39) hide show
  1. package/CHANGELOG.md +79 -0
  2. package/package.json +1 -1
  3. package/scripts/index/refresh-index.mjs +10 -5
  4. package/src/cli/commands/doctor.js +59 -2
  5. package/src/core/gatewayProbe.js +143 -15
  6. package/src/core/gatewayResilienceEnv.js +136 -7
  7. package/src/index/buildIndex.js +74 -24
  8. package/templates/.claude/hooks/auto-allow-bash.sh +24 -1
  9. package/templates/.claude/hooks/auto-prune-bash.sh +4 -0
  10. package/templates/.claude/hooks/block-dangerous.sh +20 -1
  11. package/templates/.claude/hooks/completion-gate.sh +19 -2
  12. package/templates/.claude/hooks/compress-output.sh +17 -2
  13. package/templates/.claude/hooks/context-hardcap-gate.sh +22 -3
  14. package/templates/.claude/hooks/context-window-guard.sh +84 -61
  15. package/templates/.claude/hooks/handoff-model-guard.sh +24 -3
  16. package/templates/.claude/hooks/handoff-resume.sh +21 -3
  17. package/templates/.claude/hooks/post-edit-verify.sh +19 -2
  18. package/templates/.claude/hooks/pre-edit-backup.sh +19 -2
  19. package/templates/.claude/hooks/protect-files.sh +20 -1
  20. package/templates/.claude/hooks/record-execution.sh +20 -2
  21. package/templates/.claude/hooks/reinject-context.sh +1 -1
  22. package/templates/.claude/hooks/reset-compact-pressure.sh +4 -0
  23. package/templates/.claude/hooks/sensitive-data-guard.sh +64 -17
  24. package/templates/.claude/hooks/skill-router.sh +44 -5
  25. package/templates/.claude/hooks/stale-spec-guard.sh +21 -2
  26. package/templates/.claude/hooks/task-watchdog.sh +25 -7
  27. package/templates/.claude/hooks/verification-guard.sh +54 -19
  28. package/templates/.claude/hooks/vision-router.sh +96 -12
  29. package/templates/.claude/ukit/index/lib/index-core.mjs +78 -16
  30. package/templates/.claude/ukit/index/post-edit-verify.mjs +8 -0
  31. package/templates/.claude/ukit/index/pre-edit-backup.mjs +8 -0
  32. package/templates/.claude/ukit/index/refresh-index.mjs +10 -5
  33. package/templates/.claude/ukit/index/stale-spec-check.mjs +8 -0
  34. package/templates/.claude/ukit/runtime/execution-ledger.mjs +8 -0
  35. package/templates/.claude/ukit/runtime/hook-input.mjs +120 -0
  36. package/templates/.claude/ukit/runtime/hook-input.sh +60 -0
  37. package/templates/.claude/ukit/runtime/output-compression.mjs +8 -0
  38. package/templates/.claude/ukit/runtime/reinject-context.mjs +8 -0
  39. package/templates/.claude/ukit/runtime/transcript-tail.mjs +107 -0
@@ -2,16 +2,37 @@
2
2
  # Hook: route prompt/tool signals to the smallest useful installed skill set.
3
3
  # Used from UserPromptSubmit and PreToolUse so end users do not need to name skills.
4
4
 
5
- INPUT=$(cat)
5
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
6
+ # shellcheck source=/dev/null
7
+ if source "$SCRIPT_DIR/../ukit/runtime/hook-input.sh" 2>/dev/null; then
8
+ trap ukit_cleanup_hook_input EXIT
9
+ # Bounded stdin (H01): stage before anything reads it; the shell var is capped,
10
+ # env passes only the staged-file path — never the payload.
11
+ ukit_stage_hook_input 2097152 truncate || exit 0
12
+ else
13
+ # Runtime helper missing (pre-install tree): bounded inline staging keeps the
14
+ # wrapper's own fail-loud/advisory behavior below alive.
15
+ UKIT_INPUT_FILE="$(mktemp "${TMPDIR:-/tmp}/ukit-hook-in.XXXXXX")"
16
+ head -c 2097153 > "$UKIT_INPUT_FILE" 2>/dev/null
17
+ cat >/dev/null 2>&1
18
+ if [ "$(wc -c < "$UKIT_INPUT_FILE" | tr -d '[:space:]')" -gt 2097152 ]; then
19
+ truncate -s 2097152 "$UKIT_INPUT_FILE" 2>/dev/null
20
+ fi
21
+ trap '[ -n "$UKIT_INPUT_FILE" ] && rm -f "$UKIT_INPUT_FILE"' EXIT
22
+ fi
23
+ INPUT="$(cat "$UKIT_INPUT_FILE")"
6
24
  PROJECT_ROOT="${CLAUDE_PROJECT_DIR:-$PWD}"
7
25
 
8
26
  # Internal orchestration envelopes are not user requests. Ignore them before routing or
9
27
  # compact-pressure bookkeeping so task results cannot replace the active completion contract.
10
28
  case "$INPUT" in
11
29
  *"<task-notification"*|*"<tool-result"*|*"<system-reminder"*|*"<local-command-caveat"*)
12
- INTERNAL_ORCHESTRATION=$(INPUT="$INPUT" node -e '
30
+ INTERNAL_ORCHESTRATION=$(INPUT_FILE="$UKIT_INPUT_FILE" node -e '
31
+ // wall-clock watchdog: never let a wedged parse outlive the hook (same arm as the router heredoc below)
32
+ const HOOK_DEADLINE_MS = Number.parseInt(process.env.UKIT_HOOK_DEADLINE_MS || "", 10) || 3000;
33
+ setTimeout(() => process.exit(0), HOOK_DEADLINE_MS).unref();
13
34
  try {
14
- const payload = JSON.parse(process.env.INPUT || "{}");
35
+ const payload = JSON.parse(require("fs").readFileSync(process.env.INPUT_FILE || "", "utf8") || "{}");
15
36
  const candidates = [payload?.prompt, payload?.user_prompt, payload?.text, payload?.message, payload?.input]
16
37
  .filter((value) => typeof value === "string")
17
38
  .map((value) => value.trim());
@@ -44,6 +65,9 @@ esac
44
65
  case "$INPUT" in
45
66
  *".worktrees/"*)
46
67
  if printf '%s' "$INPUT" | node -e '
68
+ // wall-clock watchdog: never let a wedged stdin read outlive the hook (same arm as the router heredoc below)
69
+ const HOOK_DEADLINE_MS = Number.parseInt(process.env.UKIT_HOOK_DEADLINE_MS || "", 10) || 3000;
70
+ setTimeout(() => process.exit(0), HOOK_DEADLINE_MS).unref();
47
71
  const chunks = [];
48
72
  process.stdin.on("data", (c) => chunks.push(c));
49
73
  process.stdin.on("end", () => {
@@ -79,6 +103,9 @@ THRESHOLD_SCRIPT="$HOOK_DIR/../ukit/runtime/compact-threshold.mjs"
79
103
  case "$INPUT" in
80
104
  *'"agent_id"'*|*'"agent_type"'*)
81
105
  if printf '%s' "$INPUT" | node -e '
106
+ // wall-clock watchdog: never let a wedged stdin read outlive the hook (same arm as the router heredoc below)
107
+ const HOOK_DEADLINE_MS = Number.parseInt(process.env.UKIT_HOOK_DEADLINE_MS || "", 10) || 3000;
108
+ setTimeout(() => process.exit(0), HOOK_DEADLINE_MS).unref();
82
109
  const chunks = [];
83
110
  process.stdin.on("data", (c) => chunks.push(c));
84
111
  process.stdin.on("end", () => {
@@ -96,12 +123,23 @@ process.stdin.on("end", () => {
96
123
  ;;
97
124
  esac
98
125
 
99
- INPUT="$INPUT" PROJECT_ROOT="$PROJECT_ROOT" STATE_FILE="$STATE_FILE" HOOK_DIR="$HOOK_DIR" node <<'NODE'
126
+ INPUT_FILE="$UKIT_INPUT_FILE" PROJECT_ROOT="$PROJECT_ROOT" STATE_FILE="$STATE_FILE" HOOK_DIR="$HOOK_DIR" node <<'NODE'
100
127
  const fs = require('fs');
101
128
  const path = require('path');
102
129
  const crypto = require('crypto');
103
130
  const { pathToFileURL } = require('url');
104
131
 
132
+ // Wall-clock watchdog. This hook runs on the UserPromptSubmit/PreToolUse hot path but has
133
+ // no host-side process-group kill: when indexed-context resolution hangs (a wedged import,
134
+ // a stalled mount), the host kills only THIS bash wrapper, the node grandchild below is
135
+ // reparented to launchd, and it spins forever. Verified: exactly one orphan leaked per run
136
+ // of the router timeout test, 200+ accumulated over days. Self-exit at the deadline so a
137
+ // hang can never outlive the hook. Advisory only — an early exit just drops the routing
138
+ // hint, the same posture as a cache miss. Same watchdog task-watchdog.sh/handoff-resume.sh
139
+ // already carry; keep this one aligned with them.
140
+ const HOOK_DEADLINE_MS = Number.parseInt(process.env.UKIT_HOOK_DEADLINE_MS || '', 10) || 3000;
141
+ setTimeout(() => process.exit(0), HOOK_DEADLINE_MS).unref();
142
+
105
143
  (async () => {
106
144
  const STOPWORDS = new Set([
107
145
  'the', 'a', 'an', 'and', 'or', 'to', 'for', 'of', 'with', 'in', 'on', 'is', 'are',
@@ -2555,7 +2593,8 @@ const { pathToFileURL } = require('url');
2555
2593
  return compactState;
2556
2594
  }
2557
2595
 
2558
- const rawInput = process.env.INPUT || '';
2596
+ let rawInput = '';
2597
+ try { rawInput = fs.readFileSync(process.env.INPUT_FILE || '', 'utf8'); } catch {}
2559
2598
  const projectRoot = process.env.PROJECT_ROOT || process.cwd();
2560
2599
  const statePath = process.env.STATE_FILE || path.join(projectRoot, '.claude', 'ukit', 'skill-router-state.json');
2561
2600
  const routeCachePath = path.join(projectRoot, '.claude', 'ukit', 'route-cache.json');
@@ -2,7 +2,24 @@
2
2
  # PreToolUse hook: prevent stale/ambiguous risky Edit specs and whole-file risky Write.
3
3
  # Matched on: Edit|Write
4
4
 
5
- INPUT=$(cat)
5
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
6
+ # shellcheck source=/dev/null
7
+ if source "$SCRIPT_DIR/../ukit/runtime/hook-input.sh" 2>/dev/null; then
8
+ trap ukit_cleanup_hook_input EXIT
9
+ # Bounded stdin (H01): stage before anything reads it; the shell var is capped.
10
+ ukit_stage_hook_input 2097152 truncate || exit 0
11
+ else
12
+ # Runtime helper missing (pre-install tree): bounded inline staging keeps the
13
+ # wrapper's own fail-loud/advisory behavior below alive.
14
+ 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
18
+ truncate -s 2097152 "$UKIT_INPUT_FILE" 2>/dev/null
19
+ fi
20
+ trap '[ -n "$UKIT_INPUT_FILE" ] && rm -f "$UKIT_INPUT_FILE"' EXIT
21
+ fi
22
+ INPUT="$(cat "$UKIT_INPUT_FILE")"
6
23
  PROJECT_ROOT="${CLAUDE_PROJECT_DIR:-$(pwd)}"
7
24
  SCRIPT="$PROJECT_ROOT/.claude/ukit/index/stale-spec-check.mjs"
8
25
 
@@ -21,6 +38,8 @@ fi
21
38
  case "$INPUT" in
22
39
  *".worktrees/"*)
23
40
  if printf '%s' "$INPUT" | node -e '
41
+ const HOOK_DEADLINE_MS = Number.parseInt(process.env.UKIT_HOOK_DEADLINE_MS || "", 10) || 3000;
42
+ setTimeout(() => process.exit(0), HOOK_DEADLINE_MS).unref();
24
43
  const chunks = [];
25
44
  process.stdin.on("data", (c) => chunks.push(c));
26
45
  process.stdin.on("end", () => {
@@ -42,4 +61,4 @@ process.stdin.on("end", () => {
42
61
  ;;
43
62
  esac
44
63
 
45
- printf '%s' "$INPUT" | node "$SCRIPT"
64
+ printf '%s' "$INPUT" | UKIT_HOOK_DEADLINE_MS="${UKIT_HOOK_DEADLINE_MS:-3000}" node "$SCRIPT"
@@ -29,14 +29,30 @@
29
29
  # PostToolUse → advisory only (systemMessage); NEVER emits decision.
30
30
  # anything else → exit 0 silently.
31
31
 
32
- INPUT="$(cat)"
33
- # Cap the payload before it reaches node's env: oversized stdin would exceed the
34
- # exec environment limit and node would never start. A truncated payload simply
35
- # fails JSON.parse inside node → {} → silent degrade exit 0 (documented posture).
36
- INPUT="${INPUT:0:65536}"
32
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
33
+ # shellcheck source=/dev/null
34
+ if source "$SCRIPT_DIR/../ukit/runtime/hook-input.sh" 2>/dev/null; then
35
+ trap ukit_cleanup_hook_input EXIT
36
+ # Bounded stdin (H01): the 64 KiB cap is preserved, but the payload now travels
37
+ # as a staged file path — never through the exec environment, so an oversized
38
+ # input can no longer stop node from starting (E2BIG) or stall the wrapper in
39
+ # `cat`. A truncated payload simply fails JSON.parse inside node → {} → silent
40
+ # degrade exit 0 (documented posture).
41
+ ukit_stage_hook_input 65536 truncate || exit 0
42
+ else
43
+ # Runtime helper missing (pre-install tree): bounded inline staging keeps the
44
+ # 64 KiB cap and the advisory degrade path alive.
45
+ UKIT_INPUT_FILE="$(mktemp "${TMPDIR:-/tmp}/ukit-hook-in.XXXXXX")"
46
+ head -c 65537 > "$UKIT_INPUT_FILE" 2>/dev/null
47
+ cat >/dev/null 2>&1
48
+ if [ "$(wc -c < "$UKIT_INPUT_FILE" | tr -d '[:space:]')" -gt 65536 ]; then
49
+ truncate -s 65536 "$UKIT_INPUT_FILE" 2>/dev/null
50
+ fi
51
+ trap '[ -n "$UKIT_INPUT_FILE" ] && rm -f "$UKIT_INPUT_FILE"' EXIT
52
+ fi
37
53
  PROJECT_ROOT="${CLAUDE_PROJECT_DIR:-$PWD}"
38
54
 
39
- INPUT="$INPUT" PROJECT_ROOT="$PROJECT_ROOT" node <<'NODE' || true
55
+ INPUT_FILE="$UKIT_INPUT_FILE" PROJECT_ROOT="$PROJECT_ROOT" node <<'NODE' || true
40
56
  'use strict';
41
57
 
42
58
  const fsp = require('fs/promises');
@@ -50,10 +66,12 @@ const { pathToFileURL } = require('url');
50
66
  const HOOK_DEADLINE_MS = Number.parseInt(process.env.UKIT_HOOK_DEADLINE_MS || '', 10) || 3000;
51
67
  setTimeout(() => process.exit(0), HOOK_DEADLINE_MS).unref();
52
68
 
69
+ let rawInput = '';
70
+ try { rawInput = fs.readFileSync(process.env.INPUT_FILE || '', 'utf8'); } catch {}
53
71
  (async () => {
54
72
  const payload = (() => {
55
73
  try {
56
- const parsed = JSON.parse(process.env.INPUT || '');
74
+ const parsed = JSON.parse(rawInput);
57
75
  return parsed && typeof parsed === 'object' ? parsed : {};
58
76
  } catch {
59
77
  return {};
@@ -2,13 +2,31 @@
2
2
  # PreToolUse hook: enforce routed verification execution policy so AI stays index-first
3
3
  # and does not jump to blanket verification when targeted evidence already exists.
4
4
 
5
- INPUT=$(cat)
5
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
6
+ # shellcheck source=/dev/null
7
+ if source "$SCRIPT_DIR/../ukit/runtime/hook-input.sh" 2>/dev/null; then
8
+ trap ukit_cleanup_hook_input EXIT
9
+ # Bounded stdin (H01): stage before anything reads it; Node gets a path, never the payload.
10
+ ukit_stage_hook_input 2097152 truncate || exit 0
11
+ 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.
14
+ 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
18
+ truncate -s 2097152 "$UKIT_INPUT_FILE" 2>/dev/null
19
+ fi
20
+ trap '[ -n "$UKIT_INPUT_FILE" ] && rm -f "$UKIT_INPUT_FILE"' EXIT
21
+ fi
6
22
  PROJECT_ROOT="${CLAUDE_PROJECT_DIR:-$PWD}"
7
23
  STATE_FILE="$PROJECT_ROOT/.claude/ukit/skill-router-state.json"
8
24
  ROUTE_CACHE_FILE="$PROJECT_ROOT/.claude/ukit/route-cache.json"
9
25
  PROGRESS_FILE="$PROJECT_ROOT/.claude/ukit/verification-progress.json"
10
26
 
11
- INPUT="$INPUT" STATE_FILE="$STATE_FILE" ROUTE_CACHE_FILE="$ROUTE_CACHE_FILE" PROGRESS_FILE="$PROGRESS_FILE" node <<'NODE'
27
+ INPUT_FILE="$UKIT_INPUT_FILE" STATE_FILE="$STATE_FILE" ROUTE_CACHE_FILE="$ROUTE_CACHE_FILE" PROGRESS_FILE="$PROGRESS_FILE" node <<'NODE'
28
+ const HOOK_DEADLINE_MS = Number.parseInt(process.env.UKIT_HOOK_DEADLINE_MS || '', 10) || 3000;
29
+ setTimeout(() => process.exit(0), HOOK_DEADLINE_MS).unref();
12
30
  const crypto = require('crypto');
13
31
  const fs = require('fs');
14
32
  const path = require('path');
@@ -35,11 +53,12 @@ function writeJsonAtomic(filePath, value) {
35
53
  }
36
54
  }
37
55
 
38
- // Sync sleep without burning CPU — the hook's flow calls process.exit right after
39
- // persistAttempt, so the critical section must complete synchronously.
40
- function sleepSync(ms) {
41
- const buffer = new SharedArrayBuffer(4);
42
- Atomics.wait(new Int32Array(buffer), 0, 0, ms);
56
+ // TASK-015: lock waits poll asynchronously. The old blocking shared-memory
57
+ // sleep froze the event loop, so the unref'd deadline timer above could never
58
+ // 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));
43
62
  }
44
63
 
45
64
  function isPidAlive(pid) {
@@ -59,10 +78,12 @@ function readLockOwner(lockPath) {
59
78
  : null;
60
79
  }
61
80
 
62
- // mkdir-based lock, protocol-compatible with runtime token-utils withFileLock. Sync on
63
- // purpose (see sleepSync). Fail-open after 5s: this hook must never hang a PreToolUse
64
- // chain, and a lost progress entry only costs one repeated advisory.
65
- function withLockSync(lockPath, fn) {
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) {
66
87
  const staleMs = 10000;
67
88
  const maxWaitMs = 5000;
68
89
  const startedAt = Date.now();
@@ -100,11 +121,11 @@ function withLockSync(lockPath, fn) {
100
121
  }
101
122
  } catch {}
102
123
  if (Date.now() - startedAt > maxWaitMs) break;
103
- sleepSync(3 + Math.floor(Math.random() * 9));
124
+ await sleep(3 + Math.floor(Math.random() * 9));
104
125
  }
105
126
  }
106
127
  try {
107
- return fn();
128
+ return await fn();
108
129
  } finally {
109
130
  if (held) {
110
131
  const owner = readLockOwner(lockPath);
@@ -334,9 +355,15 @@ function advise(message) {
334
355
  process.exit(0);
335
356
  }
336
357
 
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.
361
+ (async () => {
362
+ let rawInput = '';
363
+ try { rawInput = fs.readFileSync(process.env.INPUT_FILE || '', 'utf8'); } catch {}
337
364
  const payload = (() => {
338
365
  try {
339
- return JSON.parse(process.env.INPUT || '{}');
366
+ return JSON.parse(rawInput || '{}');
340
367
  } catch {
341
368
  return {};
342
369
  }
@@ -402,14 +429,14 @@ const attemptedCommands = collectAttemptedCommands(progress, {
402
429
  maxAgeMs: STATE_FRESH_MS,
403
430
  });
404
431
 
405
- function persistAttempt(commandText) {
432
+ async function persistAttempt(commandText) {
406
433
  const normalizedCommandText = normalizeCommand(commandText);
407
434
  attemptedCommands.add(normalizedCommandText);
408
435
  const now = Date.now();
409
436
  // Locked re-read + merge: parallel subagents record attempts concurrently, and a
410
437
  // stale-snapshot rewrite would silently drop their entries — the guard would then keep
411
438
  // advising instead of allowing, re-litigating verification order forever.
412
- withLockSync(`${progressPath}.lock`, () => {
439
+ await withLock(`${progressPath}.lock`, async () => {
413
440
  const current = readJson(progressPath, {});
414
441
  const mergedAttempts = new Set(collectAttemptedCommands(current, {
415
442
  currentFingerprint: state?.fingerprint || null,
@@ -463,7 +490,7 @@ const preferredText = formatCompactCommandList(preferredOrder, { separator: ' ->
463
490
  const missingPrimaryText = formatCompactCommandList(missingPrimary);
464
491
 
465
492
  if (isTargetedVerification) {
466
- persistAttempt(command);
493
+ await persistAttempt(command);
467
494
  process.exit(0);
468
495
  }
469
496
 
@@ -498,15 +525,23 @@ if (policyMode === 'docs-light' && !explicitBroadRequested && isBroadTestCommand
498
525
  }
499
526
 
500
527
  if (isPrimaryCommand) {
501
- persistAttempt(command);
528
+ await persistAttempt(command);
502
529
  process.exit(0);
503
530
  }
504
531
 
505
532
  if (isFallbackCommand || isVerificationCommand(command)) {
506
- persistAttempt(command);
533
+ await persistAttempt(command);
507
534
  }
508
535
 
509
536
  process.exit(0);
537
+ })().catch((err) => {
538
+ // Advisory hook: an unexpected error (including a rejected lock) must never
539
+ // turn into a non-zero exit — degrade to a bounded diagnostic and exit 0.
540
+ try {
541
+ fs.writeSync(2, `verification-guard: unexpected error (${err?.message ?? err})\n`);
542
+ } catch {}
543
+ process.exit(0);
544
+ });
510
545
  NODE
511
546
 
512
547
  exit $?
@@ -19,19 +19,52 @@
19
19
  # every unrelated prompt is the false-positive loop this version exists to kill.
20
20
  # A named path that does not resolve from the project root gets a short note only.
21
21
 
22
- INPUT=$(cat)
22
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
23
+ # shellcheck source=/dev/null
24
+ if source "$SCRIPT_DIR/../ukit/runtime/hook-input.sh" 2>/dev/null; then
25
+ trap ukit_cleanup_hook_input EXIT
26
+ # Bounded stdin (H01): stage before anything reads it; Node gets a path, never the payload.
27
+ ukit_stage_hook_input 2097152 truncate || exit 0
28
+ 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.
31
+ 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
35
+ truncate -s 2097152 "$UKIT_INPUT_FILE" 2>/dev/null
36
+ fi
37
+ trap '[ -n "$UKIT_INPUT_FILE" ] && rm -f "$UKIT_INPUT_FILE"' EXIT
38
+ fi
23
39
  PROJECT_ROOT="${CLAUDE_PROJECT_DIR:-$PWD}"
24
40
 
25
- INPUT="$INPUT" PROJECT_ROOT="$PROJECT_ROOT" node <<'NODE' || true
41
+ INPUT_FILE="$UKIT_INPUT_FILE" PROJECT_ROOT="$PROJECT_ROOT" node <<'NODE' || true
42
+ const HOOK_DEADLINE_MS = Number.parseInt(process.env.UKIT_HOOK_DEADLINE_MS || '', 10) || 8000;
43
+ setTimeout(() => process.exit(0), HOOK_DEADLINE_MS).unref();
26
44
  const fs = require('fs');
27
45
  const path = require('path');
28
- const { execFileSync } = require('child_process');
46
+ const { spawn } = require('child_process');
29
47
  const { pathToFileURL } = require('url');
30
48
 
49
+ // TASK-015: a self-deadline can only preempt work that yields to the event
50
+ // loop. The extractor is spawned asynchronously with an abort timer armed
51
+ // strictly BEFORE HOOK_DEADLINE_MS (so the deadline always wins), killed with
52
+ // SIGKILL (no child can ignore it), and every live child is reaped on process
53
+ // exit as a backstop. The old synchronous spawn blocked the loop and made the
54
+ // unref'd watchdog above un-fireable — banned on this hot path.
55
+ const liveChildren = new Set();
56
+ process.on('exit', () => {
57
+ for (const child of liveChildren) {
58
+ try { child.kill('SIGKILL'); } catch {}
59
+ }
60
+ });
61
+
62
+ let rawInput = '';
63
+ try { rawInput = fs.readFileSync(process.env.INPUT_FILE || '', 'utf8'); } catch {}
31
64
  (async () => {
32
65
  const payload = (() => {
33
66
  try {
34
- const parsed = JSON.parse(process.env.INPUT || '');
67
+ const parsed = JSON.parse(rawInput);
35
68
  return parsed && typeof parsed === 'object' ? parsed : {};
36
69
  } catch {
37
70
  return {};
@@ -70,8 +103,9 @@ const { pathToFileURL } = require('url');
70
103
  // whose receipt could never be accepted. Only a POSITIVE false short-circuits; null
71
104
  // (detection failed) still routes, matching the gate's fail-safe direction.
72
105
  //
73
- // Cheaper than the old order, too: this replaces an execFileSync node spawn per prompt
74
- // with one local import on the overwhelmingly common off-gateway path.
106
+ // Cheaper than the old order, too: this replaces a synchronous node spawn
107
+ // per prompt with one local import on the overwhelmingly common off-gateway
108
+ // path.
75
109
  if (unicMode === false) {
76
110
  process.exit(0);
77
111
  }
@@ -142,6 +176,56 @@ const { pathToFileURL } = require('url');
142
176
  return noSoft !== value ? noSoft : value;
143
177
  }
144
178
 
179
+ // TASK-015: async spawn, one settle path per child. The abort timer fires
180
+ // strictly before the hook deadline (min floor keeps it meaningful if the
181
+ // deadline is configured tiny) and is unref'd so it never holds the loop
182
+ // open by itself. Stdout capture is bounded; stream/child errors are absorbed
183
+ // so an abort racing a child exit settles exactly once with no unhandled
184
+ // rejection. Any failure degrades to a bounded diagnostic + advisory flow.
185
+ function runExtractorAsync(args) {
186
+ return new Promise((resolve, reject) => {
187
+ let child;
188
+ try {
189
+ child = spawn('node', args, { cwd: projectRoot, stdio: ['ignore', 'pipe', 'pipe'] });
190
+ } catch (err) {
191
+ reject(err);
192
+ return;
193
+ }
194
+ liveChildren.add(child);
195
+ let stdout = '';
196
+ let settled = false;
197
+ let abortTimer = null;
198
+ const settle = (err, text) => {
199
+ if (settled) return;
200
+ settled = true;
201
+ if (abortTimer) clearTimeout(abortTimer);
202
+ liveChildren.delete(child);
203
+ if (err) reject(err);
204
+ else resolve(text);
205
+ };
206
+ abortTimer = setTimeout(() => {
207
+ try { child.kill('SIGKILL'); } catch {}
208
+ settle(new Error(`extractor killed at ${HOOK_DEADLINE_MS}ms hook deadline`));
209
+ }, Math.max(250, HOOK_DEADLINE_MS - 500));
210
+ abortTimer.unref();
211
+ child.stdout.on('error', () => {});
212
+ child.stderr.on('error', () => {});
213
+ child.stdout.on('data', (chunk) => {
214
+ if (stdout.length < 1000000) stdout += chunk; // bounded capture
215
+ });
216
+ child.on('error', (err) => settle(err));
217
+ child.on('close', (code) => {
218
+ if (code === 0) {
219
+ settle(null, stdout);
220
+ } else {
221
+ settle(new Error(code === null
222
+ ? 'extractor was killed before exiting'
223
+ : `extractor exited with code ${code}`));
224
+ }
225
+ });
226
+ });
227
+ }
228
+
145
229
  const urlMatches = promptText.match(URL_IMAGE_RE) || [];
146
230
  const textWithoutUrls = promptText.replace(URL_IMAGE_RE, ' ');
147
231
  const localMatches = textWithoutUrls.match(LOCAL_PATH_RE) || [];
@@ -166,15 +250,15 @@ const { pathToFileURL } = require('url');
166
250
  // handing off so the hook and the extractor agree on the ref string.
167
251
  args.push('--ref', stripPathNoise(ref));
168
252
  }
253
+ let out = null;
169
254
  try {
170
- const out = execFileSync('node', args, {
171
- cwd: projectRoot,
172
- encoding: 'utf8',
173
- timeout: 6000,
174
- });
255
+ out = await runExtractorAsync(args);
175
256
  extractorJson = JSON.parse(out);
176
257
  } catch (err) {
177
- process.stderr.write(`vision-router: extractor call skipped (${err?.message ?? err})\n`);
258
+ // Bounded diagnostic: the head of whatever the extractor emitted (if
259
+ // anything) plus the failure reason. Never the whole payload.
260
+ const head = typeof out === 'string' && out ? `; output head: ${out.slice(0, 200)}` : '';
261
+ process.stderr.write(`vision-router: extractor call skipped (${err?.message ?? err})${head}\n`);
178
262
  }
179
263
  }
180
264
 
@@ -118,7 +118,11 @@ const VIETNAMESE_TOKEN_ALIASES = new Map([
118
118
 
119
119
  // ── Build Index ──
120
120
 
121
- export async function buildCodeIndex({ rootDir = process.cwd() } = {}) {
121
+ // Bumped only when the snapshot shape itself changes; consumers must reject
122
+ // snapshots from a different version instead of trusting unknown fields.
123
+ export const DISCOVERY_SNAPSHOT_VERSION = 1;
124
+
125
+ export async function buildCodeIndex({ rootDir = process.cwd(), discoverySnapshot = null } = {}) {
122
126
  const absoluteRoot = path.resolve(rootDir);
123
127
  const indexDir = getIndexDir(absoluteRoot);
124
128
 
@@ -132,7 +136,13 @@ export async function buildCodeIndex({ rootDir = process.cwd() } = {}) {
132
136
  previousCodeFileRecords.map((item) => [item.filePath, { mtimeMs: Number(item.mtimeMs ?? -1), size: Number(item.size ?? -1) }]),
133
137
  );
134
138
 
135
- const discoveredFiles = await discoverProjectFiles(absoluteRoot);
139
+ // Reuse the caller's discovery snapshot when it describes exactly this root
140
+ // under the current schema and snapshot version; anything else (foreign root,
141
+ // stale schema, wrong shape) is rejected and the repository is rediscovered
142
+ // once here. Keeps a stale-check + rebuild refresh at a single enumeration.
143
+ const discoveredFiles = isDiscoverySnapshotUsable(discoverySnapshot, absoluteRoot)
144
+ ? discoverySnapshot.files
145
+ : await discoverProjectFiles(absoluteRoot);
136
146
  const sourceFingerprint = createSourceFingerprint(absoluteRoot, discoveredFiles);
137
147
  const styleFilePaths = discoveredFiles
138
148
  .map((entry) => {
@@ -2940,6 +2950,27 @@ async function discoverProjectFiles(rootDir) {
2940
2950
  return collectFiles([rootDir]);
2941
2951
  }
2942
2952
 
2953
+ function createDiscoverySnapshot(rootDir, files, fingerprint) {
2954
+ // Frozen: the snapshot must stay byte-stable between the staleness inspection
2955
+ // and the build that consumes it, and callers must not mutate shared state.
2956
+ return Object.freeze({
2957
+ snapshotVersion: DISCOVERY_SNAPSHOT_VERSION,
2958
+ schemaVersion: INDEX_SCHEMA_VERSION,
2959
+ rootDir,
2960
+ fingerprint,
2961
+ files: Object.freeze(files.slice()),
2962
+ });
2963
+ }
2964
+
2965
+ function isDiscoverySnapshotUsable(discoverySnapshot, absoluteRoot) {
2966
+ return Boolean(discoverySnapshot)
2967
+ && discoverySnapshot.snapshotVersion === DISCOVERY_SNAPSHOT_VERSION
2968
+ && discoverySnapshot.schemaVersion === INDEX_SCHEMA_VERSION
2969
+ && typeof discoverySnapshot.rootDir === 'string'
2970
+ && path.resolve(discoverySnapshot.rootDir) === absoluteRoot
2971
+ && Array.isArray(discoverySnapshot.files);
2972
+ }
2973
+
2943
2974
  function runGit(rootDir, args) {
2944
2975
  const result = spawnSync('git', args, {
2945
2976
  cwd: rootDir,
@@ -3406,24 +3437,55 @@ export async function getIndexArtifactGeneratedAt({
3406
3437
  return parseArtifactGeneratedAt(artifact);
3407
3438
  }
3408
3439
 
3440
+ async function evaluateIndexStaleness({ rootDir, maxAgeMs, now, generatedAtMs, enumerateForSnapshot }) {
3441
+ const absoluteRoot = path.resolve(rootDir);
3442
+ const metaArtifact = await readArtifactIfExists(absoluteRoot, INDEX_ARTIFACTS.meta);
3443
+ const effectiveGeneratedAtMs = Number.isFinite(generatedAtMs)
3444
+ ? generatedAtMs
3445
+ : parseArtifactGeneratedAt(metaArtifact);
3446
+
3447
+ const staleWithoutEnumeration = effectiveGeneratedAtMs === null
3448
+ || typeof maxAgeMs !== 'number'
3449
+ || Number.isNaN(maxAgeMs)
3450
+ || maxAgeMs < 0
3451
+ || (now - effectiveGeneratedAtMs) >= maxAgeMs
3452
+ || !metaArtifact?.sourceFingerprint;
3453
+
3454
+ // Legacy boolean callers (query-index, triage, verify-context) stop here and
3455
+ // let buildCodeIndex do its own discovery when a rebuild is needed.
3456
+ if (staleWithoutEnumeration && !enumerateForSnapshot) {
3457
+ return { stale: true, snapshot: null };
3458
+ }
3459
+
3460
+ // Exactly one enumeration per inspection. In the refresh flow this snapshot is
3461
+ // handed to buildCodeIndex, so stale-check + rebuild enumerates the repository
3462
+ // once instead of twice.
3463
+ const discoveredFiles = await discoverProjectFiles(absoluteRoot);
3464
+ const fingerprint = createSourceFingerprint(absoluteRoot, discoveredFiles);
3465
+
3466
+ return {
3467
+ stale: staleWithoutEnumeration
3468
+ ? true
3469
+ : !areSourceFingerprintsEqual(metaArtifact.sourceFingerprint, fingerprint),
3470
+ snapshot: createDiscoverySnapshot(absoluteRoot, discoveredFiles, fingerprint),
3471
+ };
3472
+ }
3473
+
3474
+ export async function inspectIndexStaleness({
3475
+ rootDir = process.cwd(),
3476
+ maxAgeMs = DEFAULT_INDEX_CACHE_MAX_AGE_MS,
3477
+ now = Date.now(),
3478
+ generatedAtMs = null,
3479
+ } = {}) {
3480
+ return evaluateIndexStaleness({ rootDir, maxAgeMs, now, generatedAtMs, enumerateForSnapshot: true });
3481
+ }
3482
+
3409
3483
  export async function isIndexStale({
3410
3484
  rootDir = process.cwd(),
3411
3485
  maxAgeMs = DEFAULT_INDEX_CACHE_MAX_AGE_MS,
3412
3486
  now = Date.now(),
3413
3487
  generatedAtMs = null,
3414
3488
  } = {}) {
3415
- const absoluteRoot = path.resolve(rootDir);
3416
- const metaArtifact = await readArtifactIfExists(absoluteRoot, INDEX_ARTIFACTS.meta);
3417
- const effectiveGeneratedAtMs = Number.isFinite(generatedAtMs)
3418
- ? generatedAtMs
3419
- : parseArtifactGeneratedAt(metaArtifact);
3420
- if (effectiveGeneratedAtMs === null) return true;
3421
- if (typeof maxAgeMs !== 'number' || Number.isNaN(maxAgeMs) || maxAgeMs < 0) return true;
3422
- if ((now - effectiveGeneratedAtMs) >= maxAgeMs) return true;
3423
- if (!metaArtifact?.sourceFingerprint) return true;
3424
- const currentSourceFingerprint = createSourceFingerprint(
3425
- absoluteRoot,
3426
- await discoverProjectFiles(absoluteRoot),
3427
- );
3428
- return !areSourceFingerprintsEqual(metaArtifact.sourceFingerprint, currentSourceFingerprint);
3489
+ const { stale } = await evaluateIndexStaleness({ rootDir, maxAgeMs, now, generatedAtMs, enumerateForSnapshot: false });
3490
+ return stale;
3429
3491
  }
@@ -4,6 +4,14 @@ import { fileURLToPath } from 'node:url';
4
4
  import { analyzeTextBuffer, analyzeTextFile, publicTextProfile } from '../runtime/text-profile.mjs';
5
5
  import { classifySafePatchRisk, isSafePatchAdvisoryOnly, parseJsonInput, pathExists, readRuntimeSafePatchConfig, resolveProjectFile } from '../runtime/safe-patch-core.mjs';
6
6
 
7
+ // Hook-context self-deadline (2.4.1 orphan-leak class): the post-edit-verify hook passes
8
+ // UKIT_HOOK_DEADLINE_MS so a wedged read can never orphan this process past the hook
9
+ // budget. Non-hook usage never sets it and is never self-killed.
10
+ const HOOK_DEADLINE_MS = Number.parseInt(process.env.UKIT_HOOK_DEADLINE_MS || '', 10);
11
+ if (Number.isFinite(HOOK_DEADLINE_MS) && HOOK_DEADLINE_MS > 0) {
12
+ setTimeout(() => process.exit(0), HOOK_DEADLINE_MS).unref();
13
+ }
14
+
7
15
  async function readStdin() {
8
16
  return await new Promise((resolve) => {
9
17
  let raw = '';
@@ -4,6 +4,14 @@ import { analyzeTextFile, publicTextProfile } from '../runtime/text-profile.mjs'
4
4
  import { classifySafePatchRisk, parseJsonInput, pathExists, readRuntimeSafePatchConfig, resolveProjectFile } from '../runtime/safe-patch-core.mjs';
5
5
  import { fileURLToPath } from 'node:url';
6
6
 
7
+ // Hook-context self-deadline (2.4.1 orphan-leak class): the pre-edit-backup hook passes
8
+ // UKIT_HOOK_DEADLINE_MS so a wedged read can never orphan this process past the hook
9
+ // budget. Non-hook usage never sets it and is never self-killed.
10
+ const HOOK_DEADLINE_MS = Number.parseInt(process.env.UKIT_HOOK_DEADLINE_MS || '', 10);
11
+ if (Number.isFinite(HOOK_DEADLINE_MS) && HOOK_DEADLINE_MS > 0) {
12
+ setTimeout(() => process.exit(0), HOOK_DEADLINE_MS).unref();
13
+ }
14
+
7
15
  async function readStdin() {
8
16
  return await new Promise((resolve) => {
9
17
  let raw = '';