@ngockhoale/ukit 1.1.6 → 1.1.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,17 @@ All notable changes to UKit are documented here.
4
4
 
5
5
  ## Unreleased
6
6
 
7
+ ## 1.1.7 - 2026-04-25
8
+
9
+ ### Impact Confidence
10
+
11
+ - Added impact-confidence indexing for changed shared/runtime files, including lightweight call extraction and `calls.json` artifacts.
12
+ - Added shared-impact classification so source index runtime, install/runtime core, shipped hooks/helpers, manifests, and templates are routed as higher-confidence verification work instead of ordinary simple edits.
13
+ - Added mirror-counterpart guidance between source modules and installed helper mirrors, helping agents catch source/template drift before release.
14
+ - Added shipped `impact-context.mjs` helper and manifest coverage so installed workspaces can resolve impact context after `ukit install`.
15
+ - Tightened verification planning and guard behavior so impact-test lanes run targeted checks first, while broad verification remains advisory unless explicitly enforced.
16
+ - Added package-artifact, hook, indexing, impact-context, and impact-catalog coverage for the new release surface.
17
+
7
18
  ## 1.1.6 - 2026-04-23
8
19
 
9
20
  ### Hidden Runtime Root
package/README.md CHANGED
@@ -66,7 +66,7 @@ If maintainers roll out a newer CLI build, the in-project workflow still stays t
66
66
  - `.ukit/` — hidden shared runtime storage for config, cache, and cross-agent memory
67
67
  - `docs/` — PROJECT / MEMORY / WORKLOG baseline
68
68
 
69
- ## UKit v1.1.6 Runtime
69
+ ## UKit v1.1.7 Runtime
70
70
 
71
71
  UKit now installs a hidden shared local runtime at `.ukit/` for production-oriented state that should survive across agent sessions:
72
72
 
@@ -83,13 +83,15 @@ When long sessions approach the compact threshold, UKit now uses a conservative
83
83
  - compact only safe-zone history/noise
84
84
  - preserve active task, rules, decisions, and current code focus
85
85
 
86
- UKit v1.1.6 keeps the same shared runtime contract while the npm distribution now lives under the owned maintainer scope:
86
+ UKit v1.1.7 keeps the same shared runtime contract while adding impact-confidence routing for shared runtime/template changes:
87
87
 
88
88
  - install globally with `npm install -g @ngockhoale/ukit`
89
89
  - keep using the exact same human workflow inside projects: `ukit install`
90
90
  - preserve the same `ukit` binary, hooks, and install-first orchestration while standardizing the runtime root as hidden `.ukit/`
91
+ - route shared index/helper/hook/template edits as higher-impact work instead of treating them like ordinary simple changes
92
+ - surface mirror-counterpart and impact-test hints so agents verify source/runtime/template changes with better confidence
91
93
 
92
- UKit v1.1.6 also keeps the fast path improvements from the recent runtime releases:
94
+ UKit v1.1.7 also keeps the fast path improvements from the recent runtime releases:
93
95
 
94
96
  - Vietnamese prompts now normalize more effectively for English-heavy code symbols and paths
95
97
  - localized simple direct-target lanes skip extra previous-context / recent-output work when it would not change the next action
@@ -1000,6 +1000,19 @@ items:
1000
1000
  packs:
1001
1001
  - core
1002
1002
 
1003
+ - id: ukit-index-impact-context-script
1004
+ type: config
1005
+ sourceTemplate: .claude/ukit/index/impact-context.mjs
1006
+ targetPath: .claude/ukit/index/impact-context.mjs
1007
+ requires:
1008
+ - ukit-index-core-lib
1009
+ - ukit-index-cache-utils-script
1010
+ mergeStrategy: overwrite_with_backup
1011
+ variables: []
1012
+ enabledByDefault: true
1013
+ packs:
1014
+ - core
1015
+
1003
1016
  - id: ukit-index-cache-utils-script
1004
1017
  type: config
