@ngockhoale/ukit 2.3.2 → 2.3.4

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.
@@ -75,7 +75,7 @@ function loadHardCap() {
75
75
  const value = JSON.parse(raw)?.compact?.hardCapTokens;
76
76
  if (Number.isFinite(value) && value > 0) return value;
77
77
  } catch { /* fall through to the default */ }
78
- return 220_000;
78
+ return 500_000;
79
79
  }
80
80
 
81
81
  let text;
@@ -94,12 +94,14 @@ if (truncated) lines.shift(); // a tail read almost certainly split the first li
94
94
  // Everything before the last compact boundary is already summarised away and is NOT in
95
95
  // the live context. Counting it is what makes naive file-size estimates useless.
96
96
  let start = 0;
97
+ let boundaryTs = null;
97
98
  for (let i = lines.length - 1; i >= 0; i -= 1) {
98
99
  if (!lines[i].includes('compact_boundary')) continue;
99
100
  try {
100
101
  const entry = JSON.parse(lines[i]);
101
102
  if (entry?.type === 'system' && entry?.subtype === 'compact_boundary') {
102
103
  start = i + 1;
104
+ boundaryTs = Date.parse(entry.timestamp) || null;
103
105
  break;
104
106
  }
105
107
  } catch { /* not a usable boundary line */ }
@@ -200,6 +202,31 @@ if (swapDetected && persistedState.lastSwapModel !== currentModel) {
200
202
  }
201
203
  }
202
204
 
205
+ // ── Post-compact shrink check ──
206
+ // Compaction only helps if the live context AFTER the boundary is actually small. When the
207
+ // prompts right after a fresh compact already measure >= 60% of the cap, the summary did
208
+ // not shrink the session enough — and re-compacting the same session recovers very little.
209
+ // The honest recovery moves are: stop re-inflating it with rereads, delegate broad work to
210
+ // subagents (separate windows), or finish in a FRESH session (UKit resume state carries
211
+ // the goal across). Fires once per compact boundary.
212
+ const POST_COMPACT_RATIO = 0.6;
213
+ const POST_COMPACT_WINDOW_MS = 10 * 60 * 1000;
214
+ const boundaryAgeMs = boundaryTs !== null ? Date.now() - boundaryTs : null;
215
+ const justCompacted = boundaryAgeMs !== null && boundaryAgeMs >= 0 && boundaryAgeMs < POST_COMPACT_WINDOW_MS;
216
+ if (justCompacted && ratio >= POST_COMPACT_RATIO && persistedState.lastPostCompactBoundary !== String(boundaryTs)) {
217
+ persistedState.lastPostCompactBoundary = String(boundaryTs);
218
+ writeGuardState(persistedState);
219
+ process.stdout.write(
220
+ [
221
+ `UKIT POST-COMPACT CHECK — compaction ~${Math.max(1, Math.round(boundaryAgeMs / 60000))} min ago, but live context is still ~${estimatedTokens.toLocaleString()} tokens (${Math.round(ratio * 100)}% of the ${hardCap.toLocaleString()} cap).`,
222
+ 'The compact did not shrink this session enough. Recover context now, in this order:',
223
+ '1) STOP re-reading old files, logs and tool outputs — durable state is in the PROJECT CONTEXT block or on disk. Rereads are what re-inflate a just-compacted session.',
224
+ '2) DELEGATE remaining broad work (searches, big reads, multi-file edits) to subagents: their tool output lives in their own windows; keep this context for decisions and short summaries only.',
225
+ '3) If substantial work still remains: land the current step, persist docs/STATUS.md, then ask the user to start a FRESH session — UKit resume state carries the goal forward with a near-empty window. Another /compact here recovers far less.',
226
+ ].join('\n') + '\n',
227
+ );
228
+ }
229
+
203
230
  if (ratio < 0.8) process.exit(0);
204
231
 
205
232
  const pct = Math.round(ratio * 100);
