@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
@@ -22,26 +22,80 @@
22
22
  # loud early warning is the thing that actually helps: it arrives while compacting or
23
23
  # starting a fresh session still works.
24
24
 
25
- INPUT=$(cat)
25
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
26
+ # shellcheck source=/dev/null
27
+ if source "$SCRIPT_DIR/../ukit/runtime/hook-input.sh" 2>/dev/null; then
28
+ # TASK-019: arm advisory latency telemetry; the finish row is written by the
29
+ # shared cleanup path before the staged payload is removed. A missing
30
+ # runtime (pre-install tree) simply leaves telemetry off.
31
+ source "$SCRIPT_DIR/../ukit/runtime/hook-telemetry.sh" 2>/dev/null || true
32
+ trap ukit_cleanup_hook_input EXIT
33
+ # Bounded stdin (H01): stage before anything reads it; Node gets a path, never the payload.
34
+ ukit_stage_hook_input 2097152 truncate || exit 0
35
+ else
36
+ # Runtime helper missing (pre-install tree): the SAME bounded staging,
37
+ # inline - `cat >/dev/null` used to block forever on a producer that never
38
+ # closes the pipe (R4.5). Read cap+1 in the background, bound the wait.
39
+ UKIT_INPUT_FILE="$(mktemp "${TMPDIR:-/tmp}/ukit-hook-in.XXXXXX")"
40
+ if [ -e /dev/fd/0 ]; then
41
+ exec 8<&0
42
+ head -c 2097153 <&8 > "$UKIT_INPUT_FILE" 2>/dev/null &
43
+ else
44
+ head -c 2097153 > "$UKIT_INPUT_FILE" 2>/dev/null &
45
+ fi
46
+ __ukit_reader=$!
47
+ __ukit_stage_ms="${UKIT_HOOK_STAGE_MS:-2000}"
48
+ case "$__ukit_stage_ms" in
49
+ ''|*[!0-9]*) __ukit_stage_ms=2000 ;;
50
+ esac
51
+ [ "$__ukit_stage_ms" -gt 0 ] 2>/dev/null || __ukit_stage_ms=2000
52
+ printf -v __ukit_stage_s '%d.%03d' $((__ukit_stage_ms / 1000)) $((__ukit_stage_ms % 1000))
53
+ ( sleep "$__ukit_stage_s" 2>/dev/null; kill -9 "$__ukit_reader" 2>/dev/null ) <&- >/dev/null 2>&1 &
54
+ __ukit_waiter=$!
55
+ wait "$__ukit_reader" 2>/dev/null
56
+ __ukit_stage_rc=$?
57
+ kill "$__ukit_waiter" 2>/dev/null
58
+ wait "$__ukit_waiter" 2>/dev/null
59
+ exec 8<&-
60
+ __ukit_size="$(wc -c < "$UKIT_INPUT_FILE" 2>/dev/null | tr -d '[:space:]')"
61
+ # A bounded cut (rc != 0) leaves a short payload that the JSON consumer
62
+ # already degrades on - the shared helper's truncate posture (R4.5).
63
+ if [ "$__ukit_size" -gt 2097152 ]; then
64
+ truncate -s 2097152 "$UKIT_INPUT_FILE" 2>/dev/null
65
+ fi
66
+ trap '[ -n "$UKIT_INPUT_FILE" ] && rm -f "$UKIT_INPUT_FILE"' EXIT
67
+ fi
26
68
  PROJECT_ROOT="${CLAUDE_PROJECT_DIR:-$PWD}"
27
69
 
