@ngockhoale/ukit 2.6.0 → 2.6.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.
- package/CHANGELOG.md +59 -0
- package/manifests/platform.full.yaml +16 -0
- package/package.json +1 -1
- package/src/cli/commands/install.js +49 -2
- package/src/cli/commands/update.js +5 -0
- package/src/core/output/index.js +77 -0
- package/src/core/update.js +36 -5
- package/templates/.claude/commands/ukit/handoff-clear.md +2 -2
- package/templates/.claude/commands/ukit/handoff-fullstack.md +10 -7
- package/templates/.claude/hooks/auto-allow-bash.sh +17 -5
- package/templates/.claude/hooks/block-dangerous.sh +18 -5
- package/templates/.claude/hooks/completion-gate.sh +17 -5
- package/templates/.claude/hooks/compress-output.sh +15 -6
- package/templates/.claude/hooks/context-hardcap-gate.sh +77 -46
- package/templates/.claude/hooks/context-window-guard.sh +41 -27
- package/templates/.claude/hooks/handoff-model-guard.sh +41 -17
- package/templates/.claude/hooks/handoff-resume.sh +20 -7
- package/templates/.claude/hooks/post-edit-verify.sh +17 -5
- package/templates/.claude/hooks/pre-edit-backup.sh +17 -5
- package/templates/.claude/hooks/project-important.sh +18 -1
- package/templates/.claude/hooks/protect-files.sh +18 -5
- package/templates/.claude/hooks/record-execution.sh +17 -5
- package/templates/.claude/hooks/sensitive-data-guard.sh +48 -9
- package/templates/.claude/hooks/skill-router.sh +124 -86
- package/templates/.claude/hooks/stale-spec-guard.sh +22 -6
- package/templates/.claude/hooks/task-watchdog.sh +22 -7
- package/templates/.claude/hooks/verification-guard.sh +17 -5
- package/templates/.claude/hooks/vision-router.sh +17 -5
- package/templates/.claude/settings.json +15 -10
- package/templates/.claude/ukit/index/provision-worktree.mjs +30 -2
- package/templates/.claude/ukit/runtime/execution-ledger.mjs +115 -1
- package/templates/.claude/ukit/runtime/hook-input.sh +25 -0
- package/templates/.claude/ukit/runtime/hook-telemetry.mjs +86 -2
- package/templates/.claude/ukit/runtime/output-compression.mjs +87 -0
- package/templates/.omp/hooks/pre/ukit-bridge.js +110 -3
- package/templates/ukit/storage/config.json +1 -0
|
@@ -12,7 +12,15 @@ source "$SCRIPT_DIR/../ukit/runtime/hook-telemetry.sh" 2>/dev/null || true
|
|
|
12
12
|
trap ukit_cleanup_hook_input EXIT
|
|
13
13
|
# Bounded stdin (H01): stage before anything reads it; the shell var is capped,
|
|
14
14
|
# env passes only the staged-file path — never the payload.
|
|
15
|
-
ukit_stage_hook_input 2097152 truncate
|
|
15
|
+
ukit_stage_hook_input 2097152 truncate
|
|
16
|
+
__ukit_main_stage_rc=$?
|
|
17
|
+
if [ "$__ukit_main_stage_rc" -ne 0 ]; then
|
|
18
|
+
# Infra failure during staging (mktemp/truncate): payload was never
|
|
19
|
+
# inspected — announce the degrade (SPEC §8), never a silent pass.
|
|
20
|
+
ukit_emit_input_degraded advisory "skill-router"
|
|
21
|
+
fi
|
|
22
|
+
# BUG-C21-01: a truncated/stalled staged payload must be announced (SPEC §8), never silently passed.
|
|
23
|
+
ukit_input_degraded && ukit_emit_input_degraded advisory "skill-router"
|
|
16
24
|
else
|
|
17
25
|
# Runtime helper missing (pre-install tree): the SAME bounded staging,
|
|
18
26
|
# inline - `cat >/dev/null` used to block forever on a producer that never
|
|
@@ -39,10 +47,14 @@ else
|
|
|
39
47
|
wait "$__ukit_waiter" 2>/dev/null
|
|
40
48
|
exec 8<&-
|
|
41
49
|
__ukit_size="$(wc -c < "$UKIT_INPUT_FILE" 2>/dev/null | tr -d '[:space:]')"
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
50
|
+
if [ "$__ukit_stage_rc" -ne 0 ] || [ "$__ukit_size" -gt 2097152 ]; then
|
|
51
|
+
# BUG-C21-01 fallback parity (SPEC §8): a truncated/stalled staged payload
|
|
52
|
+
# must be announced, never silently acted on — same shape as the helper
|
|
53
|
+
# path's ukit_emit_input_degraded.
|
|
54
|
+
rm -f "$UKIT_INPUT_FILE"
|
|
55
|
+
UKIT_INPUT_FILE=""
|
|
56
|
+
printf '%s\n' '{"systemMessage":"UKit skill-router: tool-call payload was truncated or stalled during stdin staging; skipped this pass rather than act on incomplete input."}'
|
|
57
|
+
exit 0
|
|
46
58
|
fi
|
|
47
59
|
trap '[ -n "$UKIT_INPUT_FILE" ] && rm -f "$UKIT_INPUT_FILE"' EXIT
|
|
48
60
|
fi
|
|
@@ -57,8 +69,11 @@ case "$INPUT" in
|
|
|
57
69
|
// wall-clock watchdog: never let a wedged parse outlive the hook (same arm as the router heredoc below)
|
|
58
70
|
const HOOK_DEADLINE_MS = Number.parseInt(process.env.UKIT_HOOK_DEADLINE_MS || "", 10) || 3000;
|
|
59
71
|
setTimeout(() => process.exit(0), HOOK_DEADLINE_MS).unref();
|
|
72
|
+
// BUG-C21-04: the staged read is async — a sync read on a stalled mount parks
|
|
73
|
+
// the event loop and the deadline above could never fire.
|
|
74
|
+
require("fs").promises.readFile(process.env.INPUT_FILE || "", "utf8").then((rawInput) => {
|
|
60
75
|
try {
|
|
61
|
-
const payload = JSON.parse(
|
|
76
|
+
const payload = JSON.parse(rawInput || "{}");
|
|
62
77
|
const candidates = [payload?.prompt, payload?.user_prompt, payload?.text, payload?.message, payload?.input]
|
|
63
78
|
.filter((value) => typeof value === "string")
|
|
64
79
|
.map((value) => value.trim());
|
|
@@ -73,6 +88,7 @@ try {
|
|
|
73
88
|
} catch {
|
|
74
89
|
process.stdout.write("0");
|
|
75
90
|
}
|
|
91
|
+
}).catch(() => process.stdout.write("0"));
|
|
76
92
|
' 2>/dev/null)
|
|
77
93
|
if [ "$INTERNAL_ORCHESTRATION" = "1" ]; then
|
|
78
94
|
exit 0
|
|
@@ -92,8 +108,11 @@ case "$INPUT" in
|
|
|
92
108
|
*".worktrees/"*)
|
|
93
109
|
if printf '%s' "$INPUT" | node -e '
|
|
94
110
|
// wall-clock watchdog: never let a wedged stdin read outlive the hook (same arm as the router heredoc below)
|
|
111
|
+
// BUG-C21-06 class: the deadline must NOT exit 0 — the wrapper reads exit 0 as
|
|
112
|
+
// "worktree match → skip routing", so a stalled pre-check would silently drop
|
|
113
|
+
// the router. Non-zero falls through to the normal hook path below.
|
|
95
114
|
const HOOK_DEADLINE_MS = Number.parseInt(process.env.UKIT_HOOK_DEADLINE_MS || "", 10) || 3000;
|
|
96
|
-
setTimeout(() => process.exit(
|
|
115
|
+
setTimeout(() => process.exit(1), HOOK_DEADLINE_MS).unref();
|
|
97
116
|
const chunks = [];
|
|
98
117
|
process.stdin.on("data", (c) => chunks.push(c));
|
|
99
118
|
process.stdin.on("end", () => {
|
|
@@ -130,8 +149,10 @@ case "$INPUT" in
|
|
|
130
149
|
*'"agent_id"'*|*'"agent_type"'*)
|
|
131
150
|
if printf '%s' "$INPUT" | node -e '
|
|
132
151
|
// wall-clock watchdog: never let a wedged stdin read outlive the hook (same arm as the router heredoc below)
|
|
152
|
+
// BUG-C21-06 class: deadline exit(0) reads as "subagent → skip routing"; a
|
|
153
|
+
// stalled pre-check must fall through instead. Exit non-zero on expiry.
|
|
133
154
|
const HOOK_DEADLINE_MS = Number.parseInt(process.env.UKIT_HOOK_DEADLINE_MS || "", 10) || 3000;
|
|
134
|
-
setTimeout(() => process.exit(
|
|
155
|
+
setTimeout(() => process.exit(1), HOOK_DEADLINE_MS).unref();
|
|
135
156
|
const chunks = [];
|
|
136
157
|
process.stdin.on("data", (c) => chunks.push(c));
|
|
137
158
|
process.stdin.on("end", () => {
|
|
@@ -173,30 +194,39 @@ setTimeout(() => process.exit(0), HOOK_DEADLINE_MS).unref();
|
|
|
173
194
|
'cần', 'và', 'là', 'cho', 'một', 'những', 'dùng',
|
|
174
195
|
]);
|
|
175
196
|
|
|
176
|
-
|
|
197
|
+
// BUG-C21-04: every fs call in this deadline-guarded block is async — a sync
|
|
198
|
+
// read/stat/scan on a stalled mount parks the event loop and the unref'd
|
|
199
|
+
// deadline above can never fire (orphan node grandchild).
|
|
200
|
+
const fsp = fs.promises;
|
|
201
|
+
|
|
202
|
+
async function pathExists(filePath) {
|
|
203
|
+
try { await fsp.access(filePath); return true; } catch { return false; }
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
async function readJson(filePath, fallback = null) {
|
|
177
207
|
try {
|
|
178
|
-
return JSON.parse(
|
|
208
|
+
return JSON.parse(await fsp.readFile(filePath, 'utf8'));
|
|
179
209
|
} catch {
|
|
180
210
|
return fallback;
|
|
181
211
|
}
|
|
182
212
|
}
|
|
183
213
|
|
|
184
|
-
function ensureDir(filePath) {
|
|
185
|
-
|
|
214
|
+
async function ensureDir(filePath) {
|
|
215
|
+
await fsp.mkdir(path.dirname(filePath), { recursive: true });
|
|
186
216
|
}
|
|
187
217
|
|
|
188
|
-
function writeJsonFileAtomic(filePath, text) {
|
|
218
|
+
async function writeJsonFileAtomic(filePath, text) {
|
|
189
219
|
// tmp+rename: readers see old-or-new, never a torn/half file. A truncated
|
|
190
220
|
// skill-router-state.json parses as `{}` downstream and completion-gate.sh then
|
|
191
221
|
// releases a premature Stop with no message — the mid-run silent-stop class.
|
|
192
|
-
ensureDir(filePath);
|
|
222
|
+
await ensureDir(filePath);
|
|
193
223
|
const tempPath = `${filePath}.tmp-${process.pid}-${crypto.randomBytes(6).toString('hex')}`;
|
|
194
224
|
try {
|
|
195
|
-
|
|
196
|
-
|
|
225
|
+
await fsp.writeFile(tempPath, text);
|
|
226
|
+
await fsp.rename(tempPath, filePath);
|
|
197
227
|
} catch (error) {
|
|
198
228
|
try {
|
|
199
|
-
|
|
229
|
+
await fsp.rm(tempPath, { force: true });
|
|
200
230
|
} catch {}
|
|
201
231
|
throw error;
|
|
202
232
|
}
|
|
@@ -249,14 +279,14 @@ setTimeout(() => process.exit(0), HOOK_DEADLINE_MS).unref();
|
|
|
249
279
|
};
|
|
250
280
|
}
|
|
251
281
|
|
|
252
|
-
function loadRuntimeConfig(projectRoot) {
|
|
282
|
+
async function loadRuntimeConfig(projectRoot) {
|
|
253
283
|
const runtimePaths = buildRuntimePaths(projectRoot);
|
|
254
|
-
const raw = readJson(runtimePaths.configPath, {});
|
|
284
|
+
const raw = await readJson(runtimePaths.configPath, {});
|
|
255
285
|
return deepMerge(defaultRuntimeConfig(), raw && typeof raw === 'object' ? raw : {});
|
|
256
286
|
}
|
|
257
287
|
|
|
258
|
-
function readIndexGeneratedAtMs(projectRoot) {
|
|
259
|
-
const artifact = readJson(path.join(projectRoot, '.cache', 'index', 'files.json'), null);
|
|
288
|
+
async function readIndexGeneratedAtMs(projectRoot) {
|
|
289
|
+
const artifact = await readJson(path.join(projectRoot, '.cache', 'index', 'files.json'), null);
|
|
260
290
|
const generatedAtRaw = String(artifact?.generatedAt || '').trim();
|
|
261
291
|
if (!generatedAtRaw) {
|
|
262
292
|
return null;
|
|
@@ -268,7 +298,7 @@ setTimeout(() => process.exit(0), HOOK_DEADLINE_MS).unref();
|
|
|
268
298
|
|
|
269
299
|
async function loadRouteCatalog(projectRoot) {
|
|
270
300
|
const routeCatalogPath = path.join(projectRoot, '.claude', 'ukit', 'index', 'route-catalog.mjs');
|
|
271
|
-
if (!
|
|
301
|
+
if (!(await pathExists(routeCatalogPath))) {
|
|
272
302
|
return [];
|
|
273
303
|
}
|
|
274
304
|
|
|
@@ -289,7 +319,7 @@ setTimeout(() => process.exit(0), HOOK_DEADLINE_MS).unref();
|
|
|
289
319
|
].filter(Boolean);
|
|
290
320
|
|
|
291
321
|
for (const indexCorePath of candidatePaths) {
|
|
292
|
-
if (!
|
|
322
|
+
if (!(await pathExists(indexCorePath))) {
|
|
293
323
|
continue;
|
|
294
324
|
}
|
|
295
325
|
|
|
@@ -312,8 +342,8 @@ setTimeout(() => process.exit(0), HOOK_DEADLINE_MS).unref();
|
|
|
312
342
|
};
|
|
313
343
|
}
|
|
314
344
|
|
|
315
|
-
function existsSkill(projectRoot, relativePath) {
|
|
316
|
-
return
|
|
345
|
+
async function existsSkill(projectRoot, relativePath) {
|
|
346
|
+
return pathExists(path.join(projectRoot, relativePath));
|
|
317
347
|
}
|
|
318
348
|
|
|
319
349
|
function scoreSkillRouteEntry(entry, routeSignals = {}) {
|
|
@@ -793,7 +823,7 @@ setTimeout(() => process.exit(0), HOOK_DEADLINE_MS).unref();
|
|
|
793
823
|
};
|
|
794
824
|
|
|
795
825
|
const indexCorePath = path.join(projectRoot, '.claude', 'ukit', 'index', 'lib', 'index-core.mjs');
|
|
796
|
-
if (!
|
|
826
|
+
if (!(await pathExists(indexCorePath))) {
|
|
797
827
|
return recommendation;
|
|
798
828
|
}
|
|
799
829
|
|
|
@@ -824,18 +854,18 @@ setTimeout(() => process.exit(0), HOOK_DEADLINE_MS).unref();
|
|
|
824
854
|
}
|
|
825
855
|
}
|
|
826
856
|
|
|
827
|
-
function readProjectPackage(projectRoot) {
|
|
857
|
+
async function readProjectPackage(projectRoot) {
|
|
828
858
|
return readJson(path.join(projectRoot, 'package.json'), null);
|
|
829
859
|
}
|
|
830
860
|
|
|
831
|
-
function readProjectVerificationFingerprint(projectRoot) {
|
|
861
|
+
async function readProjectVerificationFingerprint(projectRoot) {
|
|
832
862
|
let pkg = null;
|
|
833
863
|
const entries = [];
|
|
834
864
|
|
|
835
865
|
try {
|
|
836
866
|
const packageJsonPath = path.join(projectRoot, 'package.json');
|
|
837
|
-
const raw =
|
|
838
|
-
const stat =
|
|
867
|
+
const raw = await fsp.readFile(packageJsonPath, 'utf8');
|
|
868
|
+
const stat = await fsp.stat(packageJsonPath);
|
|
839
869
|
pkg = JSON.parse(raw);
|
|
840
870
|
entries.push({
|
|
841
871
|
filePath: 'package.json',
|
|
@@ -859,9 +889,9 @@ setTimeout(() => process.exit(0), HOOK_DEADLINE_MS).unref();
|
|
|
859
889
|
'bun.lockb',
|
|
860
890
|
'bun.lock',
|
|
861
891
|
];
|
|
862
|
-
entries.push(...trackedFiles.map((relativePath) => {
|
|
892
|
+
entries.push(...(await Promise.all(trackedFiles.map(async (relativePath) => {
|
|
863
893
|
try {
|
|
864
|
-
const stat =
|
|
894
|
+
const stat = await fsp.stat(path.join(projectRoot, relativePath));
|
|
865
895
|
return {
|
|
866
896
|
filePath: relativePath,
|
|
867
897
|
size: stat.size,
|
|
@@ -874,7 +904,7 @@ setTimeout(() => process.exit(0), HOOK_DEADLINE_MS).unref();
|
|
|
874
904
|
mtimeMs: null,
|
|
875
905
|
};
|
|
876
906
|
}
|
|
877
|
-
}));
|
|
907
|
+
}))));
|
|
878
908
|
|
|
879
909
|
return {
|
|
880
910
|
pkg,
|
|
@@ -894,8 +924,8 @@ setTimeout(() => process.exit(0), HOOK_DEADLINE_MS).unref();
|
|
|
894
924
|
return null;
|
|
895
925
|
}
|
|
896
926
|
|
|
897
|
-
function detectProjectId(projectRoot) {
|
|
898
|
-
const pkg = readJson(path.join(projectRoot, 'package.json'), null);
|
|
927
|
+
async function detectProjectId(projectRoot) {
|
|
928
|
+
const pkg = await readJson(path.join(projectRoot, 'package.json'), null);
|
|
899
929
|
if (typeof pkg?.name === 'string' && pkg.name.trim()) {
|
|
900
930
|
return pkg.name.trim();
|
|
901
931
|
}
|
|
@@ -903,10 +933,10 @@ setTimeout(() => process.exit(0), HOOK_DEADLINE_MS).unref();
|
|
|
903
933
|
return path.basename(projectRoot);
|
|
904
934
|
}
|
|
905
935
|
|
|
906
|
-
function readDirectoryJsonItems(dirPath) {
|
|
936
|
+
async function readDirectoryJsonItems(dirPath) {
|
|
907
937
|
let entries = [];
|
|
908
938
|
try {
|
|
909
|
-
entries =
|
|
939
|
+
entries = await fsp.readdir(dirPath, { withFileTypes: true });
|
|
910
940
|
} catch {
|
|
911
941
|
return [];
|
|
912
942
|
}
|
|
@@ -918,7 +948,7 @@ setTimeout(() => process.exit(0), HOOK_DEADLINE_MS).unref();
|
|
|
918
948
|
}
|
|
919
949
|
|
|
920
950
|
const fullPath = path.join(dirPath, entry.name);
|
|
921
|
-
const content = readJson(fullPath, null);
|
|
951
|
+
const content = await readJson(fullPath, null);
|
|
922
952
|
if (content && typeof content === 'object') {
|
|
923
953
|
items.push({
|
|
924
954
|
fileName: entry.name,
|
|
@@ -931,11 +961,11 @@ setTimeout(() => process.exit(0), HOOK_DEADLINE_MS).unref();
|
|
|
931
961
|
return items.sort((left, right) => left.fileName.localeCompare(right.fileName));
|
|
932
962
|
}
|
|
933
963
|
|
|
934
|
-
function listMemoryItems(projectRoot) {
|
|
964
|
+
async function listMemoryItems(projectRoot) {
|
|
935
965
|
const runtimePaths = buildRuntimePaths(projectRoot);
|
|
936
|
-
const userMemory = readJson(runtimePaths.userMemoryPath, null) ?? { preferences: {}, rules: [] };
|
|
937
|
-
const projectMemories = readDirectoryJsonItems(runtimePaths.projectsDir);
|
|
938
|
-
const sessionMemories = readDirectoryJsonItems(runtimePaths.sessionsDir);
|
|
966
|
+
const userMemory = (await readJson(runtimePaths.userMemoryPath, null)) ?? { preferences: {}, rules: [] };
|
|
967
|
+
const projectMemories = await readDirectoryJsonItems(runtimePaths.projectsDir);
|
|
968
|
+
const sessionMemories = await readDirectoryJsonItems(runtimePaths.sessionsDir);
|
|
939
969
|
|
|
940
970
|
return [
|
|
941
971
|
{
|
|
@@ -1051,7 +1081,7 @@ setTimeout(() => process.exit(0), HOOK_DEADLINE_MS).unref();
|
|
|
1051
1081
|
return `[user] rules: ${rules || 'n/a'}`;
|
|
1052
1082
|
}
|
|
1053
1083
|
|
|
1054
|
-
function buildPreviousContextSnapshot({
|
|
1084
|
+
async function buildPreviousContextSnapshot({
|
|
1055
1085
|
projectRoot,
|
|
1056
1086
|
routingContext = {},
|
|
1057
1087
|
config = defaultRuntimeConfig(),
|
|
@@ -1070,8 +1100,8 @@ setTimeout(() => process.exit(0), HOOK_DEADLINE_MS).unref();
|
|
|
1070
1100
|
return null;
|
|
1071
1101
|
}
|
|
1072
1102
|
|
|
1073
|
-
const projectId = detectProjectId(projectRoot);
|
|
1074
|
-
const items = listMemoryItems(projectRoot);
|
|
1103
|
+
const projectId = await detectProjectId(projectRoot);
|
|
1104
|
+
const items = await listMemoryItems(projectRoot);
|
|
1075
1105
|
const queryTokens = tokenizeMemoryText(taskQuery);
|
|
1076
1106
|
const rankedItems = items
|
|
1077
1107
|
.filter((item) => item.type !== 'user')
|
|
@@ -1113,13 +1143,13 @@ setTimeout(() => process.exit(0), HOOK_DEADLINE_MS).unref();
|
|
|
1113
1143
|
};
|
|
1114
1144
|
}
|
|
1115
1145
|
|
|
1116
|
-
function detectPackageManager(projectRoot, pkg) {
|
|
1146
|
+
async function detectPackageManager(projectRoot, pkg) {
|
|
1117
1147
|
const declared = getDeclaredPackageManager(pkg);
|
|
1118
1148
|
if (declared) return declared;
|
|
1119
1149
|
|
|
1120
|
-
if (
|
|
1121
|
-
if (
|
|
1122
|
-
if (
|
|
1150
|
+
if (await pathExists(path.join(projectRoot, 'pnpm-lock.yaml'))) return 'pnpm';
|
|
1151
|
+
if (await pathExists(path.join(projectRoot, 'yarn.lock'))) return 'yarn';
|
|
1152
|
+
if (await pathExists(path.join(projectRoot, 'bun.lockb')) || await pathExists(path.join(projectRoot, 'bun.lock'))) return 'bun';
|
|
1123
1153
|
return 'npm';
|
|
1124
1154
|
}
|
|
1125
1155
|
|
|
@@ -1141,7 +1171,7 @@ setTimeout(() => process.exit(0), HOOK_DEADLINE_MS).unref();
|
|
|
1141
1171
|
return [packageManager, scriptName, ...safeArgs].join(' ');
|
|
1142
1172
|
}
|
|
1143
1173
|
|
|
1144
|
-
function buildVerificationRecommendation({
|
|
1174
|
+
async function buildVerificationRecommendation({
|
|
1145
1175
|
projectRoot,
|
|
1146
1176
|
selectedIds,
|
|
1147
1177
|
contextRecommendation,
|
|
@@ -1150,9 +1180,9 @@ setTimeout(() => process.exit(0), HOOK_DEADLINE_MS).unref();
|
|
|
1150
1180
|
taskType,
|
|
1151
1181
|
pkg = null,
|
|
1152
1182
|
}) {
|
|
1153
|
-
const resolvedPkg = pkg || readProjectPackage(projectRoot) || {};
|
|
1183
|
+
const resolvedPkg = pkg || (await readProjectPackage(projectRoot)) || {};
|
|
1154
1184
|
const scripts = typeof resolvedPkg.scripts === 'object' && resolvedPkg.scripts ? resolvedPkg.scripts : {};
|
|
1155
|
-
const packageManager = detectPackageManager(projectRoot, resolvedPkg);
|
|
1185
|
+
const packageManager = await detectPackageManager(projectRoot, resolvedPkg);
|
|
1156
1186
|
const preview = contextRecommendation?.preview || {};
|
|
1157
1187
|
const relatedTests = [...new Set([
|
|
1158
1188
|
...(Array.isArray(preview.relatedTests) ? preview.relatedTests : []),
|
|
@@ -2419,8 +2449,8 @@ setTimeout(() => process.exit(0), HOOK_DEADLINE_MS).unref();
|
|
|
2419
2449
|
}
|
|
2420
2450
|
}
|
|
2421
2451
|
|
|
2422
|
-
function readLockOwner(lockPath) {
|
|
2423
|
-
const owner = readJson(path.join(lockPath, 'owner'), null);
|
|
2452
|
+
async function readLockOwner(lockPath) {
|
|
2453
|
+
const owner = await readJson(path.join(lockPath, 'owner'), null);
|
|
2424
2454
|
return owner && Number.isInteger(owner.pid) && typeof owner.token === 'string'
|
|
2425
2455
|
? owner
|
|
2426
2456
|
: null;
|
|
@@ -2437,16 +2467,16 @@ setTimeout(() => process.exit(0), HOOK_DEADLINE_MS).unref();
|
|
|
2437
2467
|
let held = false;
|
|
2438
2468
|
while (!held) {
|
|
2439
2469
|
try {
|
|
2440
|
-
|
|
2470
|
+
await fsp.mkdir(lockPath);
|
|
2441
2471
|
try {
|
|
2442
|
-
|
|
2472
|
+
await fsp.writeFile(path.join(lockPath, 'owner'), JSON.stringify({
|
|
2443
2473
|
pid: process.pid,
|
|
2444
2474
|
token: ownerToken,
|
|
2445
2475
|
ts: Date.now(),
|
|
2446
2476
|
}));
|
|
2447
2477
|
} catch (error) {
|
|
2448
2478
|
try {
|
|
2449
|
-
|
|
2479
|
+
await fsp.rm(lockPath, { recursive: true, force: true });
|
|
2450
2480
|
} catch {}
|
|
2451
2481
|
throw error;
|
|
2452
2482
|
}
|
|
@@ -2454,12 +2484,12 @@ setTimeout(() => process.exit(0), HOOK_DEADLINE_MS).unref();
|
|
|
2454
2484
|
} catch (error) {
|
|
2455
2485
|
if (!error || error.code !== 'EEXIST') return fn();
|
|
2456
2486
|
try {
|
|
2457
|
-
const stat =
|
|
2487
|
+
const stat = await fsp.stat(lockPath);
|
|
2458
2488
|
if (Date.now() - stat.mtimeMs > staleMs) {
|
|
2459
|
-
const owner = readLockOwner(lockPath);
|
|
2489
|
+
const owner = await readLockOwner(lockPath);
|
|
2460
2490
|
if (!owner || !isPidAlive(owner.pid)) {
|
|
2461
2491
|
try {
|
|
2462
|
-
|
|
2492
|
+
await fsp.rm(lockPath, { recursive: true, force: true });
|
|
2463
2493
|
continue;
|
|
2464
2494
|
} catch {}
|
|
2465
2495
|
}
|
|
@@ -2473,10 +2503,10 @@ setTimeout(() => process.exit(0), HOOK_DEADLINE_MS).unref();
|
|
|
2473
2503
|
return await fn();
|
|
2474
2504
|
} finally {
|
|
2475
2505
|
if (held) {
|
|
2476
|
-
const owner = readLockOwner(lockPath);
|
|
2506
|
+
const owner = await readLockOwner(lockPath);
|
|
2477
2507
|
if (owner?.token === ownerToken) {
|
|
2478
2508
|
try {
|
|
2479
|
-
|
|
2509
|
+
await fsp.rm(lockPath, { recursive: true, force: true });
|
|
2480
2510
|
} catch {}
|
|
2481
2511
|
}
|
|
2482
2512
|
}
|
|
@@ -2488,11 +2518,11 @@ setTimeout(() => process.exit(0), HOOK_DEADLINE_MS).unref();
|
|
|
2488
2518
|
return;
|
|
2489
2519
|
}
|
|
2490
2520
|
|
|
2491
|
-
await withStateLock(`${filePath}.lock`, () => {
|
|
2521
|
+
await withStateLock(`${filePath}.lock`, async () => {
|
|
2492
2522
|
let parsed = { entries: [] };
|
|
2493
|
-
if (
|
|
2523
|
+
if (await pathExists(filePath)) {
|
|
2494
2524
|
try {
|
|
2495
|
-
parsed = JSON.parse(
|
|
2525
|
+
parsed = JSON.parse(await fsp.readFile(filePath, 'utf8'));
|
|
2496
2526
|
} catch {
|
|
2497
2527
|
parsed = { entries: [] };
|
|
2498
2528
|
}
|
|
@@ -2519,7 +2549,7 @@ setTimeout(() => process.exit(0), HOOK_DEADLINE_MS).unref();
|
|
|
2519
2549
|
return itemKey !== dedupeKey;
|
|
2520
2550
|
});
|
|
2521
2551
|
|
|
2522
|
-
writeJsonFileAtomic(filePath, JSON.stringify({
|
|
2552
|
+
await writeJsonFileAtomic(filePath, JSON.stringify({
|
|
2523
2553
|
entries: [entry, ...filtered].slice(0, 40),
|
|
2524
2554
|
}));
|
|
2525
2555
|
});
|
|
@@ -2620,7 +2650,7 @@ setTimeout(() => process.exit(0), HOOK_DEADLINE_MS).unref();
|
|
|
2620
2650
|
}
|
|
2621
2651
|
|
|
2622
2652
|
let rawInput = '';
|
|
2623
|
-
try { rawInput =
|
|
2653
|
+
try { rawInput = await fsp.readFile(process.env.INPUT_FILE || '', 'utf8'); } catch {}
|
|
2624
2654
|
const projectRoot = process.env.PROJECT_ROOT || process.cwd();
|
|
2625
2655
|
const statePath = process.env.STATE_FILE || path.join(projectRoot, '.claude', 'ukit', 'skill-router-state.json');
|
|
2626
2656
|
const routeCachePath = path.join(projectRoot, '.claude', 'ukit', 'route-cache.json');
|
|
@@ -2638,7 +2668,7 @@ setTimeout(() => process.exit(0), HOOK_DEADLINE_MS).unref();
|
|
|
2638
2668
|
const sessionId = sessionCandidate === undefined || sessionCandidate === null
|
|
2639
2669
|
? null
|
|
2640
2670
|
: String(sessionCandidate).trim() || null;
|
|
2641
|
-
const storedPrevious = readJson(statePath, {});
|
|
2671
|
+
const storedPrevious = await readJson(statePath, {});
|
|
2642
2672
|
// Route state is project-local, while hook ledgers are session-local. Do not let a
|
|
2643
2673
|
// differently-bound snapshot become the contract for a new session.
|
|
2644
2674
|
const previous = sessionId
|
|
@@ -2646,7 +2676,7 @@ setTimeout(() => process.exit(0), HOOK_DEADLINE_MS).unref();
|
|
|
2646
2676
|
? storedPrevious
|
|
2647
2677
|
: {})
|
|
2648
2678
|
: storedPrevious;
|
|
2649
|
-
const runtimeConfig = loadRuntimeConfig(projectRoot);
|
|
2679
|
+
const runtimeConfig = await loadRuntimeConfig(projectRoot);
|
|
2650
2680
|
|
|
2651
2681
|
const promptText = extractPrompt(payload);
|
|
2652
2682
|
const commandText = payload.tool_input?.command || payload.command || '';
|
|
@@ -2667,10 +2697,18 @@ setTimeout(() => process.exit(0), HOOK_DEADLINE_MS).unref();
|
|
|
2667
2697
|
|
|
2668
2698
|
const catalog = await loadRouteCatalog(projectRoot);
|
|
2669
2699
|
|
|
2670
|
-
const
|
|
2700
|
+
const scoredCatalog = catalog
|
|
2671
2701
|
.map((entry) => scoreSkillRouteEntry(entry, routeSignals))
|
|
2672
|
-
.filter((entry) => entry.score > 0
|
|
2673
|
-
|
|
2702
|
+
.filter((entry) => entry.score > 0);
|
|
2703
|
+
const active = [];
|
|
2704
|
+
for (const entry of scoredCatalog) {
|
|
2705
|
+
if (await existsSkill(projectRoot, entry.path)) {
|
|
2706
|
+
active.push(entry);
|
|
2707
|
+
}
|
|
2708
|
+
}
|
|
2709
|
+
const keptActive = active.filter((entry) => shouldKeepRouteEntryForIntent(entry, intentMode));
|
|
2710
|
+
active.length = 0;
|
|
2711
|
+
active.push(...keptActive);
|
|
2674
2712
|
|
|
2675
2713
|
const now = Date.now();
|
|
2676
2714
|
const debounceMs = 10 * 60 * 1000;
|
|
@@ -2723,7 +2761,7 @@ setTimeout(() => process.exit(0), HOOK_DEADLINE_MS).unref();
|
|
|
2723
2761
|
taskType: '',
|
|
2724
2762
|
...((informationalNoTargetPrompt || deliveryOnlyNoRoute) ? { executionMode: 'informational' } : {}),
|
|
2725
2763
|
};
|
|
2726
|
-
const previousContext = buildPreviousContextSnapshot({
|
|
2764
|
+
const previousContext = await buildPreviousContextSnapshot({
|
|
2727
2765
|
projectRoot,
|
|
2728
2766
|
routingContext,
|
|
2729
2767
|
config: runtimeConfig,
|
|
@@ -2745,12 +2783,12 @@ setTimeout(() => process.exit(0), HOOK_DEADLINE_MS).unref();
|
|
|
2745
2783
|
process.exit(0);
|
|
2746
2784
|
}
|
|
2747
2785
|
|
|
2748
|
-
ensureDir(statePath);
|
|
2786
|
+
await ensureDir(statePath);
|
|
2749
2787
|
// A no-route prompt (short nudge, informational question) must not wipe the last full
|
|
2750
2788
|
// route: completion-gate.sh reads routeSummary/requestKey from this state to decide
|
|
2751
2789
|
// whether a Stop is premature. Downgrading to a route-less state here silently disarms
|
|
2752
2790
|
// the gate mid-task, so carry the previous route forward until a real route replaces it.
|
|
2753
|
-
writeJsonFileAtomic(statePath, JSON.stringify({
|
|
2791
|
+
await writeJsonFileAtomic(statePath, JSON.stringify({
|
|
2754
2792
|
...(sessionId ? { sessionId } : {}),
|
|
2755
2793
|
fingerprint,
|
|
2756
2794
|
ts: now,
|
|
@@ -2825,17 +2863,17 @@ setTimeout(() => process.exit(0), HOOK_DEADLINE_MS).unref();
|
|
|
2825
2863
|
routingContext,
|
|
2826
2864
|
useIndexedContext,
|
|
2827
2865
|
})
|
|
2828
|
-
? buildPreviousContextSnapshot({
|
|
2866
|
+
? await buildPreviousContextSnapshot({
|
|
2829
2867
|
projectRoot,
|
|
2830
2868
|
routingContext,
|
|
2831
2869
|
config: runtimeConfig,
|
|
2832
2870
|
})
|
|
2833
2871
|
: null;
|
|
2834
2872
|
const indexGeneratedAtMs = useIndexedContext
|
|
2835
|
-
? readIndexGeneratedAtMs(projectRoot)
|
|
2873
|
+
? await readIndexGeneratedAtMs(projectRoot)
|
|
2836
2874
|
: null;
|
|
2837
2875
|
const projectVerification = useIndexedContext
|
|
2838
|
-
? readProjectVerificationFingerprint(projectRoot)
|
|
2876
|
+
? await readProjectVerificationFingerprint(projectRoot)
|
|
2839
2877
|
: null;
|
|
2840
2878
|
const requestKey = buildRouteRequestKey({
|
|
2841
2879
|
commandNamespace: '.claude',
|
|
@@ -2870,11 +2908,11 @@ setTimeout(() => process.exit(0), HOOK_DEADLINE_MS).unref();
|
|
|
2870
2908
|
routeSummary: compactRouteSummary(provisionalRouteSummary),
|
|
2871
2909
|
};
|
|
2872
2910
|
provisionalState.fingerprint = buildRouteStateFingerprint(provisionalState);
|
|
2873
|
-
ensureDir(statePath);
|
|
2874
|
-
writeJsonFileAtomic(statePath, JSON.stringify(provisionalState));
|
|
2911
|
+
await ensureDir(statePath);
|
|
2912
|
+
await writeJsonFileAtomic(statePath, JSON.stringify(provisionalState));
|
|
2875
2913
|
|
|
2876
2914
|
let cacheUtils = null;
|
|
2877
|
-
if (
|
|
2915
|
+
if (await pathExists(cacheUtilsPath)) {
|
|
2878
2916
|
try {
|
|
2879
2917
|
cacheUtils = await import(pathToFileURL(cacheUtilsPath).href);
|
|
2880
2918
|
} catch {
|
|
@@ -2931,8 +2969,8 @@ setTimeout(() => process.exit(0), HOOK_DEADLINE_MS).unref();
|
|
|
2931
2969
|
process.exit(0);
|
|
2932
2970
|
}
|
|
2933
2971
|
|
|
2934
|
-
ensureDir(statePath);
|
|
2935
|
-
writeJsonFileAtomic(statePath, JSON.stringify(reusedState));
|
|
2972
|
+
await ensureDir(statePath);
|
|
2973
|
+
await writeJsonFileAtomic(statePath, JSON.stringify(reusedState));
|
|
2936
2974
|
await appendRouteAuditEntry(routeAuditPath, buildRouteAuditEntry({
|
|
2937
2975
|
state: reusedState,
|
|
2938
2976
|
}));
|
|
@@ -2965,7 +3003,7 @@ setTimeout(() => process.exit(0), HOOK_DEADLINE_MS).unref();
|
|
|
2965
3003
|
})
|
|
2966
3004
|
: null;
|
|
2967
3005
|
const verificationRecommendation = useIndexedContext
|
|
2968
|
-
? buildVerificationRecommendation({
|
|
3006
|
+
? await buildVerificationRecommendation({
|
|
2969
3007
|
projectRoot,
|
|
2970
3008
|
selectedIds,
|
|
2971
3009
|
contextRecommendation,
|
|
@@ -3004,7 +3042,7 @@ setTimeout(() => process.exit(0), HOOK_DEADLINE_MS).unref();
|
|
|
3004
3042
|
process.exit(0);
|
|
3005
3043
|
}
|
|
3006
3044
|
|
|
3007
|
-
ensureDir(statePath);
|
|
3045
|
+
await ensureDir(statePath);
|
|
3008
3046
|
const helpers = compactHelpers({
|
|
3009
3047
|
verificationRecommendation,
|
|
3010
3048
|
routeSummary,
|
|
@@ -3021,7 +3059,7 @@ setTimeout(() => process.exit(0), HOOK_DEADLINE_MS).unref();
|
|
|
3021
3059
|
...(helpers ? { helpers } : {}),
|
|
3022
3060
|
routeSummary: compactRouteSummary(routeSummary),
|
|
3023
3061
|
};
|
|
3024
|
-
writeJsonFileAtomic(statePath, JSON.stringify(sharedState));
|
|
3062
|
+
await writeJsonFileAtomic(statePath, JSON.stringify(sharedState));
|
|
3025
3063
|
await appendRouteAuditEntry(routeAuditPath, buildRouteAuditEntry({
|
|
3026
3064
|
routingContext,
|
|
3027
3065
|
routeSummary,
|
|
@@ -11,7 +11,15 @@ if source "$SCRIPT_DIR/../ukit/runtime/hook-input.sh" 2>/dev/null; then
|
|
|
11
11
|
source "$SCRIPT_DIR/../ukit/runtime/hook-telemetry.sh" 2>/dev/null || true
|
|
12
12
|
trap ukit_cleanup_hook_input EXIT
|
|
13
13
|
# Bounded stdin (H01): stage before anything reads it; the shell var is capped.
|
|
14
|
-
ukit_stage_hook_input 2097152 truncate
|
|
14
|
+
ukit_stage_hook_input 2097152 truncate
|
|
15
|
+
__ukit_main_stage_rc=$?
|
|
16
|
+
if [ "$__ukit_main_stage_rc" -ne 0 ]; then
|
|
17
|
+
# Infra failure during staging (mktemp/truncate): payload was never
|
|
18
|
+
# inspected — announce the degrade (SPEC §8), never a silent pass.
|
|
19
|
+
ukit_emit_input_degraded advisory "stale-spec guard"
|
|
20
|
+
fi
|
|
21
|
+
# BUG-C21-01: a truncated/stalled staged payload must be announced (SPEC §8), never silently passed.
|
|
22
|
+
ukit_input_degraded && ukit_emit_input_degraded advisory "stale-spec guard"
|
|
15
23
|
else
|
|
16
24
|
# Runtime helper missing (pre-install tree): the SAME bounded staging,
|
|
17
25
|
# inline - `cat >/dev/null` used to block forever on a producer that never
|
|
@@ -38,10 +46,14 @@ else
|
|
|
38
46
|
wait "$__ukit_waiter" 2>/dev/null
|
|
39
47
|
exec 8<&-
|
|
40
48
|
__ukit_size="$(wc -c < "$UKIT_INPUT_FILE" 2>/dev/null | tr -d '[:space:]')"
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
49
|
+
if [ "$__ukit_stage_rc" -ne 0 ] || [ "$__ukit_size" -gt 2097152 ]; then
|
|
50
|
+
# BUG-C21-01 fallback parity (SPEC §8): a truncated/stalled staged payload
|
|
51
|
+
# must be announced, never silently acted on — same shape as the helper
|
|
52
|
+
# path's ukit_emit_input_degraded.
|
|
53
|
+
rm -f "$UKIT_INPUT_FILE"
|
|
54
|
+
UKIT_INPUT_FILE=""
|
|
55
|
+
printf '%s\n' '{"systemMessage":"UKit stale-spec guard: tool-call payload was truncated or stalled during stdin staging; skipped this pass rather than act on incomplete input."}'
|
|
56
|
+
exit 0
|
|
45
57
|
fi
|
|
46
58
|
trap '[ -n "$UKIT_INPUT_FILE" ] && rm -f "$UKIT_INPUT_FILE"' EXIT
|
|
47
59
|
fi
|
|
@@ -65,7 +77,11 @@ case "$INPUT" in
|
|
|
65
77
|
*".worktrees/"*)
|
|
66
78
|
if printf '%s' "$INPUT" | node -e '
|
|
67
79
|
const HOOK_DEADLINE_MS = Number.parseInt(process.env.UKIT_HOOK_DEADLINE_MS || "", 10) || 3000;
|
|
68
|
-
|
|
80
|
+
// BUG-C21-06: the deadline path must NOT exit 0 — the wrapper treats exit 0 as
|
|
81
|
+
// "worktree match → skip the guard", which silently dropped the stale-spec
|
|
82
|
+
// check whenever the pre-check stalled. Non-zero falls THROUGH to the real
|
|
83
|
+
// check below (documented fail-open posture), never a silent skip.
|
|
84
|
+
setTimeout(() => process.exit(1), HOOK_DEADLINE_MS).unref();
|
|
69
85
|
const chunks = [];
|
|
70
86
|
process.stdin.on("data", (c) => chunks.push(c));
|
|
71
87
|
process.stdin.on("end", () => {
|
|
@@ -40,7 +40,15 @@ source "$SCRIPT_DIR/../ukit/runtime/hook-telemetry.sh" 2>/dev/null || true
|
|
|
40
40
|
# input can no longer stop node from starting (E2BIG) or stall the wrapper in
|
|
41
41
|
# `cat`. A truncated payload simply fails JSON.parse inside node → {} → silent
|
|
42
42
|
# degrade exit 0 (documented posture).
|
|
43
|
-
ukit_stage_hook_input 65536 truncate
|
|
43
|
+
ukit_stage_hook_input 65536 truncate
|
|
44
|
+
__ukit_main_stage_rc=$?
|
|
45
|
+
if [ "$__ukit_main_stage_rc" -ne 0 ]; then
|
|
46
|
+
# Infra failure during staging (mktemp/truncate): payload was never
|
|
47
|
+
# inspected — announce the degrade (SPEC §8), never a silent pass.
|
|
48
|
+
ukit_emit_input_degraded advisory "task-watchdog"
|
|
49
|
+
fi
|
|
50
|
+
# BUG-C21-01: a truncated/stalled staged payload must be announced (SPEC §8), never silently passed.
|
|
51
|
+
ukit_input_degraded && ukit_emit_input_degraded advisory "task-watchdog"
|
|
44
52
|
else
|
|
45
53
|
# 64 KiB cap and the advisory degrade path alive.
|
|
46
54
|
# Runtime helper missing (pre-install tree): the SAME bounded staging,
|
|
@@ -68,10 +76,14 @@ else
|
|
|
68
76
|
wait "$__ukit_waiter" 2>/dev/null
|
|
69
77
|
exec 8<&-
|
|
70
78
|
__ukit_size="$(wc -c < "$UKIT_INPUT_FILE" 2>/dev/null | tr -d '[:space:]')"
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
79
|
+
if [ "$__ukit_stage_rc" -ne 0 ] || [ "$__ukit_size" -gt 65536 ]; then
|
|
80
|
+
# BUG-C21-01 fallback parity (SPEC §8): a truncated/stalled staged payload
|
|
81
|
+
# must be announced, never silently acted on — same shape as the helper
|
|
82
|
+
# path's ukit_emit_input_degraded.
|
|
83
|
+
rm -f "$UKIT_INPUT_FILE"
|
|
84
|
+
UKIT_INPUT_FILE=""
|
|
85
|
+
printf '%s\n' '{"systemMessage":"UKit task-watchdog: tool-call payload was truncated or stalled during stdin staging; skipped this pass rather than act on incomplete input."}'
|
|
86
|
+
exit 0
|
|
75
87
|
fi
|
|
76
88
|
trap '[ -n "$UKIT_INPUT_FILE" ] && rm -f "$UKIT_INPUT_FILE"' EXIT
|
|
77
89
|
fi
|
|
@@ -97,9 +109,12 @@ const { pathToFileURL } = require('url');
|
|
|
97
109
|
const HOOK_DEADLINE_MS = Number.parseInt(process.env.UKIT_HOOK_DEADLINE_MS || '', 10) || 3000;
|
|
98
110
|
setTimeout(() => process.exit(0), HOOK_DEADLINE_MS).unref();
|
|
99
111
|
|
|
100
|
-
let rawInput = '';
|
|
101
|
-
try { rawInput = fs.readFileSync(process.env.INPUT_FILE || '', 'utf8'); } catch {}
|
|
102
112
|
(async () => {
|
|
113
|
+
// BUG-C21-04: the staged-payload read must be async too — a sync
|
|
114
|
+
// readFileSync on a stalled mount parks the event loop and the deadline
|
|
115
|
+
// above can never fire (orphan node grandchild).
|
|
116
|
+
let rawInput = '';
|
|
117
|
+
try { rawInput = await fsp.readFile(process.env.INPUT_FILE || '', 'utf8'); } catch {}
|
|
103
118
|
const payload = (() => {
|
|
104
119
|
try {
|
|
105
120
|
const parsed = JSON.parse(rawInput);
|