homegraph 1.5.10 → 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 (35) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/dist/extraction/grammars.d.ts +19 -0
  3. package/dist/extraction/grammars.js +44 -1
  4. package/dist/mcp/arkts-evidence-packs.js +1 -0
  5. package/dist/mcp/daemon.d.ts +21 -3
  6. package/dist/mcp/daemon.js +60 -6
  7. package/dist/mcp/engine.d.ts +26 -0
  8. package/dist/mcp/engine.js +143 -7
  9. package/dist/mcp/index-availability.d.ts +22 -0
  10. package/dist/mcp/index-availability.js +40 -1
  11. package/dist/mcp/index.js +9 -0
  12. package/dist/mcp/indexable-root.d.ts +22 -0
  13. package/dist/mcp/indexable-root.js +140 -0
  14. package/dist/mcp/liveness-watchdog.d.ts +6 -1
  15. package/dist/mcp/liveness-watchdog.js +17 -5
  16. package/dist/mcp/locate-contract.d.ts +50 -0
  17. package/dist/mcp/locate-contract.js +146 -0
  18. package/dist/mcp/server-instructions.d.ts +2 -2
  19. package/dist/mcp/server-instructions.js +7 -4
  20. package/dist/mcp/session.js +15 -0
  21. package/dist/mcp/tools.d.ts +49 -2
  22. package/dist/mcp/tools.js +714 -64
  23. package/dist/project-map/index.d.ts +45 -0
  24. package/dist/project-map/index.js +371 -3
  25. package/dist/resolution/callback-synthesizer.js +251 -0
  26. package/dist/resolution/frameworks/arkts-entry.d.ts +35 -6
  27. package/dist/resolution/frameworks/arkts-entry.js +513 -30
  28. package/dist/resolution/index.js +25 -21
  29. package/dist/runtime-log.d.ts +52 -0
  30. package/dist/runtime-log.js +199 -0
  31. package/dist/search/query-plan-provider.js +3 -2
  32. package/dist/search/query-plan.js +21 -19
  33. package/dist/search/query-utils.d.ts +13 -0
  34. package/dist/search/query-utils.js +64 -0
  35. package/package.json +1 -1
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,8 +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");
34
+ const grammars_1 = require("../extraction/grammars");
35
+ const project_map_1 = require("../project-map");
29
36
  const index_availability_1 = require("./index-availability");
30
37
  const directory_1 = require("../directory");
38
+ const runtime_log_1 = require("../runtime-log");
31
39
  // Lazy-load the heavy HomeGraph chain off the MCP startup path — see the same
32
40
  // helper in engine.ts. ToolHandler must load to answer tools/list (static
33
41
  // schemas), but it must NOT drag in sqlite/query layers before the daemon binds;
@@ -55,6 +63,7 @@ const arkts_1 = require("../extraction/languages/arkts");
55
63
  const explore_session_state_1 = require("./explore-session-state");
56
64
  const explore_dedup_1 = require("./explore-dedup");
57
65
  const explore_repeat_guard_1 = require("./explore-repeat-guard");
66
+ const locate_contract_1 = require("./locate-contract");
58
67
  const dynamic_boundaries_1 = require("./dynamic-boundaries");
59
68
  const query_cache_1 = require("./query-cache");
60
69
  const diff_impact_1 = require("./diff-impact");
@@ -565,6 +574,258 @@ function fileSectionHeader(filePath, suffix) {
565
574
  ? `${FILE_SECTION_PREFIX}${filePath}\`** — ${suffix}`
566
575
  : `${FILE_SECTION_PREFIX}${filePath}\`**`;
567
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
+ }
568
829
  /**
569
830
  * Per-file staleness banner emitted at the top of a tool response when the
570
831
  * file watcher has pending events for files referenced by the response.
@@ -921,11 +1182,12 @@ 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
1188
  'For a missing usage, dependency/cycle or native-registration relation, use ' +
927
1189
  '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. ' +
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. ' +
929
1191
  'Reuse unchanged complete ranges; refresh missing, edited or truncated evidence. ' +
930
1192
  'No new evidence → change to a targeted source inspection, not a paraphrased explore. ' +
931
1193
  'Partial/busy → at most one focused recovery for the named gap; budgets are ceilings, not required calls. ' +
@@ -967,9 +1229,12 @@ exports.tools = [
967
1229
  },
968
1230
  {
969
1231
  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.',
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).',
973
1238
  inputSchema: {
974
1239
  type: 'object',
975
1240
  properties: {
@@ -1326,6 +1591,10 @@ class ToolHandler {
1326
1591
  // The directory the server last searched for a default project. Surfaced in
1327
1592
  // the "not initialized" error so users can see why detection missed.
1328
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;
1329
1598
  // Per-start-path cache of the git worktree/index mismatch (issue #155). The
1330
1599
  // mismatch is a fixed property of (where the request came from → which
1331
1600
  // .homegraph/ it resolves to), so the up-to-two `git rev-parse` spawns run
@@ -1580,6 +1849,11 @@ class ToolHandler {
1580
1849
  *
1581
1850
  * Walks up parent directories to find the nearest .homegraph/ folder,
1582
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.
1583
1857
  */