28
- INPUT="$INPUT" PROJECT_ROOT="$PROJECT_ROOT" node <<'NODE' || true
29
- const fs = require('fs');
30
- const path = require('path');
70
+ INPUT_FILE="$UKIT_INPUT_FILE" PROJECT_ROOT="$PROJECT_ROOT" SCRIPT_DIR="$SCRIPT_DIR" node --input-type=module <<'NODE' || true
71
+ const HOOK_DEADLINE_MS = Number.parseInt(process.env.UKIT_HOOK_DEADLINE_MS || '', 10) || 3000;
72
+ // H04: the transcript scan below is async and tail-bounded. It must be abandoned past
73
+ // the existing 3 s self-deadline: this controller fires a moment BEFORE the hard exit so
74
+ // the in-flight read unwinds cooperatively, and the timer underneath still guarantees the
75
+ // process is gone at the deadline no matter what. Every path exits 0 — advisory only.
76
+ const scanAbort = new AbortController();
77
+ setTimeout(() => { try { scanAbort.abort(); } catch { /* already aborted */ } }, Math.max(0, HOOK_DEADLINE_MS - 250)).unref();
78
+ setTimeout(() => process.exit(0), HOOK_DEADLINE_MS).unref();
79
+ import fs from 'node:fs';
80
+ import path from 'node:path';
81
+ import { pathToFileURL } from 'node:url';
31
82
 
32
- // Reading more than this per prompt is not worth the latency. Reading only the TAIL is
33
- // also the safe direction: if the last compact boundary is older than the tail, we count
34
- // the whole tail as live context, which over-estimates and warns earlier.
35
- const MAX_READ_BYTES = 24 * 1024 * 1024;
83
+ // Reading only the TAIL is also the safe direction: if the last compact boundary is
84
+ // older than the tail, we count the whole tail as live context, which over-estimates and
85
+ // warns earlier. The byte ceiling itself lives in transcript-tail.mjs
86
+ // (DEFAULT_TAIL_MAX_BYTES) so the reader and its regression tests share one number —
87
+ // never again the old whole-transcript sync stat/read/split of up to 24 MiB per prompt.
36
88
 
37
89
  // Chars-per-token. Calibrated against a real 2843-line transcript: whole-file bytes read
38
90
  // ~1.44M "tokens" (constant false alarms), while message content after the last compact
39
91
  // boundary read ~145k for a session that did in fact need compacting right after.
40
92
  const CHARS_PER_TOKEN = 4;
41
93
 
