@ngockhoale/ukit 2.4.1 → 2.4.3
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 +65 -0
- package/manifests/platform.full.yaml +19 -111
- package/package.json +2 -1
- package/scripts/index/refresh-index.mjs +48 -18
- package/src/cli/commands/doctor.js +59 -2
- package/src/core/compact/threshold.js +36 -6
- package/src/core/gatewayProbe.js +143 -15
- package/src/core/gatewayResilienceEnv.js +136 -7
- package/src/diagnostics/classifyHang.js +246 -0
- package/src/index/buildIndex.js +1096 -75
- package/templates/.claude/hooks/auto-allow-bash.sh +99 -87
- package/templates/.claude/hooks/auto-prune-bash.sh +4 -0
- package/templates/.claude/hooks/block-dangerous.sh +46 -1
- package/templates/.claude/hooks/completion-gate.sh +65 -7
- package/templates/.claude/hooks/compress-output.sh +49 -2
- package/templates/.claude/hooks/context-hardcap-gate.sh +52 -4
- package/templates/.claude/hooks/context-window-guard.sh +204 -71
- package/templates/.claude/hooks/handoff-model-guard.sh +50 -3
- package/templates/.claude/hooks/handoff-resume.sh +47 -3
- package/templates/.claude/hooks/post-edit-verify.sh +45 -2
- package/templates/.claude/hooks/pre-edit-backup.sh +45 -2
- package/templates/.claude/hooks/protect-files.sh +46 -1
- package/templates/.claude/hooks/record-execution.sh +46 -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 +101 -18
- package/templates/.claude/hooks/skill-router.sh +59 -5
- package/templates/.claude/hooks/stale-spec-guard.sh +47 -2
- package/templates/.claude/hooks/task-watchdog.sh +129 -126
- package/templates/.claude/hooks/verification-guard.sh +136 -106
- package/templates/.claude/hooks/vision-router.sh +138 -18
- package/templates/.claude/settings.json +0 -5
- package/templates/.claude/ukit/index/lib/index-core.mjs +1027 -68
- 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 +48 -18
- package/templates/.claude/ukit/index/route-task.mjs +610 -4
- package/templates/.claude/ukit/index/stale-spec-check.mjs +8 -0
- 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 +672 -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.mjs +120 -0
- package/templates/.claude/ukit/runtime/hook-input.sh +140 -0
- 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/output-compression.mjs +8 -0
- package/templates/.claude/ukit/runtime/reinject-context.mjs +8 -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 +107 -0
- package/templates/.omp/hooks/pre/ukit-bridge.js +171 -57
|
@@ -5,42 +5,89 @@
|
|
|
5
5
|
# The Quality Gate lets handoff runs go on for a while, but a wedged task
|
|
6
6
|
# that never reports a blocker still burns the rest of the pipeline. This
|
|
7
7
|
# hook measures wall-clock per in_progress task against approved budgets
|
|
8
|
-
# (S 8/15, M 15/30, L 25/45 minutes by default)
|
|
9
|
-
# - soft overrun: emits a VISIBLE "checkpoint milestone now + split remainder"
|
|
10
|
-
# advisory on Stop (systemMessage), never blocks.
|
|
11
|
-
# - hard overrun (split policy, default): emits Stop decision=block with a
|
|
12
|
-
# visible split instruction naming the follow-up
|
|
13
|
-
# TASK-<id>-b; keeps the cycle running instead of letting
|
|
14
|
-
# the agent stop silently mid-budget.
|
|
15
|
-
# - hard overrun (pause policy, selectable in config): emits a pause
|
|
16
|
-
# advisory on Stop, never blocks.
|
|
8
|
+
# (S 8/15, M 15/30, L 25/45 minutes by default).
|
|
17
9
|
#
|
|
18
|
-
#
|
|
19
|
-
#
|
|
10
|
+
# TASK-025 (PLAN §2 H17): the watchdog no longer owns any Stop decision. A single
|
|
11
|
+
# coordinator (.claude/ukit/runtime/stop-coordinator.mjs, registered once on Stop
|
|
12
|
+
# through completion-gate.sh) evaluates BOTH the completion policy and this
|
|
13
|
+
# watchdog's budget policy and emits at most one continuation decision — two
|
|
14
|
+
# independent blockers used to double-block the same Stop.
|
|
15
|
+
#
|
|
16
|
+
# Dispatch on hook_event_name:
|
|
17
|
+
# Stop → thin delegate: hand the staged payload to the stop coordinator
|
|
18
|
+
# and pass its single decision through. Kept for legacy settings
|
|
19
|
+
# that still register this script on Stop; the coordinator's
|
|
20
|
+
# once-per-stop dedupe keeps a double-registered Stop inert.
|
|
21
|
+
# PostToolUse → advisory only (systemMessage); NEVER emits decision. Re-evaluates
|
|
22
|
+
# so the runtime state stays warm for the next Stop.
|
|
23
|
+
# anything else → exit 0 silently.
|
|
20
24
|
#
|
|
21
25
|
# Everything fails open and stays inside the 4s hook-chain budget:
|
|
22
26
|
# - HOOK_DEADLINE_MS=3000 self-kill via setTimeout(...).unref()
|
|
23
27
|
# - all I/O is async (sync reads on a stalled mount would block the timer)
|
|
24
28
|
# - process.exit(0) on every path; never throws.
|
|
25
|
-
#
|
|
26
|
-
# Dispatch on hook_event_name:
|
|
27
|
-
# Stop → evaluate and may emit decision=block; never emits decision
|
|
28
|
-
# unless the trip crosses hardMin AND hardPolicy='split'.
|
|
29
|
-
# PostToolUse → advisory only (systemMessage); NEVER emits decision.
|
|
30
|
-
# anything else → exit 0 silently.
|
|
31
29
|
|
|
32
|
-
|
|
33
|
-
#
|
|
34
|
-
|
|
35
|
-
#
|
|
36
|
-
|
|
30
|
+
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
31
|
+
# shellcheck source=/dev/null
|
|
32
|
+
if source "$SCRIPT_DIR/../ukit/runtime/hook-input.sh" 2>/dev/null; then
|
|
33
|
+
# TASK-019: arm advisory latency telemetry; the finish row is written by the
|
|
34
|
+
# shared cleanup path before the staged payload is removed. A missing
|
|
35
|
+
# runtime (pre-install tree) simply leaves telemetry off.
|
|
36
|
+
source "$SCRIPT_DIR/../ukit/runtime/hook-telemetry.sh" 2>/dev/null || true
|
|
37
|
+
trap ukit_cleanup_hook_input EXIT
|
|
38
|
+
# Bounded stdin (H01): the 64 KiB cap is preserved, but the payload now travels
|
|
39
|
+
# as a staged file path — never through the exec environment, so an oversized
|
|
40
|
+
# input can no longer stop node from starting (E2BIG) or stall the wrapper in
|
|
41
|
+
# `cat`. A truncated payload simply fails JSON.parse inside node → {} → silent
|
|
42
|
+
# degrade exit 0 (documented posture).
|
|
43
|
+
ukit_stage_hook_input 65536 truncate || exit 0
|
|
44
|
+
else
|
|
45
|
+
# 64 KiB cap and the advisory degrade path alive.
|
|
46
|
+
# Runtime helper missing (pre-install tree): the SAME bounded staging,
|
|
47
|
+
# inline - `cat >/dev/null` used to block forever on a producer that never
|
|
48
|
+
# closes the pipe (R4.5). Read cap+1 in the background, bound the wait.
|
|
49
|
+
UKIT_INPUT_FILE="$(mktemp "${TMPDIR:-/tmp}/ukit-hook-in.XXXXXX")"
|
|
50
|
+
if [ -e /dev/fd/0 ]; then
|
|
51
|
+
exec 8<&0
|
|
52
|
+
head -c 65537 <&8 > "$UKIT_INPUT_FILE" 2>/dev/null &
|
|
53
|
+
else
|
|
54
|
+
head -c 65537 > "$UKIT_INPUT_FILE" 2>/dev/null &
|
|
55
|
+
fi
|
|
56
|
+
__ukit_reader=$!
|
|
57
|
+
__ukit_stage_ms="${UKIT_HOOK_STAGE_MS:-2000}"
|
|
58
|
+
case "$__ukit_stage_ms" in
|
|
59
|
+
''|*[!0-9]*) __ukit_stage_ms=2000 ;;
|
|
60
|
+
esac
|
|
61
|
+
[ "$__ukit_stage_ms" -gt 0 ] 2>/dev/null || __ukit_stage_ms=2000
|
|
62
|
+
printf -v __ukit_stage_s '%d.%03d' $((__ukit_stage_ms / 1000)) $((__ukit_stage_ms % 1000))
|
|
63
|
+
( sleep "$__ukit_stage_s" 2>/dev/null; kill -9 "$__ukit_reader" 2>/dev/null ) <&- >/dev/null 2>&1 &
|
|
64
|
+
__ukit_waiter=$!
|
|
65
|
+
wait "$__ukit_reader" 2>/dev/null
|
|
66
|
+
__ukit_stage_rc=$?
|
|
67
|
+
kill "$__ukit_waiter" 2>/dev/null
|
|
68
|
+
wait "$__ukit_waiter" 2>/dev/null
|
|
69
|
+
exec 8<&-
|
|
70
|
+
__ukit_size="$(wc -c < "$UKIT_INPUT_FILE" 2>/dev/null | tr -d '[:space:]')"
|
|
71
|
+
# A bounded cut (rc != 0) leaves a short payload that the JSON consumer
|
|
72
|
+
# already degrades on - the shared helper's truncate posture (R4.5).
|
|
73
|
+
if [ "$__ukit_size" -gt 65536 ]; then
|
|
74
|
+
truncate -s 65536 "$UKIT_INPUT_FILE" 2>/dev/null
|
|
75
|
+
fi
|
|
76
|
+
trap '[ -n "$UKIT_INPUT_FILE" ] && rm -f "$UKIT_INPUT_FILE"' EXIT
|
|
77
|
+
fi
|
|
37
78
|
PROJECT_ROOT="${CLAUDE_PROJECT_DIR:-$PWD}"
|
|
38
79
|
|
|
39
|
-
|
|
80
|
+
INPUT_FILE="$UKIT_INPUT_FILE" PROJECT_ROOT="$PROJECT_ROOT" node <<'NODE' || true
|
|
40
81
|
'use strict';
|
|
41
82
|
|
|
42
83
|
const fsp = require('fs/promises');
|
|
84
|
+
// TASK-024 fix (regression from TASK-014's H01 bounded-staging refactor): the staged
|
|
85
|
+
// payload is read with fs.readFileSync, but the `fs` require was dropped — every
|
|
86
|
+
// payload read then threw ReferenceError, was swallowed by the try/catch, and the
|
|
87
|
+
// hook silently became a no-op (empty payload → irrelevant event → exit 0).
|
|
88
|
+
const fs = require('fs');
|
|
43
89
|
const path = require('path');
|
|
90
|
+
const { spawn } = require('child_process');
|
|
44
91
|
const { pathToFileURL } = require('url');
|
|
45
92
|
|
|
46
93
|
// Wall-clock watchdog. Anything that stalls here must end in clean exit 0 —
|
|
@@ -50,10 +97,12 @@ const { pathToFileURL } = require('url');
|
|
|
50
97
|
const HOOK_DEADLINE_MS = Number.parseInt(process.env.UKIT_HOOK_DEADLINE_MS || '', 10) || 3000;
|
|
51
98
|
setTimeout(() => process.exit(0), HOOK_DEADLINE_MS).unref();
|
|
52
99
|
|
|
100
|
+
let rawInput = '';
|
|
101
|
+
try { rawInput = fs.readFileSync(process.env.INPUT_FILE || '', 'utf8'); } catch {}
|
|
53
102
|
(async () => {
|
|
54
103
|
const payload = (() => {
|
|
55
104
|
try {
|
|
56
|
-
const parsed = JSON.parse(
|
|
105
|
+
const parsed = JSON.parse(rawInput);
|
|
57
106
|
return parsed && typeof parsed === 'object' ? parsed : {};
|
|
58
107
|
} catch {
|
|
59
108
|
return {};
|
|
@@ -61,7 +110,6 @@ setTimeout(() => process.exit(0), HOOK_DEADLINE_MS).unref();
|
|
|
61
110
|
})();
|
|
62
111
|
|
|
63
112
|
const hookEvent = typeof payload.hook_event_name === 'string' ? payload.hook_event_name : '';
|
|
64
|
-
const toolName = typeof payload.tool_name === 'string' ? payload.tool_name : '';
|
|
65
113
|
const projectRoot = process.env.PROJECT_ROOT || process.cwd();
|
|
66
114
|
|
|
67
115
|
// Silent degrade for non-relevant events — same posture as every other
|
|
@@ -70,6 +118,43 @@ setTimeout(() => process.exit(0), HOOK_DEADLINE_MS).unref();
|
|
|
70
118
|
process.exit(0);
|
|
71
119
|
}
|
|
72
120
|
|
|
121
|
+
// TASK-025: Stop is a thin delegate — the stop coordinator owns every Stop
|
|
122
|
+
// decision (completion policy + this watchdog's budget policy, merged into at
|
|
123
|
+
// most one continuation decision). Fail-open: a missing coordinator (pre-install
|
|
124
|
+
// tree) just means no watchdog Stop evaluation, as before the delegate existed.
|
|
125
|
+
if (hookEvent === 'Stop') {
|
|
126
|
+
const coordinatorPath = path.join(projectRoot, '.claude', 'ukit', 'runtime', 'stop-coordinator.mjs');
|
|
127
|
+
try {
|
|
128
|
+
await fsp.access(coordinatorPath);
|
|
129
|
+
} catch {
|
|
130
|
+
process.exit(0);
|
|
131
|
+
}
|
|
132
|
+
const delegateInput = rawInput;
|
|
133
|
+
const child = spawn(process.execPath, [coordinatorPath, '--evaluate-stop'], {
|
|
134
|
+
cwd: projectRoot,
|
|
135
|
+
env: { ...process.env, CLAUDE_PROJECT_DIR: projectRoot },
|
|
136
|
+
stdio: ['pipe', 'pipe', 'inherit'],
|
|
137
|
+
});
|
|
138
|
+
let delegatedOutput = '';
|
|
139
|
+
const killTimer = setTimeout(() => {
|
|
140
|
+
try { child.kill('SIGKILL'); } catch {}
|
|
141
|
+
}, HOOK_DEADLINE_MS);
|
|
142
|
+
if (typeof killTimer.unref === 'function') killTimer.unref();
|
|
143
|
+
child.stdout.on('data', (chunk) => { delegatedOutput += chunk; });
|
|
144
|
+
child.stdin.on('error', () => {}); // a stub coordinator that never reads stdin must not crash us
|
|
145
|
+
child.on('error', () => {
|
|
146
|
+
try { process.exit(0); } catch {}
|
|
147
|
+
});
|
|
148
|
+
child.on('close', () => {
|
|
149
|
+
try {
|
|
150
|
+
if (delegatedOutput.trim()) process.stdout.write(delegatedOutput);
|
|
151
|
+
} catch {}
|
|
152
|
+
process.exit(0);
|
|
153
|
+
});
|
|
154
|
+
child.stdin.end(delegateInput);
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
|
|
73
158
|
const runtimePath = path.join(projectRoot, '.claude', 'ukit', 'runtime', 'task-watchdog.mjs');
|
|
74
159
|
// Async existence check — sync fs here would violate the all-async invariant
|
|
75
160
|
// (a stalled mount would block the loop and the 3s self-kill could not fire).
|
|
@@ -103,112 +188,30 @@ setTimeout(() => process.exit(0), HOOK_DEADLINE_MS).unref();
|
|
|
103
188
|
// PostToolUse: advisory only. NEVER emit decision — that would block the
|
|
104
189
|
// in-flight Edit and stall the run. We still re-evaluate so the runtime
|
|
105
190
|
// state stays warm and the next Stop sees an up-to-date view.
|
|
106
|
-
if (hookEvent === 'PostToolUse') {
|
|
107
|
-
const now = Date.now();
|
|
108
|
-
const evals = runtime.evaluateBudgets({ tasks, state, now, config });
|
|
109
|
-
const advisories = evals
|
|
110
|
-
.filter((r) => r.phase !== 'ok')
|
|
111
|
-
.map((r) => {
|
|
112
|
-
const task = tasks.find((t) => t.id === r.id);
|
|
113
|
-
if (!task) return null;
|
|
114
|
-
if (r.phase === 'hard') {
|
|
115
|
-
const blocks = Number(state.hardBlocks[r.id] || 0);
|
|
116
|
-
if (blocks >= (runtime.HARD_BLOCK_CAP || 2)) {
|
|
117
|
-
return runtime.describeDegraded({ id: r.id, hardBlocks: blocks });
|
|
118
|
-
}
|
|
119
|
-
return runtime.describeHardAdvisory(r, r.id);
|
|
120
|
-
}
|
|
121
|
-
return runtime.describeSoft(r, r.id);
|
|
122
|
-
})
|
|
123
|
-
.filter(Boolean);
|
|
124
|
-
|
|
125
|
-
if (advisories.length > 0) {
|
|
126
|
-
const message = advisories.join('\n');
|
|
127
|
-
try {
|
|
128
|
-
process.stdout.write(`${JSON.stringify({ systemMessage: message })}\n`);
|
|
129
|
-
} catch {}
|
|
130
|
-
}
|
|
131
|
-
process.exit(0);
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
// Stop: full evaluation, may emit decision=block under split policy.
|
|
135
191
|
const now = Date.now();
|
|
136
|
-
for (const task of tasks) {
|
|
137
|
-
try {
|
|
138
|
-
await runtime.ensureFirstSeen(state, task.id, now);
|
|
139
|
-
} catch {}
|
|
140
|
-
}
|
|
141
|
-
|
|
142
192
|
const evals = runtime.evaluateBudgets({ tasks, state, now, config });
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
} catch {}
|
|
162
|
-
}
|
|
163
|
-
process.exit(0);
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
// At least one hard-trip. Apply policy.
|
|
167
|
-
const hardPolicy = String(config.taskBudgets?.hardPolicy || 'split');
|
|
168
|
-
const firstHard = hardResults[0];
|
|
169
|
-
const blocks = Number(state.hardBlocks[firstHard.id] || 0);
|
|
170
|
-
|
|
171
|
-
if (hardPolicy !== 'split') {
|
|
172
|
-
// pause (or anything non-split) → advisory only, never block.
|
|
173
|
-
const advisory = hardResults
|
|
174
|
-
.map((r) => runtime.describePause({ id: r.id, result: r }))
|
|
175
|
-
.join('\n');
|
|
176
|
-
try {
|
|
177
|
-
process.stdout.write(`${JSON.stringify({ systemMessage: advisory })}\n`);
|
|
178
|
-
} catch {}
|
|
179
|
-
process.exit(0);
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
// split policy + hard-trip — but the per-task block cap has already been
|
|
183
|
-
// hit. Degrade to advisory so the orchestrator can hand back to the user.
|
|
184
|
-
if (blocks >= (runtime.HARD_BLOCK_CAP || 2)) {
|
|
185
|
-
await runtime.bumpHardBlocks(state, firstHard.id);
|
|
186
|
-
await runtime.writeState(statePath, state);
|
|
187
|
-
const advisory = runtime.describeDegraded({ id: firstHard.id, hardBlocks: blocks + 1 });
|
|
193
|
+
const advisories = evals
|
|
194
|
+
.filter((r) => r.phase !== 'ok')
|
|
195
|
+
.map((r) => {
|
|
196
|
+
const task = tasks.find((t) => t.id === r.id);
|
|
197
|
+
if (!task) return null;
|
|
198
|
+
if (r.phase === 'hard') {
|
|
199
|
+
const blocks = Number(state.hardBlocks[r.id] || 0);
|
|
200
|
+
if (blocks >= (runtime.HARD_BLOCK_CAP || 2)) {
|
|
201
|
+
return runtime.describeDegraded({ id: r.id, hardBlocks: blocks });
|
|
202
|
+
}
|
|
203
|
+
return runtime.describeHardAdvisory(r, r.id);
|
|
204
|
+
}
|
|
205
|
+
return runtime.describeSoft(r, r.id);
|
|
206
|
+
})
|
|
207
|
+
.filter(Boolean);
|
|
208
|
+
|
|
209
|
+
if (advisories.length > 0) {
|
|
210
|
+
const message = advisories.join('\n');
|
|
188
211
|
try {
|
|
189
|
-
process.stdout.write(`${JSON.stringify({ systemMessage:
|
|
212
|
+
process.stdout.write(`${JSON.stringify({ systemMessage: message })}\n`);
|
|
190
213
|
} catch {}
|
|
191
|
-
process.exit(0);
|
|
192
214
|
}
|
|
193
|
-
|
|
194
|
-
// Hard trip + split policy + under the cap → emit Stop decision=block
|
|
195
|
-
// with the visible split instruction. This is the "auto-split and continue"
|
|
196
|
-
// path: blocking the stop IS the continue mechanism — the executor / orchestrator
|
|
197
|
-
// performs the actual TASK-<id>-b cut on the next turn following the reason.
|
|
198
|
-
const newBlocks = await runtime.bumpHardBlocks(state, firstHard.id);
|
|
199
|
-
await runtime.writeState(statePath, state);
|
|
200
|
-
|
|
201
|
-
const task = tasks.find((t) => t.id === firstHard.id) || { id: firstHard.id };
|
|
202
|
-
const lastGreen = runtime.pickLastGreen(task);
|
|
203
|
-
const reason = runtime.describeSplitReason({
|
|
204
|
-
id: firstHard.id,
|
|
205
|
-
result: firstHard,
|
|
206
|
-
hardBlocks: newBlocks,
|
|
207
|
-
lastGreen,
|
|
208
|
-
});
|
|
209
|
-
try {
|
|
210
|
-
process.stdout.write(`${JSON.stringify({ decision: 'block', reason })}\n`);
|
|
211
|
-
} catch {}
|
|
212
215
|
process.exit(0);
|
|
213
216
|
})().catch(() => {
|
|
214
217
|
// Fail-open: never let an exception kill the hook.
|
|
@@ -2,119 +2,110 @@
|
|
|
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
|
+
# 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
|
|
12
|
+
trap ukit_cleanup_hook_input EXIT
|
|
13
|
+
# Bounded stdin (H01): stage before anything reads it; Node gets a path, never the payload.
|
|
14
|
+
ukit_stage_hook_input 2097152 truncate || exit 0
|
|
15
|
+
else
|
|
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.
|
|
19
|
+
UKIT_INPUT_FILE="$(mktemp "${TMPDIR:-/tmp}/ukit-hook-in.XXXXXX")"
|
|
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
|
|
44
|
+
truncate -s 2097152 "$UKIT_INPUT_FILE" 2>/dev/null
|
|
45
|
+
fi
|
|
46
|
+
trap '[ -n "$UKIT_INPUT_FILE" ] && rm -f "$UKIT_INPUT_FILE"' EXIT
|
|
47
|
+
fi
|
|
6
48
|
PROJECT_ROOT="${CLAUDE_PROJECT_DIR:-$PWD}"
|
|
7
49
|
STATE_FILE="$PROJECT_ROOT/.claude/ukit/skill-router-state.json"
|
|
8
50
|
ROUTE_CACHE_FILE="$PROJECT_ROOT/.claude/ukit/route-cache.json"
|
|
9
51
|
PROGRESS_FILE="$PROJECT_ROOT/.claude/ukit/verification-progress.json"
|
|
10
52
|
|
|
11
|
-
|
|
12
|
-
const
|
|
13
|
-
const
|
|
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'
|
|
54
|
+
const HOOK_DEADLINE_MS = Number.parseInt(process.env.UKIT_HOOK_DEADLINE_MS || '', 10) || 3000;
|
|
55
|
+
const LOCK_STARTED_AT = Date.now();
|
|
56
|
+
setTimeout(() => process.exit(0), HOOK_DEADLINE_MS).unref();
|
|
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;
|
|
14
61
|
const path = require('path');
|
|
62
|
+
const { pathToFileURL } = require('url');
|
|
15
63
|
|
|
16
|
-
function readJson(filePath, fallback = null) {
|
|
64
|
+
async function readJson(filePath, fallback = null) {
|
|
17
65
|
try {
|
|
18
|
-
return JSON.parse(fs.
|
|
66
|
+
return JSON.parse(await fs.readFile(filePath, 'utf8'));
|
|
19
67
|
} catch {
|
|
20
68
|
return fallback;
|
|
21
69
|
}
|
|
22
70
|
}
|
|
23
71
|
|
|
24
|
-
function writeJsonAtomic(filePath, value) {
|
|
25
|
-
fs.
|
|
72
|
+
async function writeJsonAtomic(filePath, value) {
|
|
73
|
+
await fs.mkdir(path.dirname(filePath), { recursive: true });
|
|
26
74
|
const tempPath = `${filePath}.tmp-${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
|
27
75
|
try {
|
|
28
|
-
fs.
|
|
29
|
-
fs.
|
|
76
|
+
await fs.writeFile(tempPath, JSON.stringify(value, null, 2) + '\n');
|
|
77
|
+
await fs.rename(tempPath, filePath);
|
|
30
78
|
} catch (error) {
|
|
31
79
|
try {
|
|
32
|
-
fs.
|
|
80
|
+
await fs.rm(tempPath, { force: true });
|
|
33
81
|
} catch {}
|
|
34
82
|
throw error;
|
|
35
83
|
}
|
|
36
84
|
}
|
|
37
85
|
|
|
38
|
-
//
|
|
39
|
-
//
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
}
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
function readLockOwner(lockPath) {
|
|
56
|
-
const owner = readJson(path.join(lockPath, 'owner'), null);
|
|
57
|
-
return owner && Number.isInteger(owner.pid) && typeof owner.token === 'string'
|
|
58
|
-
? owner
|
|
59
|
-
: null;
|
|
60
|
-
}
|
|
61
|
-
|
|
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) {
|
|
66
|
-
const staleMs = 10000;
|
|
67
|
-
const maxWaitMs = 5000;
|
|
68
|
-
const startedAt = Date.now();
|
|
69
|
-
const ownerToken = `${process.pid}-${crypto.randomBytes(8).toString('hex')}`;
|
|
70
|
-
let held = false;
|
|
71
|
-
while (!held) {
|
|
72
|
-
try {
|
|
73
|
-
fs.mkdirSync(path.dirname(lockPath), { recursive: true });
|
|
74
|
-
fs.mkdirSync(lockPath);
|
|
86
|
+
// TASK-015: lock waits poll asynchronously. The old blocking shared-memory
|
|
87
|
+
// sleep froze the event loop, so the unref'd deadline timer above could never
|
|
88
|
+
// fire while waiting — a contended lock held the hook past its own 3s
|
|
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;
|
|
75
100
|
try {
|
|
76
|
-
fs.
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
}));
|
|
81
|
-
} catch (error) {
|
|
82
|
-
try {
|
|
83
|
-
fs.rmSync(lockPath, { recursive: true, force: true });
|
|
84
|
-
} catch {}
|
|
85
|
-
throw error;
|
|
101
|
+
await fs.access(modulePath);
|
|
102
|
+
return await import(pathToFileURL(modulePath).href);
|
|
103
|
+
} catch {
|
|
104
|
+
return null;
|
|
86
105
|
}
|
|
87
|
-
|
|
88
|
-
} catch (error) {
|
|
89
|
-
if (!error || error.code !== 'EEXIST') return fn();
|
|
90
|
-
try {
|
|
91
|
-
const stat = fs.statSync(lockPath);
|
|
92
|
-
if (Date.now() - stat.mtimeMs > staleMs) {
|
|
93
|
-
const owner = readLockOwner(lockPath);
|
|
94
|
-
if (!owner || !isPidAlive(owner.pid)) {
|
|
95
|
-
try {
|
|
96
|
-
fs.rmSync(lockPath, { recursive: true, force: true });
|
|
97
|
-
continue;
|
|
98
|
-
} catch {}
|
|
99
|
-
}
|
|
100
|
-
}
|
|
101
|
-
} catch {}
|
|
102
|
-
if (Date.now() - startedAt > maxWaitMs) break;
|
|
103
|
-
sleepSync(3 + Math.floor(Math.random() * 9));
|
|
104
|
-
}
|
|
105
|
-
}
|
|
106
|
-
try {
|
|
107
|
-
return fn();
|
|
108
|
-
} finally {
|
|
109
|
-
if (held) {
|
|
110
|
-
const owner = readLockOwner(lockPath);
|
|
111
|
-
if (owner?.token === ownerToken) {
|
|
112
|
-
try {
|
|
113
|
-
fs.rmSync(lockPath, { recursive: true, force: true });
|
|
114
|
-
} catch {}
|
|
115
|
-
}
|
|
116
|
-
}
|
|
106
|
+
})();
|
|
117
107
|
}
|
|
108
|
+
return lockModulePromise;
|
|
118
109
|
}
|
|
119
110
|
|
|
120
111
|
function normalizeCommand(value) {
|
|
@@ -330,17 +321,30 @@ function advise(message) {
|
|
|
330
321
|
// ledger, context hard-cap, protected-file, and destructive-command guards; turning
|
|
331
322
|
// a verification-order recommendation into an exit-2 refusal gave a stale opt-in env
|
|
332
323
|
// variable a way to freeze a run before it could diagnose or land its current change.
|
|
333
|
-
|
|
324
|
+
process.stdout.write(`${message.replace(/^BLOCKED:/, 'ADVISORY:')}\n`);
|
|
334
325
|
process.exit(0);
|
|
335
326
|
}
|
|
336
327
|
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
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.
|
|
331
|
+
(async () => {
|
|
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
|
+
}
|
|
342
347
|
}
|
|
343
|
-
})();
|
|
344
348
|
const command = normalizeCommand(payload?.tool_input?.command || payload?.command || '');
|
|
345
349
|
if (!command) {
|
|
346
350
|
process.exit(0);
|
|
@@ -348,8 +352,8 @@ if (!command) {
|
|
|
348
352
|
|
|
349
353
|
const statePath = process.env.STATE_FILE;
|
|
350
354
|
const progressPath = process.env.PROGRESS_FILE;
|
|
351
|
-
const state = readJson(statePath, null);
|
|
352
|
-
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);
|
|
353
357
|
const recommendation = state?.verificationRecommendation ?? null;
|
|
354
358
|
const routeSummary = state?.routeSummary ?? null;
|
|
355
359
|
const helpers = state?.helpers ?? null;
|
|
@@ -396,21 +400,21 @@ if (!policyMode && primaryCommands.length === 0 && fallbackCommands.length === 0
|
|
|
396
400
|
process.exit(0);
|
|
397
401
|
}
|
|
398
402
|
|
|
399
|
-
const progress = readJson(progressPath, {});
|
|
403
|
+
const progress = await readJson(progressPath, {});
|
|
400
404
|
const attemptedCommands = collectAttemptedCommands(progress, {
|
|
401
405
|
currentFingerprint: state?.fingerprint || null,
|
|
402
406
|
maxAgeMs: STATE_FRESH_MS,
|
|
403
407
|
});
|
|
404
408
|
|
|
405
|
-
function persistAttempt(commandText) {
|
|
409
|
+
async function persistAttempt(commandText) {
|
|
406
410
|
const normalizedCommandText = normalizeCommand(commandText);
|
|
407
411
|
attemptedCommands.add(normalizedCommandText);
|
|
408
412
|
const now = Date.now();
|
|
409
413
|
// Locked re-read + merge: parallel subagents record attempts concurrently, and a
|
|
410
414
|
// stale-snapshot rewrite would silently drop their entries — the guard would then keep
|
|
411
415
|
// advising instead of allowing, re-litigating verification order forever.
|
|
412
|
-
|
|
413
|
-
const current = readJson(progressPath, {});
|
|
416
|
+
const mutation = async () => {
|
|
417
|
+
const current = await readJson(progressPath, {});
|
|
414
418
|
const mergedAttempts = new Set(collectAttemptedCommands(current, {
|
|
415
419
|
currentFingerprint: state?.fingerprint || null,
|
|
416
420
|
maxAgeMs: STATE_FRESH_MS,
|
|
@@ -430,13 +434,31 @@ function persistAttempt(commandText) {
|
|
|
430
434
|
.filter((entry, index, list) => (
|
|
431
435
|
list.findLastIndex((candidate) => candidate.command === entry.command) === index
|
|
432
436
|
));
|
|
433
|
-
writeJsonAtomic(progressPath, {
|
|
437
|
+
await writeJsonAtomic(progressPath, {
|
|
434
438
|
fingerprint: state?.fingerprint || null,
|
|
435
439
|
updatedAt: now,
|
|
436
440
|
attemptedCommands: [...mergedAttempts],
|
|
437
441
|
recentAttempts,
|
|
438
442
|
});
|
|
439
|
-
}
|
|
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();
|
|
440
462
|
}
|
|
441
463
|
|
|
442
464
|
const isPrimaryCommand = primaryCommands.includes(command);
|
|
@@ -463,7 +485,7 @@ const preferredText = formatCompactCommandList(preferredOrder, { separator: ' ->
|
|
|
463
485
|
const missingPrimaryText = formatCompactCommandList(missingPrimary);
|
|
464
486
|
|
|
465
487
|
if (isTargetedVerification) {
|
|
466
|
-
persistAttempt(command);
|
|
488
|
+
await persistAttempt(command);
|
|
467
489
|
process.exit(0);
|
|
468
490
|
}
|
|
469
491
|
|
|
@@ -498,15 +520,23 @@ if (policyMode === 'docs-light' && !explicitBroadRequested && isBroadTestCommand
|
|
|
498
520
|
}
|
|
499
521
|
|
|
500
522
|
if (isPrimaryCommand) {
|
|
501
|
-
persistAttempt(command);
|
|
523
|
+
await persistAttempt(command);
|
|
502
524
|
process.exit(0);
|
|
503
525
|
}
|
|
504
526
|
|
|
505
527
|
if (isFallbackCommand || isVerificationCommand(command)) {
|
|
506
|
-
persistAttempt(command);
|
|
528
|
+
await persistAttempt(command);
|
|
507
529
|
}
|
|
508
530
|
|
|
509
531
|
process.exit(0);
|
|
532
|
+
})().catch((err) => {
|
|
533
|
+
// Advisory hook: an unexpected error (including a rejected lock) must never
|
|
534
|
+
// turn into a non-zero exit — degrade to a bounded diagnostic and exit 0.
|
|
535
|
+
try {
|
|
536
|
+
process.stderr.write(`verification-guard: unexpected error (${err?.message ?? err})\n`);
|
|
537
|
+
} catch {}
|
|
538
|
+
process.exit(0);
|
|
539
|
+
});
|
|
510
540
|
NODE
|
|
511
541
|
|
|
512
542
|
exit $?
|