1584
1858
  getHomeGraph(projectPath) {
1585
1859
  const sourcesMode = (0, graph_sources_1.resolveGraphSources)();
@@ -1626,25 +1900,34 @@ class ToolHandler {
1626
1900
  // (#926). The DB connection itself is still cached (by resolved root,
1627
1901
  // below), so re-resolving costs only the stat walk, never a reopen.
1628
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
+ }
1629
1922
  if (!resolvedRoot) {
1630
1923
  throw new NotIndexedError(`The project at ${projectPath} isn't indexed with homegraph (no .homegraph/ directory found ` +
1631
1924
  'walking up from it), so homegraph cannot query it. Use your built-in tools (Read/Grep/Glob) ' +
1632
1925
  "for that codebase instead, and don't call homegraph for it again this session. " +
1633
1926
  "Indexing is the user's decision — they can run 'homegraph init' in that project to enable it.");
1634
1927
  }
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.
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.
1648
1931
  const cached = this.projectCache.get(resolvedRoot);
1649
1932
  if (cached)
1650
1933
  return this.freshen(cached);
@@ -1889,6 +2172,15 @@ class ToolHandler {
1889
2172
  catch {
1890
2173
  reason = null;
1891
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
+ }
1892
2184
  const composed = `${formatDegradedBanner(reason)}\n\n${head.text}`;
1893
2185
  return { ...result, content: [{ type: 'text', text: composed }, ...tail] };
1894
2186
  }
@@ -1898,8 +2190,9 @@ class ToolHandler {
1898
2190
  return result;
1899
2191
  }
1900
2192
  /**
1901
- * Append a one-line product index status footer (Spec 0035).
1902
- * Guidance-only replies that already start with `HomeGraph status=` are left alone.
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.
1903
2196
  */
1904
2197
  withProductStatusFooter(result, projectPath) {
1905
2198
  if (result.isError)
@@ -1907,8 +2200,6 @@ class ToolHandler {
1907
2200
  const [first, ...rest] = result.content;
1908
2201
  if (!first || first.type !== 'text')
1909
2202
  return result;
1910
- if ((0, index_availability_1.textAlreadyHasProductStatus)(first.text))
1911
- return result;
1912
2203
  let cg;
1913
2204
  try {
1914
2205
  cg = this.getHomeGraph(projectPath);
@@ -1926,26 +2217,52 @@ class ToolHandler {
1926
2217
  /* leave cg */
1927
2218
  }
1928
2219
  }
1929
- let state;
1930
- try {
1931
- state = (0, index_availability_1.resolveProductIndexState)(cg);
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)) {
2223
+ try {
2224
+ const absRoot = (0, path_1.resolve)(cg.getProjectRoot());
2225
+ if (absRoot) {
2226
+ text = `${(0, index_availability_1.formatProjectRootPathHint)(absRoot)}\n\n${text}`;
2227
+ }
2228
+ }
2229
+ catch {
2230
+ /* no root — skip hint */
2231
+ }
1932
2232
  }
1933
- catch {
1934
- return result;
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}`;
1935
2238
  }
1936
- let pendingPaths;
1937
- if (state === 'dirty') {
2239
+ // Spec 0035: one-line product index status footer (skip if already present).
2240
+ if (!(0, index_availability_1.textAlreadyHasProductStatus)(text)) {
2241
+ let state;
1938
2242
  try {
1939
- pendingPaths = (cg.getPendingFiles?.() ?? []).map((p) => p.path);
2243
+ state = (0, index_availability_1.resolveProductIndexState)(cg);
1940
2244
  }
1941
2245
  catch {
1942
- pendingPaths = [];
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
+ }
1943
2259
  }
2260
+ const line = (0, index_availability_1.formatProductStatusLine)(state, { pendingPaths });
2261
+ text = `${text}\n\n${line}`;
1944
2262
  }
1945
- const line = (0, index_availability_1.formatProductStatusLine)(state, { pendingPaths });
1946
2263
  return {
1947
2264
  ...result,
1948
- content: [{ type: 'text', text: `${first.text}\n\n${line}` }, ...rest],
2265
+ content: [{ type: 'text', text }, ...rest],
1949
2266
  };
1950
2267
  }
1951
2268
  /**
@@ -1956,10 +2273,47 @@ class ToolHandler {
1956
2273
  */
1957
2274
  async execute(toolName, args, sessionState) {
1958
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) {
1959
2310
  args = { ...args };
1960
2311
  for (const key of [QUERY_PLAN_ARG, QUERY_DEADLINE_ARG, QUERY_STARTED_ARG, QUERY_INDEX_STATE_ARG, QUERY_FAST_ATTEMPTED_ARG, '_hgEvidenceMaxChars'])
1961
2312
  delete args[key];
1962
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;
1963
2317
  // Block the first tool call on the engine's post-open reconcile so we
1964
2318
  // never serve rows for files deleted/edited while no MCP server was
1965
2319
  // running. The wait is time-boxed (#905): a huge-repo reconcile takes
@@ -2222,15 +2576,19 @@ class ToolHandler {
2222
2576
  try {
2223
2577
  const cg = this.getHomeGraph(args.projectPath);
2224
2578
  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'
2579
+ const prior = sessionState.forProject(root);
2580
+ const symbolHint = typeof args.symbol === 'string'
2229
2581
  ? args.symbol
2230
2582
  : typeof args.file === 'string'
2231
2583
  ? args.file
2232
2584
  : undefined;
2233
- 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));
2234
2592
  }
2235
2593
  catch {
2236
2594
  return null;
@@ -2302,6 +2660,10 @@ class ToolHandler {
2302
2660
  const last = prior?.calls[prior.calls.length - 1];
2303
2661
  if (!last || (0, explore_session_state_1.inferExploreEvidenceStatus)(last) === 'complete')
2304
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;
2305
2667
  sessionState.recordDepthTool(root);
2306
2668
  }
2307
2669
  catch { /* bookkeeping only */ }
@@ -3150,6 +3512,13 @@ class ToolHandler {
3150
3512
  registeredAt,
3151
3513
  };
3152
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
+ }
3153
3522
  if (m?.synthesizedBy === 'goframe-route') {
3154
3523
  const route = m.route ? `\`${String(m.route)}\`` : 'a route';
3155
3524
  return {
@@ -6318,7 +6687,13 @@ class ToolHandler {
6318
6687
  const isTestPath = (p) => /(^|\/)(tests?|spec)\//i.test(p) || /\.(test|spec)\./i.test(p);
6319
6688
  const fileNodes = new Map();
6320
6689
  const seedIds = new Set();
6321
- 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);
6322
6697
  const addNode = (n) => {
6323
6698
  if ((0, arkts_1.isOhosApiFilePath)(n.filePath))
6324
6699
  return;
@@ -6777,9 +7152,8 @@ class ToolHandler {
6777
7152
  * Static AA class/interface nodes that look like domain Managers/Services.
6778
7153
  */
6779
7154
  collectDomainRoleSymbols(cg, query, domainTokens) {
6780
- const tokens = domainTokens.length > 0
6781
- ? domainTokens
6782
- : (0, query_utils_1.mechanismDomainPathTokens)(query);
7155
+ // Spec 0044 §9: callers may pass [] to suppress CJK→ASCII synonym expansion.
7156
+ const tokens = domainTokens;
6783
7157
  if (tokens.length === 0)
6784
7158
  return [];
6785
7159
  const byId = new Map();
@@ -7226,7 +7600,9 @@ class ToolHandler {
7226
7600
  }
7227
7601
  if (!callersOnly) {
7228
7602
  for (const { node: c } of cg.getCallees(id).slice(0, 10)) {
7229
- // Skip log*/hilog helpers — Export→logInfo homonyms ballooned seeds.
7603
+ if ((0, locate_contract_1.shouldDemoteLogToastSpine)(c.name, query)) {
7604
+ continue;
7605
+ }
7230
7606
  if (/^log(?:Info|Error|Warn|Debug|Fatal)?$/i.test(c.name) || /^hilog$/i.test(c.name)) {
7231
7607
  continue;
7232
7608
  }
@@ -7415,11 +7791,15 @@ class ToolHandler {
7415
7791
  continue;
7416
7792
  trail.push(`- \`${seedNode.name}\` (${seedNode.kind}) — ${seedNode.filePath}:${seedNode.startLine}`);
7417
7793
  for (const c of callers) {
7794
+ if ((0, query_utils_1.isMcpNoiseNode)(c.node))
7795
+ continue;
7418
7796
  const loc = c.node.startLine ? `:${c.node.startLine}` : '';
7419
7797
  trail.push(` ← used by \`${c.node.name}\` (${c.node.kind}) — ${c.node.filePath}${loc}`);
7420
7798
  trailBullets++;
7421
7799
  }
7422
7800
  for (const c of callees) {
7801
+ if ((0, query_utils_1.isMcpNoiseNode)(c.node))
7802
+ continue;
7423
7803
  const loc = c.node.startLine ? `:${c.node.startLine}` : '';
7424
7804
  trail.push(` → calls \`${c.node.name}\` (${c.node.kind}) — ${c.node.filePath}${loc}`);
7425
7805
  trailBullets++;
@@ -9638,6 +10018,8 @@ class ToolHandler {
9638
10018
  const explicitMaxFiles = typeof args.maxFiles === 'number' && !Number.isNaN(args.maxFiles);
9639
10019
  let maxFiles = (0, utils_1.clamp)(args.maxFiles || budget.defaultMaxFiles, 1, 20);
9640
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);
9641
10023
  const interpretationQuery = feature('queryAsInterpretationSurvey', query_utils_1.queryAsInterpretationSurvey);
9642
10024
  const testOnlyInterpretation = feature('queryAsTestOnlyInterpretation', query_utils_1.queryAsTestOnlyInterpretation);
9643
10025
  const crossModuleFlow = feature('queryAsCrossModuleFlowSurvey', query_utils_1.queryAsCrossModuleFlowSurvey) || plan?.intent === 'flow';
@@ -9649,19 +10031,28 @@ class ToolHandler {
9649
10031
  ? `${queryFileBasenames[0]} ${query}`
9650
10032
  : queryFileBasenames.length === 1
9651
10033
  ? `${queryFileBasenames[0]} ${query}`
9652
- : 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';
9653
10039
  const subgraph = await cg.findRelevantContext(contextQuery, {
9654
10040
  ...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),
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),
9658
10049
  } } : {}),
9659
10050
  });
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)) {
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) {
9665
10056
  let hits = [];
9666
10057
  try {
9667
10058
  hits = cg.searchNodes(base, { limit: 50 });
@@ -9670,7 +10061,15 @@ class ToolHandler {
9670
10061
  continue;
9671
10062
  }
9672
10063
  for (const r of hits) {
9673
- 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)
9674
10073
  continue;
9675
10074
  if (!subgraph.nodes.has(r.node.id)) {
9676
10075
  subgraph.nodes.set(r.node.id, r.node);
@@ -9681,10 +10080,26 @@ class ToolHandler {
9681
10080
  }
9682
10081
  const literalSource = this.renderLiteralSource(cg, subgraph);
9683
10082
  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' });
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
+ });
9688
10103
  }
9689
10104
  // Seed import nodes for @kit.* / *Kit names (and named symbols like taskpool).
9690
10105
  const importTerms = (0, query_utils_1.extractImportSearchTerms)(query);
@@ -9852,7 +10267,7 @@ class ToolHandler {
9852
10267
  const raw = isQual ? this.findAllSymbols(cg, t).nodes : cg.getNodesByName(t);
9853
10268
  let cands = raw
9854
10269
  .filter((n) => SEED_KINDS.has(n.kind) && !isTestPath(n.filePath)
9855
- && !(plan?.sourceScope === 'local' && (0, arkts_1.isOhosApiFilePath)(n.filePath)))
10270
+ && !(exploreSourceScope === 'local' && (0, arkts_1.isOhosApiFilePath)(n.filePath)))
9856
10271
  .sort((a, b) => {
9857
10272
  // Prefer callables over types when both share a name, then body size.
9858
10273
  const ac = CALLABLE.has(a.kind) ? 1 : 0;
@@ -10213,10 +10628,12 @@ class ToolHandler {
10213
10628
  // dropped, so the budget never fills with incidental files. Guarded so it
10214
10629
  // never prunes below 2.
10215
10630
  if (maxGraph > 0) {
10631
+ const litKeep = new Set((subgraph.literalEvidence?.hits ?? []).map((h) => h.filePath).filter(Boolean));
10216
10632
  const gated = relevantFiles.filter(([fp]) => (fileGraphScore.get(fp) ?? 0) >= maxGraph * 0.06
10217
10633
  || centralFiles.has(fp)
10218
10634
  || entryFiles.has(fp)
10219
10635
  || changeSurfaceFiles.has(fp)
10636
+ || litKeep.has(fp)
10220
10637
  || (fileTermHits.get(fp) ?? 0) >= 2);
10221
10638
  if (gated.length >= 2)
10222
10639
  relevantFiles = gated;
@@ -10241,6 +10658,22 @@ class ToolHandler {
10241
10658
  // tier so it isn't buried under files that merely share surface words (#1064).
10242
10659
  for (const fp of changeSurfaceFiles)
10243
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
+ }
10244
10677
  // Multi-term corroboration tier: a file that is BOTH (a) an entry/central file
10245
10678
  // (a search root, named seed, or graph-central hub — i.e. structurally part of
10246
10679
  // the answer) AND (b) matched by ≥2 DISTINCT query terms must not be buried by
@@ -10264,7 +10697,7 @@ class ToolHandler {
10264
10697
  const sortedFiles = relevantFiles.sort((a, b) => {
10265
10698
  const aPath = a[0].toLowerCase();
10266
10699
  const bPath = b[0].toLowerCase();
10267
- if (plan?.sourceScope === 'local') {
10700
+ if (exploreSourceScope === 'local') {
10268
10701
  const sdkOrder = Number((0, arkts_1.isOhosApiFilePath)(a[0])) - Number((0, arkts_1.isOhosApiFilePath)(b[0]));
10269
10702
  if (sdkOrder)
10270
10703
  return sdkOrder;
@@ -10273,6 +10706,17 @@ class ToolHandler {
10273
10706
  - Number((subgraph.literalEvidence?.hits ?? []).some((hit) => hit.filePath === a[0]));
10274
10707
  if (literalOrder)
10275
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;
10276
10720
  // Query-named file (LocationController.ets in the question) before partial
10277
10721
  // substring matches (control.ets matching "Controller" inside LocationController).
10278
10722
  const aExactBase = (0, query_utils_1.fileMatchesQueryBasename)(a[0], queryFileBasenames) ? 1 : 0;
@@ -11345,7 +11789,15 @@ class ToolHandler {
11345
11789
  const headerSuffix = omittedCount > 0
11346
11790
  ? `${headerSymbols.join(', ')}, +${omittedCount} more`
11347
11791
  : headerSymbols.join(', ');
11348
- 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
+ }
11349
11801
  // The total cap bounds INCIDENTAL files only. A file that DEFINES a symbol
11350
11802
  // the agent named (or that's on the flow spine) renders even when the
11351
11803
  // nominal total is used up — it's the answer, and the set is bounded by
@@ -11639,6 +12091,106 @@ class ToolHandler {
11639
12091
  }
11640
12092
  return { text: sections.join('\n\n'), files, nodes };
11641
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
+ }
11642
12194
  /**
11643
12195
  * An explore response plus the record of what it emitted (CG-17). The record
11644
12196
  * rides the result only as far as {@link execute}, which files it into the
@@ -11646,17 +12198,42 @@ class ToolHandler {
11646
12198
  */
11647
12199
  exploreResult(text, emission) {
11648
12200
  const meta = (0, explore_repeat_guard_1.inferExplorePartialMeta)(text);
11649
- 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);
11650
12223
  result[explore_session_state_1.EXPLORE_EMISSION_KEY] = {
11651
12224
  ...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),
12225
+ evidenceStatus,
12226
+ partial: emission.partial ?? (evidenceStatus !== 'complete' ? true : meta.partial),
11654
12227
  nextAnchor: emission.nextAnchor ?? meta.nextAnchor,
11655
12228
  };
12229
+ result = this.prependHarmonySeamNotes(result, emission.projectRoot, result[explore_session_state_1.EXPLORE_EMISSION_KEY]);
11656
12230
  return result;
11657
12231
  }
11658
12232
  /** Attach a minimal session emission when a compact/light path used textResult. */
11659
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);
11660
12237
  if (result[explore_session_state_1.EXPLORE_EMISSION_KEY]) {
11661
12238
  const em = result[explore_session_state_1.EXPLORE_EMISSION_KEY];
11662
12239
  if (em.partial === undefined || !em.nextAnchor) {
@@ -11666,6 +12243,7 @@ class ToolHandler {
11666
12243
  if (!em.nextAnchor && meta.nextAnchor)
11667
12244
  em.nextAnchor = meta.nextAnchor;
11668
12245
  }
12246
+ result = this.prependHarmonySeamNotes(result, projectRoot, em);
11669
12247
  return result;
11670
12248
  }
11671
12249
  const text = result.content?.[0]?.text ?? '';
@@ -11839,7 +12417,8 @@ class ToolHandler {
11839
12417
  const nodes = cg.getNodesInFile(filePath)
11840
12418
  .filter((n) => n.kind !== 'file' && n.kind !== 'import' && n.kind !== 'export')
11841
12419
  .sort((a, b) => a.startLine - b.startLine);
11842
- const dependents = cg.getFileDependents(filePath);
12420
+ const dependents = cg.getFileDependents(filePath)
12421
+ .filter((p) => !p.includes('@dummy') && !p.replace(/\\/g, '/').split('/').pop()?.startsWith('@dummy'));
11843
12422
  // Compact, one-line blast radius (homegraph's value-add over a plain Read).
11844
12423
  const depSummary = dependents.length
11845
12424
  ? `used by ${dependents.length} file${dependents.length === 1 ? '' : 's'}: ${dependents.slice(0, 8).join(', ')}${dependents.length > 8 ? `, +${dependents.length - 8} more` : ''}`
@@ -12019,6 +12598,8 @@ class ToolHandler {
12019
12598
  for (const e of edges) {
12020
12599
  if (seen.has(e.node.id))
12021
12600
  continue;
12601
+ if ((0, query_utils_1.isMcpNoiseNode)(e.node))
12602
+ continue;
12022
12603
  seen.add(e.node.id);
12023
12604
  out.push(e);
12024
12605
  }
@@ -12194,14 +12775,47 @@ class ToolHandler {
12194
12775
  return this.textResult('No modules found in the project map.');
12195
12776
  }
12196
12777
  const FILE_CAP = 80;
12778
+ const projectRoot = cg.getProjectRoot();
12779
+ const hasHarmony = map.modules.some((m) => m.kind === 'harmony');
12197
12780
  const lines = [
12198
12781
  `**Project map** (phase=${map.phase})`,
12199
12782
  `modules: ${map.modules.length} · files: ${map.fileCount}`,
12200
- '',
12201
12783
  ];
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 */
12792
+ }
12793
+ if (hasHarmony) {
12794
+ lines.push('modules from `build-profile.json5` — skeleton map only (not call edges; use `homegraph_explore` for route_map → page)');
12795
+ }
12796
+ lines.push('');
12202
12797
  for (const m of map.modules) {
12203
12798
  const rootLabel = m.rootPath || '.';
12204
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
+ }
12205
12819
  if (m.files && m.files.length > 0) {
12206
12820
  const shown = m.files.slice(0, FILE_CAP);
12207
12821
  for (const f of shown) {
@@ -12213,6 +12827,42 @@ class ToolHandler {
12213
12827
  }
12214
12828
  lines.push('');
12215
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
+ }
12216
12866
  return this.textResult(lines.join('\n').trimEnd());
12217
12867
  }
12218
12868
  /**