@ngockhoale/ukit 3.0.9 → 3.0.11

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 (46) hide show
  1. package/CHANGELOG.md +15 -1
  2. package/bin/ukit +5 -9
  3. package/package.json +1 -1
  4. package/scripts/bench/memory-baseline.mjs +1 -1
  5. package/scripts/bench/memory-bench.mjs +1 -1
  6. package/scripts/bench/memory-canary.mjs +12 -2
  7. package/scripts/bench/recorder-overhead.mjs +1 -1
  8. package/scripts/bench/sqlite-spike.mjs +1 -1
  9. package/scripts/measure-decision-gateway.mjs +1 -1
  10. package/src/cli/commands/memory.js +3 -0
  11. package/src/cli/deadline.js +19 -0
  12. package/src/core/agentRuntime/eventStore.js +38 -10
  13. package/src/core/agentRuntime/supervisor.js +41 -9
  14. package/src/core/agentRuntime/vmEngine.js +33 -2
  15. package/src/core/codeintel/freshness.js +13 -9
  16. package/src/core/codeintel/retriever.js +25 -20
  17. package/src/core/fileOps.js +19 -4
  18. package/src/core/memory/memoryFlags.js +14 -6
  19. package/src/core/metadata.js +111 -81
  20. package/src/core/token/index.js +69 -28
  21. package/src/index/buildIndex.js +7 -0
  22. package/src/index/impactContext.js +14 -5
  23. package/src/render/instructionRenderer.js +71 -19
  24. package/template_project/.claude/hooks/auto-prune-bash.sh +60 -83
  25. package/template_project/.claude/hooks/block-dangerous.mjs +42 -11
  26. package/template_project/.claude/skills/docs-manager/init-project-docs.sh +59 -16
  27. package/template_project/.claude/skills/docx/scripts/document.py +31 -8
  28. package/template_project/.claude/skills/frontend-vue/composables/indexDBStore.js +26 -5
  29. package/template_project/.claude/skills/frontend-vue/composables/useRequest.js +24 -12
  30. package/template_project/.claude/skills/frontend-vue/composables/useSession.js +16 -11
  31. package/template_project/.claude/skills/frontend-vue/composables/useWebSocket.js +25 -2
  32. package/template_project/.claude/skills/pptx/scripts/rearrange.py +11 -2
  33. package/template_project/.claude/skills/root-cause-tracing/find-polluter.sh +20 -5
  34. package/template_project/.claude/skills/webapp-testing/scripts/with_server.py +37 -6
  35. package/template_project/.claude/ukit/index/cache-utils.mjs +25 -19
  36. package/template_project/.claude/ukit/index/impact-context.mjs +21 -13
  37. package/template_project/.claude/ukit/index/lib/index-core.mjs +96 -14
  38. package/template_project/.claude/ukit/index/query-index.mjs +13 -11
  39. package/template_project/.claude/ukit/index/resolve-context.mjs +12 -11
  40. package/template_project/.claude/ukit/index/route-task.mjs +44 -25
  41. package/template_project/.claude/ukit/index/triage.mjs +12 -11
  42. package/template_project/.claude/ukit/index/verify-context.mjs +12 -11
  43. package/template_project/.claude/ukit/runtime/async-lock.mjs +33 -0
  44. package/template_project/.claude/ukit/runtime/output-compression.mjs +9 -2
  45. package/template_project/.claude/ukit/runtime/resumable-run.mjs +18 -2
  46. package/template_project/.claude/ukit/runtime/token-utils.mjs +60 -18
