@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
@@ -19,19 +19,84 @@
19
19
  # every unrelated prompt is the false-positive loop this version exists to kill.
20
20
  # A named path that does not resolve from the project root gets a short note only.
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
26
- const fs = require('fs');
67
+ INPUT_FILE="$UKIT_INPUT_FILE" PROJECT_ROOT="$PROJECT_ROOT" node <<'NODE' || true
68
+ const HOOK_DEADLINE_MS = Number.parseInt(process.env.UKIT_HOOK_DEADLINE_MS || '', 10) || 8000;
69
+ setTimeout(() => process.exit(0), HOOK_DEADLINE_MS).unref();
70
+ const fs = require('fs').promises;
27
71
  const path = require('path');
28
- const { execFileSync } = require('child_process');
72
+ const { spawn } = require('child_process');
29
73
  const { pathToFileURL } = require('url');
30
74
 
75
+ // TASK-015: a self-deadline can only preempt work that yields to the event
76
+ // loop. The extractor is spawned asynchronously with an abort timer armed
77
+ // strictly BEFORE HOOK_DEADLINE_MS (so the deadline always wins), killed with
78
+ // SIGKILL (no child can ignore it), and every live child is reaped on process
79
+ // exit as a backstop. The old synchronous spawn blocked the loop and made the
80
+ // unref'd watchdog above un-fireable — banned on this hot path.
81
+ const liveChildren = new Set();
82
+ process.on('exit', () => {
83
+ for (const child of liveChildren) {
84
+ try { child.kill('SIGKILL'); } catch {}
85
+ }
86
+ });
87
+
88
+ // TASK-015 fix round 1: every hot-path fs call is awaited. A synchronous read
89
+ // parks the event loop for the whole syscall, so the unref'd self-deadline above
90
+ // could never fire while a project filesystem stalled.
91
+ async function readTextSafe(filePath) {
92
+ try { return await fs.readFile(filePath, 'utf8'); } catch { return ''; }
93
+ }
94
+
31
95
  (async () => {
96
+ const rawInput = await readTextSafe(process.env.INPUT_FILE || '');
32
97
  const payload = (() => {
33
98
  try {
34
- const parsed = JSON.parse(process.env.INPUT || '');
99
+ const parsed = JSON.parse(rawInput);
35
100
  return parsed && typeof parsed === 'object' ? parsed : {};
36
101
  } catch {
37
102
  return {};
@@ -53,7 +118,7 @@ const { pathToFileURL } = require('url');
53
118
  async function detectUnicModeSafe() {
54
119
  try {
55
120
  const gatewayPath = path.join(projectRoot, '.claude', 'ukit', 'index', 'unic-gateway.mjs');
56
- if (!fs.existsSync(gatewayPath)) return null;
121
+ try { await fs.access(gatewayPath); } catch { return null; }
57
122
  const mod = await import(pathToFileURL(gatewayPath).href);
58
123
  if (typeof mod.detectUnicGateway !== 'function') return null;
59
124
  return !!mod.detectUnicGateway({ rootDir: projectRoot })?.unicMode;
@@ -70,8 +135,9 @@ const { pathToFileURL } = require('url');
70
135
  // whose receipt could never be accepted. Only a POSITIVE false short-circuits; null
71
136
  // (detection failed) still routes, matching the gate's fail-safe direction.
72
137
  //
73
- // Cheaper than the old order, too: this replaces an execFileSync node spawn per prompt
74
- // with one local import on the overwhelmingly common off-gateway path.
138
+ // Cheaper than the old order, too: this replaces a synchronous node spawn
139
+ // per prompt with one local import on the overwhelmingly common off-gateway
140
+ // path.
75
141
  if (unicMode === false) {
76
142
  process.exit(0);
77
143
  }
@@ -101,7 +167,7 @@ const { pathToFileURL } = require('url');
101
167
  // pair) and silently skips it. Kept here as a small inline copy rather than
102
168
  // a cross-file import — the contract is a few lines, the cost of drift is
103
169
  // small, and the hook must stay self-contained (no module resolution).
104
- function stripPathNoise(raw) {
170
+ async function stripPathNoise(raw) {
105
171
  const value = String(raw ?? '').trim();
106
172
  if (!value) return value;
107
173
  const pairs = { '(': ')', '[': ']', '{': '}', '"': '"', "'": "'" };
@@ -134,7 +200,8 @@ const { pathToFileURL } = require('url');
134
200
  for (const c of candidates) {
135
201
  if (!c) continue;
136
202
  try {
137
- if (fs.existsSync(path.resolve(c))) return c;
203
+ await fs.access(path.resolve(c));
204
+ return c;
138
205
  } catch {
139
206
  // ignore
140
207
  }
@@ -142,6 +209,56 @@ const { pathToFileURL } = require('url');
142
209
  return noSoft !== value ? noSoft : value;
143
210
  }
144
211
 
212
+ // TASK-015: async spawn, one settle path per child. The abort timer fires
213
+ // strictly before the hook deadline (min floor keeps it meaningful if the
214
+ // deadline is configured tiny) and is unref'd so it never holds the loop
215
+ // open by itself. Stdout capture is bounded; stream/child errors are absorbed
216
+ // so an abort racing a child exit settles exactly once with no unhandled
217
+ // rejection. Any failure degrades to a bounded diagnostic + advisory flow.
218
+ function runExtractorAsync(args) {
219
+ return new Promise((resolve, reject) => {
220
+ let child;
221
+ try {
222
+ child = spawn('node', args, { cwd: projectRoot, stdio: ['ignore', 'pipe', 'pipe'] });
223
+ } catch (err) {
224
+ reject(err);
225
+ return;
226
+ }
227
+ liveChildren.add(child);
228
+ let stdout = '';
229
+ let settled = false;
230
+ let abortTimer = null;
231
+ const settle = (err, text) => {
232
+ if (settled) return;
233
+ settled = true;
234
+ if (abortTimer) clearTimeout(abortTimer);
235
+ liveChildren.delete(child);
236
+ if (err) reject(err);
237
+ else resolve(text);
238
+ };
239
+ abortTimer = setTimeout(() => {
240
+ try { child.kill('SIGKILL'); } catch {}
241
+ settle(new Error(`extractor killed at ${HOOK_DEADLINE_MS}ms hook deadline`));
242
+ }, Math.max(250, HOOK_DEADLINE_MS - 500));
243
+ abortTimer.unref();
244
+ child.stdout.on('error', () => {});
245
+ child.stderr.on('error', () => {});
246
+ child.stdout.on('data', (chunk) => {
247
+ if (stdout.length < 1000000) stdout += chunk; // bounded capture
248
+ });
249
+ child.on('error', (err) => settle(err));
250
+ child.on('close', (code) => {
251
+ if (code === 0) {
252
+ settle(null, stdout);
253
+ } else {
254
+ settle(new Error(code === null
255
+ ? 'extractor was killed before exiting'
256
+ : `extractor exited with code ${code}`));
257
+ }
258
+ });
259
+ });
260
+ }
261
+
145
262
  const urlMatches = promptText.match(URL_IMAGE_RE) || [];
146
263
  const textWithoutUrls = promptText.replace(URL_IMAGE_RE, ' ');
147
264
  const localMatches = textWithoutUrls.match(LOCAL_PATH_RE) || [];
@@ -152,7 +269,8 @@ const { pathToFileURL } = require('url');
152
269
  // string inside the extractor.
153
270
  let extractorJson = null;
154
271
  const extractorPath = path.join(projectRoot, '.claude', 'ukit', 'index', 'extract-image.mjs');
155
- if (fs.existsSync(extractorPath)) {
272
+ try {
273
+ await fs.access(extractorPath);
156
274
  const args = [extractorPath, '--detect', '--mark-pending', '--json'];
157
275
  if (typeof payload?.transcript_path === 'string' && payload.transcript_path.trim()) {
158
276
  args.push('--session', payload.transcript_path.trim());
@@ -164,18 +282,20 @@ const { pathToFileURL } = require('url');
164
282
  // Mirror extractor's resolveRefs input contract: the LLM sometimes leaves
165
283
  // trailing punctuation/quotes on a path named in a prompt. Clean before
166
284
  // handing off so the hook and the extractor agree on the ref string.
167
- args.push('--ref', stripPathNoise(ref));
285
+ args.push('--ref', await stripPathNoise(ref));
168
286
  }
287
+ let out = null;
169
288
  try {
170
- const out = execFileSync('node', args, {
171
- cwd: projectRoot,
172
- encoding: 'utf8',
173
- timeout: 6000,
174
- });
289
+ out = await runExtractorAsync(args);
175
290
  extractorJson = JSON.parse(out);
176
291
  } catch (err) {
177
- process.stderr.write(`vision-router: extractor call skipped (${err?.message ?? err})\n`);
292
+ // Bounded diagnostic: the head of whatever the extractor emitted (if
293
+ // anything) plus the failure reason. Never the whole payload.
294
+ const head = typeof out === 'string' && out ? `; output head: ${out.slice(0, 200)}` : '';
295
+ process.stderr.write(`vision-router: extractor call skipped (${err?.message ?? err})${head}\n`);
178
296
  }
297
+ } catch {
298
+ // Missing extractor is a normal fail-open condition.
179
299
  }
180
300
 
181
301
  // TASK-006 contract: in --mark-pending --json mode `images[]` is the
@@ -208,11 +208,6 @@
208
208
  "type": "command",
209
209
  "command": "\"$CLAUDE_PROJECT_DIR/.claude/hooks/completion-gate.sh\"",
210
210
  "timeout": 4
211
- },
212
- {
213
- "type": "command",
214
- "command": "\"$CLAUDE_PROJECT_DIR/.claude/hooks/task-watchdog.sh\"",
215
- "timeout": 4
216
211
  }
217
212
  ]
218
213
  }