@ngockhoale/ukit 2.7.5 → 2.7.7
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 +89 -0
- package/package.json +1 -1
- package/scripts/install/sync-installed-mirror.mjs +250 -0
- package/scripts/perf/audit-perf.mjs +287 -36
- package/scripts/perf/diff-perf-findings.mjs +136 -0
- package/scripts/perf/perf-findings.json +260 -206
- package/scripts/perf/perf-measure.md +271 -0
- package/src/core/hookChainDoctor.js +65 -2
- package/src/core/memory/store.js +22 -1
- package/src/core/ompConfigMerge.js +7 -2
- package/src/core/permissionDoctor.js +59 -4
- package/src/core/permissionPolicy.js +1 -1
- package/src/core/taskBudgetValidator.js +9 -6
- package/src/core/unattendedDoctor.js +8 -1
- package/templates/.claude/hooks/auto-prune-bash.sh +19 -0
- package/templates/.claude/hooks/reinject-context.sh +22 -0
- package/templates/.claude/hooks/reset-compact-pressure.sh +19 -0
- package/templates/.claude/hooks/session-episode.sh +20 -0
- package/templates/.claude/settings.json +0 -1
- package/templates/.claude/ukit/index/task-budget-validator.mjs +6 -2
- package/templates/.claude/ukit/runtime/hook-chain-runner.mjs +118 -20
- package/templates/.claude/ukit/runtime/hook-telemetry.mjs +84 -12
- package/templates/.claude/ukit/runtime/hook-telemetry.sh +50 -0
- package/templates/.omp/config.yml +9 -8
|
@@ -9,6 +9,28 @@ fi
|
|
|
9
9
|
HOOK_DIR="$(cd "$(dirname "$0")" && pwd)"
|
|
10
10
|
SCRIPT_PATH="$HOOK_DIR/../ukit/runtime/reinject-context.mjs"
|
|
11
11
|
|
|
12
|
+
# O1 (TASK-009): this hook stages no stdin (SPEC §14), so the shared cleanup path in
|
|
13
|
+
# hook-input.sh never runs for it and nothing here would ever be measured. Arm the
|
|
14
|
+
# no-staging start marker and finish it from this hook's own EXIT trap: arming costs
|
|
15
|
+
# one mktemp and reads no clock, and the single bounded `--finish` child is the
|
|
16
|
+
# telemetry process rather than a hook work step. A missing runtime (pre-install
|
|
17
|
+
# tree) leaves the flag unset — exactly as unmeasured as today — and the trap returns
|
|
18
|
+
# the status it captured, so the `exit $?` below still reaches the host unchanged.
|
|
19
|
+
# shellcheck source=/dev/null
|
|
20
|
+
source "$HOOK_DIR/../ukit/runtime/hook-telemetry.sh" 2>/dev/null || true
|
|
21
|
+
if [ "${UKIT_TEL_ARMED:-}" = "1" ]; then
|
|
22
|
+
ukit_hook_telemetry_arm
|
|
23
|
+
__ukit_tel_finish() {
|
|
24
|
+
local __ukit_tel_rc=$?
|
|
25
|
+
if [ "${UKIT_TEL_ARMED:-}" = "1" ] && command -v ukit_hook_telemetry_finish >/dev/null 2>&1; then
|
|
26
|
+
# The event travels per call — no staged envelope exists to carry it.
|
|
27
|
+
UKIT_TEL_RC="$__ukit_tel_rc" UKIT_TEL_EVENT="PreCompact" ukit_hook_telemetry_finish
|
|
28
|
+
fi
|
|
29
|
+
return "$__ukit_tel_rc"
|
|
30
|
+
}
|
|
31
|
+
trap __ukit_tel_finish EXIT
|
|
32
|
+
fi
|
|
33
|
+
|
|
12
34
|
if [ -f "$SCRIPT_PATH" ]; then
|
|
13
35
|
UKIT_HOOK_DEADLINE_MS="${UKIT_HOOK_DEADLINE_MS:-3000}" node "$SCRIPT_PATH"
|
|
14
36
|
exit $?
|
|
@@ -24,6 +24,25 @@ if [ ! -f "$PRESSURE_FILE" ]; then
|
|
|
24
24
|
exit 0
|
|
25
25
|
fi
|
|
26
26
|
|
|
27
|
+
# O1 (TASK-009): armed AFTER the no-pressure-file short-circuit above, so the common
|
|
28
|
+
# "nothing to reset" start stays unmeasured while real resets get timed. This hook
|
|
29
|
+
# stages no stdin (SPEC §14), so it owns its EXIT trap; the trap restores the captured
|
|
30
|
+
# status and the always-exit-0 posture below is unchanged.
|
|
31
|
+
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
32
|
+
# shellcheck source=/dev/null
|
|
33
|
+
source "$SCRIPT_DIR/../ukit/runtime/hook-telemetry.sh" 2>/dev/null || true
|
|
34
|
+
if [ "${UKIT_TEL_ARMED:-}" = "1" ]; then
|
|
35
|
+
ukit_hook_telemetry_arm
|
|
36
|
+
__ukit_tel_finish() {
|
|
37
|
+
local __ukit_tel_rc=$?
|
|
38
|
+
if [ "${UKIT_TEL_ARMED:-}" = "1" ] && command -v ukit_hook_telemetry_finish >/dev/null 2>&1; then
|
|
39
|
+
UKIT_TEL_RC="$__ukit_tel_rc" UKIT_TEL_EVENT="SessionStart" ukit_hook_telemetry_finish
|
|
40
|
+
fi
|
|
41
|
+
return "$__ukit_tel_rc"
|
|
42
|
+
}
|
|
43
|
+
trap __ukit_tel_finish EXIT
|
|
44
|
+
fi
|
|
45
|
+
|
|
27
46
|
# The lock directory lives beside the state file; its parent exists because the file does.
|
|
28
47
|
node -e '
|
|
29
48
|
// Deadline must exceed the bounded lock wait in this block (maxWaitMs = 5000) so the
|
|
@@ -37,6 +37,26 @@ if [ "$__ukit_ep_gate" != "1" ]; then
|
|
|
37
37
|
fi
|
|
38
38
|
unset __ukit_ep_gate
|
|
39
39
|
|
|
40
|
+
# O1 (TASK-009): armed only past the gate above, so a gate-off SessionEnd stays a pure
|
|
41
|
+
# fast exit. This hook stages no stdin (SPEC §14) and owns its EXIT trap; the trap
|
|
42
|
+
# returns the captured status, so the always-exit-0 contract is unchanged. The row is
|
|
43
|
+
# not session-attributed: the staged payload is reaped below before this trap runs, and
|
|
44
|
+
# re-deriving the id in shell would mean another spawn on the teardown path.
|
|
45
|
+
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
46
|
+
# shellcheck source=/dev/null
|
|
47
|
+
source "$SCRIPT_DIR/../ukit/runtime/hook-telemetry.sh" 2>/dev/null || true
|
|
48
|
+
if [ "${UKIT_TEL_ARMED:-}" = "1" ]; then
|
|
49
|
+
ukit_hook_telemetry_arm
|
|
50
|
+
__ukit_tel_finish() {
|
|
51
|
+
local __ukit_tel_rc=$?
|
|
52
|
+
if [ "${UKIT_TEL_ARMED:-}" = "1" ] && command -v ukit_hook_telemetry_finish >/dev/null 2>&1; then
|
|
53
|
+
UKIT_TEL_RC="$__ukit_tel_rc" UKIT_TEL_EVENT="SessionEnd" ukit_hook_telemetry_finish
|
|
54
|
+
fi
|
|
55
|
+
return "$__ukit_tel_rc"
|
|
56
|
+
}
|
|
57
|
+
trap __ukit_tel_finish EXIT
|
|
58
|
+
fi
|
|
59
|
+
|
|
40
60
|
# Bounded stdin read (existing hook style): cap +1 byte in background so a
|
|
41
61
|
# producer that never closes the pipe cannot park the session teardown.
|
|
42
62
|
UKIT_INPUT_FILE="$(mktemp "${TMPDIR:-/tmp}/ukit-episode-in.XXXXXX")" || exit 0
|
|
@@ -27,7 +27,7 @@ const VERIFICATION_MINUTE_TABLE = Object.freeze({
|
|
|
27
27
|
'node scripts/release/verify-release.mjs': 2,
|
|
28
28
|
});
|
|
29
29
|
|
|
30
|
-
function estimateVerificationMinutes(commands) {
|
|
30
|
+
export function estimateVerificationMinutes(commands) {
|
|
31
31
|
if (!Array.isArray(commands)) return 0;
|
|
32
32
|
let total = 0;
|
|
33
33
|
for (const raw of commands) {
|
|
@@ -37,7 +37,11 @@ function estimateVerificationMinutes(commands) {
|
|
|
37
37
|
total += VERIFICATION_MINUTE_TABLE[cmd];
|
|
38
38
|
continue;
|
|
39
39
|
}
|
|
40
|
-
|
|
40
|
+
// `yarn vitest [run]` plus the `yarn test` alias (package.json: `"test": "vitest run"`).
|
|
41
|
+
// The `(?=\s|$)` boundary is deliberate — `\b` would also swallow `yarn test:artifact` /
|
|
42
|
+
// `yarn test:liveness`, which are *different* scripts that must keep the 1-minute fallback.
|
|
43
|
+
if (/^yarn\s+(?:vitest(\s+run)?|test)(?=\s|$)/.test(cmd)) {
|
|
44
|
+
// 0.5 minutes per non-flag argument after the runner word (`run`, or `yarn test` itself).
|
|
41
45
|
const tokens = cmd.split(/\s+/);
|
|
42
46
|
const runIdx = tokens.indexOf('run');
|
|
43
47
|
const tail = runIdx >= 0 ? tokens.slice(runIdx + 1) : tokens.slice(2);
|
|
@@ -97,6 +97,20 @@ function chainFailureKind(processResult) {
|
|
|
97
97
|
}
|
|
98
98
|
}
|
|
99
99
|
|
|
100
|
+
// B1 (TASK-001, SPEC §FR-001): a child that emits a `hookSpecificOutput`
|
|
101
|
+
// permission decision owns the verdict. `hookSpecificOutput` alone is NOT
|
|
102
|
+
// enough — a child killed mid-write can carry the marker in a truncated
|
|
103
|
+
// capture, and calling that a decision would exempt a genuinely-skipped gate
|
|
104
|
+
// from the fail-closed verdict. A real decision is a clean exit-0 verdict.
|
|
105
|
+
function emitsPermissionDecision(entry) {
|
|
106
|
+
return Boolean(entry)
|
|
107
|
+
&& !entry.killed
|
|
108
|
+
&& entry.code === 0
|
|
109
|
+
&& entry.failureKind === 'ok'
|
|
110
|
+
&& typeof entry.stdout === 'string'
|
|
111
|
+
&& entry.stdout.includes('"hookSpecificOutput"');
|
|
112
|
+
}
|
|
113
|
+
|
|
100
114
|
function recordTiming(projectRoot, payload, timing) {
|
|
101
115
|
// Timing telemetry is advisory and must never delay or block a tool call;
|
|
102
116
|
// appendTelemetryRow carries the same posture (and the per-session cap).
|
|
@@ -109,12 +123,37 @@ function recordTiming(projectRoot, payload, timing) {
|
|
|
109
123
|
// A `:0` or negative suffix is rejected (falls back) — zero would mean "no
|
|
110
124
|
// budget", which silently disables the deadline; that is never a valid hook
|
|
111
125
|
// contract.
|
|
126
|
+
// TASK-001 (C8 / SPEC §FR-006): "rejected silently" was the bug. A `:0`/`: -1`
|
|
127
|
+
// typo used to leave the suffix on the path, so the child never spawned and the
|
|
128
|
+
// hook silently vanished from the chain. It now warns (never hard-fails — a
|
|
129
|
+
// config typo must not block every tool call in a fail-closed chain) and runs
|
|
130
|
+
// the script under the DEFAULT child budget.
|
|
131
|
+
const TIMEOUT_SUFFIX_RE = /^(.*):(-?\d+(?:\.\d+)?)$/;
|
|
132
|
+
|
|
133
|
+
// The ONE suffix stripper. Identity checks that need a script's declared name
|
|
134
|
+
// (fail-closed lookups, skipped-gate reporting) go through it too: a `:-1` arg
|
|
135
|
+
// must not look like an advisory script there while `parseScriptArg` already
|
|
136
|
+
// normalized it to a gate for execution.
|
|
137
|
+
function stripTimeoutSuffix(arg) {
|
|
138
|
+
const match = TIMEOUT_SUFFIX_RE.exec(String(arg ?? ''));
|
|
139
|
+
return match ? match[1] : String(arg ?? '');
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function failClosedName(arg) {
|
|
143
|
+
return path.basename(stripTimeoutSuffix(arg));
|
|
144
|
+
}
|
|
145
|
+
|
|
112
146
|
function parseScriptArg(arg) {
|
|
113
|
-
const
|
|
114
|
-
if (!
|
|
115
|
-
const seconds = Number(
|
|
116
|
-
if (!Number.isFinite(seconds) || seconds <= 0)
|
|
117
|
-
|
|
147
|
+
const suffix = TIMEOUT_SUFFIX_RE.exec(String(arg ?? ''));
|
|
148
|
+
if (!suffix) return { scriptPath: arg, timeoutMs: null };
|
|
149
|
+
const seconds = Number(suffix[2]);
|
|
150
|
+
if (!Number.isFinite(seconds) || seconds <= 0) {
|
|
151
|
+
process.stderr.write(
|
|
152
|
+
`[ukit] ignoring invalid timeout suffix "${arg}" — using default child budget\n`,
|
|
153
|
+
);
|
|
154
|
+
return { scriptPath: suffix[1], timeoutMs: null };
|
|
155
|
+
}
|
|
156
|
+
return { scriptPath: suffix[1], timeoutMs: Math.round(seconds * 1000) };
|
|
118
157
|
}
|
|
119
158
|
|
|
120
159
|
// TASK-001 (SPEC §FR-001, §8): a chain arg ending in `.mjs` is an IN-PROC step —
|
|
@@ -178,7 +217,7 @@ async function runModuleStep({ scriptPath, payload, payloadText, projectRoot, en
|
|
|
178
217
|
}
|
|
179
218
|
}
|
|
180
219
|
|
|
181
|
-
async function run(payloadText, scriptArgs, { chainMarker = true } = {}) {
|
|
220
|
+
async function run(payloadText, scriptArgs, { chainMarker = true, decisionShortCircuit = false, stdinStageMs = null } = {}) {
|
|
182
221
|
const parsedArgs = scriptArgs.map(parseScriptArg);
|
|
183
222
|
const scriptPaths = parsedArgs.map((a) => a.scriptPath);
|
|
184
223
|
const payload = JSON.parse(payloadText || '{}');
|
|
@@ -224,7 +263,7 @@ async function run(payloadText, scriptArgs, { chainMarker = true } = {}) {
|
|
|
224
263
|
const deadline = startedAt + totalBudgetMs;
|
|
225
264
|
const results = [];
|
|
226
265
|
let budgetExhausted = false;
|
|
227
|
-
|
|
266
|
+
let decisionEmitted = false;
|
|
228
267
|
for (let scriptIndex = 0; scriptIndex < scriptPaths.length; scriptIndex++) {
|
|
229
268
|
const scriptPath = scriptPaths[scriptIndex];
|
|
230
269
|
const scriptName = path.basename(scriptPath);
|
|
@@ -321,7 +360,20 @@ async function run(payloadText, scriptArgs, { chainMarker = true } = {}) {
|
|
|
321
360
|
});
|
|
322
361
|
}
|
|
323
362
|
|
|
324
|
-
|
|
363
|
+
// B1 (TASK-001, SPEC §FR-001): under `--emit-verdict` a child that emits a
|
|
364
|
+
// `hookSpecificOutput` permission decision owns the verdict even at exit 0
|
|
365
|
+
// (the direct Claude contract: deny + exit 0 still blocks). The chain stops
|
|
366
|
+
// HERE, before the next child spawns — filtering the decision at emit time
|
|
367
|
+
// instead would still let the next script's side effects run (e.g.
|
|
368
|
+
// pre-edit-backup.sh backing up a file whose edit was just denied) and its
|
|
369
|
+
// stdout would be concatenated in front of the decision JSON, which Claude
|
|
370
|
+
// Code rejects as invalid JSON. The predicate is shared with the emit-time
|
|
371
|
+
// lookup so a truncated capture can never count as a decision here.
|
|
372
|
+
|
|
373
|
+
if (decisionShortCircuit && emitsPermissionDecision(results[results.length - 1])) {
|
|
374
|
+
decisionEmitted = true;
|
|
375
|
+
}
|
|
376
|
+
if (decisionEmitted || code === 2 || killed || (code !== 0 && FAIL_CLOSED_SCRIPTS.has(scriptName))) {
|
|
325
377
|
break;
|
|
326
378
|
}
|
|
327
379
|
}
|
|
@@ -331,9 +383,12 @@ async function run(payloadText, scriptArgs, { chainMarker = true } = {}) {
|
|
|
331
383
|
// of those unrun scripts is fail-closed, the chain must fail CLOSED — the old
|
|
332
384
|
// per-script path ran every hook independently, so a timed-out advisory never
|
|
333
385
|
// skipped a gate. `skippedFailClosed` carries that signal to the verdict.
|
|
334
|
-
|
|
386
|
+
// ... unless this break WAS the decision: a decision owns the verdict, so the
|
|
387
|
+
// unrun gates are not a fail-closed gap (SPEC §FR-001 — the verdict must stay
|
|
388
|
+
// exit 0 with the decision JSON).
|
|
389
|
+
const skippedFailClosed = !decisionEmitted && scriptPaths
|
|
335
390
|
.slice(results.length)
|
|
336
|
-
.some((p) => FAIL_CLOSED_SCRIPTS.has(
|
|
391
|
+
.some((p) => FAIL_CLOSED_SCRIPTS.has(failClosedName(p)));
|
|
337
392
|
|
|
338
393
|
const elapsedMs = Date.now() - startedAt;
|
|
339
394
|
// TASK-019: versioned rows shared with direct hooks. `outcome` reuses this
|
|
@@ -350,6 +405,18 @@ async function run(payloadText, scriptArgs, { chainMarker = true } = {}) {
|
|
|
350
405
|
toolName: payload?.tool_name || null,
|
|
351
406
|
toolUseId: payload?.tool_use_id || null,
|
|
352
407
|
elapsedMs,
|
|
408
|
+
// O3 (SPEC §FR-009): the row's wall time split into the stdin stage — the
|
|
409
|
+
// bounded read this process waits on while the producer writes — and the
|
|
410
|
+
// chain's own execution. A producer holding the pipe open is then
|
|
411
|
+
// attributable instead of looking like a slow hook (the reported artifact:
|
|
412
|
+
// project-important.sh p95 = 1539ms with no row doing real work ≥ 1s).
|
|
413
|
+
// Clamped to `elapsedMs` because the split describes the row it sits on
|
|
414
|
+
// (the two are measured from different origins). Absent — not 0 and not
|
|
415
|
+
// null — when this invocation never read stdin, so a v1 reader sees the
|
|
416
|
+
// row it always saw.
|
|
417
|
+
...(stdinStageMs === null
|
|
418
|
+
? {}
|
|
419
|
+
: { stdinStageMs: Math.min(Math.max(0, Math.round(stdinStageMs)), elapsedMs) }),
|
|
353
420
|
budgetMs: totalBudgetMs,
|
|
354
421
|
budgetExhausted,
|
|
355
422
|
scripts: results.map(({ scriptName, code, killed, failureKind, elapsedMs: scriptElapsedMs }) => ({
|
|
@@ -358,6 +425,12 @@ async function run(payloadText, scriptArgs, { chainMarker = true } = {}) {
|
|
|
358
425
|
killed,
|
|
359
426
|
failureKind,
|
|
360
427
|
elapsedMs: scriptElapsedMs,
|
|
428
|
+
// TASK-007 (SPEC §FR-012): which steps the runner treated as gates. The
|
|
429
|
+
// doctor reads this flag instead of importing FAIL_CLOSED_SCRIPTS — the
|
|
430
|
+
// runner is the only component that knows which paths it gated, and a
|
|
431
|
+
// second copy of a security-relevant list is a drift hazard. Additive and
|
|
432
|
+
// optional: v1 readers that read the five original keys keep working.
|
|
433
|
+
failClosed: FAIL_CLOSED_SCRIPTS.has(scriptName),
|
|
361
434
|
})),
|
|
362
435
|
});
|
|
363
436
|
|
|
@@ -384,8 +457,14 @@ try {
|
|
|
384
457
|
// '-' reads the payload from stdin — the form Claude Code hook commands use.
|
|
385
458
|
let payloadText = payloadArg;
|
|
386
459
|
let stdinTruncated = false;
|
|
460
|
+
// O3: the stage window is measured HERE, around the bounded read, and not
|
|
461
|
+
// inside run() — run() starts after the payload is already in hand, and a
|
|
462
|
+
// truncated read exits before run() is ever called.
|
|
463
|
+
let stdinStageMs = null;
|
|
387
464
|
if (payloadArg === '-') {
|
|
465
|
+
const stageStartedAt = Date.now();
|
|
388
466
|
const staged = await readStdinBounded();
|
|
467
|
+
stdinStageMs = Date.now() - stageStartedAt;
|
|
389
468
|
payloadText = staged.text;
|
|
390
469
|
stdinTruncated = staged.truncated;
|
|
391
470
|
} else if (payloadArg.startsWith('@')) {
|
|
@@ -402,8 +481,7 @@ try {
|
|
|
402
481
|
// chain carries a fail-closed gate, emit deny now instead of letting gates
|
|
403
482
|
// pass on a payload they never fully received.
|
|
404
483
|
if (stdinTruncated) {
|
|
405
|
-
const hasFailClosed = scriptPaths.some((p) =>
|
|
406
|
-
FAIL_CLOSED_SCRIPTS.has(path.basename(String(p).replace(/:\d+(?:\.\d+)?$/, ''))));
|
|
484
|
+
const hasFailClosed = scriptPaths.some((p) => FAIL_CLOSED_SCRIPTS.has(failClosedName(p)));
|
|
407
485
|
if (hasFailClosed) {
|
|
408
486
|
const deny = JSON.stringify({
|
|
409
487
|
hookSpecificOutput: {
|
|
@@ -427,7 +505,13 @@ try {
|
|
|
427
505
|
process.exit(emitVerdict ? 0 : 2);
|
|
428
506
|
}
|
|
429
507
|
}
|
|
430
|
-
const chain = await run(payloadText, scriptPaths, {
|
|
508
|
+
const chain = await run(payloadText, scriptPaths, {
|
|
509
|
+
chainMarker: !emitVerdict,
|
|
510
|
+
// B1: only the --emit-verdict contract short-circuits on a decision; the
|
|
511
|
+
// omp bridge parses the JSON envelope and needs every step's context stdout.
|
|
512
|
+
decisionShortCircuit: emitVerdict,
|
|
513
|
+
stdinStageMs,
|
|
514
|
+
});
|
|
431
515
|
if (!emitVerdict) {
|
|
432
516
|
process.stdout.write(JSON.stringify(chain));
|
|
433
517
|
} else {
|
|
@@ -435,10 +519,21 @@ try {
|
|
|
435
519
|
// verdict even when it exits 0 (the direct Claude contract: deny + exit 0
|
|
436
520
|
// still blocks). The first decision wins, matching per-script semantics
|
|
437
521
|
// where each hook's output is its own verdict and a deny short-circuits.
|
|
522
|
+
// The loose lookup decides which entry is REPORTED as the verdict owner (a
|
|
523
|
+
// non-zero decision emitter stays routed through the code-2/fail-closed
|
|
524
|
+
// paths below, per SPEC §FR-001); the strict predicate decides whether that
|
|
525
|
+
// entry may claim stdout outright.
|
|
438
526
|
const decisionResult = chain.results.find((r) =>
|
|
439
527
|
typeof r.stdout === 'string' && r.stdout.includes('"hookSpecificOutput"'));
|
|
528
|
+
const decisionOwnsVerdict = emitsPermissionDecision(decisionResult);
|
|
440
529
|
const last = decisionResult ?? chain.results[chain.results.length - 1];
|
|
441
530
|
|
|
531
|
+
// B1 (SPEC §FR-001): a clean decision owns the ENTIRE stdout — no context
|
|
532
|
+
// text before or after it. Claude Code parses stdout as one JSON document,
|
|
533
|
+
// so leading context turns a valid deny into "Hook JSON output validation
|
|
534
|
+
// failed"; the chain already stopped at the decision, so no later script's
|
|
535
|
+
// output can be concatenated in.
|
|
536
|
+
|
|
442
537
|
// TASK-234 review fix (critical): context stdout must be REPLAYED, not
|
|
443
538
|
// dropped. SessionStart/UserPromptSubmit hooks emit plain-text context
|
|
444
539
|
// (PROJECT_IMPORTANT mandate, skill-router guidance) — replaying only the
|
|
@@ -449,15 +544,18 @@ try {
|
|
|
449
544
|
.filter((r) => r !== decisionResult && r !== last && typeof r.stdout === 'string' && r.stdout.length > 0)
|
|
450
545
|
.map((r) => r.stdout)
|
|
451
546
|
.join('');
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
if (chain.skippedFailClosed) {
|
|
547
|
+
if (decisionOwnsVerdict) {
|
|
548
|
+
process.stdout.write(decisionResult.stdout);
|
|
549
|
+
if (decisionResult.stderr) process.stderr.write(decisionResult.stderr);
|
|
550
|
+
process.exitCode = 0;
|
|
551
|
+
} else if (chain.skippedFailClosed) {
|
|
552
|
+
// TASK-234 review fix (critical): a mid-chain break that skipped a
|
|
553
|
+
// fail-closed gate must fail CLOSED — the old per-script path ran every
|
|
554
|
+
// hook independently, so a killed advisory never skipped a gate.
|
|
457
555
|
if (contextStdout) process.stdout.write(contextStdout);
|
|
458
556
|
const skipped = scriptPaths
|
|
459
557
|
.slice(chain.results.length)
|
|
460
|
-
.map((p) =>
|
|
558
|
+
.map((p) => failClosedName(p))
|
|
461
559
|
.filter((name) => FAIL_CLOSED_SCRIPTS.has(name))
|
|
462
560
|
.join(', ');
|
|
463
561
|
process.stderr.write(`UKit hook chain broke before fail-closed gate(s) ran: ${skipped}\n`);
|
|
@@ -466,7 +564,7 @@ try {
|
|
|
466
564
|
// No script ran at all (empty chain or budget spent before the first
|
|
467
565
|
// child). With fail-closed scripts declared in the chain this must not
|
|
468
566
|
// fail open.
|
|
469
|
-
const hasFailClosed = scriptPaths.some((p) => FAIL_CLOSED_SCRIPTS.has(
|
|
567
|
+
const hasFailClosed = scriptPaths.some((p) => FAIL_CLOSED_SCRIPTS.has(failClosedName(p)));
|
|
470
568
|
if (hasFailClosed) {
|
|
471
569
|
process.stderr.write('UKit hook chain produced no verdict — fail-closed gate did not run\n');
|
|
472
570
|
process.exitCode = 2;
|
|
@@ -69,7 +69,13 @@ function normalizeElapsedMs(value) {
|
|
|
69
69
|
// Allowlist builder — the ONLY place row fields are chosen. `elapsedMs` may be
|
|
70
70
|
// null when the caller genuinely could not measure it (e.g. the hook exited
|
|
71
71
|
// before its stdin envelope was staged); unknown stays unknown, never zero.
|
|
72
|
+
//
|
|
73
|
+
// O3 (SPEC §FR-009): `stageMs` splits that elapsed window into the part the hook
|
|
74
|
+
// spent waiting on stdin staging and the part it spent executing. It is OPTIONAL
|
|
75
|
+
// — omitted entirely when unmeasurable — so a v1 reader's key set is unchanged
|
|
76
|
+
// for a row that has no stage data, and `TELEMETRY_VERSION` stays 1.
|
|
72
77
|
export function buildTimingRow(entry = {}) {
|
|
78
|
+
const stageMs = normalizeElapsedMs(entry.stageMs);
|
|
73
79
|
return {
|
|
74
80
|
v: TELEMETRY_VERSION,
|
|
75
81
|
ts: Number.isFinite(entry.ts) ? entry.ts : Date.now(),
|
|
@@ -79,6 +85,7 @@ export function buildTimingRow(entry = {}) {
|
|
|
79
85
|
hook: shortString(entry.hook, 128),
|
|
80
86
|
elapsedMs: normalizeElapsedMs(entry.elapsedMs),
|
|
81
87
|
outcome: shortString(entry.outcome, 32) || 'ok',
|
|
88
|
+
...(stageMs === null ? {} : { stageMs }),
|
|
82
89
|
};
|
|
83
90
|
}
|
|
84
91
|
|
|
@@ -242,13 +249,14 @@ export function recordHookTiming({
|
|
|
242
249
|
tool,
|
|
243
250
|
hook,
|
|
244
251
|
elapsedMs,
|
|
252
|
+
stageMs,
|
|
245
253
|
outcome,
|
|
246
254
|
toolUseId,
|
|
247
255
|
ts,
|
|
248
256
|
projectRoot,
|
|
249
257
|
maxBytes,
|
|
250
258
|
} = {}) {
|
|
251
|
-
const row = buildTimingRow({ ts, event, tool, toolUseId, hook, elapsedMs, outcome });
|
|
259
|
+
const row = buildTimingRow({ ts, event, tool, toolUseId, hook, elapsedMs, outcome, stageMs });
|
|
252
260
|
const root = projectRoot || process.env.CLAUDE_PROJECT_DIR || process.cwd();
|
|
253
261
|
return appendTelemetryRow(root, sessionId, row, { maxBytes });
|
|
254
262
|
}
|
|
@@ -256,6 +264,13 @@ export function recordHookTiming({
|
|
|
256
264
|
// --- `--finish` CLI: one short-lived node process per direct hook exit ------
|
|
257
265
|
// Invoked by ukit_hook_telemetry_finish (hook-telemetry.sh) from the shared
|
|
258
266
|
// cleanup path in hook-input.sh, while the staged payload file still exists.
|
|
267
|
+
//
|
|
268
|
+
// O1 (TASK-008): the same CLI serves hooks that deliberately never stage stdin
|
|
269
|
+
// (gate-first fast paths). Those have no payload mtime to measure from, so the
|
|
270
|
+
// arm helper hands over an explicit start plus the envelope's identifying
|
|
271
|
+
// fields: UKIT_TEL_START_MS (epoch ms), UKIT_TEL_SESSION_ID, UKIT_TEL_EVENT.
|
|
272
|
+
// Every override is optional and falsy-safe, so the 18 staging hooks that set
|
|
273
|
+
// none of them keep byte-for-byte today's behaviour.
|
|
259
274
|
|
|
260
275
|
const FINISH_PARSE_LIMIT = 2 * 1024 * 1024; // all hooks except record-execution stage <= 2 MiB
|
|
261
276
|
const FINISH_HEAD_BYTES = 256 * 1024; // bounded head scan for oversized payloads
|
|
@@ -318,32 +333,89 @@ function readEnvelope(inputFile) {
|
|
|
318
333
|
}
|
|
319
334
|
}
|
|
320
335
|
|
|
336
|
+
// Explicit arm start. Non-finite, zero, or negative values are ignored: a
|
|
337
|
+
// malformed marker must degrade to the staged-payload mtime (or to the honest
|
|
338
|
+
// null), never to a bogus "0ms" measurement.
|
|
339
|
+
function explicitStartMs(raw) {
|
|
340
|
+
const parsed = Number.parseInt(String(raw ?? ''), 10);
|
|
341
|
+
if (!Number.isFinite(parsed) || parsed <= 0) return null;
|
|
342
|
+
return parsed;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
// Start reference, most explicit first: the arm helper's epoch-ms reading, the
|
|
346
|
+
// arm marker's mtime (the shell could not express sub-second precision on this
|
|
347
|
+
// host), then the staged payload's mtime. None present stays null — an armed
|
|
348
|
+
// hook that lost its marker must not be reported as a zero-millisecond hook.
|
|
349
|
+
//
|
|
350
|
+
// O3 (SPEC §FR-009) needs the two ends SEPARATELY, not just their difference:
|
|
351
|
+
// when the payload's own mtime is the start, `elapsedMs` already begins where
|
|
352
|
+
// staging ended, so `source` is what tells the stage split that no window is
|
|
353
|
+
// left to measure.
|
|
354
|
+
function resolveStartRef() {
|
|
355
|
+
const explicit = explicitStartMs(process.env.UKIT_TEL_START_MS);
|
|
356
|
+
if (explicit !== null) return { startMs: explicit, source: 'explicit' };
|
|
357
|
+
const marker = process.env.UKIT_TEL_START_FILE || '';
|
|
358
|
+
if (marker) {
|
|
359
|
+
try {
|
|
360
|
+
return { startMs: fs.statSync(marker).mtimeMs, source: 'marker' };
|
|
361
|
+
} catch {
|
|
362
|
+
// Marker already reaped — fall through to the staged payload.
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
const inputFile = process.env.UKIT_INPUT_FILE || '';
|
|
366
|
+
if (!inputFile) return { startMs: null, source: null };
|
|
367
|
+
try {
|
|
368
|
+
return { startMs: fs.statSync(inputFile).mtimeMs, source: 'payload' };
|
|
369
|
+
} catch {
|
|
370
|
+
return { startMs: null, source: null }; // never staged (refuse path) — unknown, not zero
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
// O3 (SPEC §FR-009): the stage window = the staged payload's mtime minus the arm
|
|
375
|
+
// start, and only the two ARM sources qualify. A hook that deliberately skips
|
|
376
|
+
// stdin staging (gate-first fast paths, SPEC §14) has no payload at all, and an
|
|
377
|
+
// unarmed hook's start IS the payload mtime — reporting a stage from either
|
|
378
|
+
// would invent a window that never existed, so unmeasurable stays ABSENT.
|
|
379
|
+
// Clamped to the row's own elapsed window: the split attributes time inside that
|
|
380
|
+
// whole, it never exceeds it.
|
|
381
|
+
function resolveStageMs(startRef, elapsedMs) {
|
|
382
|
+
if (startRef.source !== 'explicit' && startRef.source !== 'marker') return null;
|
|
383
|
+
const inputFile = process.env.UKIT_INPUT_FILE || '';
|
|
384
|
+
if (!inputFile) return null;
|
|
385
|
+
let stagedAt;
|
|
386
|
+
try {
|
|
387
|
+
stagedAt = fs.statSync(inputFile).mtimeMs;
|
|
388
|
+
} catch {
|
|
389
|
+
return null;
|
|
390
|
+
}
|
|
391
|
+
const raw = stagedAt - startRef.startMs;
|
|
392
|
+
if (!Number.isFinite(raw) || raw < 0) return null;
|
|
393
|
+
return Math.min(raw, normalizeElapsedMs(elapsedMs) ?? raw);
|
|
394
|
+
}
|
|
395
|
+
|
|
321
396
|
function finishMain() {
|
|
322
397
|
try {
|
|
323
398
|
// process.uptime() covers this spawn itself so the row measures the hook,
|
|
324
399
|
// not the telemetry process startup.
|
|
325
400
|
const spawnOverheadMs = process.uptime() * 1000;
|
|
326
401
|
const inputFile = process.env.UKIT_INPUT_FILE || '';
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
} catch {
|
|
332
|
-
startMs = null; // envelope never staged (refuse path) — unknown, not zero
|
|
333
|
-
}
|
|
334
|
-
}
|
|
402
|
+
const startRef = resolveStartRef();
|
|
403
|
+
const elapsedMs = startRef.startMs === null
|
|
404
|
+
? null
|
|
405
|
+
: Date.now() - startRef.startMs - spawnOverheadMs;
|
|
335
406
|
const envelope = inputFile ? readEnvelope(inputFile) : {};
|
|
336
407
|
const rc = Number.parseInt(process.env.UKIT_TEL_RC ?? '', 10);
|
|
337
408
|
// The wrapper resolves PROJECT_ROOT before arming; fall back to the hook
|
|
338
409
|
// env's CLAUDE_PROJECT_DIR, then cwd (same resolution as the wrappers).
|
|
339
410
|
recordHookTiming({
|
|
340
411
|
projectRoot: process.env.PROJECT_ROOT || process.env.CLAUDE_PROJECT_DIR || undefined,
|
|
341
|
-
sessionId: envelope.session_id,
|
|
342
|
-
event: envelope.hook_event_name,
|
|
412
|
+
sessionId: process.env.UKIT_TEL_SESSION_ID || envelope.session_id,
|
|
413
|
+
event: process.env.UKIT_TEL_EVENT || envelope.hook_event_name,
|
|
343
414
|
tool: envelope.tool_name,
|
|
344
415
|
toolUseId: envelope.tool_use_id,
|
|
345
416
|
hook: process.env.UKIT_TEL_HOOK,
|
|
346
|
-
elapsedMs
|
|
417
|
+
elapsedMs,
|
|
418
|
+
stageMs: resolveStageMs(startRef, elapsedMs),
|
|
347
419
|
// Direct rows reuse the chain taxonomy (TASK-018): a non-zero exit is a
|
|
348
420
|
// verdict ('exit-code'), not a guess. Timeout/overflow kills bypass the
|
|
349
421
|
// EXIT trap, so those kinds stay chain-runner-reported.
|
|
@@ -11,12 +11,54 @@
|
|
|
11
11
|
# payload. Finish spawns at most one bounded node process, discards all output,
|
|
12
12
|
# and never changes the hook's exit status or verdict.
|
|
13
13
|
#
|
|
14
|
+
# O1 (TASK-008): hooks that never stage stdin (the gate-first fast paths wired
|
|
15
|
+
# in TASK-009) have no payload mtime to measure from. They call
|
|
16
|
+
# ukit_hook_telemetry_arm instead, which creates the same kind of start marker
|
|
17
|
+
# without adding a node spawn to the hot path — and trap
|
|
18
|
+
# ukit_hook_telemetry_finish on EXIT themselves.
|
|
19
|
+
#
|
|
14
20
|
# Missing runtime (pre-install tree) simply leaves telemetry off: wrappers
|
|
15
21
|
# source this file with `|| true` and the armed flag stays unset.
|
|
16
22
|
|
|
17
23
|
UKIT_TEL_ARMED=1
|
|
18
24
|
UKIT_TEL_HOOK="$(basename "${BASH_SOURCE[1]:-$0}")"
|
|
19
25
|
UKIT_TEL_RUNTIME_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
26
|
+
UKIT_TEL_START_FILE=""
|
|
27
|
+
UKIT_TEL_START_MS=""
|
|
28
|
+
|
|
29
|
+
# Epoch milliseconds from a file's mtime, or empty when this host's stat cannot
|
|
30
|
+
# express sub-second precision. GNU coreutils is probed first: it rejects the
|
|
31
|
+
# BSD format string outright, while BSD stat would read a GNU format string as
|
|
32
|
+
# a list of directives and print something plausible-but-wrong.
|
|
33
|
+
ukit_tel_file_ms() {
|
|
34
|
+
local raw seconds frac
|
|
35
|
+
raw="$(/usr/bin/stat -c '%.3Y' "$1" 2>/dev/null)" || raw=""
|
|
36
|
+
case "$raw" in
|
|
37
|
+
''|*[!0-9.]*) raw="$(/usr/bin/stat -f '%Fm' "$1" 2>/dev/null)" || raw="" ;;
|
|
38
|
+
esac
|
|
39
|
+
case "$raw" in
|
|
40
|
+
*.*) seconds="${raw%%.*}"; frac="${raw#*.}" ;;
|
|
41
|
+
*) seconds="$raw"; frac="" ;;
|
|
42
|
+
esac
|
|
43
|
+
# Unknown stays unknown: an unparseable mtime yields empty, never a bogus 0.
|
|
44
|
+
case "$seconds" in
|
|
45
|
+
''|*[!0-9]*) return 0 ;;
|
|
46
|
+
esac
|
|
47
|
+
frac="${frac}000"
|
|
48
|
+
printf '%s' "$(( seconds * 1000 + 10#${frac:0:3} ))"
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
# No-staging arm path. A marker file whose mtime is the start reference costs
|
|
52
|
+
# one mktemp; a `node -e` clock read would cost ~30ms to measure milliseconds.
|
|
53
|
+
# Always returns 0 — an unarmed hook must behave exactly like an unmeasured one.
|
|
54
|
+
ukit_hook_telemetry_arm() {
|
|
55
|
+
UKIT_TEL_START_FILE="$(mktemp "${TMPDIR:-/tmp}/ukit-tel-start.XXXXXX" 2>/dev/null)" || {
|
|
56
|
+
UKIT_TEL_START_FILE=""
|
|
57
|
+
return 0
|
|
58
|
+
}
|
|
59
|
+
UKIT_TEL_START_MS="$(ukit_tel_file_ms "$UKIT_TEL_START_FILE")"
|
|
60
|
+
return 0
|
|
61
|
+
}
|
|
20
62
|
|
|
21
63
|
ukit_hook_telemetry_finish() {
|
|
22
64
|
# The hook's exit status arrives via UKIT_TEL_RC (ukit_cleanup_hook_input
|
|
@@ -44,6 +86,8 @@ ukit_hook_telemetry_finish() {
|
|
|
44
86
|
|
|
45
87
|
UKIT_TEL_RC="$rc" \
|
|
46
88
|
UKIT_TEL_HOOK="$UKIT_TEL_HOOK" \
|
|
89
|
+
UKIT_TEL_START_MS="${UKIT_TEL_START_MS:-}" \
|
|
90
|
+
UKIT_TEL_START_FILE="${UKIT_TEL_START_FILE:-}" \
|
|
47
91
|
UKIT_INPUT_FILE="${UKIT_INPUT_FILE:-}" \
|
|
48
92
|
PROJECT_ROOT="${PROJECT_ROOT:-${CLAUDE_PROJECT_DIR:-}}" \
|
|
49
93
|
node "$UKIT_TEL_RUNTIME_DIR/hook-telemetry.mjs" --finish </dev/null >/dev/null 2>&1 &
|
|
@@ -56,5 +100,11 @@ ukit_hook_telemetry_finish() {
|
|
|
56
100
|
if wait "$child" 2>/dev/null; then :; fi
|
|
57
101
|
kill "$watchdog" 2>/dev/null || true
|
|
58
102
|
if wait "$watchdog" 2>/dev/null; then :; fi
|
|
103
|
+
# The marker existed only to be stat'd by the child above; dropping it here
|
|
104
|
+
# keeps a long session from accumulating one temp file per hooked event.
|
|
105
|
+
if [ -n "${UKIT_TEL_START_FILE:-}" ]; then
|
|
106
|
+
rm -f "$UKIT_TEL_START_FILE" 2>/dev/null || true
|
|
107
|
+
UKIT_TEL_START_FILE=""
|
|
108
|
+
fi
|
|
59
109
|
return 0
|
|
60
110
|
}
|
|
@@ -98,14 +98,15 @@ bash:
|
|
|
98
98
|
approval: deny
|
|
99
99
|
- match: "dd if=/dev/*"
|
|
100
100
|
approval: deny
|
|
101
|
-
#
|
|
102
|
-
# (
|
|
103
|
-
#
|
|
104
|
-
#
|
|
105
|
-
#
|
|
106
|
-
#
|
|
107
|
-
|
|
108
|
-
|
|
101
|
+
# Generic `rm -rf <dir>` is intentionally NOT denied here — the
|
|
102
|
+
# block-dangerous hook (running via TASK-004's bridge) is the authority:
|
|
103
|
+
# its safe-cleanup allowlist (dist/build/coverage/.next/.nuxt/.turbo/
|
|
104
|
+
# tmp/temp/.cache/node_modules, realpath-contained in the project root)
|
|
105
|
+
# passes routine cleanup, and every other `rm -rf` is gated fail-closed
|
|
106
|
+
# (ask → block on omp, deny on claude). A static `rm -rf *` deny used to
|
|
107
|
+
# sit here but outranked that allowlist, so allowlisted cleanup could
|
|
108
|
+
# never run. The hard rm denies above (/, ~, ., ..) stay — those are
|
|
109
|
+
# catastrophic regardless of allowlist.
|
|
109
110
|
- match: "*"
|
|
110
111
|
approval: allow
|
|
111
112
|
|