@@ -42,6 +42,12 @@ export function useWebSocket(url, options = {}) {
42
42
  const RETRY_DELAY = options.retryDelay || 3000
43
43
  const connectionStatus = ref('initial') // 'initial', 'connecting', 'connected', 'disconnected', 'error'
44
44
  const lastError = ref(null)
45
+ // C80-12/13: pending retry timer + disposed flag. Both onerror and onclose
46
+ // call retryConnection for a single failure; without dedup that fires two
47
+ // parallel reconnect loops. Without storing the timer, a pending retry
48
+ // survives unmount and reconnects a zombie socket.
49
+ let retryTimer = null
50
+ let disposed = false
45
51
 
46
52
  // Debug logging function
47
53
  const debugLog = (type, message, data = null) => {
@@ -50,6 +56,7 @@ export function useWebSocket(url, options = {}) {
50
56
  }
51
57
 
52
58
  const connect = () => {
59
+ disposed = false
53
60
  if (!url) {
54
61
  debugLog('ERROR', 'WebSocket URL is required')
55
62
  error.value = 'WebSocket URL is required'
@@ -61,7 +68,7 @@ export function useWebSocket(url, options = {}) {
61
68
 
62
69
  try {
63
70
  socket.value = new WebSocket(url)
64
-
71
+
65
72
  socket.value.onopen = () => {
66
73
  connectionStatus.value = 'connected'
67
74
  debugLog('SUCCESS', 'WebSocket connection opened successfully')
@@ -106,10 +113,16 @@ export function useWebSocket(url, options = {}) {
106
113
  }
107
114
 
108
115
  const retryConnection = () => {
116
+ if (disposed || retryTimer !== null) {
117
+ return
118
+ }
109
119
  if (retryCount.value < MAX_RETRIES) {
110
120
  retryCount.value++
111
121
  debugLog('INFO', `Retrying connection (${retryCount.value}/${MAX_RETRIES})...`)
112
- setTimeout(connect, RETRY_DELAY)
122
+ retryTimer = setTimeout(() => {
123
+ retryTimer = null
124
+ connect()
125
+ }, RETRY_DELAY)
113
126
  } else {
114
127
  connectionStatus.value = 'error'
115
128
  debugLog('ERROR', 'Max retry attempts reached')
@@ -130,6 +143,11 @@ export function useWebSocket(url, options = {}) {
130
143
 
131
144
  onMounted(connect)
132
145
  onUnmounted(() => {
146
+ disposed = true
147
+ if (retryTimer !== null) {
148
+ clearTimeout(retryTimer)
149
+ retryTimer = null
150
+ }
133
151
  if (socket.value) {
134
152
  debugLog('INFO', 'Disconnecting WebSocket')
135
153
  socket.value.close()
@@ -143,6 +161,11 @@ export function useWebSocket(url, options = {}) {
143
161
  return {
144
162
  connect,
145
163
  disconnect: () => {
164
+ disposed = true
165
+ if (retryTimer !== null) {
166
+ clearTimeout(retryTimer)
167
+ retryTimer = null
168
+ }
146
169
  if (socket.value) {
147
170
  debugLog('INFO', 'Disconnecting WebSocket')
148
171
  socket.value.close()
@@ -155,8 +155,17 @@ def rearrange_presentation(template_path, output_path, slide_sequence):
155
155
  output_path: Path for output PPTX file
156
156
  slide_sequence: List of slide indices (0-based) to include
157
157
  """
158
- # Copy template to preserve dimensions and theme
159
- if template_path != output_path:
158
+ # Copy template to preserve dimensions and theme.
159
+ # C80-21: compare RESOLVED paths, not raw strings — "./a.pptx" vs "a.pptx"
160
+ # are the same file and shutil.copy2 onto itself raises SameFileError.
161
+ same_file = os.path.abspath(template_path) == os.path.abspath(output_path)
162
+ if not same_file:
163
+ if os.path.exists(output_path) and os.path.exists(template_path):
164
+ try:
165
+ same_file = os.path.samefile(template_path, output_path)
166
+ except OSError:
167
+ same_file = False
168
+ if not same_file:
160
169
  shutil.copy2(template_path, output_path)
161
170
  prs = Presentation(output_path)
162
171
  else:
@@ -18,15 +18,30 @@ echo "🔍 Searching for test that creates: $POLLUTION_CHECK"
18
18
  echo "Test pattern: $TEST_PATTERN"
19
19
  echo ""
20
20
 
21
- # Get list of test files
22
- TEST_FILES=$(find . -path "$TEST_PATTERN" | sort)
23
- TOTAL=$(echo "$TEST_FILES" | wc -l | tr -d ' ')
21
+ # Get list of test files.
22
+ # C80-22: find -path has no '**' semantics — collapse runs of '*' so
23
+ # 'src/**/*.test.ts' matches files at ANY depth (find's '*' already crosses
24
+ # '/'), and expand '/**/' to also cover zero directory levels.
25
+ FIND_PATTERN="${TEST_PATTERN//\*\*\/\*/*}"
26
+ FIND_PATTERN="${FIND_PATTERN//\*\*\/\//*/}"
27
+ FIND_PATTERN="${FIND_PATTERN//\/**\//}"
28
+ FIND_PATTERN="${FIND_PATTERN//\*\*/*}"
29
+ # find prints './'-prefixed paths; a pattern must match the WHOLE path.
30
+ case "$FIND_PATTERN" in
31
+ ./*|/*|\**) ;;
32
+ *) FIND_PATTERN="./$FIND_PATTERN" ;;
33
+ esac
34
+ TEST_FILES=$(find . -path "$FIND_PATTERN" | sort)
35
+ TOTAL=$(printf '%s\n' "$TEST_FILES" | grep -c . | tr -d ' ')
24
36
 
25
37
  echo "Found $TOTAL test files"
26
38
  echo ""
27
39
 
28
40
  COUNT=0
29
- for TEST_FILE in $TEST_FILES; do
41
+ # C80-22: iterate line-wise — `for x in $files` splits on whitespace and
42
+ # breaks test filenames containing spaces.
43
+ while IFS= read -r TEST_FILE; do
44
+ [ -z "$TEST_FILE" ] && continue
30
45
  COUNT=$((COUNT + 1))
31
46
 
32
47
  # Skip if pollution already exists
@@ -56,7 +71,7 @@ for TEST_FILE in $TEST_FILES; do
56
71
  echo " cat $TEST_FILE # Review test code"
57
72
  exit 1
58
73
  fi
59
- done
74
+ done <<< "$TEST_FILES"
60
75
 
61
76
  echo ""
62
77
  echo "✅ No polluter found - all tests clean!"
@@ -19,6 +19,9 @@ import socket
19
19
  import time
20
20
  import sys
21
21
  import argparse
22
+ import os
23
+ import signal
24
+
22
25
 
23
26
  def is_server_ready(port, timeout=30):
24
27
  """Wait for server to be ready by polling the port."""
@@ -65,12 +68,19 @@ def main():
65
68
  for i, server in enumerate(servers):
66
69
  print(f"Starting server {i+1}/{len(servers)}: {server['cmd']}")
67
70
 
68
- # Use shell=True to support commands with cd and &&
71
+ # Use shell=True to support commands with cd and &&.
72
+ # C80-17: stdout/stderr go to the inherited terminal, NOT PIPE —
73
+ # a piped-but-undrained server deadlocks once it logs past the OS
74
+ # pipe buffer (~64KB) and is_server_ready then reports a bogus
75
+ # "failed to start" timeout.
76
+ # C80-18: start_new_session puts the shell + all its children in a
77
+ # dedicated process group so cleanup can signal the WHOLE tree —
78
+ # terminate() alone kills only the /bin/sh wrapper and orphans the
79
+ # real server (port stays bound for the next run).
69
80
  process = subprocess.Popen(
70
81
  server['cmd'],
71
82
  shell=True,
72
- stdout=subprocess.PIPE,
73
- stderr=subprocess.PIPE
83
+ start_new_session=(os.name == 'posix'),
74
84
  )
75
85
  server_processes.append(process)
76
86
 
@@ -93,14 +103,35 @@ def main():
93
103
  print(f"\nStopping {len(server_processes)} server(s)...")
94
104
  for i, process in enumerate(server_processes):
95
105
  try:
96
- process.terminate()
106
+ _terminate_group(process)
97
107
  process.wait(timeout=5)
98
108
  except subprocess.TimeoutExpired:
99
- process.kill()
109
+ _kill_group(process)
100
110
  process.wait()
101
111
  print(f"Server {i+1} stopped")
102
112
  print("All servers stopped")
103
113
 
104
114
 
105
115
  if __name__ == '__main__':
106
- main()
116
+ main()
117
+
118
+ def _terminate_group(process):
119
+ """SIGTERM the whole process group on POSIX; fall back to terminate()."""
120
+ if os.name == 'posix':
121
+ try:
122
+ os.killpg(process.pid, signal.SIGTERM)
123
+ return
124
+ except (ProcessLookupError, PermissionError):
125
+ return # group already gone
126
+ process.terminate()
127
+
128
+
129
+ def _kill_group(process):
130
+ """SIGKILL the whole process group on POSIX; fall back to kill()."""
131
+ if os.name == 'posix':
132
+ try:
133
+ os.killpg(process.pid, signal.SIGKILL)
134
+ return
135
+ except (ProcessLookupError, PermissionError):
136
+ return
137
+ process.kill()
@@ -60,25 +60,31 @@ export async function readRecentCacheEntry(
60
60
  ...entry,
61
61
  updatedAt: Date.now(),
62
62
  };
63
- return withFileLock(cachePath, async () => {
64
- const locked = await readRecentCache(cachePath, { maxEntries });
65
- const lockedIndex = locked.entries.findIndex((lockedEntry) => lockedEntry.requestKey === requestKey);
66
- if (lockedIndex < 0) {
67
- return touchedEntry;
68
- }
69
- const lockedEntry = {
70
- ...locked.entries[lockedIndex],
71
- updatedAt: touchedEntry.updatedAt,
72
- };
73
- const entries = [
74
- lockedEntry,
75
- ...locked.entries.slice(0, lockedIndex),
76
- ...locked.entries.slice(lockedIndex + 1),
77
- ].slice(0, maxEntries);
78
- const document = buildCacheDocument(entries, { maxEntries });
79
- await writeCacheJson(cachePath, document);
80
- return document.entries[0] ?? lockedEntry;
81
- });
63
+ // A full/unwritable cache must never reject the read — degrade to the
64
+ // in-memory touched entry (same contract as token-utils readPromptCacheEntry).
65
+ try {
66
+ return await withFileLock(cachePath, async () => {
67
+ const locked = await readRecentCache(cachePath, { maxEntries });
68
+ const lockedIndex = locked.entries.findIndex((lockedEntry) => lockedEntry.requestKey === requestKey);
69
+ if (lockedIndex < 0) {
70
+ return touchedEntry;
71
+ }
72
+ const lockedEntry = {
73
+ ...locked.entries[lockedIndex],
74
+ updatedAt: touchedEntry.updatedAt,
75
+ };
76
+ const entries = [
77
+ lockedEntry,
78
+ ...locked.entries.slice(0, lockedIndex),
79
+ ...locked.entries.slice(lockedIndex + 1),
80
+ ].slice(0, maxEntries);
81
+ const document = buildCacheDocument(entries, { maxEntries });
82
+ await writeCacheJson(cachePath, document);
83
+ return document.entries[0] ?? lockedEntry;
84
+ });
85
+ } catch {
86
+ return touchedEntry;
87
+ }
82
88
  }
83
89
 
84
90
  export async function writeRecentCacheEntry(
@@ -59,19 +59,22 @@ async function main() {
59
59
  }
60
60
 
61
61
  async function ensureFreshIndex(rootDir) {
62
- const generatedAtMs = await getIndexArtifactGeneratedAt({ rootDir });
63
- const stale = generatedAtMs === null
64
- ? true
65
- : await isIndexStale({
66
- rootDir,
67
- maxAgeMs: DEFAULT_INDEX_CACHE_MAX_AGE_MS,
68
- now: Date.now(),
69
- generatedAtMs,
70
- });
71
-
72
- if (stale) {
73
- await buildCodeIndex({ rootDir });
62
+ if (typeof indexCore.ensureIndexFresh === 'function') {
63
+ return indexCore.ensureIndexFresh({ rootDir });
74
64
  }
65
+ const lastRefreshMs = await getIndexArtifactGeneratedAt({ rootDir });
66
+ const stale = lastRefreshMs === null
67
+ ? true
68
+ : typeof indexCore.isIndexStale === 'function'
69
+ ? await indexCore.isIndexStale({
70
+ rootDir,
71
+ maxAgeMs: DEFAULT_INDEX_CACHE_MAX_AGE_MS,
72
+ now: Date.now(),
73
+ generatedAtMs: lastRefreshMs,
74
+ })
75
+ : (Date.now() - lastRefreshMs) > DEFAULT_INDEX_CACHE_MAX_AGE_MS;
76
+ if (!stale) return;
77
+ await buildCodeIndex({ rootDir });
75
78
  }
76
79
 
77
80
  function printCompact(impact) {
@@ -119,4 +122,9 @@ function getRootDir(args) {
119
122
  return path.resolve(explicitRoot || process.cwd());
120
123
  }
121
124
 
122
- await main();
125
+ try {
126
+ await main();
127
+ } catch (error) {
128
+ console.error(error && error.message ? error.message : error);
129
+ process.exit(1);
130
+ }
@@ -2,6 +2,7 @@ import fs from 'node:fs/promises';
2
2
  import path from 'node:path';
3
3
  import crypto from 'node:crypto';
4
4
  import { spawnSync } from 'node:child_process';
5
+ import { withFileLock } from '../../runtime/token-utils.mjs';
5
6
 
6
7
  // One wall-clock ceiling for the WHOLE discovery phase: both `git ls-files`
7
8
  // enumerations AND the non-git fallback walk share this single budget. Every
@@ -464,6 +465,20 @@ export async function buildCodeIndex({ rootDir = process.cwd(), changedFiles = n
464
465
  // artifact-graph phases above) must not replace any artifact either, so the
465
466
  // whole write block sits behind this gate.
466
467
  assertNotAborted(signal, 'publish');
468
+ // Invalidate the process-lifetime artifact caches BEFORE the first write:
469
+ // clearing only after all writes leaves a window where queued/concurrent
470
+ // reads in this process keep serving the pre-rebuild generation (C80-08).
471
+ const preservedRuntimeCaches = canReuseFilesArtifact
472
+ && canReuseAllParsedArtifacts
473
+ && canReuseTestsMap
474
+ && canReuseHotspots
475
+ && canReuseArchetypes
476
+ && canReuseRelations
477
+ && canReuseAnalogs;
478
+ if (!preservedRuntimeCaches) {
479
+ clearIndexArtifactCache(absoluteRoot);
480
+ RELATED_TEST_LOOKUP_CACHE.delete(absoluteRoot);
481
+ }
467
482
  await fs.mkdir(indexDir, { recursive: true });
468
483
  if (!canReuseFilesArtifact) {
469
484
  await writeArtifact(absoluteRoot, INDEX_ARTIFACTS.files, { schemaVersion: INDEX_SCHEMA_VERSION, generatedAt, items: fileRecords });
@@ -502,17 +517,6 @@ export async function buildCodeIndex({ rootDir = process.cwd(), changedFiles = n
502
517
  [INDEX_ARTIFACTS.relations]: relations.length,
503
518
  },
504
519
  });
505
- const preservedRuntimeCaches = canReuseFilesArtifact
506
- && canReuseAllParsedArtifacts
507
- && canReuseTestsMap
508
- && canReuseHotspots
509
- && canReuseArchetypes
510
- && canReuseRelations
511
- && canReuseAnalogs;
512
- if (!preservedRuntimeCaches) {
513
- clearIndexArtifactCache(absoluteRoot);
514
- RELATED_TEST_LOOKUP_CACHE.delete(absoluteRoot);
515
- }
516
520
 
517
521
  return {
518
522
  indexDir,
@@ -694,6 +698,13 @@ async function resolveChangedHintUpdates(rootDir, changedPaths, previousRecordsB
694
698
  if (!isSafeContainedRelative(rootDir, relativePath)) {
695
699
  return null;
696
700
  }
701
+ // Hints under directories discovery never indexes are no-ops, not trust
702
+ // failures: `node_modules/`, `.cache/` and friends bypassed this lane and
703
+ // entered the index as phantom rows that kept the index perpetually stale.
704
+ // Same predicate as collectGitTrackedFiles.
705
+ if (relativePath.split('/').some((segment) => EXCLUDED_DIR_NAMES.has(segment))) {
706
+ continue;
707
+ }
697
708
 
698
709
  const absolutePath = path.join(rootDir, relativePath);
699
710
  let stat = null;
@@ -4319,8 +4330,8 @@ export async function resolveImpactContext({
4319
4330
  loadRelatedTestArtifacts({ rootDir: absoluteRoot }),
4320
4331
  ]);
4321
4332
 
4322
- const callItems = callsArtifact.items ?? [];
4323
- const importItems = importsArtifact.items ?? [];
4333
+ const callItems = safeItems(callsArtifact);
4334
+ const importItems = safeItems(importsArtifact);
4324
4335
  const risk = classifyImpactRisk(normalizedChangedFiles);
4325
4336
  const mirrorCounterparts = findMirrorCounterparts(normalizedChangedFiles).slice(0, budget.maxMirrors);
4326
4337
  const changedFileSet = new Set(normalizedChangedFiles);
@@ -4358,7 +4369,7 @@ export async function resolveImpactContext({
4358
4369
  // through the same machinery the index builder uses; relative specifiers keep
4359
4370
  // the candidate-list match so a changed file absent from files.json (e.g. a
4360
4371
  // brand-new file) still counts.
4361
- const indexedFileSet = new Set((filesArtifact.items ?? []).map((item) => item.filePath));
4372
+ const indexedFileSet = new Set(safeItems(filesArtifact).map((item) => item.filePath));
4362
4373
  const importAliasContext = importsNeedAliasContext(importItems)
4363
4374
  ? await loadImportAliasContext({ rootDir: absoluteRoot }).catch(() => null)
4364
4375
  : null;
@@ -4528,3 +4539,74 @@ export async function isIndexStale({
4528
4539
  const { stale } = await evaluateIndexStaleness({ rootDir, maxAgeMs, now, generatedAtMs, enumerateForSnapshot: false, signal, deadlineMs });
4529
4540
  return stale;
4530
4541
  }
4542
+
4543
+ /**
4544
+ * Shared freshness gate for all index CLIs/hooks. The stale check + rebuild runs
4545
+ * inside a cross-process file lock (withAsyncLock under withFileLock): without
4546
+ * it, two processes that both observe a stale index enumerate the repository
4547
+ * twice and interleave writeArtifact renames, so a reader starting mid-race can
4548
+ * consume files.json from build A and symbols.json from build B. Inside the
4549
+ * lock the staleness check re-runs, so the second contender becomes a no-op.
4550
+ * Fail-closed lock policy: if the lock cannot be acquired (an extreme hold), we
4551
+ * return the prior timestamp and the caller serves the existing index rather
4552
+ * than building unlocked.
4553
+ */
4554
+ export async function ensureIndexFresh({
4555
+ rootDir = process.cwd(),
4556
+ maxAgeMs = DEFAULT_INDEX_CACHE_MAX_AGE_MS,
4557
+ logPrefix = null,
4558
+ signal = null,
4559
+ deadlineMs = null,
4560
+ discoverySnapshot = null,
4561
+ } = {}) {
4562
+ const absoluteRoot = path.resolve(rootDir);
4563
+ const discoveryOptions = {
4564
+ ...(signal ? { signal } : {}),
4565
+ ...(Number.isFinite(deadlineMs) && deadlineMs > 0 ? { deadlineMs } : {}),
4566
+ };
4567
+ const lastRefreshMs = await getIndexArtifactGeneratedAt({ rootDir: absoluteRoot });
4568
+ const stale = lastRefreshMs === null
4569
+ || (await isIndexStale({
4570
+ rootDir: absoluteRoot,
4571
+ maxAgeMs,
4572
+ now: Date.now(),
4573
+ generatedAtMs: lastRefreshMs,
4574
+ ...discoveryOptions,
4575
+ }));
4576
+
4577
+ if (!stale) {
4578
+ return lastRefreshMs;
4579
+ }
4580
+
4581
+ const lockPath = `${getIndexDir(absoluteRoot)}/index-build.lock`;
4582
+ const result = await withFileLock(lockPath, async () => {
4583
+ const lockedRefreshMs = await getIndexArtifactGeneratedAt({ rootDir: absoluteRoot });
4584
+ const lockedStale = lockedRefreshMs === null
4585
+ || (await isIndexStale({
4586
+ rootDir: absoluteRoot,
4587
+ maxAgeMs,
4588
+ now: Date.now(),
4589
+ generatedAtMs: lockedRefreshMs,
4590
+ ...discoveryOptions,
4591
+ }));
4592
+ if (!lockedStale) {
4593
+ return { refreshed: false, generatedAtMs: lockedRefreshMs };
4594
+ }
4595
+ const summary = await buildCodeIndex({ rootDir: absoluteRoot, ...discoveryOptions, discoverySnapshot });
4596
+ return { refreshed: true, summary, generatedAtMs: summary?.generatedAtMs ?? Date.now() };
4597
+ });
4598
+
4599
+ if (result === undefined) {
4600
+ // Lock wait expired — serve the previous generation rather than build
4601
+ // unlocked. The drop is journaled by withFileLock.
4602
+ return lastRefreshMs;
4603
+ }
4604
+ if (result.refreshed && logPrefix) {
4605
+ if (lastRefreshMs !== null) {
4606
+ console.log(`[ukit:${logPrefix}] stale index refreshed (last=${new Date(lastRefreshMs).toISOString()})`);
4607
+ } else {
4608
+ console.log(`[ukit:${logPrefix}] stale index refreshed (no previous cache timestamp)`);
4609
+ }
4610
+ }
4611
+ return result.generatedAtMs ?? lastRefreshMs;
4612
+ }
@@ -29,7 +29,7 @@ const query = collectPositionalArgs(args, ['--root', '--limit']);
29
29
  if (!query) {
30
30
  console.error('Usage: node .claude/ukit/index/query-index.mjs "<error|symbol|path>"');
31
31
  process.exitCode = 1;
32
- } else {
32
+ } else try {
33
33
  const indexGeneratedAtMs = await ensureFreshIndex({ rootDir, logPrefix: 'index:query' });
34
34
  const effectiveLimit = Number.isNaN(limit) ? 5 : limit;
35
35
  const cachePath = path.join(rootDir, '.claude', 'ukit', 'query-index-cache.json');
@@ -79,6 +79,11 @@ if (!query) {
79
79
  }
80
80
  }
81
81
 
82
+ catch (error) {
83
+ console.error(error && error.message ? error.message : error);
84
+ process.exit(1);
85
+ }
86
+
82
87
  async function printOutlineForFile(root, filePath) {
83
88
  if (typeof getFileOutline !== 'function') {
84
89
  return;
@@ -106,6 +111,10 @@ async function printOutlineForFile(root, filePath) {
106
111
  }
107
112
 
108
113
  async function ensureFreshIndex({ rootDir, logPrefix }) {
114
+ if (typeof indexCore.ensureIndexFresh === 'function') {
115
+ return indexCore.ensureIndexFresh({ rootDir, logPrefix });
116
+ }
117
+ // Fallback for stubbed/legacy index-cores (tests inject minimal modules).
109
118
  const lastRefreshMs = await getIndexArtifactGeneratedAt({ rootDir });
110
119
  const stale = lastRefreshMs === null
111
120
  ? true
@@ -117,17 +126,10 @@ async function ensureFreshIndex({ rootDir, logPrefix }) {
117
126
  generatedAtMs: lastRefreshMs,
118
127
  })
119
128
  : (Date.now() - lastRefreshMs) > DEFAULT_INDEX_CACHE_MAX_AGE_MS;
120
-
121
- if (!stale) {
122
- return lastRefreshMs;
123
- }
124
-
129
+ if (!stale) return lastRefreshMs;
125
130
  const summary = await buildCodeIndex({ rootDir });
126
- if (lastRefreshMs !== null) {
127
- console.log(`[ukit:${logPrefix}] stale index refreshed (last=${new Date(lastRefreshMs).toISOString()})`);
128
- } else {
129
- console.log(`[ukit:${logPrefix}] stale index refreshed (no previous cache timestamp)`);
130
- }
131
+ // Prefer the build's own generatedAtMs; only re-read when the stubbed
132
+ // build didn't report one (keeps generatedAt call count at 1).
131
133
  return Number.isFinite(summary?.generatedAtMs)
132
134
  ? summary.generatedAtMs
133
135
  : getIndexArtifactGeneratedAt({ rootDir });
@@ -31,7 +31,7 @@ const effectiveIntent = deriveHelperIntent({ intent, targetFile });
31
31
  if (!intent && !targetFile) {
32
32
  console.error('Usage: node .claude/ukit/index/resolve-context.mjs "<intent>" [--target <file>] [--type trivial|simple|non-trivial]');
33
33
  process.exitCode = 1;
34
- } else {
34
+ } else try {
35
35
  const indexGeneratedAtMs = await ensureFreshIndex({
36
36
  rootDir,
37
37
  logPrefix: 'context',
@@ -64,7 +64,15 @@ if (!intent && !targetFile) {
64
64
  await printContextResult(result, { rootDir, showOutline });
65
65
  }
66
66
 
67
+ catch (error) {
68
+ console.error(error && error.message ? error.message : error);
69
+ process.exit(1);
70
+ }
71
+
67
72
  async function ensureFreshIndex({ rootDir, logPrefix }) {
73
+ if (typeof indexCore.ensureIndexFresh === 'function') {
74
+ return indexCore.ensureIndexFresh({ rootDir, logPrefix });
75
+ }
68
76
  const lastRefreshMs = await getIndexArtifactGeneratedAt({ rootDir });
69
77
  const stale = lastRefreshMs === null
70
78
  ? true
@@ -76,17 +84,10 @@ async function ensureFreshIndex({ rootDir, logPrefix }) {
76
84
  generatedAtMs: lastRefreshMs,
77
85
  })
78
86
  : (Date.now() - lastRefreshMs) > DEFAULT_INDEX_CACHE_MAX_AGE_MS;
79
-
80
- if (!stale) {
81
- return lastRefreshMs;
82
- }
83
-
87
+ if (!stale) return lastRefreshMs;
84
88
  const summary = await buildCodeIndex({ rootDir });
85
- if (lastRefreshMs !== null) {
86
- console.log(`[ukit:${logPrefix}] stale index refreshed (last=${new Date(lastRefreshMs).toISOString()})`);
87
- } else {
88
- console.log(`[ukit:${logPrefix}] stale index refreshed (no previous cache timestamp)`);
89
- }
89
+ // Prefer the build's own generatedAtMs; only re-read when the stubbed
90
+ // build didn't report one (keeps generatedAt call count at 1).
90
91
  return Number.isFinite(summary?.generatedAtMs)
91
92
  ? summary.generatedAtMs
92
93
  : getIndexArtifactGeneratedAt({ rootDir });
@@ -1599,10 +1599,16 @@ async function main() {
1599
1599
  }
1600
1600
 
1601
1601
  if (cachedRouteState && canPersistCachedRouteState) {
1602
- await readRecentCacheEntry(routeCachePath, requestKey, {
1603
- maxEntries: DEFAULT_RECENT_CACHE_MAX_ENTRIES,
1604
- touch: true,
1605
- });
1602
+ // Advisory hit-count touch — an EACCES/ENOSPC under the lock must never
1603
+ // lose the cached route; skip the touch and keep going.
1604
+ try {
1605
+ await readRecentCacheEntry(routeCachePath, requestKey, {
1606
+ maxEntries: DEFAULT_RECENT_CACHE_MAX_ENTRIES,
1607
+ touch: true,
1608
+ });
1609
+ } catch (error) {
1610
+ console.error(`route-task: state persist degraded — ${error?.message ?? error}`);
1611
+ }
1606
1612
  }
1607
1613
 
1608
1614
  if (cachedRouteState) {
@@ -1646,10 +1652,16 @@ async function main() {
1646
1652
  };
1647
1653
  sharedState.fingerprint = buildRouteStateFingerprint(sharedState);
1648
1654
  if (canPersistCachedRouteState) {
1649
- await persistSharedRouteState(sharedStatePath, sharedState);
1650
- await appendRouteAuditEntry(routeAuditPath, buildRouteAuditEntry({
1651
- state: sharedState,
1652
- }));
1655
+ // Advisory persistence — an EACCES/ENOSPC under the lock must never lose
1656
+ // the printed route; degrade to stderr and print anyway.
1657
+ try {
1658
+ await persistSharedRouteState(sharedStatePath, sharedState);
1659
+ await appendRouteAuditEntry(routeAuditPath, buildRouteAuditEntry({
1660
+ state: sharedState,
1661
+ }));
1662
+ } catch (error) {
1663
+ console.error(`route-task: state persist degraded — ${error?.message ?? error}`);
1664
+ }
1653
1665
  }
1654
1666
  printRouteState(sharedState);
1655
1667
  return;
@@ -1693,23 +1705,31 @@ async function main() {
1693
1705
  sessionId,
1694
1706
  });
1695
1707
  if (canPersistRouteState) {
1696
- await persistSharedRouteState(sharedStatePath, sharedState);
1697
- await appendRouteAuditEntry(routeAuditPath, buildRouteAuditEntry({
1698
- route,
1699
- state: sharedState,
1700
- }));
1701
- await writeRecentCacheEntry(routeCachePath, compactRouteCacheState(sharedState), {
1702
- maxEntries: DEFAULT_RECENT_CACHE_MAX_ENTRIES,
1703
- });
1708
+ // Advisory persistence — an EACCES/ENOSPC under the lock must never lose
1709
+ // the printed route; degrade to stderr and print anyway.
1710
+ try {
1711
+ await persistSharedRouteState(sharedStatePath, sharedState);
1712
+ await appendRouteAuditEntry(routeAuditPath, buildRouteAuditEntry({
1713
+ route,
1714
+ state: sharedState,
1715
+ }));
1716
+ await writeRecentCacheEntry(routeCachePath, compactRouteCacheState(sharedState), {
1717
+ maxEntries: DEFAULT_RECENT_CACHE_MAX_ENTRIES,
1718
+ });
1719
+ } catch (error) {
1720
+ console.error(`route-task: state persist degraded — ${error?.message ?? error}`);
1721
+ }
1704
1722
  }
1705
1723
 
1706
1724
  printRouteState(sharedState);
1707
1725
  }
1708
1726
 
1709
1727
  async function ensureFreshIndex({ rootDir, logPrefix, signal = null, deadlineMs = null, discoverySnapshot = null } = {}) {
1710
- // TASK-030: the stale check and the rebuild are the router's heaviest unchanged-input
1711
- // reads (one full repository enumeration each), so both accept the caller's top-level
1712
- // deadline/signal. Omitted (main()'s CLI path) keeps the index-core defaults.
1728
+ if (typeof indexCore.ensureIndexFresh === 'function') {
1729
+ // Cross-process build lock + staleness re-check live in index-core (C80-07).
1730
+ return indexCore.ensureIndexFresh({ rootDir, logPrefix, signal, deadlineMs, discoverySnapshot });
1731
+ }
1732
+ // Fallback for stubbed/legacy index-cores (tests inject minimal modules).
1713
1733
  const discoveryOptions = {
1714
1734
  ...(signal ? { signal } : {}),
1715
1735
  ...(Number.isFinite(deadlineMs) && deadlineMs > 0 ? { deadlineMs } : {}),
@@ -1726,11 +1746,7 @@ async function ensureFreshIndex({ rootDir, logPrefix, signal = null, deadlineMs
1726
1746
  ...discoveryOptions,
1727
1747
  })
1728
1748
  : (Date.now() - lastRefreshMs) > DEFAULT_INDEX_CACHE_MAX_AGE_MS;
1729
-
1730
- if (!stale) {
1731
- return lastRefreshMs;
1732
- }
1733
-
1749
+ if (!stale) return lastRefreshMs;
1734
1750
  const summary = await buildCodeIndex({ rootDir, ...discoveryOptions, discoverySnapshot });
1735
1751
  if (lastRefreshMs !== null) {
1736
1752
  console.log(`[ukit:${logPrefix}] stale index refreshed (last=${new Date(lastRefreshMs).toISOString()})`);
@@ -6009,5 +6025,8 @@ function isDirectCliInvocation() {
6009
6025
  }
6010
6026
 
6011
6027
  if (isDirectCliInvocation()) {
6012
- await main();
6028
+ main().catch((error) => {
6029
+ console.error(`route-task: ${error?.message ?? error}`);
6030
+ process.exitCode = 1;
6031
+ });
6013
6032
  }