homegraph 1.5.10 → 1.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/CHANGELOG.md +27 -0
  2. package/README.md +64 -14
  3. package/dist/db/queries.d.ts +4 -0
  4. package/dist/db/queries.js +15 -0
  5. package/dist/extraction/grammars.d.ts +19 -0
  6. package/dist/extraction/grammars.js +44 -1
  7. package/dist/graph/evidence-paths.d.ts +2 -0
  8. package/dist/graph/evidence-paths.js +20 -3
  9. package/dist/mcp/arkts-evidence-packs.d.ts +17 -0
  10. package/dist/mcp/arkts-evidence-packs.js +219 -10
  11. package/dist/mcp/daemon.d.ts +21 -3
  12. package/dist/mcp/daemon.js +60 -6
  13. package/dist/mcp/engine.d.ts +26 -0
  14. package/dist/mcp/engine.js +143 -7
  15. package/dist/mcp/evidence-audit.d.ts +3 -0
  16. package/dist/mcp/evidence-audit.js +40 -0
  17. package/dist/mcp/implementation-context.d.ts +35 -0
  18. package/dist/mcp/implementation-context.js +232 -0
  19. package/dist/mcp/index-availability.d.ts +22 -0
  20. package/dist/mcp/index-availability.js +40 -1
  21. package/dist/mcp/index.js +9 -0
  22. package/dist/mcp/indexable-root.d.ts +22 -0
  23. package/dist/mcp/indexable-root.js +140 -0
  24. package/dist/mcp/liveness-watchdog.d.ts +6 -1
  25. package/dist/mcp/liveness-watchdog.js +17 -5
  26. package/dist/mcp/locate-contract.d.ts +50 -0
  27. package/dist/mcp/locate-contract.js +146 -0
  28. package/dist/mcp/query-cache.d.ts +1 -15
  29. package/dist/mcp/query-cache.js +20 -11
  30. package/dist/mcp/request-evidence.d.ts +69 -0
  31. package/dist/mcp/request-evidence.js +297 -0
  32. package/dist/mcp/server-instructions.d.ts +2 -2
  33. package/dist/mcp/server-instructions.js +12 -9
  34. package/dist/mcp/session.js +15 -0
  35. package/dist/mcp/tools.d.ts +67 -12
  36. package/dist/mcp/tools.js +841 -134
  37. package/dist/project-map/index.d.ts +45 -0
  38. package/dist/project-map/index.js +371 -3
  39. package/dist/resolution/callback-synthesizer.js +251 -0
  40. package/dist/resolution/frameworks/arkts-entry.d.ts +35 -6
  41. package/dist/resolution/frameworks/arkts-entry.js +513 -30
  42. package/dist/resolution/index.js +25 -21
  43. package/dist/runtime-log.d.ts +52 -0
  44. package/dist/runtime-log.js +199 -0
  45. package/dist/search/literal-evidence.js +11 -3
  46. package/dist/search/query-plan-provider.js +15 -6
  47. package/dist/search/query-plan.d.ts +3 -1
  48. package/dist/search/query-plan.js +25 -16
  49. package/dist/search/query-utils.d.ts +13 -0
  50. package/dist/search/query-utils.js +64 -0
  51. package/dist/search/request-contract.d.ts +29 -0
  52. package/dist/search/request-contract.js +76 -0
  53. package/package.json +1 -1
package/dist/mcp/tools.js CHANGED
@@ -1,33 +1,40 @@
1
1
  "use strict";
2
- /**
3
- * MCP Tool Definitions
4
- *
5
- * Defines the tools exposed by the HomeGraph MCP server.
6
- */
7
2
  Object.defineProperty(exports, "__esModule", { value: true });
8
- exports.ToolHandler = exports.tools = exports.PathRefusalError = exports.GraphSourcesDisabledError = exports.NotIndexedError = void 0;
3
+ exports.ToolHandler = exports.DEFAULT_MCP_TOOL_SHORT_NAMES = exports.tools = exports.PathRefusalError = exports.GraphSourcesDisabledError = exports.NotIndexedError = void 0;
9
4
  exports.__setLoadHomeGraphForTests = __setLoadHomeGraphForTests;
10
5
  exports.normalizeQuerySpelling = normalizeQuerySpelling;
11
6
  exports.getExploreBudget = getExploreBudget;
12
7
  exports.getExploreOutputBudget = getExploreOutputBudget;
13
8
  exports.tightenExploreBudgetForQuery = tightenExploreBudgetForQuery;
14
9
  exports.exploreLocatorDigestEnabled = exploreLocatorDigestEnabled;
10
+ exports.formatHarmonyRegistrationSources = formatHarmonyRegistrationSources;
11
+ exports.formatHarmonyResourceHits = formatHarmonyResourceHits;
12
+ exports.formatHarmonyCapabilityProfiles = formatHarmonyCapabilityProfiles;
13
+ exports.isHarmonyStubBody = isHarmonyStubBody;
14
+ exports.formatHarmonySeamNotes = formatHarmonySeamNotes;
15
15
  exports.formatStaleBanner = formatStaleBanner;
16
16
  exports.formatStaleFooter = formatStaleFooter;
17
17
  exports.formatDegradedBanner = formatDegradedBanner;
18
+ exports.resolveMcpToolAllowlist = resolveMcpToolAllowlist;
18
19
  exports.getStaticTools = getStaticTools;
19
20
  exports.extractBareUsageSymbols = extractBareUsageSymbols;
20
21
  exports.queryAsBareSymbolInventory = queryAsBareSymbolInventory;
21
22
  exports.hasPositiveAnswerNowDirective = hasPositiveAnswerNowDirective;
22
23
  exports.reconcilePartialAnswerNow = reconcilePartialAnswerNow;
24
+ const evidence_audit_1 = require("./evidence-audit");
25
+ const implementation_context_1 = require("./implementation-context");
26
+ const request_contract_1 = require("../search/request-contract");
23
27
  const query_pool_1 = require("./query-pool");
24
28
  const source_slice_identity_1 = require("./source-slice-identity");
25
29
  const evidence_rendering_1 = require("./evidence-rendering");
26
30
  const arkts_evidence_packs_1 = require("./arkts-evidence-packs");
27
31
  const evidence_paths_1 = require("../graph/evidence-paths");
28
32
  const query_plan_1 = require("../search/query-plan");
33
+ const grammars_1 = require("../extraction/grammars");
34
+ const project_map_1 = require("../project-map");
29
35
  const index_availability_1 = require("./index-availability");
30
36
  const directory_1 = require("../directory");
37
+ const runtime_log_1 = require("../runtime-log");
31
38
  // Lazy-load the heavy HomeGraph chain off the MCP startup path — see the same
32
39
  // helper in engine.ts. ToolHandler must load to answer tools/list (static
33
40
  // schemas), but it must NOT drag in sqlite/query layers before the daemon binds;
@@ -55,6 +62,7 @@ const arkts_1 = require("../extraction/languages/arkts");
55
62
  const explore_session_state_1 = require("./explore-session-state");
56
63
  const explore_dedup_1 = require("./explore-dedup");
57
64
  const explore_repeat_guard_1 = require("./explore-repeat-guard");
65
+ const locate_contract_1 = require("./locate-contract");
58
66
  const dynamic_boundaries_1 = require("./dynamic-boundaries");
59
67
  const query_cache_1 = require("./query-cache");
60
68
  const diff_impact_1 = require("./diff-impact");
@@ -565,6 +573,258 @@ function fileSectionHeader(filePath, suffix) {
565
573
  ? `${FILE_SECTION_PREFIX}${filePath}\`** — ${suffix}`
566
574
  : `${FILE_SECTION_PREFIX}${filePath}\`**`;
567
575
  }
