homegraph 1.1.3 → 1.2.0

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 (246) hide show
  1. package/README.md +43 -18
  2. package/dist/arkts/ohos-api-index.d.ts +15 -0
  3. package/dist/arkts/ohos-api-index.d.ts.map +1 -0
  4. package/dist/arkts/ohos-api-index.js +190 -0
  5. package/dist/arkts/ohos-api-index.js.map +1 -0
  6. package/dist/arkts/ohos-sdk-input.d.ts +36 -0
  7. package/dist/arkts/ohos-sdk-input.d.ts.map +1 -0
  8. package/dist/arkts/ohos-sdk-input.js +214 -0
  9. package/dist/arkts/ohos-sdk-input.js.map +1 -0
  10. package/dist/bin/command-supervision.d.ts +12 -0
  11. package/dist/bin/command-supervision.d.ts.map +1 -0
  12. package/dist/bin/command-supervision.js +86 -0
  13. package/dist/bin/command-supervision.js.map +1 -0
  14. package/dist/bin/homegraph.d.ts +1 -1
  15. package/dist/bin/homegraph.js +706 -168
  16. package/dist/bin/homegraph.js.map +1 -1
  17. package/dist/db/index.d.ts +20 -0
  18. package/dist/db/index.d.ts.map +1 -1
  19. package/dist/db/index.js +39 -0
  20. package/dist/db/index.js.map +1 -1
  21. package/dist/db/migrations.d.ts +1 -1
  22. package/dist/db/migrations.d.ts.map +1 -1
  23. package/dist/db/migrations.js +40 -1
  24. package/dist/db/migrations.js.map +1 -1
  25. package/dist/db/queries.d.ts +14 -0
  26. package/dist/db/queries.d.ts.map +1 -1
  27. package/dist/db/queries.js +174 -3
  28. package/dist/db/queries.js.map +1 -1
  29. package/dist/db/schema.sql +20 -0
  30. package/dist/directory.d.ts +32 -0
  31. package/dist/directory.d.ts.map +1 -1
  32. package/dist/directory.js +83 -0
  33. package/dist/directory.js.map +1 -1
  34. package/dist/extraction/arkts-batch-worker.d.ts +2 -0
  35. package/dist/extraction/arkts-batch-worker.d.ts.map +1 -0
  36. package/dist/extraction/arkts-batch-worker.js +28 -0
  37. package/dist/extraction/arkts-batch-worker.js.map +1 -0
  38. package/dist/extraction/context.d.ts +11 -0
  39. package/dist/extraction/context.d.ts.map +1 -1
  40. package/dist/extraction/context.js +20 -0
  41. package/dist/extraction/context.js.map +1 -1
  42. package/dist/extraction/index.d.ts +16 -2
  43. package/dist/extraction/index.d.ts.map +1 -1
  44. package/dist/extraction/index.js +619 -380
  45. package/dist/extraction/index.js.map +1 -1
  46. package/dist/extraction/languages/arkts-state-decorators.d.ts +13 -0
  47. package/dist/extraction/languages/arkts-state-decorators.d.ts.map +1 -0
  48. package/dist/extraction/languages/arkts-state-decorators.js +26 -0
  49. package/dist/extraction/languages/arkts-state-decorators.js.map +1 -0
  50. package/dist/extraction/languages/arkts-viewtree.d.ts +4 -2
  51. package/dist/extraction/languages/arkts-viewtree.d.ts.map +1 -1
  52. package/dist/extraction/languages/arkts-viewtree.js +21 -6
  53. package/dist/extraction/languages/arkts-viewtree.js.map +1 -1
  54. package/dist/extraction/languages/arkts.d.ts +98 -2
  55. package/dist/extraction/languages/arkts.d.ts.map +1 -1
  56. package/dist/extraction/languages/arkts.js +1290 -61
  57. package/dist/extraction/languages/arkts.js.map +1 -1
  58. package/dist/extraction/languages/c-cpp.d.ts +56 -0
  59. package/dist/extraction/languages/c-cpp.d.ts.map +1 -1
  60. package/dist/extraction/languages/c-cpp.js +198 -1
  61. package/dist/extraction/languages/c-cpp.js.map +1 -1
  62. package/dist/extraction/languages/ohos-api-consumer.d.ts +34 -0
  63. package/dist/extraction/languages/ohos-api-consumer.d.ts.map +1 -0
  64. package/dist/extraction/languages/ohos-api-consumer.js +283 -0
  65. package/dist/extraction/languages/ohos-api-consumer.js.map +1 -0
  66. package/dist/extraction/parse-pool.d.ts +126 -0
  67. package/dist/extraction/parse-pool.d.ts.map +1 -0
  68. package/dist/extraction/parse-pool.js +319 -0
  69. package/dist/extraction/parse-pool.js.map +1 -0
  70. package/dist/extraction/tree-sitter-types.d.ts +17 -0
  71. package/dist/extraction/tree-sitter-types.d.ts.map +1 -1
  72. package/dist/extraction/tree-sitter.d.ts +21 -0
  73. package/dist/extraction/tree-sitter.d.ts.map +1 -1
  74. package/dist/extraction/tree-sitter.js +198 -27
  75. package/dist/extraction/tree-sitter.js.map +1 -1
  76. package/dist/graph/traversal.d.ts.map +1 -1
  77. package/dist/graph/traversal.js +76 -17
  78. package/dist/graph/traversal.js.map +1 -1
  79. package/dist/index.d.ts +25 -0
  80. package/dist/index.d.ts.map +1 -1
  81. package/dist/index.js +78 -4
  82. package/dist/index.js.map +1 -1
  83. package/dist/mcp/daemon-paths.d.ts +30 -3
  84. package/dist/mcp/daemon-paths.d.ts.map +1 -1
  85. package/dist/mcp/daemon-paths.js +50 -10
  86. package/dist/mcp/daemon-paths.js.map +1 -1
  87. package/dist/mcp/daemon-registry.d.ts.map +1 -1
  88. package/dist/mcp/daemon-registry.js +7 -3
  89. package/dist/mcp/daemon-registry.js.map +1 -1
  90. package/dist/mcp/daemon.d.ts +48 -0
  91. package/dist/mcp/daemon.d.ts.map +1 -1
  92. package/dist/mcp/daemon.js +196 -32
  93. package/dist/mcp/daemon.js.map +1 -1
  94. package/dist/mcp/engine.d.ts +17 -0
  95. package/dist/mcp/engine.d.ts.map +1 -1
  96. package/dist/mcp/engine.js +73 -1
  97. package/dist/mcp/engine.js.map +1 -1
  98. package/dist/mcp/index.d.ts.map +1 -1
  99. package/dist/mcp/index.js +25 -43
  100. package/dist/mcp/index.js.map +1 -1
  101. package/dist/mcp/ppid-watchdog.d.ts +18 -0
  102. package/dist/mcp/ppid-watchdog.d.ts.map +1 -1
  103. package/dist/mcp/ppid-watchdog.js +37 -0
  104. package/dist/mcp/ppid-watchdog.js.map +1 -1
  105. package/dist/mcp/query-cache.d.ts +25 -0
  106. package/dist/mcp/query-cache.d.ts.map +1 -0
  107. package/dist/mcp/query-cache.js +191 -0
  108. package/dist/mcp/query-cache.js.map +1 -0
  109. package/dist/mcp/query-pool.d.ts +94 -0
  110. package/dist/mcp/query-pool.d.ts.map +1 -0
  111. package/dist/mcp/query-pool.js +297 -0
  112. package/dist/mcp/query-pool.js.map +1 -0
  113. package/dist/mcp/query-worker.d.ts +24 -0
  114. package/dist/mcp/query-worker.d.ts.map +1 -0
  115. package/dist/mcp/query-worker.js +87 -0
  116. package/dist/mcp/query-worker.js.map +1 -0
  117. package/dist/mcp/server-instructions.d.ts +5 -7
  118. package/dist/mcp/server-instructions.d.ts.map +1 -1
  119. package/dist/mcp/server-instructions.js +12 -14
  120. package/dist/mcp/server-instructions.js.map +1 -1
  121. package/dist/mcp/tools.d.ts +77 -0
  122. package/dist/mcp/tools.d.ts.map +1 -1
  123. package/dist/mcp/tools.js +696 -60
  124. package/dist/mcp/tools.js.map +1 -1
  125. package/dist/project-config.d.ts +20 -0
  126. package/dist/project-config.d.ts.map +1 -1
  127. package/dist/project-config.js +42 -2
  128. package/dist/project-config.js.map +1 -1
  129. package/dist/reasoning/login.js +1 -1
  130. package/dist/reasoning/login.js.map +1 -1
  131. package/dist/resolution/c-fnptr-synthesizer.d.ts +0 -28
  132. package/dist/resolution/c-fnptr-synthesizer.d.ts.map +1 -1
  133. package/dist/resolution/c-fnptr-synthesizer.js +765 -79
  134. package/dist/resolution/c-fnptr-synthesizer.js.map +1 -1
  135. package/dist/resolution/callback-synthesizer.d.ts +1 -1
  136. package/dist/resolution/callback-synthesizer.d.ts.map +1 -1
  137. package/dist/resolution/callback-synthesizer.js +72 -11
  138. package/dist/resolution/callback-synthesizer.js.map +1 -1
  139. package/dist/resolution/cooperative-yield.d.ts +32 -0
  140. package/dist/resolution/cooperative-yield.d.ts.map +1 -0
  141. package/dist/resolution/cooperative-yield.js +42 -0
  142. package/dist/resolution/cooperative-yield.js.map +1 -0
  143. package/dist/resolution/index.d.ts +11 -2
  144. package/dist/resolution/index.d.ts.map +1 -1
  145. package/dist/resolution/index.js +72 -4
  146. package/dist/resolution/index.js.map +1 -1
  147. package/dist/resolution/name-matcher.d.ts +22 -0
  148. package/dist/resolution/name-matcher.d.ts.map +1 -1
  149. package/dist/resolution/name-matcher.js +317 -20
  150. package/dist/resolution/name-matcher.js.map +1 -1
  151. package/dist/spec/config.d.ts +7 -11
  152. package/dist/spec/config.d.ts.map +1 -1
  153. package/dist/spec/config.js +77 -48
  154. package/dist/spec/config.js.map +1 -1
  155. package/dist/spec/db/fragment-node.d.ts +1 -0
  156. package/dist/spec/db/fragment-node.d.ts.map +1 -1
  157. package/dist/spec/db/fragment-node.js +8 -0
  158. package/dist/spec/db/fragment-node.js.map +1 -1
  159. package/dist/spec/db/fts.d.ts +14 -0
  160. package/dist/spec/db/fts.d.ts.map +1 -1
  161. package/dist/spec/db/fts.js +43 -4
  162. package/dist/spec/db/fts.js.map +1 -1
  163. package/dist/spec/db/schema.d.ts +6 -3
  164. package/dist/spec/db/schema.d.ts.map +1 -1
  165. package/dist/spec/db/schema.js +36 -4
  166. package/dist/spec/db/schema.js.map +1 -1
  167. package/dist/spec/db/schema.sql +11 -1
  168. package/dist/spec/evolve/logic-checker.d.ts +2 -2
  169. package/dist/spec/evolve/logic-checker.d.ts.map +1 -1
  170. package/dist/spec/evolve/logic-checker.js +3 -27
  171. package/dist/spec/evolve/logic-checker.js.map +1 -1
  172. package/dist/spec/evolve/pipeline.d.ts +21 -19
  173. package/dist/spec/evolve/pipeline.d.ts.map +1 -1
  174. package/dist/spec/evolve/pipeline.js +284 -31
  175. package/dist/spec/evolve/pipeline.js.map +1 -1
  176. package/dist/spec/evolve/spec-rewriter.d.ts +2 -2
  177. package/dist/spec/evolve/spec-rewriter.d.ts.map +1 -1
  178. package/dist/spec/evolve/spec-rewriter.js +7 -31
  179. package/dist/spec/evolve/spec-rewriter.js.map +1 -1
  180. package/dist/spec/graph/queries.d.ts +97 -0
  181. package/dist/spec/graph/queries.d.ts.map +1 -1
  182. package/dist/spec/graph/queries.js +225 -0
  183. package/dist/spec/graph/queries.js.map +1 -1
  184. package/dist/spec/llm/client.d.ts +29 -0
  185. package/dist/spec/llm/client.d.ts.map +1 -0
  186. package/dist/spec/llm/client.js +123 -0
  187. package/dist/spec/llm/client.js.map +1 -0
  188. package/dist/spec/llm/index.d.ts +3 -0
  189. package/dist/spec/llm/index.d.ts.map +1 -0
  190. package/dist/spec/llm/index.js +11 -0
  191. package/dist/spec/llm/index.js.map +1 -0
  192. package/dist/spec/llm/prompts.d.ts +13 -0
  193. package/dist/spec/llm/prompts.d.ts.map +1 -0
  194. package/dist/spec/llm/prompts.js +75 -0
  195. package/dist/spec/llm/prompts.js.map +1 -0
  196. package/dist/spec/mining/git-scanner.d.ts +12 -0
  197. package/dist/spec/mining/git-scanner.d.ts.map +1 -1
  198. package/dist/spec/mining/git-scanner.js +41 -0
  199. package/dist/spec/mining/git-scanner.js.map +1 -1
  200. package/dist/spec/mining/pipeline.d.ts.map +1 -1
  201. package/dist/spec/mining/pipeline.js +14 -1
  202. package/dist/spec/mining/pipeline.js.map +1 -1
  203. package/dist/spec/utils.d.ts +2 -1
  204. package/dist/spec/utils.d.ts.map +1 -1
  205. package/dist/spec/utils.js +3 -1
  206. package/dist/spec/utils.js.map +1 -1
  207. package/dist/sync/worktree.d.ts +9 -0
  208. package/dist/sync/worktree.d.ts.map +1 -1
  209. package/dist/sync/worktree.js +40 -0
  210. package/dist/sync/worktree.js.map +1 -1
  211. package/dist/types.d.ts +6 -1
  212. package/dist/types.d.ts.map +1 -1
  213. package/dist/ui/shimmer-progress.d.ts +2 -0
  214. package/dist/ui/shimmer-progress.d.ts.map +1 -1
  215. package/dist/ui/shimmer-progress.js +19 -2
  216. package/dist/ui/shimmer-progress.js.map +1 -1
  217. package/dist/upgrade/index.js +1 -1
  218. package/dist/upgrade/index.js.map +1 -1
  219. package/package.json +3 -3
  220. package/scripts/agent-eval/repro-concurrent-explore.mjs +119 -0
  221. package/scripts/agent-eval/repro-daemon-clients.mjs +125 -0
  222. package/scripts/exp_boundary_eval/README.md +247 -0
  223. package/scripts/exp_boundary_eval/__pycache__/_utils.cpython-310.pyc +0 -0
  224. package/scripts/exp_boundary_eval/__pycache__/analyze.cpython-310.pyc +0 -0
  225. package/scripts/exp_boundary_eval/__pycache__/deveco_arm.cpython-310.pyc +0 -0
  226. package/scripts/exp_boundary_eval/__pycache__/run_one.cpython-310.pyc +0 -0
  227. package/scripts/exp_boundary_eval/__pycache__/run_session.cpython-310.pyc +0 -0
  228. package/scripts/exp_boundary_eval/__pycache__/setup.cpython-310.pyc +0 -0
  229. package/scripts/exp_boundary_eval/_test_mcp_chain.py +78 -0
  230. package/scripts/exp_boundary_eval/_test_stdin.py +8 -0
  231. package/scripts/exp_boundary_eval/_utils.py +1116 -0
  232. package/scripts/exp_boundary_eval/analyze.py +1313 -0
  233. package/scripts/exp_boundary_eval/data/agents.json +109 -0
  234. package/scripts/exp_boundary_eval/data/experiments.json +140 -0
  235. package/scripts/exp_boundary_eval/deveco_arm.py +519 -0
  236. package/scripts/exp_boundary_eval/run_all.py +378 -0
  237. package/scripts/exp_boundary_eval/run_one.py +165 -0
  238. package/scripts/exp_boundary_eval/run_session.py +158 -0
  239. package/scripts/exp_boundary_eval/setup.py +120 -0
  240. package/scripts/exp_boundary_eval/win_mcp_launcher.py +73 -0
  241. package/scripts/exp_boundary_eval/win_mcp_stdio_wrap.js +36 -0
  242. package/scripts/exp_boundary_eval/win_node_launcher.py +24 -0
  243. package/scripts/npm-shim.js +25 -3
  244. package/scripts/ohos-sdk-publish.mjs +133 -0
  245. package/scripts/qa_eval/README.md +1 -1
  246. package/scripts/qa_eval/agent_runner.py +1 -1
