@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.
- package/CHANGELOG.md +100 -0
- package/package.json +1 -1
- package/src/cli/commands/diff.js +1 -1
- package/src/cli/commands/install.js +4 -1
- package/src/core/runtimeConfig.js +1 -1
- package/src/index/taskRouting.js +16 -0
- package/templates/.claude/hooks/auto-allow-bash.sh +16 -1
- package/templates/.claude/hooks/block-dangerous.sh +97 -16
- package/templates/.claude/hooks/context-hardcap-gate.sh +131 -36
- package/templates/.claude/hooks/context-window-guard.sh +33 -5
- package/templates/.claude/hooks/handoff-model-guard.sh +7 -0
- package/templates/.claude/hooks/handoff-resume.sh +71 -40
- package/templates/.claude/hooks/protect-files.sh +42 -18
- package/templates/.claude/hooks/sensitive-data-guard.sh +18 -2
- package/templates/.claude/hooks/skill-router.sh +81 -4
- package/templates/.claude/ukit/index/route-task.mjs +90 -23
- package/templates/.claude/ukit/runtime/execution-ledger.mjs +149 -11
- package/templates/.claude/ukit/runtime/hook-chain-runner.mjs +12 -1
- package/templates/.claude/ukit/runtime/reinject-context.mjs +4 -0
- package/templates/.omp/hooks/pre/ukit-bridge.js +83 -5
- package/templates/AGENTS.md +1 -1
- package/templates/CLAUDE.md +1 -1
- package/templates/docs/AI_HANDOFF/RULES.md +2 -2
- package/templates/ukit/storage/config.json +2 -2
|
@@ -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
|
|
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
|
|
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)
|
|
265
|
-
lines_out.push('2)
|
|
266
|
-
lines_out.push('3)
|
|
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
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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
|
-
|
|
87
|
-
|
|
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
|
-
|
|
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
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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
|
-
|
|
167
|
-
|
|
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
|
-
|
|
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
|