@ngockhoale/ukit 1.3.1 → 1.4.1

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 +29 -1
  2. package/package.json +1 -1
  3. package/src/bug/triageBug.js +1 -33
  4. package/src/cli/commands/install.js +5 -10
  5. package/src/context/detectProjectContext.js +3 -24
  6. package/src/core/compact/index.js +19 -27
  7. package/src/core/ensureGitignore.js +1 -1
  8. package/src/core/fileOps.js +41 -2
  9. package/src/core/memory/hygiene.js +17 -1
  10. package/src/core/memory/store.js +14 -36
  11. package/src/core/metadata.js +5 -5
  12. package/src/core/output/index.js +20 -20
  13. package/src/core/packageManager.js +51 -0
  14. package/src/core/router/router.js +22 -6
  15. package/src/core/runInstallPipeline.js +1 -36
  16. package/src/core/runtimeConfig.js +27 -6
  17. package/src/core/token/index.js +21 -1
  18. package/src/core/uninstall.js +15 -38
  19. package/src/index/buildIndex.js +217 -49
  20. package/src/index/gitHooks.js +32 -7
  21. package/src/index/impactContext.js +16 -6
  22. package/src/index/importResolution.js +105 -28
  23. package/src/index/paths.js +29 -0
  24. package/src/index/queryIndex.js +20 -35
  25. package/src/index/relatedTests.js +15 -2
  26. package/src/index/routeCatalog.js +1 -1
  27. package/src/index/taskRouting.js +96 -17
  28. package/src/index/verificationPlan.js +12 -38
  29. package/templates/.claude/hooks/skill-router.sh +5 -2
  30. package/templates/.claude/ukit/index/route-catalog.mjs +1 -1
  31. package/templates/.claude/ukit/index/route-task.mjs +41 -5
  32. package/templates/.claude/ukit/index/verify-context.mjs +10 -2
  33. package/templates/.codex/README.md +1 -1
  34. package/templates/CLAUDE.md +9 -1
  35. package/templates/ukit/README.md +1 -1
  36. package/templates/ukit/storage/config.json +23 -5
@@ -38,6 +38,17 @@ function unique(values = []) {
38
38
  return [...new Set(values.filter(Boolean))];
39
39
  }
40
40
 
