@ngockhoale/ukit 2.4.1 → 2.4.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/CHANGELOG.md +65 -0
  2. package/manifests/platform.full.yaml +19 -111
  3. package/package.json +2 -1
  4. package/scripts/index/refresh-index.mjs +48 -18
  5. package/src/cli/commands/doctor.js +59 -2
  6. package/src/core/compact/threshold.js +36 -6
  7. package/src/core/gatewayProbe.js +143 -15
  8. package/src/core/gatewayResilienceEnv.js +136 -7
  9. package/src/diagnostics/classifyHang.js +246 -0
  10. package/src/index/buildIndex.js +1096 -75
  11. package/templates/.claude/hooks/auto-allow-bash.sh +99 -87
  12. package/templates/.claude/hooks/auto-prune-bash.sh +4 -0
  13. package/templates/.claude/hooks/block-dangerous.sh +46 -1
  14. package/templates/.claude/hooks/completion-gate.sh +65 -7
  15. package/templates/.claude/hooks/compress-output.sh +49 -2
  16. package/templates/.claude/hooks/context-hardcap-gate.sh +52 -4
  17. package/templates/.claude/hooks/context-window-guard.sh +204 -71
  18. package/templates/.claude/hooks/handoff-model-guard.sh +50 -3
  19. package/templates/.claude/hooks/handoff-resume.sh +47 -3
  20. package/templates/.claude/hooks/post-edit-verify.sh +45 -2
  21. package/templates/.claude/hooks/pre-edit-backup.sh +45 -2
  22. package/templates/.claude/hooks/protect-files.sh +46 -1
  23. package/templates/.claude/hooks/record-execution.sh +46 -2
  24. package/templates/.claude/hooks/reinject-context.sh +1 -1
  25. package/templates/.claude/hooks/reset-compact-pressure.sh +4 -0
  26. package/templates/.claude/hooks/sensitive-data-guard.sh +101 -18
  27. package/templates/.claude/hooks/skill-router.sh +59 -5
  28. package/templates/.claude/hooks/stale-spec-guard.sh +47 -2
  29. package/templates/.claude/hooks/task-watchdog.sh +129 -126
  30. package/templates/.claude/hooks/verification-guard.sh +136 -106
  31. package/templates/.claude/hooks/vision-router.sh +138 -18
  32. package/templates/.claude/settings.json +0 -5
  33. package/templates/.claude/ukit/index/lib/index-core.mjs +1027 -68
  34. package/templates/.claude/ukit/index/post-edit-verify.mjs +8 -0
  35. package/templates/.claude/ukit/index/pre-edit-backup.mjs +8 -0
  36. package/templates/.claude/ukit/index/refresh-index.mjs +48 -18
  37. package/templates/.claude/ukit/index/route-task.mjs +610 -4
  38. package/templates/.claude/ukit/index/stale-spec-check.mjs +8 -0
  39. package/templates/.claude/ukit/runtime/async-lock.mjs +340 -0
  40. package/templates/.claude/ukit/runtime/compact-threshold.mjs +73 -24
  41. package/templates/.claude/ukit/runtime/context-capacity.mjs +144 -0
  42. package/templates/.claude/ukit/runtime/execution-ledger.mjs +672 -170
  43. package/templates/.claude/ukit/runtime/hook-chain-budget.mjs +92 -0
  44. package/templates/.claude/ukit/runtime/hook-chain-runner.mjs +84 -29
  45. package/templates/.claude/ukit/runtime/hook-input.mjs +120 -0
  46. package/templates/.claude/ukit/runtime/hook-input.sh +140 -0
  47. package/templates/.claude/ukit/runtime/hook-payload-store.mjs +160 -0
  48. package/templates/.claude/ukit/runtime/hook-process.mjs +250 -0
  49. package/templates/.claude/ukit/runtime/hook-telemetry.mjs +255 -0
  50. package/templates/.claude/ukit/runtime/hook-telemetry.sh +60 -0
  51. package/templates/.claude/ukit/runtime/output-compression.mjs +8 -0
  52. package/templates/.claude/ukit/runtime/reinject-context.mjs +8 -0
  53. package/templates/.claude/ukit/runtime/stop-coordinator.mjs +509 -0
  54. package/templates/.claude/ukit/runtime/task-watchdog.mjs +180 -6
  55. package/templates/.claude/ukit/runtime/transcript-tail.mjs +107 -0
  56. package/templates/.omp/hooks/pre/ukit-bridge.js +171 -57
