homegraph 1.5.5 → 1.5.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.
Files changed (83) hide show
  1. package/CHANGELOG.md +46 -0
  2. package/README.md +10 -4
  3. package/dist/bin/homegraph.js +6 -0
  4. package/dist/bin/homegraph.js.map +1 -1
  5. package/dist/context/index.d.ts.map +1 -1
  6. package/dist/context/index.js +120 -15
  7. package/dist/context/index.js.map +1 -1
  8. package/dist/db/migrations.d.ts +1 -1
  9. package/dist/db/migrations.d.ts.map +1 -1
  10. package/dist/db/migrations.js +37 -1
  11. package/dist/db/migrations.js.map +1 -1
  12. package/dist/db/queries.d.ts +25 -0
  13. package/dist/db/queries.d.ts.map +1 -1
  14. package/dist/db/queries.js +50 -2
  15. package/dist/db/queries.js.map +1 -1
  16. package/dist/db/schema.sql +24 -0
  17. package/dist/extraction/languages/arkts.d.ts +40 -3
  18. package/dist/extraction/languages/arkts.d.ts.map +1 -1
  19. package/dist/extraction/languages/arkts.js +302 -22
  20. package/dist/extraction/languages/arkts.js.map +1 -1
  21. package/dist/index.d.ts +31 -0
  22. package/dist/index.d.ts.map +1 -1
  23. package/dist/index.js +87 -1
  24. package/dist/index.js.map +1 -1
  25. package/dist/mcp/engine.d.ts +26 -0
  26. package/dist/mcp/engine.d.ts.map +1 -1
  27. package/dist/mcp/engine.js +229 -8
  28. package/dist/mcp/engine.js.map +1 -1
  29. package/dist/mcp/evidence-rendering.d.ts +10 -0
  30. package/dist/mcp/evidence-rendering.d.ts.map +1 -0
  31. package/dist/mcp/evidence-rendering.js +71 -0
  32. package/dist/mcp/evidence-rendering.js.map +1 -0
  33. package/dist/mcp/explore-repeat-guard.d.ts +8 -13
  34. package/dist/mcp/explore-repeat-guard.d.ts.map +1 -1
  35. package/dist/mcp/explore-repeat-guard.js +54 -114
  36. package/dist/mcp/explore-repeat-guard.js.map +1 -1
  37. package/dist/mcp/explore-session-state.d.ts +26 -4
  38. package/dist/mcp/explore-session-state.d.ts.map +1 -1
  39. package/dist/mcp/explore-session-state.js +50 -4
  40. package/dist/mcp/explore-session-state.js.map +1 -1
  41. package/dist/mcp/index.d.ts.map +1 -1
  42. package/dist/mcp/index.js +16 -1
  43. package/dist/mcp/index.js.map +1 -1
  44. package/dist/mcp/query-cache.d.ts +1 -1
  45. package/dist/mcp/query-cache.d.ts.map +1 -1
  46. package/dist/mcp/query-cache.js +86 -3
  47. package/dist/mcp/query-cache.js.map +1 -1
  48. package/dist/mcp/query-pool.js +4 -4
  49. package/dist/mcp/server-instructions.d.ts +2 -2
  50. package/dist/mcp/server-instructions.d.ts.map +1 -1
  51. package/dist/mcp/server-instructions.js +36 -21
  52. package/dist/mcp/server-instructions.js.map +1 -1
  53. package/dist/mcp/tools.d.ts +29 -3
  54. package/dist/mcp/tools.d.ts.map +1 -1
  55. package/dist/mcp/tools.js +827 -109
  56. package/dist/mcp/tools.js.map +1 -1
  57. package/dist/project-map/index.d.ts +33 -0
  58. package/dist/project-map/index.d.ts.map +1 -0
  59. package/dist/project-map/index.js +260 -0
  60. package/dist/project-map/index.js.map +1 -0
  61. package/dist/search/literal-evidence.d.ts +44 -0
  62. package/dist/search/literal-evidence.d.ts.map +1 -0
  63. package/dist/search/literal-evidence.js +278 -0
  64. package/dist/search/literal-evidence.js.map +1 -0
  65. package/dist/search/query-plan-provider.d.ts +6 -0
  66. package/dist/search/query-plan-provider.d.ts.map +1 -0
  67. package/dist/search/query-plan-provider.js +289 -0
  68. package/dist/search/query-plan-provider.js.map +1 -0
  69. package/dist/search/query-plan.d.ts +98 -0
  70. package/dist/search/query-plan.d.ts.map +1 -0
  71. package/dist/search/query-plan.js +334 -0
  72. package/dist/search/query-plan.js.map +1 -0
  73. package/dist/search/query-utils.d.ts +6 -1
  74. package/dist/search/query-utils.d.ts.map +1 -1
  75. package/dist/search/query-utils.js +64 -5
  76. package/dist/search/query-utils.js.map +1 -1
  77. package/dist/sync/watcher.d.ts +15 -3
  78. package/dist/sync/watcher.d.ts.map +1 -1
  79. package/dist/sync/watcher.js +14 -1
  80. package/dist/sync/watcher.js.map +1 -1
  81. package/dist/types.d.ts +23 -0
  82. package/dist/types.d.ts.map +1 -1
  83. package/package.json +1 -1
package/dist/mcp/tools.js CHANGED
@@ -21,6 +21,8 @@ exports.queryAsBareSymbolInventory = queryAsBareSymbolInventory;
21
21
  exports.hasPositiveAnswerNowDirective = hasPositiveAnswerNowDirective;
22
22
  exports.reconcilePartialAnswerNow = reconcilePartialAnswerNow;
23
23
  const query_pool_1 = require("./query-pool");
24
+ const evidence_rendering_1 = require("./evidence-rendering");
25
+ const query_plan_1 = require("../search/query-plan");
24
26
  const memory_budget_1 = require("./memory-budget");
25
27
  const directory_1 = require("../directory");
26
28
  // Lazy-load the heavy HomeGraph chain off the MCP startup path — see the same
@@ -99,6 +101,20 @@ const MAX_OUTPUT_LENGTH = 15000;
99
101
  * far beyond any realistic legitimate query.
100
102
  */
101
103
  const MAX_INPUT_LENGTH = 10_000;
104
+ // Server-owned, structured-clone-safe request context. Never accept these from MCP clients.
105
+ const QUERY_PLAN_ARG = '__homegraphQueryPlan';
106
+ const QUERY_DEADLINE_ARG = '__homegraphQueryDeadlineAt';
107
+ const QUERY_STARTED_ARG = '__homegraphQueryStartedAt';
108
+ const QUERY_INDEX_STATE_ARG = '__homegraphQueryIndexState';
109
+ const QUERY_FAST_ATTEMPTED_ARG = '__homegraphQueryFastAttempted';
110
+ function readQueryPlan(args) {
111
+ const plan = args[QUERY_PLAN_ARG];
112
+ return plan?.version === query_plan_1.QUERY_PLAN_VERSION && typeof plan.originalQuery === 'string'
113
+ && typeof plan.canonicalQuery === 'string' && Array.isArray(plan.steps) ? plan : undefined;
114
+ }
115
+ function planFeature(plan, name, query, fallback) {
116
+ return plan?.features[name] ?? fallback(query);
117
+ }
102
118
  /** Example values for success-shaped bad-arg guidance (keyed by arg name). */