@@ -226,7 +253,7 @@ function readRunCursor() {
226
253
  try {
227
254
  const text = fs.readFileSync(path.join(projectRoot, 'docs', 'AI_HANDOFF', 'RUN.md'), 'utf8');
228
255
  const runPhase = (text.match(/^Phase:\s*(.+)$/m)?.[1] || '').trim();
229
- if (!runPhase || /^done$/i.test(runPhase)) return null;
256
+ if (!runPhase || /^done\b/i.test(runPhase)) return null;
230
257
  return {
231
258
  phase: runPhase,
232
259
  cursor: (text.match(/^Cursor:\s*(.+)$/m)?.[1] || '').trim(),
@@ -261,9 +288,10 @@ if (withinCooldown) {
261
288
  writeGuardState({ ...persistedState, lastPhase: phase, lastActionAt: now });
262
289
  } else {
263
290
  lines_out.push('ACTION THIS TURN, before starting new investigation/subagents/pipeline phases:');
264
- lines_out.push('1) Persist current progress now (update docs/STATUS.md, e.g. via the update-status skill) so nothing is lost.');
265
- lines_out.push('2) If the remaining work still has multiple steps or pipeline phases left, split what remains into bounded docs/AI_HANDOFF tasks instead of continuing in this one thread — smaller phases keep each turn under the cap.');
266
- lines_out.push('3) Otherwise, make the user\'s next action the FIRST sentence of your reply so it cannot be missed: "Context sắp đầy — gõ /compact ngay (run /compact now)", or wrap up and start a fresh session — work already committed/persisted to disk is not lost. Do not keep working silently until tools start being refused.');
291
+ lines_out.push('1) LAND ONE THING: pick the smallest in-flight item you can finish end-to-end in ≤3 tool calls (edit + verify), complete exactly that one, and report it done. Do not open anything new.');
292
+ lines_out.push('2) DEFER THE REST: write each remaining step as one line into docs/STATUS.md (update-status skill), or split what remains into bounded docs/AI_HANDOFF tasks — a fresh context then continues from disk without re-reading this thread.');
293
+ lines_out.push('3) DELEGATE anything broad that must still run (searches, big reads, multi-file edits) to subagents instead of doing it inline — their tool output stays in their own windows, not this one.');
294
+ lines_out.push('4) Then make the user\'s next action the FIRST sentence of your reply: "Context sắp đầy — gõ /compact ngay (run /compact now)". After /compact UKit auto-resumes from disk; if the compacted session is still large, ask for a fresh session instead. Do not keep working silently until tools start being refused.');
267
295
  if (sidechainEntries > 0) {
268
296
  lines_out.push(`Note: ${sidechainEntries} subagent entries in this stretch — each teammate carries its own context window, and every finished report is injected back here, so running many at once is the fastest way to overflow this session. Avoid spawning more until context drops back under the cap.`);
269
297
  }
@@ -39,6 +39,13 @@ const input = payload?.tool_input || {};
39
39
 
40
40
  function block(message) {
41
41
  process.stderr.write(`BLOCKED (handoff model-tier guard): ${message}\n`);
42
+ // stderr reaches the model, not reliably the user. Pair the refusal with a structured
43
+ // systemMessage (first line only — the full problem list stays in stderr) so a
44
+ // handoff-contract block never looks like a silent stall in the UI.
45
+ const firstLine = String(message).split('\n').find((l) => l.trim()) || 'model-tier contract violated';
46
+ process.stdout.write(`${JSON.stringify({
47
+ systemMessage: `UKit handoff model-tier guard refused this action: ${firstLine.trim()}`,
48
+ })}\n`);
42
49
  process.exit(2);
43
50
  }
44
51
 
@@ -25,6 +25,7 @@ PROJECT_ROOT="${CLAUDE_PROJECT_DIR:-$PWD}"
25
25
  INPUT="$INPUT" PROJECT_ROOT="$PROJECT_ROOT" node <<'NODE' || true
26
26
  const fs = require('fs');
27
27
  const path = require('path');
28
+ const { pathToFileURL } = require('url');
28
29
 
29
30
  const payload = (() => {
30
31
  try {
@@ -37,54 +38,84 @@ const payload = (() => {
37
38
 
38
39
  const projectRoot = process.env.PROJECT_ROOT;
39
40
  const runPath = path.join(projectRoot, 'docs', 'AI_HANDOFF', 'RUN.md');
41
+ const runtimePath = path.join(projectRoot, '.claude', 'ukit', 'runtime', 'execution-ledger.mjs');
42
+ const source = typeof payload.source === 'string' ? payload.source : 'startup';
40
43
 
41
- let text;
42
- try {
43
- text = fs.readFileSync(runPath, 'utf8');
44
- } catch {
45
- // No cursor file: nothing was running, or the last cycle was cleared. Stay silent —
46
- // a resume banner on every ordinary session start would be pure noise.
47
- process.exit(0);
44
+ async function emitOrdinaryResume() {
45
+ if (!fs.existsSync(runtimePath)) return false;
46
+ try {
47
+ const runtime = await import(pathToFileURL(runtimePath).href);
48
+ const intent = await runtime.readResumeIntent(projectRoot, payload, { consume: true });
49
+ if (!intent) return false;
50
+ const out = [
51
+ 'UKIT ROUTED TASK RESUME — an unfinished ordinary routed task was preserved before context compaction.',
52
+ '',
53
+ 'This is a CONTINUATION, not a new request. Do not re-plan or ask whether to continue.',
54
+ 'Resume the current routed task from its existing route state and execution ledger.',
55
+ 'Use one bounded source/context slice if needed, then execute the next missing edit or verification milestone.',
56
+ ];
57
+ if (source === 'compact') {
58
+ out.push('');
59
+ out.push('The session was just compacted, so the window is clean — resume immediately rather than reporting status and waiting.');
60
+ }
61
+ process.stdout.write(`${out.join('\n')}\n`);
62
+ return true;
63
+ } catch {
64
+ return false;
65
+ }
48
66
  }
49
67
 
50
- const field = (name) => (text.match(new RegExp(`^${name}:\\s*(.+)$`, 'm'))?.[1] || '').trim();
68
+ (async () => {
69
+ let text;
70
+ try {
71
+ text = fs.readFileSync(runPath, 'utf8');
72
+ } catch {
73
+ // No handoff cursor: an ordinary routed-task intent may still be resumable.
74
+ await emitOrdinaryResume();
75
+ process.exit(0);
76
+ }
51
77
 
52
- const phase = field('Phase');
53
- // `done` means the last cycle finished cleanly. Anything else means a step was in flight.
54
- if (!phase || /^done$/i.test(phase)) process.exit(0);
78
+ const field = (name) => (text.match(new RegExp(`^${name}:\\s*(.+)$`, 'm'))?.[1] || '').trim();
55
79
 
56
- const goal = field('Goal');
57
- const base = field('Base');
58
- const cursor = field('Cursor');
59
- const next = field('Next');
60
- const source = typeof payload.source === 'string' ? payload.source : 'startup';
80
+ const phase = field('Phase');
81
+ // `done` means the last cycle finished cleanly. Anything else means a step was in flight.
82
+ if (!phase || /^done\b/i.test(phase)) {
83
+ await emitOrdinaryResume();
84
+ process.exit(0);
85
+ }
61
86
 
62
- const out = [
63
- 'UKIT HANDOFF RESUME — an unfinished handoff-fullstack run was found on disk.',
64
- ` Goal: ${goal || '(not recorded)'}`,
65
- ` Base: ${base || '(not recorded)'}`,
66
- ` Phase: ${phase}`,
67
- ` Cursor: ${cursor || '(not recorded)'}`,
68
- ` Next: ${next || '(not recorded)'}`,
69
- '',
70
- 'This is a CONTINUATION, not a new cycle. Do not re-plan, do not overwrite PLAN.md or the',
71
- 'task files, and do not ask the user whether to continue — they already asked for a',
72
- 'one-shot run and the pipeline was interrupted, not cancelled.',
73
- '',
74
- 'Read docs/AI_HANDOFF/RUN.md and docs/AI_HANDOFF/INDEX.md, then execute the `Next:` step',
75
- 'above using .claude/commands/ukit/handoff-fullstack.md as the procedure. Skip tasks',
76
- 'already `done` or `pending_review`; pick up `ready`, `in_progress`, `changes_requested`',
77
- 'and `blocked` ones. Keep writing the cursor after every step.',
78
- ];
87
+ const goal = field('Goal');
88
+ const base = field('Base');
89
+ const cursor = field('Cursor');
90
+ const next = field('Next');
79
91
 
80
- if (source === 'compact') {
81
- out.push('');
82
- out.push('The session was just compacted, so the window is clean — this is the ideal moment');
83
- out.push('to continue. Resume immediately rather than reporting status and waiting.');
84
- }
92
+ const out = [
93
+ 'UKIT HANDOFF RESUME — an unfinished handoff-fullstack run was found on disk.',
94
+ ` Goal: ${goal || '(not recorded)'}`,
95
+ ` Base: ${base || '(not recorded)'}`,
96
+ ` Phase: ${phase}`,
97
+ ` Cursor: ${cursor || '(not recorded)'}`,
98
+ ` Next: ${next || '(not recorded)'}`,
99
+ '',
100
+ 'This is a CONTINUATION, not a new cycle. Do not re-plan, do not overwrite PLAN.md or the',
101
+ 'task files, and do not ask the user whether to continue — they already asked for a',
102
+ 'one-shot run and the pipeline was interrupted, not cancelled.',
103
+ '',
104
+ 'Read docs/AI_HANDOFF/RUN.md and docs/AI_HANDOFF/INDEX.md, then execute the `Next:` step',
105
+ 'above using .claude/commands/ukit/handoff-fullstack.md as the procedure. Skip tasks',
106
+ 'already `done` or `pending_review`; pick up `ready`, `in_progress`, `changes_requested`',
107
+ 'and `blocked` ones. Keep writing the cursor after every step.',
108
+ ];
85
109
 
86
- process.stdout.write(`${out.join('\n')}\n`);
87
- process.exit(0);
110
+ if (source === 'compact') {
111
+ out.push('');
112
+ out.push('The session was just compacted, so the window is clean — this is the ideal moment');
113
+ out.push('to continue. Resume immediately rather than reporting status and waiting.');
114
+ }
115
+
116
+ process.stdout.write(`${out.join('\n')}\n`);
117
+ process.exit(0);
118
+ })();
88
119
  NODE
89
120
 
90
121
  exit 0
@@ -3,7 +3,22 @@
3
3
  # Matched on: Edit|Write
4
4
 
5
5
  INPUT=$(cat)
6
- FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // empty')
6
+ # jq is absent on stock macOS. Keep jq as the low-latency normal path, but fall back to
7
+ # UKit's required Node runtime so a missing optional binary cannot disable this gate.
8
+ if command -v jq >/dev/null 2>&1 && jq --version >/dev/null 2>&1; then
9
+ FILE_PATH=$(printf '%s' "$INPUT" | jq -r '.tool_input.file_path // empty')
10
+ else
11
+ FILE_PATH=$(printf '%s' "$INPUT" | node -e '
12
+ const chunks = [];
13
+ process.stdin.on("data", (chunk) => chunks.push(chunk));
14
+ process.stdin.on("end", () => {
15
+ try {
16
+ const payload = JSON.parse(Buffer.concat(chunks).toString("utf8") || "{}");
17
+ process.stdout.write(typeof payload?.tool_input?.file_path === "string" ? payload.tool_input.file_path : "");
18
+ } catch {}
19
+ });
20
+ ' 2>/dev/null)
21
+ fi
7
22
 
8
23
  if [ -z "$FILE_PATH" ]; then
9
24
  exit 0
@@ -16,23 +31,32 @@ if [[ "$BASENAME" == ".env.example" ]]; then
16
31
  exit 0
17
32
  fi
18
33
 
19
- PROTECTED_PATTERNS=(
20
- ".env"
21
- ".env.local"
22
- ".env.production"
23
- "package-lock.json"
24
- "yarn.lock"
25
- ".git/"
26
- "node_modules/"
27
- ".claude/settings.local.json"
28
- ".ukit/storage/security/"
29
- )
34
+ # Match exact sensitive filenames and directory components, never arbitrary substrings:
35
+ # `.env.d.ts` and `app.envrc` are source files, not secrets. The old `*".env"*`
36
+ # check blocked both, which could appear as a mysterious stopped edit.
37
+ PATH_SLASHED="/${FILE_PATH#/}"
38
+ PROTECTED_MATCH=""
39
+ case "$BASENAME" in
40
+ .env|.env.local|.env.production|package-lock.json|yarn.lock)
41
+ PROTECTED_MATCH="$BASENAME"
42
+ ;;
43
+ esac
44
+ if [ -z "$PROTECTED_MATCH" ]; then
45
+ case "$PATH_SLASHED" in
46
+ */.git/*) PROTECTED_MATCH=".git/" ;;
47
+ */node_modules/*) PROTECTED_MATCH="node_modules/" ;;
48
+ */.claude/settings.local.json) PROTECTED_MATCH=".claude/settings.local.json" ;;
49
+ */.ukit/storage/security/*) PROTECTED_MATCH=".ukit/storage/security/" ;;
50
+ esac
51
+ fi
30
52
 
31
- for pattern in "${PROTECTED_PATTERNS[@]}"; do
32
- if [[ "$FILE_PATH" == *"$pattern"* ]]; then
33
- echo "BLOCKED: Cannot modify '$FILE_PATH' — matches protected pattern '$pattern'. Ask the user to modify this file manually." >&2
34
- exit 2
35
- fi
36
- done
53
+ if [ -n "$PROTECTED_MATCH" ]; then
54
+ # A stderr-only refusal can look like a silent stall when the host does not render
55
+ # hook stderr. Do not expose the path/pattern on stdout: the generic explanation is
56
+ # enough for the user and avoids leaking a potentially sensitive filename.
57
+ printf '%s\n' '{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"ask","permissionDecisionReason":"UKit protected-file guard blocked this edit. Ask the user to modify the protected file manually."}}'
58
+ echo "BLOCKED: Cannot modify '$FILE_PATH' — matches protected pattern '$PROTECTED_MATCH'. Ask the user to modify this file manually." >&2
59
+ exit 2
60
+ fi
37
61
 
38
62
  exit 0
@@ -163,8 +163,18 @@ function scanBashShapes(command) {
163
163
  }
164
164
  }
165
165
 
166
- if (/(^|\s)(-u|--user)\s+["']?[^\s"':]+:[^\s"']+/.test(segment)) {
167
- found.push({ label: 'curl/wget inline credentials (-u user:password)', value: segment });
166
+ // `-u/--user user:pass` is only credentials on tools that authenticate with it.
167
+ // The same flag shape on docker/runuser/chown (`--user 1000:1000`, a uid:gid pair)
168
+ // is NOT a secret — matching it anyway false-positives every containerised test run.
169
+ const headBase = head.replace(/^.*\//, '');
170
+ const CRED_USER_TOOLS = new Set(['curl', 'wget', 'ftp', 'lftp', 'aria2c', 'http', 'https']);
171
+ const inlineUser = /(^|\s)(-u|--user)\s+["']?([^\s"':]+):([^\s"']+)/.exec(segment);
172
+ if (inlineUser && CRED_USER_TOOLS.has(headBase)) {
173
+ // Even on credential tools, a purely numeric pair is a uid:gid, not a password.
174
+ const numericIds = /^\d+$/.test(inlineUser[3]) && /^\d+$/.test(inlineUser[4]);
175
+ if (!numericIds) {
176
+ found.push({ label: 'curl/wget inline credentials (-u user:password)', value: segment });
177
+ }
168
178
  }
169
179
  if (/https?:\/\/[^\s/@'"]+:[^\s/@'"]+@/.test(segment)) {
170
180
  found.push({ label: 'URL with embedded credentials (user:pass@host)', value: segment });
@@ -263,6 +273,12 @@ const lines = [
263
273
  'Never repeat or guess the detected value in your reply. Ask the user instead.',
264
274
  ];
265
275
  process.stderr.write(`${lines.join('\n')}\n`);
276
+ // stderr reaches the model; the user needs a stop reason they can actually see. Emit a
277
+ // structured systemMessage (redacted — labels only, never the matched value) so a
278
+ // sensitive-data block never looks like a silent stall in the UI.
279
+ process.stdout.write(`${JSON.stringify({
280
+ systemMessage: `UKit blocked this call: ${findings.length} potential secret(s) detected (${findings.map((f) => f.label).slice(0, 3).join('; ')}${findings.length > 3 ? '; …' : ''}). Redact the value, allowlist its sha256, or ask the user to decide.`,
281
+ })}\n`);
266
282
  process.exit(2);
267
283
  NODE
268
284
 
@@ -5,6 +5,34 @@
5
5
  INPUT=$(cat)
6
6
  PROJECT_ROOT="${CLAUDE_PROJECT_DIR:-$PWD}"
7
7
 
8
+ # Internal orchestration envelopes are not user requests. Ignore them before routing or
9
+ # compact-pressure bookkeeping so task results cannot replace the active completion contract.
10
+ case "$INPUT" in
11
+ *"<task-notification"*|*"<tool-result"*|*"<system-reminder"*|*"<local-command-caveat"*)
12
+ INTERNAL_ORCHESTRATION=$(INPUT="$INPUT" node -e '
13
+ try {
14
+ const payload = JSON.parse(process.env.INPUT || "{}");
15
+ const candidates = [payload?.prompt, payload?.user_prompt, payload?.text, payload?.message, payload?.input]
16
+ .filter((value) => typeof value === "string")
17
+ .map((value) => value.trim());
18
+ const block = (name) => new RegExp(`^<${name}(?:\\s[^>]*)?>[\\s\\S]*<\\/${name}>$`, "i");
19
+ const internal = candidates.some((value) => (
20
+ block("task-notification").test(value)
21
+ || block("tool-result").test(value)
22
+ || block("system-reminder").test(value)
23
+ || /^<local-command-caveat(?:\s[^>]*)?>[\s\S]*<\/local-command-stdout>$/i.test(value)
24
+ ));
25
+ process.stdout.write(internal ? "1" : "0");
26
+ } catch {
27
+ process.stdout.write("0");
28
+ }
29
+ ' 2>/dev/null)
30
+ if [ "$INTERNAL_ORCHESTRATION" = "1" ]; then
31
+ exit 0
32
+ fi
33
+ ;;
34
+ esac
35
+
8
36
  # Worktree early-exit: when the tool call is scoped to a disposable .worktrees/task-*
9
37
  # tree, there is nothing to route — exit before spawning the router. Reads
10
38
  # tool_input.file_path / tool_input.path / tool_input.command (never tool_input.pattern).
@@ -2367,8 +2395,6 @@ const { pathToFileURL } = require('url');
2367
2395
  const routeCachePath = path.join(projectRoot, '.claude', 'ukit', 'route-cache.json');
2368
2396
  const routeAuditPath = path.join(projectRoot, '.ukit', 'storage', 'cache', 'route-audit.json');
2369
2397
  const cacheUtilsPath = path.join(projectRoot, '.claude', 'ukit', 'index', 'cache-utils.mjs');
2370
- const previous = readJson(statePath, {});
2371
- const runtimeConfig = loadRuntimeConfig(projectRoot);
2372
2398
 
2373
2399
  let payload = {};
2374
2400
  try {
@@ -2377,6 +2403,20 @@ const { pathToFileURL } = require('url');
2377
2403
  payload = {};
2378
2404
  }
2379
2405
 
2406
+ const sessionCandidate = payload.session_id ?? payload.sessionId;
2407
+ const sessionId = sessionCandidate === undefined || sessionCandidate === null
2408
+ ? null
2409
+ : String(sessionCandidate).trim() || null;
2410
+ const storedPrevious = readJson(statePath, {});
2411
+ // Route state is project-local, while hook ledgers are session-local. Do not let a
2412
+ // differently-bound snapshot become the contract for a new session.
2413
+ const previous = sessionId
2414
+ ? (storedPrevious?.sessionId && String(storedPrevious.sessionId).trim() === sessionId
2415
+ ? storedPrevious
2416
+ : {})
2417
+ : storedPrevious;
2418
+ const runtimeConfig = loadRuntimeConfig(projectRoot);
2419
+
2380
2420
  const promptText = extractPrompt(payload);
2381
2421
  const commandText = payload.tool_input?.command || payload.command || '';
2382
2422
  const filePath = normalizeRelativeFile(projectRoot, payload.tool_input?.file_path || payload.file_path || '');
@@ -2447,6 +2487,7 @@ const { pathToFileURL } = require('url');
2447
2487
  // whether a Stop is premature. Downgrading to a route-less state here silently disarms
2448
2488
  // the gate mid-task, so carry the previous route forward until a real route replaces it.
2449
2489
  fs.writeFileSync(statePath, JSON.stringify({
2490
+ ...(sessionId ? { sessionId } : {}),
2450
2491
  fingerprint,
2451
2492
  ts: now,
2452
2493
  source: 'skill-router',
@@ -2539,6 +2580,34 @@ const { pathToFileURL } = require('url');
2539
2580
  routingContext,
2540
2581
  previousContextFingerprint: previousContext?.fingerprint || null,
2541
2582
  });
2583
+ // Persist the completion contract BEFORE any cache import or indexed-context resolution.
2584
+ // Those later steps can take longer than the host's PreToolUse hook timeout. Previously a
2585
+ // timeout killed this process before its only state write, and completion-gate.sh then had
2586
+ // no route to evaluate — so it released Stop with no message. This provisional state has
2587
+ // the full execution/evidence contract; the final richer state below atomically replaces it.
2588
+ const provisionalRouteSummary = buildRouteSummary({
2589
+ activeSkills: selected,
2590
+ routingContext,
2591
+ });
2592
+ provisionalRouteSummary.continuationState = advanceContinuationState(
2593
+ provisionalRouteSummary.continuationState,
2594
+ previous?.routeSummary?.continuationState || null,
2595
+ );
2596
+ applyRescueBiasToRouteSummary(provisionalRouteSummary);
2597
+ const provisionalState = {
2598
+ ...(sessionId ? { sessionId } : {}),
2599
+ requestKey,
2600
+ ts: now,
2601
+ source: 'skill-router-provisional',
2602
+ activeSkills: compactActiveSkills(selected),
2603
+ routingContext: compactRoutingContext(routingContext),
2604
+ ...(previousContext ? { previousContext: compactPreviousContext(previousContext) } : {}),
2605
+ routeSummary: compactRouteSummary(provisionalRouteSummary),
2606
+ };
2607
+ provisionalState.fingerprint = buildRouteStateFingerprint(provisionalState);
2608
+ ensureDir(statePath);
2609
+ fs.writeFileSync(statePath, JSON.stringify(provisionalState));
2610
+
2542
2611
  let cacheUtils = null;
2543
2612
  if (fs.existsSync(cacheUtilsPath)) {
2544
2613
  try {
@@ -2562,8 +2631,12 @@ const { pathToFileURL } = require('url');
2562
2631
  touch: true,
2563
2632
  })
2564
2633
  : null;
2634
+ const sessionCompatibleCachedRoute = !sessionId
2635
+ || Boolean(cachedRouteState?.sessionId)
2636
+ && String(cachedRouteState.sessionId).trim() === sessionId;
2565
2637
  if (
2566
- cachedRouteState?.requestKey === requestKey
2638
+ sessionCompatibleCachedRoute
2639
+ && cachedRouteState?.requestKey === requestKey
2567
2640
  && cachedRouteState?.routeSummary
2568
2641
  && Array.isArray(cachedRouteState?.activeSkills)
2569
2642
  ) {
@@ -2581,6 +2654,7 @@ const { pathToFileURL } = require('url');
2581
2654
  }
2582
2655
  const reusedState = {
2583
2656
  ...cachedRouteState,
2657
+ ...(sessionId ? { sessionId } : {}),
2584
2658
  source: 'skill-router',
2585
2659
  ts: now,
2586
2660
  requestKey,
@@ -2671,6 +2745,7 @@ const { pathToFileURL } = require('url');
2671
2745
  routeSummary,
2672
2746
  });
2673
2747
  const sharedState = {
2748
+ ...(sessionId ? { sessionId } : {}),
2674
2749
  requestKey,
2675
2750
  fingerprint,
2676
2751
  ts: now,
@@ -2709,7 +2784,9 @@ const { pathToFileURL } = require('url');
2709
2784
  ) {
2710
2785
  process.stdout.write(`[ukit-skill-router] Helper: ${routeSummary.helperHint}\n`);
2711
2786
  }
2712
- })().catch(() => {
2787
+ })().catch((error) => {
2788
+ const detail = error?.message || String(error);
2789
+ process.stderr.write(`[ukit-skill-router] ${detail}\n`);
2713
2790
  process.exit(0);
2714
2791
  });
2715
2792
  NODE