package/dist/mcp/tools.js CHANGED
@@ -24,12 +24,15 @@ const query_utils_1 = require("../search/query-utils");
24
24
  const fs_1 = require("fs");
25
25
  const utils_1 = require("../utils");
26
26
  const generated_detection_1 = require("../extraction/generated-detection");
27
+ const arkts_1 = require("../extraction/languages/arkts");
27
28
  const dynamic_boundaries_1 = require("./dynamic-boundaries");
29
+ const query_cache_1 = require("./query-cache");
28
30
  /** ViewTree structural `references` vias — not UI event bindings. */
29
31
  const VIEWTREE_STRUCTURE_VIAS = new Set([
30
32
  'child-component',
31
33
  'state-binding',
32
- 'prop-transfer',
34
+ 'Prop',
35
+ 'Link',
33
36
  'builder',
34
37
  'builder-param',
35
38
  ]);
@@ -275,6 +278,29 @@ function numberSourceLines(slice, firstLineNumber) {
275
278
  }
276
279
  return out.join('\n');
277
280
  }
281
+ /** Primary signature line (first line when overloads are stored newline-separated). */
282
+ function primarySignatureLine(signature) {
283
+ return signature.split('\n')[0]?.trim() ?? signature.trim();
284
+ }
285
+ /** Render a stored signature (single line or newline-separated overloads) for MCP output. */
286
+ function formatNodeSignatureBlock(signature) {
287
+ const lines = signature.split('\n').map((l) => l.trim()).filter(Boolean);
288
+ if (lines.length <= 1) {
289
+ return lines.length ? [`**Signature:** \`${lines[0]}\``] : [];
290
+ }
291
+ return [
292
+ '**Signature:**',
293
+ `- \`${lines[0]}\` (primary)`,
294
+ ...lines.slice(1).map((l) => `- \`${l}\` (overload)`),
295
+ ];
296
+ }
297
+ function formatInlineSignature(signature) {
298
+ const primary = primarySignatureLine(signature);
299
+ const overloadCount = signature.split('\n').filter((l) => l.trim()).length - 1;
300
+ if (overloadCount <= 0)
301
+ return primary;
302
+ return `${primary} (+${overloadCount} overload${overloadCount === 1 ? '' : 's'})`;
303
+ }
278
304
  /**
279
305
  * Unique line-prefix for a per-file source section in homegraph_explore output.
280
306
  * Issue #778: tool results dropped ATX headings (`####`, `##`, `###`) for bold
@@ -286,6 +312,12 @@ function numberSourceLines(slice, firstLineNumber) {
286
312
  * (`reasoning/reasoner.ts`) both key off to cut on whole file sections.
287
313
  */
