@ngockhoale/ukit 2.4.2 → 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.
Files changed (43) hide show
  1. package/manifests/platform.full.yaml +19 -111
  2. package/package.json +2 -1
  3. package/scripts/index/refresh-index.mjs +47 -22
  4. package/src/core/compact/threshold.js +36 -6
  5. package/src/diagnostics/classifyHang.js +246 -0
  6. package/src/index/buildIndex.js +1033 -62
  7. package/templates/.claude/hooks/auto-allow-bash.sh +82 -93
  8. package/templates/.claude/hooks/block-dangerous.sh +31 -5
  9. package/templates/.claude/hooks/completion-gate.sh +51 -10
  10. package/templates/.claude/hooks/compress-output.sh +38 -6
  11. package/templates/.claude/hooks/context-hardcap-gate.sh +35 -6
  12. package/templates/.claude/hooks/context-window-guard.sh +128 -18
  13. package/templates/.claude/hooks/handoff-model-guard.sh +31 -5
  14. package/templates/.claude/hooks/handoff-resume.sh +31 -5
  15. package/templates/.claude/hooks/post-edit-verify.sh +31 -5
  16. package/templates/.claude/hooks/pre-edit-backup.sh +31 -5
  17. package/templates/.claude/hooks/protect-files.sh +31 -5
  18. package/templates/.claude/hooks/record-execution.sh +31 -5
  19. package/templates/.claude/hooks/sensitive-data-guard.sh +44 -8
  20. package/templates/.claude/hooks/skill-router.sh +31 -5
  21. package/templates/.claude/hooks/stale-spec-guard.sh +31 -5
  22. package/templates/.claude/hooks/task-watchdog.sh +108 -123
  23. package/templates/.claude/hooks/verification-guard.sh +107 -112
  24. package/templates/.claude/hooks/vision-router.sh +49 -13
  25. package/templates/.claude/settings.json +0 -5
  26. package/templates/.claude/ukit/index/lib/index-core.mjs +960 -63
  27. package/templates/.claude/ukit/index/refresh-index.mjs +47 -22
  28. package/templates/.claude/ukit/index/route-task.mjs +610 -4
  29. package/templates/.claude/ukit/runtime/async-lock.mjs +340 -0
  30. package/templates/.claude/ukit/runtime/compact-threshold.mjs +73 -24
  31. package/templates/.claude/ukit/runtime/context-capacity.mjs +144 -0
  32. package/templates/.claude/ukit/runtime/execution-ledger.mjs +664 -170
  33. package/templates/.claude/ukit/runtime/hook-chain-budget.mjs +92 -0
  34. package/templates/.claude/ukit/runtime/hook-chain-runner.mjs +84 -29
  35. package/templates/.claude/ukit/runtime/hook-input.sh +85 -5
  36. package/templates/.claude/ukit/runtime/hook-payload-store.mjs +160 -0
  37. package/templates/.claude/ukit/runtime/hook-process.mjs +250 -0
  38. package/templates/.claude/ukit/runtime/hook-telemetry.mjs +255 -0
  39. package/templates/.claude/ukit/runtime/hook-telemetry.sh +60 -0
  40. package/templates/.claude/ukit/runtime/stop-coordinator.mjs +509 -0
  41. package/templates/.claude/ukit/runtime/task-watchdog.mjs +180 -6
  42. package/templates/.claude/ukit/runtime/transcript-tail.mjs +1 -1
  43. package/templates/.omp/hooks/pre/ukit-bridge.js +171 -57
@@ -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; the shell var is capped.
10
14
  ukit_stage_hook_input 2097152 truncate || exit 0
11
15
  else
12
- # Runtime helper missing (pre-install tree): bounded inline staging keeps the
13
- # wrapper's own fail-loud/advisory behavior below alive.
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
- head -c 2097153 > "$UKIT_INPUT_FILE" 2>/dev/null
16
- cat >/dev/null 2>&1
17
- if [ "$(wc -c < "$UKIT_INPUT_FILE" | tr -d '[:space:]')" -gt 2097152 ]; then
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
@@ -5,33 +5,35 @@
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) and either:
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
- # PostToolUse NEVER emits decision — that would block the in-flight Edit.
19
- # Stop is the only path that can hard-trip; PostToolUse stays advisory only.
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
30
  SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
33
31
  # shellcheck source=/dev/null
34
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
35
37
  trap ukit_cleanup_hook_input EXIT
36
38
  # Bounded stdin (H01): the 64 KiB cap is preserved, but the payload now travels
37
39
  # as a staged file path — never through the exec environment, so an oversized
@@ -40,12 +42,35 @@ if source "$SCRIPT_DIR/../ukit/runtime/hook-input.sh" 2>/dev/null; then
40
42
  # degrade exit 0 (documented posture).
41
43
  ukit_stage_hook_input 65536 truncate || exit 0
42
44
  else