41
+ function dedupeCallers(callers = []) {
42
+ const byKey = new Map();
43
+ for (const caller of callers) {
44
+ const key = `${caller.filePath}\0${caller.symbol}`;
45
+ if (!byKey.has(key)) {
46
+ byKey.set(key, caller);
47
+ }
48
+ }
49
+ return [...byKey.values()];
50
+ }
51
+
41
52
  function resolveImportTarget(fromFilePath, specifier) {
42
53
  const from = normalizePath(fromFilePath);
43
54
  const to = String(specifier ?? '').trim();
@@ -119,10 +130,7 @@ export async function resolveImpactContext({
119
130
  continue;
120
131
  }
121
132
 
122
- if (
123
- changedFileSet.has(normalizePath(record.filePath))
124
- && changedSymbolSet.has(record.symbol)
125
- ) {
133
+ if (changedFileSet.has(normalizePath(record.filePath))) {
126
134
  continue;
127
135
  }
128
136
 
@@ -161,13 +169,15 @@ export async function resolveImpactContext({
161
169
  ...risk.riskLabels.flatMap((label) => RISK_TEST_RECOMMENDATIONS[label] ?? []),
162
170
  ]);
163
171
 
172
+ const dedupedCallers = dedupeCallers(callers).slice(0, budget.maxCallers);
173
+
164
174
  return {
165
175
  changedFiles: normalizedChangedFiles,
166
176
  changedSymbols: normalizedChangedSymbols,
167
177
  riskLabels: risk.riskLabels,
168
178
  requiresImpactCheck: risk.requiresImpactCheck,
169
179
  callees: unique(callees.map((item) => JSON.stringify(item))).map((value) => JSON.parse(value)).slice(0, budget.maxCallees),
170
- callers: unique(callers.map((item) => JSON.stringify(item))).map((value) => JSON.parse(value)).slice(0, budget.maxCallers),
180
+ callers: dedupedCallers,
171
181
  importers: unique(importers).slice(0, budget.maxImporters),
172
182
  dependencies: unique(dependencies).slice(0, budget.maxImporters),
173
183
  mirrorCounterparts,
@@ -175,7 +185,7 @@ export async function resolveImpactContext({
175
185
  recommendedTestFiles,
176
186
  explanations: {
177
187
  callees: changedRecords.map((record) => `${record.filePath}:${record.symbol}`),
178
- callers: callers.map((entry) => `${entry.filePath}:${entry.symbol}`),
188
+ callers: dedupedCallers.map((entry) => `${entry.filePath}:${entry.symbol}`),
179
189
  importers,
180
190
  relatedTests,
181
191
  mirrorCounterparts: mirrorCounterparts.map((entry) => `${entry.source} -> ${entry.filePath}`),
@@ -27,6 +27,17 @@ const DEFAULT_ALIAS_RULES = [
27
27
  ];
28
28
  const ALIAS_CONTEXT_CACHE = new Map();
29
29
  const ROOT_ALIAS_CONFIG_FILES = ['tsconfig.json', 'jsconfig.json'];
30
+ const MAX_CACHE_ENTRIES = 100;
31
+
32
+ function setBoundedCacheEntry(map, key, value, maxEntries = MAX_CACHE_ENTRIES) {
33
+ if (map.has(key)) {
34
+ map.delete(key);
35
+ }
36
+ map.set(key, value);
37
+ while (map.size > maxEntries) {
38
+ map.delete(map.keys().next().value);
39
+ }
40
+ }
30
41
 
31
42
  export async function loadImportAliasContext({ rootDir }) {
32
43
  const loaded = await loadImportAliasContextState({ rootDir });
@@ -49,7 +60,7 @@ export async function loadImportAliasContextState({ rootDir }) {
49
60
  ALIAS_CONTEXT_CACHE.delete(absoluteRoot);
50
61
  throw error;
51
62
  });
52
- ALIAS_CONTEXT_CACHE.set(absoluteRoot, contextPromise);
63
+ setBoundedCacheEntry(ALIAS_CONTEXT_CACHE, absoluteRoot, contextPromise);
53
64
 
54
65
  return contextPromise;
55
66
  }
@@ -225,9 +236,9 @@ async function loadAliasConfig(configPath, visited = new Set(), tracker = null)
225
236
 
226
237
  let inherited = null;
227
238
  const extendsValue = typeof parsed?.extends === 'string' ? parsed.extends.trim() : '';
228
- if (extendsValue && (extendsValue.startsWith('.') || extendsValue.startsWith('/'))) {
229
- const inheritedPath = resolveExtendedConfigPath(configDir, extendsValue);
230
- inherited = await loadAliasConfig(inheritedPath, visited, tracker);
239
+ if (extendsValue) {
240
+ const inheritedPath = await resolveExtendedConfigPath(configDir, extendsValue);
241
+ inherited = inheritedPath ? await loadAliasConfig(inheritedPath, visited, tracker) : null;
231
242
  }
232
243
 
233
244
  const inheritedRules = inherited?.pathRules ?? [];
@@ -279,11 +290,48 @@ function buildBaseUrlDirs({ rootDir, compilerOptions }) {
279
290
  return [path.resolve(rootDir, baseUrl)];
280
291
  }
281
292
 
282
- function resolveExtendedConfigPath(configDir, extendsValue) {
293
+ async function resolveExtendedConfigPath(configDir, extendsValue) {
283
294
  const withExtension = extendsValue.endsWith('.json')
284
295
  ? extendsValue
285
296
  : `${extendsValue}.json`;
286
- return path.resolve(configDir, withExtension);
297
+ if (withExtension.startsWith('.') || withExtension.startsWith('/')) {
298
+ return path.resolve(configDir, withExtension);
299
+ }
300
+
301
+ const parts = withExtension.split('/').filter(Boolean);
302
+ if (parts.length === 0) {
303
+ return null;
304
+ }
305
+
306
+ const packageName = withExtension.startsWith('@')
307
+ ? parts.slice(0, 2).join('/')
308
+ : parts[0];
309
+ const packageRest = parts.slice(packageName.startsWith('@') ? 2 : 1).join('/');
310
+ const packageRoot = await findNodeModulePackageRoot(configDir, packageName);
311
+ if (!packageRoot) {
312
+ return null;
313
+ }
314
+
315
+ return path.join(packageRoot, packageRest || 'tsconfig.json');
316
+ }
317
+
318
+ async function findNodeModulePackageRoot(startDir, packageName) {
319
+ let currentDir = path.resolve(startDir);
320
+ while (true) {
321
+ const candidate = path.join(currentDir, 'node_modules', packageName);
322
+ try {
323
+ const stat = await fs.stat(candidate);
324
+ if (stat.isDirectory()) {
325
+ return candidate;
326
+ }
327
+ } catch {}
328
+
329
+ const parentDir = path.dirname(currentDir);
330
+ if (parentDir === currentDir) {
331
+ return null;
332
+ }
333
+ currentDir = parentDir;
334
+ }
287
335
  }
288
336
 
289
337
  async function isAliasSnapshotValid(snapshot = []) {
@@ -319,18 +367,16 @@ function isSameSnapshotEntry(current, previous) {
319
367
 
320
368
  function parseJsonc(raw) {
321
369
  const withoutBom = raw.replace(/^\uFEFF/, '');
322
- const withoutBlockComments = withoutBom.replace(/\/\*[\s\S]*?\*\//g, '');
323
- const withoutLineComments = stripLineComments(withoutBlockComments);
324
- const withoutTrailingCommas = withoutLineComments.replace(/,\s*([}\]])/g, '$1');
370
+ const withoutComments = stripJsoncComments(withoutBom);
371
+ const withoutTrailingCommas = stripJsoncTrailingCommas(withoutComments);
325
372
 
326
373
  return JSON.parse(withoutTrailingCommas);
327
374
  }
328
375
 
329
- function stripLineComments(raw) {
376
+ function stripJsoncComments(raw) {
330
377
  let result = '';
331
378
  let inString = false;
332
- let stringQuote = '';
333
- let isEscaped = false;
379
+ let escaped = false;
334
380
 
335
381
  for (let index = 0; index < raw.length; index += 1) {
336
382
  const char = raw[index];
@@ -338,31 +384,28 @@ function stripLineComments(raw) {
338
384
 
339
385
  if (inString) {
340
386
  result += char;
341
- if (isEscaped) {
342
- isEscaped = false;
343
- } else if (char === '\\') {
344
- isEscaped = true;
345
- } else if (char === stringQuote) {
346
- inString = false;
347
- stringQuote = '';
348
- }
387
+ if (escaped) escaped = false;
388
+ else if (char === '\\') escaped = true;
389
+ else if (char === '"') inString = false;
349
390
  continue;
350
391
  }
351
392
 
352
- if (char === '"' || char === '\'') {
393
+ if (char === '"') {
353
394
  inString = true;
354
- stringQuote = char;
355
395
  result += char;
356
396
  continue;
357
397
  }
358
398
 
359
399
  if (char === '/' && nextChar === '/') {
360
- while (index < raw.length && raw[index] !== '\n') {
361
- index += 1;
362
- }
363
- if (index < raw.length) {
364
- result += '\n';
365
- }
400
+ while (index < raw.length && raw[index] !== '\n') index += 1;
401
+ if (index < raw.length) result += '\n';
402
+ continue;
403
+ }
404
+
405
+ if (char === '/' && nextChar === '*') {
406
+ index += 2;
407
+ while (index < raw.length && !(raw[index] === '*' && raw[index + 1] === '/')) index += 1;
408
+ index += 1;
366
409
  continue;
367
410
  }
368
411
 
@@ -372,6 +415,40 @@ function stripLineComments(raw) {
372
415
  return result;
373
416
  }
374
417
 
418
+ function stripJsoncTrailingCommas(raw) {
419
+ let result = '';
420
+ let inString = false;
421
+ let escaped = false;
422
+
423
+ for (let index = 0; index < raw.length; index += 1) {
424
+ const char = raw[index];
425
+
426
+ if (inString) {
427
+ result += char;
428
+ if (escaped) escaped = false;
429
+ else if (char === '\\') escaped = true;
430
+ else if (char === '"') inString = false;
431
+ continue;
432
+ }
433
+
434
+ if (char === '"') {
435
+ inString = true;
436
+ result += char;
437
+ continue;
438
+ }
439
+
440
+ if (char === ',') {
441
+ let nextIndex = index + 1;
442
+ while (/\s/.test(raw[nextIndex] ?? '')) nextIndex += 1;
443
+ if (raw[nextIndex] === '}' || raw[nextIndex] === ']') continue;
444
+ }
445
+
446
+ result += char;
447
+ }
448
+
449
+ return result;
450
+ }
451
+
375
452
  function dedupe(values) {
376
453
  return [...new Set(values)];
377
454
  }
@@ -26,3 +26,32 @@ export function getArtifactPath(rootDir, artifactName) {
26
26
  export function normalizeRelative(rootDir, absolutePath) {
27
27
  return path.relative(rootDir, absolutePath).replace(/\\/g, '/');
28
28
  }
29
+
30
+ export function isLikelyTestFilePath(filePath) {
31
+ const lower = filePath.toLowerCase();
32
+ return (
33
+ lower.startsWith('__tests__/')
34
+ || lower.startsWith('test/')
35
+ || lower.startsWith('tests/')
36
+ || lower.startsWith('spec/')
37
+ || lower.startsWith('specs/')
38
+ || lower.includes('/__tests__/')
39
+ || lower.includes('/test/')
40
+ || lower.includes('/spec/')
41
+ || lower.includes('/specs/')
42
+ || lower.endsWith('.test.js')
43
+ || lower.endsWith('.test.ts')
44
+ || lower.endsWith('.test.vue')
45
+ || lower.endsWith('.test.tsx')
46
+ || lower.endsWith('.test.jsx')
47
+ || lower.endsWith('.test.mjs')
48
+ || lower.endsWith('.test.cjs')
49
+ || lower.endsWith('.spec.js')
50
+ || lower.endsWith('.spec.ts')
51
+ || lower.endsWith('.spec.vue')
52
+ || lower.endsWith('.spec.tsx')
53
+ || lower.endsWith('.spec.jsx')
54
+ || lower.endsWith('.spec.mjs')
55
+ || lower.endsWith('.spec.cjs')
56
+ );
57
+ }
@@ -1,7 +1,7 @@
1
1
  import fs from 'node:fs/promises';
2
2
  import path from 'node:path';
3
3
 
4
- import { getArtifactPath, getIndexDir, INDEX_ARTIFACTS } from './paths.js';
4
+ import { getArtifactPath, getIndexDir, INDEX_ARTIFACTS, isLikelyTestFilePath } from './paths.js';
5
5
  import { importsNeedAliasContext, loadImportAliasContext, resolveImportSpecifier } from './importResolution.js';
6
6
  import { buildSearchDescriptor } from './languageTools.js';
7
7
 
@@ -10,8 +10,19 @@ const QUERY_SEARCH_BUNDLE_CACHE = new Map();
10
10
  const QUERY_SUPPORT_BUNDLE_CACHE = new Map();
11
11
  const QUERY_RESULT_CACHE = new Map();
12
12
  const ANALOG_RESULT_CACHE = new Map();
13
+ const MAX_CACHE_ENTRIES = 100;
13
14
  const MAX_IMPORTER_HOPS = 2;
14
15
 
16
+ function setBoundedCacheEntry(map, key, value, maxEntries = MAX_CACHE_ENTRIES) {
17
+ if (map.has(key)) {
18
+ map.delete(key);
19
+ }
20
+ map.set(key, value);
21
+ while (map.size > maxEntries) {
22
+ map.delete(map.keys().next().value);
23
+ }
24
+ }
25
+
15
26
  export async function queryCodeIndex({ rootDir = process.cwd(), query, limit = 5 } = {}) {
16
27
  if (!query || !query.trim()) {
17
28
  return [];
@@ -115,7 +126,7 @@ export async function queryCodeIndex({ rootDir = process.cwd(), query, limit = 5
115
126
  throw error;
116
127
  });
117
128
 
118
- QUERY_RESULT_CACHE.set(queryCacheKey, queryPromise);
129
+ setBoundedCacheEntry(QUERY_RESULT_CACHE, queryCacheKey, queryPromise);
119
130
  return queryPromise;
120
131
  }
121
132
 
@@ -123,7 +134,8 @@ async function readArtifact(rootDir, artifactName) {
123
134
  const artifactPath = getArtifactPath(path.resolve(rootDir), artifactName);
124
135
 
125
136
  if (!ARTIFACT_CACHE.has(artifactPath)) {
126
- ARTIFACT_CACHE.set(
137
+ setBoundedCacheEntry(
138
+ ARTIFACT_CACHE,
127
139
  artifactPath,
128
140
  fs.readFile(artifactPath, 'utf8')
129
141
  .then((content) => {
@@ -148,7 +160,8 @@ async function loadQuerySearchBundle(rootDir) {
148
160
  const absoluteRoot = path.resolve(rootDir);
149
161
 
150
162
  if (!QUERY_SEARCH_BUNDLE_CACHE.has(absoluteRoot)) {
151
- QUERY_SEARCH_BUNDLE_CACHE.set(
163
+ setBoundedCacheEntry(
164
+ QUERY_SEARCH_BUNDLE_CACHE,
152
165
  absoluteRoot,
153
166
  Promise.all([
154
167
  readArtifact(absoluteRoot, INDEX_ARTIFACTS.files),
@@ -177,7 +190,8 @@ async function loadQuerySupportBundle(rootDir) {
177
190
  const absoluteRoot = path.resolve(rootDir);
178
191
 
179
192
  if (!QUERY_SUPPORT_BUNDLE_CACHE.has(absoluteRoot)) {
180
- QUERY_SUPPORT_BUNDLE_CACHE.set(
193
+ setBoundedCacheEntry(
194
+ QUERY_SUPPORT_BUNDLE_CACHE,
181
195
  absoluteRoot,
182
196
  loadQuerySearchBundle(absoluteRoot)
183
197
  .then(({ files }) => Promise.all([
@@ -517,35 +531,6 @@ function buildResolvedImportGraphs(rootDir, imports, indexedFileSet, importAlias
517
531
  };
518
532
  }
519
533
 
520
- function isLikelyTestFilePath(filePath) {
521
- const lower = filePath.toLowerCase();
522
- return (
523
- lower.startsWith('__tests__/')
524
- || lower.startsWith('test/')
525
- || lower.startsWith('tests/')
526
- || lower.startsWith('spec/')
527
- || lower.startsWith('specs/')
528
- || lower.includes('/__tests__/')
529
- || lower.includes('/test/')
530
- || lower.includes('/spec/')
531
- || lower.includes('/specs/')
532
- || lower.endsWith('.test.js')
533
- || lower.endsWith('.test.ts')
534
- || lower.endsWith('.test.tsx')
535
- || lower.endsWith('.test.jsx')
536
- || lower.endsWith('.test.vue')
537
- || lower.endsWith('.test.mjs')
538
- || lower.endsWith('.test.cjs')
539
- || lower.endsWith('.spec.js')
540
- || lower.endsWith('.spec.ts')
541
- || lower.endsWith('.spec.tsx')
542
- || lower.endsWith('.spec.jsx')
543
- || lower.endsWith('.spec.vue')
544
- || lower.endsWith('.spec.mjs')
545
- || lower.endsWith('.spec.cjs')
546
- );
547
- }
548
-
549
534
  // ── Index V2: Analog Query ──
550
535
 
551
536
  export async function queryAnalog({ rootDir = process.cwd(), filePath, limit = 5 } = {}) {
@@ -577,7 +562,7 @@ export async function queryAnalog({ rootDir = process.cwd(), filePath, limit = 5
577
562
  throw error;
578
563
  });
579
564
 
580
- ANALOG_RESULT_CACHE.set(analogCacheKey, analogPromise);
565
+ setBoundedCacheEntry(ANALOG_RESULT_CACHE, analogCacheKey, analogPromise);
581
566
  return analogPromise;
582
567
  }
583
568
 
@@ -12,6 +12,17 @@ const RELATED_TEST_RELATION_TYPES = [
12
12
  ];
13
13
  const RELATED_TEST_ARTIFACT_CACHE = new Map();
14
14
  const RELATED_TEST_LOOKUP_CACHE = new Map();
15
+ const MAX_CACHE_ENTRIES = 100;
16
+
17
+ function setBoundedCacheEntry(map, key, value, maxEntries = MAX_CACHE_ENTRIES) {
18
+ if (map.has(key)) {
19
+ map.delete(key);
20
+ }
21
+ map.set(key, value);
22
+ while (map.size > maxEntries) {
23
+ map.delete(map.keys().next().value);
24
+ }
25
+ }
15
26
 
16
27
  export async function inferRelatedTests({
17
28
  rootDir = process.cwd(),
@@ -48,7 +59,8 @@ export async function loadRelatedTestArtifacts({
48
59
 
49
60
  if (!analogsArtifact && !relationsArtifact) {
50
61
  if (!RELATED_TEST_LOOKUP_CACHE.has(absoluteRoot)) {
51
- RELATED_TEST_LOOKUP_CACHE.set(
62
+ setBoundedCacheEntry(
63
+ RELATED_TEST_LOOKUP_CACHE,
52
64
  absoluteRoot,
53
65
  Promise.all([
54
66
  readArtifact(absoluteRoot, INDEX_ARTIFACTS.analogs),
@@ -193,7 +205,8 @@ async function readArtifact(rootDir, artifactName) {
193
205
  const artifactPath = getArtifactPath(path.resolve(rootDir), artifactName);
194
206
 
195
207
  if (!RELATED_TEST_ARTIFACT_CACHE.has(artifactPath)) {
196
- RELATED_TEST_ARTIFACT_CACHE.set(
208
+ setBoundedCacheEntry(
209
+ RELATED_TEST_ARTIFACT_CACHE,
197
210
  artifactPath,
198
211
  fs.readFile(artifactPath, 'utf8')
199
212
  .then((content) => JSON.parse(content))
@@ -66,7 +66,7 @@ export const ROUTE_CATALOG = [
66
66
  {
67
67
  id: 'postgres',
68
68
  path: '.claude/skills/postgres/SKILL.md',
69
- order: 4,
69
+ order: 4.5,
70
70
  signals: [
71
71
  { type: 'prompt', regex: /\b(sql|postgres|postgresql|migration|schema|table|view|index|trigger|function|stored procedure|materialized view|query plan|explain)\b/i, score: 5 },
72
72
  { type: 'command', regex: /\b(psql|prisma|drizzle|knex|sequelize|typeorm)\b/i, score: 4 },
@@ -17,6 +17,7 @@ export async function deriveTaskRoute({
17
17
  taskType = null,
18
18
  lastExplicitUserPromptText = '',
19
19
  commandNamespace = '.claude',
20
+ autonomyLevel = 'balanced',
20
21
  } = {}) {
21
22
  const absoluteRoot = path.resolve(rootDir);
22
23
  const normalizedPrompt = String(promptText || '').trim();
@@ -52,17 +53,28 @@ export async function deriveTaskRoute({
52
53
  targetFile: normalizedTarget,
53
54
  });
54
55
  const preservedPrompt = normalizedPrompt || String(lastExplicitUserPromptText || '').trim();
55
- const contextResult = useIndexedContext && (contextIntent || normalizedTarget)
56
- ? await resolveContext({
57
- rootDir: absoluteRoot,
58
- intent: contextIntent,
59
- targetFile: normalizedTarget,
60
- taskType: inferredTaskType,
61
- })
62
- : null;
56
+ const degradedWarnings = [];
57
+ let contextResult = null;
58
+ if (useIndexedContext && (contextIntent || normalizedTarget)) {
59
+ const indexReadError = await findUnreadableIndexArtifact(absoluteRoot);
60
+ if (indexReadError) {
61
+ degradedWarnings.push(`resolve-context failed: ${indexReadError.message}`);
62
+ } else {
63
+ try {
64
+ contextResult = await resolveContext({
65
+ rootDir: absoluteRoot,
66
+ intent: contextIntent,
67
+ targetFile: normalizedTarget,
68
+ taskType: inferredTaskType,
69
+ });
70
+ } catch (error) {
71
+ degradedWarnings.push(`resolve-context failed: ${error?.message ?? String(error)}`);
72
+ }
73
+ }
74
+ }
63
75
  const enrichedContextResult = expandRouteContext(contextResult);
64
76
 
65
- const contextRecommendation = useIndexedContext
77
+ const contextRecommendation = useIndexedContext && degradedWarnings.length === 0
66
78
  ? buildContextRecommendation({
67
79
  commandNamespace,
68
80
  contextIntent,
@@ -71,16 +83,22 @@ export async function deriveTaskRoute({
71
83
  contextResult: enrichedContextResult,
72
84
  })
73
85
  : null;
74
- const verificationPlan = useIndexedContext
75
- ? await deriveVerificationPlan({
86
+ let verificationPlan = null;
87
+ if (useIndexedContext && contextResult) {
88
+ try {
89
+ verificationPlan = await deriveVerificationPlan({
76
90
  rootDir: absoluteRoot,
77
91
  intent: contextIntent,
78
92
  targetFile: normalizedTarget,
79
93
  taskType: inferredTaskType,
80
94
  contextResult: enrichedContextResult,
81
95
  skillIds: selectedIds,
82
- })
83
- : null;
96
+ autonomyLevel,
97
+ });
98
+ } catch (error) {
99
+ degradedWarnings.push(`verify-context failed: ${error?.message ?? String(error)}`);
100
+ }
101
+ }
84
102
 
85
103
  const verificationRecommendation = verificationPlan
86
104
  ? {
@@ -109,6 +127,7 @@ export async function deriveTaskRoute({
109
127
  contextIntent,
110
128
  taskType: inferredTaskType,
111
129
  intentMode,
130
+ autonomyLevel,
112
131
  },
113
132
  contextRecommendation,
114
133
  verificationRecommendation,
@@ -125,11 +144,13 @@ export async function deriveTaskRoute({
125
144
  contextIntent,
126
145
  taskType: inferredTaskType,
127
146
  intentMode,
147
+ autonomyLevel,
128
148
  },
129
149
  contextRecommendation,
130
150
  verificationRecommendation,
131
151
  nextAction,
132
152
  routeSummary,
153
+ ...(degradedWarnings.length > 0 ? { degradedWarnings } : {}),
133
154
  };
134
155
  }
135
156
 
@@ -140,11 +161,13 @@ export function buildRouteSummary({
140
161
  verificationRecommendation = null,
141
162
  nextAction = null,
142
163
  } = {}) {
164
+ const autonomyLevel = routingContext.autonomyLevel ?? 'balanced';
143
165
  const delegationRecommendation = deriveDelegationRecommendation({
144
166
  activeSkills,
145
167
  routingContext,
146
168
  contextRecommendation,
147
169
  verificationRecommendation,
170
+ autonomyLevel,
148
171
  });
149
172
  const preview = contextRecommendation?.preview ?? {};
150
173
  const primaryTargets = summarizeCompactList(preview.primaryTargets ?? [], 2);
@@ -158,6 +181,8 @@ export function buildRouteSummary({
158
181
  );
159
182
  const policyMode = verificationRecommendation?.executionPolicy?.policyMode ?? null;
160
183
  const editGuardHint = isSharedImpactFile(routingContext.targetFile) ? 'anchor-required' : null;
184
+ const taskType = routingContext.taskType ?? null;
185
+ const contextMode = deriveContextMode(taskType);
161
186
  const compactHelperLane = nextAction?.type === 'pull-indexed-context'
162
187
  && typeof contextRecommendation?.command === 'string'
163
188
  && contextRecommendation.command.trim();
@@ -179,6 +204,7 @@ export function buildRouteSummary({
179
204
  editGuardHint ? `editGuard=${editGuardHint}` : null,
180
205
  delegationRecommendation?.hint ? `delegate=${delegationRecommendation.hint}` : null,
181
206
  policyMode ? `policy=${policyMode}` : null,
207
+ contextMode ? `mode=${contextMode}` : null,
182
208
  ].filter(Boolean).join(' | ');
183
209
 
184
210
  return {
@@ -192,10 +218,17 @@ export function buildRouteSummary({
192
218
  nextActionType: nextAction?.type ?? null,
193
219
  nextActionCommand,
194
220
  helperHint,
221
+ contextMode,
195
222
  line: summaryLine || 'task=unknown',
196
223
  };
197
224
  }
198
225
 
226
+ function deriveContextMode(taskType) {
227
+ if (taskType === 'trivial' || taskType === 'simple') return 'LITE';
228
+ if (taskType === 'non-trivial' || taskType === 'shared-simple') return 'FULL';
229
+ return null;
230
+ }
231
+
199
232
  async function selectActiveSkills({ rootDir, promptText, commandText, targetFile, intentMode = null }) {
200
233
  const routeSignals = {
201
234
  promptRawText: String(promptText || '').toLowerCase(),
@@ -598,6 +631,7 @@ function deriveDelegationRecommendation({
598
631
  routingContext = {},
599
632
  contextRecommendation = null,
600
633
  verificationRecommendation = null,
634
+ autonomyLevel = 'balanced',
601
635
  } = {}) {
602
636
  const skillIds = activeSkills.map((item) => item.id);
603
637
  const lower = `${routingContext.promptText ?? ''}\n${routingContext.commandText ?? ''}`.toLowerCase();
@@ -622,6 +656,21 @@ function deriveDelegationRecommendation({
622
656
  return null;
623
657
  }
624
658
 
659
+ if (autonomyLevel === 'conservative') {
660
+ if (
661
+ skillIds.includes('executing-plans')
662
+ || /\b(execute this plan|follow this plan|implementation plan|rollout plan|controlled batches?|review checkpoints?|batch execution|execute in batches)\b/.test(lower)
663
+ ) {
664
+ return {
665
+ hint: 'subagent-driven-development',
666
+ when,
667
+ reason: 'Explicit plan/batch execution is separable enough for deliberate subagent passes.',
668
+ };
669
+ }
670
+
671
+ return null;
672
+ }
673
+
625
674
  if (
626
675
  skillIds.includes('executing-plans')
627
676
  || /\b(execute this plan|follow this plan|implementation plan|rollout plan|controlled batches?|review checkpoints?|batch execution|execute in batches)\b/.test(lower)
@@ -638,7 +687,8 @@ function deriveDelegationRecommendation({
638
687
  || /\b(debug|error|crash|stack(?: trace)?|failing|flake|flaky|timeout|triage|root cause)\b/.test(lower)
639
688
  || verificationRecommendation?.executionPolicy?.policyMode === 'confirm-then-broad'
640
689
  );
641
- if (noisyDebugLane && (!localizedIndexedLane || !hasRelatedTests || contextBreadth >= 4)) {
690
+ const debugDelegationThreshold = autonomyLevel === 'free-run' ? 3 : 4;
691
+ if (noisyDebugLane && (!localizedIndexedLane || !hasRelatedTests || contextBreadth >= debugDelegationThreshold)) {
642
692
  return {
643
693
  hint: 'bug-debugger',
644
694
  when,
@@ -649,11 +699,15 @@ function deriveDelegationRecommendation({
649
699
  const hasImplementationSkill = skillIds.some((id) => DELEGATABLE_IMPLEMENTATION_SKILL_IDS.has(id));
650
700
  const clearImplementationSignal = /\b(implement|build|create|add|ship|deliver|refactor|integrat(?:e|ion)|scaffold|feature)\b/.test(lower);
651
701
  const multiLaneSignal = /\b(multiple|several|parallel|independent|across files|across modules|batch)\b/.test(lower);
702
+ const featureDelegationTaskType = autonomyLevel === 'free-run'
703
+ ? (routingContext.taskType === 'non-trivial' || routingContext.taskType === 'simple')
704
+ : routingContext.taskType === 'non-trivial';
705
+ const featureDelegationBreadth = autonomyLevel === 'free-run' ? 3 : 4;
652
706
  if (
653
707
  hasImplementationSkill
654
708
  && clearImplementationSignal
655
- && routingContext.taskType === 'non-trivial'
656
- && (!localizedIndexedLane || !hasExplicitTarget || contextBreadth >= 4 || multiLaneSignal)
709
+ && featureDelegationTaskType
710
+ && (autonomyLevel === 'free-run' || !localizedIndexedLane || !hasExplicitTarget || contextBreadth >= featureDelegationBreadth || multiLaneSignal)
657
711
  ) {
658
712
  return {
659
713
  hint: 'feature-implementer',
@@ -820,12 +874,36 @@ function normalizeRelativeFile(rootDir, rawFilePath) {
820
874
  if (relative && !relative.startsWith('..') && !path.isAbsolute(relative)) {
821
875
  return relative.replaceAll('\\', '/');
822
876
  }
823
- return trimmed.replaceAll('\\', '/');
877
+ return null;
824
878
  }
825
879
 
826
880
  return trimmed.replace(/^\.\/+/, '').replaceAll('\\', '/');
827
881
  }
828
882
 
883
+ async function findUnreadableIndexArtifact(rootDir) {
884
+ const indexDir = path.join(rootDir, '.cache', 'index');
885
+ let entries;
886
+ try {
887
+ entries = await fs.readdir(indexDir);
888
+ } catch {
889
+ return null;
890
+ }
891
+
892
+ for (const entry of entries) {
893
+ if (!entry.endsWith('.json')) {
894
+ continue;
895
+ }
896
+
897
+ try {
898
+ JSON.parse(await fs.readFile(path.join(indexDir, entry), 'utf8'));
899
+ } catch (error) {
900
+ return new Error(`${entry}: ${error?.message ?? String(error)}`);
901
+ }
902
+ }
903
+
904
+ return null;
905
+ }
906
+
829
907
  async function pathExists(filePath) {
830
908
  try {
831
909
  await fs.access(filePath);
@@ -842,6 +920,7 @@ function unique(values) {
842
920
  const DELEGATABLE_IMPLEMENTATION_SKILL_IDS = new Set([
843
921
  'delivery',
844
922
  'frontend',
923
+ 'frontend-design',
845
924
  'frontend-vue',
846
925
  'backend-api',
847
926
  'postgres',