@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.
Files changed (36) hide show
  1. package/CHANGELOG.md +59 -0
  2. package/manifests/platform.full.yaml +16 -0
  3. package/package.json +1 -1
  4. package/src/cli/commands/install.js +49 -2
  5. package/src/cli/commands/update.js +5 -0
  6. package/src/core/output/index.js +77 -0
  7. package/src/core/update.js +36 -5
  8. package/templates/.claude/commands/ukit/handoff-clear.md +2 -2
  9. package/templates/.claude/commands/ukit/handoff-fullstack.md +10 -7
  10. package/templates/.claude/hooks/auto-allow-bash.sh +17 -5
  11. package/templates/.claude/hooks/block-dangerous.sh +18 -5
  12. package/templates/.claude/hooks/completion-gate.sh +17 -5
  13. package/templates/.claude/hooks/compress-output.sh +15 -6
  14. package/templates/.claude/hooks/context-hardcap-gate.sh +77 -46
  15. package/templates/.claude/hooks/context-window-guard.sh +41 -27
  16. package/templates/.claude/hooks/handoff-model-guard.sh +41 -17
  17. package/templates/.claude/hooks/handoff-resume.sh +20 -7
  18. package/templates/.claude/hooks/post-edit-verify.sh +17 -5
  19. package/templates/.claude/hooks/pre-edit-backup.sh +17 -5
  20. package/templates/.claude/hooks/project-important.sh +18 -1
  21. package/templates/.claude/hooks/protect-files.sh +18 -5
  22. package/templates/.claude/hooks/record-execution.sh +17 -5
  23. package/templates/.claude/hooks/sensitive-data-guard.sh +48 -9
  24. package/templates/.claude/hooks/skill-router.sh +124 -86
  25. package/templates/.claude/hooks/stale-spec-guard.sh +22 -6
  26. package/templates/.claude/hooks/task-watchdog.sh +22 -7
  27. package/templates/.claude/hooks/verification-guard.sh +17 -5
  28. package/templates/.claude/hooks/vision-router.sh +17 -5
  29. package/templates/.claude/settings.json +15 -10
  30. package/templates/.claude/ukit/index/provision-worktree.mjs +30 -2
  31. package/templates/.claude/ukit/runtime/execution-ledger.mjs +115 -1
  32. package/templates/.claude/ukit/runtime/hook-input.sh +25 -0
  33. package/templates/.claude/ukit/runtime/hook-telemetry.mjs +86 -2
  34. package/templates/.claude/ukit/runtime/output-compression.mjs +87 -0
  35. package/templates/.omp/hooks/pre/ukit-bridge.js +110 -3
  36. 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 || exit 0
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
- # A bounded cut (rc != 0) leaves a short payload that the JSON consumer
43
- # already degrades on - the shared helper's truncate posture (R4.5).
44
- if [ "$__ukit_size" -gt 2097152 ]; then
45
- truncate -s 2097152 "$UKIT_INPUT_FILE" 2>/dev/null
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(require("fs").readFileSync(process.env.INPUT_FILE || "", "utf8") || "{}");
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(0), HOOK_DEADLINE_MS).unref();
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(0), HOOK_DEADLINE_MS).unref();
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
- function readJson(filePath, fallback = null) {
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(fs.readFileSync(filePath, 'utf8'));
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
- fs.mkdirSync(path.dirname(filePath), { recursive: true });
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
- fs.writeFileSync(tempPath, text);
196
- fs.renameSync(tempPath, filePath);
225
+ await fsp.writeFile(tempPath, text);
226
+ await fsp.rename(tempPath, filePath);
197
227
  } catch (error) {
198
228
  try {
199
- fs.rmSync(tempPath, { force: true });
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 (!fs.existsSync(routeCatalogPath)) {
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 (!fs.existsSync(indexCorePath)) {
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 fs.existsSync(path.join(projectRoot, relativePath));
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 (!fs.existsSync(indexCorePath)) {
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 = fs.readFileSync(packageJsonPath, 'utf8');
838
- const stat = fs.statSync(packageJsonPath);
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 = fs.statSync(path.join(projectRoot, relativePath));
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 = fs.readdirSync(dirPath, { withFileTypes: true });
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 (fs.existsSync(path.join(projectRoot, 'pnpm-lock.yaml'))) return 'pnpm';
1121
- if (fs.existsSync(path.join(projectRoot, 'yarn.lock'))) return 'yarn';
1122
- if (fs.existsSync(path.join(projectRoot, 'bun.lockb')) || fs.existsSync(path.join(projectRoot, 'bun.lock'))) return 'bun';
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
- fs.mkdirSync(lockPath);
2470
+ await fsp.mkdir(lockPath);
2441
2471
  try {
2442
- fs.writeFileSync(path.join(lockPath, 'owner'), JSON.stringify({
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
- fs.rmSync(lockPath, { recursive: true, force: true });
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 = fs.statSync(lockPath);
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
- fs.rmSync(lockPath, { recursive: true, force: true });
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
- fs.rmSync(lockPath, { recursive: true, force: true });
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 (fs.existsSync(filePath)) {
2523
+ if (await pathExists(filePath)) {
2494
2524
  try {
2495
- parsed = JSON.parse(fs.readFileSync(filePath, 'utf8'));
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 = fs.readFileSync(process.env.INPUT_FILE || '', 'utf8'); } catch {}
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 active = catalog
2700
+ const scoredCatalog = catalog
2671
2701
  .map((entry) => scoreSkillRouteEntry(entry, routeSignals))
2672
- .filter((entry) => entry.score > 0 && existsSkill(projectRoot, entry.path))
2673
- .filter((entry) => shouldKeepRouteEntryForIntent(entry, intentMode));
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 (fs.existsSync(cacheUtilsPath)) {
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 || exit 0
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
- # A bounded cut (rc != 0) leaves a short payload that the JSON consumer
42
- # already degrades on - the shared helper's truncate posture (R4.5).
43
- if [ "$__ukit_size" -gt 2097152 ]; then
44
- truncate -s 2097152 "$UKIT_INPUT_FILE" 2>/dev/null
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
- setTimeout(() => process.exit(0), HOOK_DEADLINE_MS).unref();
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 || exit 0
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
- # A bounded cut (rc != 0) leaves a short payload that the JSON consumer
72
- # already degrades on - the shared helper's truncate posture (R4.5).
73
- if [ "$__ukit_size" -gt 65536 ]; then
74
- truncate -s 65536 "$UKIT_INPUT_FILE" 2>/dev/null
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);