@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
@@ -86,24 +112,30 @@ MAX_RULES="${CLAUDE_AUTO_ALLOW_MAX_RULES:-150}"
86
112
  # stale snapshot and rewrite the whole file, silently dropping each other's allow rules
87
113
  # (verified: 30 concurrent invocations kept 26/30 rules and 23/30 usage records). A lost
88
114
  # rule means the NEXT invocation of that command prompts for permission again — the agent
89
- # stalls waiting for a human click. The lock protocol below is protocol-compatible with
90
- # runtime token-utils withFileLock / src/core/fileOps.js (mkdir-based `<file>.lock`),
91
- # with the usage file as the single serialization point for both files this hook writes.
115
+ # stalls waiting for a human click. The lock is the shared runtime async-lock utility
116
+ # (TASK-028): protocol-compatible with runtime token-utils withFileLock /
117
+ # src/core/fileOps.js (mkdir-based `<file>.lock` + owner file), with the usage file as
118
+ # the single serialization point for both files this hook writes.
92
119
  #
93
120
  # Why atomic writes: Claude Code re-reads settings.local.json to decide permissions.
94
121
  # A torn/partial write would fail JSON parsing and drop the ENTIRE allow list — every
95
122
  # command would prompt. tmp+rename makes readers see old-or-new, never half.
96
123
  #
97
- # Liveness over strictness: if the lock cannot be acquired within maxWaitMs the mutation
98
- # runs anyway (pre-lock behaviour) — this hook must never hang a PreToolUse chain.
99
- node -e '
100
- // Deadline must exceed the bounded lock wait in this block (maxWaitMs = 5000) so the
101
- // watchdog can never fire while the mutation is still legally waiting for the lock.
124
+ # TASK-028 fail-closed policy: the acquisition budget derives from the remaining hook
125
+ # deadline minus a cleanup reserve, capped at a short slice, so a contended lock can
126
+ # never hold this hook near its own deadline. On busy/abort the mutation is SKIPPED —
127
+ # sensitive permission state is never written unlocked, and the command simply falls
128
+ # back to the normal permission prompt (the pre-UKit behaviour, always safe).
129
+ UKIT_RUNTIME_DIR="$SCRIPT_DIR/../ukit/runtime" node -e '
130
+ // Deadline must exceed the derived lock budget in this block (remaining deadline minus
131
+ // reserve, capped at a short slice) so the watchdog can never fire while the mutation
132
+ // is still legally waiting for the lock.
102
133
  const HOOK_DEADLINE_MS = Number.parseInt(process.env.UKIT_HOOK_DEADLINE_MS || "", 10) || 8000;
134
+ const LOCK_STARTED_AT = Date.now();
103
135
  setTimeout(() => process.exit(0), HOOK_DEADLINE_MS).unref();
104
- const crypto = require("crypto");
105
136
  const fs = require("fs");
106
137
  const path = require("path");
138
+ const { pathToFileURL } = require("url");
107
139
 
108
140
  const settingsPath = process.argv[1];
109
141
  const usagePath = process.argv[2];
@@ -139,84 +171,7 @@ function appendAudit(event, payload) {
139
171
  fs.appendFileSync(auditPath, JSON.stringify({ ts: nowUtc, event, ...payload }) + "\n");
140
172
  }
141
173
 