103
119
  const BAD_ARG_EXAMPLES = {
104
120
  query: 'authenticate login',
@@ -843,95 +859,107 @@ exports.tools = [
843
859
  annotations: READ_ONLY_ANNOTATIONS,
844
860
  },
845
861
  {
846
- name: 'homegraph_explore',
847
- description: 'PRIMARY entry for understanding THIS repo before you edit or answer structural questions. ' +
848
- 'Returns call paths + compact line-numbered source for the relevant symbols. Required: `query`. ' +
849
- 'CALL FIRST (alone, no parallel Grep/Read) when you will change an existing codebase — pass the user task or domain keywords ' +
850
- '(page/module/feature/component words); locate where to edit before writing code. Also CALL FIRST for how/wired questions, ' +
851
- 'named Type/Component/Page/Dialog, Type.member, click→handler, inheritance/subtypes, declaration/attribute sites, ' +
852
- 'ALL_CAPS constant / field-mutex usages, path-module NAPI/exports or inter-deps, and in-repo @kit/@ohos *usages/dependencies* ' +
853
- '(which files import a named export — not the SDK feature catalog). PascalCase names optional when domain keywords suffice. ' +
854
- 'Prefer callers/node when one named symbol is already enough. ' +
855
- 'DO NOT call for topic file-lists with no Type/file, literal copy hunts / pure existence compares with no anchors, ' +
856
- 'official-docs-only asks, empty-project-from-scratch scaffolds, git history, or media/binary asset inventories — those return Skip. ' +
857
- '@kit / OHOS API questions ARE in scope when the SDK API graph is available — call explore (usages or API symbols). ' +
858
- 'Literal string/pattern hunts → Grep; media assets → Glob; git history → git. ' +
859
- 'One explore; then answer or edit from Source + trail — do not re-grep/node/read the same symbols. ' +
860
- 'Overlapping paraphrase explores are refused (name a new Type/file/@kit to continue). ' +
861
- 'Busy/partial → retry ONCE with the named Next anchor or ONE narrow Grep — not a Grep/node storm '
862
- + '(session refuses further explore / depth fan-out after Partial).',
862
+ name: 'homegraph_usages',
863
+ description: 'PRIMARY first tool for a narrow WHERE-USED question about one named API, `.member`, ALL_CAPS constant, field, or mutex. ' +
864
+ 'Choose this instead of homegraph_explore when the requested answer is usage/reference locations. ' +
865
+ 'Required: `query`. Returns usage files/lines only; it does not build a general flow or source dump. ' +
866
+ '`homegraph_explore` auto-routes equivalent high-confidence queries here only for compatibility.',
863
867
  inputSchema: {
864
868
  type: 'object',
865
869
  properties: {
866
870
  query: {
867
871
  type: 'string',
868
- description: 'Required. For pre-edit orientation or how/mechanism: pass the user task or domain keywords (page/module/feature words). ' +
869
- 'For named flows, include Type / Type.member / component names. For @kit, ask usages (depend/import sites), not SDK catalogs.',
870
- },
871
- maxFiles: {
872
- type: 'number',
873
- description: 'Maximum number of files to include source code from (default: 12)',
874
- default: 12,
872
+ description: 'Required. Exact API/member/constant/field name, with or without where-used wording.',
875
873
  },
876
874
  projectPath: projectPathProperty,
877
875
  },
878
876
  required: ['query'],
879
877
  },
880
- annotations: READ_ONLY_ANNOTATIONS,
878
+ annotations: { ...READ_ONLY_ANNOTATIONS, title: 'HomeGraph Where-used Inventory' },
881
879
  },
882
880
  {
883
- name: 'homegraph_usages',
884
- description: 'Focused in-repo usage inventory for one named API, `.member`, ALL_CAPS constant, field, or mutex. ' +
885
- 'Required: `query`. Returns usage files/lines only; it does not build a general flow or source dump. ' +
886
- 'Use directly for a narrow where-used question; `homegraph_explore` auto-routes equivalent high-confidence queries here.',
881
+ name: 'homegraph_modules',
882
+ description: 'PRIMARY first tool for a narrow DEPENDENCY/CYCLE question about named path modules or `*common` / `*service` / `*component` / `*constants` modules. ' +
883
+ 'Choose this instead of homegraph_explore when the requested answer is module topology. ' +
884
+ 'Required: `query`. It does not build a general code flow or scan unrelated survey families. ' +
885
+ '`homegraph_explore` auto-routes equivalent high-confidence dependency questions here only for compatibility.',
887
886
  inputSchema: {
888
887
  type: 'object',
889
888
  properties: {
890
889
  query: {
891
890
  type: 'string',
892
- description: 'Required. Exact API/member/constant/field name, with or without where-used wording.',
891
+ description: 'Required. Dependency/cycle question containing the exact module names or paths.',
893
892
  },
894
893
  projectPath: projectPathProperty,
895
894
  },
896
895
  required: ['query'],
897
896
  },
898
- annotations: READ_ONLY_ANNOTATIONS,
897
+ annotations: { ...READ_ONLY_ANNOTATIONS, title: 'HomeGraph Module Dependencies' },
899
898
  },
900
899
  {
901
- name: 'homegraph_modules',
902
- description: 'Focused dependency/cycle inventory for named path modules or `*common` / `*service` / `*component` / `*constants` modules. ' +
903
- 'Required: `query`. It does not build a general code flow or scan unrelated survey families. ' +
904
- '`homegraph_explore` auto-routes equivalent high-confidence dependency questions here.',
900
+ name: 'homegraph_native',
901
+ description: 'PRIMARY first tool for a narrow NAPI/NATIVE EXPORT or registration question about one named path or Type. ' +
902
+ 'Choose this instead of homegraph_explore when the requested answer is the ArkTS↔native export surface. Required: `query`. ' +
903
+ 'Returns indexed export descriptors/registration sites without a general domain file dump. ' +
904
+ '`homegraph_explore` auto-routes equivalent high-confidence NAPI/export questions here only for compatibility.',
905
905
  inputSchema: {
906
906
  type: 'object',
907
907
  properties: {
908
908
  query: {
909
909
  type: 'string',
910
- description: 'Required. Dependency/cycle question containing the exact module names or paths.',
910
+ description: 'Required. NAPI/native export question containing the exact path or Type name.',
911
911
  },
912
912
  projectPath: projectPathProperty,
913
913
  },
914
914
  required: ['query'],
915
915
  },
916
- annotations: READ_ONLY_ANNOTATIONS,
916
+ annotations: { ...READ_ONLY_ANNOTATIONS, title: 'HomeGraph Native Exports' },
917
917
  },
918
918
  {
919
- name: 'homegraph_native',
920
- description: 'Focused NAPI/native export inventory for one named path or Type. Required: `query`. ' +
921
- 'Returns indexed export descriptors/registration sites without a general domain file dump. ' +
922
- '`homegraph_explore` auto-routes equivalent high-confidence NAPI/export questions here.',
919
+ name: 'homegraph_explore',
920
+ description: 'GENERAL PRIMARY entry for understanding THIS repo before you edit or answer structural questions. ' +
921
+ 'For an explicit narrow where-used, named module dependency/cycle, or NAPI/native export inventory, choose ' +
922
+ 'homegraph_usages, homegraph_modules, or homegraph_native instead; this tool keeps conservative auto-routing only for compatibility. ' +
923
+ 'Returns call paths + compact line-numbered source for the relevant symbols. Required: `query`. ' +
924
+ 'CALL FIRST (alone, no parallel Grep/Read) when you will change an existing codebase — pass the user task or domain keywords ' +
925
+ '(page/module/feature/component words); locate where to edit before writing code. Also CALL FIRST for how/wired questions, ' +
926
+ 'named Type/Component/Page/Dialog, Type.member, click→handler, inheritance/subtypes, declaration/attribute sites, ' +
927
+ 'or a cross-symbol mechanism/flow. Use the named focused tools for exact usage, module-topology, or native-export inventories. ' +
928
+ 'PascalCase names optional when domain keywords suffice. ' +
929
+ 'For edits preserve the requested action, target product/module and exclusions; taskContext may carry the full task. ' +
930
+ 'Prefer callers/node when one named symbol is already enough. ' +
931
+ 'DO NOT call for topic file-lists with no Type/file, literal copy hunts / pure existence compares with no anchors, ' +
932
+ 'official-docs-only asks, empty-project-from-scratch scaffolds, git history, or media/binary asset inventories — those return Skip. ' +
933
+ '@kit / OHOS API questions ARE in scope when the SDK API graph is available — exact where-used → homegraph_usages; ' +
934
+ 'mechanism/API-symbol flow → homegraph_explore. ' +
935
+ 'Literal string/pattern hunts → Grep; media assets → Glob; git history → git. ' +
936
+ 'One explore; then answer or edit from Source + trail — do not re-grep/node/read the same symbols. ' +
937
+ 'Overlapping paraphrase explores are refused (name a new Type/file/@kit to continue). ' +
938
+ 'Busy/partial → retry ONCE with the named Next anchor or ONE narrow Grep — not a Grep/node storm '
939
+ + '(session refuses further explore / depth fan-out after Partial).',
923
940
  inputSchema: {
924
941
  type: 'object',
925
942
  properties: {
926
943
  query: {
927
944
  type: 'string',
928
- description: 'Required. NAPI/native export question containing the exact path or Type name.',
945
+ description: 'Required. For pre-edit orientation or how/mechanism: pass the user task or domain keywords (page/module/feature words). ' +
946
+ 'For named flows, include Type / Type.member / component names. For @kit mechanism/flow, include module/export tokens; ' +
947
+ 'use homegraph_usages for a narrow import/usage inventory, not SDK catalogs.',
948
+ },
949
+ taskContext: {
950
+ type: 'string',
951
+ description: 'Optional original task, including requested changes, product/module scope, exclusions and acceptance requirements. Not source evidence; bounded to 4000 characters.',
952
+ },
953
+ maxFiles: {
954
+ type: 'number',
955
+ description: 'Maximum number of files to include source code from (default: 12)',
956
+ default: 12,
929
957
  },
930
958
  projectPath: projectPathProperty,
931
959
  },
932
960
  required: ['query'],
933
961
  },
934
- annotations: READ_ONLY_ANNOTATIONS,
962
+ annotations: { ...READ_ONLY_ANNOTATIONS, title: 'HomeGraph General Explore' },
935
963
  },
936
964
  {
937
965
  name: 'homegraph_status',
@@ -944,6 +972,27 @@ exports.tools = [
944
972
  },
945
973
  annotations: READ_ONLY_ANNOTATIONS,
946
974
  },
975
+ {
976
+ name: 'homegraph_project',
977
+ description: 'Shallow project map: modules + files per module (no symbols/call edges). ' +
978
+ 'PRIMARY for engineering overview while the full index is still building; also useful after full index. ' +
979
+ 'Optional `module` filters by name/path; `includeFiles` defaults true.',
980
+ inputSchema: {
981
+ type: 'object',
982
+ properties: {
983
+ module: {
984
+ type: 'string',
985
+ description: 'Optional module name or root path substring to filter.',
986
+ },
987
+ includeFiles: {
988
+ type: 'boolean',
989
+ description: 'Include per-module file lists (default true). Set false for module list only.',
990
+ },
991
+ projectPath: projectPathProperty,
992
+ },
993
+ },
994
+ annotations: READ_ONLY_ANNOTATIONS,
995
+ },
947
996
  {
948
997
  name: 'homegraph_arkui_migrate',
949
998
  description: 'ArkUI migrate / state-semantics snapshot in ONE call (components, state decorators + args, ' +
@@ -1256,10 +1305,15 @@ function reconcilePartialAnswerNow(text) {
1256
1305
  if (!/\*\*Partial locator\*\*/i.test(text))
1257
1306
  return text;
1258
1307
  let changed = false;
1308
+ let fenced = false;
1259
1309
  const out = text
1260
1310
  .split('\n')
1261
1311
  .map((line) => {
1262
- if (!/ANSWER NOW/i.test(line))
1312
+ if (/^\s*```/.test(line)) {
1313
+ fenced = !fenced;
1314
+ return line;
1315
+ }
1316
+ if (fenced || !/ANSWER NOW/i.test(line))
1263
1317
  return line;
1264
1318
  const stripped = stripPositiveAnswerNowFromLine(line);
1265
1319
  if (stripped.changed)
@@ -1270,7 +1324,7 @@ function reconcilePartialAnswerNow(text) {
1270
1324
  .join('\n');
1271
1325
  if (!changed)
1272
1326
  return text;
1273
- return `${out}\n\n> This survey is a **Partial locator**: the sections above are anchors, not a closed answer. Continue with the narrower lookup named above, or a text search, before answering.`;
1327
+ return `${out}\n\n> This survey is a **Partial locator**: the sections above are anchors, not a closed answer. Inspect missing source with a focused lookup, continue the requested edits, and verify the resulting code.`;
1274
1328
  }
1275
1329
  class ToolHandler {
1276
1330
  cg;
@@ -1459,8 +1513,11 @@ class ToolHandler {
1459
1513
  'homegraph_search',
1460
1514
  'homegraph_node',
1461
1515
  'homegraph_diff_impact',
1516
+ 'homegraph_project',
1462
1517
  ]);
1463
- if (stats.fileCount < TINY_REPO_FILE_THRESHOLD) {
1518
+ // An explicit host selection overrides the default size-based surface.
1519
+ // Otherwise small ArkTS repos silently lose requested specialized tools.
1520
+ if (!allow && stats.fileCount < TINY_REPO_FILE_THRESHOLD) {
1464
1521
  visible = visible.filter(t => TINY_REPO_CORE_TOOLS.has(t.name));
1465
1522
  }
1466
1523
  return visible.map(tool => {
@@ -1854,6 +1911,10 @@ class ToolHandler {
1854
1911
  * it (the CLI does) and explore behaves exactly as before, untracked.
1855
1912
  */
1856
1913
  async execute(toolName, args, sessionState) {
1914
+ const requestStartedAt = Date.now();
1915
+ args = { ...args };
1916
+ for (const key of [QUERY_PLAN_ARG, QUERY_DEADLINE_ARG, QUERY_STARTED_ARG, QUERY_INDEX_STATE_ARG, QUERY_FAST_ATTEMPTED_ARG])
1917
+ delete args[key];
1857
1918
  try {
1858
1919
  // Block the first tool call on the engine's post-open reconcile so we
1859
1920
  // never serve rows for files deleted/edited while no MCP server was
@@ -1894,13 +1955,37 @@ class ToolHandler {
1894
1955
  return check;
1895
1956
  }
1896
1957
  const projectPath = args.projectPath;
1958
+ if (toolName === 'homegraph_explore' && process.env.HOMEGRAPH_QUERY_PLANNER !== 'off') {
1959
+ const query = this.validateString(args.query, 'query');
1960
+ if (typeof query !== 'string')
1961
+ return query;
1962
+ const cg = this.getHomeGraph(projectPath);
1963
+ const gated = this.maybeDeepToolPhaseGate(cg, toolName);
1964
+ if (gated)
1965
+ return gated;
1966
+ // Refused repeats must not spend a model call or re-serve cached evidence.
1967
+ if (sessionState) {
1968
+ const repeat = (0, explore_repeat_guard_1.decideExploreRepeat)(sessionState.forProject(cg.getProjectRoot()), query);
1969
+ if (this.shouldRefuseRepeatedEvidence(repeat, cg.getProjectRoot())) {
1970
+ return this.textResult((0, explore_repeat_guard_1.formatExploreRepeatRefuse)(repeat, query));
1971
+ }
1972
+ }
1973
+ args[QUERY_STARTED_ARG] = requestStartedAt;
1974
+ args[QUERY_DEADLINE_ARG] = Date.now() + (0, query_pool_1.resolveToolDeadlineMs)();
1975
+ args[QUERY_PLAN_ARG] = await (0, query_plan_1.planQuery)(query, {
1976
+ deadlineAt: args[QUERY_DEADLINE_ARG],
1977
+ taskContext: (0, query_plan_1.mergeQueryPlanTaskContext)(process.env.HOMEGRAPH_QUERY_TASK_CONTEXT, typeof args.taskContext === 'string' ? args.taskContext : undefined),
1978
+ validateAnchor: (anchor) => this.isExactPlanAnchor(cg, anchor),
1979
+ });
1980
+ args[QUERY_INDEX_STATE_ARG] = `${cg.getBuildPhase()}:${cg.getStats().nodeCount}:${cg.getLastIndexedAt() ?? 0}`;
1981
+ }
1897
1982
  // Wrong question shapes → short Skip (before cache / graph work).
1898
1983
  if (toolName === 'homegraph_explore' || toolName === 'homegraph_search') {
1899
1984
  const qEarly = typeof args.query === 'string' ? args.query : '';
1900
1985
  if (qEarly) {
1901
1986
  const deferKind = (0, query_utils_1.queryShouldDeferToBuiltinTools)(qEarly);
1902
- if (deferKind) {
1903
- return this.textResult((0, query_utils_1.homegraphDeferGuidance)(deferKind, qEarly));
1987
+ if (deferKind && readQueryPlan(args)?.source !== 'llm') {
1988
+ return this.withQueryPlanDiagnostics(this.textResult((0, query_utils_1.homegraphDeferGuidance)(deferKind, qEarly)), args);
1904
1989
  }
1905
1990
  }
1906
1991
  }
@@ -1917,7 +2002,9 @@ class ToolHandler {
1917
2002
  // Session-tracked explore must not hit the MCP query cache: a cache hit
1918
2003
  // would re-serve the first call's full source and defeat CG-18 dedup.
1919
2004
  const skipCacheForSession = toolName === 'homegraph_explore' && !!sessionState;
1920
- const cacheEnabled = !skipCacheForSession && (0, query_cache_1.isMcpQueryCacheEnabled)() && (0, query_cache_1.isCacheableMcpTool)(toolName);
2005
+ const requestPlan = readQueryPlan(args);
2006
+ const cacheEnabled = !skipCacheForSession && requestPlan?.source !== 'llm'
2007
+ && !requestPlan?.telemetry.fallbackReason && (0, query_cache_1.isMcpQueryCacheEnabled)() && (0, query_cache_1.isCacheableMcpTool)(toolName);
1921
2008
  let cacheKey;
1922
2009
  let cacheQueries;
1923
2010
  let cacheIndex;
@@ -1937,7 +2024,8 @@ class ToolHandler {
1937
2024
  cacheKey = (0, query_cache_1.buildMcpQueryCacheKey)(toolName, args, fileCount);
1938
2025
  const cached = cacheIndex.getEntry(cacheQueries, cacheKey);
1939
2026
  if (cached) {
1940
- const withWorktree = this.withWorktreeNotice(cached, projectPath);
2027
+ const diagnosed = this.withQueryPlanDiagnostics(cached, args, true);
2028
+ const withWorktree = this.withWorktreeNotice(diagnosed, projectPath);
1941
2029
  return this.withStalenessNotice(withWorktree, projectPath);
1942
2030
  }
1943
2031
  }
@@ -1953,27 +2041,43 @@ class ToolHandler {
1953
2041
  if (toolName === 'homegraph_status') {
1954
2042
  return await this.handleStatus(args);
1955
2043
  }
2044
+ // While auto-init is still on the fast/indexing phases (no symbols yet),
2045
+ // deep tools return guidance so the agent uses homegraph_project instead
2046
+ // of abandoning HomeGraph after empty explore results.
2047
+ if (toolName !== 'homegraph_project') {
2048
+ try {
2049
+ const gated = this.maybeDeepToolPhaseGate(this.getHomeGraph(projectPath), toolName);
2050
+ if (gated)
2051
+ return gated;
2052
+ }
2053
+ catch {
2054
+ /* NotIndexed / sources disabled — fall through to normal handlers */
2055
+ }
2056
+ }
1956
2057
  // Every read tool races a deadline ≪ MCP client ~60s hard timeout and
1957
2058
  // Named-member / local-compact questions finish in tens of ms on the warm
1958
2059
  // main connection. Serving them here — before the query-pool offload —
1959
2060
  // avoids cold-worker / wedged-daemon paths that otherwise surface as empty
1960
2061
  // MCP client `-32001` (the handler itself is fine; the transport times out).
1961
- if (toolName === 'homegraph_explore' || toolName === 'homegraph_search') {
2062
+ if ((toolName === 'homegraph_explore' || toolName === 'homegraph_search')
2063
+ && (!requestPlan || (requestPlan.source === 'rules' && requestPlan.steps.length === 1
2064
+ && !requestPlan.telemetry.requestCount))) {
1962
2065
  const q = typeof args.query === 'string' ? args.query : '';
1963
2066
  if (q) {
1964
2067
  try {
1965
2068
  const cgFast = this.getHomeGraph(projectPath);
1966
2069
  const rootFast = cgFast.getProjectRoot();
1967
- const fast = (toolName === 'homegraph_explore'
1968
- ? this.trySpecializedExploreRoute(cgFast, q, rootFast)
1969
- : null)
1970
- ?? (toolName === 'homegraph_explore' || toolName === 'homegraph_search'
1971
- ? this.tryFastInventoryExplore(cgFast, q, rootFast)
1972
- : null)
1973
- ?? (toolName === 'homegraph_explore' || toolName === 'homegraph_search'
1974
- ? this.tryLightMechanismExplore(cgFast, q, rootFast)
2070
+ const fast = requestPlan ? this.tryPlannedFastPath(cgFast, requestPlan, rootFast) :
2071
+ (toolName === 'homegraph_explore'
2072
+ ? this.trySpecializedExploreRoute(cgFast, q, rootFast)
1975
2073
  : null)
1976
- ?? this.tryCompactLocalSymbolExplore(cgFast, q, rootFast);
2074
+ ?? (toolName === 'homegraph_explore' || toolName === 'homegraph_search'
2075
+ ? this.tryFastInventoryExplore(cgFast, q, rootFast)
2076
+ : null)
2077
+ ?? (toolName === 'homegraph_explore' || toolName === 'homegraph_search'
2078
+ ? this.tryLightMechanismExplore(cgFast, q, rootFast)
2079
+ : null)
2080
+ ?? this.tryCompactLocalSymbolExplore(cgFast, q, rootFast);
1977
2081
  if (fast) {
1978
2082
  // Fast explore must still file Partial/ANSWER meta into the session
1979
2083
  // so explore + depth fuses see it (textResult-only paths used to skip).
@@ -1981,12 +2085,15 @@ class ToolHandler {
1981
2085
  if (toolName === 'homegraph_explore' && sessionState) {
1982
2086
  served = this.takeExploreEmission(this.ensureExploreEmission(fast, rootFast, q), sessionState);
1983
2087
  }
2088
+ served = this.withQueryPlanDiagnostics(served, args);
1984
2089
  if (cacheEnabled && cacheKey && cacheQueries && cacheIndex && !served.isError) {
1985
2090
  cacheIndex.setEntry(cacheQueries, cacheKey, toolName, served);
1986
2091
  }
1987
2092
  const withWorktree = this.withWorktreeNotice(served, projectPath);
1988
2093
  return this.withStalenessNotice(withWorktree, projectPath);
1989
2094
  }
2095
+ if (requestPlan)
2096
+ args[QUERY_FAST_ATTEMPTED_ARG] = true;
1990
2097
  }
1991
2098
  catch {
1992
2099
  // Not indexed / path issue — fall through to normal dispatch.
@@ -2002,7 +2109,7 @@ class ToolHandler {
2002
2109
  // (a frozen main loop prevents setTimeout deadlines from firing → empty
2003
2110
  // `-32001`). Fast-path surveys run inside the worker via executeReadTool.
2004
2111
  const raw = await this.runReadToolWithDeadline(toolName, dispatchArgs);
2005
- const result = this.takeExploreEmission(raw, sessionState);
2112
+ const result = this.withQueryPlanDiagnostics(this.takeExploreEmission(raw, sessionState), args);
2006
2113
  if (sessionState
2007
2114
  && !result.isError
2008
2115
  && (toolName === 'homegraph_node'
@@ -2078,16 +2185,67 @@ class ToolHandler {
2078
2185
  return null;
2079
2186
  }
2080
2187
  }
2188
+ /** A repeated query is duplicate evidence only while its source is unchanged. */
2189
+ shouldRefuseRepeatedEvidence(decision, projectRoot) {
2190
+ if (!decision.refuse)
2191
+ return false;
2192
+ // Source changes do not reset the total retrieval budget.
2193
+ if (decision.reason !== 'overlap')
2194
+ return true;
2195
+ const files = decision.matched?.files.filter((file) => file.bytes > 0 && file.ranges.length > 0) ?? [];
2196
+ if (files.length === 0 || files.length > 24)
2197
+ return false;
2198
+ let remainingBytes = 2 * 1024 * 1024;
2199
+ for (const file of files) {
2200
+ if (!file.fingerprint)
2201
+ return false;
2202
+ const absolute = (0, utils_1.validatePathWithinRoot)(projectRoot, file.path);
2203
+ if (!absolute)
2204
+ return false;
2205
+ let descriptor;
2206
+ try {
2207
+ const stat = (0, fs_1.statSync)(absolute);
2208
+ const limit = Math.min(1024 * 1024, remainingBytes);
2209
+ if (!stat.isFile() || stat.size > limit)
2210
+ return false;
2211
+ // Fixed-size reads also remain bounded if the file grows after stat.
2212
+ descriptor = (0, fs_1.openSync)(absolute, 'r');
2213
+ const buffer = Buffer.alloc(stat.size + 1);
2214
+ let bytes = 0;
2215
+ while (bytes < buffer.length) {
2216
+ const read = (0, fs_1.readSync)(descriptor, buffer, bytes, buffer.length - bytes, bytes);
2217
+ if (read === 0)
2218
+ break;
2219
+ bytes += read;
2220
+ }
2221
+ if (bytes !== stat.size)
2222
+ return false;
2223
+ remainingBytes -= bytes;
2224
+ if ((0, explore_dedup_1.fileFingerprint)(buffer.subarray(0, bytes).toString('utf8')) !== file.fingerprint)
2225
+ return false;
2226
+ }
2227
+ catch {
2228
+ return false;
2229
+ }
2230
+ finally {
2231
+ if (descriptor !== undefined) {
2232
+ try {
2233
+ (0, fs_1.closeSync)(descriptor);
2234
+ }
2235
+ catch { /* bookkeeping only */ }
2236
+ }
2237
+ }
2238
+ }
2239
+ return true;
2240
+ }
2081
2241
  /** Count a successful depth-tool call when the latest explore was Partial. */
2082
2242
  noteDepthToolUse(args, sessionState) {
2083
2243
  try {
2084
2244
  const cg = this.getHomeGraph(args.projectPath);
2085
2245
  const root = cg.getProjectRoot();
2086
2246
  const prior = sessionState.forProject(root);
2087
- const last = prior?.calls.length
2088
- ? [...prior.calls].reverse().find((c) => (c.responseBytes || 0) >= 400)
2089
- : undefined;
2090
- if (!last?.partial)
2247
+ const last = prior?.calls[prior.calls.length - 1];
2248
+ if (!last || (0, explore_session_state_1.inferExploreEvidenceStatus)(last) === 'complete')
2091
2249
  return;
2092
2250
  sessionState.recordDepthTool(root);
2093
2251
  }
@@ -2107,17 +2265,22 @@ class ToolHandler {
2107
2265
  const emission = result?.[explore_session_state_1.EXPLORE_EMISSION_KEY];
2108
2266
  if (emission === undefined)
2109
2267
  return result;
2268
+ emission.evidenceStatus = (0, explore_session_state_1.inferExploreEvidenceStatus)(emission);
2269
+ emission.partial = emission.evidenceStatus !== 'complete';
2270
+ result._meta = { ...result._meta, homegraphEvidence: { status: emission.evidenceStatus,
2271
+ files: emission.files, locatedNodes: emission.locatedNodes, coveredObligations: emission.coveredObligations,
2272
+ uncoveredObligations: emission.uncoveredObligations } };
2110
2273
  delete result[explore_session_state_1.EXPLORE_EMISSION_KEY];
2111
2274
  if (sessionState) {
2112
2275
  try {
2113
2276
  const prior = sessionState.forProject(emission.projectRoot);
2114
- const priorPartials = (prior?.calls ?? []).filter((c) => c.partial === true && (c.responseBytes || 0) >= 400);
2277
+ const priorPartials = (prior?.calls ?? []).filter((c) => (0, explore_session_state_1.inferExploreEvidenceStatus)(c) !== 'complete');
2115
2278
  const metaPartial = emission.partial === true
2116
2279
  || (emission.partial === undefined
2117
2280
  && (0, explore_repeat_guard_1.inferExplorePartialMeta)(result.content?.[0]?.text ?? '').partial);
2118
2281
  if (metaPartial && priorPartials.length >= 1) {
2119
2282
  const text = result.content?.[0]?.text ?? '';
2120
- if (text && !/Second Partial — stop HomeGraph/i.test(text)) {
2283
+ if (text && !/Second Partial/i.test(text)) {
2121
2284
  const stop = (0, explore_repeat_guard_1.formatSecondPartialStopFooter)(emission.nextAnchor);
2122
2285
  result = {
2123
2286
  ...result,
@@ -2150,12 +2313,19 @@ class ToolHandler {
2150
2313
  * the success-shaped reply never flushed → empty client `-32001`.
2151
2314
  */
2152
2315
  async runReadToolWithDeadline(toolName, args) {
2153
- const deadlineMs = (0, query_pool_1.resolveToolDeadlineMs)();
2316
+ const deadlineMs = typeof args[QUERY_DEADLINE_ARG] === 'number'
2317
+ ? Math.max(0, Math.min((0, query_pool_1.resolveToolDeadlineMs)(), args[QUERY_DEADLINE_ARG] - Date.now()))
2318
+ : (0, query_pool_1.resolveToolDeadlineMs)();
2319
+ if (deadlineMs <= 0)
2320
+ return this.deadlineBusyResult((0, query_pool_1.resolveToolDeadlineMs)(), args);
2154
2321
  const light = toolName === 'homegraph_search'
2155
2322
  || toolName === 'homegraph_node'
2156
2323
  || toolName === 'homegraph_callers'
2157
2324
  || toolName === 'homegraph_callees'
2158
- || toolName === 'homegraph_files';
2325
+ || toolName === 'homegraph_files'
2326
+ || toolName === 'homegraph_project'
2327
+ // Project maps may be built on demand: never run them on a read worker.
2328
+ || (readQueryPlan(args)?.steps.some((step) => step.intent === 'overview') ?? false);
2159
2329
  const work = () => {
2160
2330
  if (!light && this.queryPool && this.queryPool.healthy) {
2161
2331
  return this.queryPool.run(toolName, args, {
@@ -2210,6 +2380,14 @@ class ToolHandler {
2210
2380
  */
2211
2381
  async executeReadTool(toolName, args) {
2212
2382
  try {
2383
+ if (toolName !== 'homegraph_project' && toolName !== 'homegraph_status') {
2384
+ const gate = this.maybeDeepToolPhaseGate(this.getHomeGraph(args.projectPath), toolName);
2385
+ if (gate)
2386
+ return gate;
2387
+ }
2388
+ const plan = toolName === 'homegraph_explore' ? readQueryPlan(args) : undefined;
2389
+ if (plan)
2390
+ return await this.executeQueryPlan(args, plan);
2213
2391
  // Compact inventory / one-symbol surveys — safe on the worker (keeps the
2214
2392
  // daemon main loop free). Never run these unprotected on the MCP transport
2215
2393
  // thread: they can block long enough for the client to emit empty `-32001`.
@@ -2240,6 +2418,16 @@ class ToolHandler {
2240
2418
  * NotIndexed/PathRefusal, which {@link executeReadTool} classifies.
2241
2419
  */
2242
2420
  async dispatchTool(toolName, args) {
2421
+ if (toolName !== 'homegraph_project' && toolName !== 'homegraph_status') {
2422
+ try {
2423
+ const gated = this.maybeDeepToolPhaseGate(this.getHomeGraph(args.projectPath), toolName);
2424
+ if (gated)
2425
+ return gated;
2426
+ }
2427
+ catch {
2428
+ /* fall through */
2429
+ }
2430
+ }
2243
2431
  switch (toolName) {
2244
2432
  case 'homegraph_search': return await this.handleSearch(args);
2245
2433
  case 'homegraph_callers': return await this.handleCallers(args);
@@ -2252,6 +2440,7 @@ class ToolHandler {
2252
2440
  case 'homegraph_native': return await this.handleSpecializedNative(args);
2253
2441
  case 'homegraph_node': return await this.handleNode(args);
2254
2442
  case 'homegraph_files': return await this.handleFiles(args);
2443
+ case 'homegraph_project': return await this.handleProject(args);
2255
2444
  case 'homegraph_arkui_migrate': return await this.handleArkuiMigrate(args);
2256
2445
  case 'homegraph_spec_match': return await this.handleSpecMatch(args);
2257
2446
  case 'homegraph_spec_find': return await this.handleSpecFind(args);
@@ -2259,6 +2448,36 @@ class ToolHandler {
2259
2448
  default: return this.errorResult(`Unknown tool: ${toolName}`);
2260
2449
  }
2261
2450
  }
2451
+ /**
2452
+ * Gate symbol/graph tools while auto-init is still on the fast map or
2453
+ * background full index (and no nodes exist yet). Success-shaped guidance —
2454
+ * never isError — so agents keep using HomeGraph.
2455
+ */
2456
+ maybeDeepToolPhaseGate(cg, _toolName) {
2457
+ const phase = cg.getBuildPhase();
2458
+ if (phase === 'full')
2459
+ return null;
2460
+ try {
2461
+ if (cg.getStats().nodeCount > 0)
2462
+ return null;
2463
+ }
2464
+ catch {
2465
+ /* ignore */
2466
+ }
2467
+ if (phase === 'fast' || phase === 'indexing') {
2468
+ return this.textResult([
2469
+ `Full symbol index is still building (phase=${phase}).`,
2470
+ 'Use `homegraph_project` for the module/file map now, then retry this tool once indexing finishes.',
2471
+ ].join('\n'));
2472
+ }
2473
+ if (phase === 'building_fast' || phase === 'none') {
2474
+ return this.textResult([
2475
+ 'HomeGraph is still preparing the project map.',
2476
+ 'Retry in a few seconds, or call `homegraph_project` once the fast build completes.',
2477
+ ].join('\n'));
2478
+ }
2479
+ return null;
2480
+ }
2262
2481
  async handleSearch(args) {
2263
2482
  const query = this.validateString(args.query, 'query');
2264
2483
  if (typeof query !== 'string')
@@ -5054,9 +5273,261 @@ class ToolHandler {
5054
5273
  lines.push('');
5055
5274
  return { section: lines.join('\n'), hitCount: locs.length + edges.length + extendsHits.length };
5056
5275
  }
5057
- /**
5058
- * Main-thread fast path for inventory surveys — skips the worker queue.
5059
- */
5276
+ /** Exact validation for model-proposed names: a fuzzy hit is not proof. */
5277
+ isExactPlanAnchor(cg, anchor) {
5278
+ if (!anchor || anchor.length > 256)
5279
+ return false;
5280
+ try {
5281
+ const matches = cg.searchNodes(anchor, { limit: 12 });
5282
+ return matches.some(({ node }) => node.name === anchor || node.qualifiedName === anchor
5283
+ || node.filePath.replace(/\\/g, '/') === anchor.replace(/\\/g, '/'));
5284
+ }
5285
+ catch {
5286
+ return false;
5287
+ }
5288
+ }
5289
+ /** Bind only declarations returned by this step, not input or global fuzzy hits. */
5290
+ locatedPlanBindings(cg, result, inherited) {
5291
+ const seen = new Set(inherited.map((binding) => binding.id));
5292
+ const declarations = new Set(inherited.map((binding) => `${binding.filePath}:${binding.startLine}:${binding.name}`));
5293
+ const located = [];
5294
+ for (const receipt of (result[explore_session_state_1.EXPLORE_EMISSION_KEY]?.locatedNodes ?? []).slice(0, 32)) {
5295
+ if (seen.has(receipt.id) || declarations.has(`${receipt.filePath}:${receipt.startLine}:${receipt.name}`))
5296
+ continue;
5297
+ const node = cg.getNode(receipt.id);
5298
+ if (!node || ['file', 'import', 'export', 'parameter'].includes(node.kind)
5299
+ || node.name === 'constructor' || node.name.startsWith('%AM') || node.filePath.includes('@dummy')
5300
+ || node.name !== receipt.name || node.filePath !== receipt.filePath || node.startLine !== receipt.startLine
5301
+ || node.qualifiedName !== receipt.qualifiedName)
5302
+ continue;
5303
+ seen.add(node.id);
5304
+ declarations.add(`${node.filePath}:${node.startLine}:${node.name}`);
5305
+ located.push({ id: node.id, name: node.name, qualifiedName: node.qualifiedName,
5306
+ filePath: node.filePath, startLine: node.startLine });
5307
+ if (located.length >= 8)
5308
+ break;
5309
+ }
5310
+ return located;
5311
+ }
5312
+ withQueryPlanDiagnostics(result, args, cacheHit = false) {
5313
+ const plan = readQueryPlan(args);
5314
+ if (!plan)
5315
+ return result;
5316
+ const prior = result._meta?.homegraphQueryPlan;
5317
+ const started = typeof args[QUERY_STARTED_ARG] === 'number' ? args[QUERY_STARTED_ARG] : Date.now();
5318
+ return { ...result, _meta: { ...result._meta, homegraphQueryPlan: {
5319
+ ...prior, version: plan.version, source: plan.source, intent: plan.intent, route: plan.route,
5320
+ confidence: plan.confidence,
5321
+ plannerSeeds: { anchors: plan.anchors, searchTerms: plan.searchTerms, literalTexts: plan.literalTexts,
5322
+ sourceScope: plan.sourceScope, relation: plan.relation },
5323
+ hasTaskContext: !!plan.taskContext,
5324
+ matchedFeatures: Object.entries(plan.features).filter(([, matched]) => matched).map(([name]) => name).slice(0, 12),
5325
+ planningMs: plan.telemetry.durationMs, durationMs: Math.max(plan.telemetry.durationMs, Date.now() - started),
5326
+ modelRequests: plan.telemetry.requestCount ?? 0,
5327
+ planningEligible: plan.telemetry.decision?.eligible,
5328
+ planningReason: plan.telemetry.decision?.reason,
5329
+ skip_reason: plan.telemetry.decision?.eligible === false ? plan.telemetry.decision.reason : undefined,
5330
+ ruleRoute: plan.telemetry.decision?.ruleRoute,
5331
+ inputTokens: plan.telemetry.inputTokens ?? (plan.telemetry.requestCount ? null : 0),
5332
+ outputTokens: plan.telemetry.outputTokens ?? (plan.telemetry.requestCount ? null : 0),
5333
+ fallbackReason: plan.telemetry.fallbackReason, cacheHit,
5334
+ steps: cacheHit ? [] : prior?.steps ?? [{ id: plan.steps[0]?.id, intent: plan.intent,
5335
+ status: result.isError ? 'failed'
5336
+ : (0, explore_repeat_guard_1.inferExplorePartialMeta)(result.content[0]?.text ?? '').partial ? 'partial'
5337
+ : /Status: (?:no_indexed_evidence|not_surveyed)|No relevant code|Skip HomeGraph/i.test(result.content[0]?.text ?? '')
5338
+ ? 'no_evidence' : 'evidence', resolvedAnchors: [] }],
5339
+ } } };
5340
+ }
5341
+ /** Select once; legacy section builders consume the same canonical query/features. */
5342
+ tryPlannedFastPath(cg, plan, root) {
5343
+ const query = plan.canonicalQuery;
5344
+ if (plan.route === 'usages' || plan.route === 'modules' || plan.route === 'native') {
5345
+ return this.runSpecializedExploreRoute(plan.route, cg, query, root, plan);
5346
+ }
5347
+ // These legacy paths re-extract seeds from text and cannot consume bound
5348
+ // node identity / step hints. Model general/flow plans use full explore;
5349
+ // rule/default and specialized routes retain their existing fast behavior.
5350
+ if (plan.source === 'llm' && (plan.intent === 'general' || plan.intent === 'flow'))
5351
+ return null;
5352
+ return this.tryFastInventoryExplore(cg, query, root, plan)
5353
+ ?? this.tryLightMechanismExplore(cg, query, root, plan)
5354
+ ?? this.tryCompactLocalSymbolExplore(cg, query, root, plan);
5355
+ }
5356
+ /** Internal execution only: no recursive MCP calls, model requests or new deadline. */
5357
+ async executePlannedStep(args, plan) {
5358
+ const cg = this.getHomeGraph(args.projectPath);
5359
+ if (plan.intent === 'overview') {
5360
+ return this.handleProject({ projectPath: args.projectPath });
5361
+ }
5362
+ const gate = this.maybeDeepToolPhaseGate(cg, 'homegraph_explore');
5363
+ if (gate)
5364
+ return gate;
5365
+ const query = plan.canonicalQuery;
5366
+ const defer = (0, query_utils_1.queryShouldDeferToBuiltinTools)(query);
5367
+ if (defer && plan.source === 'rules')
5368
+ return this.textResult((0, query_utils_1.homegraphDeferGuidance)(defer, query));
5369
+ // A typed relationship still needs a real target. Discover source from the
5370
+ // same bounded hints first; do not turn an unanchored usage request into an
5371
+ // empty survey or claim that its reference obligation has been covered.
5372
+ if (plan.source === 'llm' && plan.route === 'usages'
5373
+ && !plan.anchors.length && !plan.bindings?.length) {
5374
+ const result = await this.handleExplore({ ...args, query, [QUERY_PLAN_ARG]: plan });
5375
+ const served = this.ensureExploreEmission(result, cg.getProjectRoot(), plan.originalQuery);
5376
+ const emission = served[explore_session_state_1.EXPLORE_EMISSION_KEY];
5377
+ emission.partial = true;
5378
+ emission.evidenceStatus = emission.sourceBytes > 0 ? 'partial' : 'empty';
5379
+ emission.coveredObligations = [];
5380
+ emission.uncoveredObligations = [plan.relation ?? 'incoming_references'];
5381
+ if (served.content[0]?.type === 'text')
5382
+ served.content[0].text =
5383
+ '**Reference target discovery — partial**\nSource candidates follow. Verify the relevant target before surveying its references; the reference obligation remains open.\n\n'
5384
+ + served.content[0].text;
5385
+ return served;
5386
+ }
5387
+ const fast = args[QUERY_FAST_ATTEMPTED_ARG] === true && plan.source === 'rules'
5388
+ ? null : this.tryPlannedFastPath(cg, plan, cg.getProjectRoot());
5389
+ if (fast)
5390
+ return fast;
5391
+ return this.handleExplore({ ...args, query, [QUERY_PLAN_ARG]: plan });
5392
+ }
5393
+ async executeQueryPlan(args, plan) {
5394
+ const cg = this.getHomeGraph(args.projectPath);
5395
+ const root = cg.getProjectRoot();
5396
+ const deadline = typeof args[QUERY_DEADLINE_ARG] === 'number'
5397
+ ? args[QUERY_DEADLINE_ARG] : Date.now() + (0, query_pool_1.resolveToolDeadlineMs)();
5398
+ const prior = (0, explore_session_state_1.viewForProject)((0, explore_session_state_1.readExploreSessionView)(args), root);
5399
+ const repeat = (0, explore_repeat_guard_1.decideExploreRepeat)(prior, plan.originalQuery);
5400
+ if (this.shouldRefuseRepeatedEvidence(repeat, root)) {
5401
+ return this.textResult((0, explore_repeat_guard_1.formatExploreRepeatRefuse)(repeat, plan.originalQuery));
5402
+ }
5403
+ const multi = plan.steps.length > 1;
5404
+ let outputBudget = Math.min(MAX_OUTPUT_LENGTH, getExploreOutputBudget(cg.getStats().fileCount).maxOutputChars);
5405
+ if (!Number.isFinite(outputBudget))
5406
+ outputBudget = MAX_OUTPUT_LENGTH;
5407
+ const constraints = [plan.originalQuery, plan.taskContext].filter(Boolean).join('\n');
5408
+ const constraintLimit = Math.min(2000, Math.floor(outputBudget / 5));
5409
+ const constraintNotice = constraints.length > constraintLimit
5410
+ ? constraints.slice(0, constraintLimit) + '\n[Constraint display shortened; the original task remains authoritative.]'
5411
+ : constraints;
5412
+ const preamble = [
5413
+ '**Planned exploration — Partial locator**',
5414
+ '> **Partial locator** — scoped subquestion evidence, not proof that every requirement in the original question is covered.',
5415
+ '**Task constraints (not search seeds or verified source evidence)**\n' + constraintNotice,
5416
+ ].join('\n\n');
5417
+ const pieces = [];
5418
+ const diagnostics = [];
5419
+ const bindings = new Map();
5420
+ const files = [];
5421
+ const seenQueries = new Set();
5422
+ let single;
5423
+ let used = multi ? preamble.length + 400 : 400;
5424
+ for (const step of plan.steps.slice(0, 3)) {
5425
+ const started = Date.now();
5426
+ const diagnostic = { id: step.id, intent: step.intent, status: 'pending', resolvedAnchors: [],
5427
+ locatedNodes: [], durationMs: 0 };
5428
+ diagnostics.push(diagnostic);
5429
+ if (Date.now() >= deadline || used >= outputBudget) {
5430
+ diagnostic.status = 'budget_exhausted';
5431
+ pieces.push(`**Step ${step.id}: ${step.intent}** — not executed: shared request budget exhausted.`);
5432
+ continue;
5433
+ }
5434
+ if (step.dependsOn.some((id) => !bindings.get(id)?.length)) {
5435
+ diagnostic.status = 'dependency_unresolved';
5436
+ pieces.push(`**Step ${step.id}: ${step.intent}** — not executed: predecessor supplied no resolved symbol anchors.`);
5437
+ continue;
5438
+ }
5439
+ const resolved = step.dependsOn.flatMap((id) => bindings.get(id) ?? []);
5440
+ // Keep rule-only single queries byte-compatible; only model subplans are adapted.
5441
+ const compiled = plan.source === 'rules' && !multi ? plan : {
5442
+ ...(0, query_plan_1.compileQueryPlanStep)(plan, step, resolved.map((node) => node.qualifiedName || node.name)), bindings: resolved,
5443
+ };
5444
+ if (seenQueries.has(`${compiled.intent}:${compiled.canonicalQuery}`)) {
5445
+ diagnostic.status = 'duplicate_skipped';
5446
+ pieces.push(`**Step ${step.id}: ${step.intent}** — duplicate query skipped.`);
5447
+ continue;
5448
+ }
5449
+ seenQueries.add(`${compiled.intent}:${compiled.canonicalQuery}`);
5450
+ try {
5451
+ const result = await this.executePlannedStep({ ...args, [QUERY_DEADLINE_ARG]: deadline }, compiled);
5452
+ const body = result.content.map((part) => part.text).join('\n');
5453
+ const candidates = result.isError ? [] : this.locatedPlanBindings(cg, result, resolved);
5454
+ const childEmission = result[explore_session_state_1.EXPLORE_EMISSION_KEY];
5455
+ const hasLocalEvidence = candidates.length > 0 || (childEmission?.sourceBytes ?? 0) > 0;
5456
+ diagnostic.status = result.isError ? 'failed'
5457
+ : childEmission?.evidenceStatus === 'complete' ? 'evidence'
5458
+ : hasLocalEvidence ? (childEmission?.partial ? 'partial' : 'evidence')
5459
+ : /HarmonyOS SDK API|ohos-sdk:/.test(body) ? 'sdk_only' : 'no_evidence';
5460
+ if (!multi) {
5461
+ diagnostic.locatedNodes = candidates;
5462
+ diagnostic.resolvedAnchors = candidates.map((node) => node.name);
5463
+ single = result;
5464
+ break;
5465
+ }
5466
+ // A child cannot declare the original multi-part question answered.
5467
+ let fenced = false;
5468
+ const neutralBody = body.split('\n').filter((line) => {
5469
+ if (/^\s*```/.test(line)) {
5470
+ fenced = !fenced;
5471
+ return true;
5472
+ }
5473
+ return fenced || !/ANSWER NOW|Compact local explore complete|Do \*\*not\*\* (?:Read|Grep)|ONE tighter|ONE narrow/i.test(line);
5474
+ }).join('\n');
5475
+ const cap = Math.max(0, Math.floor((outputBudget - used) / Math.max(1, plan.steps.length - diagnostics.length + 1)) - 100);
5476
+ // Keep the grounded location receipt before source trimming. Only the
5477
+ // identities visibly delivered here may be consumed by the next step.
5478
+ let receipt = '';
5479
+ const visibleBindings = [];
5480
+ const inline = (value) => value.replace(/[`\r\n]/g, ' ');
5481
+ for (const candidate of candidates) {
5482
+ const next = (receipt ? '' : '**Located source candidates — relevance still needs verification**\n')
5483
+ + `- \`${inline(candidate.qualifiedName || candidate.name)}\` — \`${inline(candidate.filePath)}:${candidate.startLine}\`\n`;
5484
+ if (receipt.length + next.length > Math.min(1600, Math.floor(cap / 2)))
5485
+ break;
5486
+ receipt += next;
5487
+ visibleBindings.push(candidate);
5488
+ }
5489
+ diagnostic.locatedNodes = visibleBindings;
5490
+ diagnostic.resolvedAnchors = visibleBindings.map((node) => node.name);
5491
+ bindings.set(step.id, visibleBindings);
5492
+ const bodyCap = Math.max(0, cap - receipt.length - 2);
5493
+ const trimmed = neutralBody.length > bodyCap;
5494
+ const kept = (0, evidence_rendering_1.trimEvidenceAtLine)(neutralBody, bodyCap);
5495
+ if (trimmed)
5496
+ diagnostic.status = 'partial';
5497
+ else
5498
+ files.push(...(result[explore_session_state_1.EXPLORE_EMISSION_KEY]?.files ?? []));
5499
+ const piece = `**Step ${step.id}: ${step.intent}** (${diagnostic.status})\n${receipt}\n${kept}`;
5500
+ pieces.push(piece);
5501
+ used += piece.length;
5502
+ }
5503
+ catch (error) {
5504
+ if (error instanceof PathRefusalError)
5505
+ throw error;
5506
+ diagnostic.status = 'failed';
5507
+ pieces.push(`**Step ${step.id}: ${step.intent}** — retrieval failed; other step evidence is retained.`);
5508
+ }
5509
+ finally {
5510
+ diagnostic.durationMs = Date.now() - started;
5511
+ }
5512
+ // Yield between synchronous graph stages so deadlines and MCP I/O can run.
5513
+ await new Promise((resolve) => setImmediate(resolve));
5514
+ }
5515
+ const result = single ?? this.textResult([preamble, ...pieces].join('\n\n').slice(0, outputBudget));
5516
+ const served = this.ensureExploreEmission(result, root, plan.originalQuery);
5517
+ const emission = served[explore_session_state_1.EXPLORE_EMISSION_KEY];
5518
+ emission.query = plan.originalQuery;
5519
+ if (multi) {
5520
+ emission.partial = true;
5521
+ emission.evidenceStatus = 'partial';
5522
+ emission.coveredObligations = diagnostics.filter((step) => step.status === 'evidence').map((step) => step.id);
5523
+ emission.uncoveredObligations = diagnostics.filter((step) => step.status !== 'evidence').map((step) => step.id);
5524
+ emission.files = files;
5525
+ emission.sourceBytes = files.reduce((sum, file) => sum + file.bytes, 0);
5526
+ }
5527
+ served._meta = { ...served._meta, homegraphQueryPlan: { steps: diagnostics } };
5528
+ return served;
5529
+ }
5530
+ /** Main-thread fast path for inventory surveys — skips the worker queue. */
5060
5531
  tryFastPathResult(toolName, args) {
5061
5532
  const query = args.query;
5062
5533
  if (typeof query !== 'string')
@@ -5104,11 +5575,13 @@ class ToolHandler {
5104
5575
  : null;
5105
5576
  }
5106
5577
  /** Run exactly one bounded survey family and expose the selected route. */
5107
- runSpecializedExploreRoute(route, cg, query, projectRoot) {
5578
+ runSpecializedExploreRoute(route, cg, query, projectRoot, plan) {
5108
5579
  let section = '';
5109
5580
  let status = 'no_indexed_evidence';
5110
5581
  let coverage = '';
5111
5582
  if (route === 'modules') {
5583
+ if (plan?.relation === 'module_imports')
5584
+ return this.renderModuleImports(cg, plan);
5112
5585
  const manifestResult = this.buildFocusedModuleManifestSection(projectRoot, query);
5113
5586
  const graphResult = manifestResult.manifestCount === 0
5114
5587
  ? this.buildModuleDependencySurveySection(cg, query)
@@ -5126,10 +5599,10 @@ class ToolHandler {
5126
5599
  coverage = 'named path/Type NAPI export registrations only; no domain file dump was built';
5127
5600
  }
5128
5601
  else {
5129
- const apiUsage = (0, query_utils_1.shouldBuildApiUsageSurvey)(query)
5602
+ const apiUsage = planFeature(plan, 'shouldBuildApiUsageSurvey', query, query_utils_1.shouldBuildApiUsageSurvey)
5130
5603
  ? this.buildApiUsageSection(cg, query, projectRoot)
5131
5604
  : { section: '', fileCount: 0 };
5132
- const memberUsage = (0, query_utils_1.shouldBuildMemberSurvey)(query)
5605
+ const memberUsage = planFeature(plan, 'shouldBuildMemberSurvey', query, query_utils_1.shouldBuildMemberSurvey)
5133
5606
  && !((0, query_utils_1.queryAsFieldUsageSurvey)(query) && apiUsage.fileCount > 0)
5134
5607
  ? this.buildMemberSurveySection(cg, query, projectRoot)
5135
5608
  : '';
@@ -5147,13 +5620,43 @@ class ToolHandler {
5147
5620
  || (status === 'not_surveyed'
5148
5621
  ? '- No survey ran: this query carries no symbol name to scan. Re-run naming the symbol(s), or use `homegraph_explore`.'
5149
5622
  : '- No matching evidence was found in the current index for this focused survey.');
5150
- return this.textResult(this.truncateOutput([
5623
+ const text = this.truncateOutput([
5151
5624
  `**HomeGraph specialized route: ${route}**`,
5152
5625
  `Status: ${status}`,
5153
5626
  `Coverage: ${coverage}.`,
5154
5627
  '',
5155
5628
  body,
5156
- ].join('\n')));
5629
+ ].join('\n'));
5630
+ return this.exploreResult(text, { projectRoot, query, files: [], sourceBytes: 0,
5631
+ responseBytes: text.length, evidenceStatus: status === 'complete' ? 'complete' : 'empty',
5632
+ partial: status !== 'complete', coveredObligations: status === 'complete' ? [plan?.relation ?? route] : [],
5633
+ uncoveredObligations: status !== 'complete' ? [plan?.relation ?? route] : [] });
5634
+ }
5635
+ /** Directed file import witnesses for an explicit module-import relation. */
5636
+ renderModuleImports(cg, plan) {
5637
+ const scope = [...new Set([...plan.anchors, ...(plan.bindings ?? []).map(node => node.filePath)])]
5638
+ .map(value => value.replace(/\\/g, '/')).filter(Boolean);
5639
+ const paths = cg.getFiles().map(file => file.path).filter(file => scope.some(anchor => file === anchor || file.startsWith(anchor + '/') || file.split('/').includes(anchor)));
5640
+ const rows = [];
5641
+ let scanned = 0;
5642
+ for (const file of paths.slice(0, 120)) {
5643
+ scanned++;
5644
+ for (const target of cg.getFileDependencies(file)) {
5645
+ rows.push(`- \`${file}\` imports → \`${target}\``);
5646
+ if (rows.length >= 40)
5647
+ break;
5648
+ }
5649
+ if (rows.length >= 40)
5650
+ break;
5651
+ }
5652
+ const partial = scanned < paths.length || rows.length >= 40;
5653
+ const text = ['**Directed module imports**',
5654
+ rows.length ? rows.join('\n') : 'No indexed import edges found for the supplied module paths.',
5655
+ `Coverage: ${scanned} of ${paths.length} scoped files scanned; cycle analysis was not requested.`].join('\n\n');
5656
+ return this.exploreResult(text, { projectRoot: cg.getProjectRoot(), query: plan.originalQuery,
5657
+ files: [], sourceBytes: 0, responseBytes: text.length,
5658
+ evidenceStatus: !rows.length ? 'empty' : partial ? 'partial' : 'complete',
5659
+ partial: partial || !rows.length });
5157
5660
  }
5158
5661
  /** Usage sites for a bare symbol bag, including non-call textual references. */
5159
5662
  buildBareSymbolUsageSection(cg, query, projectRoot) {
@@ -5315,10 +5818,10 @@ class ToolHandler {
5315
5818
  /**
5316
5819
  * Fast inventory-only explore — skips findRelevantContext for survey/caller/dependency queries.
5317
5820
  */
5318
- tryFastInventoryExplore(cg, query, projectRoot) {
5319
- if (!(0, query_utils_1.shouldTryFastInventoryExplore)(query))
5821
+ tryFastInventoryExplore(cg, query, projectRoot, plan) {
5822
+ if (!planFeature(plan, 'shouldTryFastInventoryExplore', query, query_utils_1.shouldTryFastInventoryExplore))
5320
5823
  return null;
5321
- // Multi-Type dependency asks: inventory-only early exit (avoids fat compact / 25s busy).
5824
+ // Multi-Type dependency asks: inventory-only early exit (avoids fat compact / busy timeout).
5322
5825
  if ((0, query_utils_1.queryAsMultiTypeDependencySurvey)(query)) {
5323
5826
  const multi = this.buildMultiTypeDependencySection(cg, query);
5324
5827
  if (multi.section) {
@@ -5720,8 +6223,8 @@ class ToolHandler {
5720
6223
  * findRelevantContext. Fast enough for MCP budget; complete enough to avoid
5721
6224
  * agent grep/read loops (token savings).
5722
6225
  */
5723
- tryLightMechanismExplore(cg, query, projectRoot) {
5724
- if (!(0, query_utils_1.shouldTryLightMechanismExplore)(query))
6226
+ tryLightMechanismExplore(cg, query, projectRoot, plan) {
6227
+ if (!planFeature(plan, 'shouldTryLightMechanismExplore', query, query_utils_1.shouldTryLightMechanismExplore))
5725
6228
  return null;
5726
6229
  const STRUCTURE_KINDS = new Set(['class', 'struct', 'interface', 'component', 'method', 'function']);
5727
6230
  const isTestPath = (p) => /(^|\/)(tests?|spec)\//i.test(p) || /\.(test|spec)\./i.test(p);
@@ -6358,7 +6861,7 @@ class ToolHandler {
6358
6861
  * Compact explore for local-symbol behavior questions — skips findRelevantContext
6359
6862
  * and caps to 1–2 defining files (avoids the ~24K related-file dump).
6360
6863
  */
6361
- tryCompactLocalSymbolExplore(cg, query, projectRoot) {
6864
+ tryCompactLocalSymbolExplore(cg, query, projectRoot, plan) {
6362
6865
  // Inventory runs *before* this on the call sites. Do not refuse compact
6363
6866
  // merely because inventory *intent* matched — empty inventory must fall
6364
6867
  // through here (bare callbacks like OnSurfaceChangedCB).
@@ -6372,8 +6875,13 @@ class ToolHandler {
6372
6875
  // Light-mechanism owns domain howtos. Bare "how/如何" NL must NOT veto compact
6373
6876
  // when a local/flag/lifecycle shape already owns the query (flag impact was
6374
6877
  // falling through to a 20k Dynamic-dispatch dump).
6375
- if ((0, query_utils_1.shouldTryLightMechanismExplore)(query))
6878
+ if (planFeature(plan, 'shouldTryLightMechanismExplore', query, query_utils_1.shouldTryLightMechanismExplore))
6376
6879
  return null;
6880
+ // Explicit `…/Foo.ets` path asks need file-scoped full explore, not compact
6881
+ // trails seeded from path-segment homonyms (`/order/` → `order` property).
6882
+ if ((0, query_utils_1.shouldLimitToQueryNamedFile)(query, false, (0, query_utils_1.queryNamesMultipleExploreAnchors)(query))) {
6883
+ return null;
6884
+ }
6377
6885
  if ((0, query_utils_1.queryAsMechanismSurvey)(query)
6378
6886
  && !(0, query_utils_1.queryAsLocalSymbolDetail)(query)
6379
6887
  && !(0, query_utils_1.queryAsAssignedFlagImpactSurvey)(query)
@@ -8994,8 +9502,10 @@ class ToolHandler {
8994
9502
  // One normalization point so the flow-builder, relevance search, and
8995
9503
  // ranking all see the same canonical spelling (Erlang `mod:fn/arity`).
8996
9504
  const query = normalizeQuerySpelling(rawQuery);
9505
+ const plan = readQueryPlan(args);
9506
+ const feature = (name, fallback) => planFeature(plan, name, query, fallback);
8997
9507
  const deferKind = (0, query_utils_1.queryShouldDeferToBuiltinTools)(query);
8998
- if (deferKind) {
9508
+ if (deferKind && plan?.source !== 'llm') {
8999
9509
  return this.textResult((0, query_utils_1.homegraphDeferGuidance)(deferKind, query));
9000
9510
  }
9001
9511
  const cg = this.getHomeGraph(args.projectPath);
@@ -9003,7 +9513,7 @@ class ToolHandler {
9003
9513
  // Same-bag / call-budget refuse (session view injected by execute).
9004
9514
  const sessionPrior = (0, explore_session_state_1.viewForProject)((0, explore_session_state_1.readExploreSessionView)(args), projectRoot);
9005
9515
  const repeat = (0, explore_repeat_guard_1.decideExploreRepeat)(sessionPrior, query);
9006
- if (repeat.refuse) {
9516
+ if (!plan && this.shouldRefuseRepeatedEvidence(repeat, projectRoot)) {
9007
9517
  const text = (0, explore_repeat_guard_1.formatExploreRepeatRefuse)(repeat, query);
9008
9518
  return this.exploreResult(text, {
9009
9519
  projectRoot,
@@ -9013,7 +9523,8 @@ class ToolHandler {
9013
9523
  responseBytes: text.length,
9014
9524
  });
9015
9525
  }
9016
- const compactLocal = this.tryFastInventoryExplore(cg, query, projectRoot)
9526
+ // A planned step already tried fast paths once, before entering full explore.
9527
+ const compactLocal = plan ? null : this.tryFastInventoryExplore(cg, query, projectRoot)
9017
9528
  ?? this.tryLightMechanismExplore(cg, query, projectRoot)
9018
9529
  ?? this.tryCompactLocalSymbolExplore(cg, query, projectRoot);
9019
9530
  if (compactLocal)
@@ -9033,19 +9544,53 @@ class ToolHandler {
9033
9544
  const explicitMaxFiles = typeof args.maxFiles === 'number' && !Number.isNaN(args.maxFiles);
9034
9545
  let maxFiles = (0, utils_1.clamp)(args.maxFiles || budget.defaultMaxFiles, 1, 20);
9035
9546
  const queryFileBasenames = (0, query_utils_1.extractFileBasenamesFromQuery)(query);
9036
- const interpretationQuery = (0, query_utils_1.queryAsInterpretationSurvey)(query);
9037
- const testOnlyInterpretation = (0, query_utils_1.queryAsTestOnlyInterpretation)(query);
9038
- const crossModuleFlow = (0, query_utils_1.queryAsCrossModuleFlowSurvey)(query);
9547
+ const interpretationQuery = feature('queryAsInterpretationSurvey', query_utils_1.queryAsInterpretationSurvey);
9548
+ const testOnlyInterpretation = feature('queryAsTestOnlyInterpretation', query_utils_1.queryAsTestOnlyInterpretation);
9549
+ const crossModuleFlow = feature('queryAsCrossModuleFlowSurvey', query_utils_1.queryAsCrossModuleFlowSurvey) || plan?.intent === 'flow';
9039
9550
  // Step 1: Find relevant context with generous parameters.
9040
9551
  const contextOpts = interpretationQuery && queryFileBasenames.length === 1
9041
9552
  ? { searchLimit: 6, traversalDepth: 2, maxNodes: 60, minScore: 0.25 }
9042
9553
  : { searchLimit: 8, traversalDepth: 3, maxNodes: 200, minScore: 0.2 };
9043
9554
  const contextQuery = interpretationQuery && queryFileBasenames.length === 1
9044
9555
  ? `${queryFileBasenames[0]} ${query}`
9045
- : query;
9046
- const subgraph = await cg.findRelevantContext(contextQuery, contextOpts);
9556
+ : queryFileBasenames.length === 1
9557
+ ? `${queryFileBasenames[0]} ${query}`
9558
+ : query;
9559
+ const subgraph = await cg.findRelevantContext(contextQuery, {
9560
+ ...contextOpts,
9561
+ ...(plan && (plan.source === 'llm' || plan.literalTexts?.length) ? { retrievalHints: {
9562
+ symbols: plan.anchors.filter((anchor) => !(plan.bindings ?? []).some((node) => anchor === node.name || anchor === node.qualifiedName)),
9563
+ searchTerms: plan.searchTerms, literalTexts: plan.literalTexts, sourceScope: plan.sourceScope, nodeIds: (plan.bindings ?? []).map((node) => node.id),
9564
+ } } : {}),
9565
+ });
9566
+ // Path-first: always seed nodes from an explicit `Foo.ets` basename so a
9567
+ // CJK-only ask + path (or a shared prop like showSearchIcon) cannot leave
9568
+ // the named file out of the subgraph / digests.
9569
+ if (queryFileBasenames.length > 0 && plan?.sourceScope !== 'sdk') {
9570
+ for (const base of queryFileBasenames.slice(0, 3)) {
9571
+ let hits = [];
9572
+ try {
9573
+ hits = cg.searchNodes(base, { limit: 50 });
9574
+ }
9575
+ catch {
9576
+ continue;
9577
+ }
9578
+ for (const r of hits) {
9579
+ if (!(0, query_utils_1.fileMatchesQueryBasename)(r.node.filePath, [base]))
9580
+ continue;
9581
+ if (!subgraph.nodes.has(r.node.id)) {
9582
+ subgraph.nodes.set(r.node.id, r.node);
9583
+ subgraph.roots.push(r.node.id);
9584
+ }
9585
+ }
9586
+ }
9587
+ }
9588
+ const literalSource = this.renderLiteralSource(cg, subgraph);
9047
9589
  if (subgraph.nodes.size === 0) {
9048
- return this.textResult(`No relevant code found for "${query}"`);
9590
+ const text = literalSource.text || `No relevant code found for "${query}"`;
9591
+ return this.exploreResult(text, { projectRoot, query, files: literalSource.files,
9592
+ sourceBytes: literalSource.files.reduce((sum, file) => sum + file.bytes, 0), responseBytes: text.length,
9593
+ locatedNodes: literalSource.nodes, partial: true, evidenceStatus: literalSource.text ? 'partial' : 'empty' });
9049
9594
  }
9050
9595
  // Seed import nodes for @kit.* / *Kit names (and named symbols like taskpool).
9051
9596
  const importTerms = (0, query_utils_1.extractImportSearchTerms)(query);
@@ -9184,8 +9729,8 @@ class ToolHandler {
9184
9729
  namedParts.push(m[3]);
9185
9730
  }
9186
9731
  const tokens = [...new Set([
9187
- ...namedParts,
9188
- ...query.split(/[\s,()[\]]+/)
9732
+ ...(plan?.source === 'llm' ? plan.anchors : namedParts),
9733
+ ...(plan?.source === 'llm' ? plan.anchors.join(' ') : query).split(/[\s,()[\]]+/)
9189
9734
  .map((t) => t.replace(FILE_EXT, '').trim())
9190
9735
  .filter((t) => t.length >= 3 && /^[A-Za-z_$][\w$]*(?:(?:::|\.)[\w$]+)*$/.test(t)),
9191
9736
  ])].slice(0, 16);
@@ -9212,7 +9757,8 @@ class ToolHandler {
9212
9757
  const isQual = /[.\/]|::/.test(t);
9213
9758
  const raw = isQual ? this.findAllSymbols(cg, t).nodes : cg.getNodesByName(t);
9214
9759
  let cands = raw
9215
- .filter((n) => SEED_KINDS.has(n.kind) && !isTestPath(n.filePath))
9760
+ .filter((n) => SEED_KINDS.has(n.kind) && !isTestPath(n.filePath)
9761
+ && !(plan?.sourceScope === 'local' && (0, arkts_1.isOhosApiFilePath)(n.filePath)))
9216
9762
  .sort((a, b) => {
9217
9763
  // Prefer callables over types when both share a name, then body size.
9218
9764
  const ac = CALLABLE.has(a.kind) ? 1 : 0;
@@ -9427,6 +9973,8 @@ class ToolHandler {
9427
9973
  }
9428
9974
  fileGroups.set(node.filePath, group);
9429
9975
  }
9976
+ for (const group of fileGroups.values())
9977
+ group.nodes = (0, evidence_rendering_1.canonicalSourceDeclarations)(group.nodes);
9430
9978
  if (testOnlyInterpretation) {
9431
9979
  for (const [, group] of fileGroups) {
9432
9980
  const fp = group.nodes[0]?.filePath ?? '';
@@ -9622,6 +10170,15 @@ class ToolHandler {
9622
10170
  const sortedFiles = relevantFiles.sort((a, b) => {
9623
10171
  const aPath = a[0].toLowerCase();
9624
10172
  const bPath = b[0].toLowerCase();
10173
+ if (plan?.sourceScope === 'local') {
10174
+ const sdkOrder = Number((0, arkts_1.isOhosApiFilePath)(a[0])) - Number((0, arkts_1.isOhosApiFilePath)(b[0]));
10175
+ if (sdkOrder)
10176
+ return sdkOrder;
10177
+ }
10178
+ const literalOrder = Number((subgraph.literalEvidence?.hits ?? []).some((hit) => hit.filePath === b[0]))
10179
+ - Number((subgraph.literalEvidence?.hits ?? []).some((hit) => hit.filePath === a[0]));
10180
+ if (literalOrder)
10181
+ return literalOrder;
9625
10182
  // Query-named file (LocationController.ets in the question) before partial
9626
10183
  // substring matches (control.ets matching "Controller" inside LocationController).
9627
10184
  const aExactBase = (0, query_utils_1.fileMatchesQueryBasename)(a[0], queryFileBasenames) ? 1 : 0;
@@ -9681,6 +10238,8 @@ class ToolHandler {
9681
10238
  '',
9682
10239
  ];
9683
10240
  const summaryLineIdx = 2;
10241
+ if (literalSource.text)
10242
+ lines.push(literalSource.text);
9684
10243
  if (testOnlyInterpretation) {
9685
10244
  lines.push('> **Test-file scope only** — answer from the named `.test.ets` file below; ' +
9686
10245
  'production handlers are out of scope unless explicitly referenced in the test.');
@@ -9705,30 +10264,30 @@ class ToolHandler {
9705
10264
  : { section: '', symbolCount: 0 };
9706
10265
  if (kitUsageResult.section)
9707
10266
  lines.push(kitUsageResult.section);
9708
- const domainFileResult = (0, query_utils_1.shouldBuildDomainFileSurvey)(query)
10267
+ const domainFileResult = feature('shouldBuildDomainFileSurvey', query_utils_1.shouldBuildDomainFileSurvey)
9709
10268
  ? this.buildDomainFileSurveySection(cg, query)
9710
10269
  : { section: '', fileCount: 0 };
9711
10270
  if (domainFileResult.section)
9712
10271
  lines.push(domainFileResult.section);
9713
- const apiUsageResult = (0, query_utils_1.shouldBuildApiUsageSurvey)(query)
9714
- && !(0, query_utils_1.shouldBuildKitModuleUsageSurvey)(query)
10272
+ const apiUsageResult = feature('shouldBuildApiUsageSurvey', query_utils_1.shouldBuildApiUsageSurvey)
10273
+ && !feature('shouldBuildKitModuleUsageSurvey', query_utils_1.shouldBuildKitModuleUsageSurvey)
9715
10274
  ? this.buildApiUsageSection(cg, query, projectRoot)
9716
10275
  : { section: '', fileCount: 0 };
9717
10276
  if (apiUsageResult.section)
9718
10277
  lines.push(apiUsageResult.section);
9719
- const dataSourceResult = (0, query_utils_1.queryAsDataSourceSurvey)(query)
10278
+ const dataSourceResult = feature('queryAsDataSourceSurvey', query_utils_1.queryAsDataSourceSurvey)
9720
10279
  ? this.buildDataSourceSection(cg, query)
9721
10280
  : { section: '', edgeCount: 0, sdkImportCount: 0, strongCount: 0 };
9722
10281
  if (dataSourceResult.section)
9723
10282
  lines.push(dataSourceResult.section);
9724
- const eventDispatchResult = (0, query_utils_1.queryAsEventDispatchSurvey)(query)
10283
+ const eventDispatchResult = feature('queryAsEventDispatchSurvey', query_utils_1.queryAsEventDispatchSurvey)
9725
10284
  ? this.buildEventDispatchSection(cg, query, projectRoot)
9726
10285
  : { section: '', hitCount: 0, eventCount: 0, handlerCount: 0, memberCount: 0, complete: false };
9727
10286
  if (eventDispatchResult.section)
9728
10287
  lines.push(eventDispatchResult.section);
9729
10288
  const importInventoryFilter = (0, query_utils_1.hasImportInventoryFilter)(query);
9730
10289
  const multiAnchor = (0, query_utils_1.queryNamesMultipleExploreAnchors)(query) || crossModuleFlow;
9731
- const mechanismSurvey = (0, query_utils_1.queryAsMechanismSurvey)(query);
10290
+ const mechanismSurvey = feature('queryAsMechanismSurvey', query_utils_1.queryAsMechanismSurvey);
9732
10291
  // Flow path — computed before omit-source so graph connectivity drives the decision,
9733
10292
  // not question-text keyword matching. Mechanism/cross-module surveys augment the
9734
10293
  // query with seeded entry symbol names so buildFlowFromNamedSymbols can connect them.
@@ -9959,6 +10518,17 @@ class ToolHandler {
9959
10518
  const priorCalls = (0, explore_session_state_1.viewForProject)((0, explore_session_state_1.readExploreSessionView)(args), projectRoot);
9960
10519
  const dedupEnabled = (0, explore_dedup_1.exploreDedupEnabled)() && (priorCalls?.calls.length ?? 0) > 0;
9961
10520
  const emittedByFile = new Map();
10521
+ // Session emissions may include prior-call coverage or skeleton envelopes.
10522
+ // Dependency receipts instead require *fresh*, actually printed source and
10523
+ // a whole surviving file section (never the tail cut by the hard ceiling).
10524
+ const freshRangesByFile = new Map();
10525
+ const sourceEndByFile = new Map();
10526
+ const noteFreshSource = (fp, ranges) => {
10527
+ if (plan?.source !== 'llm' || !ranges.length)
10528
+ return;
10529
+ freshRangesByFile.set(fp, [...(freshRangesByFile.get(fp) ?? []), ...ranges]);
10530
+ sourceEndByFile.set(fp, lines.join('\n').length);
10531
+ };
9962
10532
  const noteEmitted = (fp, ranges, bytes, fingerprint) => {
9963
10533
  const existing = emittedByFile.get(fp);
9964
10534
  if (existing) {
@@ -10130,6 +10700,7 @@ class ToolHandler {
10130
10700
  }
10131
10701
  if (body.length > 0) {
10132
10702
  lines.push('```' + lang, body, '```', '');
10703
+ noteFreshSource(filePath, ranges);
10133
10704
  totalChars += body.length + lang.length + 11;
10134
10705
  noteEmitted(filePath, [...ranges, ...opts.covered], body.length, fingerprint);
10135
10706
  renderedFilePaths.push(filePath);
@@ -10216,6 +10787,7 @@ class ToolHandler {
10216
10787
  // signature line (capped, with a "+N more" tail so the structure map of a
10217
10788
  // god-file doesn't itself bloat the budget).
10218
10789
  const skel = [];
10790
+ const freshSkelRanges = [];
10219
10791
  let coveredUntil = 0; // skip symbols already inside an emitted body
10220
10792
  let sigCount = 0, sigDropped = 0;
10221
10793
  const SIG_MAX = Math.max(12, budget.maxSymbolsInFileHeader * 2);
@@ -10226,6 +10798,7 @@ class ToolHandler {
10226
10798
  const end = n.endLine;
10227
10799
  const body = fileLines.slice(n.startLine - 1, end).join('\n');
10228
10800
  skel.push(exploreLineNumbersEnabled() ? numberSourceLines(body, n.startLine) : body);
10801
+ freshSkelRanges.push({ start: n.startLine, end });
10229
10802
  coveredUntil = end;
10230
10803
  }
10231
10804
  else {
@@ -10248,6 +10821,7 @@ class ToolHandler {
10248
10821
  if (sig) {
10249
10822
  skel.push(exploreLineNumbersEnabled() ? `${lineNo}\t${sig}` : sig);
10250
10823
  sigCount++;
10824
+ freshSkelRanges.push({ start: lineNo, end: lineNo });
10251
10825
  }
10252
10826
  }
10253
10827
  }
@@ -10285,6 +10859,7 @@ class ToolHandler {
10285
10859
  return n ? { start: n.startLine, end: n.endLine || n.startLine } : null;
10286
10860
  }).filter((r) => !!r);
10287
10861
  lines.push(skelHeader, '', '```' + lang, skelBody, '```', '');
10862
+ noteFreshSource(filePath, freshSkelRanges);
10288
10863
  totalChars += skelBody.length + 120;
10289
10864
  noteEmitted(filePath, bodyRanges.length > 0 ? bodyRanges : [wholeRange], skelBody.length, fingerprint);
10290
10865
  renderedFilePaths.push(filePath);
@@ -10317,7 +10892,15 @@ class ToolHandler {
10317
10892
  ? Math.min(Math.max(0, budget.maxOutputChars - totalChars - 200), Math.round(budget.maxCharsPerFile * 1.5))
10318
10893
  : budget.maxCharsPerFile * 3;
10319
10894
  if (fileLines.length <= WHOLE_FILE_MAX_LINES && fileContent.length <= WHOLE_FILE_MAX_CHARS) {
10320
- const wholeRange = { start: 1, end: Math.max(1, fileLines.length) };
10895
+ let sourceStart = 0;
10896
+ if (fileLines[0]?.trim().startsWith('/*')) {
10897
+ const endComment = fileLines.findIndex((line) => line.includes('*/'));
10898
+ if (endComment >= 0 && endComment < 40)
10899
+ sourceStart = endComment + 1;
10900
+ }
10901
+ while (sourceStart < fileLines.length - 1 && !fileLines[sourceStart]?.trim())
10902
+ sourceStart++;
10903
+ const wholeRange = { start: sourceStart + 1, end: Math.max(1, fileLines.length) };
10321
10904
  const dd = (0, explore_dedup_1.dedupeRange)(wholeRange, served);
10322
10905
  const uniqSymbols = [...new Set(group.nodes
10323
10906
  .filter(n => n.kind !== 'import' && n.kind !== 'export')
@@ -10778,6 +11361,7 @@ class ToolHandler {
10778
11361
  const output = flow.text + lines.join('\n');
10779
11362
  const hardCeiling = Math.min(Math.round(budget.maxOutputChars * 1.5), 25000);
10780
11363
  let finalText;
11364
+ let sourceCutoff = output.length;
10781
11365
  if (output.length > hardCeiling) {
10782
11366
  // Prefer dropping trailing notes ("Not shown above", completeness, budget)
10783
11367
  // over dropping a whole file's source — notes are recoverable, source isn't.
@@ -10792,8 +11376,10 @@ class ToolHandler {
10792
11376
  if (trimmed.length <= hardCeiling)
10793
11377
  break;
10794
11378
  const at = trimmed.lastIndexOf(marker);
10795
- if (at > hardCeiling * 0.4)
11379
+ if (at > hardCeiling * 0.4) {
10796
11380
  trimmed = trimmed.slice(0, at).replace(/\n+$/, '');
11381
+ sourceCutoff = Math.min(sourceCutoff, trimmed.length);
11382
+ }
10797
11383
  }
10798
11384
  if (trimmed.length > hardCeiling) {
10799
11385
  // Cut at a FILE-SECTION boundary so we drop whole trailing file-sections
@@ -10822,6 +11408,7 @@ class ToolHandler {
10822
11408
  boundary = lastSection > hardCeiling * 0.5 ? lastSection : cut.lastIndexOf('\n');
10823
11409
  }
10824
11410
  const safe = boundary > 0 ? cut.slice(0, boundary) : cut;
11411
+ sourceCutoff = Math.min(sourceCutoff, safe.length);
10825
11412
  finalText = safe + '\n\n... (output truncated to budget; the source above is complete and verbatim — treat it as already Read. For uncovered files/symbols, run another homegraph_explore with their exact names — not grep/read/node for symbols already shown.)';
10826
11413
  }
10827
11414
  else {
@@ -10872,14 +11459,85 @@ class ToolHandler {
10872
11459
  });
10873
11460
  sourceBytes += emitted.bytes;
10874
11461
  }
11462
+ // Do not promote unseen subgraph hits or a large parent's unshown header.
11463
+ // The declaration's starting line must be in a surviving, fresh source span.
11464
+ const rootIds = new Set(subgraph.roots);
11465
+ for (const file of literalSource.files) {
11466
+ if (!finalText.includes(literalSource.text))
11467
+ break;
11468
+ emittedFiles.push(file);
11469
+ sourceBytes += file.bytes;
11470
+ }
11471
+ const locatedNodes = plan?.source === 'llm' ? [...(finalText.includes(literalSource.text) ? literalSource.nodes : []), ...emittedFiles.flatMap((file) => staleRendered.includes(file.path) || flow.text.length + (sourceEndByFile.get(file.path) ?? Infinity) > sourceCutoff
11472
+ ? [] : (fileGroups.get(file.path)?.nodes ?? []).filter((node) => !['file', 'import', 'export', 'parameter'].includes(node.kind)
11473
+ && (freshRangesByFile.get(file.path) ?? []).some((range) => node.startLine >= range.start && node.startLine <= range.end)))]
11474
+ .sort((a, b) => Number(rootIds.has(b.id)) - Number(rootIds.has(a.id)))
11475
+ .filter((node, i, nodes) => nodes.findIndex((other) => other.id === node.id) === i)
11476
+ .slice(0, 32).map((node) => ({ id: node.id, name: node.name, qualifiedName: node.qualifiedName,
11477
+ filePath: node.filePath, startLine: node.startLine })) : undefined;
10875
11478
  return this.exploreResult(finalText, {
10876
11479
  projectRoot,
10877
11480
  query,
10878
11481
  files: emittedFiles,
10879
11482
  sourceBytes,
11483
+ evidenceStatus: sourceBytes > 0 ? (anyFileTrimmed || subgraph.confidence === 'low' || sourceCutoff < output.length
11484
+ || (plan?.source === 'llm' && !(locatedNodes?.length)) ? 'partial' : 'complete')
11485
+ : filesIncluded > 0 && /HarmonyOS SDK API/.test(finalText) ? 'sdk-only' : 'empty',
10880
11486
  responseBytes: finalText.length,
11487
+ ...(locatedNodes ? { locatedNodes } : {}),
10881
11488
  });
10882
11489
  }
11490
+ /** Render literal witnesses before graph-heavy sections, including unindexed UI. */
11491
+ renderLiteralSource(cg, subgraph) {
11492
+ const sections = [];
11493
+ const files = [];
11494
+ const nodes = [];
11495
+ const seen = new Set();
11496
+ let chars = 0;
11497
+ for (const hit of subgraph.literalEvidence?.hits ?? []) {
11498
+ if (files.length >= 2 || seen.has(hit.filePath))
11499
+ continue;
11500
+ const absolute = (0, utils_1.validatePathWithinRoot)(cg.getProjectRoot(), hit.filePath);
11501
+ if (!absolute)
11502
+ continue;
11503
+ let source;
11504
+ try {
11505
+ source = (0, fs_1.readFileSync)(absolute, 'utf8');
11506
+ }
11507
+ catch {
11508
+ continue;
11509
+ }
11510
+ const lines = source.split('\n');
11511
+ // Never serve an earlier witness against a changed file.
11512
+ if (lines.slice(hit.startLine - 1, hit.endLine).join('\n') !== hit.text)
11513
+ continue;
11514
+ const containing = this.isFileStaleOnDisk(cg, hit.filePath, source) ? undefined
11515
+ : (0, evidence_rendering_1.canonicalSourceDeclarations)(cg.getNodesInFile(hit.filePath))
11516
+ .filter(node => !['file', 'import', 'export', 'parameter'].includes(node.kind)
11517
+ && !node.name.startsWith('%') && node.name !== 'constructor'
11518
+ && node.startLine <= hit.line && node.endLine >= hit.line)
11519
+ .sort((a, b) => (a.endLine - a.startLine) - (b.endLine - b.startLine))[0];
11520
+ const ranges = [{ start: hit.startLine, end: hit.endLine }];
11521
+ if (containing && containing.startLine < hit.startLine) {
11522
+ ranges.unshift({ start: containing.startLine, end: Math.min(containing.startLine + 2, hit.startLine - 1) });
11523
+ }
11524
+ const body = ranges.map(range => lines.slice(range.start - 1, range.end)
11525
+ .map((line, index) => `${range.start + index}\t${line}`).join('\n')).join('\n... (gap) ...\n');
11526
+ const resource = hit.resource
11527
+ ? `\nResource: \`${hit.resource.filePath}:${hit.resource.line}\` — ${JSON.stringify(hit.resource.value)} → \`${hit.resource.key}\`.` : '';
11528
+ const section = `**Literal source witness: \`${hit.filePath}:${hit.line}\`**${resource}\n\n\`\`\`\n${body}\n\`\`\``;
11529
+ if (chars + section.length > 3000)
11530
+ continue;
11531
+ sections.push(section);
11532
+ chars += section.length;
11533
+ seen.add(hit.filePath);
11534
+ files.push({ path: hit.filePath, ranges, bytes: body.length, fingerprint: (0, explore_dedup_1.fileFingerprint)(source) });
11535
+ if (containing)
11536
+ nodes.push({ id: containing.id, name: containing.name,
11537
+ qualifiedName: containing.qualifiedName, filePath: containing.filePath, startLine: containing.startLine });
11538
+ }
11539
+ return { text: sections.join('\n\n'), files, nodes };
11540
+ }
10883
11541
  /**
10884
11542
  * An explore response plus the record of what it emitted (CG-17). The record
10885
11543
  * rides the result only as far as {@link execute}, which files it into the
@@ -10890,7 +11548,8 @@ class ToolHandler {
10890
11548
  const result = this.textResult(text);
10891
11549
  result[explore_session_state_1.EXPLORE_EMISSION_KEY] = {
10892
11550
  ...emission,
10893
- partial: emission.partial ?? meta.partial,
11551
+ evidenceStatus: emission.evidenceStatus ?? (emission.sourceBytes > 0 ? (meta.partial ? 'partial' : 'complete') : 'partial'),
11552
+ partial: emission.partial ?? (emission.evidenceStatus && emission.evidenceStatus !== 'complete' ? true : meta.partial),
10894
11553
  nextAnchor: emission.nextAnchor ?? meta.nextAnchor,
10895
11554
  };
10896
11555
  return result;
@@ -10916,7 +11575,8 @@ class ToolHandler {
10916
11575
  files: [],
10917
11576
  sourceBytes: 0,
10918
11577
  responseBytes: text.length,
10919
- partial: meta.partial,
11578
+ partial: true,
11579
+ evidenceStatus: /HarmonyOS SDK API|ohos-sdk:/.test(text) ? 'sdk-only' : /No relevant code|No matching evidence|No survey ran/.test(text) ? 'empty' : 'partial',
10920
11580
  nextAnchor: meta.nextAnchor,
10921
11581
  };
10922
11582
  return result;
@@ -10951,7 +11611,7 @@ class ToolHandler {
10951
11611
  const symbol = this.validateString(args.symbol, 'symbol');
10952
11612
  if (typeof symbol !== 'string')
10953
11613
  return symbol;
10954
- let matches = this.findSymbolMatches(cg, symbol);
11614
+ let matches = (0, evidence_rendering_1.canonicalSourceDeclarations)(this.findSymbolMatches(cg, symbol));
10955
11615
  if (matches.length === 0) {
10956
11616
  return this.textResult(`Symbol "${symbol}" not found in the codebase`);
10957
11617
  }
@@ -11317,7 +11977,7 @@ class ToolHandler {
11317
11977
  if (mismatch) {
11318
11978
  lines.push(`> ⚠ ${(0, worktree_1.worktreeMismatchWarning)(mismatch).replace(/\n/g, '\n> ')}`, '');
11319
11979
  }
11320
- lines.push(`**Files indexed:** ${stats.fileCount}`, `**Total nodes:** ${stats.nodeCount}`, `**Total edges:** ${stats.edgeCount}`, `**Database size:** ${(stats.dbSizeBytes / 1024 / 1024).toFixed(2)} MB`, ...(stats.walSizeBytes > 0
11980
+ lines.push(`**Build phase:** ${cg.getBuildPhase()}`, `**Files indexed:** ${stats.fileCount}`, `**Total nodes:** ${stats.nodeCount}`, `**Total edges:** ${stats.edgeCount}`, `**Database size:** ${(stats.dbSizeBytes / 1024 / 1024).toFixed(2)} MB`, ...(stats.walSizeBytes > 0
11321
11981
  ? [`**WAL size:** ${(stats.walSizeBytes / 1024 / 1024).toFixed(2)} MB`]
11322
11982
  : []), `**Graph sources:** ${cg.getGraphSources()}`);
11323
11983
  // Surface the active SQLite backend: node:sqlite → better-sqlite3 → wasm.
@@ -11382,6 +12042,64 @@ class ToolHandler {
11382
12042
  }
11383
12043
  return this.textResult(lines.join('\n'));
11384
12044
  }
12045
+ /**
12046
+ * Handle homegraph_project — shallow module → file map (fast build).
12047
+ */
12048
+ async handleProject(args) {
12049
+ const cg = this.getHomeGraph(args.projectPath);
12050
+ const phase = cg.getBuildPhase();
12051
+ if (phase === 'building_fast') {
12052
+ return this.textResult('Fast project map is still building — retry in a few seconds.');
12053
+ }
12054
+ let map = cg.getProjectMap({
12055
+ module: typeof args.module === 'string' ? args.module : undefined,
12056
+ includeFiles: args.includeFiles !== false,
12057
+ });
12058
+ // On-demand build for indexes that never ran the fast path (legacy / CLI init).
12059
+ if (map.modules.length === 0) {
12060
+ try {
12061
+ cg.buildProjectMap();
12062
+ if (phase === 'none') {
12063
+ cg.setBuildPhase(cg.getStats().nodeCount > 0 ? 'full' : 'fast');
12064
+ }
12065
+ map = cg.getProjectMap({
12066
+ module: typeof args.module === 'string' ? args.module : undefined,
12067
+ includeFiles: args.includeFiles !== false,
12068
+ });
12069
+ }
12070
+ catch (err) {
12071
+ const message = err instanceof Error ? err.message : String(err);
12072
+ return this.textResult(`Failed to build project map: ${message}`);
12073
+ }
12074
+ }
12075
+ if (map.modules.length === 0) {
12076
+ return this.textResult('No modules found in the project map.');
12077
+ }
12078
+ const FILE_CAP = 80;
12079
+ const lines = [
12080
+ `**Project map** (phase=${map.phase})`,
12081
+ `modules: ${map.modules.length} · files: ${map.fileCount}`,
12082
+ '',
12083
+ ];
12084
+ if (map.phase === 'fast' || map.phase === 'indexing') {
12085
+ lines.push('_Full symbol index still building — this map has modules/files only (no call graph)._', '');
12086
+ }
12087
+ for (const m of map.modules) {
12088
+ const rootLabel = m.rootPath || '.';
12089
+ lines.push(`### ${m.name} (\`${rootLabel}\`) · ${m.kind} · ${m.fileCount} files`);
12090
+ if (m.files && m.files.length > 0) {
12091
+ const shown = m.files.slice(0, FILE_CAP);
12092
+ for (const f of shown) {
12093
+ lines.push(`- ${f.path}`);
12094
+ }
12095
+ if (m.files.length > FILE_CAP) {
12096
+ lines.push(`- … +${m.files.length - FILE_CAP} more`);
12097
+ }
12098
+ }
12099
+ lines.push('');
12100
+ }
12101
+ return this.textResult(lines.join('\n').trimEnd());
12102
+ }
11385
12103
  /**
11386
12104
  * Handle homegraph_arkui_migrate — one-shot ArkUI migrate / state snapshot (spec 0007).
11387
12105
  */
@@ -12269,7 +12987,7 @@ class ToolHandler {
12269
12987
  return {
12270
12988
  // Single choke point for every tool's text, so no section builder can ship
12271
12989
  // a stop-searching directive on an output that declares itself partial.
12272
- content: [{ type: 'text', text: reconcilePartialAnswerNow(text) }],
12990
+ content: [{ type: 'text', text: (0, evidence_rendering_1.neutralRetrievalGuidance)(reconcilePartialAnswerNow(text)) }],
12273
12991
  };
12274
12992
  }
12275
12993
  /**