@@ -2,13 +2,58 @@
2
2
  # PreToolUse hook: auto-add Bash(<binary>:*) allow rules for frequently used commands.
3
3
  # Settings/usage updates run as ONE locked, atomic read-modify-write (see node block).
4
4
 
5
- INPUT=$(cat)
5
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
6
+ # shellcheck source=/dev/null
7
+ if source "$SCRIPT_DIR/../ukit/runtime/hook-input.sh" 2>/dev/null; then
8
+ # TASK-019: arm advisory latency telemetry; the finish row is written by the
9
+ # shared cleanup path before the staged payload is removed. A missing
10
+ # runtime (pre-install tree) simply leaves telemetry off.
11
+ source "$SCRIPT_DIR/../ukit/runtime/hook-telemetry.sh" 2>/dev/null || true
12
+ trap ukit_cleanup_hook_input EXIT
13
+ # Bounded stdin (H01): stage before anything reads it; the shell var is capped.
14
+ ukit_stage_hook_input 2097152 truncate || exit 0
15
+ else
16
+ # Runtime helper missing (pre-install tree): the SAME bounded staging,
17
+ # inline - `cat >/dev/null` used to block forever on a producer that never
18
+ # closes the pipe (R4.5). Read cap+1 in the background, bound the wait.
19
+ UKIT_INPUT_FILE="$(mktemp "${TMPDIR:-/tmp}/ukit-hook-in.XXXXXX")"
20
+ if [ -e /dev/fd/0 ]; then
21
+ exec 8<&0
22
+ head -c 2097153 <&8 > "$UKIT_INPUT_FILE" 2>/dev/null &
23
+ else
24
+ head -c 2097153 > "$UKIT_INPUT_FILE" 2>/dev/null &
25
+ fi
26
+ __ukit_reader=$!
27
+ __ukit_stage_ms="${UKIT_HOOK_STAGE_MS:-2000}"
28
+ case "$__ukit_stage_ms" in
29
+ ''|*[!0-9]*) __ukit_stage_ms=2000 ;;
30
+ esac
31
+ [ "$__ukit_stage_ms" -gt 0 ] 2>/dev/null || __ukit_stage_ms=2000
32
+ printf -v __ukit_stage_s '%d.%03d' $((__ukit_stage_ms / 1000)) $((__ukit_stage_ms % 1000))
33
+ ( sleep "$__ukit_stage_s" 2>/dev/null; kill -9 "$__ukit_reader" 2>/dev/null ) <&- >/dev/null 2>&1 &
34
+ __ukit_waiter=$!
35
+ wait "$__ukit_reader" 2>/dev/null
36
+ __ukit_stage_rc=$?
37
+ kill "$__ukit_waiter" 2>/dev/null
38
+ wait "$__ukit_waiter" 2>/dev/null
39
+ exec 8<&-
40
+ __ukit_size="$(wc -c < "$UKIT_INPUT_FILE" 2>/dev/null | tr -d '[:space:]')"
41
+ # A bounded cut (rc != 0) leaves a short payload that the JSON consumer
42
+ # already degrades on - the shared helper's truncate posture (R4.5).
43
+ if [ "$__ukit_size" -gt 2097152 ]; then
44
+ truncate -s 2097152 "$UKIT_INPUT_FILE" 2>/dev/null
45
+ fi
46
+ trap '[ -n "$UKIT_INPUT_FILE" ] && rm -f "$UKIT_INPUT_FILE"' EXIT
47
+ fi
48
+ INPUT="$(cat "$UKIT_INPUT_FILE")"
6
49
  # jq is absent on stock macOS. Keep jq as the low-latency normal path, but fall back to
7
50
  # UKit's required Node runtime so safe commands still receive managed metadata.
8
51
  if command -v jq >/dev/null 2>&1 && jq --version >/dev/null 2>&1; then
9
52
  COMMAND=$(printf '%s' "$INPUT" | jq -r '.tool_input.command // empty')
10
53
  else