94
+ let rawInput = '';
95
+ try { rawInput = fs.readFileSync(process.env.INPUT_FILE || '', 'utf8'); } catch {}
42
96
  const payload = (() => {
43
97
  try {
44
- const parsed = JSON.parse(process.env.INPUT || '');
98
+ const parsed = JSON.parse(rawInput);
45
99
  return parsed && typeof parsed === 'object' ? parsed : {};
46
100
  } catch {
47
101
  return {};
@@ -52,70 +106,74 @@ const projectRoot = process.env.PROJECT_ROOT;
52
106
  const transcriptPath = typeof payload.transcript_path === 'string' ? payload.transcript_path.trim() : '';
53
107
  if (!transcriptPath) process.exit(0);
54
108
 
55
- function readTail(filePath) {
56
- const stat = fs.statSync(filePath);
57
- if (stat.size <= MAX_READ_BYTES) {
58
- return { text: fs.readFileSync(filePath, 'utf8'), truncated: false };
59
- }
60
- const fd = fs.openSync(filePath, 'r');
61
- try {
62
- const buf = Buffer.allocUnsafe(MAX_READ_BYTES);
63
- fs.readSync(fd, buf, 0, MAX_READ_BYTES, stat.size - MAX_READ_BYTES);
64
- return { text: buf.toString('utf8'), truncated: true };
65
- } finally {
66
- fs.closeSync(fd);
67
- }
109
+ // Operator config is read once; a missing/corrupt file just means the defaults.
110
+ let runtimeConfig = {};
111
+ try {
112
+ runtimeConfig = JSON.parse(fs.readFileSync(path.join(projectRoot, '.ukit', 'storage', 'config.json'), 'utf8'));
113
+ } catch { /* fall through to the defaults */ }
114
+ if (!runtimeConfig || typeof runtimeConfig !== 'object') runtimeConfig = {};
115
+
116
+ // Absolute operator ceiling (compact.hardCapTokens). Negotiated capacity can only ever
117
+ // tighten this, never raise it.
118
+ function operatorHardCap() {
119
+ const value = runtimeConfig?.compact?.hardCapTokens;
120
+ if (typeof value === 'number' && Number.isFinite(value) && Number.isInteger(value) && value > 0) return value;
121
+ return 500_000;
68
122
  }
69
123
 
70
- // The cap the rest of UKit already agrees on, so one config key tunes both this warning
71
- // and context-hardcap-gate.sh. Default mirrors compact-threshold.mjs.
72
- function loadHardCap() {
124
+ // H04: bounded async tail scan instead of a whole-transcript sync stat/read/split. The
125
+ // runtime module owns the byte ceiling (DEFAULT_TAIL_MAX_BYTES, regression-tested well
126
+ // under the old 24 MiB) and the fail-open posture. A missing runtime helper (pre-install
127
+ // tree) simply means nothing to measure: the advisory guard exits 0.
128
+ async function loadTailScanner() {
73
129
  try {
74
- const raw = fs.readFileSync(path.join(projectRoot, '.ukit', 'storage', 'config.json'), 'utf8');
75
- const value = JSON.parse(raw)?.compact?.hardCapTokens;
76
- if (typeof value === 'number' && Number.isFinite(value) && Number.isInteger(value) && value > 0) return value;
77
- } catch { /* fall through to the default */ }
78
- return 500_000;
130
+ return await import(pathToFileURL(path.join(
131
+ process.env.SCRIPT_DIR || process.cwd(),
132
+ '..',
133
+ 'ukit',
134
+ 'runtime',
135
+ 'transcript-tail.mjs',
136
+ )).href);
137
+ } catch {
138
+ return null;
139
+ }
79
140
  }
80
141
 
81
- let text;
82
- let truncated;
142
+ const scanner = await loadTailScanner();
143
+ if (!scanner || typeof scanner.scanTranscriptTail !== 'function') process.exit(0);
144
+ let tail;
83
145
  try {
84
- ({ text, truncated } = readTail(transcriptPath));
146
+ tail = await scanner.scanTranscriptTail(transcriptPath, { signal: scanAbort.signal });
85
147
  } catch {
86
148
  // Transcript unreadable (first prompt of a session, permissions, races). Nothing to
87
149
  // measure and nothing worth reporting.
88
150
  process.exit(0);
89
151
  }
90
-
91
- const lines = text.split('\n').filter(Boolean);
92
- if (truncated) lines.shift(); // a tail read almost certainly split the first line
152
+ const entries = Array.isArray(tail?.entries) ? tail.entries : [];
93
153
 
94
154
  // Everything before the last compact boundary is already summarised away and is NOT in
95
155
  // the live context. Counting it is what makes naive file-size estimates useless.
96
156
  let start = 0;
97
157
  let boundaryTs = null;
98
- for (let i = lines.length - 1; i >= 0; i -= 1) {
99
- if (!lines[i].includes('compact_boundary')) continue;
100
- try {
101
- const entry = JSON.parse(lines[i]);
102
- if (entry?.type === 'system' && entry?.subtype === 'compact_boundary') {
103
- start = i + 1;
104
- boundaryTs = Date.parse(entry.timestamp) || null;
105
- break;
106
- }
107
- } catch { /* not a usable boundary line */ }
158
+ for (let i = entries.length - 1; i >= 0; i -= 1) {
159
+ const entry = entries[i];
160
+ if (entry?.type === 'system' && entry?.subtype === 'compact_boundary') {
161
+ start = i + 1;
162
+ boundaryTs = Date.parse(entry.timestamp) || null;
163
+ break;
164
+ }
108
165
  }
109
166
 
167
+ // If the tail window cuts off history AND holds no boundary, the newest compact boundary
168
+ // may sit before the window: the estimate counts only what was read and is a lower
169
+ // bound. Say so instead of pretending whole-session precision.
170
+ const estimatePartial = Boolean(tail?.truncated) && boundaryTs === null;
171
+
110
172
  let contentChars = 0;
111
173
  let sidechainEntries = 0;
112
- for (let i = start; i < lines.length; i += 1) {
113
- let entry;
114
- try {
115
- entry = JSON.parse(lines[i]);
116
- } catch {
117
- continue;
118
- }
174
+ const modelSequence = [];
175
+ for (let i = start; i < entries.length; i += 1) {
176
+ const entry = entries[i];
119
177
  // Subagent traffic lives in its own context window, not the main one. Count it
120
178
  // separately: it is a leading indicator of the "many teammates at once" blow-up, but
121
179
  // adding it to the main estimate would overstate the main window.
@@ -123,6 +181,10 @@ for (let i = start; i < lines.length; i += 1) {
123
181
  sidechainEntries += 1;
124
182
  continue;
125
183
  }
184
+ if (entry?.type === 'assistant') {
185
+ const model = entry?.message?.model;
186
+ if (typeof model === 'string' && model.trim()) modelSequence.push(model.trim());
187
+ }
126
188
  if (entry?.type !== 'user' && entry?.type !== 'assistant' && entry?.type !== 'attachment') continue;
127
189
  try {
128
190
  contentChars += JSON.stringify(entry.message ?? entry.attachment ?? '').length;
@@ -130,9 +192,89 @@ for (let i = start; i < lines.length; i += 1) {
130
192
  }
131
193
 
132
194
  const estimatedTokens = Math.round(contentChars / CHARS_PER_TOKEN);
133
- const hardCap = loadHardCap();
195
+
196
+ // Latest assistant model in the live window — this is the model that must fit the NEXT
197
+ // request, so capacity negotiation follows it. A mid-session gateway swap to a smaller
198
+ // model tightens the cap immediately; the earlier model's window is never sticky.
199
+ // (Hoisted above the ratio so both capacity negotiation and the swap note share it.)
200
+ const RECENT_MODEL_WINDOW = 40;
201
+ const recentModels = modelSequence.slice(-RECENT_MODEL_WINDOW);
202
+ const currentModel = recentModels[recentModels.length - 1] || null;
203
+
204
+ // Negotiated capacity (H22): derive the advisory cap from the best available evidence —
205
+ // env override > config override > verified model table > conservative default — instead
206
+ // of assuming one hard-coded 500k window. The source is surfaced in the warning lines
207
+ // below (capacitySource=...) so a mis-negotiated cap is diagnosable at a glance. A
208
+ // missing runtime helper (pre-install tree) keeps the shipped fallback behavior —
209
+ // advisory and fail-open either way.
210
+ async function loadCapacityResolver() {
211
+ try {
212
+ return await import(pathToFileURL(path.join(
213
+ process.env.SCRIPT_DIR || process.cwd(),
214
+ '..',
215
+ 'ukit',
216
+ 'runtime',
217
+ 'context-capacity.mjs',
218
+ )).href);
219
+ } catch {
220
+ return null;
221
+ }
222
+ }
223
+
224
+ let hardCap = operatorHardCap();
225
+ let capacitySource = 'default';
226
+ let negotiatedCapacity = null;
227
+ try {
228
+ const capacityModule = await loadCapacityResolver();
229
+ if (capacityModule && typeof capacityModule.resolveContextCapTokens === 'function') {
230
+ const resolved = capacityModule.resolveContextCapTokens({
231
+ env: process.env,
232
+ config: runtimeConfig,
233
+ modelMetadata: { model: currentModel },
234
+ });
235
+ hardCap = resolved.capTokens;
236
+ capacitySource = resolved.capacity?.source ?? 'default';
237
+ negotiatedCapacity = resolved.capacity ?? null;
238
+ }
239
+ } catch { /* negotiation is advisory — the operator ceiling already applies */ }
134
240
  const ratio = estimatedTokens / hardCap;
135
241
 
242
+ // Publish what this hook negotiated so the compact-threshold/hard-cap-gate path stops
243
+ // assuming a 500k window: this is the only place with live evidence (env + config + the
244
+ // transcript's assistant models). Without the record, automatic compact waits until 240k
245
+ // and the fail-closed gate until 500k on a verified 200k route — after the provider already
246
+ // rejected the request. Best-effort: an unwritable cache dir must never break the guard.
247
+ const CAPACITY_RECORD_PATH = path.join(projectRoot, '.ukit', 'storage', 'cache', 'context-capacity.json');
248
+ if (negotiatedCapacity) {
249
+ try {
250
+ fs.mkdirSync(path.dirname(CAPACITY_RECORD_PATH), { recursive: true });
251
+ const previous = readCapacityRecord();
252
+ // Only rewrite when the evidence changed, so prompt caching sees a stable file.
253
+ if (!previous
254
+ || previous.tokens !== negotiatedCapacity.tokens
255
+ || previous.source !== negotiatedCapacity.source
256
+ || previous.model !== (currentModel ?? null)) {
257
+ const record = {
258
+ tokens: negotiatedCapacity.tokens,
259
+ source: negotiatedCapacity.source,
260
+ confidence: negotiatedCapacity.confidence,
261
+ model: currentModel ?? null,
262
+ updatedAt: Date.now(),
263
+ };
264
+ fs.writeFileSync(CAPACITY_RECORD_PATH, `${JSON.stringify(record)}\n`, 'utf8');
265
+ }
266
+ } catch { /* fail open: negotiation is advisory */ }
267
+ }
268
+
269
+ function readCapacityRecord() {
270
+ try {
271
+ const parsed = JSON.parse(fs.readFileSync(CAPACITY_RECORD_PATH, 'utf8'));
272
+ return parsed && typeof parsed === 'object' ? parsed : null;
273
+ } catch {
274
+ return null;
275
+ }
276
+ }
277
+
136
278
  // Debounce/state file shared by the context warning and the gateway model-swap note.
137
279
  const GUARD_STATE_PATH = path.join(projectRoot, '.ukit', 'storage', 'cache', 'context-guard-state.json');
138
280
 
@@ -164,22 +306,9 @@ let persistedState = { ...guardState };
164
306
  // keep working through swaps, and on a tool error after a swap, re-check the tool and
165
307
  // call it again instead of stopping. Runs before the ratio early-exit because a swap
166
308
  // can happen at any context level.
167
- const RECENT_MODEL_WINDOW = 40;
168
309
  const SWAP_NOTE_COOLDOWN_MS = 60 * 60 * 1000;
169
- const modelSequence = [];
170
- for (let i = start; i < lines.length; i += 1) {
171
- let entry;
172
- try {
173
- entry = JSON.parse(lines[i]);
174
- } catch {
175
- continue;
176
- }
177
- if (entry?.isSidechain || entry?.type !== 'assistant') continue;
178
- const model = entry?.message?.model;
179
- if (typeof model === 'string' && model.trim()) modelSequence.push(model.trim());
180
- }
181
- const recentModels = modelSequence.slice(-RECENT_MODEL_WINDOW);
182
- const currentModel = recentModels[recentModels.length - 1] || null;
310
+ // recentModels/currentModel were hoisted above for capacity negotiation; modelSequence
311
+ // was collected in the single pass over entries.
183
312
  let previousModel = null;
184
313
  for (let i = recentModels.length - 2; i >= 0; i -= 1) {
185
314
  if (recentModels[i] !== currentModel) {
@@ -267,10 +396,14 @@ const run = readRunCursor();
267
396
 
268
397
  const lines_out = [];
269
398
  if (ratio >= 1) {
270
- lines_out.push(`UKIT CONTEXT ALERT — live context ~${estimatedTokens.toLocaleString()} tokens, at/over the ${hardCap.toLocaleString()} cap (${pct}%).`);
399
+ lines_out.push(`UKIT CONTEXT ALERT — live context ~${estimatedTokens.toLocaleString()} tokens, at/over the ${hardCap.toLocaleString()} cap (${pct}%, capacitySource=${capacitySource}).`);
271
400
  lines_out.push('The next few turns risk "Your input exceeds the context window of this model".');
272
401
  } else {
273
- lines_out.push(`UKIT CONTEXT WARNING — live context ~${estimatedTokens.toLocaleString()} tokens (${pct}% of the ${hardCap.toLocaleString()} cap).`);
402
+ lines_out.push(`UKIT CONTEXT WARNING — live context ~${estimatedTokens.toLocaleString()} tokens (${pct}% of the ${hardCap.toLocaleString()} cap, capacitySource=${capacitySource}).`);
403
+ }
404
+
405
+ if (estimatePartial) {
406
+ lines_out.push(`Note: this estimate is partial — only the last ~${Math.max(1, Math.round(Number(tail?.bytesRead || 0) / 1024))} KiB of transcript were read, so real live context may be higher.`);
274
407
  }
275
408
 
276
409
  if (withinCooldown) {
@@ -11,7 +11,50 @@
11
11
  # Always hard-blocks (exit 2). No advisory/soft mode — the user explicitly asked for
12
12
  # strict enforcement of this contract.
13
13
 
14
- INPUT=$(cat)
14
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
15
+ # shellcheck source=/dev/null
16
+ if source "$SCRIPT_DIR/../ukit/runtime/hook-input.sh" 2>/dev/null; then
17
+ # TASK-019: arm advisory latency telemetry; the finish row is written by the
18
+ # shared cleanup path before the staged payload is removed. A missing
19
+ # runtime (pre-install tree) simply leaves telemetry off.
20
+ source "$SCRIPT_DIR/../ukit/runtime/hook-telemetry.sh" 2>/dev/null || true
21
+ trap ukit_cleanup_hook_input EXIT
22
+ # Bounded stdin (H01): stage before anything reads it; the shell var is capped.
23
+ ukit_stage_hook_input 2097152 truncate || exit 0
24
+ else
25
+ # Runtime helper missing (pre-install tree): the SAME bounded staging,
26
+ # inline - `cat >/dev/null` used to block forever on a producer that never
27
+ # closes the pipe (R4.5). Read cap+1 in the background, bound the wait.
28
+ UKIT_INPUT_FILE="$(mktemp "${TMPDIR:-/tmp}/ukit-hook-in.XXXXXX")"
29
+ if [ -e /dev/fd/0 ]; then
30
+ exec 8<&0
31
+ head -c 2097153 <&8 > "$UKIT_INPUT_FILE" 2>/dev/null &
32
+ else
33
+ head -c 2097153 > "$UKIT_INPUT_FILE" 2>/dev/null &
34
+ fi
35
+ __ukit_reader=$!
36
+ __ukit_stage_ms="${UKIT_HOOK_STAGE_MS:-2000}"
37
+ case "$__ukit_stage_ms" in
38
+ ''|*[!0-9]*) __ukit_stage_ms=2000 ;;
39
+ esac
40
+ [ "$__ukit_stage_ms" -gt 0 ] 2>/dev/null || __ukit_stage_ms=2000
41
+ printf -v __ukit_stage_s '%d.%03d' $((__ukit_stage_ms / 1000)) $((__ukit_stage_ms % 1000))
42
+ ( sleep "$__ukit_stage_s" 2>/dev/null; kill -9 "$__ukit_reader" 2>/dev/null ) <&- >/dev/null 2>&1 &
43
+ __ukit_waiter=$!
44
+ wait "$__ukit_reader" 2>/dev/null
45
+ __ukit_stage_rc=$?
46
+ kill "$__ukit_waiter" 2>/dev/null
47
+ wait "$__ukit_waiter" 2>/dev/null
48
+ exec 8<&-
49
+ __ukit_size="$(wc -c < "$UKIT_INPUT_FILE" 2>/dev/null | tr -d '[:space:]')"
50
+ # A bounded cut (rc != 0) leaves a short payload that the JSON consumer
51
+ # already degrades on - the shared helper's truncate posture (R4.5).
52
+ if [ "$__ukit_size" -gt 2097152 ]; then
53
+ truncate -s 2097152 "$UKIT_INPUT_FILE" 2>/dev/null
54
+ fi
55
+ trap '[ -n "$UKIT_INPUT_FILE" ] && rm -f "$UKIT_INPUT_FILE"' EXIT
56
+ fi
57
+ INPUT="$(cat "$UKIT_INPUT_FILE")"
15
58
  PROJECT_ROOT="${CLAUDE_PROJECT_DIR:-$PWD}"
16
59
 
17
60
  # Fast path — skip the node spawn (~70ms) when this hook provably has nothing to say.
@@ -26,12 +69,16 @@ if ! printf '%s' "$INPUT" | grep -qE 'AI_HANDOFF|git[[:space:]]+push'; then
26
69
  exit 0
27
70
  fi
28
71
 
29
- INPUT="$INPUT" PROJECT_ROOT="$PROJECT_ROOT" node <<'NODE'
72
+ INPUT_FILE="$UKIT_INPUT_FILE" PROJECT_ROOT="$PROJECT_ROOT" node <<'NODE'
73
+ const HOOK_DEADLINE_MS = Number.parseInt(process.env.UKIT_HOOK_DEADLINE_MS || '', 10) || 3000;
74
+ setTimeout(() => process.exit(0), HOOK_DEADLINE_MS).unref();
30
75
  const fs = require('fs');
31
76
  const path = require('path');
32
77
 
33
78
  const payload = (() => {
34
- try { return JSON.parse(process.env.INPUT || '{}'); } catch { return {}; }
79
+ let raw = '';
80
+ try { raw = fs.readFileSync(process.env.INPUT_FILE || '', 'utf8'); } catch {}
81
+ try { return JSON.parse(raw || '{}'); } catch { return {}; }
35
82
  })();
36
83
  const projectRoot = process.env.PROJECT_ROOT;
37
84
  const toolName = payload?.tool_name || '';
@@ -19,10 +19,52 @@
19
19
  # ADVISORY ONLY — always exit 0. A resume hint must never be able to block a session from
20
20
  # starting.
21
21
 
22
- INPUT=$(cat)
22
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
23
+ # shellcheck source=/dev/null
24
+ if source "$SCRIPT_DIR/../ukit/runtime/hook-input.sh" 2>/dev/null; then
25
+ # TASK-019: arm advisory latency telemetry; the finish row is written by the
26
+ # shared cleanup path before the staged payload is removed. A missing
27
+ # runtime (pre-install tree) simply leaves telemetry off.
28
+ source "$SCRIPT_DIR/../ukit/runtime/hook-telemetry.sh" 2>/dev/null || true
29
+ trap ukit_cleanup_hook_input EXIT
30
+ # Bounded stdin (H01): stage before anything reads it; Node gets a path, never the payload.
31
+ ukit_stage_hook_input 2097152 truncate || exit 0
32
+ else
33
+ # Runtime helper missing (pre-install tree): the SAME bounded staging,
34
+ # inline - `cat >/dev/null` used to block forever on a producer that never
35
+ # closes the pipe (R4.5). Read cap+1 in the background, bound the wait.
36
+ UKIT_INPUT_FILE="$(mktemp "${TMPDIR:-/tmp}/ukit-hook-in.XXXXXX")"
37
+ if [ -e /dev/fd/0 ]; then
38
+ exec 8<&0
39
+ head -c 2097153 <&8 > "$UKIT_INPUT_FILE" 2>/dev/null &
40
+ else
41
+ head -c 2097153 > "$UKIT_INPUT_FILE" 2>/dev/null &
42
+ fi
43
+ __ukit_reader=$!
44
+ __ukit_stage_ms="${UKIT_HOOK_STAGE_MS:-2000}"
45
+ case "$__ukit_stage_ms" in
46
+ ''|*[!0-9]*) __ukit_stage_ms=2000 ;;
47
+ esac
48
+ [ "$__ukit_stage_ms" -gt 0 ] 2>/dev/null || __ukit_stage_ms=2000
49
+ printf -v __ukit_stage_s '%d.%03d' $((__ukit_stage_ms / 1000)) $((__ukit_stage_ms % 1000))
50
+ ( sleep "$__ukit_stage_s" 2>/dev/null; kill -9 "$__ukit_reader" 2>/dev/null ) <&- >/dev/null 2>&1 &
51
+ __ukit_waiter=$!
52
+ wait "$__ukit_reader" 2>/dev/null
53
+ __ukit_stage_rc=$?
54
+ kill "$__ukit_waiter" 2>/dev/null
55
+ wait "$__ukit_waiter" 2>/dev/null
56
+ exec 8<&-
57
+ __ukit_size="$(wc -c < "$UKIT_INPUT_FILE" 2>/dev/null | tr -d '[:space:]')"
58
+ # A bounded cut (rc != 0) leaves a short payload that the JSON consumer
59
+ # already degrades on - the shared helper's truncate posture (R4.5).
60
+ if [ "$__ukit_size" -gt 2097152 ]; then
61
+ truncate -s 2097152 "$UKIT_INPUT_FILE" 2>/dev/null
62
+ fi
63
+ trap '[ -n "$UKIT_INPUT_FILE" ] && rm -f "$UKIT_INPUT_FILE"' EXIT
64
+ fi
23
65
  PROJECT_ROOT="${CLAUDE_PROJECT_DIR:-$PWD}"
24
66
 
25
- INPUT="$INPUT" PROJECT_ROOT="$PROJECT_ROOT" node <<'NODE' || true
67
+ INPUT_FILE="$UKIT_INPUT_FILE" PROJECT_ROOT="$PROJECT_ROOT" node <<'NODE' || true
26
68
  const fs = require('fs');
27
69
  const path = require('path');
28
70
  const { pathToFileURL } = require('url');
@@ -35,9 +77,11 @@ const { pathToFileURL } = require('url');
35
77
  const HOOK_DEADLINE_MS = Number.parseInt(process.env.UKIT_HOOK_DEADLINE_MS || '', 10) || 3000;
36
78
  setTimeout(() => process.exit(0), HOOK_DEADLINE_MS).unref();
37
79
 
80
+ let rawInput = '';
81
+ try { rawInput = fs.readFileSync(process.env.INPUT_FILE || '', 'utf8'); } catch {}
38
82
  const payload = (() => {
39
83
  try {
40
- const parsed = JSON.parse(process.env.INPUT || '');
84
+ const parsed = JSON.parse(rawInput);
41
85
  return parsed && typeof parsed === 'object' ? parsed : {};
42
86
  } catch {
43
87
  return {};
@@ -2,7 +2,50 @@
2
2
  # PostToolUse hook: verify risky Edit|Write delta after rollback bytes exist.
3
3
  # Matched on: Edit|Write
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
  PROJECT_ROOT="${CLAUDE_PROJECT_DIR:-$(pwd)}"
7
50
  SCRIPT="$PROJECT_ROOT/.claude/ukit/index/post-edit-verify.mjs"
8
51
 
@@ -10,4 +53,4 @@ if [ ! -f "$SCRIPT" ]; then
10
53
  exit 0
11
54
  fi
12
55
 
13
- printf '%s' "$INPUT" | node "$SCRIPT"
56
+ printf '%s' "$INPUT" | UKIT_HOOK_DEADLINE_MS="${UKIT_HOOK_DEADLINE_MS:-3000}" node "$SCRIPT"
@@ -2,7 +2,50 @@
2
2
  # PreToolUse hook: create rollback bytes for risky Edit|Write operations.
3
3
  # Matched on: Edit|Write
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
  PROJECT_ROOT="${CLAUDE_PROJECT_DIR:-$(pwd)}"
7
50
  SCRIPT="$PROJECT_ROOT/.claude/ukit/index/pre-edit-backup.mjs"
8
51
 
@@ -10,4 +53,4 @@ if [ ! -f "$SCRIPT" ]; then
10
53
  exit 0
11
54
  fi
12
55
 
13
- printf '%s' "$INPUT" | node "$SCRIPT"
56
+ printf '%s' "$INPUT" | UKIT_HOOK_DEADLINE_MS="${UKIT_HOOK_DEADLINE_MS:-3000}" node "$SCRIPT"