@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.
- package/CHANGELOG.md +79 -0
- package/package.json +1 -1
- package/scripts/index/refresh-index.mjs +10 -5
- package/src/cli/commands/doctor.js +59 -2
- package/src/core/gatewayProbe.js +143 -15
- package/src/core/gatewayResilienceEnv.js +136 -7
- package/src/index/buildIndex.js +74 -24
- package/templates/.claude/hooks/auto-allow-bash.sh +24 -1
- package/templates/.claude/hooks/auto-prune-bash.sh +4 -0
- package/templates/.claude/hooks/block-dangerous.sh +20 -1
- package/templates/.claude/hooks/completion-gate.sh +19 -2
- package/templates/.claude/hooks/compress-output.sh +17 -2
- package/templates/.claude/hooks/context-hardcap-gate.sh +22 -3
- package/templates/.claude/hooks/context-window-guard.sh +84 -61
- package/templates/.claude/hooks/handoff-model-guard.sh +24 -3
- package/templates/.claude/hooks/handoff-resume.sh +21 -3
- package/templates/.claude/hooks/post-edit-verify.sh +19 -2
- package/templates/.claude/hooks/pre-edit-backup.sh +19 -2
- package/templates/.claude/hooks/protect-files.sh +20 -1
- package/templates/.claude/hooks/record-execution.sh +20 -2
- package/templates/.claude/hooks/reinject-context.sh +1 -1
- package/templates/.claude/hooks/reset-compact-pressure.sh +4 -0
- package/templates/.claude/hooks/sensitive-data-guard.sh +64 -17
- package/templates/.claude/hooks/skill-router.sh +44 -5
- package/templates/.claude/hooks/stale-spec-guard.sh +21 -2
- package/templates/.claude/hooks/task-watchdog.sh +25 -7
- package/templates/.claude/hooks/verification-guard.sh +54 -19
- package/templates/.claude/hooks/vision-router.sh +96 -12
- package/templates/.claude/ukit/index/lib/index-core.mjs +78 -16
- package/templates/.claude/ukit/index/post-edit-verify.mjs +8 -0
- package/templates/.claude/ukit/index/pre-edit-backup.mjs +8 -0
- package/templates/.claude/ukit/index/refresh-index.mjs +10 -5
- package/templates/.claude/ukit/index/stale-spec-check.mjs +8 -0
- package/templates/.claude/ukit/runtime/execution-ledger.mjs +8 -0
- package/templates/.claude/ukit/runtime/hook-input.mjs +120 -0
- package/templates/.claude/ukit/runtime/hook-input.sh +60 -0
- package/templates/.claude/ukit/runtime/output-compression.mjs +8 -0
- package/templates/.claude/ukit/runtime/reinject-context.mjs +8 -0
- 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
|
-
|
|
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=$(
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
33
|
-
#
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
39
|
-
//
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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.
|
|
63
|
-
//
|
|
64
|
-
//
|
|
65
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
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(
|
|
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
|
|
74
|
-
// with one local import on the overwhelmingly common off-gateway
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
3416
|
-
|
|
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 = '';
|