43
- # Runtime helper missing (pre-install tree): bounded inline staging keeps the
44
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.
45
49
  UKIT_INPUT_FILE="$(mktemp "${TMPDIR:-/tmp}/ukit-hook-in.XXXXXX")"
46
- head -c 65537 > "$UKIT_INPUT_FILE" 2>/dev/null
47
- cat >/dev/null 2>&1
48
- if [ "$(wc -c < "$UKIT_INPUT_FILE" | tr -d '[:space:]')" -gt 65536 ]; then
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
49
74
  truncate -s 65536 "$UKIT_INPUT_FILE" 2>/dev/null
50
75
  fi
51
76
  trap '[ -n "$UKIT_INPUT_FILE" ] && rm -f "$UKIT_INPUT_FILE"' EXIT
@@ -56,7 +81,13 @@ INPUT_FILE="$UKIT_INPUT_FILE" PROJECT_ROOT="$PROJECT_ROOT" node <<'NODE' || true
56
81
  'use strict';
57
82
 
58
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');
59
89
  const path = require('path');
90
+ const { spawn } = require('child_process');
60
91
  const { pathToFileURL } = require('url');
61
92
 
62
93
  // Wall-clock watchdog. Anything that stalls here must end in clean exit 0 —
@@ -79,7 +110,6 @@ try { rawInput = fs.readFileSync(process.env.INPUT_FILE || '', 'utf8'); } catch
79
110
  })();
80
111
 
81
112
  const hookEvent = typeof payload.hook_event_name === 'string' ? payload.hook_event_name : '';
82
- const toolName = typeof payload.tool_name === 'string' ? payload.tool_name : '';
83
113
  const projectRoot = process.env.PROJECT_ROOT || process.cwd();
84
114
 
85
115
  // Silent degrade for non-relevant events — same posture as every other
@@ -88,6 +118,43 @@ try { rawInput = fs.readFileSync(process.env.INPUT_FILE || '', 'utf8'); } catch
88
118
  process.exit(0);
89
119
  }
90
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
+
91
158
  const runtimePath = path.join(projectRoot, '.claude', 'ukit', 'runtime', 'task-watchdog.mjs');
92
159
  // Async existence check — sync fs here would violate the all-async invariant
93
160
  // (a stalled mount would block the loop and the 3s self-kill could not fire).
@@ -121,112 +188,30 @@ try { rawInput = fs.readFileSync(process.env.INPUT_FILE || '', 'utf8'); } catch
121
188
  // PostToolUse: advisory only. NEVER emit decision — that would block the
122
189
  // in-flight Edit and stall the run. We still re-evaluate so the runtime
123
190
  // state stays warm and the next Stop sees an up-to-date view.
124
- if (hookEvent === 'PostToolUse') {
125
- const now = Date.now();
126
- const evals = runtime.evaluateBudgets({ tasks, state, now, config });
127
- const advisories = evals
128
- .filter((r) => r.phase !== 'ok')
129
- .map((r) => {
130
- const task = tasks.find((t) => t.id === r.id);
131
- if (!task) return null;
132
- if (r.phase === 'hard') {
133
- const blocks = Number(state.hardBlocks[r.id] || 0);
134
- if (blocks >= (runtime.HARD_BLOCK_CAP || 2)) {
135
- return runtime.describeDegraded({ id: r.id, hardBlocks: blocks });
136
- }
137
- return runtime.describeHardAdvisory(r, r.id);
138
- }
139
- return runtime.describeSoft(r, r.id);
140
- })
141
- .filter(Boolean);
142
-
143
- if (advisories.length > 0) {
144
- const message = advisories.join('\n');
145
- try {
146
- process.stdout.write(`${JSON.stringify({ systemMessage: message })}\n`);
147
- } catch {}
148
- }
149
- process.exit(0);
150
- }
151
-
152
- // Stop: full evaluation, may emit decision=block under split policy.
153
191
  const now = Date.now();
154
- for (const task of tasks) {
155
- try {
156
- await runtime.ensureFirstSeen(state, task.id, now);
157
- } catch {}
158
- }
159
-
160
192
  const evals = runtime.evaluateBudgets({ tasks, state, now, config });
