homegraph 1.5.6 → 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 (52) hide show
  1. package/CHANGELOG.md +32 -0
  2. package/README.md +10 -4
  3. package/dist/context/index.d.ts.map +1 -1
  4. package/dist/context/index.js +120 -15
  5. package/dist/context/index.js.map +1 -1
  6. package/dist/extraction/languages/arkts.d.ts +25 -3
  7. package/dist/extraction/languages/arkts.d.ts.map +1 -1
  8. package/dist/extraction/languages/arkts.js +184 -22
  9. package/dist/extraction/languages/arkts.js.map +1 -1
  10. package/dist/mcp/evidence-rendering.d.ts +10 -0
  11. package/dist/mcp/evidence-rendering.d.ts.map +1 -0
  12. package/dist/mcp/evidence-rendering.js +71 -0
  13. package/dist/mcp/evidence-rendering.js.map +1 -0
  14. package/dist/mcp/explore-repeat-guard.d.ts +8 -13
  15. package/dist/mcp/explore-repeat-guard.d.ts.map +1 -1
  16. package/dist/mcp/explore-repeat-guard.js +54 -114
  17. package/dist/mcp/explore-repeat-guard.js.map +1 -1
  18. package/dist/mcp/explore-session-state.d.ts +26 -4
  19. package/dist/mcp/explore-session-state.d.ts.map +1 -1
  20. package/dist/mcp/explore-session-state.js +50 -4
  21. package/dist/mcp/explore-session-state.js.map +1 -1
  22. package/dist/mcp/query-cache.d.ts +1 -1
  23. package/dist/mcp/query-cache.d.ts.map +1 -1
  24. package/dist/mcp/query-cache.js +86 -3
  25. package/dist/mcp/query-cache.js.map +1 -1
  26. package/dist/mcp/server-instructions.d.ts +2 -2
  27. package/dist/mcp/server-instructions.d.ts.map +1 -1
  28. package/dist/mcp/server-instructions.js +34 -21
  29. package/dist/mcp/server-instructions.js.map +1 -1
  30. package/dist/mcp/tools.d.ts +19 -3
  31. package/dist/mcp/tools.d.ts.map +1 -1
  32. package/dist/mcp/tools.js +690 -107
  33. package/dist/mcp/tools.js.map +1 -1
  34. package/dist/search/literal-evidence.d.ts +44 -0
  35. package/dist/search/literal-evidence.d.ts.map +1 -0
  36. package/dist/search/literal-evidence.js +278 -0
  37. package/dist/search/literal-evidence.js.map +1 -0
  38. package/dist/search/query-plan-provider.d.ts +6 -0
  39. package/dist/search/query-plan-provider.d.ts.map +1 -0
  40. package/dist/search/query-plan-provider.js +289 -0
  41. package/dist/search/query-plan-provider.js.map +1 -0
  42. package/dist/search/query-plan.d.ts +98 -0
  43. package/dist/search/query-plan.d.ts.map +1 -0
  44. package/dist/search/query-plan.js +334 -0
  45. package/dist/search/query-plan.js.map +1 -0
  46. package/dist/search/query-utils.d.ts +6 -1
  47. package/dist/search/query-utils.d.ts.map +1 -1
  48. package/dist/search/query-utils.js +64 -5
  49. package/dist/search/query-utils.js.map +1 -1
  50. package/dist/types.d.ts +17 -0
  51. package/dist/types.d.ts.map +1 -1
  52. 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',