1005
1018
  sourceTemplate: .claude/ukit/index/cache-utils.mjs
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ngockhoale/ukit",
3
- "version": "1.1.6",
3
+ "version": "1.1.7",
4
4
  "description": "Install/update an index-first AI workspace for Claude Code, Antigravity, OpenAI Codex, and OpenCode.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -42,7 +42,7 @@ export function buildDefaultRuntimeConfig(overrides = {}) {
42
42
  const safeOverrides = isPlainObject(overrides) ? overrides : {};
43
43
 
44
44
  return mergeObjects({
45
- version: '1.1.6',
45
+ version: '1.1.7',
46
46
  agent: 'claude-code',
47
47
  compact: {
48
48
  enabled: true,
@@ -34,6 +34,9 @@ const TRACKED_EXTENSIONS = new Set(['.js', '.mjs', '.cjs', '.ts', '.tsx', '.jsx'
34
34
  const DISCOVERED_EXTENSIONS = new Set([...TRACKED_EXTENSIONS, ...STYLE_EXTENSIONS]);
35
35
  export const DEFAULT_INDEX_CACHE_MAX_AGE_MS = 24 * 60 * 60 * 1000;
36
36
  const INDEX_PARSE_BATCH_SIZE = 8;
37
+ const CALL_IGNORE_WORDS = new Set([
38
+ 'if', 'for', 'while', 'switch', 'catch', 'function', 'return', 'typeof',
39
+ ]);
37
40
 
38
41
  export async function buildCodeIndex({ rootDir = process.cwd() } = {}) {
39
42
  const absoluteRoot = path.resolve(rootDir);
@@ -89,6 +92,7 @@ export async function buildCodeIndex({ rootDir = process.cwd() } = {}) {
89
92
 
90
93
  const symbols = [];
91
94
  const imports = [];
95
+ const calls = [];
92
96
  const reusableCodeFiles = [];
93
97
  let filesToParse = [];
94
98
  let reusedCodeFileCount = 0;
@@ -109,20 +113,24 @@ export async function buildCodeIndex({ rootDir = process.cwd() } = {}) {
109
113
  }
110
114
 
111
115
  if (reusableCodeFiles.length > 0) {
112
- const [previousSymbolsArtifact, previousImportsArtifact] = await Promise.all([
116
+ const [previousSymbolsArtifact, previousImportsArtifact, previousCallsArtifact] = await Promise.all([
113
117
  readArtifactIfExists(absoluteRoot, INDEX_ARTIFACTS.symbols),
114
118
  readArtifactIfExists(absoluteRoot, INDEX_ARTIFACTS.imports),
119
+ readArtifactIfExists(absoluteRoot, INDEX_ARTIFACTS.calls),
115
120
  ]);
116
121
  canReuseParsedArtifacts = previousSymbolsArtifact?.schemaVersion === INDEX_SCHEMA_VERSION
117
- && previousImportsArtifact?.schemaVersion === INDEX_SCHEMA_VERSION;
122
+ && previousImportsArtifact?.schemaVersion === INDEX_SCHEMA_VERSION
123
+ && previousCallsArtifact?.schemaVersion === INDEX_SCHEMA_VERSION;
118
124
 
119
125
  if (canReuseParsedArtifacts) {
120
126
  const previousSymbolsByPath = groupBy(previousSymbolsArtifact.items ?? [], (item) => item.filePath);
121
127
  const previousImportsByPath = groupBy(previousImportsArtifact.items ?? [], (item) => item.from);
128
+ const previousCallsByPath = groupBy(previousCallsArtifact.items ?? [], (item) => item.filePath);
122
129
 
123
130
  for (const filePath of reusableCodeFiles) {
124
131
  symbols.push(...(previousSymbolsByPath.get(filePath) ?? []));
125
132
  imports.push(...(previousImportsByPath.get(filePath) ?? []));
133
+ calls.push(...(previousCallsByPath.get(filePath) ?? []));
126
134
  reusedCodeFileCount += 1;
127
135
  }
128
136
  } else {
@@ -145,6 +153,7 @@ export async function buildCodeIndex({ rootDir = process.cwd() } = {}) {
145
153
  ...extractImports(filePath, scriptContent),
146
154
  ...extractSupplementalImports(filePath, content),
147
155
  ],
156
+ calls: extractFunctionCalls(filePath, scriptContent),
148
157
  };
149
158
  } catch {
150
159
  return null;
@@ -154,6 +163,7 @@ export async function buildCodeIndex({ rootDir = process.cwd() } = {}) {
154
163
  for (const parsedFile of parsedBatch) {
155
164
  symbols.push(...parsedFile.symbols);
156
165
  imports.push(...parsedFile.imports);
166
+ calls.push(...parsedFile.calls);
157
167
  }
158
168
  }
159
169
 
@@ -279,6 +289,12 @@ export async function buildCodeIndex({ rootDir = process.cwd() } = {}) {
279
289
  rootDir: absoluteRoot,
280
290
  items: imports,
281
291
  });
292
+ await writeArtifact(absoluteRoot, INDEX_ARTIFACTS.calls, {
293
+ schemaVersion: INDEX_SCHEMA_VERSION,
294
+ generatedAt,
295
+ rootDir: absoluteRoot,
296
+ items: calls,
297
+ });
282
298
  }
283
299
  if (!canReuseTestsMap) {
284
300
  await writeArtifact(absoluteRoot, INDEX_ARTIFACTS.testsMap, {
@@ -579,6 +595,76 @@ function extractSupplementalImports(filePath, content) {
579
595
  return imports;
580
596
  }
581
597
 
598
+ function extractFunctionBlocks(content) {
599
+ const blocks = [];
600
+
601
+ for (const match of content.matchAll(/export\s+(async\s+)?function\s+([A-Za-z_$][A-Za-z0-9_$]*)\s*\([^)]*\)\s*\{/g)) {
602
+ blocks.push({ symbol: match[2], exported: true, bodyStart: match.index + match[0].length });
603
+ }
604
+
605
+ for (const match of content.matchAll(/function\s+([A-Za-z_$][A-Za-z0-9_$]*)\s*\([^)]*\)\s*\{/g)) {
606
+ const prefix = content.slice(Math.max(0, match.index - 16), match.index);
607
+ if (/export\s+$/.test(prefix)) {
608
+ continue;
609
+ }
610
+ blocks.push({ symbol: match[1], exported: false, bodyStart: match.index + match[0].length });
611
+ }
612
+
613
+ for (const match of content.matchAll(/export\s+const\s+([A-Za-z_$][A-Za-z0-9_$]*)\s*=\s*(?:async\s*)?(?:\([^)]*\)|[A-Za-z_$][A-Za-z0-9_$]*)\s*=>\s*\{/g)) {
614
+ blocks.push({ symbol: match[1], exported: true, bodyStart: match.index + match[0].length });
615
+ }
616
+
617
+ for (const match of content.matchAll(/const\s+([A-Za-z_$][A-Za-z0-9_$]*)\s*=\s*(?:async\s*)?(?:\([^)]*\)|[A-Za-z_$][A-Za-z0-9_$]*)\s*=>\s*\{/g)) {
618
+ const prefix = content.slice(Math.max(0, match.index - 16), match.index);
619
+ if (/export\s+$/.test(prefix)) {
620
+ continue;
621
+ }
622
+ blocks.push({ symbol: match[1], exported: false, bodyStart: match.index + match[0].length });
623
+ }
624
+
625
+ return blocks
626
+ .map((block) => ({ ...block, body: sliceBalancedBlock(content, block.bodyStart) }))
627
+ .filter((block) => block.body);
628
+ }
629
+
630
+ function sliceBalancedBlock(content, startIndex) {
631
+ let depth = 1;
632
+ for (let index = startIndex; index < content.length; index += 1) {
633
+ const char = content[index];
634
+ if (char === '{') {
635
+ depth += 1;
636
+ }
637
+ if (char === '}') {
638
+ depth -= 1;
639
+ }
640
+ if (depth === 0) {
641
+ return content.slice(startIndex, index);
642
+ }
643
+ }
644
+ return '';
645
+ }
646
+
647
+ function extractFunctionCalls(filePath, content) {
648
+ return extractFunctionBlocks(content).map((block) => {
649
+ const calls = [];
650
+ for (const match of block.body.matchAll(/(?:\b|\.)([A-Za-z_$][A-Za-z0-9_$]*)\s*\(/g)) {
651
+ const name = match[1];
652
+ if (!name || CALL_IGNORE_WORDS.has(name)) {
653
+ continue;
654
+ }
655
+ if (!calls.includes(name)) {
656
+ calls.push(name);
657
+ }
658
+ }
659
+ return {
660
+ filePath,
661
+ symbol: block.symbol,
662
+ exported: block.exported,
663
+ calls,
664
+ };
665
+ });
666
+ }
667
+
582
668
  function buildTestsMap(fileRecords) {
583
669
  const testFiles = fileRecords.filter((file) => isLikelyTestFile(file.filePath));
584
670
  const sourceFiles = fileRecords.filter((file) => CODE_EXTENSIONS.has(file.ext) && !isLikelyTestFile(file.filePath));
@@ -0,0 +1,126 @@
1
+ const SHARED_IMPACT_PATTERNS = [
2
+ {
3
+ regex: /^src\/index\//,
4
+ label: 'shared-index-runtime',
5
+ },
6
+ {
7
+ regex: /^src\/core\/(runInstallPipeline|applyPlan|buildPlan|diffPlan|metadata|migrateLegacy|uninstall|runtimeConfig|runtimePaths)\.js$/,
8
+ label: 'shared-install-runtime',
9
+ },
10
+ {
11
+ regex: /^src\/core\/(output|token|compact)\//,
12
+ label: 'shared-token-runtime',
13
+ },
14
+ {
15
+ regex: /^templates\/\.claude\/hooks\//,
16
+ label: 'installed-hook-runtime',
17
+ },
18
+ {
19
+ regex: /^templates\/\.claude\/ukit\//,
20
+ label: 'installed-helper-runtime',
21
+ },
22
+ {
23
+ regex: /^manifests\/platform\.full\.yaml$/,
24
+ label: 'install-manifest',
25
+ },
26
+ {
27
+ regex: /^templates\//,
28
+ label: 'published-template-surface',
29
+ },
30
+ ];
31
+
32
+ const MIRROR_PAIRS = [
33
+ {
34
+ source: 'src/index/buildIndex.js',
35
+ mirror: 'templates/.claude/ukit/index/lib/index-core.mjs',
36
+ reason: 'installed index-core mirror embeds source index behavior',
37
+ },
38
+ {
39
+ source: 'src/index/queryIndex.js',
40
+ mirror: 'templates/.claude/ukit/index/lib/index-core.mjs',
41
+ reason: 'installed index-core mirror embeds source query behavior',
42
+ },
43
+ {
44
+ source: 'src/index/resolveContext.js',
45
+ mirror: 'templates/.claude/ukit/index/lib/index-core.mjs',
46
+ reason: 'installed index-core mirror embeds context resolution behavior',
47
+ },
48
+ {
49
+ source: 'src/index/verificationPlan.js',
50
+ mirror: 'templates/.claude/ukit/index/lib/index-core.mjs',
51
+ reason: 'installed index-core mirror embeds verification planning behavior',
52
+ },
53
+ {
54
+ source: 'src/index/taskRouting.js',
55
+ mirror: 'templates/.claude/ukit/index/route-task.mjs',
56
+ reason: 'installed route-task helper mirrors source task routing behavior',
57
+ },
58
+ {
59
+ source: 'src/index/routeCatalog.js',
60
+ mirror: 'templates/.claude/ukit/index/route-catalog.mjs',
61
+ reason: 'installed route catalog must match source route catalog',
62
+ },
63
+ {
64
+ source: 'src/core/output/index.js',
65
+ mirror: 'templates/.claude/ukit/runtime/output-compression.mjs',
66
+ reason: 'source output compression runtime should stay aligned with installed hook runtime',
67
+ },
68
+ {
69
+ source: 'src/core/token/index.js',
70
+ mirror: 'templates/.claude/ukit/runtime/token-utils.mjs',
71
+ reason: 'source token runtime should stay aligned with installed token utilities',
72
+ },
73
+ {
74
+ source: 'src/core/compact/threshold.js',
75
+ mirror: 'templates/.claude/ukit/runtime/compact-threshold.mjs',
76
+ reason: 'source compact threshold runtime should stay aligned with installed compact threshold hook',
77
+ },
78
+ ];
79
+
80
+ function normalizePath(filePath) {
81
+ return String(filePath ?? '').trim().replace(/\\/g, '/').replace(/^\.\//, '');
82
+ }
83
+
84
+ export function isSharedImpactFile(filePath) {
85
+ const normalized = normalizePath(filePath);
86
+ return SHARED_IMPACT_PATTERNS.some((entry) => entry.regex.test(normalized));
87
+ }
88
+
89
+ export function classifyImpactRisk(filePaths = []) {
90
+ const normalizedPaths = [...new Set(filePaths.map(normalizePath).filter(Boolean))];
91
+ const riskLabels = [];
92
+
93
+ for (const filePath of normalizedPaths) {
94
+ for (const entry of SHARED_IMPACT_PATTERNS) {
95
+ if (entry.regex.test(filePath) && !riskLabels.includes(entry.label)) {
96
+ riskLabels.push(entry.label);
97
+ }
98
+ }
99
+ }
100
+
101
+ return {
102
+ changedFiles: normalizedPaths,
103
+ riskLabels,
104
+ requiresImpactCheck: riskLabels.length > 0,
105
+ };
106
+ }
107
+
108
+ export function findMirrorCounterparts(filePaths = []) {
109
+ const normalizedSet = new Set(filePaths.map(normalizePath).filter(Boolean));
110
+ const counterparts = [];
111
+
112
+ for (const pair of MIRROR_PAIRS) {
113
+ if (normalizedSet.has(pair.source)) {
114
+ counterparts.push({ filePath: pair.mirror, reason: pair.reason, source: pair.source });
115
+ }
116
+ if (normalizedSet.has(pair.mirror)) {
117
+ counterparts.push({ filePath: pair.source, reason: pair.reason, source: pair.mirror });
118
+ }
119
+ }
120
+
121
+ return counterparts.filter((entry, index, list) => (
122
+ list.findIndex((candidate) => candidate.filePath === entry.filePath && candidate.source === entry.source) === index
123
+ ));
124
+ }
125
+
126
+ export { MIRROR_PAIRS, SHARED_IMPACT_PATTERNS };
@@ -0,0 +1,232 @@
1
+ import fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+
4
+ import { getArtifactPath, INDEX_ARTIFACTS } from './paths.js';
5
+ import { classifyImpactRisk, findMirrorCounterparts } from './impactCatalog.js';
6
+ import { inferRelatedTestsFromArtifacts, loadRelatedTestArtifacts } from './relatedTests.js';
7
+
8
+ const DEFAULT_IMPACT_LIMITS = {
9
+ maxCallers: 8,
10
+ maxCallees: 8,
11
+ maxImporters: 8,
12
+ maxTests: 8,
13
+ maxMirrors: 4,
14
+ };
15
+
16
+ const RISK_TEST_RECOMMENDATIONS = {
17
+ 'shared-index-runtime': ['tests/index/indexing.test.js', 'tests/index/taskRouting.test.js'],
18
+ 'shared-install-runtime': ['tests/integration/installPipeline.test.js'],
19
+ 'installed-hook-runtime': ['tests/hooks/skillRouterHook.test.js', 'tests/integration/installPipeline.test.js'],
20
+ 'published-template-surface': ['tests/integration/packageArtifact.test.js', 'tests/integration/installPipeline.test.js'],
21
+ 'install-manifest': ['tests/integration/packageArtifact.test.js', 'tests/integration/installPipeline.test.js'],
22
+ };
23
+
24
+ async function readArtifact(rootDir, artifactName) {
25
+ try {
26
+ const raw = await fs.readFile(getArtifactPath(path.resolve(rootDir), artifactName), 'utf8');
27
+ return JSON.parse(raw);
28
+ } catch {
29
+ return { items: [] };
30
+ }
31
+ }
32
+
33
+ function normalizePath(filePath) {
34
+ return String(filePath ?? '').trim().replace(/\\/g, '/').replace(/^\.\//, '');
35
+ }
36
+
37
+ function unique(values = []) {
38
+ return [...new Set(values.filter(Boolean))];
39
+ }
40
+
41
+ function resolveImportTarget(fromFilePath, specifier) {
42
+ const from = normalizePath(fromFilePath);
43
+ const to = String(specifier ?? '').trim();
44
+ if (!from || !to || !to.startsWith('.')) {
45
+ return null;
46
+ }
47
+
48
+ const fromDir = path.posix.dirname(from);
49
+ const base = path.posix.normalize(path.posix.join(fromDir, to));
50
+ const candidates = [
51
+ base,
52
+ `${base}.js`,
53
+ `${base}.mjs`,
54
+ `${base}.cjs`,
55
+ `${base}.ts`,
56
+ `${base}.tsx`,
57
+ `${base}.jsx`,
58
+ `${base}.vue`,
59
+ path.posix.join(base, 'index.js'),
60
+ path.posix.join(base, 'index.ts'),
61
+ path.posix.join(base, 'index.mjs'),
62
+ ];
63
+
64
+ return unique(candidates);
65
+ }
66
+
67
+ export async function resolveImpactContext({
68
+ rootDir = process.cwd(),
69
+ changedFiles = [],
70
+ changedSymbols = [],
71
+ limits = {},
72
+ } = {}) {
73
+ const absoluteRoot = path.resolve(rootDir);
74
+ const budget = { ...DEFAULT_IMPACT_LIMITS, ...(limits ?? {}) };
75
+ const normalizedChangedFiles = unique(changedFiles.map(normalizePath));
76
+ const normalizedChangedSymbols = unique(changedSymbols.map((symbol) => String(symbol ?? '').trim()));
77
+
78
+ const [callsArtifact, importsArtifact, relatedArtifacts] = await Promise.all([
79
+ readArtifact(absoluteRoot, INDEX_ARTIFACTS.calls),
80
+ readArtifact(absoluteRoot, INDEX_ARTIFACTS.imports),
81
+ loadRelatedTestArtifacts({ rootDir: absoluteRoot }),
82
+ ]);
83
+
84
+ const callItems = callsArtifact.items ?? [];
85
+ const importItems = importsArtifact.items ?? [];
86
+ const risk = classifyImpactRisk(normalizedChangedFiles);
87
+ const mirrorCounterparts = findMirrorCounterparts(normalizedChangedFiles).slice(0, budget.maxMirrors);
88
+
89
+ const changedFileSet = new Set(normalizedChangedFiles);
90
+ const changedSymbolSet = new Set(normalizedChangedSymbols);
91
+
92
+ const changedRecords = callItems.filter((item) => {
93
+ const fileMatched = changedFileSet.has(normalizePath(item.filePath));
94
+ if (!fileMatched) {
95
+ return false;
96
+ }
97
+ if (changedSymbolSet.size === 0) {
98
+ return true;
99
+ }
100
+ return changedSymbolSet.has(item.symbol);
101
+ });
102
+
103
+ const callees = [];
104
+ for (const record of changedRecords) {
105
+ for (const symbol of record.calls ?? []) {
106
+ callees.push({ filePath: record.filePath, fromSymbol: record.symbol, symbol });
107
+ }
108
+ }
109
+
110
+ const callers = [];
111
+ const callerTargets = new Set([
112
+ ...normalizedChangedSymbols,
113
+ ...changedRecords.map((record) => record.symbol),
114
+ ]);
115
+ if (callerTargets.size > 0) {
116
+ for (const record of callItems) {
117
+ const matchedSymbol = (record.calls ?? []).find((name) => callerTargets.has(name));
118
+ if (!matchedSymbol) {
119
+ continue;
120
+ }
121
+
122
+ if (
123
+ changedFileSet.has(normalizePath(record.filePath))
124
+ && changedSymbolSet.has(record.symbol)
125
+ ) {
126
+ continue;
127
+ }
128
+
129
+ callers.push({ filePath: record.filePath, symbol: record.symbol, reason: `calls ${matchedSymbol}` });
130
+ }
131
+ }
132
+
133
+ const importers = [];
134
+ const dependencies = [];
135
+ for (const imp of importItems) {
136
+ const from = normalizePath(imp.from);
137
+ const targets = resolveImportTarget(from, imp.to) ?? [];
138
+ const matched = targets.find((candidate) => changedFileSet.has(normalizePath(candidate)));
139
+
140
+ if (matched) {
141
+ importers.push(from);
142
+ dependencies.push(matched);
143
+ }
144
+ }
145
+
146
+ const candidateFiles = unique([
147
+ ...normalizedChangedFiles,
148
+ ...importers,
149
+ ...callers.map((item) => item.filePath),
150
+ ...mirrorCounterparts.map((item) => item.filePath),
151
+ ]);
152
+ const relatedTests = inferRelatedTestsFromArtifacts({
153
+ candidateFiles,
154
+ analogsMap: relatedArtifacts.analogsMap,
155
+ relationsMap: relatedArtifacts.relationsMap,
156
+ limit: budget.maxTests,
157
+ }).map((entry) => entry.filePath);
158
+
159
+ const recommendedTestFiles = unique([
160
+ ...relatedTests,
161
+ ...risk.riskLabels.flatMap((label) => RISK_TEST_RECOMMENDATIONS[label] ?? []),
162
+ ]);
163
+
164
+ return {
165
+ changedFiles: normalizedChangedFiles,
166
+ changedSymbols: normalizedChangedSymbols,
167
+ riskLabels: risk.riskLabels,
168
+ requiresImpactCheck: risk.requiresImpactCheck,
169
+ 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),
171
+ importers: unique(importers).slice(0, budget.maxImporters),
172
+ dependencies: unique(dependencies).slice(0, budget.maxImporters),
173
+ mirrorCounterparts,
174
+ relatedTests,
175
+ recommendedTestFiles,
176
+ explanations: {
177
+ callees: changedRecords.map((record) => `${record.filePath}:${record.symbol}`),
178
+ callers: callers.map((entry) => `${entry.filePath}:${entry.symbol}`),
179
+ importers,
180
+ relatedTests,
181
+ mirrorCounterparts: mirrorCounterparts.map((entry) => `${entry.source} -> ${entry.filePath}`),
182
+ },
183
+ };
184
+ }
185
+
186
+ export function formatImpactConfidenceSummary(impact = {}) {
187
+ const lines = ['Impact checked:'];
188
+
189
+ if ((impact.changedFiles ?? []).length > 0) {
190
+ lines.push(`- Changed: ${(impact.changedFiles ?? []).slice(0, 3).join(', ')}`);
191
+ }
192
+ if ((impact.changedSymbols ?? []).length > 0) {
193
+ lines.push(`- Symbols: ${(impact.changedSymbols ?? []).slice(0, 5).join(', ')}`);
194
+ }
195
+ if ((impact.riskLabels ?? []).length > 0) {
196
+ lines.push(`- Risk: ${(impact.riskLabels ?? []).join(', ')}`);
197
+ }
198
+
199
+ const calleeSymbols = (impact.callees ?? []).map((item) => item?.symbol).filter(Boolean).slice(0, 5);
200
+ if (calleeSymbols.length > 0) {
201
+ lines.push(`- Callees: ${calleeSymbols.join(', ')}`);
202
+ }
203
+
204
+ const callerFiles = (impact.callers ?? []).map((item) => item?.filePath).filter(Boolean).slice(0, 5);
205
+ if (callerFiles.length > 0) {
206
+ lines.push(`- Callers: ${callerFiles.join(', ')}`);
207
+ }
208
+
209
+ const importers = (impact.importers ?? []).slice(0, 5);
210
+ if (importers.length > 0) {
211
+ lines.push(`- Importers: ${importers.join(', ')}`);
212
+ }
213
+
214
+ const mirrors = (impact.mirrorCounterparts ?? []).map((item) => item?.filePath).filter(Boolean).slice(0, 3);
215
+ if (mirrors.length > 0) {
216
+ lines.push(`- Mirrors: ${mirrors.join(', ')}`);
217
+ }
218
+
219
+ const relatedTests = (impact.relatedTests ?? []).slice(0, 4);
220
+ if (relatedTests.length > 0) {
221
+ lines.push(`- Related tests: ${relatedTests.join(', ')}`);
222
+ }
223
+
224
+ const recommendedTests = (impact.recommendedTestFiles ?? []).slice(0, 4);
225
+ if (recommendedTests.length > 0) {
226
+ lines.push(`- Recommended tests: ${recommendedTests.join(', ')}`);
227
+ }
228
+
229
+ return lines.join('\n');
230
+ }
231
+
232
+ export { DEFAULT_IMPACT_LIMITS };
@@ -5,6 +5,7 @@ export const INDEX_ARTIFACTS = {
5
5
  files: 'files.json',
6
6
  symbols: 'symbols.json',
7
7
  imports: 'imports.json',
8
+ calls: 'calls.json',
8
9
  testsMap: 'tests-map.json',
9
10
  hotspots: 'hotspots.json',
10
11
  archetypes: 'archetypes.json',
@@ -4,6 +4,7 @@ import { inferRelatedTestsFromArtifacts, loadRelatedTestArtifacts } from './rela
4
4
  const TASK_TYPE_BUDGETS = {
5
5
  trivial: { minFiles: 1, maxFiles: 2 },
6
6
  simple: { minFiles: 2, maxFiles: 5 },
7
+ 'shared-simple': { minFiles: 3, maxFiles: 8 },
7
8
  'non-trivial': { minFiles: 4, maxFiles: 8 },
8
9
  };
9
10
 
@@ -5,6 +5,7 @@ import { ROUTE_CATALOG } from './routeCatalog.js';
5
5
  import { buildRouteSignalText } from './languageTools.js';
6
6
  import { resolveContext } from './resolveContext.js';
7
7
  import { deriveVerificationPlan } from './verificationPlan.js';
8
+ import { isSharedImpactFile } from './impactCatalog.js';
8
9
 
9
10
  const MAX_ACTIVE_ROUTE_SKILLS = 2;
10
11
 
@@ -42,6 +43,7 @@ export async function deriveTaskRoute({
42
43
  promptText: normalizedPrompt,
43
44
  commandText: normalizedCommand,
44
45
  selectedIds,
46
+ targetFile: normalizedTarget,
45
47
  });
46
48
  const preservedPrompt = normalizedPrompt || String(lastExplicitUserPromptText || '').trim();
47
49
  const contextResult = useIndexedContext && (contextIntent || normalizedTarget)
@@ -531,7 +533,7 @@ function deriveContextIntent({ promptText, commandText, targetFile, selectedIds
531
533
  return '';
532
534
  }
533
535
 
534
- function inferTaskType({ promptText, commandText, selectedIds }) {
536
+ function inferTaskType({ promptText, commandText, selectedIds, targetFile = null }) {
535
537
  const lower = buildRouteSignalText(promptText, commandText);
536
538
 
537
539
  if (
@@ -542,11 +544,16 @@ function inferTaskType({ promptText, commandText, selectedIds }) {
542
544
  return 'non-trivial';
543
545
  }
544
546
 
547
+ let inferred = 'simple';
545
548
  if (/\b(typo|label|text|rename|color|spacing|toggle|comment)\b/.test(lower)) {
546
- return 'trivial';
549
+ inferred = 'trivial';
550
+ }
551
+
552
+ if ((inferred === 'simple' || inferred === 'trivial') && isSharedImpactFile(targetFile)) {
553
+ return 'shared-simple';
547
554
  }
548
555
 
549
- return 'simple';
556
+ return inferred;
550
557
  }
551
558
 
552
559
  function shellEscape(str) {