@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.
- package/CHANGELOG.md +57 -0
- package/README.md +20 -0
- package/manifests/platform.full.yaml +51 -112
- package/package.json +2 -1
- package/scripts/index/refresh-index.mjs +47 -22
- package/src/cli/commands/doctor.js +132 -2
- package/src/cli/commands/uninstall.js +18 -0
- package/src/core/applyPlan.js +17 -2
- package/src/core/compact/threshold.js +36 -6
- package/src/core/diffPlan.js +35 -0
- package/src/core/fileOps.js +26 -0
- package/src/core/projectImportant.js +430 -0
- package/src/core/sensitiveValueScanner.js +118 -0
- package/src/core/status.js +55 -1
- package/src/core/uninstall.js +183 -3
- package/src/diagnostics/classifyHang.js +246 -0
- package/src/index/buildIndex.js +1033 -62
- package/templates/.claude/hooks/auto-allow-bash.sh +82 -93
- package/templates/.claude/hooks/block-dangerous.sh +31 -5
- package/templates/.claude/hooks/completion-gate.sh +51 -10
- package/templates/.claude/hooks/compress-output.sh +38 -6
- package/templates/.claude/hooks/context-hardcap-gate.sh +35 -6
- package/templates/.claude/hooks/context-window-guard.sh +128 -18
- package/templates/.claude/hooks/handoff-model-guard.sh +31 -5
- package/templates/.claude/hooks/handoff-resume.sh +31 -5
- package/templates/.claude/hooks/post-edit-verify.sh +31 -5
- package/templates/.claude/hooks/pre-edit-backup.sh +31 -5
- package/templates/.claude/hooks/project-important.sh +67 -0
- package/templates/.claude/hooks/protect-files.sh +31 -5
- package/templates/.claude/hooks/record-execution.sh +31 -5
- package/templates/.claude/hooks/sensitive-data-guard.sh +124 -56
- package/templates/.claude/hooks/skill-router.sh +31 -5
- package/templates/.claude/hooks/stale-spec-guard.sh +31 -5
- package/templates/.claude/hooks/task-watchdog.sh +108 -123
- package/templates/.claude/hooks/verification-guard.sh +107 -112
- package/templates/.claude/hooks/vision-router.sh +49 -13
- package/templates/.claude/settings.json +5 -5
- package/templates/.claude/ukit/index/lib/index-core.mjs +960 -63
- package/templates/.claude/ukit/index/refresh-index.mjs +47 -22
- package/templates/.claude/ukit/index/route-task.mjs +610 -4
- package/templates/.claude/ukit/runtime/async-lock.mjs +340 -0
- package/templates/.claude/ukit/runtime/compact-threshold.mjs +73 -24
- package/templates/.claude/ukit/runtime/context-capacity.mjs +144 -0
- package/templates/.claude/ukit/runtime/execution-ledger.mjs +664 -170
- package/templates/.claude/ukit/runtime/hook-chain-budget.mjs +92 -0
- package/templates/.claude/ukit/runtime/hook-chain-runner.mjs +84 -29
- package/templates/.claude/ukit/runtime/hook-input.sh +85 -5
- package/templates/.claude/ukit/runtime/hook-payload-store.mjs +160 -0
- package/templates/.claude/ukit/runtime/hook-process.mjs +250 -0
- package/templates/.claude/ukit/runtime/hook-telemetry.mjs +255 -0
- package/templates/.claude/ukit/runtime/hook-telemetry.sh +60 -0
- package/templates/.claude/ukit/runtime/project-important.mjs +381 -0
- package/templates/.claude/ukit/runtime/sensitive-value-scanner.mjs +128 -0
- package/templates/.claude/ukit/runtime/stop-coordinator.mjs +509 -0
- package/templates/.claude/ukit/runtime/task-watchdog.mjs +180 -6
- package/templates/.claude/ukit/runtime/transcript-tail.mjs +1 -1
- package/templates/.omp/hooks/pre/ukit-bridge.js +178 -61
- package/templates/AGENTS.md +8 -0
- 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
|
|
13
|
-
#
|
|
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
|
-
|
|
16
|
-
|
|
17
|
-
|
|
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
|
-
|
|
31
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
47
|
-
fs.
|
|
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.
|
|
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.
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
function
|
|
65
|
-
if (!
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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.
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
359
|
-
//
|
|
360
|
-
//
|
|
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
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
30
|
-
#
|
|
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
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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
|
-
|
|
63
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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\"",
|