142
- function isPidAlive(pid) {
143
- if (!Number.isInteger(pid) || pid <= 0) return false;
144
- try {
145
- process.kill(pid, 0);
146
- return true;
147
- } catch (error) {
148
- return error?.code === "EPERM";
149
- }
150
- }
151
-
152
- function readLockOwner(lockPath) {
153
- const owner = readJson(path.join(lockPath, "owner"), null);
154
- return owner && Number.isInteger(owner.pid) && typeof owner.token === "string"
155
- ? owner
156
- : null;
157
- }
158
-
159
- async function withLock(lockPath, fn) {
160
- const staleMs = 10000;
161
- const maxWaitMs = 5000;
162
- const startedAt = Date.now();
163
- const ownerToken = `${process.pid}-${crypto.randomBytes(8).toString("hex")}`;
164
- let held = false;
165
- while (!held) {
166
- try {
167
- fs.mkdirSync(lockPath);
168
- try {
169
- fs.writeFileSync(path.join(lockPath, "owner"), JSON.stringify({
170
- pid: process.pid,
171
- token: ownerToken,
172
- ts: Date.now(),
173
- }));
174
- } catch (error) {
175
- try {
176
- fs.rmSync(lockPath, { recursive: true, force: true });
177
- } catch {}
178
- throw error;
179
- }
180
- held = true;
181
- } catch (error) {
182
- if (!error || error.code !== "EEXIST") return fn();
183
- try {
184
- const stat = fs.statSync(lockPath);
185
- if (Date.now() - stat.mtimeMs > staleMs) {
186
- const owner = readLockOwner(lockPath);
187
- if (!owner || !isPidAlive(owner.pid)) {
188
- try {
189
- fs.rmSync(lockPath, { recursive: true, force: true });
190
- continue;
191
- } catch {}
192
- }
193
- }
194
- } catch {}
195
- if (Date.now() - startedAt > maxWaitMs) break;
196
- await new Promise((resolve) => setTimeout(resolve, 3 + Math.floor(Math.random() * 9)));
197
- }
198
- }
199
- try {
200
- return await fn();
201
- } finally {
202
- if (held) {
203
- const owner = readLockOwner(lockPath);
204
- if (owner?.token === ownerToken) {
205
- try {
206
- fs.rmSync(lockPath, { recursive: true, force: true });
207
- } catch {}
208
- }
209
- }
210
- }
211
- }
212
-
213
- // The lock directory lives beside the usage file, so its parent must exist BEFORE the
214
- // lock is taken — a first-ever run in a fresh project would otherwise fail mkdir with
215
- // ENOENT, fail open, and race.
216
- fs.mkdirSync(path.dirname(settingsPath), { recursive: true });
217
- fs.mkdirSync(path.dirname(usagePath), { recursive: true });
218
-
219
- withLock(`${usagePath}.lock`, () => {
174
+ async function applyMutation() {
220
175
  const settings = readJson(settingsPath, {});
221
176
  if (!settings.permissions || typeof settings.permissions !== "object") {
222
177
  settings.permissions = {};
@@ -268,7 +223,41 @@ withLock(`${usagePath}.lock`, () => {
268
223
  writeJsonAtomic(settingsPath, settings);
269
224
  }
270
225
  writeJsonAtomic(usagePath, usage);
271
- }).catch(() => {});
226
+ }
227
+
228
+ (async () => {
229
+ // The lock directory lives beside the usage file, so its parent must exist BEFORE
230
+ // the lock is taken — a first-ever run in a fresh project would otherwise fail
231
+ // mkdir with ENOENT.
232
+ fs.mkdirSync(path.dirname(settingsPath), { recursive: true });
233
+ fs.mkdirSync(path.dirname(usagePath), { recursive: true });
234
+
235
+ const runtimeDir = process.env.UKIT_RUNTIME_DIR || "";
236
+ const lockModulePath = runtimeDir ? path.join(runtimeDir, "async-lock.mjs") : "";
237
+ let lockModule = null;
238
+ if (lockModulePath && fs.existsSync(lockModulePath)) {
239
+ try {
240
+ lockModule = await import(pathToFileURL(lockModulePath).href);
241
+ } catch {
242
+ lockModule = null;
243
+ }
244
+ }
245
+ if (!lockModule || typeof lockModule.withAsyncLock !== "function") {
246
+ // Degraded install (runtime module missing): fail closed. Writing permission
247
+ // state unlocked silently dropped allow rules under concurrency; skipping only
248
+ // costs the normal permission prompt for this one command.
249
+ return;
250
+ }
251
+ const deadlineMs = lockModule.lockBudgetMs({
252
+ hookDeadlineMs: HOOK_DEADLINE_MS,
253
+ startedAt: LOCK_STARTED_AT,
254
+ });
255
+ const outcome = await lockModule.withAsyncLock(usagePath, { deadlineMs }, applyMutation);
256
+ if (!outcome.ok) {
257
+ // busy or aborted: fail closed — write nothing unlocked, exit 0.
258
+ return;
259
+ }
260
+ })().catch(() => {});
272
261
  ' "$SETTINGS_LOCAL" "$USAGE_FILE" "$AUDIT_FILE" "$RULE" "$NOW_UTC" "$MAX_RULES" >/dev/null 2>&1 || true
273
262
 
274
263
  exit 0
@@ -5,16 +5,42 @@
5
5
  SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
6
6
  # shellcheck source=/dev/null
7
7
  if source "$SCRIPT_DIR/../ukit/runtime/hook-input.sh" 2>/dev/null; then
8
+ # TASK-019: arm advisory latency telemetry; the finish row is written by the
9
+ # shared cleanup path before the staged payload is removed. A missing
10
+ # runtime (pre-install tree) simply leaves telemetry off.
11
+ source "$SCRIPT_DIR/../ukit/runtime/hook-telemetry.sh" 2>/dev/null || true
8
12
  trap ukit_cleanup_hook_input EXIT
9
13
  # Bounded stdin (H01): stage before anything reads it; 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
@@ -1,30 +1,68 @@
1
1
  #!/bin/bash
2
- # Stop hook: block premature terminal stops while routed completion evidence is missing.
3
- # ADVISORY ONLY — always exit 0. A missing or failing runtime must be loud, not a silent pass.
2
+ # Stop hook: the ONE Stop delegate (TASK-025). Blocks premature terminal stops while
3
+ # routed completion evidence is missing, and — via stop-coordinator.mjs — merges the
4
+ # task watchdog's budget policy into the same single decision (PLAN §2 H17: both hooks
5
+ # used to block Stop independently).
6
+ #
7
+ # This script is a thin delegate: it stages the hook payload, hands it to
8
+ # .claude/ukit/runtime/stop-coordinator.mjs once, and passes the single decision
9
+ # through. Failure posture:
10
+ # - coordinator script missing (pre-install tree) → loud advisory release ("ukit install")
11
+ # - coordinator crashed (non-zero exit) → fail-closed BLOCK with a fixed,
12
+ # redacted infrastructure reason;
13
+ # a crashed gate must not silently
14
+ # release the stop.
4
15
 
5
16
  SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
6
17
  # shellcheck source=/dev/null
7
18
  if source "$SCRIPT_DIR/../ukit/runtime/hook-input.sh" 2>/dev/null; then
19
+ # TASK-019: arm advisory latency telemetry; the finish row is written by the
20
+ # shared cleanup path before the staged payload is removed. A missing
21
+ # runtime (pre-install tree) simply leaves telemetry off.
22
+ source "$SCRIPT_DIR/../ukit/runtime/hook-telemetry.sh" 2>/dev/null || true
8
23
  trap ukit_cleanup_hook_input EXIT
9
24
  # Bounded stdin (H01): stage before anything reads it; the shell var is capped.
10
25
  ukit_stage_hook_input 2097152 truncate || exit 0
11
26
  else
12
- # Runtime helper missing (pre-install tree): bounded inline staging keeps the
13
- # wrapper's own fail-loud/advisory behavior below alive.
27
+ # wrapper's own fail-loud behavior below alive.
28
+ # Runtime helper missing (pre-install tree): the SAME bounded staging,
29
+ # inline - `cat >/dev/null` used to block forever on a producer that never
30
+ # closes the pipe (R4.5). Read cap+1 in the background, bound the wait.
14
31
  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
32
+ if [ -e /dev/fd/0 ]; then
33
+ exec 8<&0
34
+ head -c 2097153 <&8 > "$UKIT_INPUT_FILE" 2>/dev/null &
35
+ else
36
+ head -c 2097153 > "$UKIT_INPUT_FILE" 2>/dev/null &
37
+ fi
38
+ __ukit_reader=$!
39
+ __ukit_stage_ms="${UKIT_HOOK_STAGE_MS:-2000}"
40
+ case "$__ukit_stage_ms" in
41
+ ''|*[!0-9]*) __ukit_stage_ms=2000 ;;
42
+ esac
43
+ [ "$__ukit_stage_ms" -gt 0 ] 2>/dev/null || __ukit_stage_ms=2000
44
+ printf -v __ukit_stage_s '%d.%03d' $((__ukit_stage_ms / 1000)) $((__ukit_stage_ms % 1000))
45
+ ( sleep "$__ukit_stage_s" 2>/dev/null; kill -9 "$__ukit_reader" 2>/dev/null ) <&- >/dev/null 2>&1 &
46
+ __ukit_waiter=$!
47
+ wait "$__ukit_reader" 2>/dev/null
48
+ __ukit_stage_rc=$?
49
+ kill "$__ukit_waiter" 2>/dev/null
50
+ wait "$__ukit_waiter" 2>/dev/null
51
+ exec 8<&-
52
+ __ukit_size="$(wc -c < "$UKIT_INPUT_FILE" 2>/dev/null | tr -d '[:space:]')"
53
+ # A bounded cut (rc != 0) leaves a short payload that the JSON consumer
54
+ # already degrades on - the shared helper's truncate posture (R4.5).
55
+ if [ "$__ukit_size" -gt 2097152 ]; then
18
56
  truncate -s 2097152 "$UKIT_INPUT_FILE" 2>/dev/null
19
57
  fi
20
58
  trap '[ -n "$UKIT_INPUT_FILE" ] && rm -f "$UKIT_INPUT_FILE"' EXIT
21
59
  fi
22
60
  INPUT="$(cat "$UKIT_INPUT_FILE")"
23
61
  PROJECT_ROOT="${CLAUDE_PROJECT_DIR:-$PWD}"
24
- SCRIPT="$PROJECT_ROOT/.claude/ukit/runtime/execution-ledger.mjs"
62
+ SCRIPT="$PROJECT_ROOT/.claude/ukit/runtime/stop-coordinator.mjs"
25
63
 
26
64
  if [ ! -f "$SCRIPT" ]; then
27
- printf '%s\n' '{"systemMessage":"UKit completion gate: runtime script missing — run: ukit install"}'
65
+ printf '%s\n' '{"systemMessage":"UKit stop coordinator: runtime script missing — run: ukit install"}'
28
66
  exit 0
29
67
  fi
30
68
 
@@ -32,7 +70,10 @@ OUTPUT=$(printf '%s' "$INPUT" | UKIT_HARNESS=claude-code UKIT_HOOK_DEADLINE_MS="
32
70
  STATUS=$?
33
71
 
34
72
  if [ "$STATUS" -ne 0 ]; then
35
- printf '%s\n' '{"systemMessage":"UKit completion gate: runtime script failed — run: ukit install"}'
73
+ # TASK-025: the Stop coordinator is the fail-closed blocker — a crash must not
74
+ # release the stop. The reason is a fixed redacted string: crash detail (already
75
+ # written to stderr by the coordinator) never leaks into the decision.
76
+ printf '%s\n' '{"decision":"block","reason":"UKit stop coordinator: infrastructure failure while evaluating this stop — the stop is blocked fail-closed (details withheld). Run: ukit install, then re-send the task in a new message."}'
36
77
  exit 0
37
78
  fi
38
79
 
@@ -11,19 +11,51 @@ SCRIPT_PATH="$HOOK_DIR/../ukit/runtime/output-compression.mjs"
11
11
  if [ -f "$SCRIPT_PATH" ]; then
12
12
  # shellcheck source=/dev/null
13
13
  if source "$HOOK_DIR/../ukit/runtime/hook-input.sh" 2>/dev/null; then
14
+ # TASK-019: arm advisory latency telemetry; the finish row is written by the
15
+ # shared cleanup path before the staged payload is removed. A missing
16
+ # runtime (pre-install tree) simply leaves telemetry off.
17
+ source "$HOOK_DIR/../ukit/runtime/hook-telemetry.sh" 2>/dev/null || true
14
18
  trap ukit_cleanup_hook_input EXIT
15
19
  # Bounded stdin (H01): compressed tool output can be large, so the staged
16
20
  # file is the transport and the shell var only mirrors the capped payload.
17
21
  ukit_stage_hook_input 33554432 temp-file || exit 0
18
22
  else
19
- # Runtime helper missing (pre-install tree): bounded inline staging keeps the
20
- # wrapper's own fail-loud/advisory behavior below alive.
21
- UKIT_INPUT_FILE="$(mktemp "${TMPDIR:-/tmp}/ukit-hook-in.XXXXXX")"
22
- head -c 33554433 > "$UKIT_INPUT_FILE" 2>/dev/null
23
- cat >/dev/null 2>&1
24
- truncate -s 33554432 "$UKIT_INPUT_FILE" 2>/dev/null
23
+ # Runtime helper missing (pre-install tree): the SAME bounded staging,
24
+ # inline — `cat >/dev/null` used to block forever on a producer that never
25
+ # closes the pipe (R4.5). Read cap+1 in the background, bound the wait.
26
+ UKIT_INPUT_FILE="$(mktemp "${TMPDIR:-/tmp}/ukit-hook-in.XXXXXX")"
27
+ if [ -e /dev/fd/0 ]; then
28
+ exec 8<&0
29
+ head -c 33554433 <&8 > "$UKIT_INPUT_FILE" 2>/dev/null &
30
+ else
31
+ head -c 33554433 > "$UKIT_INPUT_FILE" 2>/dev/null &
32
+ fi
33
+ __ukit_reader=$!
34
+ __ukit_stage_ms="${UKIT_HOOK_STAGE_MS:-2000}"
35
+ case "$__ukit_stage_ms" in
36
+ ''|*[!0-9]*) __ukit_stage_ms=2000 ;;
37
+ esac
38
+ [ "$__ukit_stage_ms" -gt 0 ] 2>/dev/null || __ukit_stage_ms=2000
39
+ printf -v __ukit_stage_s '%d.%03d' $((__ukit_stage_ms / 1000)) $((__ukit_stage_ms % 1000))
40
+ ( sleep "$__ukit_stage_s" 2>/dev/null; kill -9 "$__ukit_reader" 2>/dev/null ) <&- >/dev/null 2>&1 &
41
+ __ukit_waiter=$!
42
+ wait "$__ukit_reader" 2>/dev/null
43
+ __ukit_stage_rc=$?
44
+ kill "$__ukit_waiter" 2>/dev/null
45
+ wait "$__ukit_waiter" 2>/dev/null
46
+ exec 8<&-
47
+ __ukit_size="$(wc -c < "$UKIT_INPUT_FILE" 2>/dev/null | tr -d '[:space:]')"
48
+ if [ "$__ukit_stage_rc" -ne 0 ]; then
49
+ # Unstaged: the hook cannot inspect this payload.
50
+ rm -f "$UKIT_INPUT_FILE"
51
+ UKIT_INPUT_FILE=""
52
+ else
53
+ if [ "$__ukit_size" -gt 33554432 ]; then
54
+ truncate -s 33554432 "$UKIT_INPUT_FILE" 2>/dev/null
55
+ fi
25
56
  trap '[ -n "$UKIT_INPUT_FILE" ] && rm -f "$UKIT_INPUT_FILE"' EXIT
26
57
  fi
58
+ fi
27
59
  INPUT="$(cat "$UKIT_INPUT_FILE")"
28
60
  printf '%s' "$INPUT" | UKIT_HOOK_DEADLINE_MS="${UKIT_HOOK_DEADLINE_MS:-8000}" node "$SCRIPT_PATH"
29
61
  exit $?
@@ -33,16 +33,42 @@ PROJECT_ROOT="${CLAUDE_PROJECT_DIR:-$PWD}"
33
33
  HOOK_DIR="$(cd "$(dirname "$0")" && pwd)"
34
34
  # shellcheck source=/dev/null
35
35
  if source "$HOOK_DIR/../ukit/runtime/hook-input.sh" 2>/dev/null; then
36
+ # TASK-019: arm advisory latency telemetry; the finish row is written by the
37
+ # shared cleanup path before the staged payload is removed. A missing
38
+ # runtime (pre-install tree) simply leaves telemetry off.
39
+ source "$HOOK_DIR/../ukit/runtime/hook-telemetry.sh" 2>/dev/null || true
36
40
  trap ukit_cleanup_hook_input EXIT
37
41
  # Bounded stdin (H01): stage before anything reads it; Node gets a path, never the payload.
38
42
  ukit_stage_hook_input 2097152 truncate || exit 0
39
43
  else
40
- # Runtime helper missing (pre-install tree): bounded inline staging keeps the
41
- # advisory/fail-open behavior below alive with a capped payload file.
44
+ # Runtime helper missing (pre-install tree): the SAME bounded staging,
45
+ # inline - `cat >/dev/null` used to block forever on a producer that never
46
+ # closes the pipe (R4.5). Read cap+1 in the background, bound the wait.
42
47
  UKIT_INPUT_FILE="$(mktemp "${TMPDIR:-/tmp}/ukit-hook-in.XXXXXX")"
43
- head -c 2097153 > "$UKIT_INPUT_FILE" 2>/dev/null
44
- cat >/dev/null 2>&1
45
- if [ "$(wc -c < "$UKIT_INPUT_FILE" | tr -d '[:space:]')" -gt 2097152 ]; then
48
+ if [ -e /dev/fd/0 ]; then
49
+ exec 8<&0
50
+ head -c 2097153 <&8 > "$UKIT_INPUT_FILE" 2>/dev/null &
51
+ else
52
+ head -c 2097153 > "$UKIT_INPUT_FILE" 2>/dev/null &
53
+ fi
54
+ __ukit_reader=$!
55
+ __ukit_stage_ms="${UKIT_HOOK_STAGE_MS:-2000}"
56
+ case "$__ukit_stage_ms" in
57
+ ''|*[!0-9]*) __ukit_stage_ms=2000 ;;
58
+ esac
59
+ [ "$__ukit_stage_ms" -gt 0 ] 2>/dev/null || __ukit_stage_ms=2000
60
+ printf -v __ukit_stage_s '%d.%03d' $((__ukit_stage_ms / 1000)) $((__ukit_stage_ms % 1000))
61
+ ( sleep "$__ukit_stage_s" 2>/dev/null; kill -9 "$__ukit_reader" 2>/dev/null ) <&- >/dev/null 2>&1 &
62
+ __ukit_waiter=$!
63
+ wait "$__ukit_reader" 2>/dev/null
64
+ __ukit_stage_rc=$?
65
+ kill "$__ukit_waiter" 2>/dev/null
66
+ wait "$__ukit_waiter" 2>/dev/null
67
+ exec 8<&-
68
+ __ukit_size="$(wc -c < "$UKIT_INPUT_FILE" 2>/dev/null | tr -d '[:space:]')"
69
+ # A bounded cut (rc != 0) leaves a short payload that the JSON consumer
70
+ # already degrades on - the shared helper's truncate posture (R4.5).
71
+ if [ "$__ukit_size" -gt 2097152 ]; then
46
72
  truncate -s 2097152 "$UKIT_INPUT_FILE" 2>/dev/null
47
73
  fi
48
74
  trap '[ -n "$UKIT_INPUT_FILE" ] && rm -f "$UKIT_INPUT_FILE"' EXIT
@@ -155,7 +181,10 @@ function readRunCursor() {
155
181
  } catch {
156
182
  state = mod.buildCompactPressureState(rawState, sessionConfig);
157
183
  }
158
- const thresholds = mod.buildCompactThresholds(config);
184
+ // projectRoot is passed explicitly so the cap is negotiated from THIS project's
185
+ // published capacity record rather than from whatever cwd the host happened to use —
186
+ // the gate is fail-closed, so it must read the same evidence the guard wrote.
187
+ const thresholds = mod.buildCompactThresholds({ ...config, projectRoot });
159
188
 
160
189
  // The grace budget must survive parallel subagents hitting the cap in the same episode.
161
190
  // A read-modify-write of one JSON file under-counts (two processes both read used=3 and