288
314
  const FILE_SECTION_PREFIX = '**`';
315
+ // Placeholder for codegraph_explore's "Found N symbols across M files." line.
316
+ // The honest N/M can only be known after the final truncation drops trailing
317
+ // sections (#1046), so the header is emitted as this sentinel and substituted
318
+ // at the very end. This bracketed token never occurs in rendered source or a
319
+ // file path, so the final string-replace can't collide.
320
+ const SUMMARY_SENTINEL = '[[codegraph-explore-summary]]';
289
321
  function fileSectionHeader(filePath, suffix) {
290
322
  return suffix
291
323
  ? `${FILE_SECTION_PREFIX}${filePath}\`** — ${suffix}`
@@ -348,6 +380,23 @@ const projectPathProperty = {
348
380
  type: 'string',
349
381
  description: 'Absolute path to the project to query (or any directory inside it) — homegraph uses the nearest .homegraph/ index at or above that path. Omit to use this session\'s default project. Pass it to query a second codebase, or when the server root has no index of its own (e.g. a monorepo where only sub-projects are indexed, so there is no default project).',
350
382
  };
383
+ /**
384
+ * EVERY homegraph tool is query-only: it reads the pre-built index and never
385
+ * mutates the workspace (indexing is the user's explicit CLI call, never the
386
+ * agent's). Advertising this read-only contract lets clients that gate on it run
387
+ * the tools where a possibly-mutating tool would be blocked — most concretely,
388
+ * Cursor's Ask mode, which rejects any MCP tool lacking `readOnlyHint: true`
389
+ * (issue #1018). `idempotentHint`: a repeated query has no additional effect.
390
+ * `openWorldHint: false`: the domain is the closed local index, not an open
391
+ * external world. Shared so the contract is declared once; a hypothetical
392
+ * mutating tool would simply not reference it.
393
+ */
394
+ const READ_ONLY_ANNOTATIONS = {
395
+ readOnlyHint: true,
396
+ destructiveHint: false,
397
+ idempotentHint: true,
398
+ openWorldHint: false,
399
+ };
351
400
  /**
352
401
  * All HomeGraph MCP tools
353
402
  *
@@ -382,6 +431,7 @@ exports.tools = [
382
431
  },
383
432
  required: ['query'],
384
433
  },
434
+ annotations: READ_ONLY_ANNOTATIONS,
385
435
  },
386
436
  {
387
437
  name: 'homegraph_callers',
@@ -406,6 +456,7 @@ exports.tools = [
406
456
  },
407
457
  required: ['symbol'],
408
458
  },
459
+ annotations: READ_ONLY_ANNOTATIONS,
409
460
  },
410
461
  {
411
462
  name: 'homegraph_callees',
@@ -430,6 +481,7 @@ exports.tools = [
430
481
  },
431
482
  required: ['symbol'],
432
483
  },
484
+ annotations: READ_ONLY_ANNOTATIONS,
433
485
  },
434
486
  {
435
487
  name: 'homegraph_impact',
@@ -454,6 +506,7 @@ exports.tools = [
454
506
  },
455
507
  required: ['symbol'],
456
508
  },
509
+ annotations: READ_ONLY_ANNOTATIONS,
457
510
  },
458
511
  {
459
512
  name: 'homegraph_node',
@@ -495,6 +548,7 @@ exports.tools = [
495
548
  },
496
549
  required: [],
497
550
  },
551
+ annotations: READ_ONLY_ANNOTATIONS,
498
552
  },
499
553
  {
500
554
  name: 'homegraph_explore',
@@ -515,6 +569,7 @@ exports.tools = [
515
569
  },
516
570
  required: ['query'],
517
571
  },
572
+ annotations: READ_ONLY_ANNOTATIONS,
518
573
  },
519
574
  {
520
575
  name: 'homegraph_status',
@@ -525,6 +580,7 @@ exports.tools = [
525
580
  projectPath: projectPathProperty,
526
581
  },
527
582
  },
583
+ annotations: READ_ONLY_ANNOTATIONS,
528
584
  },
529
585
  {
530
586
  name: 'homegraph_files',
@@ -558,6 +614,7 @@ exports.tools = [
558
614
  projectPath: projectPathProperty,
559
615
  },
560
616
  },
617
+ annotations: READ_ONLY_ANNOTATIONS,
561
618
  },
562
619
  {
563
620
  name: 'homegraph_spec_match',
@@ -592,8 +649,108 @@ exports.tools = [
592
649
  },
593
650
  required: ['query'],
594
651
  },
652
+ annotations: READ_ONLY_ANNOTATIONS,
653
+ },
654
+ {
655
+ name: 'homegraph_spec_find',
656
+ description: 'Find which specs are related to the given file path by matching against code-fragment file paths ' +
657
+ 'in the Commit4Spec knowledge graph. Traverses code_fragment_nodes → commit_fragment_relations ' +
658
+ '→ spec_commit_relations → spec_nodes. Useful for answering "which specs does this file affect?" ' +
659
+ 'The database defaults to .homegraph/commit4spec/commit4spec.db under the repo path.',
660
+ inputSchema: {
661
+ type: 'object',
662
+ properties: {
663
+ filePath: {
664
+ type: 'string',
665
+ description: 'File path to look up (substring LIKE match). E.g. "src/auth.ts" or "src/auth".',
666
+ },
667
+ repoPath: {
668
+ type: 'string',
669
+ description: 'Path to the repository root. Defaults to the current working directory.',
670
+ },
671
+ dbPath: {
672
+ type: 'string',
673
+ description: 'Explicit path to the Commit4Spec database. Overrides repoPath-based resolution.',
674
+ },
675
+ },
676
+ required: ['filePath'],
677
+ },
678
+ annotations: READ_ONLY_ANNOTATIONS,
679
+ },
680
+ {
681
+ name: 'homegraph_spec_trace',
682
+ description: 'Trace a code symbol (function, method, class) back to its associated design Specs in the Commit4Spec ' +
683
+ 'knowledge graph. Resolves the symbol via the HomeGraph code index, then matches against code-fragment ' +
684
+ 'records in the Spec database using five-dimensional scoring: file-path match, code-diff content search ' +
685
+ '(FTS5), Spec title/subtitle name match, Spec recency, and line-range overlap. ' +
686
+ 'Returns ranked Specs with score breakdowns even when exact line overlap is absent — code drifts over ' +
687
+ 'time, so recency and content matching compensate. ' +
688
+ 'The Spec DB defaults to .homegraph/commit4spec/commit4spec.db under the repo path.',
689
+ inputSchema: {
690
+ type: 'object',
691
+ properties: {
692
+ symbol: {
693
+ type: 'string',
694
+ description: 'Symbol name (bare or qualified). E.g. "authenticate", "AuthService.login", "auth::validate".',
695
+ },
696
+ file: {
697
+ type: 'string',
698
+ description: 'File path for disambiguation when multiple symbols share the same name (optional).',
699
+ },
700
+ line: {
701
+ type: 'number',
702
+ description: 'Line number for disambiguation (optional).',
703
+ },
704
+ repoPath: {
705
+ type: 'string',
706
+ description: 'Path to the repository root. Defaults to the current working directory.',
707
+ },
708
+ topK: {
709
+ type: 'number',
710
+ description: 'Maximum number of matching Specs to return (default: 10).',
711
+ default: 10,
712
+ },
713
+ dbPath: {
714
+ type: 'string',
715
+ description: 'Explicit path to the Commit4Spec database. Overrides repoPath-based resolution.',
716
+ },
717
+ },
718
+ required: ['symbol'],
719
+ },
720
+ annotations: READ_ONLY_ANNOTATIONS,
595
721
  },
596
722
  ];
723
+ /**
724
+ * Return `defs` with `projectPath` marked `required` in each tool's inputSchema.
725
+ *
726
+ * Used for the NO-DEFAULT-PROJECT tool surface (issue #993): when the MCP server
727
+ * has no default project to fall back to — a gateway server started outside any
728
+ * repo, or a monorepo root whose `.codegraph/` indexes live only in sub-projects
729
+ * — every call MUST carry an explicit `projectPath`, so the schema should say so.
730
+ * A `required` field is a HIGH-salience channel (MCP clients surface and often
731
+ * validate it), unlike the instructions text the reporter found too weak to stop
732
+ * the agent omitting the param. When a default project IS open, callers leave
733
+ * projectPath optional and never call this.
734
+ *
735
+ * Pure: clones each tool's schema rather than mutating the shared module-level
736
+ * `tools` array (reused by every session and the static surface). A tool that
737
+ * doesn't expose projectPath, or already requires it, is returned untouched;
738
+ * explore's `['query']` becomes `['query', 'projectPath']`, and a tool with no
739
+ * `required` list (status/files) gains `['projectPath']`.
740
+ */
741
+ function withRequiredProjectPath(defs) {
742
+ return defs.map((tool) => {
743
+ if (!tool.inputSchema.properties.projectPath)
744
+ return tool;
745
+ const required = tool.inputSchema.required ?? [];
746
+ if (required.includes('projectPath'))
747
+ return tool;
748
+ return {
749
+ ...tool,
750
+ inputSchema: { ...tool.inputSchema, required: [...required, 'projectPath'] },
751
+ };
752
+ });
753
+ }
597
754
  /**
598
755
  * Allowlist-filtered tool definitions WITHOUT an engine — the static surface the
599
756
  * proxy answers `tools/list` with before any project is open. Mirrors
@@ -637,9 +794,23 @@ class ToolHandler {
637
794
  // huge repo can't hang the first call (#905); cleared on first await so
638
795
  // subsequent calls don't pay any cost.
639
796
  catchUpGate = null;
797
+ // Optional worker-thread pool for off-loop read-tool dispatch (daemon mode).
798
+ // When set + healthy, the heavy read tools run on a worker so the daemon's
799
+ // main loop stays free for the MCP transport under concurrent load. Null in
800
+ // direct/in-process mode (one client, no concurrency to parallelize).
801
+ queryPool = null;
640
802
  constructor(cg) {
641
803
  this.cg = cg;
642
804
  }
805
+ /**
806
+ * Engine-only: attach (or detach with null) the worker-thread query pool. The
807
+ * shared daemon sets this once its default project is open; the workers each
808
+ * hold their own WAL read connection and run {@link executeReadTool}. A
809
+ * worker's own ToolHandler never has a pool, so there is no nested off-loading.
810
+ */
811
+ setQueryPool(pool) {
812
+ this.queryPool = pool;
813
+ }
643
814
  /**
644
815
  * Update the default HomeGraph instance (e.g. after lazy initialization)
645
816
  */
@@ -743,8 +914,18 @@ class ToolHandler {
743
914
  let visible = allow
744
915
  ? exports.tools.filter(t => allow.has(t.name.replace(/^homegraph_/, '')))
745
916
  : exports.tools;
917
+ // No default project loaded → no-root-index case (#993): a gateway server
918
+ // started outside any repo, or a monorepo root whose indexes live in
919
+ // sub-projects. With nothing to fall back to, EVERY call needs an explicit
920
+ // projectPath, so mark it required in the schema — a high-salience nudge the
921
+ // agent acts on, where SERVER_INSTRUCTIONS_NO_ROOT_INDEX's prose alone
922
+ // wasn't enough (the reporter had to add an AGENTS.md note). `this.cg` is
923
+ // settled by `retryInitIfNeeded()` before `handleToolsList` calls us, so a
924
+ // null here means "genuinely no default", not a startup race. When a default
925
+ // IS open we leave projectPath optional (below): a bare call falls back to
926
+ // it, exactly as in the common single-project launch.
746
927
  if (!this.cg)
747
- return visible;
928
+ return withRequiredProjectPath(visible);
748
929
  try {
749
930
  const stats = this.cg.getStats();
750
931
  const budget = getExploreBudget(stats.fileCount);
@@ -1143,48 +1324,58 @@ class ToolHandler {
1143
1324
  if (typeof check === 'object' && check !== undefined)
1144
1325
  return check;
1145
1326
  }
1146
- // Read tools resolve through a single result variable so cross-cutting
1147
- // notices worktree-index mismatch (issue #155) and per-file
1148
- // staleness (issue #403) — can be applied in one place. status embeds
1149
- // its own verbose worktree warning but still flows through the
1150
- // staleness wrapper so its pending-files section stays consistent
1151
- // with what the read tools surface.
1152
- let result;
1153
- switch (toolName) {
1154
- case 'homegraph_search':
1155
- result = await this.handleSearch(args);
1156
- break;
1157
- case 'homegraph_callers':
1158
- result = await this.handleCallers(args);
1159
- break;
1160
- case 'homegraph_callees':
1161
- result = await this.handleCallees(args);
1162
- break;
1163
- case 'homegraph_impact':
1164
- result = await this.handleImpact(args);
1165
- break;
1166
- case 'homegraph_explore':
1167
- result = await this.handleExplore(args);
1168
- break;
1169
- case 'homegraph_node':
1170
- result = await this.handleNode(args);
1171
- break;
1172
- case 'homegraph_status':
1173
- // status embeds the pending-files list as a first-class section
1174
- // (see handleStatus), so we skip the auto-banner wrapper here to
1175
- // avoid duplicating the same info at the top of the response.
1176
- return await this.handleStatus(args);
1177
- case 'homegraph_files':
1178
- result = await this.handleFiles(args);
1179
- break;
1180
- case 'homegraph_spec_match':
1181
- result = await this.handleSpecMatch(args);
1182
- break;
1183
- default:
1184
- return this.errorResult(`Unknown tool: ${toolName}`);
1327
+ const projectPath = args.projectPath;
1328
+ const cacheEnabled = (0, query_cache_1.isMcpQueryCacheEnabled)() && (0, query_cache_1.isCacheableMcpTool)(toolName);
1329
+ let cacheKey;
1330
+ let cacheQueries;
1331
+ if (cacheEnabled) {
1332
+ try {
1333
+ const cacheCg = this.getHomeGraph(projectPath);
1334
+ cacheQueries = cacheCg.getQueryBuilder();
1335
+ (0, query_cache_1.ensureMcpQueryCacheValid)(cacheQueries, () => cacheCg.getLastIndexedAt());
1336
+ let fileCount;
1337
+ try {
1338
+ fileCount = cacheCg.getStats().fileCount;
1339
+ }
1340
+ catch {
1341
+ fileCount = undefined;
1342
+ }
1343
+ cacheKey = (0, query_cache_1.buildMcpQueryCacheKey)(toolName, args, fileCount);
1344
+ const cached = (0, query_cache_1.getMcpQueryCacheEntry)(cacheQueries, cacheKey);
1345
+ if (cached) {
1346
+ const withWorktree = this.withWorktreeNotice(cached, projectPath);
1347
+ return this.withStalenessNotice(withWorktree, projectPath);
1348
+ }
1349
+ }
1350
+ catch {
1351
+ // No indexed project — fall through; handler returns guidance.
1352
+ }
1185
1353
  }
1186
- const withWorktree = this.withWorktreeNotice(result, args.projectPath);
1187
- return this.withStalenessNotice(withWorktree, args.projectPath);
1354
+ // homegraph_status reports watcher state (pending files, degraded mode,
1355
+ // worktree warning) and embeds its own sections — it must run on the MAIN
1356
+ // thread against the watched default instance, so it is NEVER off-loaded to
1357
+ // a worker (whose read connection has no watcher). It also skips the
1358
+ // auto-banner wrapper to avoid duplicating its own pending-files section.
1359
+ if (toolName === 'homegraph_status') {
1360
+ return await this.handleStatus(args);
1361
+ }
1362
+ // Read tools: off-load the CPU-heavy dispatch to the worker pool when one
1363
+ // is attached and healthy (daemon mode), so the daemon's single event loop
1364
+ // stays free for the MCP transport under concurrent load — otherwise N
1365
+ // concurrent explores serialize AND starve the transport until the whole
1366
+ // batch drains (clients then time out). With no pool (direct mode) or a
1367
+ // degraded one, dispatch runs in-process exactly as before. Either way the
1368
+ // result flows through the cross-cutting notices — worktree-index mismatch
1369
+ // (#155) and per-file staleness (#403) — which need the watched MAIN
1370
+ // instance and so are always applied here, never in the worker.
1371
+ const result = (this.queryPool && this.queryPool.healthy)
1372
+ ? await this.queryPool.run(toolName, args)
1373
+ : await this.executeReadTool(toolName, args);
1374
+ if (cacheEnabled && cacheKey && cacheQueries && !result.isError) {
1375
+ (0, query_cache_1.setMcpQueryCacheEntry)(cacheQueries, cacheKey, toolName, result);
1376
+ }
1377
+ const withWorktree = this.withWorktreeNotice(result, projectPath);
1378
+ return this.withStalenessNotice(withWorktree, projectPath);
1188
1379
  }
1189
1380
  catch (err) {
1190
1381
  // Expected condition, not a malfunction: answer as a SUCCESS so the
@@ -1202,6 +1393,56 @@ class ToolHandler {
1202
1393
  'continue without homegraph for this task.');
1203
1394
  }
1204
1395
  }
1396
+ /**
1397
+ * Run a single read tool to completion and return its raw {@link ToolResult},
1398
+ * classifying expected failures the same way {@link execute}'s catch does so
1399
+ * the SHAPE is identical whether dispatch runs in-process or on a worker:
1400
+ * NotIndexed → success-shaped guidance, PathRefusal → clean error, anything
1401
+ * else → internal-error-with-retry. Never throws.
1402
+ *
1403
+ * This is the worker thread's entry point (see {@link ./query-worker}) and the
1404
+ * in-process fallback for {@link execute}. It deliberately does NOT run the
1405
+ * catch-up gate or the staleness/worktree notices — those need the daemon's
1406
+ * watched main instance and stay on the main thread. Cross-cutting allowlist +
1407
+ * path validation already ran in {@link execute} before routing here.
1408
+ */
1409
+ async executeReadTool(toolName, args) {
1410
+ try {
1411
+ return await this.dispatchTool(toolName, args);
1412
+ }
1413
+ catch (err) {
1414
+ if (err instanceof NotIndexedError) {
1415
+ return this.textResult(err.message);
1416
+ }
1417
+ if (err instanceof PathRefusalError) {
1418
+ return this.errorResult(err.message);
1419
+ }
1420
+ return this.errorResult(`Tool execution failed: ${err instanceof Error ? err.message : String(err)}. ` +
1421
+ 'This is an internal homegraph error — retry the call once; if it persists, ' +
1422
+ 'continue without homegraph for this task.');
1423
+ }
1424
+ }
1425
+ /**
1426
+ * Pure dispatch over the read tools — the switch, with no gate, no notices, no
1427
+ * allowlist/validation (the caller owns those). `homegraph_status` is handled
1428
+ * on the main thread in {@link execute} and never reaches here. May throw
1429
+ * NotIndexed/PathRefusal, which {@link executeReadTool} classifies.
1430
+ */
1431
+ async dispatchTool(toolName, args) {
1432
+ switch (toolName) {
1433
+ case 'homegraph_search': return await this.handleSearch(args);
1434
+ case 'homegraph_callers': return await this.handleCallers(args);
1435
+ case 'homegraph_callees': return await this.handleCallees(args);
1436
+ case 'homegraph_impact': return await this.handleImpact(args);
1437
+ case 'homegraph_explore': return await this.handleExplore(args);
1438
+ case 'homegraph_node': return await this.handleNode(args);
1439
+ case 'homegraph_files': return await this.handleFiles(args);
1440
+ case 'homegraph_spec_match': return await this.handleSpecMatch(args);
1441
+ case 'homegraph_spec_find': return await this.handleSpecFind(args);
1442
+ case 'homegraph_spec_trace': return await this.handleSpecTrace(args);
1443
+ default: return this.errorResult(`Unknown tool: ${toolName}`);
1444
+ }
1445
+ }
1205
1446
  /**
1206
1447
  * Handle homegraph_search
1207
1448
  */
@@ -1215,8 +1456,8 @@ class ToolHandler {
1215
1456
  // NodeKind is 'type_alias'. Without the mapping, kind: "type" silently
1216
1457
  // matched nothing — a filter value we advertise must work.
1217
1458
  const kind = rawKind === 'type' ? 'type_alias' : rawKind;
1218
- const rawLimit = Number(args.limit) || 10;
1219
- const limit = (0, utils_1.clamp)(rawLimit, 1, 100);
1459
+ const rawLimit = Number(args.limit);
1460
+ const limit = (0, utils_1.clamp)(isNaN(rawLimit) ? 10 : rawLimit, 1, 100);
1220
1461
  const results = cg.searchNodes(query, {
1221
1462
  limit,
1222
1463
  kinds: kind ? [kind] : undefined,
@@ -1526,6 +1767,20 @@ class ToolHandler {
1526
1767
  }
1527
1768
  if (m?.synthesizedBy === 'viewtree') {
1528
1769
  const via = typeof m.via === 'string' ? m.via : '';
1770
+ if (via === 'Prop') {
1771
+ return {
1772
+ label: `@Prop one-way state transfer (parent → child)`,
1773
+ compact: `state: @Prop one-way${at}`,
1774
+ registeredAt,
1775
+ };
1776
+ }
1777
+ if (via === 'Link') {
1778
+ return {
1779
+ label: `@Link two-way state transfer (parent ↔ child)`,
1780
+ compact: `state: @Link two-way${at}`,
1781
+ registeredAt,
1782
+ };
1783
+ }
1529
1784
  if (via && !VIEWTREE_STRUCTURE_VIAS.has(via)) {
1530
1785
  return {
1531
1786
  label: `ArkUI event \`.${via}\` — bound handler (dynamic dispatch)`,
@@ -2374,11 +2629,24 @@ class ToolHandler {
2374
2629
  // trace endpoint picker uses) and inject it as an entry, so every symbol the
2375
2630
  // agent explicitly named is in the subgraph and its file is scored.
2376
2631
  const namedSeedIds = new Set();
2632
+ // The subset of named seeds that earns the named-FIRST sort tier. We still
2633
+ // SEED every ≤3-def name (so RWR / flow ranking is unchanged), but only the
2634
+ // most-substantive def is tiered — a bare name's unrelated namesakes (Go's
2635
+ // `NewClient` = real client + test fake + xds pool) must not fill the tier
2636
+ // and crowd out the real answer file (grpc's `dialoptions.go`). Corroborated
2637
+ // overloads (the query also named the type) all earn it. (#1064)
2638
+ const tierSeedIds = new Set();
2377
2639
  {
2378
2640
  const FILE_EXT = /\.(?:java|kt|kts|ts|tsx|js|jsx|mjs|cjs|cs|py|go|rb|php|swift|rs|cpp|cc|cxx|c|h|hpp|scala|lua|dart|vue|svelte|astro)$/i;
2379
2641
  const CALLABLE = new Set(['method', 'function', 'component', 'constructor']);
2380
2642
  const isTestPath = (p) => /(^|\/)(tests?|specs?|__tests__|testdata|mocks?|fixtures?)\//i.test(p) || /\.(test|spec)\.[a-z]+$/i.test(p);
2381
2643
  const bodyLines = (n) => Math.max(0, (n.endLine ?? n.startLine) - n.startLine);
2644
+ const callerCount = (n) => { try {
2645
+ return cg.getCallers(n.id).length;
2646
+ }
2647
+ catch {
2648
+ return 0;
2649
+ } };
2382
2650
  const tokens = [...new Set(query.split(/[\s,()[\]]+/)
2383
2651
  .map((t) => t.replace(FILE_EXT, '').trim())
2384
2652
  .filter((t) => t.length >= 3 && /^[A-Za-z_$][\w$]*(?:(?:::|\.)[\w$]+)*$/.test(t)))].slice(0, 16);
@@ -2414,12 +2682,23 @@ class ToolHandler {
2414
2682
  // capped; else fall back to the single most-substantive def. This is the
2415
2683
  // explore-side mirror of homegraph_node's overload disambiguation.
2416
2684
  let picks;
2685
+ let tierPicks; // subset that earns the named-first tier (#1064)
2417
2686
  if (cands.length <= 3) {
2418
2687
  picks = cands;
2688
+ // Centrality de-noise: tier the most-substantive def PLUS any co-named
2689
+ // def of comparable centrality (a real overload/wrapper — excalidraw's
2690
+ // `mutateElement` lives in mutateElement.ts, App.tsx AND Scene.ts, all
2691
+ // within ~2x callers). EXCLUDE a vastly-less-central namesake (Go's
2692
+ // `NewClient`: real client 492 callers vs xds-pool 11, test-fake 3 →
2693
+ // ratio <0.025) so it doesn't fill the tier and crowd out the answer.
2694
+ const counts = new Map(cands.map((c) => [c.id, callerCount(c)]));
2695
+ const maxCallers = Math.max(1, ...counts.values());
2696
+ tierPicks = cands.filter((c, i) => i === 0 || (counts.get(c.id) ?? 0) >= maxCallers * 0.25);
2419
2697
  }
2420
2698
  else {
2421
2699
  const ctx = cands.filter(inNamedContext);
2422
2700
  picks = ctx.length > 0 ? ctx.slice(0, 4) : cands.slice(0, 1);
2701
+ tierPicks = picks; // corroborated overloads (or the single fallback) all earn it
2423
2702
  }
2424
2703
  for (const n of picks) {
2425
2704
  if (!subgraph.nodes.has(n.id))
@@ -2431,6 +2710,8 @@ class ToolHandler {
2431
2710
  // so a named symbol FTS already gathered never sorted to the top.)
2432
2711
  namedSeedIds.add(n.id);
2433
2712
  }
2713
+ for (const n of tierPicks)
2714
+ tierSeedIds.add(n.id);
2434
2715
  }
2435
2716
  }
2436
2717
  // Step 2: Group nodes by file, score by relevance
@@ -2444,6 +2725,46 @@ class ToolHandler {
2444
2725
  if (entryNodeIds.has(edge.target))
2445
2726
  connectedToEntry.add(edge.source);
2446
2727
  }
2728
+ // CHANGE SURFACE (#1064): a named method's signature types — its parameter
2729
+ // and return types — are part of what you'd edit to "add a parameter to X",
2730
+ // yet they can be lexically dissimilar to the query ("add a parameter to
2731
+ // NewClient" shares no words with `dialoptions.go`, which defines NewClient's
2732
+ // `DialOption`) and sit a hop away. COLLECT them here from each named-seed
2733
+ // callable's outgoing signature edges (full graph — the type is often not in
2734
+ // the subgraph); the decision to surface one is DEFERRED to the buried-rescue
2735
+ // pass below, which fires only when the type's file would otherwise be
2736
+ // dropped — so a well-connected type (excalidraw's element types, Alamofire's
2737
+ // `DataRequest` on a flow query) is left to rank on its own and never
2738
+ // displaces a flow-central file. Bounded: only the few named seeds, only the
2739
+ // types in their signatures.
2740
+ const CALLABLE_KINDS = new Set(['method', 'function', 'component', 'constructor']);
2741
+ const TYPE_KINDS = new Set(['class', 'struct', 'interface', 'trait', 'protocol', 'enum', 'type_alias']);
2742
+ const SIG_EDGE = new Set(['references', 'type_of', 'returns']);
2743
+ const changeSurfaceCandidates = [];
2744
+ const seenChangeSurface = new Set();
2745
+ for (const seedId of tierSeedIds) {
2746
+ const seedNode = subgraph.nodes.get(seedId);
2747
+ if (!seedNode || !CALLABLE_KINDS.has(seedNode.kind))
2748
+ continue;
2749
+ let outs = [];
2750
+ try {
2751
+ outs = cg.getOutgoingEdges(seedId);
2752
+ }
2753
+ catch {
2754
+ continue;
2755
+ }
2756
+ for (const e of outs) {
2757
+ if (!SIG_EDGE.has(e.kind))
2758
+ continue;
2759
+ const tgt = cg.getNode(e.target);
2760
+ if (!tgt || !TYPE_KINDS.has(tgt.kind) || namedSeedIds.has(tgt.id))
2761
+ continue;
2762
+ if (seenChangeSurface.has(tgt.id))
2763
+ continue;
2764
+ seenChangeSurface.add(tgt.id);
2765
+ changeSurfaceCandidates.push(tgt);
2766
+ }
2767
+ }
2447
2768
  for (const node of subgraph.nodes.values()) {
2448
2769
  // Skip import/export nodes — they add noise without information
2449
2770
  if (node.kind === 'import' || node.kind === 'export')
@@ -2563,6 +2884,36 @@ class ToolHandler {
2563
2884
  if (n)
2564
2885
  entryFiles.add(n.filePath);
2565
2886
  }
2887
+ // Buried-rescue pass (#1064): surface a named method's signature type ONLY
2888
+ // when its file is genuinely buried — near-zero graph mass AND not lexically
2889
+ // matched. That is the invisible case (grpc's `DialOption` → `dialoptions.go`,
2890
+ // g≈0, 0 term hits): reachable but ranked nowhere, so the agent greps. A
2891
+ // well-connected type file (excalidraw element types, Alamofire `DataRequest`)
2892
+ // is NOT buried and is left alone — rescuing it would displace a flow-central
2893
+ // file (App.tsx, Validation.swift). Buried is judged on the PRE-rescue graph,
2894
+ // so injecting the type below can't make it look connected. A rescued file is
2895
+ // injected (so it renders), force-kept (gate + relevantFiles), and tiered.
2896
+ const changeSurfaceFiles = new Set();
2897
+ for (const t of changeSurfaceCandidates) {
2898
+ const fp = t.filePath;
2899
+ const buried = (fileGraphScore.get(fp) ?? 0) < maxGraph * 0.06
2900
+ && (fileTermHits.get(fp) ?? 0) < 2;
2901
+ if (!buried)
2902
+ continue;
2903
+ changeSurfaceFiles.add(fp);
2904
+ if (!subgraph.nodes.has(t.id))
2905
+ subgraph.nodes.set(t.id, t);
2906
+ let group = fileGroups.get(fp);
2907
+ if (!group) {
2908
+ group = { nodes: [], score: 0 };
2909
+ fileGroups.set(fp, group);
2910
+ }
2911
+ if (!group.nodes.some((n) => n.id === t.id))
2912
+ group.nodes.push(t);
2913
+ group.score = Math.max(group.score, 45);
2914
+ if (!relevantFiles.some(([f]) => f === fp))
2915
+ relevantFiles.push([fp, group]);
2916
+ }
2566
2917
  // Relevance gate (so the generous budget is a CEILING, not a target): keep a
2567
2918
  // file only if it is STRUCTURALLY relevant by ANY of:
2568
2919
  // - graph score within a fraction of the top (it's on/near the flow), OR
@@ -2579,6 +2930,7 @@ class ToolHandler {
2579
2930
  const gated = relevantFiles.filter(([fp]) => (fileGraphScore.get(fp) ?? 0) >= maxGraph * 0.06
2580
2931
  || centralFiles.has(fp)
2581
2932
  || entryFiles.has(fp)
2933
+ || changeSurfaceFiles.has(fp)
2582
2934
  || (fileTermHits.get(fp) ?? 0) >= 2);
2583
2935
  if (gated.length >= 2)
2584
2936
  relevantFiles = gated;
@@ -2593,11 +2945,16 @@ class ToolHandler {
2593
2945
  // in other files (`Validation.swift`), falls outside the budget, and the
2594
2946
  // agent Reads it. The named file is the answer — rank it at the top.
2595
2947
  const namedSeedFiles = new Set();
2596
- for (const id of namedSeedIds) {
2948
+ for (const id of tierSeedIds) {
2597
2949
  const n = subgraph.nodes.get(id);
2598
2950
  if (n)
2599
2951
  namedSeedFiles.add(n.filePath);
2600
2952
  }
2953
+ // A rescued change-surface file (only the genuinely-buried ones — see the
2954
+ // buried-rescue pass) is the lexically-dissimilar answer; give it the named
2955
+ // tier so it isn't buried under files that merely share surface words (#1064).
2956
+ for (const fp of changeSurfaceFiles)
2957
+ namedSeedFiles.add(fp);
2601
2958
  // Multi-term corroboration tier: a file that is BOTH (a) an entry/central file
2602
2959
  // (a search root, named seed, or graph-central hub — i.e. structurally part of
2603
2960
  // the answer) AND (b) matched by ≥2 DISTINCT query terms must not be buried by
@@ -2662,9 +3019,16 @@ class ToolHandler {
2662
3019
  const lines = [
2663
3020
  `**Exploration: ${query}**`,
2664
3021
  '',
2665
- `Found ${subgraph.nodes.size} symbols across ${fileGroups.size} files.`,
3022
+ // Curated summary filled in after the source loop (see below). We do NOT
3023
+ // report `subgraph.nodes.size` / `fileGroups.size` here: that's the raw
3024
+ // candidate gather, which a broad natural-language query inflates wildly
3025
+ // (260 symbols / 124 files on a 636-file repo) even though only a handful
3026
+ // render. Reporting the pool read as "260 results to wade through" when the
3027
+ // real, correctly-ranked answer is the few files below (#1046).
3028
+ '',
2666
3029
  '',
2667
3030
  ];
3031
+ const summaryLineIdx = 2;
2668
3032
  // Blast radius (always-on, compact): for the entry symbols, who depends on
2669
3033
  // them + which tests cover them — locations only, no source — so the agent
2670
3034
  // knows what to update/verify before editing without a separate call.
@@ -2766,6 +3130,9 @@ class ToolHandler {
2766
3130
  lines.push('');
2767
3131
  let totalChars = lines.join('\n').length;
2768
3132
  let filesIncluded = 0;
3133
+ // Paths we actually render source for below. Drives the curated header count
3134
+ // (#1046) — it must reflect what we show, not the raw candidate gather.
3135
+ const renderedFilePaths = [];
2769
3136
  let anyFileTrimmed = false;
2770
3137
  for (const [filePath, group] of sortedFiles) {
2771
3138
  if (filesIncluded >= maxFiles)
@@ -2779,6 +3146,24 @@ class ToolHandler {
2779
3146
  const fileNecessary = group.nodes.some(n => entryNodeIds.has(n.id) || flow.pathNodeIds.has(n.id) || flow.uniqueNamedNodeIds.has(n.id));
2780
3147
  if (!fileNecessary && totalChars > budget.maxOutputChars * 0.9)
2781
3148
  continue;
3149
+ if ((0, arkts_1.isOhosApiFilePath)(filePath)) {
3150
+ const rel = filePath.slice(arkts_1.OHOS_API_FILE_PREFIX.length);
3151
+ const syms = group.nodes
3152
+ .filter((n) => n.kind !== 'import' && n.kind !== 'export')
3153
+ .sort((a, b) => a.startLine - b.startLine);
3154
+ if (syms.length === 0)
3155
+ continue;
3156
+ lines.push(fileSectionHeader(rel, 'HarmonyOS SDK API (prebuilt db)'));
3157
+ lines.push('');
3158
+ for (const n of syms) {
3159
+ const sig = n.signature || n.docstring || `${n.kind} ${n.qualifiedName || n.name}`;
3160
+ lines.push(`\`${sig}\``);
3161
+ }
3162
+ lines.push('');
3163
+ totalChars = lines.join('\n').length;
3164
+ filesIncluded++;
3165
+ continue;
3166
+ }
2782
3167
  const absPath = (0, utils_1.validatePathWithinRoot)(projectRoot, filePath);
2783
3168
  if (!absPath || !(0, fs_1.existsSync)(absPath))
2784
3169
  continue;
@@ -2917,6 +3302,7 @@ class ToolHandler {
2917
3302
  : 'skeleton (signatures only — homegraph_explore a name for its full body; do NOT Read)';
2918
3303
  lines.push(fileSectionHeader(filePath, `${names} · ${tag}`), '', '```' + lang, skel.join('\n'), '```', '');
2919
3304
  totalChars += skel.join('\n').length + 120;
3305
+ renderedFilePaths.push(filePath);
2920
3306
  filesIncluded++;
2921
3307
  continue;
2922
3308
  }
@@ -2963,6 +3349,7 @@ class ToolHandler {
2963
3349
  }
2964
3350
  lines.push(wholeHeader, '', '```' + lang, wholeSection, '```', '');
2965
3351
  totalChars += wholeSection.length + 200;
3352
+ renderedFilePaths.push(filePath);
2966
3353
  filesIncluded++;
2967
3354
  continue;
2968
3355
  }
@@ -3251,8 +3638,14 @@ class ToolHandler {
3251
3638
  lines.push('```');
3252
3639
  lines.push('');
3253
3640
  totalChars += fileSection.length + 200;
3641
+ renderedFilePaths.push(filePath);
3254
3642
  filesIncluded++;
3255
3643
  }
3644
+ // The curated header count is computed from the files that SURVIVE the final
3645
+ // truncation (see end of method) — `filesIncluded` can over-count when the
3646
+ // hard ceiling drops trailing sections — so leave a sentinel here and fill it
3647
+ // in once the output is final.
3648
+ lines[summaryLineIdx] = SUMMARY_SENTINEL;
3256
3649
  // Add remaining files as references (from both relevant and peripheral files).
3257
3650
  // Small projects (per budget) skip this — the relevant story already fits
3258
3651
  // in the source section, and a trailing pointer list is pure overhead.
@@ -3309,6 +3702,7 @@ class ToolHandler {
3309
3702
  // externalize territory.
3310
3703
  const output = flow.text + lines.join('\n');
3311
3704
  const hardCeiling = Math.min(Math.round(budget.maxOutputChars * 1.5), 25000);
3705
+ let finalText;
3312
3706
  if (output.length > hardCeiling) {
3313
3707
  // Cut at a FILE-SECTION boundary (the last ``**` `` file header before the
3314
3708
  // ceiling) so we drop whole trailing file-sections rather than slicing
@@ -3319,9 +3713,30 @@ class ToolHandler {
3319
3713
  const lastSection = cut.lastIndexOf('\n' + FILE_SECTION_PREFIX);
3320
3714
  const boundary = lastSection > hardCeiling * 0.5 ? lastSection : cut.lastIndexOf('\n');
3321
3715
  const safe = boundary > 0 ? cut.slice(0, boundary) : cut;
3322
- return this.textResult(safe + '\n\n... (output truncated to budget; the source above is complete and verbatim — treat it as already Read. For any area not covered, run another homegraph_explore with the specific names — do NOT Read these files.)');
3716
+ finalText = safe + '\n\n... (output truncated to budget; the source above is complete and verbatim — treat it as already Read. For any area not covered, run another homegraph_explore with the specific names — do NOT Read these files.)';
3323
3717
  }
3324
- return this.textResult(output);
3718
+ else {
3719
+ finalText = output;
3720
+ }
3721
+ // Curated header (#1046): substitute the sentinel with the count of files
3722
+ // whose source SURVIVES in the final text — not `subgraph`/`fileGroups` (the
3723
+ // raw gather a broad query inflates) and not `filesIncluded` (which can
3724
+ // over-count when the ceiling above drops trailing sections). A file counts
3725
+ // only if its section header is still present; its relevant (non-import)
3726
+ // symbols are summed for N. Files we couldn't fit are still named under "Not
3727
+ // shown above" + the budget note, so nothing is silently dropped.
3728
+ const survivors = renderedFilePaths.filter((fp) => finalText.includes(`${FILE_SECTION_PREFIX}${fp}\``));
3729
+ const shownSymbols = survivors.reduce((sum, fp) => {
3730
+ const g = fileGroups.get(fp);
3731
+ if (!g)
3732
+ return sum;
3733
+ return sum + new Set(g.nodes.filter((n) => n.kind !== 'import' && n.kind !== 'export').map((n) => n.id)).size;
3734
+ }, 0);
3735
+ const summaryLine = survivors.length > 0
3736
+ ? `Found ${shownSymbols} symbol${shownSymbols === 1 ? '' : 's'} across ${survivors.length} file${survivors.length === 1 ? '' : 's'}.`
3737
+ : `Found ${subgraph.nodes.size} symbol${subgraph.nodes.size === 1 ? '' : 's'} across ${fileGroups.size} file${fileGroups.size === 1 ? '' : 's'}.`;
3738
+ finalText = finalText.replace(SUMMARY_SENTINEL, summaryLine);
3739
+ return this.textResult(finalText);
3325
3740
  }
3326
3741
  /**
3327
3742
  * Handle homegraph_node
@@ -3483,7 +3898,7 @@ class ToolHandler {
3483
3898
  const symbolMap = (heading, limit = 200) => {
3484
3899
  const lines = [heading];
3485
3900
  for (const n of nodes.slice(0, limit)) {
3486
- const sig = n.signature ? ` ${n.signature.replace(/\s+/g, ' ').trim()}` : '';
3901
+ const sig = n.signature ? ` ${formatInlineSignature(n.signature)}` : '';
3487
3902
  lines.push(`- \`${n.name}\` (${n.kind})${sig} — :${n.startLine}`);
3488
3903
  }
3489
3904
  if (nodes.length > limit)
@@ -3886,12 +4301,12 @@ class ToolHandler {
3886
4301
  return query;
3887
4302
  const repoPath = args.repoPath;
3888
4303
  const explicitDbPath = args.dbPath;
3889
- const topK = Math.max(1, Math.min(Number(args.topK) || 5, 50));
4304
+ const topKRaw = Number(args.topK);
4305
+ const topK = Math.max(1, Math.min(isNaN(topKRaw) ? 5 : topKRaw, 50));
3890
4306
  const includeFragments = args.includeFragments !== false;
3891
4307
  // Lazily require spec modules so the MCP startup path stays lean.
3892
4308
  const { resolveDbPath } = require('../spec/utils');
3893
4309
  const { createDatabase } = require('../db/sqlite-adapter');
3894
- const { initSpecSchema } = require('../spec/db/schema');
3895
4310
  const { searchAndGetContext } = require('../spec/graph/queries');
3896
4311
  const { truncateCodeDiff, truncateSubtitles, computeBudgetProfile, } = require('../spec/utils');
3897
4312
  // Resolve the database path.
@@ -3906,8 +4321,6 @@ class ToolHandler {
3906
4321
  `${err instanceof Error ? err.message : String(err)}`);
3907
4322
  }
3908
4323
  try {
3909
- // Ensure the schema exists (idempotent).
3910
- initSpecSchema(db);
3911
4324
  // Search and traverse.
3912
4325
  const contexts = searchAndGetContext(db, query, topK, includeFragments);
3913
4326
  if (contexts.length === 0) {
@@ -3975,15 +4388,238 @@ class ToolHandler {
3975
4388
  return this.textResult(`(Fragments elided — output exceeded ${MAX_OUTPUT_LENGTH} chars)\n\n` + slim);
3976
4389
  }
3977
4390
  }
3978
- // Fallback: hard-truncate the JSON.
3979
- const truncated = json.slice(0, MAX_OUTPUT_LENGTH - 3) + '...';
3980
- return this.textResult(truncated);
4391
+ // Fallback: truncate at a newline to avoid broken JSON.
4392
+ return this.textResult(this.truncateOutput(json));
3981
4393
  }
3982
4394
  finally {
3983
4395
  db.close();
3984
4396
  }
3985
4397
  }
3986
4398
  // =========================================================================
4399
+ // handleSpecFind — file-path based spec lookup
4400
+ // =========================================================================
4401
+ /**
4402
+ * Find which specs are related to the given file path by matching against
4403
+ * code-fragment file paths in the Commit4Spec knowledge graph.
4404
+ *
4405
+ * Traverses: filePath → code_fragment_nodes → commit_fragment_relations
4406
+ * → spec_commit_relations → spec_nodes.
4407
+ */
4408
+ async handleSpecFind(args) {
4409
+ const filePath = this.validateString(args.filePath, 'filePath');
4410
+ if (typeof filePath !== 'string')
4411
+ return filePath;
4412
+ const repoPath = args.repoPath;
4413
+ const explicitDbPath = args.dbPath;
4414
+ // Lazily require spec modules so the MCP startup path stays lean.
4415
+ const { resolveDbPath } = require('../spec/utils');
4416
+ const { createDatabase } = require('../db/sqlite-adapter');
4417
+ const { findSpecsByFilePath } = require('../spec/graph/queries');
4418
+ // Resolve the database path.
4419
+ const dbPath = resolveDbPath(repoPath || process.cwd(), explicitDbPath);
4420
+ // Open the database.
4421
+ let db;
4422
+ try {
4423
+ db = createDatabase(dbPath).db;
4424
+ }
4425
+ catch (err) {
4426
+ return this.errorResult(`Failed to open Commit4Spec database at "${dbPath}": ` +
4427
+ `${err instanceof Error ? err.message : String(err)}`);
4428
+ }
4429
+ try {
4430
+ const result = findSpecsByFilePath(db, filePath);
4431
+ const response = {
4432
+ filePath,
4433
+ matched_count: result.matched_count,
4434
+ truncated: result.truncated,
4435
+ results: result.results,
4436
+ };
4437
+ const json = JSON.stringify(response, null, 2);
4438
+ if (json.length <= MAX_OUTPUT_LENGTH) {
4439
+ return this.textResult(json);
4440
+ }
4441
+ // Truncate: reduce results
4442
+ const slim = {
4443
+ ...response,
4444
+ results: response.results.slice(0, Math.max(1, Math.floor(response.results.length / 2))),
4445
+ };
4446
+ const slimJson = JSON.stringify(slim, null, 2);
4447
+ if (slimJson.length <= MAX_OUTPUT_LENGTH) {
4448
+ return this.textResult(`(Results trimmed to fit output limit)\n\n${slimJson}`);
4449
+ }
4450
+ return this.textResult(this.truncateOutput(json));
4451
+ }
4452
+ finally {
4453
+ db.close();
4454
+ }
4455
+ }
4456
+ // =========================================================================
4457
+ // handleSpecTrace — code symbol → Spec reverse trace
4458
+ // =========================================================================
4459
+ /**
4460
+ * Trace a code symbol back to its associated Specs.
4461
+ *
4462
+ * Uses the HomeGraph code index to resolve the symbol to AST-level node(s),
4463
+ * then queries the Commit4Spec knowledge graph for associated Specs via
4464
+ * five-dimensional scoring (file-path, content FTS5, name FTS5, recency,
4465
+ * line overlap).
4466
+ *
4467
+ * This bridges the two databases: homegraph.db (code entities) →
4468
+ * commit4spec.db (Spec knowledge graph).
4469
+ */
4470
+ async handleSpecTrace(args) {
4471
+ const symbol = this.validateString(args.symbol, 'symbol');
4472
+ if (typeof symbol !== 'string')
4473
+ return symbol;
4474
+ const fileRaw = this.validateOptionalPath(args.file, 'file');
4475
+ if (typeof fileRaw === 'object')
4476
+ return fileRaw;
4477
+ const file = fileRaw;
4478
+ const line = typeof args.line === 'number' ? args.line : undefined;
4479
+ const repoPath = args.repoPath;
4480
+ const explicitDbPath = args.dbPath;
4481
+ const topKRaw = Number(args.topK);
4482
+ const topK = Math.max(1, Math.min(isNaN(topKRaw) ? 10 : topKRaw, 50));
4483
+ // Lazily require all needed modules
4484
+ const HomeGraph = loadHomeGraph();
4485
+ const { resolveDbPath } = require('../spec/utils');
4486
+ const { createDatabase } = require('../db/sqlite-adapter');
4487
+ const { findSpecsByCodeSymbol } = require('../spec/graph/queries');
4488
+ const { initSpecSchema, runSpecMigrations, getCurrentSpecVersion, CURRENT_SPEC_SCHEMA_VERSION } = require('../spec/db/schema');
4489
+ // Resolve project path for the code graph
4490
+ const projectPath = repoPath || process.cwd();
4491
+ // Open the HomeGraph code index
4492
+ let cg;
4493
+ try {
4494
+ cg = await HomeGraph.open(projectPath);
4495
+ }
4496
+ catch (err) {
4497
+ return this.errorResult(`Failed to open HomeGraph code index at "${projectPath}": ` +
4498
+ `${err instanceof Error ? err.message : String(err)}`);
4499
+ }
4500
+ try {
4501
+ // Step 1: Resolve symbol to nodes via findSymbolMatches
4502
+ let nodes = this.findSymbolMatches(cg, symbol);
4503
+ if (nodes.length === 0) {
4504
+ await cg.close();
4505
+ return this.textResult(JSON.stringify({
4506
+ symbol,
4507
+ error: `No code entities found for symbol "${symbol}".`,
4508
+ matches: [],
4509
+ }, null, 2));
4510
+ }
4511
+ // Disambiguate by file/line if provided
4512
+ if (file) {
4513
+ // Use endsWith for precise file path matching
4514
+ nodes = nodes.filter((n) => n.filePath.endsWith(file));
4515
+ }
4516
+ if (line !== undefined && nodes.length > 1) {
4517
+ const closest = nodes.reduce((best, n) => {
4518
+ const bestDist = Math.abs(best.startLine - line) + Math.abs(best.endLine - line);
4519
+ const curDist = Math.abs(n.startLine - line) + Math.abs(n.endLine - line);
4520
+ return curDist < bestDist ? n : best;
4521
+ });
4522
+ nodes = [closest];
4523
+ }
4524
+ // Take the best disambiguated node
4525
+ const node = nodes[0];
4526
+ if (!node) {
4527
+ await cg.close();
4528
+ return this.textResult(JSON.stringify({
4529
+ symbol,
4530
+ error: `Could not resolve symbol "${symbol}" to a specific code entity.`,
4531
+ matches: [],
4532
+ }, null, 2));
4533
+ }
4534
+ // Step 2: Resolve the Spec database path
4535
+ const dbPath = resolveDbPath(repoPath || process.cwd(), explicitDbPath);
4536
+ // Step 3: Open the Spec database
4537
+ let db;
4538
+ try {
4539
+ db = createDatabase(dbPath).db;
4540
+ }
4541
+ catch (err) {
4542
+ await cg.close();
4543
+ return this.errorResult(`Failed to open Commit4Spec database at "${dbPath}": ` +
4544
+ `${err instanceof Error ? err.message : String(err)}`);
4545
+ }
4546
+ try {
4547
+ // Ensure schema is up to date
4548
+ initSpecSchema(db);
4549
+ const currentVersion = getCurrentSpecVersion(db);
4550
+ if (currentVersion < CURRENT_SPEC_SCHEMA_VERSION) {
4551
+ runSpecMigrations(db, currentVersion);
4552
+ }
4553
+ // Step 4: Query Specs for the code entity
4554
+ const result = findSpecsByCodeSymbol(db, {
4555
+ name: node.name,
4556
+ qualifiedName: node.qualifiedName,
4557
+ kind: node.kind,
4558
+ filePath: node.filePath,
4559
+ startLine: node.startLine,
4560
+ endLine: node.endLine,
4561
+ }, topK);
4562
+ // Step 5: Serialize the response
4563
+ const response = {
4564
+ symbol,
4565
+ entity: {
4566
+ name: result.entity.name,
4567
+ qualifiedName: result.entity.qualifiedName,
4568
+ kind: result.entity.kind,
4569
+ filePath: result.entity.filePath,
4570
+ startLine: result.entity.startLine,
4571
+ endLine: result.entity.endLine,
4572
+ },
4573
+ matched_count: result.matches.length,
4574
+ total_candidates: result.totalCandidates,
4575
+ matches: result.matches.map((m) => ({
4576
+ spec_id: m.spec.id,
4577
+ title: m.spec.title,
4578
+ status: m.spec.status,
4579
+ version: m.spec.version,
4580
+ file_path: m.spec.filePath,
4581
+ score: Math.round(m.score * 1000) / 1000,
4582
+ score_detail: {
4583
+ file_path: Math.round(m.scoreDetail.filePathScore * 1000) / 1000,
4584
+ content: Math.round(m.scoreDetail.contentScore * 1000) / 1000,
4585
+ name: Math.round(m.scoreDetail.nameScore * 1000) / 1000,
4586
+ recency: Math.round(m.scoreDetail.recencyScore * 1000) / 1000,
4587
+ line_overlap: Math.round(m.scoreDetail.overlapScore * 1000) / 1000,
4588
+ },
4589
+ fragment_count: m.fragmentCount,
4590
+ commit_count: m.commitCount,
4591
+ })),
4592
+ };
4593
+ const json = JSON.stringify(response, null, 2);
4594
+ if (json.length <= MAX_OUTPUT_LENGTH) {
4595
+ return this.textResult(json);
4596
+ }
4597
+ // Truncate: reduce matches
4598
+ const slim = {
4599
+ ...response,
4600
+ matches: response.matches.slice(0, Math.max(1, Math.floor(topK / 2))),
4601
+ };
4602
+ const slimJson = JSON.stringify(slim, null, 2);
4603
+ if (slimJson.length <= MAX_OUTPUT_LENGTH) {
4604
+ return this.textResult(`(Results trimmed to fit output limit)\n\n${slimJson}`);
4605
+ }
4606
+ return this.textResult(this.truncateOutput(json));
4607
+ }
4608
+ finally {
4609
+ try {
4610
+ db.close();
4611
+ }
4612
+ catch { /* best effort */ }
4613
+ }
4614
+ }
4615
+ finally {
4616
+ try {
4617
+ await cg.close();
4618
+ }
4619
+ catch { /* best effort */ }
4620
+ }
4621
+ }
4622
+ // =========================================================================
3987
4623
  // Symbol resolution helpers
3988
4624
  // =========================================================================
3989
4625
  /**
@@ -4151,7 +4787,7 @@ class ToolHandler {
4151
4787
  lines.push(`**${node.name}** (${node.kind})`);
4152
4788
  lines.push(`${node.filePath}${location}`);
4153
4789
  if (node.signature)
4154
- lines.push(`\`${node.signature}\``);
4790
+ lines.push(`\`${formatInlineSignature(node.signature)}\``);
4155
4791
  lines.push('');
4156
4792
  }
4157
4793
  return lines.join('\n');
@@ -4225,7 +4861,7 @@ class ToolHandler {
4225
4861
  const lines = [`**Members (${children.length}):**`, ''];
4226
4862
  for (const c of children) {
4227
4863
  const loc = c.startLine ? `:${c.startLine}` : '';
4228
- const sig = c.signature ? ` — \`${c.signature}\`` : '';
4864
+ const sig = c.signature ? ` — \`${formatInlineSignature(c.signature)}\`` : '';
4229
4865
  lines.push(`- ${c.name} (${c.kind})${loc}${sig}`);
4230
4866
  }
4231
4867
  return lines.join('\n');
@@ -4238,7 +4874,7 @@ class ToolHandler {
4238
4874
  `**Location:** ${node.filePath}${location}`,
4239
4875
  ];
4240
4876
  if (node.signature) {
4241
- lines.push(`**Signature:** \`${node.signature}\``);
4877
+ lines.push(...formatNodeSignatureBlock(node.signature));
4242
4878
  }
4243
4879
  // Only include docstring if it's short and useful
4244
4880
  if (node.docstring && node.docstring.length < 200) {