576
+ /**
577
+ * Spec 0039 — short Registration sources table from indexed Harmony profile routes.
578
+ * Returns null when nothing to show.
579
+ */
580
+ function formatHarmonyRegistrationSources(cg, maxRows = 24) {
581
+ const routes = cg
582
+ .getNodesByKind('route')
583
+ .filter((n) => (0, grammars_1.isHarmonyRouteProfileJson)(n.filePath))
584
+ .sort((a, b) => a.filePath.localeCompare(b.filePath) || a.startLine - b.startLine);
585
+ if (routes.length === 0)
586
+ return null;
587
+ const byFile = new Map();
588
+ for (const r of routes) {
589
+ const fp = r.filePath.replace(/\\/g, '/');
590
+ const list = byFile.get(fp) ?? [];
591
+ list.push(r);
592
+ byFile.set(fp, list);
593
+ }
594
+ const lines = [
595
+ '**Registration sources** (indexed Harmony route profiles — evidence from these files; do not re-Read unless editing)',
596
+ ];
597
+ let rows = 0;
598
+ for (const [fp, list] of byFile) {
599
+ lines.push(`- \`${fp}\``);
600
+ for (const r of list) {
601
+ if (rows >= maxRows) {
602
+ lines.push(` - … +more routes`);
603
+ return lines.join('\n');
604
+ }
605
+ const page = typeof r.signature === 'string'
606
+ ? /pageSourceFile=([^;]+)/.exec(r.signature)?.[1]?.trim()
607
+ : undefined;
608
+ const builder = typeof r.signature === 'string'
609
+ ? /buildFunction=([^;]+)/.exec(r.signature)?.[1]?.trim()
610
+ : undefined;
611
+ if (page) {
612
+ lines.push(` - \`${r.name}\` → \`${page}\`${builder ? ` (${builder})` : ''} @${fp}:${r.startLine}`);
613
+ }
614
+ else {
615
+ lines.push(` - \`${r.name}\` @${fp}:${r.startLine}`);
616
+ }
617
+ rows++;
618
+ }
619
+ }
620
+ return lines.join('\n');
621
+ }
622
+ /**
623
+ * Spec 0041 / 0048 §3 — short Resource hits table for element/string.json constants.
624
+ * Spec 0048: append up to 2 in-repo `.ets` binding anchors when signature/docstring
625
+ * mention `app.string.<key>`.
626
+ */
627
+ function formatHarmonyResourceHits(cg, query, maxRows = 8) {
628
+ const constants = cg
629
+ .getNodesByKind('constant')
630
+ .filter((n) => (0, grammars_1.isHarmonyElementStringJson)(n.filePath));
631
+ if (constants.length === 0)
632
+ return null;
633
+ const q = query.trim();
634
+ const namedFile = /string\.json/i.test(q);
635
+ const tokens = [
636
+ ...new Set(q
637
+ .split(/[\s,;|]+/)
638
+ .map((t) => t.trim())
639
+ .filter((t) => t.length >= 2 && t.length <= 160 && !/^string\.json$/i.test(t))),
640
+ ].slice(0, 12);
641
+ const matched = constants.filter((n) => {
642
+ if (namedFile && tokens.length === 0)
643
+ return true;
644
+ const hay = `${n.name}\n${n.qualifiedName ?? ''}\n${n.docstring ?? ''}\n${n.signature ?? ''}`.toLocaleLowerCase();
645
+ return tokens.some((t) => hay.includes(t.toLocaleLowerCase()));
646
+ });
647
+ const rows = (matched.length > 0 ? matched : namedFile ? constants : [])
648
+ .sort((a, b) => a.filePath.localeCompare(b.filePath) || a.startLine - b.startLine)
649
+ .slice(0, maxRows);
650
+ if (rows.length === 0)
651
+ return null;
652
+ const projectRoot = cg.getProjectRoot();
653
+ const etsFiles = cg.getFiles()
654
+ .map((f) => f.path.replace(/\\/g, '/'))
655
+ .filter((p) => /\.ets$/i.test(p))
656
+ .slice(0, 80);
657
+ const findBindings = (key, stringFile) => {
658
+ const needle = `app.string.${key}`;
659
+ const hits = [];
660
+ const modRoot = (() => {
661
+ const marker = '/src/main/';
662
+ const i = stringFile.indexOf(marker);
663
+ return i >= 0 ? stringFile.slice(0, i) : '';
664
+ })();
665
+ for (const rel of etsFiles) {
666
+ if (hits.length >= 2)
667
+ break;
668
+ if (modRoot && !(rel === modRoot || rel.startsWith(`${modRoot}/`)))
669
+ continue;
670
+ const abs = (0, path_1.join)(projectRoot, rel);
671
+ if (!(0, fs_1.existsSync)(abs))
672
+ continue;
673
+ let text;
674
+ try {
675
+ text = (0, fs_1.readFileSync)(abs, 'utf-8');
676
+ }
677
+ catch {
678
+ continue;
679
+ }
680
+ const lines = text.split(/\n/);
681
+ for (let i = 0; i < lines.length; i++) {
682
+ if (hits.length >= 2)
683
+ break;
684
+ if (lines[i].includes(needle)) {
685
+ hits.push(`\`${rel}:${i + 1}\``);
686
+ }
687
+ }
688
+ }
689
+ return hits;
690
+ };
691
+ const lines = [
692
+ '**Resource hits** (element/string.json — searchable literals, no graph edges)',
693
+ ];
694
+ for (const n of rows) {
695
+ const fp = n.filePath.replace(/\\/g, '/');
696
+ const value = (n.docstring ?? '').slice(0, 80);
697
+ const shown = value ? JSON.stringify(value) : '(empty)';
698
+ const bounds = findBindings(n.name, fp);
699
+ const boundNote = bounds.length ? ` · bound ${bounds.join(', ')}` : '';
700
+ lines.push(`- \`${fp}:${n.startLine}\` — ${shown} → \`${n.name}\`${boundNote} · Grep \`$r('app.string.${n.name}')\` in \`.ets\``);
701
+ }
702
+ if ((matched.length || constants.length) > rows.length) {
703
+ lines.push(`- … +more string resources`);
704
+ }
705
+ return lines.join('\n');
706
+ }
707
+ const FORM_QUERY_RE = /form_config|FormExtension|服务卡片|卡片/i;
708
+ const SHORTCUT_QUERY_RE = /shortcuts_config|\bshortcuts\b|快捷方式|长按|快捷入口/i;
709
+ /**
710
+ * Spec 0048 §1–2 — Capability profiles table, or form negative evidence.
711
+ */
712
+ function formatHarmonyCapabilityProfiles(cg, query, maxRows = 8) {
713
+ const wantForm = FORM_QUERY_RE.test(query);
714
+ const wantShortcut = SHORTCUT_QUERY_RE.test(query);
715
+ if (!wantForm && !wantShortcut)
716
+ return null;
717
+ const constants = cg.getNodesByKind('constant');
718
+ const routes = cg.getNodesByKind('route');
719
+ const formNodes = [
720
+ ...constants.filter((n) => (0, grammars_1.isHarmonyCapabilityProfileJson)(n.filePath)
721
+ && /form_config\.json$/i.test(n.filePath.replace(/\\/g, '/'))),
722
+ ...constants.filter((n) => (n.qualifiedName ?? '').includes('harmony.capability.form')),
723
+ ...routes.filter((n) => (n.name ?? '').startsWith('formAbility:')),
724
+ ];
725
+ const shortcutNodes = [
726
+ ...constants.filter((n) => (0, grammars_1.isHarmonyCapabilityProfileJson)(n.filePath)
727
+ && /shortcuts_config\.json$/i.test(n.filePath.replace(/\\/g, '/'))),
728
+ ...constants.filter((n) => (n.qualifiedName ?? '').includes('harmony.capability.shortcut')
729
+ || (n.qualifiedName ?? '').includes('harmony.shortcut.')),
730
+ ];
731
+ const lines = [];
732
+ if (wantForm && formNodes.length === 0) {
733
+ lines.push('**Capability profiles:** No in-repo form_config / FormExtensionAbility (do not treat SDK `.d.ts` as project wiring).');
734
+ }
735
+ const rows = [];
736
+ const pushRows = (kind, nodes) => {
737
+ for (const n of nodes.sort((a, b) => a.filePath.localeCompare(b.filePath) || a.startLine - b.startLine)) {
738
+ if (rows.length >= maxRows)
739
+ break;
740
+ const fp = n.filePath.replace(/\\/g, '/');
741
+ rows.push(`- (${kind}) \`${n.name}\` @\`${fp}:${n.startLine}\`${n.signature ? ` — ${n.signature}` : ''}`);
742
+ }
743
+ };
744
+ if (wantForm)
745
+ pushRows('form', formNodes);
746
+ if (wantShortcut)
747
+ pushRows('shortcut', shortcutNodes);
748
+ if (rows.length > 0) {
749
+ lines.push('**Capability profiles** (form_config / shortcuts_config — paths only, no UI edges)');
750
+ lines.push(...rows);
751
+ }
752
+ return lines.length ? lines.join('\n') : null;
753
+ }
754
+ /** Spec 0048 §5 — detect empty / log-only method bodies. */
755
+ function isHarmonyStubBody(source) {
756
+ const body = source
757
+ .replace(/\/\*[\s\S]*?\*\//g, '')
758
+ .replace(/\/\/[^\n]*/g, '')
759
+ .trim();
760
+ if (!body)
761
+ return true;
762
+ const lines = body.split(/\n/).map((l) => l.trim()).filter(Boolean);
763
+ if (lines.length === 0)
764
+ return true;
765
+ if (lines.length > 8)
766
+ return false;
767
+ const code = lines.join('\n');
768
+ // Strip common wrapper braces from a method slice.
769
+ const inner = code.replace(/^[^{]*\{/, '').replace(/\}[^}]*$/, '').trim();
770
+ const stmts = inner.split(/;|\n/).map((s) => s.trim()).filter(Boolean);
771
+ if (stmts.length === 0)
772
+ return true;
773
+ return stmts.every((s) => /^(return(\s+[^;]*)?|hilog\.[a-zA-Z]+\s*\(|console\.(log|info|warn|error|debug)\s*\(|\/\/)/i.test(s)
774
+ || s === '{'
775
+ || s === '}');
776
+ }
777
+ /**
778
+ * Spec 0048 §5 — seam notes for located explore anchors.
779
+ */
780
+ function formatHarmonySeamNotes(cg, projectRoot, located, maxAnchors = 6) {
781
+ if (!located.length)
782
+ return null;
783
+ const lines = [];
784
+ const capabilityFiles = new Set([
785
+ ...cg.getNodesByKind('constant').filter((n) => (0, grammars_1.isHarmonyCapabilityProfileJson)(n.filePath)),
786
+ ...cg.getNodesByKind('route').filter((n) => (0, grammars_1.isHarmonyRouteProfileJson)(n.filePath)),
787
+ ].map((n) => n.filePath.replace(/\\/g, '/')));
788
+ for (const loc of located.slice(0, maxAnchors)) {
789
+ const fp = loc.filePath.replace(/\\/g, '/');
790
+ const modRoot = (() => {
791
+ const marker = '/src/main/';
792
+ const i = fp.indexOf(marker);
793
+ return i >= 0 ? fp.slice(0, i) : fp.includes('/') ? fp.slice(0, fp.lastIndexOf('/')) : '';
794
+ })();
795
+ let profileNote = 0;
796
+ for (const cap of capabilityFiles) {
797
+ if (profileNote >= 2)
798
+ break;
799
+ if (modRoot && (cap === modRoot || cap.startsWith(`${modRoot}/`))) {
800
+ lines.push(`- profile near \`${loc.name}\`: \`${cap}\``);
801
+ profileNote++;
802
+ }
803
+ }
804
+ try {
805
+ const node = loc.id ? cg.getNode(loc.id) : null;
806
+ const start = node?.startLine ?? loc.startLine;
807
+ const end = node?.endLine ?? start + 12;
808
+ const abs = (0, path_1.join)(projectRoot, fp);
809
+ if ((0, fs_1.existsSync)(abs)) {
810
+ const content = (0, fs_1.readFileSync)(abs, 'utf-8');
811
+ const allLines = content.split(/\n/);
812
+ const slice = allLines.slice(Math.max(0, start - 1), Math.min(allLines.length, end)).join('\n');
813
+ if (isHarmonyStubBody(slice)) {
814
+ lines.push(`- stub: \`${loc.name}\` @\`${fp}:${start}\``);
815
+ }
816
+ }
817
+ }
818
+ catch {
819
+ /* omit */
820
+ }
821
+ if (lines.length >= maxAnchors * 3)
822
+ break;
823
+ }
824
+ if (lines.length === 0)
825
+ return null;
826
+ return ['**Seam notes** (config paths / empty stubs — digests still count as Read)', ...lines.slice(0, 18)].join('\n');
827
+ }
568
828
  /**
569
829
  * Per-file staleness banner emitted at the top of a tool response when the
570
830
  * file watcher has pending events for files referenced by the response.
@@ -648,10 +908,11 @@ const READ_ONLY_ANNOTATIONS = {
648
908
  openWorldHint: false,
649
909
  };
650
910
  /**
651
- * All HomeGraph MCP tools
911
+ * All HomeGraph MCP tool definitions (handlers stay registered).
652
912
  *
653
- * Prefer the smallest tool that answers: callers/node for one named symbol,
654
- * explore for multi-file flows. Skip HomeGraph entirely for topic file-lists,
913
+ * Default tools/list is the slim pair in `DEFAULT_MCP_TOOL_SHORT_NAMES`
914
+ * (explore / project). Opt into the full catalog with
915
+ * `HOMEGRAPH_MCP_TOOLS=all`. Skip HomeGraph entirely for topic file-lists,
655
916
  * concept compares, SDK catalogs, and literal greps.
656
917
  *
657
918
  * All tools support cross-project queries via the optional `projectPath` parameter.
@@ -921,11 +1182,11 @@ exports.tools = [
921
1182
  {
922
1183
  name: 'homegraph_explore',
923
1184
  description: 'Optional graph evidence for a concrete unresolved cross-symbol mechanism in THIS repo. Required: `query`. ' +
1185
+ 'For module/route-profile **paths** and engineering overview use homegraph_project first (not this tool). ' +
924
1186
  'Use ordinary bash/search/read for paths, symbols, literal strings and local changes; continue editing when that evidence suffices. ' +
925
1187
  'Do not call for routine pre-edit orientation or merely because implementation is difficult. ' +
926
- 'For a missing usage, dependency/cycle or native-registration relation, use ' +
927
- 'homegraph_usages, homegraph_modules, or homegraph_native instead. ' +
928
- 'Returns call paths and compact line-numbered source. ArkTS symbol evidence uses complete declarations and bounded directed paths with intermediate source dependencies; explicit Gaps and stop reasons name omitted or unverified evidence. Qualify ambiguous symbols by owning type or file. State the missing relation with known anchors, requested action, scope and constraints; taskContext can carry the full task. ' +
1188
+ 'Located ArkTS code may include render scope, imported types, module configuration, indexed SDK signatures and control-state gaps. ' +
1189
+ 'Returns call paths and compact line-numbered source (Harmony route_map queries may lead with Registration sources; form/shortcuts queries may lead with Capability profiles; element/string.json literals may lead with Resource hits + optional bound .ets anchors; Seam notes may flag stubs). ArkTS symbol evidence uses complete declarations and bounded directed paths with intermediate source dependencies; explicit Gaps and stop reasons name omitted or unverified evidence. Qualify ambiguous symbols by owning type or file. State the missing relation with known anchors, requested action, scope and constraints; taskContext can carry the full task. ' +
929
1190
  'Reuse unchanged complete ranges; refresh missing, edited or truncated evidence. ' +
930
1191
  'No new evidence → change to a targeted source inspection, not a paraphrased explore. ' +
931
1192
  'Partial/busy → at most one focused recovery for the named gap; budgets are ceilings, not required calls. ' +
@@ -937,7 +1198,7 @@ exports.tools = [
937
1198
  type: 'string',
938
1199
  description: 'Required. For pre-edit orientation or how/mechanism: pass the user task or domain keywords (page/module/feature words). ' +
939
1200
  'For named flows, include Type / Type.member / component names. For @kit mechanism/flow, include module/export tokens; ' +
940
- 'use homegraph_usages for a narrow import/usage inventory, not SDK catalogs.',
1201
+ 'not SDK feature catalogs.',
941
1202
  },
942
1203
  taskContext: {
943
1204
  type: 'string',
@@ -967,9 +1228,12 @@ exports.tools = [
967
1228
  },
968
1229
  {
969
1230
  name: 'homegraph_project',
970
- description: 'Shallow project map: modules + files per module (no symbols/call edges). ' +
971
- 'PRIMARY for engineering overview while the full index is still building; also useful after full index. ' +
972
- 'Optional `module` filters by name/path; `includeFiles` defaults true.',
1231
+ description: 'Shallow engineering map: modules + per-module files. On Harmony repos also prints skeleton pointers ' +
1232
+ '(bundleName from app.json5, modules from build-profile.json5, per-module route_map/router_map/main_pages paths, oh-package name) ' +
1233
+ 'and a Module roster (local file: deps) plus bounded HarmonyOS resources path inventory (string.json / capability profiles form_config|shortcuts_config / rawfile / media dirs / on-disk modules not in the graph). ' +
1234
+ 'GIVES navigation only — NOT symbol bodies, call graphs, or JSON/file contents (use homegraph_explore for route→page / Resource hits / Capability profiles; Read to edit). ' +
1235
+ 'PRIMARY overview while the full index is still building; also useful after full index. ' +
1236
+ 'Optional `module` filters by name/path; `includeFiles` defaults true (resources section still prints when false).',
973
1237
  inputSchema: {
974
1238
  type: 'object',
975
1239
  properties: {
@@ -1156,6 +1420,28 @@ function withRequiredProjectPath(defs) {
1156
1420
  };
1157
1421
  });
1158
1422
  }
1423
+ /**
1424
+ * Default MCP tools/list surface (product slim). Handlers for other tools remain
1425
+ * in-tree; restore the full catalog with `HOMEGRAPH_MCP_TOOLS=all` (or `*`), or
1426
+ * name a comma list (e.g. `explore,node,search,arkui_migrate`).
1427
+ */
1428
+ exports.DEFAULT_MCP_TOOL_SHORT_NAMES = ['explore', 'project'];
1429
+ /** Resolve the exposed-tool allowlist from env (default = product slim pair). */
1430
+ function resolveMcpToolAllowlist(raw = process.env.HOMEGRAPH_MCP_TOOLS) {
1431
+ if (!raw || !raw.trim()) {
1432
+ return new Set(exports.DEFAULT_MCP_TOOL_SHORT_NAMES);
1433
+ }
1434
+ const trimmed = raw.trim();
1435
+ if (trimmed === '*' || /^all$/i.test(trimmed))
1436
+ return 'all';
1437
+ const set = new Set(trimmed.split(',').map((s) => s.trim().replace(/^homegraph_/, '')).filter(Boolean));
1438
+ return set.size ? set : new Set(exports.DEFAULT_MCP_TOOL_SHORT_NAMES);
1439
+ }
1440
+ function filterToolsByAllowlist(defs, allow) {
1441
+ if (allow === 'all')
1442
+ return defs;
1443
+ return defs.filter((t) => allow.has(t.name.replace(/^homegraph_/, '')));
1444
+ }
1159
1445
  /**
1160
1446
  * Allowlist-filtered tool definitions WITHOUT an engine — the static surface the
1161
1447
  * proxy answers `tools/list` with before any project is open. Mirrors
@@ -1163,12 +1449,7 @@ function withRequiredProjectPath(defs) {
1163
1449
  * note in a description only adds once `cg` is loaded; the schemas are static).
1164
1450
  */
1165
1451
  function getStaticTools() {
1166
- const raw = process.env.HOMEGRAPH_MCP_TOOLS ?? process.env.HOMEGRAPH_MCP_TOOLS;
1167
- if (!raw || !raw.trim()) {
1168
- return exports.tools;
1169
- }
1170
- const allow = new Set(raw.split(',').map(s => s.trim().replace(/^homegraph_/, '').replace(/^homegraph_/, '')).filter(Boolean));
1171
- return allow.size ? exports.tools.filter(t => allow.has(t.name.replace(/^homegraph_/, ''))) : exports.tools;
1452
+ return filterToolsByAllowlist(exports.tools, resolveMcpToolAllowlist());
1172
1453
  }
1173
1454
  /** Prose that reads like an identifier but never names a symbol worth scanning. */
1174
1455
  const BARE_USAGE_STOPWORDS = new Set([
@@ -1326,6 +1607,10 @@ class ToolHandler {
1326
1607
  // The directory the server last searched for a default project. Surfaced in
1327
1608
  // the "not initialized" error so users can see why detection missed.
1328
1609
  defaultProjectHint = null;
1610
+ // Spec 0043: when a tool `projectPath` would open a different index root than
1611
+ // the session's default bound project, we soft-pin to the default and stash
1612
+ // an English notice to prepend on the success reply (consumed once).
1613
+ boundProjectPathPinNotice = null;
1329
1614
  // Per-start-path cache of the git worktree/index mismatch (issue #155). The
1330
1615
  // mismatch is a fixed property of (where the request came from → which
1331
1616
  // .homegraph/ it resolves to), so the up-to-two `git rev-parse` spawns run
@@ -1428,26 +1713,20 @@ class ToolHandler {
1428
1713
  return this.cg !== null;
1429
1714
  }
1430
1715
  /**
1431
- * Optional allowlist of exposed tools, parsed from the HOMEGRAPH_MCP_TOOLS
1432
- * env var (comma-separated short names, e.g. "explore,search,node").
1433
- * Unset/empty → every tool is exposed. Set → only the listed tools are
1434
- * exposed. Lets an operator (or an A/B harness) trim the tool surface
1435
- * without rebuilding the client config; the ablated tool is then truly
1436
- * absent from ListTools rather than merely denied on call.
1716
+ * Optional allowlist of exposed tools, parsed from HOMEGRAPH_MCP_TOOLS.
1717
+ * Unset/empty → product slim default (`explore`, `project`).
1718
+ * `all` / `*` → full catalog. Comma list → only those short names.
1437
1719
  * Matching is on the short form, so "node" and "homegraph_node" both work.
1438
1720
  */
1439
1721
  toolAllowlist() {
1440
- const raw = process.env.HOMEGRAPH_MCP_TOOLS ?? process.env.HOMEGRAPH_MCP_TOOLS;
1441
- if (!raw || !raw.trim())
1442
- return null;
1443
- const short = (s) => s.trim().replace(/^homegraph_/, '');
1444
- const set = new Set(raw.split(',').map(short).filter(Boolean));
1445
- return set.size ? set : null;
1722
+ return resolveMcpToolAllowlist();
1446
1723
  }
1447
- /** Whether a tool name passes the HOMEGRAPH_MCP_TOOLS allowlist (if any). */
1724
+ /** Whether a tool name passes the HOMEGRAPH_MCP_TOOLS allowlist. */
1448
1725
  isToolAllowed(name) {
1449
1726
  const allow = this.toolAllowlist();
1450
- return !allow || allow.has(name.replace(/^homegraph_/, ''));
1727
+ if (allow === 'all')
1728
+ return true;
1729
+ return allow.has(name.replace(/^homegraph_/, ''));
1451
1730
  }
1452
1731
  /**
1453
1732
  * Get tool definitions with dynamic descriptions based on project size.
@@ -1457,11 +1736,7 @@ class ToolHandler {
1457
1736
  */
1458
1737
  getTools() {
1459
1738
  const allow = this.toolAllowlist();
1460
- // No explicit allowlist → expose every defined tool. An allowlist trims
1461
- // the surface to only the listed short names.
1462
- let visible = allow
1463
- ? exports.tools.filter(t => allow.has(t.name.replace(/^homegraph_/, '')))
1464
- : exports.tools;
1739
+ let visible = filterToolsByAllowlist(exports.tools, allow);
1465
1740
  // No default project loaded → no-root-index case (#993): a gateway server
1466
1741
  // started outside any repo, or a monorepo root whose indexes live in
1467
1742
  // sub-projects. With nothing to fall back to, EVERY call needs an explicit
@@ -1476,29 +1751,13 @@ class ToolHandler {
1476
1751
  return withRequiredProjectPath(visible);
1477
1752
  try {
1478
1753
  const stats = this.cg.getStats();
1479
- // Tiny-repo tool gating: on projects under TINY_REPO_FILE_THRESHOLD
1480
- // files, only expose the core trio (search, node, explore) — one
1481
- // below even the 4-tool default: at this scale callers, too, reduces
1482
- // to one grep. (Historical note: the audit below ran when context and
1483
- // trace still existed; its "5 core tools" are today's trio.)
1484
- //
1485
- // n=2 audits ruled out cutting below 5 tools:
1486
- // - 3-tool gate (search + context + trace): cost regressed on
1487
- // cobra/ky/sinatra. The agent fell back to raw Reads to cover
1488
- // what homegraph_node + homegraph_explore would have answered.
1489
- // - 1-tool gate (search only): catastrophic regression — express
1490
- // went from -43% WIN to +107% LOSS. With only search, the agent
1491
- // can't navigate the call graph structurally and reads everything.
1754
+ // Tiny-repo tool gating applies only when the host asked for the full
1755
+ // catalog (`HOMEGRAPH_MCP_TOOLS=all`). The product default is already the
1756
+ // slim pair; an explicit comma allowlist is left untouched.
1492
1757
  //
1493
- // 5 is the empirical lower bound. Tools beyond search/context/
1494
- // node/explore/trace pay overhead that the agent doesn't recoup
1495
- // on tiny-repo flow questions.
1496
- // ITER4: raise threshold 150 → 500 so single-file frameworks
1497
- // (sinatra at 159, slim_framework around 200) also get the
1498
- // 5-tool surface. The empirical 5-tool floor was set on <150
1499
- // probes; iter3 measurement showed sinatra is structurally the
1500
- // SAME problem as cobra (single-file WITHOUT-arm Read wins),
1501
- // so it deserves the same gating.
1758
+ // Historical note: n=2 audits ruled out cutting below ~5 tools on the
1759
+ // *full* catalog for tiny OSS repos (search+node+explore+…). That trim
1760
+ // still matters when someone opts into `all` on a <500-file tree.
1502
1761
  const TINY_REPO_FILE_THRESHOLD = 500;
1503
1762
  const TINY_REPO_CORE_TOOLS = new Set([
1504
1763
  'homegraph_explore',
@@ -1507,9 +1766,9 @@ class ToolHandler {
1507
1766
  'homegraph_diff_impact',
1508
1767
  'homegraph_project',
1509
1768
  ]);
1510
- // An explicit host selection overrides the default size-based surface.
1511
- // Otherwise small ArkTS repos silently lose requested specialized tools.
1512
- if (!allow && stats.fileCount < TINY_REPO_FILE_THRESHOLD) {
1769
+ const envRaw = (process.env.HOMEGRAPH_MCP_TOOLS ?? '').trim();
1770
+ const askedForFullCatalog = allow === 'all' && (/^all$/i.test(envRaw) || envRaw === '*');
1771
+ if (askedForFullCatalog && stats.fileCount < TINY_REPO_FILE_THRESHOLD) {
1513
1772
  visible = visible.filter(t => TINY_REPO_CORE_TOOLS.has(t.name));
1514
1773
  }
1515
1774
  return visible.map(tool => {
@@ -1580,6 +1839,11 @@ class ToolHandler {
1580
1839
  *
1581
1840
  * Walks up parent directories to find the nearest .homegraph/ folder,
1582
1841
  * similar to how git finds .git/ directories.
1842
+ *
1843
+ * Spec 0043: when a default project is already bound, a `projectPath` whose
1844
+ * resolved index root differs from that bound root is soft-pinned back to
1845
+ * the default (no DB switch) and a success-shaped English notice is stashed
1846
+ * for the reply preamble.
1583
1847
  */
1584
1848
  getHomeGraph(projectPath) {
1585
1849
  const sourcesMode = (0, graph_sources_1.resolveGraphSources)();
@@ -1626,25 +1890,34 @@ class ToolHandler {
1626
1890
  // (#926). The DB connection itself is still cached (by resolved root,
1627
1891
  // below), so re-resolving costs only the stat walk, never a reopen.
1628
1892
  const resolvedRoot = (0, directory_1.findNearestHomeGraphRoot)(projectPath);
1893
+ // Spec 0043: with a bound default root, never switch the graph to a
1894
+ // different index (sibling / parent / wrong absolute path). Soft-pin and
1895
+ // stash a notice; security refusals above still win.
1896
+ if (this.cg) {
1897
+ const boundRoot = (0, path_1.resolve)(this.cg.getProjectRoot());
1898
+ const resolvedAbs = resolvedRoot ? (0, path_1.resolve)(resolvedRoot) : null;
1899
+ if (resolvedAbs === null || resolvedAbs !== boundRoot) {
1900
+ this.boundProjectPathPinNotice = (0, index_availability_1.formatBoundProjectPathPinNotice)({
1901
+ boundRoot,
1902
+ requestedPath: projectPath,
1903
+ resolvedRoot: resolvedAbs,
1904
+ });
1905
+ return this.freshen(this.cg);
1906
+ }
1907
+ // Same index root (incl. nested path under the bound project) — reuse the
1908
+ // default instance so we never open a second connection to the same DB
1909
+ // (#238).
1910
+ return this.freshen(this.cg);
1911
+ }
1629
1912
  if (!resolvedRoot) {
1630
1913
  throw new NotIndexedError(`The project at ${projectPath} isn't indexed with homegraph (no .homegraph/ directory found ` +
1631
1914
  'walking up from it), so homegraph cannot query it. Use your built-in tools (Read/Grep/Glob) ' +
1632
1915
  "for that codebase instead, and don't call homegraph for it again this session. " +
1633
1916
  "Indexing is the user's decision — they can run 'homegraph init' in that project to enable it.");
1634
1917
  }
1635
- // If the path resolves to the default project, reuse the already-open
1636
- // default instance rather than opening a SECOND connection to the same DB.
1637
- // A duplicate connection serializes reads against the watcher's auto-sync
1638
- // writes; when WAL isn't in effect (e.g. a filesystem without shared-memory
1639
- // support) that surfaces as intermittent
1640
- // "database is locked" on concurrent tool calls. See issue #238. The
1641
- // default instance is owned/closed by the server, so it's never cached.
1642
- if (this.cg && this.cg.getProjectRoot() === resolvedRoot) {
1643
- return this.freshen(this.cg);
1644
- }
1645
- // Cache the open DB connection by RESOLVED ROOT only — never by the input
1646
- // path. One key per instance means closeAll() closes each exactly once, and
1647
- // a changed resolution maps to a different entry instead of a stale hit.
1918
+ // No default project — cross-project open (cached by resolved root).
1919
+ // One key per instance means closeAll() closes each exactly once, and a
1920
+ // changed resolution maps to a different entry instead of a stale hit.
1648
1921
  const cached = this.projectCache.get(resolvedRoot);
1649
1922
  if (cached)
1650
1923
  return this.freshen(cached);
@@ -1889,6 +2162,15 @@ class ToolHandler {
1889
2162
  catch {
1890
2163
  reason = null;
1891
2164
  }
2165
+ try {
2166
+ (0, runtime_log_1.logLifecycle)('watcher.degraded', {
2167
+ projectRoot: cg.getProjectRoot(),
2168
+ reason: reason ?? undefined,
2169
+ });
2170
+ }
2171
+ catch {
2172
+ /* ignore */
2173
+ }
1892
2174
  const composed = `${formatDegradedBanner(reason)}\n\n${head.text}`;
1893
2175
  return { ...result, content: [{ type: 'text', text: composed }, ...tail] };
1894
2176
  }
@@ -1898,8 +2180,9 @@ class ToolHandler {
1898
2180
  return result;
1899
2181
  }
1900
2182
  /**
1901
- * Append a one-line product index status footer (Spec 0035).
1902
- * Guidance-only replies that already start with `HomeGraph status=` are left alone.
2183
+ * Decorate successful tool text (Spec 0035 status footer + Spec 0038 project-root hint
2184
+ * + Spec 0043 bound projectPath pin notice).
2185
+ * Prepends absolute project root + join guidance when known; appends status when missing.
1903
2186
  */
1904
2187
  withProductStatusFooter(result, projectPath) {
1905
2188
  if (result.isError)
@@ -1907,8 +2190,6 @@ class ToolHandler {
1907
2190
  const [first, ...rest] = result.content;
1908
2191
  if (!first || first.type !== 'text')
1909
2192
  return result;
1910
- if ((0, index_availability_1.textAlreadyHasProductStatus)(first.text))
1911
- return result;
1912
2193
  let cg;
1913
2194
  try {
1914
2195
  cg = this.getHomeGraph(projectPath);
@@ -1926,26 +2207,52 @@ class ToolHandler {
1926
2207
  /* leave cg */
1927
2208
  }
1928
2209
  }
1929
- let state;
1930
- try {
1931
- state = (0, index_availability_1.resolveProductIndexState)(cg);
2210
+ let text = first.text;
2211
+ // Spec 0038: absolute project root + how to join repo-relative paths (idempotent).
2212
+ if (!(0, index_availability_1.textAlreadyHasProjectRootHint)(text)) {
2213
+ try {
2214
+ const absRoot = (0, path_1.resolve)(cg.getProjectRoot());
2215
+ if (absRoot) {
2216
+ text = `${(0, index_availability_1.formatProjectRootPathHint)(absRoot)}\n\n${text}`;
2217
+ }
2218
+ }
2219
+ catch {
2220
+ /* no root — skip hint */
2221
+ }
1932
2222
  }
1933
- catch {
1934
- return result;
2223
+ // Spec 0043: bound-root soft-pin notice at the very top (consume once).
2224
+ const pinNotice = this.boundProjectPathPinNotice;
2225
+ this.boundProjectPathPinNotice = null;
2226
+ if (pinNotice && !(0, index_availability_1.textAlreadyHasBoundProjectPathPinNotice)(text)) {
2227
+ text = `${pinNotice}\n\n${text}`;
1935
2228
  }
1936
- let pendingPaths;
1937
- if (state === 'dirty') {
2229
+ // Spec 0035: one-line product index status footer (skip if already present).
2230
+ if (!(0, index_availability_1.textAlreadyHasProductStatus)(text)) {
2231
+ let state;
1938
2232
  try {
1939
- pendingPaths = (cg.getPendingFiles?.() ?? []).map((p) => p.path);
2233
+ state = (0, index_availability_1.resolveProductIndexState)(cg);
1940
2234
  }
1941
2235
  catch {
1942
- pendingPaths = [];
2236
+ return {
2237
+ ...result,
2238
+ content: [{ type: 'text', text }, ...rest],
2239
+ };
1943
2240
  }
2241
+ let pendingPaths;
2242
+ if (state === 'dirty') {
2243
+ try {
2244
+ pendingPaths = (cg.getPendingFiles?.() ?? []).map((p) => p.path);
2245
+ }
2246
+ catch {
2247
+ pendingPaths = [];
2248
+ }
2249
+ }
2250
+ const line = (0, index_availability_1.formatProductStatusLine)(state, { pendingPaths });
2251
+ text = `${text}\n\n${line}`;
1944
2252
  }
1945
- const line = (0, index_availability_1.formatProductStatusLine)(state, { pendingPaths });
1946
2253
  return {
1947
2254
  ...result,
1948
- content: [{ type: 'text', text: `${first.text}\n\n${line}` }, ...rest],
2255
+ content: [{ type: 'text', text }, ...rest],
1949
2256
  };
1950
2257
  }
1951
2258
  /**
@@ -1956,10 +2263,47 @@ class ToolHandler {
1956
2263
  */
1957
2264
  async execute(toolName, args, sessionState) {
1958
2265
  const requestStartedAt = Date.now();
2266
+ const result = await this.executeCore(toolName, args, sessionState, requestStartedAt);
2267
+ this.traceToolCall(toolName, args, result, requestStartedAt);
2268
+ return result;
2269
+ }
2270
+ /** Spec 0046: HOMEGRAPH_DEBUG tool summary → stderr + daemon.log. */
2271
+ traceToolCall(toolName, args, result, requestStartedAt) {
2272
+ try {
2273
+ let projectRoot;
2274
+ try {
2275
+ projectRoot = this.getHomeGraph(args.projectPath).getProjectRoot();
2276
+ }
2277
+ catch {
2278
+ projectRoot = typeof args.projectPath === 'string' ? args.projectPath : undefined;
2279
+ }
2280
+ const q = typeof args.query === 'string'
2281
+ ? args.query
2282
+ : typeof args.symbol === 'string'
2283
+ ? args.symbol
2284
+ : undefined;
2285
+ const evidenceStatus = result._meta?.homegraphEvidence?.status
2286
+ ?? result[explore_session_state_1.EXPLORE_EMISSION_KEY]?.evidenceStatus;
2287
+ (0, runtime_log_1.logToolDebug)(toolName, {
2288
+ projectRoot,
2289
+ durationMs: Date.now() - requestStartedAt,
2290
+ isError: !!result.isError,
2291
+ evidenceStatus,
2292
+ query: q,
2293
+ });
2294
+ }
2295
+ catch {
2296
+ /* logging never fails the tool */
2297
+ }
2298
+ }
2299
+ async executeCore(toolName, args, sessionState, requestStartedAt) {
1959
2300
  args = { ...args };
1960
2301
  for (const key of [QUERY_PLAN_ARG, QUERY_DEADLINE_ARG, QUERY_STARTED_ARG, QUERY_INDEX_STATE_ARG, QUERY_FAST_ATTEMPTED_ARG, '_hgEvidenceMaxChars'])
1961
2302
  delete args[key];
1962
2303
  try {
2304
+ // Spec 0043: drop any leftover pin notice from a prior call that exited
2305
+ // before withProductStatusFooter (gate / defer / refuse).
2306
+ this.boundProjectPathPinNotice = null;
1963
2307
  // Block the first tool call on the engine's post-open reconcile so we
1964
2308
  // never serve rows for files deleted/edited while no MCP server was
1965
2309
  // running. The wait is time-boxed (#905): a huge-repo reconcile takes
@@ -2047,7 +2391,8 @@ class ToolHandler {
2047
2391
  // would re-serve the first call's full source and defeat CG-18 dedup.
2048
2392
  const skipCacheForSession = toolName === 'homegraph_explore' && !!sessionState;
2049
2393
  const requestPlan = readQueryPlan(args);
2050
- const cacheEnabled = !skipCacheForSession && requestPlan?.source !== 'llm'
2394
+ const cacheEnabled = !skipCacheForSession && !(toolName === 'homegraph_explore' && process.env.HOMEGRAPH_ARKTS_EVIDENCE_LOG === '1') && requestPlan?.source !== 'llm'
2395
+ && !(requestPlan?.requestContract && ((0, request_contract_1.accuracyTargetsEnabled)() || (0, request_contract_1.accuracyCoverageEnabled)()))
2051
2396
  && !requestPlan?.telemetry.fallbackReason && (0, query_cache_1.isMcpQueryCacheEnabled)() && (0, query_cache_1.isCacheableMcpTool)(toolName);
2052
2397
  let cacheKey;
2053
2398
  let cacheQueries;
@@ -2069,7 +2414,7 @@ class ToolHandler {
2069
2414
  const cached = cacheIndex.getEntry(cacheQueries, cacheKey);
2070
2415
  const packMeta = cached?._meta?.homegraphEvidencePacks;
2071
2416
  const cachedFiles = cached?._meta?.homegraphEvidence?.files;
2072
- if (cached && (!packMeta || (packMeta.status === 'complete'
2417
+ if (cached && (!packMeta || (packMeta.status === 'complete' && !packMeta.implementationContext?.length
2073
2418
  && this.areEvidenceFilesCurrent(cachedFiles ?? [], cacheCg.getProjectRoot())))) {
2074
2419
  const diagnosed = this.withQueryPlanDiagnostics(cached, args, true);
2075
2420
  const withWorktree = this.withWorktreeNotice(diagnosed, projectPath);
@@ -2222,15 +2567,19 @@ class ToolHandler {
2222
2567
  try {
2223
2568
  const cg = this.getHomeGraph(args.projectPath);
2224
2569
  const root = cg.getProjectRoot();
2225
- const decision = (0, explore_repeat_guard_1.decideDepthToolFuse)(sessionState.forProject(root), sessionState.depthToolCount(root), toolName);
2226
- if (!decision.refuse)
2227
- return null;
2228
- const hint = typeof args.symbol === 'string'
2570
+ const prior = sessionState.forProject(root);
2571
+ const symbolHint = typeof args.symbol === 'string'
2229
2572
  ? args.symbol
2230
2573
  : typeof args.file === 'string'
2231
2574
  ? args.file
2232
2575
  : undefined;
2233
- return this.textResult((0, explore_repeat_guard_1.formatDepthToolRefuse)(decision, toolName, hint));
2576
+ // Spec 0044 §10.2: symbols already on the locate list bypass the Partial depth cap.
2577
+ if ((0, locate_contract_1.shouldExemptDepthFuseForLocatedSymbol)(prior, symbolHint))
2578
+ return null;
2579
+ const decision = (0, explore_repeat_guard_1.decideDepthToolFuse)(prior, sessionState.depthToolCount(root), toolName);
2580
+ if (!decision.refuse)
2581
+ return null;
2582
+ return this.textResult((0, explore_repeat_guard_1.formatDepthToolRefuse)(decision, toolName, symbolHint));
2234
2583
  }
2235
2584
  catch {
2236
2585
  return null;
@@ -2302,6 +2651,10 @@ class ToolHandler {
2302
2651
  const last = prior?.calls[prior.calls.length - 1];
2303
2652
  if (!last || (0, explore_session_state_1.inferExploreEvidenceStatus)(last) === 'complete')
2304
2653
  return;
2654
+ const symbolHint = typeof args.symbol === 'string' ? args.symbol : undefined;
2655
+ // Spec 0044 §10.2: located-symbol drills do not consume the Partial depth budget.
2656
+ if ((0, locate_contract_1.shouldExemptDepthFuseForLocatedSymbol)(prior, symbolHint))
2657
+ return;
2305
2658
  sessionState.recordDepthTool(root);
2306
2659
  }
2307
2660
  catch { /* bookkeeping only */ }
@@ -3150,6 +3503,13 @@ class ToolHandler {
3150
3503
  registeredAt,
3151
3504
  };
3152
3505
  }
3506
+ if (m?.synthesizedBy === 'arkts-route-map') {
3507
+ return {
3508
+ label: `Harmony route registration (config → page/builder)`,
3509
+ compact: `route-map registration${at}`,
3510
+ registeredAt,
3511
+ };
3512
+ }
3153
3513
  if (m?.synthesizedBy === 'goframe-route') {
3154
3514
  const route = m.route ? `\`${String(m.route)}\`` : 'a route';
3155
3515
  return {
@@ -5375,7 +5735,7 @@ class ToolHandler {
5375
5735
  ...prior, version: plan.version, source: plan.source, intent: plan.intent, route: plan.route,
5376
5736
  confidence: plan.confidence,
5377
5737
  plannerSeeds: { anchors: plan.anchors, searchTerms: plan.searchTerms, literalTexts: plan.literalTexts,
5378
- sourceScope: plan.sourceScope, relation: plan.relation },
5738
+ sourceScope: plan.sourceScope, relation: plan.relation, requestContract: plan.requestContract },
5379
5739
  hasTaskContext: !!plan.taskContext,
5380
5740
  matchedFeatures: Object.entries(plan.features).filter(([, matched]) => matched).map(([name]) => name).slice(0, 12),
5381
5741
  planningMs: plan.telemetry.durationMs, durationMs: Math.max(plan.telemetry.durationMs, Date.now() - started),
@@ -5400,6 +5760,10 @@ class ToolHandler {
5400
5760
  if (plan.route === 'usages' || plan.route === 'modules' || plan.route === 'native') {
5401
5761
  return this.runSpecializedExploreRoute(plan.route, cg, query, root, plan);
5402
5762
  }
5763
+ // Constrained ArkTS retrieval needs the full, verified-source renderer.
5764
+ if (plan.requestContract && ((0, request_contract_1.accuracyTargetsEnabled)() || (0, request_contract_1.accuracyCoverageEnabled)())
5765
+ && plan.sourceScope !== 'sdk' && cg.getFiles().some(f => /\.ets$/i.test(f.path)))
5766
+ return null;
5403
5767
  // These legacy paths re-extract seeds from text and cannot consume bound
5404
5768
  // node identity / step hints. Model general/flow plans use full explore;
5405
5769
  // rule/default and specialized routes retain their existing fast behavior.
@@ -6260,20 +6624,58 @@ class ToolHandler {
6260
6624
  if (!nodes.some(n => /\.ets$/i.test(n.filePath) && !n.filePath.startsWith('ohos-sdk:')))
6261
6625
  return null;
6262
6626
  const budget = getExploreOutputBudget(cg.getStats().fileCount);
6263
- const queryPaths = process.env.HOMEGRAPH_ARKTS_QUERY_PATHS !== '0';
6627
+ const constrained = !!plan?.requestContract && ((0, request_contract_1.accuracyTargetsEnabled)() || (0, request_contract_1.accuracyCoverageEnabled)());
6628
+ const queryPaths = (!constrained || plan?.intent === 'flow' || !!plan?.bindings?.length) && process.env.HOMEGRAPH_ARKTS_QUERY_PATHS !== '0';
6264
6629
  if (queryPaths)
6265
6630
  nodes = (0, evidence_paths_1.completeEvidencePathCandidates)(query, nodes, (name, limit) => cg.getQueryBuilder().getNodesByQualifiedNameExact(name, limit), plan);
6631
+ if ((0, implementation_context_1.implementationContextEnabled)()) {
6632
+ const boundIds = new Set(focusIds);
6633
+ nodes = nodes.map(n => {
6634
+ if (n.kind !== 'struct')
6635
+ return n;
6636
+ const component = cg.getNodesInFile(n.filePath).find(c => c.kind === 'component' && c.name === n.name && c.startLine === n.startLine);
6637
+ if (component && boundIds.has(n.id))
6638
+ boundIds.add(component.id);
6639
+ return component ?? n;
6640
+ });
6641
+ focusIds = boundIds;
6642
+ }
6266
6643
  const pathSearch = !queryPaths ? undefined : (0, evidence_paths_1.searchEvidencePaths)({
6267
6644
  getNode: id => cg.getNode(id),
6268
- getEdges: (id, direction, kinds, limit, preferred) => cg.getQueryBuilder().getEvidenceEdges(id, direction, kinds, limit, preferred),
6645
+ getEdges: (id, direction, kinds, limit, preferred) => {
6646
+ const read = (nodeId) => cg.getQueryBuilder().getEvidenceEdges(nodeId, direction, kinds, limit, preferred);
6647
+ if (!(0, implementation_context_1.implementationContextEnabled)())
6648
+ return read(id);
6649
+ const owner = cg.getNode(id);
6650
+ const aliases = owner && ['struct', 'component'].includes(owner.kind)
6651
+ ? cg.getNodesInFile(owner.filePath).filter(n => ['struct', 'component'].includes(n.kind) && n.name === owner.name
6652
+ && n.startLine === owner.startLine && n.endLine === owner.endLine).slice(0, 2) : [];
6653
+ const canonical = (nodeId) => {
6654
+ const n = cg.getNode(nodeId);
6655
+ if (!n || n.kind !== 'struct')
6656
+ return nodeId;
6657
+ return cg.getNodesInFile(n.filePath).find(c => c.kind === 'component' && c.name === n.name
6658
+ && c.startLine === n.startLine && c.endLine === n.endLine)?.id ?? nodeId;
6659
+ };
6660
+ // ArkAnalyzer stores struct/component roles for the same declaration. Project
6661
+ // their ownership edges to one identity; this is not a new runtime call.
6662
+ const edges = (aliases.length ? aliases.flatMap(n => read(n.id)) : read(id)).map(e => ({ ...e,
6663
+ source: canonical(e.source), target: canonical(e.target) })).filter(e => e.source !== e.target);
6664
+ return [...new Map(edges.map(e => [JSON.stringify([e.source, e.target, e.kind, e.metadata]), e])).values()].slice(0, limit);
6665
+ },
6269
6666
  }, (0, evidence_paths_1.resolveEvidencePathGoal)(query, nodes, focusIds, plan));
6270
6667
  const result = (0, arkts_evidence_packs_1.buildArktsEvidencePacks)(cg, { projectRoot, query, nodes, focusIds,
6271
- maxChars: Math.min(budget.maxOutputChars, maxChars ?? budget.maxOutputChars), maxFiles, pathSearch });
6668
+ maxChars: Math.min(budget.maxOutputChars, maxChars ?? budget.maxOutputChars), maxFiles, pathSearch, requestContract: plan?.requestContract,
6669
+ sdkModule: cg.getGraphSources() === 'project' || cg.getGraphSources() === 'none' ? undefined : module => {
6670
+ const nodes = cg.getQueryBuilder().getOhosApiModuleNodes(module);
6671
+ return nodes.length ? { nodes, version: cg.getOhosApiBinding()?.version ?? 'unknown' } : undefined;
6672
+ } });
6272
6673
  if (!result)
6273
6674
  return null;
6675
+ const audit = (0, evidence_audit_1.recordEvidencePack)(projectRoot, { query, requestContract: plan?.requestContract }, result);
6274
6676
  // The pack renderer already supplies neutral guidance and exact source bytes.
6275
6677
  return { content: [{ type: 'text', text: result.text }], [explore_session_state_1.EXPLORE_EMISSION_KEY]: result.emission,
6276
- _meta: { homegraphEvidencePacks: result.metadata } };
6678
+ _meta: { homegraphEvidencePacks: result.metadata, ...(audit !== 'disabled' ? { homegraphEvidenceAudit: audit } : {}) } };
6277
6679
  }
6278
6680
  /** Render a compact symbol-bounded slice on the legacy mechanism path. */
6279
6681
  renderLightMechanismSource(projectRoot, filePath, nodes, maxChars) {
@@ -6318,7 +6720,13 @@ class ToolHandler {
6318
6720
  const isTestPath = (p) => /(^|\/)(tests?|spec)\//i.test(p) || /\.(test|spec)\./i.test(p);
6319
6721
  const fileNodes = new Map();
6320
6722
  const seedIds = new Set();
6321
- const domainPathTokens = (0, query_utils_1.mechanismDomainPathTokens)(query);
6723
+ const domainPathTokens = (0, locate_contract_1.shouldSuppressSynonymExpansion)({
6724
+ hasExactAnchorHit: (0, query_utils_1.extractInRepoLocateAnchors)(query).length > 0
6725
+ || (0, query_utils_1.extractTypeNamesFromQuery)(query).length > 0,
6726
+ hasLiteralWitness: false,
6727
+ })
6728
+ ? []
6729
+ : (0, query_utils_1.mechanismDomainPathTokens)(query);
6322
6730
  const addNode = (n) => {
6323
6731
  if ((0, arkts_1.isOhosApiFilePath)(n.filePath))
6324
6732
  return;
@@ -6777,9 +7185,8 @@ class ToolHandler {
6777
7185
  * Static AA class/interface nodes that look like domain Managers/Services.
6778
7186
  */
6779
7187
  collectDomainRoleSymbols(cg, query, domainTokens) {
6780
- const tokens = domainTokens.length > 0
6781
- ? domainTokens
6782
- : (0, query_utils_1.mechanismDomainPathTokens)(query);
7188
+ // Spec 0044 §9: callers may pass [] to suppress CJK→ASCII synonym expansion.
7189
+ const tokens = domainTokens;
6783
7190
  if (tokens.length === 0)
6784
7191
  return [];
6785
7192
  const byId = new Map();
@@ -7226,7 +7633,9 @@ class ToolHandler {
7226
7633
  }
7227
7634
  if (!callersOnly) {
7228
7635
  for (const { node: c } of cg.getCallees(id).slice(0, 10)) {
7229
- // Skip log*/hilog helpers — Export→logInfo homonyms ballooned seeds.
7636
+ if ((0, locate_contract_1.shouldDemoteLogToastSpine)(c.name, query)) {
7637
+ continue;
7638
+ }
7230
7639
  if (/^log(?:Info|Error|Warn|Debug|Fatal)?$/i.test(c.name) || /^hilog$/i.test(c.name)) {
7231
7640
  continue;
7232
7641
  }
@@ -7415,11 +7824,15 @@ class ToolHandler {
7415
7824
  continue;
7416
7825
  trail.push(`- \`${seedNode.name}\` (${seedNode.kind}) — ${seedNode.filePath}:${seedNode.startLine}`);
7417
7826
  for (const c of callers) {
7827
+ if ((0, query_utils_1.isMcpNoiseNode)(c.node))
7828
+ continue;
7418
7829
  const loc = c.node.startLine ? `:${c.node.startLine}` : '';
7419
7830
  trail.push(` ← used by \`${c.node.name}\` (${c.node.kind}) — ${c.node.filePath}${loc}`);
7420
7831
  trailBullets++;
7421
7832
  }
7422
7833
  for (const c of callees) {
7834
+ if ((0, query_utils_1.isMcpNoiseNode)(c.node))
7835
+ continue;
7423
7836
  const loc = c.node.startLine ? `:${c.node.startLine}` : '';
7424
7837
  trail.push(` → calls \`${c.node.name}\` (${c.node.kind}) — ${c.node.filePath}${loc}`);
7425
7838
  trailBullets++;
@@ -9638,6 +10051,8 @@ class ToolHandler {
9638
10051
  const explicitMaxFiles = typeof args.maxFiles === 'number' && !Number.isNaN(args.maxFiles);
9639
10052
  let maxFiles = (0, utils_1.clamp)(args.maxFiles || budget.defaultMaxFiles, 1, 20);
9640
10053
  const queryFileBasenames = (0, query_utils_1.extractFileBasenamesFromQuery)(query);
10054
+ const locateAnchors = (0, query_utils_1.extractInRepoLocateAnchors)(query);
10055
+ const exploreSourceScope = (0, query_utils_1.resolveExploreSourceScope)(query, plan?.sourceScope);
9641
10056
  const interpretationQuery = feature('queryAsInterpretationSurvey', query_utils_1.queryAsInterpretationSurvey);
9642
10057
  const testOnlyInterpretation = feature('queryAsTestOnlyInterpretation', query_utils_1.queryAsTestOnlyInterpretation);
9643
10058
  const crossModuleFlow = feature('queryAsCrossModuleFlowSurvey', query_utils_1.queryAsCrossModuleFlowSurvey) || plan?.intent === 'flow';
@@ -9649,19 +10064,29 @@ class ToolHandler {
9649
10064
  ? `${queryFileBasenames[0]} ${query}`
9650
10065
  : queryFileBasenames.length === 1
9651
10066
  ? `${queryFileBasenames[0]} ${query}`
9652
- : query;
10067
+ : locateAnchors.length === 1
10068
+ ? `${locateAnchors[0]} ${query}`
10069
+ : query;
10070
+ const wantHints = !!(plan && (plan.source === 'llm' || plan.literalTexts?.length || plan.anchors?.length || plan.requestContract))
10071
+ || exploreSourceScope !== 'all';
9653
10072
  const subgraph = await cg.findRelevantContext(contextQuery, {
9654
10073
  ...contextOpts,
9655
- ...(plan && (plan.source === 'llm' || plan.literalTexts?.length) ? { retrievalHints: {
9656
- symbols: plan.anchors.filter((anchor) => !(plan.bindings ?? []).some((node) => anchor === node.name || anchor === node.qualifiedName)),
9657
- searchTerms: plan.searchTerms, literalTexts: plan.literalTexts, sourceScope: plan.sourceScope, nodeIds: (plan.bindings ?? []).map((node) => node.id),
10074
+ ...(wantHints ? { retrievalHints: {
10075
+ symbols: [
10076
+ ...(plan?.anchors ?? []).filter((anchor) => !(plan?.bindings ?? []).some((node) => anchor === node.name || anchor === node.qualifiedName)),
10077
+ ...locateAnchors,
10078
+ ].slice(0, 16),
10079
+ searchTerms: plan?.searchTerms ?? [], literalTexts: [...new Set([...(plan?.literalTexts ?? []),
10080
+ ...((0, request_contract_1.accuracyTargetsEnabled)() && !plan?.bindings?.length ? (0, request_contract_1.contractLiteralTexts)(plan?.requestContract) : [])])].slice(0, 8),
10081
+ sourceScope: exploreSourceScope,
10082
+ nodeIds: (plan?.bindings ?? []).map((node) => node.id),
9658
10083
  } } : {}),
9659
10084
  });
9660
- // Path-first: always seed nodes from an explicit `Foo.ets` basename so a
9661
- // CJK-only ask + path (or a shared prop like showSearchIcon) cannot leave
9662
- // the named file out of the subgraph / digests.
9663
- if (queryFileBasenames.length > 0 && plan?.sourceScope !== 'sdk') {
9664
- for (const base of queryFileBasenames.slice(0, 3)) {
10085
+ // Path-first + Spec 0042 identifier seeding: Foo.ets and PascalCase ≥8 exact hits
10086
+ // become roots so taskContext-polluted FTS cannot drop the named in-repo file.
10087
+ if (exploreSourceScope !== 'sdk') {
10088
+ const seedNames = [...new Set([...queryFileBasenames, ...locateAnchors])].slice(0, 6);
10089
+ for (const base of seedNames) {
9665
10090
  let hits = [];
9666
10091
  try {
9667
10092
  hits = cg.searchNodes(base, { limit: 50 });
@@ -9670,7 +10095,15 @@ class ToolHandler {
9670
10095
  continue;
9671
10096
  }
9672
10097
  for (const r of hits) {
9673
- if (!(0, query_utils_1.fileMatchesQueryBasename)(r.node.filePath, [base]))
10098
+ if ((0, query_utils_1.isMcpNoiseNode)(r.node))
10099
+ continue;
10100
+ const fp = r.node.filePath.replace(/\\/g, '/');
10101
+ const stem = fp.split('/').pop()?.replace(/\.[^.]+$/, '') ?? '';
10102
+ const exactFile = stem.toLowerCase() === base.toLowerCase()
10103
+ || (0, query_utils_1.fileMatchesQueryBasename)(r.node.filePath, [base]);
10104
+ const exactSymbol = r.node.name === base
10105
+ || (r.node.qualifiedName?.split(/::|\./).pop() === base);
10106
+ if (!exactFile && !exactSymbol)
9674
10107
  continue;
9675
10108
  if (!subgraph.nodes.has(r.node.id)) {
9676
10109
  subgraph.nodes.set(r.node.id, r.node);
@@ -9679,12 +10112,48 @@ class ToolHandler {
9679
10112
  }
9680
10113
  }
9681
10114
  }
10115
+ if (plan?.requestContract && ((0, request_contract_1.accuracyTargetsEnabled)() || (0, request_contract_1.accuracyCoverageEnabled)()) && plan.sourceScope !== 'sdk'
10116
+ // Resource values need the existing resource + reference renderer; a bare
10117
+ // declaration would silently drop the value-to-key witness.
10118
+ && !subgraph.literalEvidence?.hits.some(hit => hit.kind === 'resource_reference')) {
10119
+ // One existing retrieval pass; raw literal hits affect candidate order only.
10120
+ // Actual target evidence is checked against hash-verified source in the packer.
10121
+ const hits = subgraph.literalEvidence?.hits ?? [];
10122
+ const fileHits = new Map();
10123
+ for (const hit of hits) {
10124
+ const labels = fileHits.get(hit.filePath) ?? new Set();
10125
+ labels.add(hit.literal);
10126
+ fileHits.set(hit.filePath, labels);
10127
+ }
10128
+ const candidates = [...subgraph.nodes.values()];
10129
+ if ((0, request_contract_1.accuracyTargetsEnabled)())
10130
+ candidates.sort((a, b) => (fileHits.get(b.filePath)?.size ?? 0) - (fileHits.get(a.filePath)?.size ?? 0));
10131
+ const packed = this.tryArktsEvidenceExplore(cg, query, projectRoot, candidates, new Set(plan.bindings?.length ? plan.bindings.map(b => b.id) : subgraph.roots), args._hgEvidenceMaxChars, maxFiles, plan);
10132
+ if (packed)
10133
+ return packed;
10134
+ }
9682
10135
  const literalSource = this.renderLiteralSource(cg, subgraph);
9683
10136
  if (subgraph.nodes.size === 0) {
9684
- const text = literalSource.text || `No relevant code found for "${query}"`;
9685
- return this.exploreResult(text, { projectRoot, query, files: literalSource.files,
9686
- sourceBytes: literalSource.files.reduce((sum, file) => sum + file.bytes, 0), responseBytes: text.length,
9687
- locatedNodes: literalSource.nodes, partial: true, evidenceStatus: literalSource.text ? 'partial' : 'empty' });
10137
+ // Spec 0044 §9: empty exact evidence → Miss (paths only), not a source dump.
10138
+ let fuzzyPaths = [];
10139
+ try {
10140
+ fuzzyPaths = [...new Set(cg.searchNodes(query.slice(0, 64), { limit: 12 })
10141
+ .map((r) => r.node.filePath.replace(/\\/g, '/'))
10142
+ .filter((p) => p && !(0, arkts_1.isOhosApiFilePath)(p)))].slice(0, 8);
10143
+ }
10144
+ catch { /* ignore */ }
10145
+ const text = literalSource.text
10146
+ || (0, locate_contract_1.formatMissBanner)(fuzzyPaths);
10147
+ return this.exploreResult(text, {
10148
+ projectRoot,
10149
+ query,
10150
+ files: literalSource.files,
10151
+ sourceBytes: literalSource.files.reduce((sum, file) => sum + file.bytes, 0),
10152
+ responseBytes: text.length,
10153
+ locatedNodes: literalSource.nodes,
10154
+ partial: true,
10155
+ evidenceStatus: literalSource.text ? 'partial' : 'empty',
10156
+ });
9688
10157
  }
9689
10158
  // Seed import nodes for @kit.* / *Kit names (and named symbols like taskpool).
9690
10159
  const importTerms = (0, query_utils_1.extractImportSearchTerms)(query);
@@ -9852,7 +10321,7 @@ class ToolHandler {
9852
10321
  const raw = isQual ? this.findAllSymbols(cg, t).nodes : cg.getNodesByName(t);
9853
10322
  let cands = raw
9854
10323
  .filter((n) => SEED_KINDS.has(n.kind) && !isTestPath(n.filePath)
9855
- && !(plan?.sourceScope === 'local' && (0, arkts_1.isOhosApiFilePath)(n.filePath)))
10324
+ && !(exploreSourceScope === 'local' && (0, arkts_1.isOhosApiFilePath)(n.filePath)))
9856
10325
  .sort((a, b) => {
9857
10326
  // Prefer callables over types when both share a name, then body size.
9858
10327
  const ac = CALLABLE.has(a.kind) ? 1 : 0;
@@ -10213,10 +10682,12 @@ class ToolHandler {
10213
10682
  // dropped, so the budget never fills with incidental files. Guarded so it
10214
10683
  // never prunes below 2.
10215
10684
  if (maxGraph > 0) {
10685
+ const litKeep = new Set((subgraph.literalEvidence?.hits ?? []).map((h) => h.filePath).filter(Boolean));
10216
10686
  const gated = relevantFiles.filter(([fp]) => (fileGraphScore.get(fp) ?? 0) >= maxGraph * 0.06
10217
10687
  || centralFiles.has(fp)
10218
10688
  || entryFiles.has(fp)
10219
10689
  || changeSurfaceFiles.has(fp)
10690
+ || litKeep.has(fp)
10220
10691
  || (fileTermHits.get(fp) ?? 0) >= 2);
10221
10692
  if (gated.length >= 2)
10222
10693
  relevantFiles = gated;
@@ -10241,6 +10712,22 @@ class ToolHandler {
10241
10712
  // tier so it isn't buried under files that merely share surface words (#1064).
10242
10713
  for (const fp of changeSurfaceFiles)
10243
10714
  namedSeedFiles.add(fp);
10715
+ // Spec 0044 §7: literal witness files must stay in the pack and rank with named seeds.
10716
+ {
10717
+ const litPaths = [...new Set((subgraph.literalEvidence?.hits ?? []).map((h) => h.filePath).filter(Boolean))];
10718
+ for (const fp of litPaths) {
10719
+ entryFiles.add(fp);
10720
+ namedSeedFiles.add(fp);
10721
+ if (relevantFiles.some(([f]) => f === fp))
10722
+ continue;
10723
+ let group = fileGroups.get(fp);
10724
+ if (!group) {
10725
+ group = { nodes: [], score: 1000 };
10726
+ fileGroups.set(fp, group);
10727
+ }
10728
+ relevantFiles.push([fp, group]);
10729
+ }
10730
+ }
10244
10731
  // Multi-term corroboration tier: a file that is BOTH (a) an entry/central file
10245
10732
  // (a search root, named seed, or graph-central hub — i.e. structurally part of
10246
10733
  // the answer) AND (b) matched by ≥2 DISTINCT query terms must not be buried by
@@ -10264,7 +10751,7 @@ class ToolHandler {
10264
10751
  const sortedFiles = relevantFiles.sort((a, b) => {
10265
10752
  const aPath = a[0].toLowerCase();
10266
10753
  const bPath = b[0].toLowerCase();
10267
- if (plan?.sourceScope === 'local') {
10754
+ if (exploreSourceScope === 'local') {
10268
10755
  const sdkOrder = Number((0, arkts_1.isOhosApiFilePath)(a[0])) - Number((0, arkts_1.isOhosApiFilePath)(b[0]));
10269
10756
  if (sdkOrder)
10270
10757
  return sdkOrder;
@@ -10273,6 +10760,17 @@ class ToolHandler {
10273
10760
  - Number((subgraph.literalEvidence?.hits ?? []).some((hit) => hit.filePath === a[0]));
10274
10761
  if (literalOrder)
10275
10762
  return literalOrder;
10763
+ // Spec 0044 §7: demote Logger/hilog/Toast-only files when query did not name them.
10764
+ const logToastDemote = (fp) => {
10765
+ const group = fileGroups.get(fp);
10766
+ if (!group?.nodes.length)
10767
+ return 0;
10768
+ const allNoise = group.nodes.every((n) => (0, locate_contract_1.shouldDemoteLogToastSpine)(n.name, query));
10769
+ return allNoise ? 1 : 0;
10770
+ };
10771
+ const logOrder = logToastDemote(a[0]) - logToastDemote(b[0]);
10772
+ if (logOrder)
10773
+ return logOrder;
10276
10774
  // Query-named file (LocationController.ets in the question) before partial
10277
10775
  // substring matches (control.ets matching "Controller" inside LocationController).
10278
10776
  const aExactBase = (0, query_utils_1.fileMatchesQueryBasename)(a[0], queryFileBasenames) ? 1 : 0;
@@ -11345,7 +11843,15 @@ class ToolHandler {
11345
11843
  const headerSuffix = omittedCount > 0
11346
11844
  ? `${headerSymbols.join(', ')}, +${omittedCount} more`
11347
11845
  : headerSymbols.join(', ');
11348
- const fileHeader = fileSectionHeader(filePath, headerSuffix);
11846
+ let fileHeader = fileSectionHeader(filePath, headerSuffix);
11847
+ // Spec 0044 §10.1: warn when basename ≠ primary declaration.
11848
+ {
11849
+ const primary = group?.nodes.find((n) => ['class', 'struct', 'component', 'interface', 'enum'].includes(n.kind)
11850
+ && !n.name.startsWith('%')) ?? group?.nodes.find((n) => ['function', 'method'].includes(n.kind) && !n.name.startsWith('%'));
11851
+ const mismatch = (0, locate_contract_1.formatFilenameDeclarationMismatch)(filePath, primary?.name);
11852
+ if (mismatch)
11853
+ fileHeader = `${fileHeader}\n${mismatch}`;
11854
+ }
11349
11855
  // The total cap bounds INCIDENTAL files only. A file that DEFINES a symbol
11350
11856
  // the agent named (or that's on the flow spine) renders even when the
11351
11857
  // nominal total is used up — it's the answer, and the set is bounded by
@@ -11595,7 +12101,10 @@ class ToolHandler {
11595
12101
  const nodes = [];
11596
12102
  const seen = new Set();
11597
12103
  let chars = 0;
11598
- for (const hit of subgraph.literalEvidence?.hits ?? []) {
12104
+ // Preserve value-to-key provenance when a plain label in the same file
12105
+ // would otherwise consume its single source-witness slot first.
12106
+ const literalHits = [...(subgraph.literalEvidence?.hits ?? [])].sort((a, b) => Number(!!b.resource) - Number(!!a.resource));
12107
+ for (const hit of literalHits) {
11599
12108
  if (files.length >= 2 || seen.has(hit.filePath))
11600
12109
  continue;
11601
12110
  const absolute = (0, utils_1.validatePathWithinRoot)(cg.getProjectRoot(), hit.filePath);
@@ -11639,6 +12148,106 @@ class ToolHandler {
11639
12148
  }
11640
12149
  return { text: sections.join('\n\n'), files, nodes };
11641
12150
  }
12151
+ /**
12152
+ * Spec 0039: when the query names Harmony route profiles, lead with a short
12153
+ * Registration sources table so agents see JSON was indexed (avoid re-Read).
12154
+ */
12155
+ prependHarmonyRegistrationSources(result, projectRoot, query) {
12156
+ if (!/route_map|router_map|main_pages/i.test(query))
12157
+ return result;
12158
+ const [first, ...rest] = result.content;
12159
+ if (!first || first.type !== 'text')
12160
+ return result;
12161
+ if (/^\*\*Registration sources\*\*/m.test(first.text))
12162
+ return result;
12163
+ let cg;
12164
+ try {
12165
+ cg = this.getHomeGraph(projectRoot);
12166
+ }
12167
+ catch {
12168
+ return result;
12169
+ }
12170
+ const section = formatHarmonyRegistrationSources(cg);
12171
+ if (!section)
12172
+ return result;
12173
+ return {
12174
+ ...result,
12175
+ content: [{ type: 'text', text: `${section}\n\n${first.text}` }, ...rest],
12176
+ };
12177
+ }
12178
+ /** Spec 0041: lead with Resource hits when string.json is named or matched. */
12179
+ prependHarmonyResourceHits(result, projectRoot, query) {
12180
+ const [first, ...rest] = result.content;
12181
+ if (!first || first.type !== 'text')
12182
+ return result;
12183
+ if (/^\*\*Resource hits\*\*/m.test(first.text) || /\n\*\*Resource hits\*\*/m.test(first.text)) {
12184
+ return result;
12185
+ }
12186
+ let cg;
12187
+ try {
12188
+ cg = this.getHomeGraph(projectRoot);
12189
+ }
12190
+ catch {
12191
+ return result;
12192
+ }
12193
+ const section = formatHarmonyResourceHits(cg, query);
12194
+ if (!section)
12195
+ return result;
12196
+ return {
12197
+ ...result,
12198
+ content: [{ type: 'text', text: `${section}\n\n${first.text}` }, ...rest],
12199
+ };
12200
+ }
12201
+ /** Spec 0048 §1–2: Capability profiles or form negative evidence. */
12202
+ prependHarmonyCapabilityProfiles(result, projectRoot, query) {
12203
+ const [first, ...rest] = result.content;
12204
+ if (!first || first.type !== 'text')
12205
+ return result;
12206
+ if (/^\*\*Capability profiles/m.test(first.text)
12207
+ || /\n\*\*Capability profiles/m.test(first.text)) {
12208
+ return result;
12209
+ }
12210
+ let cg;
12211
+ try {
12212
+ cg = this.getHomeGraph(projectRoot);
12213
+ }
12214
+ catch {
12215
+ return result;
12216
+ }
12217
+ const section = formatHarmonyCapabilityProfiles(cg, query);
12218
+ if (!section)
12219
+ return result;
12220
+ return {
12221
+ ...result,
12222
+ content: [{ type: 'text', text: `${section}\n\n${first.text}` }, ...rest],
12223
+ };
12224
+ }
12225
+ /** Spec 0048 §5: seam notes for located anchors. */
12226
+ prependHarmonySeamNotes(result, projectRoot, emission) {
12227
+ const located = emission?.locatedNodes;
12228
+ if (!located || located.length === 0)
12229
+ return result;
12230
+ const [first, ...rest] = result.content;
12231
+ if (!first || first.type !== 'text')
12232
+ return result;
12233
+ if (/^\*\*Seam notes\*\*/m.test(first.text) || /\n\*\*Seam notes\*\*/m.test(first.text)) {
12234
+ return result;
12235
+ }
12236
+ let cg;
12237
+ try {
12238
+ cg = this.getHomeGraph(projectRoot);
12239
+ }
12240
+ catch {
12241
+ return result;
12242
+ }
12243
+ const section = formatHarmonySeamNotes(cg, projectRoot, located);
12244
+ if (!section)
12245
+ return result;
12246
+ return {
12247
+ ...result,
12248
+ content: [{ type: 'text', text: `${section}\n\n${first.text}` }, ...rest],
12249
+ };
12250
+ }
11642
12251
  /**
11643
12252
  * An explore response plus the record of what it emitted (CG-17). The record
11644
12253
  * rides the result only as far as {@link execute}, which files it into the
@@ -11646,17 +12255,42 @@ class ToolHandler {
11646
12255
  */
11647
12256
  exploreResult(text, emission) {
11648
12257
  const meta = (0, explore_repeat_guard_1.inferExplorePartialMeta)(text);
11649
- const result = this.textResult(text);
12258
+ let evidenceStatus = emission.evidenceStatus
12259
+ ?? (emission.sourceBytes > 0 ? (meta.partial ? 'partial' : 'complete') : 'partial');
12260
+ let body = text;
12261
+ // Spec 0044 §8: Located banner when complete exact evidence; soften Partial wording.
12262
+ if (evidenceStatus === 'complete' && !body.includes(locate_contract_1.LOCATED_MARKER)) {
12263
+ if (/\*\*Partial locator\*\*/i.test(body)) {
12264
+ body = body.replace(/>\s*\*\*Partial locator\*\*[^\n]*/gi, (0, locate_contract_1.formatLocatedBanner)());
12265
+ }
12266
+ else if (!/\*\*ANSWER NOW/i.test(body) && !body.includes(locate_contract_1.MISS_MARKER)) {
12267
+ body = `${(0, locate_contract_1.formatLocatedBanner)()}\n\n${body}`;
12268
+ }
12269
+ }
12270
+ else if ((evidenceStatus === 'partial' || meta.partial)
12271
+ && /\*\*Partial locator\*\*/i.test(body)
12272
+ && !body.includes(locate_contract_1.LOCATED_MARKER)) {
12273
+ // Spec 0044 §8: Partial next-step → node/usages/search (not Grep same names).
12274
+ body = body.replace(/>\s*\*\*Partial locator\*\*[^\n]*/gi, (0, locate_contract_1.formatPartialBanner)());
12275
+ }
12276
+ let result = this.textResult(body);
12277
+ result = this.prependHarmonyRegistrationSources(result, emission.projectRoot, emission.query);
12278
+ result = this.prependHarmonyCapabilityProfiles(result, emission.projectRoot, emission.query);
12279
+ result = this.prependHarmonyResourceHits(result, emission.projectRoot, emission.query);
11650
12280
  result[explore_session_state_1.EXPLORE_EMISSION_KEY] = {
11651
12281
  ...emission,
11652
- evidenceStatus: emission.evidenceStatus ?? (emission.sourceBytes > 0 ? (meta.partial ? 'partial' : 'complete') : 'partial'),
11653
- partial: emission.partial ?? (emission.evidenceStatus && emission.evidenceStatus !== 'complete' ? true : meta.partial),
12282
+ evidenceStatus,
12283
+ partial: emission.partial ?? (evidenceStatus !== 'complete' ? true : meta.partial),
11654
12284
  nextAnchor: emission.nextAnchor ?? meta.nextAnchor,
11655
12285
  };
12286
+ result = this.prependHarmonySeamNotes(result, emission.projectRoot, result[explore_session_state_1.EXPLORE_EMISSION_KEY]);
11656
12287
  return result;
11657
12288
  }
11658
12289
  /** Attach a minimal session emission when a compact/light path used textResult. */
11659
12290
  ensureExploreEmission(result, projectRoot, query) {
12291
+ result = this.prependHarmonyRegistrationSources(result, projectRoot, query);
12292
+ result = this.prependHarmonyCapabilityProfiles(result, projectRoot, query);
12293
+ result = this.prependHarmonyResourceHits(result, projectRoot, query);
11660
12294
  if (result[explore_session_state_1.EXPLORE_EMISSION_KEY]) {
11661
12295
  const em = result[explore_session_state_1.EXPLORE_EMISSION_KEY];
11662
12296
  if (em.partial === undefined || !em.nextAnchor) {
@@ -11666,6 +12300,7 @@ class ToolHandler {
11666
12300
  if (!em.nextAnchor && meta.nextAnchor)
11667
12301
  em.nextAnchor = meta.nextAnchor;
11668
12302
  }
12303
+ result = this.prependHarmonySeamNotes(result, projectRoot, em);
11669
12304
  return result;
11670
12305
  }
11671
12306
  const text = result.content?.[0]?.text ?? '';
@@ -11839,7 +12474,8 @@ class ToolHandler {
11839
12474
  const nodes = cg.getNodesInFile(filePath)
11840
12475
  .filter((n) => n.kind !== 'file' && n.kind !== 'import' && n.kind !== 'export')
11841
12476
  .sort((a, b) => a.startLine - b.startLine);
11842
- const dependents = cg.getFileDependents(filePath);
12477
+ const dependents = cg.getFileDependents(filePath)
12478
+ .filter((p) => !p.includes('@dummy') && !p.replace(/\\/g, '/').split('/').pop()?.startsWith('@dummy'));
11843
12479
  // Compact, one-line blast radius (homegraph's value-add over a plain Read).
11844
12480
  const depSummary = dependents.length
11845
12481
  ? `used by ${dependents.length} file${dependents.length === 1 ? '' : 's'}: ${dependents.slice(0, 8).join(', ')}${dependents.length > 8 ? `, +${dependents.length - 8} more` : ''}`
@@ -12019,6 +12655,8 @@ class ToolHandler {
12019
12655
  for (const e of edges) {
12020
12656
  if (seen.has(e.node.id))
12021
12657
  continue;
12658
+ if ((0, query_utils_1.isMcpNoiseNode)(e.node))
12659
+ continue;
12022
12660
  seen.add(e.node.id);
12023
12661
  out.push(e);
12024
12662
  }
@@ -12194,14 +12832,47 @@ class ToolHandler {
12194
12832
  return this.textResult('No modules found in the project map.');
12195
12833
  }
12196
12834
  const FILE_CAP = 80;
12835
+ const projectRoot = cg.getProjectRoot();
12836
+ const hasHarmony = map.modules.some((m) => m.kind === 'harmony');
12197
12837
  const lines = [
12198
12838
  `**Project map** (phase=${map.phase})`,
12199
12839
  `modules: ${map.modules.length} · files: ${map.fileCount}`,
12200
- '',
12201
12840
  ];
12841
+ // Spec 0040 — Harmony skeleton summary (navigation pointers, not call edges).
12842
+ try {
12843
+ const bundle = (0, project_map_1.readHarmonyAppBundleName)(projectRoot);
12844
+ if (bundle)
12845
+ lines.push(`bundle: \`${bundle}\` (from app.json5)`);
12846
+ }
12847
+ catch {
12848
+ /* omit */
12849
+ }
12850
+ if (hasHarmony) {
12851
+ lines.push('modules from `build-profile.json5` — skeleton map only (not call edges; use `homegraph_explore` for route_map → page)');
12852
+ }
12853
+ lines.push('');
12202
12854
  for (const m of map.modules) {
12203
12855
  const rootLabel = m.rootPath || '.';
12204
12856
  lines.push(`### ${m.name} (\`${rootLabel}\`) · ${m.kind} · ${m.fileCount} files`);
12857
+ if (m.kind === 'harmony' || m.rootPath) {
12858
+ try {
12859
+ const profiles = (0, project_map_1.listHarmonyRouteProfilesUnderModule)(projectRoot, m.rootPath || '');
12860
+ for (const p of profiles) {
12861
+ lines.push(`- route profile: \`${p}\``);
12862
+ }
12863
+ }
12864
+ catch {
12865
+ /* omit */
12866
+ }
12867
+ try {
12868
+ const ohpm = (0, project_map_1.readModuleOhPackageName)(projectRoot, m.rootPath || '');
12869
+ if (ohpm)
12870
+ lines.push(`- oh-package: \`${ohpm}\``);
12871
+ }
12872
+ catch {
12873
+ /* omit */
12874
+ }
12875
+ }
12205
12876
  if (m.files && m.files.length > 0) {
12206
12877
  const shown = m.files.slice(0, FILE_CAP);
12207
12878
  for (const f of shown) {
@@ -12213,6 +12884,42 @@ class ToolHandler {
12213
12884
  }
12214
12885
  lines.push('');
12215
12886
  }
12887
+ // Spec 0048 §4 — module roster with local file: deps.
12888
+ try {
12889
+ const roster = (0, project_map_1.formatHarmonyModuleRoster)(projectRoot, map.modules);
12890
+ if (roster) {
12891
+ lines.push(roster);
12892
+ lines.push('');
12893
+ }
12894
+ }
12895
+ catch {
12896
+ /* omit roster on failure */
12897
+ }
12898
+ // Spec 0042 B — path inventory (string/rawfile/media + unindexed package dirs).
12899
+ // Skip when the caller filtered to a single non-Harmony module? Still useful
12900
+ // project-wide; keep always on Harmony-shaped trees or whenever scan finds hits.
12901
+ try {
12902
+ const indexedPaths = map.modules.flatMap((m) => (m.files ?? []).map((f) => f.path));
12903
+ // When includeFiles=false, files arrays are empty — fall back to full map paths.
12904
+ let pathsForUnindexed = indexedPaths;
12905
+ if (pathsForUnindexed.length === 0) {
12906
+ try {
12907
+ const full = cg.getProjectMap({ includeFiles: true });
12908
+ pathsForUnindexed = full.modules.flatMap((m) => (m.files ?? []).map((f) => f.path));
12909
+ }
12910
+ catch {
12911
+ pathsForUnindexed = [];
12912
+ }
12913
+ }
12914
+ const inv = (0, project_map_1.scanHarmonyResourceInventory)(projectRoot, { indexedPaths: pathsForUnindexed });
12915
+ const section = (0, project_map_1.formatHarmonyResourceInventory)(inv);
12916
+ if (section) {
12917
+ lines.push(section);
12918
+ }
12919
+ }
12920
+ catch {
12921
+ /* omit inventory on scan failure */
12922
+ }
12216
12923
  return this.textResult(lines.join('\n').trimEnd());
12217
12924
  }
12218
12925
  /**