11
54
  COMMAND=$(printf '%s' "$INPUT" | node -e '
55
+ const HOOK_DEADLINE_MS = Number.parseInt(process.env.UKIT_HOOK_DEADLINE_MS || "", 10) || 3000;
56
+ setTimeout(() => process.exit(0), HOOK_DEADLINE_MS).unref();
12
57
  const chunks = [];
13
58
  process.stdin.on("data", (chunk) => chunks.push(chunk));
14
59
  process.stdin.on("end", () => {
@@ -67,20 +112,30 @@ MAX_RULES="${CLAUDE_AUTO_ALLOW_MAX_RULES:-150}"
67
112
  # stale snapshot and rewrite the whole file, silently dropping each other's allow rules
68
113
  # (verified: 30 concurrent invocations kept 26/30 rules and 23/30 usage records). A lost
69
114
  # rule means the NEXT invocation of that command prompts for permission again — the agent
70
- # stalls waiting for a human click. The lock protocol below is protocol-compatible with
71
- # runtime token-utils withFileLock / src/core/fileOps.js (mkdir-based `<file>.lock`),
72
- # 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.
73
119
  #
74
120
  # Why atomic writes: Claude Code re-reads settings.local.json to decide permissions.
75
121
  # A torn/partial write would fail JSON parsing and drop the ENTIRE allow list — every
76
122
  # command would prompt. tmp+rename makes readers see old-or-new, never half.
77
123
  #
78
- # Liveness over strictness: if the lock cannot be acquired within maxWaitMs the mutation
79
- # runs anyway (pre-lock behaviour) — this hook must never hang a PreToolUse chain.
80
- node -e '
81
- const crypto = require("crypto");
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.
133
+ const HOOK_DEADLINE_MS = Number.parseInt(process.env.UKIT_HOOK_DEADLINE_MS || "", 10) || 8000;
134
+ const LOCK_STARTED_AT = Date.now();
135
+ setTimeout(() => process.exit(0), HOOK_DEADLINE_MS).unref();
82
136
  const fs = require("fs");
83
137
  const path = require("path");
138
+ const { pathToFileURL } = require("url");
84
139
 
85
140
  const settingsPath = process.argv[1];
86
141
  const usagePath = process.argv[2];
@@ -116,84 +171,7 @@ function appendAudit(event, payload) {
116
171
  fs.appendFileSync(auditPath, JSON.stringify({ ts: nowUtc, event, ...payload }) + "\n");
117
172
  }
118
173
 
119
- function isPidAlive(pid) {
120
- if (!Number.isInteger(pid) || pid <= 0) return false;
121
- try {
122
- process.kill(pid, 0);
123
- return true;
124
- } catch (error) {
125
- return error?.code === "EPERM";
126
- }
127
- }
128
-
129
- function readLockOwner(lockPath) {
130
- const owner = readJson(path.join(lockPath, "owner"), null);
131
- return owner && Number.isInteger(owner.pid) && typeof owner.token === "string"
132
- ? owner
133
- : null;
134
- }
135
-
136
- async function withLock(lockPath, fn) {
137
- const staleMs = 10000;
138
- const maxWaitMs = 5000;
139
- const startedAt = Date.now();
140
- const ownerToken = `${process.pid}-${crypto.randomBytes(8).toString("hex")}`;
141
- let held = false;
142
- while (!held) {
143
- try {
144
- fs.mkdirSync(lockPath);
145
- try {
146
- fs.writeFileSync(path.join(lockPath, "owner"), JSON.stringify({
147
- pid: process.pid,
148
- token: ownerToken,
149
- ts: Date.now(),
150
- }));
151
- } catch (error) {
152
- try {
153
- fs.rmSync(lockPath, { recursive: true, force: true });
154
- } catch {}
155
- throw error;
156
- }
157
- held = true;
158
- } catch (error) {
159
- if (!error || error.code !== "EEXIST") return fn();
160
- try {
161
- const stat = fs.statSync(lockPath);
162
- if (Date.now() - stat.mtimeMs > staleMs) {
163
- const owner = readLockOwner(lockPath);
164
- if (!owner || !isPidAlive(owner.pid)) {
165
- try {
166
- fs.rmSync(lockPath, { recursive: true, force: true });
167
- continue;
168
- } catch {}
169
- }
170
- }
171
- } catch {}
172
- if (Date.now() - startedAt > maxWaitMs) break;
173
- await new Promise((resolve) => setTimeout(resolve, 3 + Math.floor(Math.random() * 9)));
174
- }
175
- }
176
- try {
177
- return await fn();
178
- } finally {
179
- if (held) {
180
- const owner = readLockOwner(lockPath);
181
- if (owner?.token === ownerToken) {
182
- try {
183
- fs.rmSync(lockPath, { recursive: true, force: true });
184
- } catch {}
185
- }
186
- }
187
- }
188
- }
189
-
190
- // The lock directory lives beside the usage file, so its parent must exist BEFORE the
191
- // lock is taken — a first-ever run in a fresh project would otherwise fail mkdir with
192
- // ENOENT, fail open, and race.
193
- fs.mkdirSync(path.dirname(settingsPath), { recursive: true });
194
- fs.mkdirSync(path.dirname(usagePath), { recursive: true });
195
-
196
- withLock(`${usagePath}.lock`, () => {
174
+ async function applyMutation() {
197
175
  const settings = readJson(settingsPath, {});
198
176
  if (!settings.permissions || typeof settings.permissions !== "object") {
199
177
  settings.permissions = {};
@@ -245,7 +223,41 @@ withLock(`${usagePath}.lock`, () => {
245
223
  writeJsonAtomic(settingsPath, settings);
246
224
  }
247
225
  writeJsonAtomic(usagePath, usage);
248
- }).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(() => {});
249
261
  ' "$SETTINGS_LOCAL" "$USAGE_FILE" "$AUDIT_FILE" "$RULE" "$NOW_UTC" "$MAX_RULES" >/dev/null 2>&1 || true
250
262
 
251
263
  exit 0
@@ -14,6 +14,10 @@ fi
14
14
  NOW_UTC=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
15
15
 
16
16
  node -e '
17
+ // Deadline must exceed the bounded lock wait in this block (maxWaitMs = 5000) so the
18
+ // watchdog can never fire while the mutation is still legally waiting for the lock.
19
+ const HOOK_DEADLINE_MS = Number.parseInt(process.env.UKIT_HOOK_DEADLINE_MS || "", 10) || 8000;
20
+ setTimeout(() => process.exit(0), HOOK_DEADLINE_MS).unref();
17
21
  const crypto = require("crypto");
18
22
  const fs = require("fs");
19
23
  const path = require("path");
@@ -2,13 +2,58 @@
2
2
  # PreToolUse hook: Block dangerous bash commands
3
3
  # Matched on: Bash
4
4
 
5
- INPUT=$(cat)
5
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
6
+ # shellcheck source=/dev/null
7
+ if source "$SCRIPT_DIR/../ukit/runtime/hook-input.sh" 2>/dev/null; then
8
+ # TASK-019: arm advisory latency telemetry; the finish row is written by the
9
+ # shared cleanup path before the staged payload is removed. A missing
10
+ # runtime (pre-install tree) simply leaves telemetry off.
11
+ source "$SCRIPT_DIR/../ukit/runtime/hook-telemetry.sh" 2>/dev/null || true
12
+ trap ukit_cleanup_hook_input EXIT
13
+ # Bounded stdin (H01): stage before anything reads it; the shell var is capped.
14
+ ukit_stage_hook_input 2097152 truncate || exit 0
15
+ else
16
+ # Runtime helper missing (pre-install tree): the SAME bounded staging,
17
+ # inline - `cat >/dev/null` used to block forever on a producer that never
18
+ # closes the pipe (R4.5). Read cap+1 in the background, bound the wait.
19
+ UKIT_INPUT_FILE="$(mktemp "${TMPDIR:-/tmp}/ukit-hook-in.XXXXXX")"
20
+ if [ -e /dev/fd/0 ]; then
21
+ exec 8<&0
22
+ head -c 2097153 <&8 > "$UKIT_INPUT_FILE" 2>/dev/null &
23
+ else
24
+ head -c 2097153 > "$UKIT_INPUT_FILE" 2>/dev/null &
25
+ fi
26
+ __ukit_reader=$!
27
+ __ukit_stage_ms="${UKIT_HOOK_STAGE_MS:-2000}"
28
+ case "$__ukit_stage_ms" in
29
+ ''|*[!0-9]*) __ukit_stage_ms=2000 ;;
30
+ esac
31
+ [ "$__ukit_stage_ms" -gt 0 ] 2>/dev/null || __ukit_stage_ms=2000
32
+ printf -v __ukit_stage_s '%d.%03d' $((__ukit_stage_ms / 1000)) $((__ukit_stage_ms % 1000))
33
+ ( sleep "$__ukit_stage_s" 2>/dev/null; kill -9 "$__ukit_reader" 2>/dev/null ) <&- >/dev/null 2>&1 &
34
+ __ukit_waiter=$!
35
+ wait "$__ukit_reader" 2>/dev/null
36
+ __ukit_stage_rc=$?
37
+ kill "$__ukit_waiter" 2>/dev/null
38
+ wait "$__ukit_waiter" 2>/dev/null
39
+ exec 8<&-
40
+ __ukit_size="$(wc -c < "$UKIT_INPUT_FILE" 2>/dev/null | tr -d '[:space:]')"
41
+ # A bounded cut (rc != 0) leaves a short payload that the JSON consumer
42
+ # already degrades on - the shared helper's truncate posture (R4.5).
43
+ if [ "$__ukit_size" -gt 2097152 ]; then
44
+ truncate -s 2097152 "$UKIT_INPUT_FILE" 2>/dev/null
45
+ fi
46
+ trap '[ -n "$UKIT_INPUT_FILE" ] && rm -f "$UKIT_INPUT_FILE"' EXIT
47
+ fi
48
+ INPUT="$(cat "$UKIT_INPUT_FILE")"
6
49
  # jq is not installed on stock macOS. Keep jq as the low-latency normal path, but use
7
50
  # UKit's required Node runtime as a fallback so its absence cannot silently disable the gate.
8
51
  if command -v jq >/dev/null 2>&1 && jq --version >/dev/null 2>&1; then
9
52
  COMMAND=$(printf '%s' "$INPUT" | jq -r '.tool_input.command // empty')
10
53
  else
11
54
  COMMAND=$(printf '%s' "$INPUT" | node -e '
55
+ const HOOK_DEADLINE_MS = Number.parseInt(process.env.UKIT_HOOK_DEADLINE_MS || "", 10) || 3000;
56
+ setTimeout(() => process.exit(0), HOOK_DEADLINE_MS).unref();
12
57
  const chunks = [];
13
58
  process.stdin.on("data", (chunk) => chunks.push(chunk));
14
59
  process.stdin.on("end", () => {
@@ -1,21 +1,79 @@
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
- INPUT=$(cat)
16
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
17
+ # shellcheck source=/dev/null
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
23
+ trap ukit_cleanup_hook_input EXIT
24
+ # Bounded stdin (H01): stage before anything reads it; the shell var is capped.
25
+ ukit_stage_hook_input 2097152 truncate || exit 0
26
+ else
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.
31
+ UKIT_INPUT_FILE="$(mktemp "${TMPDIR:-/tmp}/ukit-hook-in.XXXXXX")"
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
56
+ truncate -s 2097152 "$UKIT_INPUT_FILE" 2>/dev/null
57
+ fi
58
+ trap '[ -n "$UKIT_INPUT_FILE" ] && rm -f "$UKIT_INPUT_FILE"' EXIT
59
+ fi
60
+ INPUT="$(cat "$UKIT_INPUT_FILE")"
6
61
  PROJECT_ROOT="${CLAUDE_PROJECT_DIR:-$PWD}"
7
- SCRIPT="$PROJECT_ROOT/.claude/ukit/runtime/execution-ledger.mjs"
62
+ SCRIPT="$PROJECT_ROOT/.claude/ukit/runtime/stop-coordinator.mjs"
8
63
 
9
64
  if [ ! -f "$SCRIPT" ]; then
10
- 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"}'
11
66
  exit 0
12
67
  fi
13
68
 
14
- OUTPUT=$(printf '%s' "$INPUT" | UKIT_HARNESS=claude-code node "$SCRIPT" --evaluate-stop)
69
+ OUTPUT=$(printf '%s' "$INPUT" | UKIT_HARNESS=claude-code UKIT_HOOK_DEADLINE_MS="${UKIT_HOOK_DEADLINE_MS:-3000}" node "$SCRIPT" --evaluate-stop)
15
70
  STATUS=$?
16
71
 
17
72
  if [ "$STATUS" -ne 0 ]; then
18
- 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."}'
19
77
  exit 0
20
78
  fi
21
79
 
@@ -9,8 +9,55 @@ HOOK_DIR="$(cd "$(dirname "$0")" && pwd)"
9
9
  SCRIPT_PATH="$HOOK_DIR/../ukit/runtime/output-compression.mjs"
10
10
 
11
11
  if [ -f "$SCRIPT_PATH" ]; then
12
- INPUT="$(cat)"
13
- printf '%s' "$INPUT" | node "$SCRIPT_PATH"
12
+ # shellcheck source=/dev/null
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
18
+ trap ukit_cleanup_hook_input EXIT
19
+ # Bounded stdin (H01): compressed tool output can be large, so the staged
20
+ # file is the transport and the shell var only mirrors the capped payload.
21
+ ukit_stage_hook_input 33554432 temp-file || exit 0
22
+ else
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
56
+ trap '[ -n "$UKIT_INPUT_FILE" ] && rm -f "$UKIT_INPUT_FILE"' EXIT
57
+ fi
58
+ fi
59
+ INPUT="$(cat "$UKIT_INPUT_FILE")"
60
+ printf '%s' "$INPUT" | UKIT_HOOK_DEADLINE_MS="${UKIT_HOOK_DEADLINE_MS:-8000}" node "$SCRIPT_PATH"
14
61
  exit $?
15
62
  fi
16
63
 
@@ -29,18 +29,63 @@
29
29
  # Config toggle: compact.hardCapBlock (default true). Set to false only to debug this
30
30
  # gate itself; it must not become a normal escape hatch.
31
31
 
32
- INPUT=$(cat)
33
32
  PROJECT_ROOT="${CLAUDE_PROJECT_DIR:-$PWD}"
34
33
  HOOK_DIR="$(cd "$(dirname "$0")" && pwd)"
34
+ # shellcheck source=/dev/null
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
40
+ trap ukit_cleanup_hook_input EXIT
41
+ # Bounded stdin (H01): stage before anything reads it; Node gets a path, never the payload.
42
+ ukit_stage_hook_input 2097152 truncate || exit 0
43
+ else
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.
47
+ UKIT_INPUT_FILE="$(mktemp "${TMPDIR:-/tmp}/ukit-hook-in.XXXXXX")"
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
72
+ truncate -s 2097152 "$UKIT_INPUT_FILE" 2>/dev/null
73
+ fi
74
+ trap '[ -n "$UKIT_INPUT_FILE" ] && rm -f "$UKIT_INPUT_FILE"' EXIT
75
+ fi
35
76
 
36
- INPUT="$INPUT" PROJECT_ROOT="$PROJECT_ROOT" HOOK_DIR="$HOOK_DIR" node <<'NODE'
77
+ INPUT_FILE="$UKIT_INPUT_FILE" PROJECT_ROOT="$PROJECT_ROOT" HOOK_DIR="$HOOK_DIR" node <<'NODE'
78
+ const HOOK_DEADLINE_MS = Number.parseInt(process.env.UKIT_HOOK_DEADLINE_MS || '', 10) || 3000;
79
+ setTimeout(() => process.exit(0), HOOK_DEADLINE_MS).unref();
37
80
  const fs = require('fs');
38
81
  const path = require('path');
39
82
  const { pathToFileURL } = require('url');
40
83
 
84
+ let rawInput = '';
85
+ try { rawInput = fs.readFileSync(process.env.INPUT_FILE || '', 'utf8'); } catch {}
41
86
  const payload = (() => {
42
87
  try {
43
- const parsed = JSON.parse(process.env.INPUT || '');
88
+ const parsed = JSON.parse(rawInput);
44
89
  return parsed && typeof parsed === 'object' ? parsed : {};
45
90
  } catch {
46
91
  return {};
@@ -136,7 +181,10 @@ function readRunCursor() {
136
181
  } catch {
137
182
  state = mod.buildCompactPressureState(rawState, sessionConfig);
138
183
  }
139
- 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 });
140
188
 
141
189
  // The grace budget must survive parallel subagents hitting the cap in the same episode.
142
190
  // A read-modify-write of one JSON file under-counts (two processes both read used=3 and