homegraph 1.5.8 → 1.6.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 (40) hide show
  1. package/CHANGELOG.md +34 -0
  2. package/dist/extraction/grammars.d.ts +19 -0
  3. package/dist/extraction/grammars.js +44 -1
  4. package/dist/extraction/index.js +17 -12
  5. package/dist/extraction/languages/arkts.d.ts +40 -7
  6. package/dist/extraction/languages/arkts.js +290 -84
  7. package/dist/extraction/tree-sitter.js +12 -3
  8. package/dist/index.js +23 -12
  9. package/dist/mcp/arkts-evidence-packs.js +1 -0
  10. package/dist/mcp/daemon.d.ts +21 -3
  11. package/dist/mcp/daemon.js +60 -6
  12. package/dist/mcp/engine.d.ts +26 -0
  13. package/dist/mcp/engine.js +143 -7
  14. package/dist/mcp/index-availability.d.ts +40 -10
  15. package/dist/mcp/index-availability.js +89 -23
  16. package/dist/mcp/index.js +9 -0
  17. package/dist/mcp/indexable-root.d.ts +22 -0
  18. package/dist/mcp/indexable-root.js +140 -0
  19. package/dist/mcp/liveness-watchdog.d.ts +6 -1
  20. package/dist/mcp/liveness-watchdog.js +17 -5
  21. package/dist/mcp/locate-contract.d.ts +50 -0
  22. package/dist/mcp/locate-contract.js +146 -0
  23. package/dist/mcp/server-instructions.d.ts +2 -6
  24. package/dist/mcp/server-instructions.js +15 -5
  25. package/dist/mcp/session.js +15 -0
  26. package/dist/mcp/tools.d.ts +52 -0
  27. package/dist/mcp/tools.js +750 -103
  28. package/dist/project-map/index.d.ts +45 -0
  29. package/dist/project-map/index.js +373 -45
  30. package/dist/resolution/callback-synthesizer.js +251 -0
  31. package/dist/resolution/frameworks/arkts-entry.d.ts +35 -6
  32. package/dist/resolution/frameworks/arkts-entry.js +513 -30
  33. package/dist/resolution/index.js +25 -21
  34. package/dist/runtime-log.d.ts +52 -0
  35. package/dist/runtime-log.js +199 -0
  36. package/dist/search/query-plan-provider.js +3 -2
  37. package/dist/search/query-plan.js +21 -19
  38. package/dist/search/query-utils.d.ts +13 -0
  39. package/dist/search/query-utils.js +64 -0
  40. package/package.json +2 -2
package/dist/mcp/tools.js CHANGED
@@ -12,6 +12,11 @@ exports.getExploreBudget = getExploreBudget;
12
12
  exports.getExploreOutputBudget = getExploreOutputBudget;
13
13
  exports.tightenExploreBudgetForQuery = tightenExploreBudgetForQuery;
14
14
  exports.exploreLocatorDigestEnabled = exploreLocatorDigestEnabled;
15
+ exports.formatHarmonyRegistrationSources = formatHarmonyRegistrationSources;
16
+ exports.formatHarmonyResourceHits = formatHarmonyResourceHits;
17
+ exports.formatHarmonyCapabilityProfiles = formatHarmonyCapabilityProfiles;
18
+ exports.isHarmonyStubBody = isHarmonyStubBody;
19
+ exports.formatHarmonySeamNotes = formatHarmonySeamNotes;
15
20
  exports.formatStaleBanner = formatStaleBanner;
16
21
  exports.formatStaleFooter = formatStaleFooter;
17
22
  exports.formatDegradedBanner = formatDegradedBanner;
@@ -26,9 +31,11 @@ const evidence_rendering_1 = require("./evidence-rendering");
26
31
  const arkts_evidence_packs_1 = require("./arkts-evidence-packs");
27
32
  const evidence_paths_1 = require("../graph/evidence-paths");
28
33
  const query_plan_1 = require("../search/query-plan");
29
- const memory_budget_1 = require("./memory-budget");
34
+ const grammars_1 = require("../extraction/grammars");
35
+ const project_map_1 = require("../project-map");
30
36
  const index_availability_1 = require("./index-availability");
31
37
  const directory_1 = require("../directory");
38
+ const runtime_log_1 = require("../runtime-log");
32
39
  // Lazy-load the heavy HomeGraph chain off the MCP startup path — see the same
33
40
  // helper in engine.ts. ToolHandler must load to answer tools/list (static
34
41
  // schemas), but it must NOT drag in sqlite/query layers before the daemon binds;
@@ -56,6 +63,7 @@ const arkts_1 = require("../extraction/languages/arkts");
56
63
  const explore_session_state_1 = require("./explore-session-state");
57
64
  const explore_dedup_1 = require("./explore-dedup");
58
65
  const explore_repeat_guard_1 = require("./explore-repeat-guard");
66
+ const locate_contract_1 = require("./locate-contract");
59
67
  const dynamic_boundaries_1 = require("./dynamic-boundaries");
60
68
  const query_cache_1 = require("./query-cache");
61
69
  const diff_impact_1 = require("./diff-impact");
@@ -566,6 +574,258 @@ function fileSectionHeader(filePath, suffix) {
566
574
  ? `${FILE_SECTION_PREFIX}${filePath}\`** — ${suffix}`
567
575
  : `${FILE_SECTION_PREFIX}${filePath}\`**`;
568
576
  }