161
-
162
- // First save firstSeen updates so the next Stop sees the same anchored clock.
163
- await runtime.writeState(statePath, state);
164
-
165
- const hardResults = evals.filter((r) => r.phase === 'hard');
166
- const softResults = evals.filter((r) => r.phase === 'soft');
167
-
168
- if (hardResults.length === 0) {
169
- // No hard trips. Emit a soft advisory if anything is in the soft zone,
170
- // then exit 0 cleanly. We deliberately do not emit decision here — the
171
- // completion gate owns Stop's continuation logic and we never want to
172
- // double-block for the same reason.
173
- if (softResults.length > 0) {
174
- const advisory = softResults
175
- .map((r) => runtime.describeSoft(r, r.id))
176
- .join('\n');
177
- try {
178
- process.stdout.write(`${JSON.stringify({ systemMessage: advisory })}\n`);
179
- } catch {}
180
- }
181
- process.exit(0);
182
- }
183
-
184
- // At least one hard-trip. Apply policy.
185
- const hardPolicy = String(config.taskBudgets?.hardPolicy || 'split');
186
- const firstHard = hardResults[0];
187
- const blocks = Number(state.hardBlocks[firstHard.id] || 0);
188
-
189
- if (hardPolicy !== 'split') {
190
- // pause (or anything non-split) → advisory only, never block.
191
- const advisory = hardResults
192
- .map((r) => runtime.describePause({ id: r.id, result: r }))
193
- .join('\n');
194
- try {
195
- process.stdout.write(`${JSON.stringify({ systemMessage: advisory })}\n`);
196
- } catch {}
197
- process.exit(0);
198
- }
199
-
200
- // split policy + hard-trip — but the per-task block cap has already been
201
- // hit. Degrade to advisory so the orchestrator can hand back to the user.
202
- if (blocks >= (runtime.HARD_BLOCK_CAP || 2)) {
203
- await runtime.bumpHardBlocks(state, firstHard.id);
204
- await runtime.writeState(statePath, state);
205
- 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');
206
211
  try {
207
- process.stdout.write(`${JSON.stringify({ systemMessage: advisory })}\n`);
212
+ process.stdout.write(`${JSON.stringify({ systemMessage: message })}\n`);
208
213
  } catch {}
209
- process.exit(0);
210
214
  }
211
-
212
- // Hard trip + split policy + under the cap → emit Stop decision=block
213
- // with the visible split instruction. This is the "auto-split and continue"
214
- // path: blocking the stop IS the continue mechanism — the executor / orchestrator
215
- // performs the actual TASK-<id>-b cut on the next turn following the reason.
216
- const newBlocks = await runtime.bumpHardBlocks(state, firstHard.id);
217
- await runtime.writeState(statePath, state);
218
-
219
- const task = tasks.find((t) => t.id === firstHard.id) || { id: firstHard.id };
220
- const lastGreen = runtime.pickLastGreen(task);
221
- const reason = runtime.describeSplitReason({
222
- id: firstHard.id,
223
- result: firstHard,
224
- hardBlocks: newBlocks,
225
- lastGreen,
226
- });
227
- try {
228
- process.stdout.write(`${JSON.stringify({ decision: 'block', reason })}\n`);
229
- } catch {}
230
215
  process.exit(0);
231
216
  })().catch(() => {
232
217
  // Fail-open: never let an exception kill the hook.
@@ -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 inline staging keeps the
13
- # advisory/fail-open behavior below alive with a capped payload file.
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
- head -c 2097153 > "$UKIT_INPUT_FILE" 2>/dev/null
16
- cat >/dev/null 2>&1
17
- if [ "$(wc -c < "$UKIT_INPUT_FILE" | tr -d '[:space:]')" -gt 2097152 ]; then
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
- const crypto = require('crypto');
31
- const fs = require('fs');
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.readFileSync(filePath, 'utf8'));
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.mkdirSync(path.dirname(filePath), { recursive: true });
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.writeFileSync(tempPath, JSON.stringify(value, null, 2) + '\n');
47
- fs.renameSync(tempPath, filePath);
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.rmSync(tempPath, { force: true });
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. setTimeout-based sleeps keep the loop live.
60
- function sleep(ms) {
61
- return new Promise((resolve) => setTimeout(resolve, ms));
62
- }
63
-
64
- function isPidAlive(pid) {
65
- if (!Number.isInteger(pid) || pid <= 0) return false;
66
- try {
67
- process.kill(pid, 0);
68
- return true;
69
- } catch (error) {
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.writeFileSync(path.join(lockPath, 'owner'), JSON.stringify({
98
- pid: process.pid,
99
- token: ownerToken,
100
- ts: Date.now(),
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
- held = true;
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
- fs.writeSync(1, `${message.replace(/^BLOCKED:/, 'ADVISORY:')}\n`);
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 so the lock acquire can be
359
- // awaited (async polling) before the policy checks; every path still ends in
360
- // exit 0 — this hook is advisory by design.
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
- let rawInput = '';
363
- try { rawInput = fs.readFileSync(process.env.INPUT_FILE || '', 'utf8'); } catch {}
364
- const payload = (() => {
365
- try {
366
- return JSON.parse(rawInput || '{}');
367
- } catch {
368
- return {};
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
- await withLock(`${progressPath}.lock`, async () => {
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
- fs.writeSync(2, `verification-guard: unexpected error (${err?.message ?? err})\n`);
536
+ process.stderr.write(`verification-guard: unexpected error (${err?.message ?? err})\n`);
542
537
  } catch {}
543
538
  process.exit(0);
544
539
  });