@@ -1277,10 +1305,15 @@ function reconcilePartialAnswerNow(text) {
1277
1305
  if (!/\*\*Partial locator\*\*/i.test(text))
1278
1306
  return text;
1279
1307
  let changed = false;
1308
+ let fenced = false;
1280
1309
  const out = text
1281
1310
  .split('\n')
1282
1311
  .map((line) => {
1283
- 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))
1284
1317
  return line;
1285
1318
  const stripped = stripPositiveAnswerNowFromLine(line);
1286
1319
  if (stripped.changed)
@@ -1291,7 +1324,7 @@ function reconcilePartialAnswerNow(text) {
1291
1324
  .join('\n');
1292
1325
  if (!changed)
1293
1326
  return text;
1294
- 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.`;
1295
1328
  }
1296
1329
  class ToolHandler {
1297
1330
  cg;
@@ -1482,7 +1515,9 @@ class ToolHandler {
1482
1515
  'homegraph_diff_impact',
1483
1516
  'homegraph_project',
1484
1517
  ]);
1485
- 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) {
1486
1521
  visible = visible.filter(t => TINY_REPO_CORE_TOOLS.has(t.name));
1487
1522
  }
1488
1523
  return visible.map(tool => {
@@ -1876,6 +1911,10 @@ class ToolHandler {
1876
1911
  * it (the CLI does) and explore behaves exactly as before, untracked.
1877
1912
  */
1878
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];
1879
1918
  try {
1880
1919
  // Block the first tool call on the engine's post-open reconcile so we
1881
1920
  // never serve rows for files deleted/edited while no MCP server was
@@ -1916,13 +1955,37 @@ class ToolHandler {
1916
1955
  return check;
1917
1956
  }
1918
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
+ }
1919
1982
  // Wrong question shapes → short Skip (before cache / graph work).
1920
1983
  if (toolName === 'homegraph_explore' || toolName === 'homegraph_search') {
1921
1984
  const qEarly = typeof args.query === 'string' ? args.query : '';
1922
1985
  if (qEarly) {
1923
1986
  const deferKind = (0, query_utils_1.queryShouldDeferToBuiltinTools)(qEarly);
1924
- if (deferKind) {
1925
- 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);
1926
1989
  }
1927
1990
  }
1928
1991
  }
@@ -1939,7 +2002,9 @@ class ToolHandler {
1939
2002
  // Session-tracked explore must not hit the MCP query cache: a cache hit
1940
2003
  // would re-serve the first call's full source and defeat CG-18 dedup.
1941
2004
  const skipCacheForSession = toolName === 'homegraph_explore' && !!sessionState;
1942
- 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);
1943
2008
  let cacheKey;
1944
2009
  let cacheQueries;
1945
2010
  let cacheIndex;
@@ -1959,7 +2024,8 @@ class ToolHandler {
1959
2024
  cacheKey = (0, query_cache_1.buildMcpQueryCacheKey)(toolName, args, fileCount);
1960
2025
  const cached = cacheIndex.getEntry(cacheQueries, cacheKey);
1961
2026
  if (cached) {
1962
- const withWorktree = this.withWorktreeNotice(cached, projectPath);
2027
+ const diagnosed = this.withQueryPlanDiagnostics(cached, args, true);
2028
+ const withWorktree = this.withWorktreeNotice(diagnosed, projectPath);
1963
2029
  return this.withStalenessNotice(withWorktree, projectPath);
1964
2030
  }
1965
2031
  }
@@ -1993,22 +2059,25 @@ class ToolHandler {
1993
2059
  // main connection. Serving them here — before the query-pool offload —
1994
2060
  // avoids cold-worker / wedged-daemon paths that otherwise surface as empty
1995
2061
  // MCP client `-32001` (the handler itself is fine; the transport times out).
1996
- 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))) {
1997
2065
  const q = typeof args.query === 'string' ? args.query : '';
1998
2066
  if (q) {
1999
2067
  try {
2000
2068
  const cgFast = this.getHomeGraph(projectPath);
2001
2069
  const rootFast = cgFast.getProjectRoot();
2002
- const fast = (toolName === 'homegraph_explore'
2003
- ? this.trySpecializedExploreRoute(cgFast, q, rootFast)
2004
- : null)
2005
- ?? (toolName === 'homegraph_explore' || toolName === 'homegraph_search'
2006
- ? this.tryFastInventoryExplore(cgFast, q, rootFast)
2070
+ const fast = requestPlan ? this.tryPlannedFastPath(cgFast, requestPlan, rootFast) :
2071
+ (toolName === 'homegraph_explore'
2072
+ ? this.trySpecializedExploreRoute(cgFast, q, rootFast)
2007
2073
  : null)
2008
- ?? (toolName === 'homegraph_explore' || toolName === 'homegraph_search'
2009
- ? this.tryLightMechanismExplore(cgFast, q, rootFast)
2010
- : null)
2011
- ?? 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);
2012
2081
  if (fast) {
2013
2082
  // Fast explore must still file Partial/ANSWER meta into the session
2014
2083
  // so explore + depth fuses see it (textResult-only paths used to skip).
@@ -2016,12 +2085,15 @@ class ToolHandler {
2016
2085
  if (toolName === 'homegraph_explore' && sessionState) {
2017
2086
  served = this.takeExploreEmission(this.ensureExploreEmission(fast, rootFast, q), sessionState);
2018
2087
  }
2088
+ served = this.withQueryPlanDiagnostics(served, args);
2019
2089
  if (cacheEnabled && cacheKey && cacheQueries && cacheIndex && !served.isError) {
2020
2090
  cacheIndex.setEntry(cacheQueries, cacheKey, toolName, served);
2021
2091
  }
2022
2092
  const withWorktree = this.withWorktreeNotice(served, projectPath);
2023
2093
  return this.withStalenessNotice(withWorktree, projectPath);
2024
2094
  }
2095
+ if (requestPlan)
2096
+ args[QUERY_FAST_ATTEMPTED_ARG] = true;
2025
2097
  }
2026
2098
  catch {
2027
2099
  // Not indexed / path issue — fall through to normal dispatch.
@@ -2037,7 +2109,7 @@ class ToolHandler {
2037
2109
  // (a frozen main loop prevents setTimeout deadlines from firing → empty
2038
2110
  // `-32001`). Fast-path surveys run inside the worker via executeReadTool.
2039
2111
  const raw = await this.runReadToolWithDeadline(toolName, dispatchArgs);
2040
- const result = this.takeExploreEmission(raw, sessionState);
2112
+ const result = this.withQueryPlanDiagnostics(this.takeExploreEmission(raw, sessionState), args);
2041
2113
  if (sessionState
2042
2114
  && !result.isError
2043
2115
  && (toolName === 'homegraph_node'
@@ -2113,16 +2185,67 @@ class ToolHandler {
2113
2185
  return null;
2114
2186
  }
2115
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
+ }
2116
2241
  /** Count a successful depth-tool call when the latest explore was Partial. */
2117
2242
  noteDepthToolUse(args, sessionState) {
2118
2243
  try {
2119
2244
  const cg = this.getHomeGraph(args.projectPath);
2120
2245
  const root = cg.getProjectRoot();
2121
2246
  const prior = sessionState.forProject(root);
2122
- const last = prior?.calls.length
2123
- ? [...prior.calls].reverse().find((c) => (c.responseBytes || 0) >= 400)
2124
- : undefined;
2125
- if (!last?.partial)
2247
+ const last = prior?.calls[prior.calls.length - 1];
2248
+ if (!last || (0, explore_session_state_1.inferExploreEvidenceStatus)(last) === 'complete')
2126
2249
  return;
2127
2250
  sessionState.recordDepthTool(root);
2128
2251
  }
@@ -2142,17 +2265,22 @@ class ToolHandler {
2142
2265
  const emission = result?.[explore_session_state_1.EXPLORE_EMISSION_KEY];
2143
2266
  if (emission === undefined)
2144
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 } };
2145
2273
  delete result[explore_session_state_1.EXPLORE_EMISSION_KEY];
2146
2274
  if (sessionState) {
2147
2275
  try {
2148
2276
  const prior = sessionState.forProject(emission.projectRoot);
2149
- 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');
2150
2278
  const metaPartial = emission.partial === true
2151
2279
  || (emission.partial === undefined
2152
2280
  && (0, explore_repeat_guard_1.inferExplorePartialMeta)(result.content?.[0]?.text ?? '').partial);
2153
2281
  if (metaPartial && priorPartials.length >= 1) {
2154
2282
  const text = result.content?.[0]?.text ?? '';
2155
- if (text && !/Second Partial — stop HomeGraph/i.test(text)) {
2283
+ if (text && !/Second Partial/i.test(text)) {
2156
2284
  const stop = (0, explore_repeat_guard_1.formatSecondPartialStopFooter)(emission.nextAnchor);
2157
2285
  result = {
2158
2286
  ...result,
@@ -2185,13 +2313,19 @@ class ToolHandler {
2185
2313
  * the success-shaped reply never flushed → empty client `-32001`.
2186
2314
  */
2187
2315
  async runReadToolWithDeadline(toolName, args) {
2188
- 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);
2189
2321
  const light = toolName === 'homegraph_search'
2190
2322
  || toolName === 'homegraph_node'
2191
2323
  || toolName === 'homegraph_callers'
2192
2324
  || toolName === 'homegraph_callees'
2193
2325
  || toolName === 'homegraph_files'
2194
- || toolName === 'homegraph_project';
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);
2195
2329
  const work = () => {
2196
2330
  if (!light && this.queryPool && this.queryPool.healthy) {
2197
2331
  return this.queryPool.run(toolName, args, {
@@ -2246,6 +2380,14 @@ class ToolHandler {
2246
2380
  */
2247
2381
  async executeReadTool(toolName, args) {
2248
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);
2249
2391
  // Compact inventory / one-symbol surveys — safe on the worker (keeps the
2250
2392
  // daemon main loop free). Never run these unprotected on the MCP transport
2251
2393
  // thread: they can block long enough for the client to emit empty `-32001`.
@@ -5131,9 +5273,261 @@ class ToolHandler {
5131
5273
  lines.push('');
5132
5274
  return { section: lines.join('\n'), hitCount: locs.length + edges.length + extendsHits.length };
5133
5275
  }
5134
- /**
5135
- * Main-thread fast path for inventory surveys — skips the worker queue.
5136
- */
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. */
5137
5531
  tryFastPathResult(toolName, args) {
5138
5532
  const query = args.query;
5139
5533
  if (typeof query !== 'string')
@@ -5181,11 +5575,13 @@ class ToolHandler {
5181
5575
  : null;
5182
5576
  }
5183
5577
  /** Run exactly one bounded survey family and expose the selected route. */
5184
- runSpecializedExploreRoute(route, cg, query, projectRoot) {
5578
+ runSpecializedExploreRoute(route, cg, query, projectRoot, plan) {
5185
5579
  let section = '';
5186
5580
  let status = 'no_indexed_evidence';
5187
5581
  let coverage = '';
5188
5582
  if (route === 'modules') {
5583
+ if (plan?.relation === 'module_imports')
5584
+ return this.renderModuleImports(cg, plan);
5189
5585
  const manifestResult = this.buildFocusedModuleManifestSection(projectRoot, query);
5190
5586
  const graphResult = manifestResult.manifestCount === 0
5191
5587
  ? this.buildModuleDependencySurveySection(cg, query)
@@ -5203,10 +5599,10 @@ class ToolHandler {
5203
5599
  coverage = 'named path/Type NAPI export registrations only; no domain file dump was built';
5204
5600
  }
5205
5601
  else {
5206
- const apiUsage = (0, query_utils_1.shouldBuildApiUsageSurvey)(query)
5602
+ const apiUsage = planFeature(plan, 'shouldBuildApiUsageSurvey', query, query_utils_1.shouldBuildApiUsageSurvey)
5207
5603
  ? this.buildApiUsageSection(cg, query, projectRoot)
5208
5604
  : { section: '', fileCount: 0 };
5209
- const memberUsage = (0, query_utils_1.shouldBuildMemberSurvey)(query)
5605
+ const memberUsage = planFeature(plan, 'shouldBuildMemberSurvey', query, query_utils_1.shouldBuildMemberSurvey)
5210
5606
  && !((0, query_utils_1.queryAsFieldUsageSurvey)(query) && apiUsage.fileCount > 0)
5211
5607
  ? this.buildMemberSurveySection(cg, query, projectRoot)
5212
5608
  : '';
@@ -5224,13 +5620,43 @@ class ToolHandler {
5224
5620
  || (status === 'not_surveyed'
5225
5621
  ? '- No survey ran: this query carries no symbol name to scan. Re-run naming the symbol(s), or use `homegraph_explore`.'
5226
5622
  : '- No matching evidence was found in the current index for this focused survey.');
5227
- return this.textResult(this.truncateOutput([
5623
+ const text = this.truncateOutput([
5228
5624
  `**HomeGraph specialized route: ${route}**`,
5229
5625
  `Status: ${status}`,
5230
5626
  `Coverage: ${coverage}.`,
5231
5627
  '',
5232
5628
  body,
5233
- ].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 });
5234
5660
  }
5235
5661
  /** Usage sites for a bare symbol bag, including non-call textual references. */
5236
5662
  buildBareSymbolUsageSection(cg, query, projectRoot) {
@@ -5392,8 +5818,8 @@ class ToolHandler {
5392
5818
  /**
5393
5819
  * Fast inventory-only explore — skips findRelevantContext for survey/caller/dependency queries.
5394
5820
  */
5395
- tryFastInventoryExplore(cg, query, projectRoot) {
5396
- if (!(0, query_utils_1.shouldTryFastInventoryExplore)(query))
5821
+ tryFastInventoryExplore(cg, query, projectRoot, plan) {
5822
+ if (!planFeature(plan, 'shouldTryFastInventoryExplore', query, query_utils_1.shouldTryFastInventoryExplore))
5397
5823
  return null;
5398
5824
  // Multi-Type dependency asks: inventory-only early exit (avoids fat compact / busy timeout).
5399
5825
  if ((0, query_utils_1.queryAsMultiTypeDependencySurvey)(query)) {
@@ -5797,8 +6223,8 @@ class ToolHandler {
5797
6223
  * findRelevantContext. Fast enough for MCP budget; complete enough to avoid
5798
6224
  * agent grep/read loops (token savings).
5799
6225
  */
5800
- tryLightMechanismExplore(cg, query, projectRoot) {
5801
- if (!(0, query_utils_1.shouldTryLightMechanismExplore)(query))
6226
+ tryLightMechanismExplore(cg, query, projectRoot, plan) {
6227
+ if (!planFeature(plan, 'shouldTryLightMechanismExplore', query, query_utils_1.shouldTryLightMechanismExplore))
5802
6228
  return null;
5803
6229
  const STRUCTURE_KINDS = new Set(['class', 'struct', 'interface', 'component', 'method', 'function']);
5804
6230
  const isTestPath = (p) => /(^|\/)(tests?|spec)\//i.test(p) || /\.(test|spec)\./i.test(p);
@@ -6435,7 +6861,7 @@ class ToolHandler {
6435
6861
  * Compact explore for local-symbol behavior questions — skips findRelevantContext
6436
6862
  * and caps to 1–2 defining files (avoids the ~24K related-file dump).
6437
6863
  */
6438
- tryCompactLocalSymbolExplore(cg, query, projectRoot) {
6864
+ tryCompactLocalSymbolExplore(cg, query, projectRoot, plan) {
6439
6865
  // Inventory runs *before* this on the call sites. Do not refuse compact
6440
6866
  // merely because inventory *intent* matched — empty inventory must fall
6441
6867
  // through here (bare callbacks like OnSurfaceChangedCB).
@@ -6449,8 +6875,13 @@ class ToolHandler {
6449
6875
  // Light-mechanism owns domain howtos. Bare "how/如何" NL must NOT veto compact
6450
6876
  // when a local/flag/lifecycle shape already owns the query (flag impact was
6451
6877
  // falling through to a 20k Dynamic-dispatch dump).
6452
- if ((0, query_utils_1.shouldTryLightMechanismExplore)(query))
6878
+ if (planFeature(plan, 'shouldTryLightMechanismExplore', query, query_utils_1.shouldTryLightMechanismExplore))
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))) {
6453
6883
  return null;
6884
+ }
6454
6885
  if ((0, query_utils_1.queryAsMechanismSurvey)(query)
6455
6886
  && !(0, query_utils_1.queryAsLocalSymbolDetail)(query)
6456
6887
  && !(0, query_utils_1.queryAsAssignedFlagImpactSurvey)(query)
@@ -9071,8 +9502,10 @@ class ToolHandler {
9071
9502
  // One normalization point so the flow-builder, relevance search, and
9072
9503
  // ranking all see the same canonical spelling (Erlang `mod:fn/arity`).
9073
9504
  const query = normalizeQuerySpelling(rawQuery);
9505
+ const plan = readQueryPlan(args);
9506
+ const feature = (name, fallback) => planFeature(plan, name, query, fallback);
9074
9507
  const deferKind = (0, query_utils_1.queryShouldDeferToBuiltinTools)(query);
9075
- if (deferKind) {
9508
+ if (deferKind && plan?.source !== 'llm') {
9076
9509
  return this.textResult((0, query_utils_1.homegraphDeferGuidance)(deferKind, query));
9077
9510
  }
9078
9511
  const cg = this.getHomeGraph(args.projectPath);
@@ -9080,7 +9513,7 @@ class ToolHandler {
9080
9513
  // Same-bag / call-budget refuse (session view injected by execute).
9081
9514
  const sessionPrior = (0, explore_session_state_1.viewForProject)((0, explore_session_state_1.readExploreSessionView)(args), projectRoot);
9082
9515
  const repeat = (0, explore_repeat_guard_1.decideExploreRepeat)(sessionPrior, query);
9083
- if (repeat.refuse) {
9516
+ if (!plan && this.shouldRefuseRepeatedEvidence(repeat, projectRoot)) {
9084
9517
  const text = (0, explore_repeat_guard_1.formatExploreRepeatRefuse)(repeat, query);
9085
9518
  return this.exploreResult(text, {
9086
9519
  projectRoot,
@@ -9090,7 +9523,8 @@ class ToolHandler {
9090
9523
  responseBytes: text.length,
9091
9524
  });
9092
9525
  }
9093
- 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)
9094
9528
  ?? this.tryLightMechanismExplore(cg, query, projectRoot)
9095
9529
  ?? this.tryCompactLocalSymbolExplore(cg, query, projectRoot);
9096
9530
  if (compactLocal)
@@ -9110,19 +9544,53 @@ class ToolHandler {
9110
9544
  const explicitMaxFiles = typeof args.maxFiles === 'number' && !Number.isNaN(args.maxFiles);
9111
9545
  let maxFiles = (0, utils_1.clamp)(args.maxFiles || budget.defaultMaxFiles, 1, 20);
9112
9546
  const queryFileBasenames = (0, query_utils_1.extractFileBasenamesFromQuery)(query);
9113
- const interpretationQuery = (0, query_utils_1.queryAsInterpretationSurvey)(query);
9114
- const testOnlyInterpretation = (0, query_utils_1.queryAsTestOnlyInterpretation)(query);
9115
- 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';
9116
9550
  // Step 1: Find relevant context with generous parameters.
9117
9551
  const contextOpts = interpretationQuery && queryFileBasenames.length === 1
9118
9552
  ? { searchLimit: 6, traversalDepth: 2, maxNodes: 60, minScore: 0.25 }
9119
9553
  : { searchLimit: 8, traversalDepth: 3, maxNodes: 200, minScore: 0.2 };
9120
9554
  const contextQuery = interpretationQuery && queryFileBasenames.length === 1
9121
9555
  ? `${queryFileBasenames[0]} ${query}`
9122
- : query;
9123
- 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);
9124
9589
  if (subgraph.nodes.size === 0) {
9125
- 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' });
9126
9594
  }
9127
9595
  // Seed import nodes for @kit.* / *Kit names (and named symbols like taskpool).
9128
9596
  const importTerms = (0, query_utils_1.extractImportSearchTerms)(query);
@@ -9261,8 +9729,8 @@ class ToolHandler {
9261
9729
  namedParts.push(m[3]);
9262
9730
  }
9263
9731
  const tokens = [...new Set([
9264
- ...namedParts,
9265
- ...query.split(/[\s,()[\]]+/)
9732
+ ...(plan?.source === 'llm' ? plan.anchors : namedParts),
9733
+ ...(plan?.source === 'llm' ? plan.anchors.join(' ') : query).split(/[\s,()[\]]+/)
9266
9734
  .map((t) => t.replace(FILE_EXT, '').trim())
9267
9735
  .filter((t) => t.length >= 3 && /^[A-Za-z_$][\w$]*(?:(?:::|\.)[\w$]+)*$/.test(t)),
9268
9736
  ])].slice(0, 16);
@@ -9289,7 +9757,8 @@ class ToolHandler {
9289
9757
  const isQual = /[.\/]|::/.test(t);
9290
9758
  const raw = isQual ? this.findAllSymbols(cg, t).nodes : cg.getNodesByName(t);
9291
9759
  let cands = raw
9292
- .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)))
9293
9762
  .sort((a, b) => {
9294
9763
  // Prefer callables over types when both share a name, then body size.
9295
9764
  const ac = CALLABLE.has(a.kind) ? 1 : 0;
@@ -9504,6 +9973,8 @@ class ToolHandler {
9504
9973
  }
9505
9974
  fileGroups.set(node.filePath, group);
9506
9975
  }
9976
+ for (const group of fileGroups.values())
9977
+ group.nodes = (0, evidence_rendering_1.canonicalSourceDeclarations)(group.nodes);
9507
9978
  if (testOnlyInterpretation) {
9508
9979
  for (const [, group] of fileGroups) {
9509
9980
  const fp = group.nodes[0]?.filePath ?? '';
@@ -9699,6 +10170,15 @@ class ToolHandler {
9699
10170
  const sortedFiles = relevantFiles.sort((a, b) => {
9700
10171
  const aPath = a[0].toLowerCase();
9701
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;
9702
10182
  // Query-named file (LocationController.ets in the question) before partial
9703
10183
  // substring matches (control.ets matching "Controller" inside LocationController).
9704
10184
  const aExactBase = (0, query_utils_1.fileMatchesQueryBasename)(a[0], queryFileBasenames) ? 1 : 0;
@@ -9758,6 +10238,8 @@ class ToolHandler {
9758
10238
  '',
9759
10239
  ];
9760
10240
  const summaryLineIdx = 2;
10241
+ if (literalSource.text)
10242
+ lines.push(literalSource.text);
9761
10243
  if (testOnlyInterpretation) {
9762
10244
  lines.push('> **Test-file scope only** — answer from the named `.test.ets` file below; ' +
9763
10245
  'production handlers are out of scope unless explicitly referenced in the test.');
@@ -9782,30 +10264,30 @@ class ToolHandler {
9782
10264
  : { section: '', symbolCount: 0 };
9783
10265
  if (kitUsageResult.section)
9784
10266
  lines.push(kitUsageResult.section);
9785
- const domainFileResult = (0, query_utils_1.shouldBuildDomainFileSurvey)(query)
10267
+ const domainFileResult = feature('shouldBuildDomainFileSurvey', query_utils_1.shouldBuildDomainFileSurvey)
9786
10268
  ? this.buildDomainFileSurveySection(cg, query)
9787
10269
  : { section: '', fileCount: 0 };
9788
10270
  if (domainFileResult.section)
9789
10271
  lines.push(domainFileResult.section);
9790
- const apiUsageResult = (0, query_utils_1.shouldBuildApiUsageSurvey)(query)
9791
- && !(0, query_utils_1.shouldBuildKitModuleUsageSurvey)(query)
10272
+ const apiUsageResult = feature('shouldBuildApiUsageSurvey', query_utils_1.shouldBuildApiUsageSurvey)
10273
+ && !feature('shouldBuildKitModuleUsageSurvey', query_utils_1.shouldBuildKitModuleUsageSurvey)
9792
10274
  ? this.buildApiUsageSection(cg, query, projectRoot)
9793
10275
  : { section: '', fileCount: 0 };
9794
10276
  if (apiUsageResult.section)
9795
10277
  lines.push(apiUsageResult.section);
9796
- const dataSourceResult = (0, query_utils_1.queryAsDataSourceSurvey)(query)
10278
+ const dataSourceResult = feature('queryAsDataSourceSurvey', query_utils_1.queryAsDataSourceSurvey)
9797
10279
  ? this.buildDataSourceSection(cg, query)
9798
10280
  : { section: '', edgeCount: 0, sdkImportCount: 0, strongCount: 0 };
9799
10281
  if (dataSourceResult.section)
9800
10282
  lines.push(dataSourceResult.section);
9801
- const eventDispatchResult = (0, query_utils_1.queryAsEventDispatchSurvey)(query)
10283
+ const eventDispatchResult = feature('queryAsEventDispatchSurvey', query_utils_1.queryAsEventDispatchSurvey)
9802
10284
  ? this.buildEventDispatchSection(cg, query, projectRoot)
9803
10285
  : { section: '', hitCount: 0, eventCount: 0, handlerCount: 0, memberCount: 0, complete: false };
9804
10286
  if (eventDispatchResult.section)
9805
10287
  lines.push(eventDispatchResult.section);
9806
10288
  const importInventoryFilter = (0, query_utils_1.hasImportInventoryFilter)(query);
9807
10289
  const multiAnchor = (0, query_utils_1.queryNamesMultipleExploreAnchors)(query) || crossModuleFlow;
9808
- const mechanismSurvey = (0, query_utils_1.queryAsMechanismSurvey)(query);
10290
+ const mechanismSurvey = feature('queryAsMechanismSurvey', query_utils_1.queryAsMechanismSurvey);
9809
10291
  // Flow path — computed before omit-source so graph connectivity drives the decision,
9810
10292
  // not question-text keyword matching. Mechanism/cross-module surveys augment the
9811
10293
  // query with seeded entry symbol names so buildFlowFromNamedSymbols can connect them.
@@ -10036,6 +10518,17 @@ class ToolHandler {
10036
10518
  const priorCalls = (0, explore_session_state_1.viewForProject)((0, explore_session_state_1.readExploreSessionView)(args), projectRoot);
10037
10519
  const dedupEnabled = (0, explore_dedup_1.exploreDedupEnabled)() && (priorCalls?.calls.length ?? 0) > 0;
10038
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
+ };
10039
10532
  const noteEmitted = (fp, ranges, bytes, fingerprint) => {
10040
10533
  const existing = emittedByFile.get(fp);
10041
10534
  if (existing) {
@@ -10207,6 +10700,7 @@ class ToolHandler {
10207
10700
  }
10208
10701
  if (body.length > 0) {
10209
10702
  lines.push('```' + lang, body, '```', '');
10703
+ noteFreshSource(filePath, ranges);
10210
10704
  totalChars += body.length + lang.length + 11;
10211
10705
  noteEmitted(filePath, [...ranges, ...opts.covered], body.length, fingerprint);
10212
10706
  renderedFilePaths.push(filePath);
@@ -10293,6 +10787,7 @@ class ToolHandler {
10293
10787
  // signature line (capped, with a "+N more" tail so the structure map of a
10294
10788
  // god-file doesn't itself bloat the budget).
10295
10789
  const skel = [];
10790
+ const freshSkelRanges = [];
10296
10791
  let coveredUntil = 0; // skip symbols already inside an emitted body
10297
10792
  let sigCount = 0, sigDropped = 0;
10298
10793
  const SIG_MAX = Math.max(12, budget.maxSymbolsInFileHeader * 2);
@@ -10303,6 +10798,7 @@ class ToolHandler {
10303
10798
  const end = n.endLine;
10304
10799
  const body = fileLines.slice(n.startLine - 1, end).join('\n');
10305
10800
  skel.push(exploreLineNumbersEnabled() ? numberSourceLines(body, n.startLine) : body);
10801
+ freshSkelRanges.push({ start: n.startLine, end });
10306
10802
  coveredUntil = end;
10307
10803
  }
10308
10804
  else {
@@ -10325,6 +10821,7 @@ class ToolHandler {
10325
10821
  if (sig) {
10326
10822
  skel.push(exploreLineNumbersEnabled() ? `${lineNo}\t${sig}` : sig);
10327
10823
  sigCount++;
10824
+ freshSkelRanges.push({ start: lineNo, end: lineNo });
10328
10825
  }
10329
10826
  }
10330
10827
  }
@@ -10362,6 +10859,7 @@ class ToolHandler {
10362
10859
  return n ? { start: n.startLine, end: n.endLine || n.startLine } : null;
10363
10860
  }).filter((r) => !!r);
10364
10861
  lines.push(skelHeader, '', '```' + lang, skelBody, '```', '');
10862
+ noteFreshSource(filePath, freshSkelRanges);
10365
10863
  totalChars += skelBody.length + 120;
10366
10864
  noteEmitted(filePath, bodyRanges.length > 0 ? bodyRanges : [wholeRange], skelBody.length, fingerprint);
10367
10865
  renderedFilePaths.push(filePath);
@@ -10394,7 +10892,15 @@ class ToolHandler {
10394
10892
  ? Math.min(Math.max(0, budget.maxOutputChars - totalChars - 200), Math.round(budget.maxCharsPerFile * 1.5))
10395
10893
  : budget.maxCharsPerFile * 3;
10396
10894
  if (fileLines.length <= WHOLE_FILE_MAX_LINES && fileContent.length <= WHOLE_FILE_MAX_CHARS) {
10397
- 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) };
10398
10904
  const dd = (0, explore_dedup_1.dedupeRange)(wholeRange, served);
10399
10905
  const uniqSymbols = [...new Set(group.nodes
10400
10906
  .filter(n => n.kind !== 'import' && n.kind !== 'export')
@@ -10855,6 +11361,7 @@ class ToolHandler {
10855
11361
  const output = flow.text + lines.join('\n');
10856
11362
  const hardCeiling = Math.min(Math.round(budget.maxOutputChars * 1.5), 25000);
10857
11363
  let finalText;
11364
+ let sourceCutoff = output.length;
10858
11365
  if (output.length > hardCeiling) {
10859
11366
  // Prefer dropping trailing notes ("Not shown above", completeness, budget)
10860
11367
  // over dropping a whole file's source — notes are recoverable, source isn't.
@@ -10869,8 +11376,10 @@ class ToolHandler {
10869
11376
  if (trimmed.length <= hardCeiling)
10870
11377
  break;
10871
11378
  const at = trimmed.lastIndexOf(marker);
10872
- if (at > hardCeiling * 0.4)
11379
+ if (at > hardCeiling * 0.4) {
10873
11380
  trimmed = trimmed.slice(0, at).replace(/\n+$/, '');
11381
+ sourceCutoff = Math.min(sourceCutoff, trimmed.length);
11382
+ }
10874
11383
  }
10875
11384
  if (trimmed.length > hardCeiling) {
10876
11385
  // Cut at a FILE-SECTION boundary so we drop whole trailing file-sections
@@ -10899,6 +11408,7 @@ class ToolHandler {
10899
11408
  boundary = lastSection > hardCeiling * 0.5 ? lastSection : cut.lastIndexOf('\n');
10900
11409
  }
10901
11410
  const safe = boundary > 0 ? cut.slice(0, boundary) : cut;
11411
+ sourceCutoff = Math.min(sourceCutoff, safe.length);
10902
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.)';
10903
11413
  }
10904
11414
  else {
@@ -10949,14 +11459,85 @@ class ToolHandler {
10949
11459
  });
10950
11460
  sourceBytes += emitted.bytes;
10951
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;
10952
11478
  return this.exploreResult(finalText, {
10953
11479
  projectRoot,
10954
11480
  query,
10955
11481
  files: emittedFiles,
10956
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',
10957
11486
  responseBytes: finalText.length,
11487
+ ...(locatedNodes ? { locatedNodes } : {}),
10958
11488
  });
10959
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
+ }
10960
11541
  /**
10961
11542
  * An explore response plus the record of what it emitted (CG-17). The record
10962
11543
  * rides the result only as far as {@link execute}, which files it into the
@@ -10967,7 +11548,8 @@ class ToolHandler {
10967
11548
  const result = this.textResult(text);
10968
11549
  result[explore_session_state_1.EXPLORE_EMISSION_KEY] = {
10969
11550
  ...emission,
10970
- 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),
10971
11553
  nextAnchor: emission.nextAnchor ?? meta.nextAnchor,
10972
11554
  };
10973
11555
  return result;
@@ -10993,7 +11575,8 @@ class ToolHandler {
10993
11575
  files: [],
10994
11576
  sourceBytes: 0,
10995
11577
  responseBytes: text.length,
10996
- 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',
10997
11580
  nextAnchor: meta.nextAnchor,
10998
11581
  };
10999
11582
  return result;
@@ -11028,7 +11611,7 @@ class ToolHandler {
11028
11611
  const symbol = this.validateString(args.symbol, 'symbol');
11029
11612
  if (typeof symbol !== 'string')
11030
11613
  return symbol;
11031
- let matches = this.findSymbolMatches(cg, symbol);
11614
+ let matches = (0, evidence_rendering_1.canonicalSourceDeclarations)(this.findSymbolMatches(cg, symbol));
11032
11615
  if (matches.length === 0) {
11033
11616
  return this.textResult(`Symbol "${symbol}" not found in the codebase`);
11034
11617
  }
@@ -12404,7 +12987,7 @@ class ToolHandler {
12404
12987
  return {
12405
12988
  // Single choke point for every tool's text, so no section builder can ship
12406
12989
  // a stop-searching directive on an output that declares itself partial.
12407
- content: [{ type: 'text', text: reconcilePartialAnswerNow(text) }],
12990
+ content: [{ type: 'text', text: (0, evidence_rendering_1.neutralRetrievalGuidance)(reconcilePartialAnswerNow(text)) }],
12408
12991
  };
12409
12992
  }
12410
12993
  /**