577
+ /**
578
+ * Spec 0039 — short Registration sources table from indexed Harmony profile routes.
579
+ * Returns null when nothing to show.
580
+ */
581
+ function formatHarmonyRegistrationSources(cg, maxRows = 24) {
582
+ const routes = cg
583
+ .getNodesByKind('route')
584
+ .filter((n) => (0, grammars_1.isHarmonyRouteProfileJson)(n.filePath))
585
+ .sort((a, b) => a.filePath.localeCompare(b.filePath) || a.startLine - b.startLine);
586
+ if (routes.length === 0)
587
+ return null;
588
+ const byFile = new Map();
589
+ for (const r of routes) {
590
+ const fp = r.filePath.replace(/\\/g, '/');
591
+ const list = byFile.get(fp) ?? [];
592
+ list.push(r);
593
+ byFile.set(fp, list);
594
+ }
595
+ const lines = [
596
+ '**Registration sources** (indexed Harmony route profiles — evidence from these files; do not re-Read unless editing)',
597
+ ];
598
+ let rows = 0;
599
+ for (const [fp, list] of byFile) {
600
+ lines.push(`- \`${fp}\``);
601
+ for (const r of list) {
602
+ if (rows >= maxRows) {
603
+ lines.push(` - … +more routes`);
604
+ return lines.join('\n');
605
+ }
606
+ const page = typeof r.signature === 'string'
607
+ ? /pageSourceFile=([^;]+)/.exec(r.signature)?.[1]?.trim()
608
+ : undefined;
609
+ const builder = typeof r.signature === 'string'
610
+ ? /buildFunction=([^;]+)/.exec(r.signature)?.[1]?.trim()
611
+ : undefined;
612
+ if (page) {
613
+ lines.push(` - \`${r.name}\` → \`${page}\`${builder ? ` (${builder})` : ''} @${fp}:${r.startLine}`);
614
+ }
615
+ else {
616
+ lines.push(` - \`${r.name}\` @${fp}:${r.startLine}`);
617
+ }
618
+ rows++;
619
+ }
620
+ }
621
+ return lines.join('\n');
622
+ }
623
+ /**
624
+ * Spec 0041 / 0048 §3 — short Resource hits table for element/string.json constants.
625
+ * Spec 0048: append up to 2 in-repo `.ets` binding anchors when signature/docstring
626
+ * mention `app.string.<key>`.
627
+ */
628
+ function formatHarmonyResourceHits(cg, query, maxRows = 8) {
629
+ const constants = cg
630
+ .getNodesByKind('constant')
631
+ .filter((n) => (0, grammars_1.isHarmonyElementStringJson)(n.filePath));
632
+ if (constants.length === 0)
633
+ return null;
634
+ const q = query.trim();
635
+ const namedFile = /string\.json/i.test(q);
636
+ const tokens = [
637
+ ...new Set(q
638
+ .split(/[\s,;|]+/)
639
+ .map((t) => t.trim())
640
+ .filter((t) => t.length >= 2 && t.length <= 160 && !/^string\.json$/i.test(t))),
641
+ ].slice(0, 12);
642
+ const matched = constants.filter((n) => {
643
+ if (namedFile && tokens.length === 0)
644
+ return true;
645
+ const hay = `${n.name}\n${n.qualifiedName ?? ''}\n${n.docstring ?? ''}\n${n.signature ?? ''}`.toLocaleLowerCase();
646
+ return tokens.some((t) => hay.includes(t.toLocaleLowerCase()));
647
+ });
648
+ const rows = (matched.length > 0 ? matched : namedFile ? constants : [])
649
+ .sort((a, b) => a.filePath.localeCompare(b.filePath) || a.startLine - b.startLine)
650
+ .slice(0, maxRows);
651
+ if (rows.length === 0)
652
+ return null;
653
+ const projectRoot = cg.getProjectRoot();
654
+ const etsFiles = cg.getFiles()
655
+ .map((f) => f.path.replace(/\\/g, '/'))
656
+ .filter((p) => /\.ets$/i.test(p))
657
+ .slice(0, 80);
658
+ const findBindings = (key, stringFile) => {
659
+ const needle = `app.string.${key}`;
660
+ const hits = [];
661
+ const modRoot = (() => {
662
+ const marker = '/src/main/';
663
+ const i = stringFile.indexOf(marker);
664
+ return i >= 0 ? stringFile.slice(0, i) : '';
665
+ })();
666
+ for (const rel of etsFiles) {
667
+ if (hits.length >= 2)
668
+ break;
669
+ if (modRoot && !(rel === modRoot || rel.startsWith(`${modRoot}/`)))
670
+ continue;
671
+ const abs = (0, path_1.join)(projectRoot, rel);
672
+ if (!(0, fs_1.existsSync)(abs))
673
+ continue;
674
+ let text;
675
+ try {
676
+ text = (0, fs_1.readFileSync)(abs, 'utf-8');
677
+ }
678
+ catch {
679
+ continue;
680
+ }
681
+ const lines = text.split(/\n/);
682
+ for (let i = 0; i < lines.length; i++) {
683
+ if (hits.length >= 2)
684
+ break;
685
+ if (lines[i].includes(needle)) {
686
+ hits.push(`\`${rel}:${i + 1}\``);
687
+ }
688
+ }
689
+ }
690
+ return hits;
691
+ };
692
+ const lines = [
693
+ '**Resource hits** (element/string.json — searchable literals, no graph edges)',
694
+ ];
695
+ for (const n of rows) {
696
+ const fp = n.filePath.replace(/\\/g, '/');
697
+ const value = (n.docstring ?? '').slice(0, 80);
698
+ const shown = value ? JSON.stringify(value) : '(empty)';
699
+ const bounds = findBindings(n.name, fp);
700
+ const boundNote = bounds.length ? ` · bound ${bounds.join(', ')}` : '';
701
+ lines.push(`- \`${fp}:${n.startLine}\` — ${shown} → \`${n.name}\`${boundNote} · Grep \`$r('app.string.${n.name}')\` in \`.ets\``);
702
+ }
703
+ if ((matched.length || constants.length) > rows.length) {
704
+ lines.push(`- … +more string resources`);
705
+ }
706
+ return lines.join('\n');
707
+ }
708
+ const FORM_QUERY_RE = /form_config|FormExtension|服务卡片|卡片/i;
709
+ const SHORTCUT_QUERY_RE = /shortcuts_config|\bshortcuts\b|快捷方式|长按|快捷入口/i;
710
+ /**
711
+ * Spec 0048 §1–2 — Capability profiles table, or form negative evidence.
712
+ */
713
+ function formatHarmonyCapabilityProfiles(cg, query, maxRows = 8) {
714
+ const wantForm = FORM_QUERY_RE.test(query);
715
+ const wantShortcut = SHORTCUT_QUERY_RE.test(query);
716
+ if (!wantForm && !wantShortcut)
717
+ return null;
718
+ const constants = cg.getNodesByKind('constant');
719
+ const routes = cg.getNodesByKind('route');
720
+ const formNodes = [
721
+ ...constants.filter((n) => (0, grammars_1.isHarmonyCapabilityProfileJson)(n.filePath)
722
+ && /form_config\.json$/i.test(n.filePath.replace(/\\/g, '/'))),
723
+ ...constants.filter((n) => (n.qualifiedName ?? '').includes('harmony.capability.form')),
724
+ ...routes.filter((n) => (n.name ?? '').startsWith('formAbility:')),
725
+ ];
726
+ const shortcutNodes = [
727
+ ...constants.filter((n) => (0, grammars_1.isHarmonyCapabilityProfileJson)(n.filePath)
728
+ && /shortcuts_config\.json$/i.test(n.filePath.replace(/\\/g, '/'))),
729
+ ...constants.filter((n) => (n.qualifiedName ?? '').includes('harmony.capability.shortcut')
730
+ || (n.qualifiedName ?? '').includes('harmony.shortcut.')),
731
+ ];
732
+ const lines = [];
733
+ if (wantForm && formNodes.length === 0) {
734
+ lines.push('**Capability profiles:** No in-repo form_config / FormExtensionAbility (do not treat SDK `.d.ts` as project wiring).');
735
+ }
736
+ const rows = [];
737
+ const pushRows = (kind, nodes) => {
738
+ for (const n of nodes.sort((a, b) => a.filePath.localeCompare(b.filePath) || a.startLine - b.startLine)) {
739
+ if (rows.length >= maxRows)
740
+ break;
741
+ const fp = n.filePath.replace(/\\/g, '/');
742
+ rows.push(`- (${kind}) \`${n.name}\` @\`${fp}:${n.startLine}\`${n.signature ? ` — ${n.signature}` : ''}`);
743
+ }
744
+ };
745
+ if (wantForm)
746
+ pushRows('form', formNodes);
747
+ if (wantShortcut)
748
+ pushRows('shortcut', shortcutNodes);
749
+ if (rows.length > 0) {
750
+ lines.push('**Capability profiles** (form_config / shortcuts_config — paths only, no UI edges)');
751
+ lines.push(...rows);
752
+ }
753
+ return lines.length ? lines.join('\n') : null;
754
+ }
755
+ /** Spec 0048 §5 — detect empty / log-only method bodies. */
756
+ function isHarmonyStubBody(source) {
757
+ const body = source
758
+ .replace(/\/\*[\s\S]*?\*\//g, '')
759
+ .replace(/\/\/[^\n]*/g, '')
760
+ .trim();
761
+ if (!body)
762
+ return true;
763
+ const lines = body.split(/\n/).map((l) => l.trim()).filter(Boolean);
764
+ if (lines.length === 0)
765
+ return true;
766
+ if (lines.length > 8)
767
+ return false;
768
+ const code = lines.join('\n');
769
+ // Strip common wrapper braces from a method slice.
770
+ const inner = code.replace(/^[^{]*\{/, '').replace(/\}[^}]*$/, '').trim();
771
+ const stmts = inner.split(/;|\n/).map((s) => s.trim()).filter(Boolean);
772
+ if (stmts.length === 0)
773
+ return true;
774
+ return stmts.every((s) => /^(return(\s+[^;]*)?|hilog\.[a-zA-Z]+\s*\(|console\.(log|info|warn|error|debug)\s*\(|\/\/)/i.test(s)
775
+ || s === '{'
776
+ || s === '}');
777
+ }
778
+ /**
779
+ * Spec 0048 §5 — seam notes for located explore anchors.
780
+ */
781
+ function formatHarmonySeamNotes(cg, projectRoot, located, maxAnchors = 6) {
782
+ if (!located.length)
783
+ return null;
784
+ const lines = [];
785
+ const capabilityFiles = new Set([
786
+ ...cg.getNodesByKind('constant').filter((n) => (0, grammars_1.isHarmonyCapabilityProfileJson)(n.filePath)),
787
+ ...cg.getNodesByKind('route').filter((n) => (0, grammars_1.isHarmonyRouteProfileJson)(n.filePath)),
788
+ ].map((n) => n.filePath.replace(/\\/g, '/')));
789
+ for (const loc of located.slice(0, maxAnchors)) {
790
+ const fp = loc.filePath.replace(/\\/g, '/');
791
+ const modRoot = (() => {
792
+ const marker = '/src/main/';
793
+ const i = fp.indexOf(marker);
794
+ return i >= 0 ? fp.slice(0, i) : fp.includes('/') ? fp.slice(0, fp.lastIndexOf('/')) : '';
795
+ })();
796
+ let profileNote = 0;
797
+ for (const cap of capabilityFiles) {
798
+ if (profileNote >= 2)
799
+ break;
800
+ if (modRoot && (cap === modRoot || cap.startsWith(`${modRoot}/`))) {
801
+ lines.push(`- profile near \`${loc.name}\`: \`${cap}\``);
802
+ profileNote++;
803
+ }
804
+ }
805
+ try {
806
+ const node = loc.id ? cg.getNode(loc.id) : null;
807
+ const start = node?.startLine ?? loc.startLine;
808
+ const end = node?.endLine ?? start + 12;
809
+ const abs = (0, path_1.join)(projectRoot, fp);
810
+ if ((0, fs_1.existsSync)(abs)) {
811
+ const content = (0, fs_1.readFileSync)(abs, 'utf-8');
812
+ const allLines = content.split(/\n/);
813
+ const slice = allLines.slice(Math.max(0, start - 1), Math.min(allLines.length, end)).join('\n');
814
+ if (isHarmonyStubBody(slice)) {
815
+ lines.push(`- stub: \`${loc.name}\` @\`${fp}:${start}\``);
816
+ }
817
+ }
818
+ }
819
+ catch {
820
+ /* omit */
821
+ }
822
+ if (lines.length >= maxAnchors * 3)
823
+ break;
824
+ }
825
+ if (lines.length === 0)
826
+ return null;
827
+ return ['**Seam notes** (config paths / empty stubs — digests still count as Read)', ...lines.slice(0, 18)].join('\n');
828
+ }
569
829
  /**
570
830
  * Per-file staleness banner emitted at the top of a tool response when the
571
831
  * file watcher has pending events for files referenced by the response.
@@ -922,11 +1182,12 @@ exports.tools = [
922
1182
  {
923
1183
  name: 'homegraph_explore',
924
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). ' +
925
1186
  'Use ordinary bash/search/read for paths, symbols, literal strings and local changes; continue editing when that evidence suffices. ' +
926
1187
  'Do not call for routine pre-edit orientation or merely because implementation is difficult. ' +
927
1188
  'For a missing usage, dependency/cycle or native-registration relation, use ' +
928
1189
  'homegraph_usages, homegraph_modules, or homegraph_native instead. ' +
929
- '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. ' +
1190
+ '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. ' +
930
1191
  'Reuse unchanged complete ranges; refresh missing, edited or truncated evidence. ' +
931
1192
  'No new evidence → change to a targeted source inspection, not a paraphrased explore. ' +
932
1193
  'Partial/busy → at most one focused recovery for the named gap; budgets are ceilings, not required calls. ' +
@@ -968,9 +1229,12 @@ exports.tools = [
968
1229
  },
969
1230
  {
970
1231
  name: 'homegraph_project',
971
- description: 'Shallow project map: modules + files per module (no symbols/call edges). ' +
972
- 'PRIMARY for engineering overview while the full index is still building; also useful after full index. ' +
973
- 'Optional `module` filters by name/path; `includeFiles` defaults true.',
1232
+ description: 'Shallow engineering map: modules + per-module files. On Harmony repos also prints skeleton pointers ' +
1233
+ '(bundleName from app.json5, modules from build-profile.json5, per-module route_map/router_map/main_pages paths, oh-package name) ' +
1234
+ '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). ' +
1235
+ '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). ' +
1236
+ 'PRIMARY overview while the full index is still building; also useful after full index. ' +
1237
+ 'Optional `module` filters by name/path; `includeFiles` defaults true (resources section still prints when false).',
974
1238
  inputSchema: {
975
1239
  type: 'object',
976
1240
  properties: {
@@ -1327,6 +1591,10 @@ class ToolHandler {
1327
1591
  // The directory the server last searched for a default project. Surfaced in
1328
1592
  // the "not initialized" error so users can see why detection missed.
1329
1593
  defaultProjectHint = null;
1594
+ // Spec 0043: when a tool `projectPath` would open a different index root than
1595
+ // the session's default bound project, we soft-pin to the default and stash
1596
+ // an English notice to prepend on the success reply (consumed once).
1597
+ boundProjectPathPinNotice = null;
1330
1598
  // Per-start-path cache of the git worktree/index mismatch (issue #155). The
1331
1599
  // mismatch is a fixed property of (where the request came from → which
1332
1600
  // .homegraph/ it resolves to), so the up-to-two `git rev-parse` spawns run
@@ -1560,12 +1828,11 @@ class ToolHandler {
1560
1828
  if (state === 'empty') {
1561
1829
  return (0, index_availability_1.productIndexGuidance)('empty');
1562
1830
  }
1563
- if (state === 'fast' || state === 'syncing') {
1564
- // Keep the Spec 0027 "still building → homegraph_project" story for
1565
- // frozen tools/list descriptions (hosts snapshot once at connect).
1566
- return state === 'syncing'
1567
- ? (0, index_availability_1.productIndexGuidance)('syncing')
1568
- : "HomeGraph is still building this project's index. A call right now returns build-progress guidance; use `homegraph_project` for the module/file map, then retry once indexing finishes.";
1831
+ if (state === 'fast') {
1832
+ return (0, index_availability_1.productIndexGuidance)('fast');
1833
+ }
1834
+ if (state === 'syncing') {
1835
+ return (0, index_availability_1.productIndexGuidance)('syncing');
1569
1836
  }
1570
1837
  }
1571
1838
  catch {
@@ -1582,6 +1849,11 @@ class ToolHandler {
1582
1849
  *
1583
1850
  * Walks up parent directories to find the nearest .homegraph/ folder,
1584
1851
  * similar to how git finds .git/ directories.
1852
+ *
1853
+ * Spec 0043: when a default project is already bound, a `projectPath` whose
1854
+ * resolved index root differs from that bound root is soft-pinned back to
1855
+ * the default (no DB switch) and a success-shaped English notice is stashed
1856
+ * for the reply preamble.
1585
1857
  */
1586
1858
  getHomeGraph(projectPath) {
1587
1859
  const sourcesMode = (0, graph_sources_1.resolveGraphSources)();
@@ -1628,25 +1900,34 @@ class ToolHandler {
1628
1900
  // (#926). The DB connection itself is still cached (by resolved root,
1629
1901
  // below), so re-resolving costs only the stat walk, never a reopen.
1630
1902
  const resolvedRoot = (0, directory_1.findNearestHomeGraphRoot)(projectPath);
1903
+ // Spec 0043: with a bound default root, never switch the graph to a
1904
+ // different index (sibling / parent / wrong absolute path). Soft-pin and
1905
+ // stash a notice; security refusals above still win.
1906
+ if (this.cg) {
1907
+ const boundRoot = (0, path_1.resolve)(this.cg.getProjectRoot());
1908
+ const resolvedAbs = resolvedRoot ? (0, path_1.resolve)(resolvedRoot) : null;
1909
+ if (resolvedAbs === null || resolvedAbs !== boundRoot) {
1910
+ this.boundProjectPathPinNotice = (0, index_availability_1.formatBoundProjectPathPinNotice)({
1911
+ boundRoot,
1912
+ requestedPath: projectPath,
1913
+ resolvedRoot: resolvedAbs,
1914
+ });
1915
+ return this.freshen(this.cg);
1916
+ }
1917
+ // Same index root (incl. nested path under the bound project) — reuse the
1918
+ // default instance so we never open a second connection to the same DB
1919
+ // (#238).
1920
+ return this.freshen(this.cg);
1921
+ }
1631
1922
  if (!resolvedRoot) {
1632
1923
  throw new NotIndexedError(`The project at ${projectPath} isn't indexed with homegraph (no .homegraph/ directory found ` +
1633
1924
  'walking up from it), so homegraph cannot query it. Use your built-in tools (Read/Grep/Glob) ' +
1634
1925
  "for that codebase instead, and don't call homegraph for it again this session. " +
1635
1926
  "Indexing is the user's decision — they can run 'homegraph init' in that project to enable it.");
1636
1927
  }
1637
- // If the path resolves to the default project, reuse the already-open
1638
- // default instance rather than opening a SECOND connection to the same DB.
1639
- // A duplicate connection serializes reads against the watcher's auto-sync
1640
- // writes; when WAL isn't in effect (e.g. a filesystem without shared-memory
1641
- // support) that surfaces as intermittent
1642
- // "database is locked" on concurrent tool calls. See issue #238. The
1643
- // default instance is owned/closed by the server, so it's never cached.
1644
- if (this.cg && this.cg.getProjectRoot() === resolvedRoot) {
1645
- return this.freshen(this.cg);
1646
- }
1647
- // Cache the open DB connection by RESOLVED ROOT only — never by the input
1648
- // path. One key per instance means closeAll() closes each exactly once, and
1649
- // a changed resolution maps to a different entry instead of a stale hit.
1928
+ // No default project — cross-project open (cached by resolved root).
1929
+ // One key per instance means closeAll() closes each exactly once, and a
1930
+ // changed resolution maps to a different entry instead of a stale hit.
1650
1931
  const cached = this.projectCache.get(resolvedRoot);
1651
1932
  if (cached)
1652
1933
  return this.freshen(cached);
@@ -1891,58 +2172,98 @@ class ToolHandler {
1891
2172
  catch {
1892
2173
  reason = null;
1893
2174
  }
2175
+ try {
2176
+ (0, runtime_log_1.logLifecycle)('watcher.degraded', {
2177
+ projectRoot: cg.getProjectRoot(),
2178
+ reason: reason ?? undefined,
2179
+ });
2180
+ }
2181
+ catch {
2182
+ /* ignore */
2183
+ }
1894
2184
  const composed = `${formatDegradedBanner(reason)}\n\n${head.text}`;
1895
2185
  return { ...result, content: [{ type: 'text', text: composed }, ...tail] };
1896
2186
  }
1897
- // Defensive: some test fakes inject a partial HomeGraph stub without the
1898
- // newer pending-files API. Treat missing/throwing as "no pending files."
1899
- let pending = [];
2187
+ // Spec 0035: pending/dirty is a one-line status footer (withProductStatusFooter),
2188
+ // not the long ⚠️ stale banner. Keep formatStaleBanner helpers for tests /
2189
+ // status tool; do not prepend them on every read tool.
2190
+ return result;
2191
+ }
2192
+ /**
2193
+ * Decorate successful tool text (Spec 0035 status footer + Spec 0038 project-root hint
2194
+ * + Spec 0043 bound projectPath pin notice).
2195
+ * Prepends absolute project root + join guidance when known; appends status when missing.
2196
+ */
2197
+ withProductStatusFooter(result, projectPath) {
2198
+ if (result.isError)
2199
+ return result;
2200
+ const [first, ...rest] = result.content;
2201
+ if (!first || first.type !== 'text')
2202
+ return result;
2203
+ let cg;
1900
2204
  try {
1901
- pending = cg.getPendingFiles?.() ?? [];
2205
+ cg = this.getHomeGraph(projectPath);
1902
2206
  }
1903
2207
  catch {
1904
2208
  return result;
1905
2209
  }
1906
- if (pending.length === 0)
1907
- return result;
1908
- const [first, ...rest] = result.content;
1909
- if (!first || first.type !== 'text')
1910
- return result;
1911
- const text = first.text;
1912
- const inResponse = [];
1913
- const elsewhere = [];
1914
- for (const p of pending) {
1915
- // Substring match against the project-relative POSIX path — that's
1916
- // exactly the format both the watcher and every homegraph response
1917
- // emit, so a plain includes() is sufficient and avoids regex pitfalls.
1918
- if (text.includes(p.path))
1919
- inResponse.push(p);
1920
- else
1921
- elsewhere.push(p);
2210
+ if (this.cg && cg !== this.cg) {
2211
+ try {
2212
+ const sameProject = (0, path_1.resolve)(this.cg.getProjectRoot()) === (0, path_1.resolve)(cg.getProjectRoot());
2213
+ if (sameProject)
2214
+ cg = this.cg;
2215
+ }
2216
+ catch {
2217
+ /* leave cg */
2218
+ }
1922
2219
  }
1923
- let banner = '';
1924
- if (inResponse.length > 0) {
1925
- let dbPath = null;
2220
+ let text = first.text;
2221
+ // Spec 0038: absolute project root + how to join repo-relative paths (idempotent).
2222
+ if (!(0, index_availability_1.textAlreadyHasProjectRootHint)(text)) {
1926
2223
  try {
1927
- // Large indexes skip catch-up — soft banner so agents don't abandon HG for Read.
1928
- const root = cg.getProjectRoot();
1929
- dbPath = (0, path_1.resolve)(root, '.homegraph', 'homegraph.db');
2224
+ const absRoot = (0, path_1.resolve)(cg.getProjectRoot());
2225
+ if (absRoot) {
2226
+ text = `${(0, index_availability_1.formatProjectRootPathHint)(absRoot)}\n\n${text}`;
2227
+ }
1930
2228
  }
1931
2229
  catch {
1932
- dbPath = null;
2230
+ /* no root — skip hint */
1933
2231
  }
1934
- banner = formatStaleBanner(inResponse, {
1935
- catchUpDeferred: (0, memory_budget_1.shouldSkipCatchUpSync)(dbPath),
1936
- });
1937
2232
  }
1938
- let footer = '';
1939
- if (elsewhere.length > 0) {
1940
- footer = formatStaleFooter(elsewhere);
2233
+ // Spec 0043: bound-root soft-pin notice at the very top (consume once).
2234
+ const pinNotice = this.boundProjectPathPinNotice;
2235
+ this.boundProjectPathPinNotice = null;
2236
+ if (pinNotice && !(0, index_availability_1.textAlreadyHasBoundProjectPathPinNotice)(text)) {
2237
+ text = `${pinNotice}\n\n${text}`;
1941
2238
  }
1942
- if (!banner && !footer)
1943
- return result;
1944
- const composed = [banner, text, footer].filter(Boolean).join('\n\n');
1945
- return { ...result, content: [{ type: 'text', text: composed }, ...rest] };
2239
+ // Spec 0035: one-line product index status footer (skip if already present).
2240
+ if (!(0, index_availability_1.textAlreadyHasProductStatus)(text)) {
2241
+ let state;
2242
+ try {
2243
+ state = (0, index_availability_1.resolveProductIndexState)(cg);
2244
+ }
2245
+ catch {
2246
+ return {
2247
+ ...result,
2248
+ content: [{ type: 'text', text }, ...rest],
2249
+ };
2250
+ }
2251
+ let pendingPaths;
2252
+ if (state === 'dirty') {
2253
+ try {
2254
+ pendingPaths = (cg.getPendingFiles?.() ?? []).map((p) => p.path);
2255
+ }
2256
+ catch {
2257
+ pendingPaths = [];
2258
+ }
2259
+ }
2260
+ const line = (0, index_availability_1.formatProductStatusLine)(state, { pendingPaths });
2261
+ text = `${text}\n\n${line}`;
2262
+ }
2263
+ return {
2264
+ ...result,
2265
+ content: [{ type: 'text', text }, ...rest],
2266
+ };
1946
2267
  }
1947
2268
  /**
1948
2269
  * Execute a tool by name.
@@ -1952,10 +2273,47 @@ class ToolHandler {
1952
2273
  */
1953
2274
  async execute(toolName, args, sessionState) {
1954
2275
  const requestStartedAt = Date.now();
2276
+ const result = await this.executeCore(toolName, args, sessionState, requestStartedAt);
2277
+ this.traceToolCall(toolName, args, result, requestStartedAt);
2278
+ return result;
2279
+ }
2280
+ /** Spec 0046: HOMEGRAPH_DEBUG tool summary → stderr + daemon.log. */
2281
+ traceToolCall(toolName, args, result, requestStartedAt) {
2282
+ try {
2283
+ let projectRoot;
2284
+ try {
2285
+ projectRoot = this.getHomeGraph(args.projectPath).getProjectRoot();
2286
+ }
2287
+ catch {
2288
+ projectRoot = typeof args.projectPath === 'string' ? args.projectPath : undefined;
2289
+ }
2290
+ const q = typeof args.query === 'string'
2291
+ ? args.query
2292
+ : typeof args.symbol === 'string'
2293
+ ? args.symbol
2294
+ : undefined;
2295
+ const evidenceStatus = result._meta?.homegraphEvidence?.status
2296
+ ?? result[explore_session_state_1.EXPLORE_EMISSION_KEY]?.evidenceStatus;
2297
+ (0, runtime_log_1.logToolDebug)(toolName, {
2298
+ projectRoot,
2299
+ durationMs: Date.now() - requestStartedAt,
2300
+ isError: !!result.isError,
2301
+ evidenceStatus,
2302
+ query: q,
2303
+ });
2304
+ }
2305
+ catch {
2306
+ /* logging never fails the tool */
2307
+ }
2308
+ }
2309
+ async executeCore(toolName, args, sessionState, requestStartedAt) {
1955
2310
  args = { ...args };
1956
2311
  for (const key of [QUERY_PLAN_ARG, QUERY_DEADLINE_ARG, QUERY_STARTED_ARG, QUERY_INDEX_STATE_ARG, QUERY_FAST_ATTEMPTED_ARG, '_hgEvidenceMaxChars'])
1957
2312
  delete args[key];
1958
2313
  try {
2314
+ // Spec 0043: drop any leftover pin notice from a prior call that exited
2315
+ // before withProductStatusFooter (gate / defer / refuse).
2316
+ this.boundProjectPathPinNotice = null;
1959
2317
  // Block the first tool call on the engine's post-open reconcile so we
1960
2318
  // never serve rows for files deleted/edited while no MCP server was
1961
2319
  // running. The wait is time-boxed (#905): a huge-repo reconcile takes
@@ -2069,7 +2427,7 @@ class ToolHandler {
2069
2427
  && this.areEvidenceFilesCurrent(cachedFiles ?? [], cacheCg.getProjectRoot())))) {
2070
2428
  const diagnosed = this.withQueryPlanDiagnostics(cached, args, true);
2071
2429
  const withWorktree = this.withWorktreeNotice(diagnosed, projectPath);
2072
- return this.withStalenessNotice(withWorktree, projectPath);
2430
+ return this.withProductStatusFooter(this.withStalenessNotice(withWorktree, projectPath), projectPath);
2073
2431
  }
2074
2432
  }
2075
2433
  catch {
@@ -2133,7 +2491,7 @@ class ToolHandler {
2133
2491
  cacheIndex.setEntry(cacheQueries, cacheKey, toolName, served);
2134
2492
  }
2135
2493
  const withWorktree = this.withWorktreeNotice(served, projectPath);
2136
- return this.withStalenessNotice(withWorktree, projectPath);
2494
+ return this.withProductStatusFooter(this.withStalenessNotice(withWorktree, projectPath), projectPath);
2137
2495
  }
2138
2496
  if (requestPlan)
2139
2497
  args[QUERY_FAST_ATTEMPTED_ARG] = true;
@@ -2164,7 +2522,7 @@ class ToolHandler {
2164
2522
  cacheIndex.setEntry(cacheQueries, cacheKey, toolName, result);
2165
2523
  }
2166
2524
  const withWorktree = this.withWorktreeNotice(result, projectPath);
2167
- return this.withStalenessNotice(withWorktree, projectPath);
2525
+ return this.withProductStatusFooter(this.withStalenessNotice(withWorktree, projectPath), projectPath);
2168
2526
  }
2169
2527
  catch (err) {
2170
2528
  // Expected condition, not a malfunction: answer as a SUCCESS so the
@@ -2218,15 +2576,19 @@ class ToolHandler {
2218
2576
  try {
2219
2577
  const cg = this.getHomeGraph(args.projectPath);
2220
2578
  const root = cg.getProjectRoot();
2221
- const decision = (0, explore_repeat_guard_1.decideDepthToolFuse)(sessionState.forProject(root), sessionState.depthToolCount(root), toolName);
2222
- if (!decision.refuse)
2223
- return null;
2224
- const hint = typeof args.symbol === 'string'
2579
+ const prior = sessionState.forProject(root);
2580
+ const symbolHint = typeof args.symbol === 'string'
2225
2581
  ? args.symbol
2226
2582
  : typeof args.file === 'string'
2227
2583
  ? args.file
2228
2584
  : undefined;
2229
- return this.textResult((0, explore_repeat_guard_1.formatDepthToolRefuse)(decision, toolName, hint));
2585
+ // Spec 0044 §10.2: symbols already on the locate list bypass the Partial depth cap.
2586
+ if ((0, locate_contract_1.shouldExemptDepthFuseForLocatedSymbol)(prior, symbolHint))
2587
+ return null;
2588
+ const decision = (0, explore_repeat_guard_1.decideDepthToolFuse)(prior, sessionState.depthToolCount(root), toolName);
2589
+ if (!decision.refuse)
2590
+ return null;
2591
+ return this.textResult((0, explore_repeat_guard_1.formatDepthToolRefuse)(decision, toolName, symbolHint));
2230
2592
  }
2231
2593
  catch {
2232
2594
  return null;
@@ -2298,6 +2660,10 @@ class ToolHandler {
2298
2660
  const last = prior?.calls[prior.calls.length - 1];
2299
2661
  if (!last || (0, explore_session_state_1.inferExploreEvidenceStatus)(last) === 'complete')
2300
2662
  return;
2663
+ const symbolHint = typeof args.symbol === 'string' ? args.symbol : undefined;
2664
+ // Spec 0044 §10.2: located-symbol drills do not consume the Partial depth budget.
2665
+ if ((0, locate_contract_1.shouldExemptDepthFuseForLocatedSymbol)(prior, symbolHint))
2666
+ return;
2301
2667
  sessionState.recordDepthTool(root);
2302
2668
  }
2303
2669
  catch { /* bookkeeping only */ }
@@ -2509,7 +2875,7 @@ class ToolHandler {
2509
2875
  */
2510
2876
  maybeDeepToolPhaseGate(cg, _toolName) {
2511
2877
  const state = (0, index_availability_1.resolveProductIndexState)(cg);
2512
- if (state === 'full')
2878
+ if (state === 'full' || state === 'dirty')
2513
2879
  return null;
2514
2880
  if (state === 'syncing') {
2515
2881
  return this.textResult((0, index_availability_1.productIndexGuidance)('syncing'));
@@ -3146,6 +3512,13 @@ class ToolHandler {
3146
3512
  registeredAt,
3147
3513
  };
3148
3514
  }
3515
+ if (m?.synthesizedBy === 'arkts-route-map') {
3516
+ return {
3517
+ label: `Harmony route registration (config → page/builder)`,
3518
+ compact: `route-map registration${at}`,
3519
+ registeredAt,
3520
+ };
3521
+ }
3149
3522
  if (m?.synthesizedBy === 'goframe-route') {
3150
3523
  const route = m.route ? `\`${String(m.route)}\`` : 'a route';
3151
3524
  return {
@@ -6314,7 +6687,13 @@ class ToolHandler {
6314
6687
  const isTestPath = (p) => /(^|\/)(tests?|spec)\//i.test(p) || /\.(test|spec)\./i.test(p);
6315
6688
  const fileNodes = new Map();
6316
6689
  const seedIds = new Set();
6317
- const domainPathTokens = (0, query_utils_1.mechanismDomainPathTokens)(query);
6690
+ const domainPathTokens = (0, locate_contract_1.shouldSuppressSynonymExpansion)({
6691
+ hasExactAnchorHit: (0, query_utils_1.extractInRepoLocateAnchors)(query).length > 0
6692
+ || (0, query_utils_1.extractTypeNamesFromQuery)(query).length > 0,
6693
+ hasLiteralWitness: false,
6694
+ })
6695
+ ? []
6696
+ : (0, query_utils_1.mechanismDomainPathTokens)(query);
6318
6697
  const addNode = (n) => {
6319
6698
  if ((0, arkts_1.isOhosApiFilePath)(n.filePath))
6320
6699
  return;
@@ -6773,9 +7152,8 @@ class ToolHandler {
6773
7152
  * Static AA class/interface nodes that look like domain Managers/Services.
6774
7153
  */
6775
7154
  collectDomainRoleSymbols(cg, query, domainTokens) {
6776
- const tokens = domainTokens.length > 0
6777
- ? domainTokens
6778
- : (0, query_utils_1.mechanismDomainPathTokens)(query);
7155
+ // Spec 0044 §9: callers may pass [] to suppress CJK→ASCII synonym expansion.
7156
+ const tokens = domainTokens;
6779
7157
  if (tokens.length === 0)
6780
7158
  return [];
6781
7159
  const byId = new Map();
@@ -7222,7 +7600,9 @@ class ToolHandler {
7222
7600
  }
7223
7601
  if (!callersOnly) {
7224
7602
  for (const { node: c } of cg.getCallees(id).slice(0, 10)) {
7225
- // Skip log*/hilog helpers — Export→logInfo homonyms ballooned seeds.
7603
+ if ((0, locate_contract_1.shouldDemoteLogToastSpine)(c.name, query)) {
7604
+ continue;
7605
+ }
7226
7606
  if (/^log(?:Info|Error|Warn|Debug|Fatal)?$/i.test(c.name) || /^hilog$/i.test(c.name)) {
7227
7607
  continue;
7228
7608
  }
@@ -7411,11 +7791,15 @@ class ToolHandler {
7411
7791
  continue;
7412
7792
  trail.push(`- \`${seedNode.name}\` (${seedNode.kind}) — ${seedNode.filePath}:${seedNode.startLine}`);
7413
7793
  for (const c of callers) {
7794
+ if ((0, query_utils_1.isMcpNoiseNode)(c.node))
7795
+ continue;
7414
7796
  const loc = c.node.startLine ? `:${c.node.startLine}` : '';
7415
7797
  trail.push(` ← used by \`${c.node.name}\` (${c.node.kind}) — ${c.node.filePath}${loc}`);
7416
7798
  trailBullets++;
7417
7799
  }
7418
7800
  for (const c of callees) {
7801
+ if ((0, query_utils_1.isMcpNoiseNode)(c.node))
7802
+ continue;
7419
7803
  const loc = c.node.startLine ? `:${c.node.startLine}` : '';
7420
7804
  trail.push(` → calls \`${c.node.name}\` (${c.node.kind}) — ${c.node.filePath}${loc}`);
7421
7805
  trailBullets++;
@@ -9634,6 +10018,8 @@ class ToolHandler {
9634
10018
  const explicitMaxFiles = typeof args.maxFiles === 'number' && !Number.isNaN(args.maxFiles);
9635
10019
  let maxFiles = (0, utils_1.clamp)(args.maxFiles || budget.defaultMaxFiles, 1, 20);
9636
10020
  const queryFileBasenames = (0, query_utils_1.extractFileBasenamesFromQuery)(query);
10021
+ const locateAnchors = (0, query_utils_1.extractInRepoLocateAnchors)(query);
10022
+ const exploreSourceScope = (0, query_utils_1.resolveExploreSourceScope)(query, plan?.sourceScope);
9637
10023
  const interpretationQuery = feature('queryAsInterpretationSurvey', query_utils_1.queryAsInterpretationSurvey);
9638
10024
  const testOnlyInterpretation = feature('queryAsTestOnlyInterpretation', query_utils_1.queryAsTestOnlyInterpretation);
9639
10025
  const crossModuleFlow = feature('queryAsCrossModuleFlowSurvey', query_utils_1.queryAsCrossModuleFlowSurvey) || plan?.intent === 'flow';
@@ -9645,19 +10031,28 @@ class ToolHandler {
9645
10031
  ? `${queryFileBasenames[0]} ${query}`
9646
10032
  : queryFileBasenames.length === 1
9647
10033
  ? `${queryFileBasenames[0]} ${query}`
9648
- : query;
10034
+ : locateAnchors.length === 1
10035
+ ? `${locateAnchors[0]} ${query}`
10036
+ : query;
10037
+ const wantHints = !!(plan && (plan.source === 'llm' || plan.literalTexts?.length || plan.anchors?.length))
10038
+ || exploreSourceScope !== 'all';
9649
10039
  const subgraph = await cg.findRelevantContext(contextQuery, {
9650
10040
  ...contextOpts,
9651
- ...(plan && (plan.source === 'llm' || plan.literalTexts?.length) ? { retrievalHints: {
9652
- symbols: plan.anchors.filter((anchor) => !(plan.bindings ?? []).some((node) => anchor === node.name || anchor === node.qualifiedName)),
9653
- searchTerms: plan.searchTerms, literalTexts: plan.literalTexts, sourceScope: plan.sourceScope, nodeIds: (plan.bindings ?? []).map((node) => node.id),
10041
+ ...(wantHints ? { retrievalHints: {
10042
+ symbols: [
10043
+ ...(plan?.anchors ?? []).filter((anchor) => !(plan?.bindings ?? []).some((node) => anchor === node.name || anchor === node.qualifiedName)),
10044
+ ...locateAnchors,
10045
+ ].slice(0, 16),
10046
+ searchTerms: plan?.searchTerms ?? [], literalTexts: plan?.literalTexts,
10047
+ sourceScope: exploreSourceScope,
10048
+ nodeIds: (plan?.bindings ?? []).map((node) => node.id),
9654
10049
  } } : {}),
9655
10050
  });
9656
- // Path-first: always seed nodes from an explicit `Foo.ets` basename so a
9657
- // CJK-only ask + path (or a shared prop like showSearchIcon) cannot leave
9658
- // the named file out of the subgraph / digests.
9659
- if (queryFileBasenames.length > 0 && plan?.sourceScope !== 'sdk') {
9660
- for (const base of queryFileBasenames.slice(0, 3)) {
10051
+ // Path-first + Spec 0042 identifier seeding: Foo.ets and PascalCase ≥8 exact hits
10052
+ // become roots so taskContext-polluted FTS cannot drop the named in-repo file.
10053
+ if (exploreSourceScope !== 'sdk') {
10054
+ const seedNames = [...new Set([...queryFileBasenames, ...locateAnchors])].slice(0, 6);
10055
+ for (const base of seedNames) {
9661
10056
  let hits = [];
9662
10057
  try {
9663
10058
  hits = cg.searchNodes(base, { limit: 50 });
@@ -9666,7 +10061,15 @@ class ToolHandler {
9666
10061
  continue;
9667
10062
  }
9668
10063
  for (const r of hits) {
9669
- if (!(0, query_utils_1.fileMatchesQueryBasename)(r.node.filePath, [base]))
10064
+ if ((0, query_utils_1.isMcpNoiseNode)(r.node))
10065
+ continue;
10066
+ const fp = r.node.filePath.replace(/\\/g, '/');
10067
+ const stem = fp.split('/').pop()?.replace(/\.[^.]+$/, '') ?? '';
10068
+ const exactFile = stem.toLowerCase() === base.toLowerCase()
10069
+ || (0, query_utils_1.fileMatchesQueryBasename)(r.node.filePath, [base]);
10070
+ const exactSymbol = r.node.name === base
10071
+ || (r.node.qualifiedName?.split(/::|\./).pop() === base);
10072
+ if (!exactFile && !exactSymbol)
9670
10073
  continue;
9671
10074
  if (!subgraph.nodes.has(r.node.id)) {
9672
10075
  subgraph.nodes.set(r.node.id, r.node);
@@ -9677,10 +10080,26 @@ class ToolHandler {
9677
10080
  }
9678
10081
  const literalSource = this.renderLiteralSource(cg, subgraph);
9679
10082
  if (subgraph.nodes.size === 0) {
9680
- const text = literalSource.text || `No relevant code found for "${query}"`;
9681
- return this.exploreResult(text, { projectRoot, query, files: literalSource.files,
9682
- sourceBytes: literalSource.files.reduce((sum, file) => sum + file.bytes, 0), responseBytes: text.length,
9683
- locatedNodes: literalSource.nodes, partial: true, evidenceStatus: literalSource.text ? 'partial' : 'empty' });
10083
+ // Spec 0044 §9: empty exact evidence → Miss (paths only), not a source dump.
10084
+ let fuzzyPaths = [];
10085
+ try {
10086
+ fuzzyPaths = [...new Set(cg.searchNodes(query.slice(0, 64), { limit: 12 })
10087
+ .map((r) => r.node.filePath.replace(/\\/g, '/'))
10088
+ .filter((p) => p && !(0, arkts_1.isOhosApiFilePath)(p)))].slice(0, 8);
10089
+ }
10090
+ catch { /* ignore */ }
10091
+ const text = literalSource.text
10092
+ || (0, locate_contract_1.formatMissBanner)(fuzzyPaths);
10093
+ return this.exploreResult(text, {
10094
+ projectRoot,
10095
+ query,
10096
+ files: literalSource.files,
10097
+ sourceBytes: literalSource.files.reduce((sum, file) => sum + file.bytes, 0),
10098
+ responseBytes: text.length,
10099
+ locatedNodes: literalSource.nodes,
10100
+ partial: true,
10101
+ evidenceStatus: literalSource.text ? 'partial' : 'empty',
10102
+ });
9684
10103
  }
9685
10104
  // Seed import nodes for @kit.* / *Kit names (and named symbols like taskpool).
9686
10105
  const importTerms = (0, query_utils_1.extractImportSearchTerms)(query);
@@ -9848,7 +10267,7 @@ class ToolHandler {
9848
10267
  const raw = isQual ? this.findAllSymbols(cg, t).nodes : cg.getNodesByName(t);
9849
10268
  let cands = raw
9850
10269
  .filter((n) => SEED_KINDS.has(n.kind) && !isTestPath(n.filePath)
9851
- && !(plan?.sourceScope === 'local' && (0, arkts_1.isOhosApiFilePath)(n.filePath)))
10270
+ && !(exploreSourceScope === 'local' && (0, arkts_1.isOhosApiFilePath)(n.filePath)))
9852
10271
  .sort((a, b) => {
9853
10272
  // Prefer callables over types when both share a name, then body size.
9854
10273
  const ac = CALLABLE.has(a.kind) ? 1 : 0;
@@ -10209,10 +10628,12 @@ class ToolHandler {
10209
10628
  // dropped, so the budget never fills with incidental files. Guarded so it
10210
10629
  // never prunes below 2.
10211
10630
  if (maxGraph > 0) {
10631
+ const litKeep = new Set((subgraph.literalEvidence?.hits ?? []).map((h) => h.filePath).filter(Boolean));
10212
10632
  const gated = relevantFiles.filter(([fp]) => (fileGraphScore.get(fp) ?? 0) >= maxGraph * 0.06
10213
10633
  || centralFiles.has(fp)
10214
10634
  || entryFiles.has(fp)
10215
10635
  || changeSurfaceFiles.has(fp)
10636
+ || litKeep.has(fp)
10216
10637
  || (fileTermHits.get(fp) ?? 0) >= 2);
10217
10638
  if (gated.length >= 2)
10218
10639
  relevantFiles = gated;
@@ -10237,6 +10658,22 @@ class ToolHandler {
10237
10658
  // tier so it isn't buried under files that merely share surface words (#1064).
10238
10659
  for (const fp of changeSurfaceFiles)
10239
10660
  namedSeedFiles.add(fp);
10661
+ // Spec 0044 §7: literal witness files must stay in the pack and rank with named seeds.
10662
+ {
10663
+ const litPaths = [...new Set((subgraph.literalEvidence?.hits ?? []).map((h) => h.filePath).filter(Boolean))];
10664
+ for (const fp of litPaths) {
10665
+ entryFiles.add(fp);
10666
+ namedSeedFiles.add(fp);
10667
+ if (relevantFiles.some(([f]) => f === fp))
10668
+ continue;
10669
+ let group = fileGroups.get(fp);
10670
+ if (!group) {
10671
+ group = { nodes: [], score: 1000 };
10672
+ fileGroups.set(fp, group);
10673
+ }
10674
+ relevantFiles.push([fp, group]);
10675
+ }
10676
+ }
10240
10677
  // Multi-term corroboration tier: a file that is BOTH (a) an entry/central file
10241
10678
  // (a search root, named seed, or graph-central hub — i.e. structurally part of
10242
10679
  // the answer) AND (b) matched by ≥2 DISTINCT query terms must not be buried by
@@ -10260,7 +10697,7 @@ class ToolHandler {
10260
10697
  const sortedFiles = relevantFiles.sort((a, b) => {
10261
10698
  const aPath = a[0].toLowerCase();
10262
10699
  const bPath = b[0].toLowerCase();
10263
- if (plan?.sourceScope === 'local') {
10700
+ if (exploreSourceScope === 'local') {
10264
10701
  const sdkOrder = Number((0, arkts_1.isOhosApiFilePath)(a[0])) - Number((0, arkts_1.isOhosApiFilePath)(b[0]));
10265
10702
  if (sdkOrder)
10266
10703
  return sdkOrder;
@@ -10269,6 +10706,17 @@ class ToolHandler {
10269
10706
  - Number((subgraph.literalEvidence?.hits ?? []).some((hit) => hit.filePath === a[0]));
10270
10707
  if (literalOrder)
10271
10708
  return literalOrder;
10709
+ // Spec 0044 §7: demote Logger/hilog/Toast-only files when query did not name them.
10710
+ const logToastDemote = (fp) => {
10711
+ const group = fileGroups.get(fp);
10712
+ if (!group?.nodes.length)
10713
+ return 0;
10714
+ const allNoise = group.nodes.every((n) => (0, locate_contract_1.shouldDemoteLogToastSpine)(n.name, query));
10715
+ return allNoise ? 1 : 0;
10716
+ };
10717
+ const logOrder = logToastDemote(a[0]) - logToastDemote(b[0]);
10718
+ if (logOrder)
10719
+ return logOrder;
10272
10720
  // Query-named file (LocationController.ets in the question) before partial
10273
10721
  // substring matches (control.ets matching "Controller" inside LocationController).
10274
10722
  const aExactBase = (0, query_utils_1.fileMatchesQueryBasename)(a[0], queryFileBasenames) ? 1 : 0;
@@ -11341,7 +11789,15 @@ class ToolHandler {
11341
11789
  const headerSuffix = omittedCount > 0
11342
11790
  ? `${headerSymbols.join(', ')}, +${omittedCount} more`
11343
11791
  : headerSymbols.join(', ');
11344
- const fileHeader = fileSectionHeader(filePath, headerSuffix);
11792
+ let fileHeader = fileSectionHeader(filePath, headerSuffix);
11793
+ // Spec 0044 §10.1: warn when basename ≠ primary declaration.
11794
+ {
11795
+ const primary = group?.nodes.find((n) => ['class', 'struct', 'component', 'interface', 'enum'].includes(n.kind)
11796
+ && !n.name.startsWith('%')) ?? group?.nodes.find((n) => ['function', 'method'].includes(n.kind) && !n.name.startsWith('%'));
11797
+ const mismatch = (0, locate_contract_1.formatFilenameDeclarationMismatch)(filePath, primary?.name);
11798
+ if (mismatch)
11799
+ fileHeader = `${fileHeader}\n${mismatch}`;
11800
+ }
11345
11801
  // The total cap bounds INCIDENTAL files only. A file that DEFINES a symbol
11346
11802
  // the agent named (or that's on the flow spine) renders even when the
11347
11803
  // nominal total is used up — it's the answer, and the set is bounded by
@@ -11635,6 +12091,106 @@ class ToolHandler {
11635
12091
  }
11636
12092
  return { text: sections.join('\n\n'), files, nodes };
11637
12093
  }
12094
+ /**
12095
+ * Spec 0039: when the query names Harmony route profiles, lead with a short
12096
+ * Registration sources table so agents see JSON was indexed (avoid re-Read).
12097
+ */
12098
+ prependHarmonyRegistrationSources(result, projectRoot, query) {
12099
+ if (!/route_map|router_map|main_pages/i.test(query))
12100
+ return result;
12101
+ const [first, ...rest] = result.content;
12102
+ if (!first || first.type !== 'text')
12103
+ return result;
12104
+ if (/^\*\*Registration sources\*\*/m.test(first.text))
12105
+ return result;
12106
+ let cg;
12107
+ try {
12108
+ cg = this.getHomeGraph(projectRoot);
12109
+ }
12110
+ catch {
12111
+ return result;
12112
+ }
12113
+ const section = formatHarmonyRegistrationSources(cg);
12114
+ if (!section)
12115
+ return result;
12116
+ return {
12117
+ ...result,
12118
+ content: [{ type: 'text', text: `${section}\n\n${first.text}` }, ...rest],
12119
+ };
12120
+ }
12121
+ /** Spec 0041: lead with Resource hits when string.json is named or matched. */
12122
+ prependHarmonyResourceHits(result, projectRoot, query) {
12123
+ const [first, ...rest] = result.content;
12124
+ if (!first || first.type !== 'text')
12125
+ return result;
12126
+ if (/^\*\*Resource hits\*\*/m.test(first.text) || /\n\*\*Resource hits\*\*/m.test(first.text)) {
12127
+ return result;
12128
+ }
12129
+ let cg;
12130
+ try {
12131
+ cg = this.getHomeGraph(projectRoot);
12132
+ }
12133
+ catch {
12134
+ return result;
12135
+ }
12136
+ const section = formatHarmonyResourceHits(cg, query);
12137
+ if (!section)
12138
+ return result;
12139
+ return {
12140
+ ...result,
12141
+ content: [{ type: 'text', text: `${section}\n\n${first.text}` }, ...rest],
12142
+ };
12143
+ }
12144
+ /** Spec 0048 §1–2: Capability profiles or form negative evidence. */
12145
+ prependHarmonyCapabilityProfiles(result, projectRoot, query) {
12146
+ const [first, ...rest] = result.content;
12147
+ if (!first || first.type !== 'text')
12148
+ return result;
12149
+ if (/^\*\*Capability profiles/m.test(first.text)
12150
+ || /\n\*\*Capability profiles/m.test(first.text)) {
12151
+ return result;
12152
+ }
12153
+ let cg;
12154
+ try {
12155
+ cg = this.getHomeGraph(projectRoot);
12156
+ }
12157
+ catch {
12158
+ return result;
12159
+ }
12160
+ const section = formatHarmonyCapabilityProfiles(cg, query);
12161
+ if (!section)
12162
+ return result;
12163
+ return {
12164
+ ...result,
12165
+ content: [{ type: 'text', text: `${section}\n\n${first.text}` }, ...rest],
12166
+ };
12167
+ }
12168
+ /** Spec 0048 §5: seam notes for located anchors. */
12169
+ prependHarmonySeamNotes(result, projectRoot, emission) {
12170
+ const located = emission?.locatedNodes;
12171
+ if (!located || located.length === 0)
12172
+ return result;
12173
+ const [first, ...rest] = result.content;
12174
+ if (!first || first.type !== 'text')
12175
+ return result;
12176
+ if (/^\*\*Seam notes\*\*/m.test(first.text) || /\n\*\*Seam notes\*\*/m.test(first.text)) {
12177
+ return result;
12178
+ }
12179
+ let cg;
12180
+ try {
12181
+ cg = this.getHomeGraph(projectRoot);
12182
+ }
12183
+ catch {
12184
+ return result;
12185
+ }
12186
+ const section = formatHarmonySeamNotes(cg, projectRoot, located);
12187
+ if (!section)
12188
+ return result;
12189
+ return {
12190
+ ...result,
12191
+ content: [{ type: 'text', text: `${section}\n\n${first.text}` }, ...rest],
12192
+ };
12193
+ }
11638
12194
  /**
11639
12195
  * An explore response plus the record of what it emitted (CG-17). The record
11640
12196
  * rides the result only as far as {@link execute}, which files it into the
@@ -11642,17 +12198,42 @@ class ToolHandler {
11642
12198
  */
11643
12199
  exploreResult(text, emission) {
11644
12200
  const meta = (0, explore_repeat_guard_1.inferExplorePartialMeta)(text);
11645
- const result = this.textResult(text);
12201
+ let evidenceStatus = emission.evidenceStatus
12202
+ ?? (emission.sourceBytes > 0 ? (meta.partial ? 'partial' : 'complete') : 'partial');
12203
+ let body = text;
12204
+ // Spec 0044 §8: Located banner when complete exact evidence; soften Partial wording.
12205
+ if (evidenceStatus === 'complete' && !body.includes(locate_contract_1.LOCATED_MARKER)) {
12206
+ if (/\*\*Partial locator\*\*/i.test(body)) {
12207
+ body = body.replace(/>\s*\*\*Partial locator\*\*[^\n]*/gi, (0, locate_contract_1.formatLocatedBanner)());
12208
+ }
12209
+ else if (!/\*\*ANSWER NOW/i.test(body) && !body.includes(locate_contract_1.MISS_MARKER)) {
12210
+ body = `${(0, locate_contract_1.formatLocatedBanner)()}\n\n${body}`;
12211
+ }
12212
+ }
12213
+ else if ((evidenceStatus === 'partial' || meta.partial)
12214
+ && /\*\*Partial locator\*\*/i.test(body)
12215
+ && !body.includes(locate_contract_1.LOCATED_MARKER)) {
12216
+ // Spec 0044 §8: Partial next-step → node/usages/search (not Grep same names).
12217
+ body = body.replace(/>\s*\*\*Partial locator\*\*[^\n]*/gi, (0, locate_contract_1.formatPartialBanner)());
12218
+ }
12219
+ let result = this.textResult(body);
12220
+ result = this.prependHarmonyRegistrationSources(result, emission.projectRoot, emission.query);
12221
+ result = this.prependHarmonyCapabilityProfiles(result, emission.projectRoot, emission.query);
12222
+ result = this.prependHarmonyResourceHits(result, emission.projectRoot, emission.query);
11646
12223
  result[explore_session_state_1.EXPLORE_EMISSION_KEY] = {
11647
12224
  ...emission,
11648
- evidenceStatus: emission.evidenceStatus ?? (emission.sourceBytes > 0 ? (meta.partial ? 'partial' : 'complete') : 'partial'),
11649
- partial: emission.partial ?? (emission.evidenceStatus && emission.evidenceStatus !== 'complete' ? true : meta.partial),
12225
+ evidenceStatus,
12226
+ partial: emission.partial ?? (evidenceStatus !== 'complete' ? true : meta.partial),
11650
12227
  nextAnchor: emission.nextAnchor ?? meta.nextAnchor,
11651
12228
  };
12229
+ result = this.prependHarmonySeamNotes(result, emission.projectRoot, result[explore_session_state_1.EXPLORE_EMISSION_KEY]);
11652
12230
  return result;
11653
12231
  }
11654
12232
  /** Attach a minimal session emission when a compact/light path used textResult. */
11655
12233
  ensureExploreEmission(result, projectRoot, query) {
12234
+ result = this.prependHarmonyRegistrationSources(result, projectRoot, query);
12235
+ result = this.prependHarmonyCapabilityProfiles(result, projectRoot, query);
12236
+ result = this.prependHarmonyResourceHits(result, projectRoot, query);
11656
12237
  if (result[explore_session_state_1.EXPLORE_EMISSION_KEY]) {
11657
12238
  const em = result[explore_session_state_1.EXPLORE_EMISSION_KEY];
11658
12239
  if (em.partial === undefined || !em.nextAnchor) {
@@ -11662,6 +12243,7 @@ class ToolHandler {
11662
12243
  if (!em.nextAnchor && meta.nextAnchor)
11663
12244
  em.nextAnchor = meta.nextAnchor;
11664
12245
  }
12246
+ result = this.prependHarmonySeamNotes(result, projectRoot, em);
11665
12247
  return result;
11666
12248
  }
11667
12249
  const text = result.content?.[0]?.text ?? '';
@@ -11835,7 +12417,8 @@ class ToolHandler {
11835
12417
  const nodes = cg.getNodesInFile(filePath)
11836
12418
  .filter((n) => n.kind !== 'file' && n.kind !== 'import' && n.kind !== 'export')
11837
12419
  .sort((a, b) => a.startLine - b.startLine);
11838
- const dependents = cg.getFileDependents(filePath);
12420
+ const dependents = cg.getFileDependents(filePath)
12421
+ .filter((p) => !p.includes('@dummy') && !p.replace(/\\/g, '/').split('/').pop()?.startsWith('@dummy'));
11839
12422
  // Compact, one-line blast radius (homegraph's value-add over a plain Read).
11840
12423
  const depSummary = dependents.length
11841
12424
  ? `used by ${dependents.length} file${dependents.length === 1 ? '' : 's'}: ${dependents.slice(0, 8).join(', ')}${dependents.length > 8 ? `, +${dependents.length - 8} more` : ''}`
@@ -12015,6 +12598,8 @@ class ToolHandler {
12015
12598
  for (const e of edges) {
12016
12599
  if (seen.has(e.node.id))
12017
12600
  continue;
12601
+ if ((0, query_utils_1.isMcpNoiseNode)(e.node))
12602
+ continue;
12018
12603
  seen.add(e.node.id);
12019
12604
  out.push(e);
12020
12605
  }
@@ -12190,21 +12775,47 @@ class ToolHandler {
12190
12775
  return this.textResult('No modules found in the project map.');
12191
12776
  }
12192
12777
  const FILE_CAP = 80;
12193
- const productState = (0, index_availability_1.resolveProductIndexState)(cg);
12778
+ const projectRoot = cg.getProjectRoot();
12779
+ const hasHarmony = map.modules.some((m) => m.kind === 'harmony');
12194
12780
  const lines = [
12195
- `**Project map** (status=${productState}, phase=${map.phase})`,
12781
+ `**Project map** (phase=${map.phase})`,
12196
12782
  `modules: ${map.modules.length} · files: ${map.fileCount}`,
12197
- '',
12198
12783
  ];
12199
- if (productState === 'fast' || map.phase === 'fast' || map.phase === 'indexing') {
12200
- lines.push('_Full symbol index still building — this map has modules/files only (no call graph)._', '');
12784
+ // Spec 0040 — Harmony skeleton summary (navigation pointers, not call edges).
12785
+ try {
12786
+ const bundle = (0, project_map_1.readHarmonyAppBundleName)(projectRoot);
12787
+ if (bundle)
12788
+ lines.push(`bundle: \`${bundle}\` (from app.json5)`);
12789
+ }
12790
+ catch {
12791
+ /* omit */
12201
12792
  }
12202
- if (productState === 'syncing') {
12203
- lines.push('_Index write in progress — map may be briefly stale._', '');
12793
+ if (hasHarmony) {
12794
+ lines.push('modules from `build-profile.json5` — skeleton map only (not call edges; use `homegraph_explore` for route_map → page)');
12204
12795
  }
12796
+ lines.push('');
12205
12797
  for (const m of map.modules) {
12206
12798
  const rootLabel = m.rootPath || '.';
12207
12799
  lines.push(`### ${m.name} (\`${rootLabel}\`) · ${m.kind} · ${m.fileCount} files`);
12800
+ if (m.kind === 'harmony' || m.rootPath) {
12801
+ try {
12802
+ const profiles = (0, project_map_1.listHarmonyRouteProfilesUnderModule)(projectRoot, m.rootPath || '');
12803
+ for (const p of profiles) {
12804
+ lines.push(`- route profile: \`${p}\``);
12805
+ }
12806
+ }
12807
+ catch {
12808
+ /* omit */
12809
+ }
12810
+ try {
12811
+ const ohpm = (0, project_map_1.readModuleOhPackageName)(projectRoot, m.rootPath || '');
12812
+ if (ohpm)
12813
+ lines.push(`- oh-package: \`${ohpm}\``);
12814
+ }
12815
+ catch {
12816
+ /* omit */
12817
+ }
12818
+ }
12208
12819
  if (m.files && m.files.length > 0) {
12209
12820
  const shown = m.files.slice(0, FILE_CAP);
12210
12821
  for (const f of shown) {
@@ -12216,6 +12827,42 @@ class ToolHandler {
12216
12827
  }
12217
12828
  lines.push('');
12218
12829
  }
12830
+ // Spec 0048 §4 — module roster with local file: deps.
12831
+ try {
12832
+ const roster = (0, project_map_1.formatHarmonyModuleRoster)(projectRoot, map.modules);
12833
+ if (roster) {
12834
+ lines.push(roster);
12835
+ lines.push('');
12836
+ }
12837
+ }
12838
+ catch {
12839
+ /* omit roster on failure */
12840
+ }
12841
+ // Spec 0042 B — path inventory (string/rawfile/media + unindexed package dirs).
12842
+ // Skip when the caller filtered to a single non-Harmony module? Still useful
12843
+ // project-wide; keep always on Harmony-shaped trees or whenever scan finds hits.
12844
+ try {
12845
+ const indexedPaths = map.modules.flatMap((m) => (m.files ?? []).map((f) => f.path));
12846
+ // When includeFiles=false, files arrays are empty — fall back to full map paths.
12847
+ let pathsForUnindexed = indexedPaths;
12848
+ if (pathsForUnindexed.length === 0) {
12849
+ try {
12850
+ const full = cg.getProjectMap({ includeFiles: true });
12851
+ pathsForUnindexed = full.modules.flatMap((m) => (m.files ?? []).map((f) => f.path));
12852
+ }
12853
+ catch {
12854
+ pathsForUnindexed = [];
12855
+ }
12856
+ }
12857
+ const inv = (0, project_map_1.scanHarmonyResourceInventory)(projectRoot, { indexedPaths: pathsForUnindexed });
12858
+ const section = (0, project_map_1.formatHarmonyResourceInventory)(inv);
12859
+ if (section) {
12860
+ lines.push(section);
12861
+ }
12862
+ }
12863
+ catch {
12864
+ /* omit inventory on scan failure */
12865
+ }
12219
12866
  return this.textResult(lines.join('\n').trimEnd());
12220
12867
  }
12221
12868
  /**