@event4u/agent-config 17.3.0 → 17.4.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 (137) hide show
  1. package/.augment-plugin/marketplace.json +3 -3
  2. package/.augment-plugin/plugin.json +1 -1
  3. package/.claude-plugin/marketplace.json +1 -1
  4. package/CHANGELOG.md +164 -342
  5. package/CREDITS.md +2 -2
  6. package/README.md +1 -1
  7. package/dist/agent-src/skills/code-intelligence/SKILL.md +3 -0
  8. package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
  9. package/dist/cli/registry.js +2 -2
  10. package/dist/cli-delegate/{chunk-DHSSP7V3.js → chunk-5AYJGUA3.js} +0 -2
  11. package/dist/cli-delegate/chunk-DIFZBP4G.js +1336 -0
  12. package/dist/cli-delegate/{chunk-MT2WOOCY.js → chunk-WY5ZZPGL.js} +2 -2
  13. package/dist/cli-delegate/{chunk-ORJPBER2.js → chunk-X6LZ7W5A.js} +73 -10
  14. package/dist/cli-delegate/cmd_conformance.js +175 -183
  15. package/dist/cli-delegate/cmd_doctor.js +5 -5
  16. package/dist/cli-delegate/cmd_doctor_neighbours.js +3 -3
  17. package/dist/cli-delegate/cmd_explain.js +1 -1
  18. package/dist/cli-delegate/cmd_handoff.js +2 -2
  19. package/dist/cli-delegate/cmd_memory_get.js +1 -1
  20. package/dist/cli-delegate/cmd_route_audit.js +1 -1
  21. package/dist/cli-delegate/cmd_session_recycle.js +2 -2
  22. package/dist/cli-delegate/cmd_update.js +24 -13
  23. package/dist/cli-delegate/cmd_upgrade.js +2 -2
  24. package/dist/cli-delegate/dist-25AZAHOP.js +3240 -0
  25. package/dist/cli-delegate/dist-P7MFPMF3.js +411 -0
  26. package/dist/cli-delegate/dist-XAS5ZLQB.js +6 -0
  27. package/dist/cli-delegate/{upgrade_unattended-VVL6JLSX.js → upgrade_unattended-QCWOKTUN.js} +178 -163
  28. package/dist/discovery/deprecation-report.md +1 -1
  29. package/dist/discovery/discovery-manifest.json +3 -3
  30. package/dist/discovery/discovery-manifest.json.sha256 +1 -1
  31. package/dist/discovery/discovery-manifest.summary.md +1 -1
  32. package/dist/discovery/dormancy-report.md +1 -1
  33. package/dist/discovery/orphan-report.md +1 -1
  34. package/dist/discovery/packs.json +2 -2
  35. package/dist/discovery/trust-report.md +1 -1
  36. package/dist/discovery/workspaces.json +2 -2
  37. package/dist/hooks/code_graph_refresh.chunk.cjs +56 -27
  38. package/dist/hooks/code_graph_refresh.chunk.cjs.sha256 +1 -1
  39. package/dist/hooks/dispatch.js +982 -929
  40. package/dist/hooks/dispatch.sha256 +1 -1
  41. package/dist/hooks/egress_engine.chunk.cjs +56 -6
  42. package/dist/hooks/egress_engine.chunk.cjs.sha256 +1 -1
  43. package/dist/hooks/update_notice.chunk.cjs +914 -0
  44. package/dist/hooks/update_notice.chunk.cjs.sha256 +1 -0
  45. package/dist/mcp/registry-manifest.json +1 -1
  46. package/dist/mcp/server.json +2 -2
  47. package/dist/mcp/server.mjs +71259 -66437
  48. package/dist/scripts/condense.js +9 -6
  49. package/package.json +4 -1
  50. package/src/config/evaluator-budgets.json +9 -8
  51. package/src/config/gate-violation-baselines.json +2 -2
  52. package/src/config/hook-bundle-budget.json +31 -6
  53. package/src/config/packed-binary-manifest.json +9 -0
  54. package/src/config/sigstore/npm_keys.json +50 -0
  55. package/src/config/sigstore/trust-material.json +9 -0
  56. package/src/config/sigstore/trusted_root.json +126 -0
  57. package/src/config/skill-body-budget.json +7 -1
  58. package/src/scripts/_cli/cmd_update.ts +48 -20
  59. package/src/scripts/_cli/cmd_upgrade.ts +7 -1
  60. package/src/scripts/_cli/upgrade_unattended.ts +90 -76
  61. package/src/scripts/_dispatch.bash +3 -2
  62. package/src/scripts/_lib/confusables.ts +0 -19
  63. package/src/scripts/_lib/egress_normalize.ts +53 -23
  64. package/src/scripts/_lib/egress_text.ts +5 -3
  65. package/src/scripts/_lib/egress_token.ts +7 -5
  66. package/src/scripts/_lib/gate_collector_experimental.ts +633 -0
  67. package/src/scripts/_lib/git_common_dir.ts +10 -0
  68. package/src/scripts/_lib/graph_feeder_record.ts +3 -2
  69. package/src/scripts/_lib/install_reach_checks.ts +32 -2
  70. package/src/scripts/_lib/lineage_backfill.ts +136 -0
  71. package/src/scripts/_lib/lineage_ledger.ts +211 -0
  72. package/src/scripts/_lib/lineage_map.ts +55 -0
  73. package/src/scripts/_lib/lineage_query.ts +140 -0
  74. package/src/scripts/_lib/lineage_seed.ts +116 -0
  75. package/src/scripts/_lib/lineage_watch.ts +249 -0
  76. package/src/scripts/_lib/link_crypto.ts +8 -4
  77. package/src/scripts/_lib/production_reach.ts +3 -3
  78. package/src/scripts/_lib/project_registry.ts +227 -0
  79. package/src/scripts/_lib/provenance_verify.ts +14 -242
  80. package/src/scripts/_lib/sigstore_provenance.ts +177 -0
  81. package/src/scripts/_lib/source_id.ts +48 -0
  82. package/src/scripts/_lib/source_shape.ts +4 -0
  83. package/src/scripts/_lib/unattended_gates.ts +6 -1
  84. package/src/scripts/advisories.ts +26 -10
  85. package/src/scripts/ai_council/council_egress.ts +5 -6
  86. package/src/scripts/bench_graph_feeder_latency.ts +10 -3
  87. package/src/scripts/check_dependency_floors.ts +7 -0
  88. package/src/scripts/check_egress_mutations.ts +1 -0
  89. package/src/scripts/code_graph/build.ts +67 -3
  90. package/src/scripts/code_graph/cli.ts +131 -28
  91. package/src/scripts/code_graph/conventions_default.ts +20 -1
  92. package/src/scripts/code_graph/detect.ts +5 -5
  93. package/src/scripts/code_graph/estate_cli.ts +135 -0
  94. package/src/scripts/code_graph/estate_index.ts +394 -0
  95. package/src/scripts/code_graph/estate_manifests.ts +223 -0
  96. package/src/scripts/code_graph/estate_map.ts +141 -0
  97. package/src/scripts/code_graph/extract.ts +4 -1
  98. package/src/scripts/code_graph/extract_more.ts +137 -2
  99. package/src/scripts/code_graph/graph_budget.ts +158 -0
  100. package/src/scripts/code_graph/graph_home.ts +80 -0
  101. package/src/scripts/code_graph/lineage_cli.ts +315 -0
  102. package/src/scripts/code_graph/map.ts +22 -13
  103. package/src/scripts/code_graph/map_model.ts +67 -12
  104. package/src/scripts/code_graph/map_verify.ts +68 -5
  105. package/src/scripts/code_graph/map_viewer/viewer.css +12 -0
  106. package/src/scripts/code_graph/map_viewer/viewer.js +45 -15
  107. package/src/scripts/code_graph/project_ref.ts +25 -0
  108. package/src/scripts/code_graph/projects_cli.ts +254 -0
  109. package/src/scripts/code_graph/providers/catalog.ts +14 -5
  110. package/src/scripts/code_graph/providers/django.ts +52 -9
  111. package/src/scripts/code_graph/providers/index.ts +2 -0
  112. package/src/scripts/code_graph/providers/rails.ts +204 -0
  113. package/src/scripts/code_graph/providers/spring.ts +38 -1
  114. package/src/scripts/code_graph/query.ts +9 -9
  115. package/src/scripts/code_graph/refresh_lock.ts +12 -8
  116. package/src/scripts/code_graph/repo_identity.ts +169 -0
  117. package/src/scripts/code_graph/sqlite_store.ts +22 -25
  118. package/src/scripts/code_graph/types.ts +6 -2
  119. package/src/scripts/condense.ts +6 -6
  120. package/src/scripts/hook_chunks.mjs +7 -0
  121. package/src/scripts/hook_manifest.json +1 -1
  122. package/src/scripts/hook_manifest.yaml +20 -17
  123. package/src/scripts/hooks/code_graph_refresh_hook.ts +10 -7
  124. package/src/scripts/hooks/dispatch_hook.ts +6 -7
  125. package/src/scripts/hooks/host_semantics.ts +72 -0
  126. package/src/scripts/hooks/lazy_concern.ts +4 -0
  127. package/src/scripts/hooks/user_notice.ts +105 -0
  128. package/src/scripts/lint_harvest_provenance.ts +137 -11
  129. package/src/scripts/mcp_server/consumer_tool_catalog.json +69 -15
  130. package/src/scripts/mcp_server/graph_tools.ts +161 -41
  131. package/src/scripts/network_sources.ts +24 -6
  132. package/src/scripts/schemas/provenance-mechanism.schema.json +46 -0
  133. package/src/scripts/update_notice_hook.ts +75 -7
  134. package/src/vendor/grammars/README.md +7 -6
  135. package/src/vendor/grammars/tree-sitter-ruby.wasm +0 -0
  136. /package/dist/cli-delegate/{chunk-FKYZOWVD.js → chunk-QBAFP67O.js} +0 -0
  137. /package/dist/cli-delegate/{chunk-BMGUGSY6.js → chunk-TQG6UMPC.js} +0 -0
@@ -66,7 +66,8 @@ import { gitEnv } from './_lib/git_env.js';
66
66
  import { graphUntestedVerdict } from './_lib/graph_feeder_record.js';
67
67
  import { LexicalIndex } from './_lib/lexical_index.js';
68
68
  import { buildFromRepo } from './code_graph/build.js';
69
- import { graphState, NATIVE_CACHE_REL } from './code_graph/detect.js';
69
+ import { graphState } from './code_graph/detect.js';
70
+ import { graphHome } from './code_graph/graph_home.js';
70
71
  import { loadGraph } from './code_graph/query.js';
71
72
  import { emitSqliteTwin, sqliteTwinPath } from './code_graph/sqlite_store.js';
72
73
  import type { CodeEdge, CodeGraph } from './code_graph/types.js';
@@ -159,7 +160,13 @@ export async function makeFixture(root: string, files: number, withGraph: boolea
159
160
  git(dir, ['init', '-q']);
160
161
  git(dir, ['add', '-A']);
161
162
  git(dir, ['commit', '-q', '-m', 'fixture']);
162
- if (withGraph) await buildFromRepo(dir, path.join(dir, NATIVE_CACHE_REL));
163
+ if (withGraph) {
164
+ // A project home, as in every repository the feeder serves: the hooks the
165
+ // bench spawns run under their own user-global root, where a global
166
+ // graph home would not be found.
167
+ fs.mkdirSync(path.join(dir, 'agents'));
168
+ await buildFromRepo(dir, graphHome(dir).cachePath);
169
+ }
163
170
  fs.writeFileSync(path.join(dir, 'src', 'service.ts'), 'export function handle(x: string): string {\n return x.trim();\n}\n');
164
171
  return dir;
165
172
  }
@@ -404,7 +411,7 @@ export function benchLoadSplit(opts: { graph: string; runs?: number | undefined
404
411
  if (!v.ok) throw new Error(`invalid graph at ${opts.graph}: ${v.errors.slice(0, 3).join('; ')}`);
405
412
  const graph = parsed as CodeGraph;
406
413
  const t3 = performance.now();
407
- const wrote = emitSqliteTwin(graph, raw, copy);
414
+ const wrote = emitSqliteTwin(graph, copy);
408
415
  const t4 = performance.now();
409
416
  const byId = new Map(graph.nodes.map((n) => [n.id, n]));
410
417
  const outM = new Map<string, CodeEdge[]>();
@@ -60,6 +60,13 @@ export const EXACT_PIN_EXCEPTIONS: Readonly<Record<string, string>> = {
60
60
  'which would make every grammar load throw rather than degrade — so the runtime ' +
61
61
  'and its grammars move together, in one reviewed change, or not at all. ' +
62
62
  'Refresh procedure: src/vendor/grammars/README.md.',
63
+ // The three sigstore modules are the trusted computing base of the
64
+ // unattended upgrade's provenance check (sigstore_provenance.ts): a
65
+ // patch release of any of them changes what "verified" means, so each
66
+ // version is reviewed rather than resolved. They move together.
67
+ '@sigstore/verify': 'Trusted computing base of the release provenance check; moves only in a reviewed change, with its two siblings.',
68
+ '@sigstore/bundle': 'Parses the bundle the provenance check verifies; pinned with @sigstore/verify for the same reason.',
69
+ '@sigstore/protobuf-specs': 'Decodes the pinned trust root and npm keys; pinned with @sigstore/verify for the same reason.',
63
70
  };
64
71
 
65
72
  /**
@@ -33,6 +33,7 @@ export const ENGINE_MODULES: readonly string[] = [
33
33
  'src/scripts/_lib/egress_rules.ts',
34
34
  'src/scripts/_lib/egress_token.ts',
35
35
  'src/scripts/_lib/egress_text.ts',
36
+ 'src/scripts/_lib/egress_normalize.ts',
36
37
  ];
37
38
 
38
39
  export interface Recipe {
@@ -79,6 +79,16 @@ const CODE_EXTS: ReadonlySet<string> = new Set(Object.keys(EXT_LANG));
79
79
  /** Generated output under the project root: the engine never reads its own artefacts back. */
80
80
  const GENERATED_DIRS: ReadonlySet<string> = new Set(['agents/map', 'agents/runtime']);
81
81
 
82
+ /** Whether the discovery walk would enter every directory of repo-relative `rel` (posix). */
83
+ export function walkedPath(rel: string): boolean {
84
+ const dirs = rel.split('/').slice(0, -1);
85
+ for (let i = 0; i < dirs.length; i++) {
86
+ const d = dirs[i] as string;
87
+ if (IGNORE_DIRS.has(d) || d.startsWith('.') || GENERATED_DIRS.has(dirs.slice(0, i + 1).join('/'))) return false;
88
+ }
89
+ return true;
90
+ }
91
+
82
92
  export function discoverFiles(root: string, exts: ReadonlySet<string> = CODE_EXTS): string[] {
83
93
  return walkFiles(root, (name) => exts.has(path.extname(name).toLowerCase()));
84
94
  }
@@ -128,7 +138,7 @@ function walkFiles(root: string, accept: (name: string) => boolean): string[] {
128
138
  }
129
139
 
130
140
  /** Manifests a provider may read to detect a stack, by basename. */
131
- const MANIFEST_NAME = /^(?:composer\.json|package\.json|pyproject\.toml|requirements[\w.-]*\.txt|pom\.xml|build\.gradle(?:\.kts)?|go\.mod|[\w.-]+\.csproj|Gemfile)$/;
141
+ export const MANIFEST_NAME = /^(?:composer\.json|package\.json|pyproject\.toml|requirements[\w.-]*\.txt|pom\.xml|build\.gradle(?:\.kts)?|go\.mod|[\w.-]+\.csproj|Gemfile)$/;
132
142
 
133
143
  /** The project's manifests and marker files, for provider detection (ADR-298). */
134
144
  export function readProjectProbe(root: string): ProjectProbe {
@@ -266,6 +276,8 @@ function resolveSpecifier(fromFile: string, spec: string, fileIds: ReadonlySet<s
266
276
  * go `example.com/x/mod` → every non-test `.go` file of the most specific
267
277
  * directory whose path ends the import path — a Go import names a
268
278
  * package, and a package is all of its files.
279
+ * ruby `require_relative 'x'` → `x.rb` beside the importer; `require 'a/b'`
280
+ * → the one `a/b.rb` under any directory (a load-path root).
269
281
  * csharp `using A.B` → the one file declaring namespace `A.B` (`namespaces`).
270
282
  * A namespace declared by several files is left to name lookup: it
271
283
  * binds no file, since a `using` names no single one.
@@ -305,6 +317,15 @@ export function resolveLangModule(
305
317
  }
306
318
  return best ? goFiles.filter((f) => idDirname(f) === best).sort() : [];
307
319
  }
320
+ if (lang === 'ruby') {
321
+ if (spec.startsWith('.')) {
322
+ const dir = idDirname(fromFile);
323
+ const joined = path.posix.normalize(dir && dir !== '.' ? `${dir}/${spec}` : spec);
324
+ const c = joined.endsWith('.rb') ? joined : `${joined}.rb`;
325
+ return fileIds.includes(c) ? [c] : [];
326
+ }
327
+ return unique([spec.endsWith('.rb') ? spec : `${spec}.rb`]);
328
+ }
308
329
  if (lang === 'csharp') return [...(namespaces.get(spec) ?? [])];
309
330
  return [];
310
331
  }
@@ -429,6 +450,19 @@ export function buildGraph(
429
450
  const extraImports: Array<{ source: string; target: string }> = [];
430
451
  const namespaceFiles = new Map<string, string[]>();
431
452
  for (const ex of extracts) for (const ns of ex.namespaces ?? []) push(namespaceFiles, ns, ex.file);
453
+ // C# name lookup is scoped by namespace, not by file: a type is visible
454
+ // from its own namespace, every enclosing one, and each `using`. Within
455
+ // that set a unique name IS the binding the compiler makes.
456
+ const csTypes = new Map<string, string[]>();
457
+ const csVisible = new Map<string, string[]>();
458
+ for (const ex of extracts) {
459
+ if (ex.lang !== 'csharp') continue;
460
+ for (const [id, ns] of Object.entries(ex.typeNamespaces ?? {})) push(csTypes, `${ns}\0${id.slice(id.indexOf('#') + 1)}`, id);
461
+ const seen = new Set<string>();
462
+ for (const ns of ex.namespaces ?? []) for (let p = ns; p; p = p.includes('.') ? p.slice(0, p.lastIndexOf('.')) : '') seen.add(p);
463
+ for (const r of ex.rawEdges) if (r.relation === 'imports' && r.importKind === 'namespace' && !r.localName && r.moduleSpecifier) seen.add(r.moduleSpecifier);
464
+ csVisible.set(ex.file, [...seen]);
465
+ }
432
466
  for (const ex of extracts) {
433
467
  const locals = new Map<string, string[]>();
434
468
  for (const n of ex.nodes) if (n.kind !== 'file') push(locals, n.label, n.id);
@@ -466,7 +500,7 @@ export function buildGraph(
466
500
  continue;
467
501
  }
468
502
  if (ex.lang === 'csharp' && (namespaceFiles.get(spec)?.length ?? 0) > 1) continue;
469
- if (ex.lang === 'python' || ex.lang === 'java' || ex.lang === 'go' || ex.lang === 'csharp') {
503
+ if (ex.lang === 'python' || ex.lang === 'java' || ex.lang === 'go' || ex.lang === 'csharp' || ex.lang === 'ruby') {
470
504
  const hits = resolveLangModule(ex.lang, ex.file, spec, fileIdList, namespaceFiles);
471
505
  const first = hits[0];
472
506
  if (first === undefined) {
@@ -549,6 +583,11 @@ export function buildGraph(
549
583
  // reported as ones.
550
584
  via: bound.via,
551
585
  };
586
+ if (lang === 'csharp') {
587
+ const ids = new Set((csVisible.get(file) ?? []).flatMap((ns) => csTypes.get(`${ns}\0${name}`) ?? []));
588
+ const fit = [...ids].filter((id) => satisfies(nodeKind.get(id), want));
589
+ if (fit.length === 1) return { target: fit[0] as string, confidence: 'EXTRACTED', via: 'namespace-scope' };
590
+ }
552
591
  if (scopeOnly) return null;
553
592
  const wide =
554
593
  want === 'constructible'
@@ -654,6 +693,9 @@ export function buildGraph(
654
693
  // against a same-named declaration elsewhere in the repository is
655
694
  // the defect this roadmap removes, applied to a second relation.
656
695
  const hit = resolveInScope(ex.file, r.targetName, 'any', ex.lang, true);
696
+ // A C# type reference to a framework type (`List`, `Task`) names no
697
+ // project file, and a `symbol:` edge per mention would only be noise.
698
+ if (!hit && ex.lang === 'csharp') return;
657
699
  emit({
658
700
  source: r.sourceId,
659
701
  target: hit?.target ?? `symbol:${r.targetName}`,
@@ -717,6 +759,28 @@ export function buildGraph(
717
759
  }
718
760
  return;
719
761
  }
762
+ // scoped `C::m()` whose class the file binds by a stated mechanism
763
+ // (an import, PSR-4, a local declaration, a C# namespace): the class
764
+ // is known, so a method it declares or inherits is the target, and the
765
+ // edge is exactly as trustworthy as the class binding.
766
+ if ((r.callStyle === 'scoped' || r.callStyle === 'dynamic') && r.scopeName) {
767
+ const cls = resolveInScope(ex.file, r.scopeName, 'any', ex.lang, true);
768
+ const kind = cls ? nodeKind.get(cls.target) : undefined;
769
+ if (cls && isStatedResolution(cls.via) && (kind === 'class' || kind === 'interface' || kind === 'enum')) {
770
+ const m = resolveHierMethod(cls.target, r.targetName);
771
+ if (m) {
772
+ emit({
773
+ source: r.sourceId,
774
+ target: m,
775
+ relation: 'calls',
776
+ confidence: m.startsWith(`${cls.target}::`) ? cls.confidence : 'INFERRED',
777
+ resolved_via: cls.via,
778
+ provider: 'native',
779
+ });
780
+ return;
781
+ }
782
+ }
783
+ }
720
784
  // scoped (Class::m unresolved / facade) or dynamic ($obj->m) → AMBIGUOUS
721
785
  const cands = methodCandidates(r.targetName, ex.lang);
722
786
  if (r.callStyle === 'dynamic' && cands.length === 0) {
@@ -1129,7 +1193,7 @@ export async function buildFromRepo(
1129
1193
  write_atomic(sidecarPath(cachePath), JSON.stringify(nextSidecar));
1130
1194
  // Derived SQLite twin (ADR-129): best-effort accelerator beside the
1131
1195
  // canonical JSON — never fails the build, no-op on old Node.
1132
- emitSqliteTwin(graph, json, cachePath);
1196
+ emitSqliteTwin(graph, cachePath);
1133
1197
  }
1134
1198
  return { graph, json, fileCount: files.length, reExtracted, reused };
1135
1199
  }
@@ -12,6 +12,11 @@
12
12
  * is discarded and retried once
13
13
  * auto-refresh on|off|status [--root P] opt this project in to the
14
14
  * session-start background refresh
15
+ * projects list|add|remove|prune the user-global registry of
16
+ * projects this machine works in
17
+ * (ADR-299, `projects_cli.ts`)
18
+ * estate where-used|projects-using| estate questions from the local
19
+ * advisory-impact index alone (`estate_cli.ts`)
15
20
  *
16
21
  * `detect --format json` (Phase 2, road-to-reachable-code-memory) emits a
17
22
  * single-line, stable-key-order JSON verdict on stdout — no timestamps, safe
@@ -29,8 +34,10 @@
29
34
  * completed temp output — on budget-exceeded, build failure, or the child
30
35
  * never producing output, the rename never happens and the OLD cache is left
31
36
  * byte-for-byte untouched. The cache itself (default
32
- * `agents/runtime/state/code-graph-v1.json`) is local-only and disposable —
33
- * gitignored, rebuildable from source at any time; never a source of truth.
37
+ * `agents/runtime/state/code-graph-v1.json`; for a root without `agents/` or a
38
+ * project install, the user-global `runtime/graphs/<repo id>/state/`, ADR-299)
39
+ * is local-only and disposable — gitignored, rebuildable from source at any
40
+ * time; never a source of truth.
34
41
  *
35
42
  * impact --diff <rev> [--depth N] callers/dependents/test files
36
43
  * reachable from the changed
@@ -46,16 +53,24 @@
46
53
  * three-state staleness — so a caller can tell "no callers" from "no callers I
47
54
  * would trust", and "dead" from "dead as of a graph 40 commits behind".
48
55
  *
56
+ * Every verb that reads a graph also takes `--project <id|name|path>`: a
57
+ * project in the user-global registry, resolved to its root and graph home
58
+ * (ADR-299). Only a registered project resolves.
59
+ *
49
60
  * Query subcommands (query/path/explain/affected) are added by the Phase-3
50
61
  * query tier. Deterministic, LLM-free, no network. Exit codes:
51
62
  * 0 ok · 1 not-found / validation-failed / refresh-budget-exceeded ·
52
63
  * 2 usage · 3 internal.
53
64
  *
54
65
  * map [--root P] [--out DIR] [--open] draw the project map into
55
- * [--no-verify] [--diff <rev>] agents/map/ (ADR-297); renders it
66
+ * [--no-verify] [--diff <rev>] agents/map/ (ADR-297), or the
67
+ * graph home's map/ (ADR-299); renders it
56
68
  * [--prs] (open PRs by area, via gh)
57
69
  * at three widths when Playwright
58
70
  * is present
71
+ * map --estate [--out DIR] [--no-verify] one page of the registered projects
72
+ * and their links, under the user-global
73
+ * home; refused inside a git work tree
59
74
  *
60
75
  * suggest-verb "<question>" (Phase 9 D3) print the verb
61
76
  * (query/affected/path/explain)
@@ -81,16 +96,21 @@ import {
81
96
  detectSources,
82
97
  type GraphState,
83
98
  graphState,
84
- NATIVE_CACHE_REL,
85
99
  pickSource,
86
100
  } from './detect.js';
101
+ import { graphHome } from './graph_home.js';
87
102
  import { suggestVerb } from './intent.js';
103
+ import { estateMain } from './estate_cli.js';
104
+ import { estateMapMain } from './estate_map.js';
105
+ import { touchHome } from './graph_budget.js';
106
+ import { resolveProjectRef } from './project_ref.js';
107
+ import { projectsMain, registerUse } from './projects_cli.js';
88
108
  import { buildMap, graphGeneration, mapGeneration, recordOutcome } from './map.js';
89
109
  import { cliPackage, usableCli } from './refresh_cli.js';
90
110
  import {
91
- AUTO_REFRESH_REL,
92
- LOCK_REL,
111
+ STALE_LOCK_MS,
93
112
  acquire as acquireRefreshLock,
113
+ pidAlive,
94
114
  readRefreshRecords,
95
115
  reclaimMutexPath,
96
116
  recordRefresh,
@@ -121,24 +141,34 @@ import {
121
141
  untested,
122
142
  type UntestedResult,
123
143
  } from './verbs.js';
144
+ import { main as lineageMain } from './lineage_cli.js';
124
145
 
125
146
  const _HERE = fileURLToPath(import.meta.url);
126
147
  export const REPO_ROOT = path.resolve(path.dirname(_HERE), '..', '..', '..');
127
148
  /**
128
- * The cache belongs to the project it describes: `<root>/agents/runtime/state/`,
129
- * the path MCP, the context hook and `detect` already read. Anchoring it to
149
+ * The cache belongs to the project it describes: `<root>/agents/runtime/state/`
150
+ * when the root has a project home, its user-global graph home otherwise
151
+ * (ADR-299) — the path MCP, the context hook and `detect` read. Anchoring it to
130
152
  * the package tree put a consumer's graph inside `node_modules`, where `npm ci`
131
153
  * deleted it, two projects sharing an install overwrote each other, and MCP
132
154
  * answered "no graph" right after the CLI reported a fresh one.
133
155
  */
134
156
  export function cacheFor(root: string): string {
135
- return path.join(root, NATIVE_CACHE_REL);
157
+ return graphHome(root).cachePath;
158
+ }
159
+ /** A path relative to the root when it lies inside it; a graph home outside the root prints in full. */
160
+ function shown(root: string, p: string): string {
161
+ const rel = path.relative(root, p);
162
+ return rel.startsWith('..') || path.isAbsolute(rel) ? p : rel;
136
163
  }
137
164
  /** Local `tsx` binary — `refresh` spawns `build` as a child of THIS file
138
165
  * through it, so a budget-exceeded kill only ever terminates the child. */
139
166
  const TSX_BIN = path.join(REPO_ROOT, 'node_modules', '.bin', process.platform === 'win32' ? 'tsx.cmd' : 'tsx');
140
167
  const DEFAULT_BUDGET_SECONDS = 60;
141
168
 
169
+ /** A refusal that names what the caller asked for wrongly; exits 1, not 3. */
170
+ class UsageError extends Error {}
171
+
142
172
  function flag(argv: string[], name: string): string | null {
143
173
  const i = argv.indexOf(name);
144
174
  return i >= 0 && i + 1 < argv.length ? (argv[i + 1] as string) : null;
@@ -157,8 +187,13 @@ function flag(argv: string[], name: string): string | null {
157
187
  */
158
188
  function resolveRoot(argv: string[]): string {
159
189
  const explicit = flag(argv, '--root');
190
+ const project = flag(argv, '--project');
160
191
  let candidate: string;
161
- if (explicit !== null) {
192
+ if (project !== null) {
193
+ const r = resolveProjectRef(project);
194
+ if ('error' in r) throw new UsageError(r.error);
195
+ candidate = r.root;
196
+ } else if (explicit !== null) {
162
197
  candidate = explicit;
163
198
  } else {
164
199
  try {
@@ -197,6 +232,7 @@ async function cmdBuild(argv: string[]): Promise<number> {
197
232
  }
198
233
  const update = argv.includes('--update');
199
234
  const { graph, fileCount, reExtracted, reused } = await buildFromRepo(root, out, { update });
235
+ if (out === cacheFor(root)) registerUse(root, graph, git(root, 'rev-parse', 'HEAD'));
200
236
  const c = graph.edge_confidence_counts;
201
237
  process.stdout.write(
202
238
  `✅ code-graph ${update ? 'updated' : 'built'} — ${fileCount} files · ${graph.nodes.length} nodes · ${graph.edges.length} edges\n` +
@@ -204,7 +240,7 @@ async function cmdBuild(argv: string[]): Promise<number> {
204
240
  ` edges: EXTRACTED ${c.EXTRACTED} · INFERRED ${c.INFERRED} · AMBIGUOUS ${c.AMBIGUOUS}\n` +
205
241
  ` resolved_via: ${resolvedViaHistogram(graph)}\n` +
206
242
  (update ? ` incremental: ${reExtracted} re-extracted · ${reused} reused\n` : '') +
207
- (out ? ` cache: ${path.relative(root, out)}\n` : '') +
243
+ (out ? ` cache: ${shown(root, out)}\n` : '') +
208
244
  legacyCacheNotice(root),
209
245
  );
210
246
  return 0;
@@ -261,12 +297,12 @@ function cmdValidate(argv: string[]): number {
261
297
  // content-addressed cache key, so agreement here means the twin was emitted
262
298
  // from THIS graph and not merely from a graph.
263
299
  const twinState = twinChecksumState(p, (parsed as CodeGraph).source_checksum);
264
- const rel = path.relative(root, p);
300
+ const rel = shown(root, p);
265
301
  if (twinState.kind === 'mismatch') {
266
302
  process.stderr.write(
267
303
  `❌ SQLite twin disagrees with the canonical JSON: twin ${twinState.twin} ≠ json ${twinState.json}\n` +
268
304
  ' The twin is derived and disposable — delete it and re-run `code-graph build`.\n' +
269
- ` ${path.relative(root, sqliteTwinPath(p))}\n`,
305
+ ` ${shown(root, sqliteTwinPath(p))}\n`,
270
306
  );
271
307
  return 1;
272
308
  }
@@ -309,7 +345,8 @@ function resolveGraph(argv: string[]): { g: LoadedGraph; note: string } | { err:
309
345
  if (!picked) return { err: 'no code-graph source found — run `code_graph build` or ship a graph.json' };
310
346
  if (picked.kind === 'scip') return { err: `${picked.note ?? 'SCIP detected'} — run \`code-graph build\` to merge it into the native graph` };
311
347
  const staleNote = picked.stale ? ` ⚠ index is ${picked.commits_behind ?? 'N'} commit(s) behind — rebuild before trusting` : '';
312
- return { g: loadGraph(picked.path, `${picked.kind}:${path.relative(root, picked.path)}`), note: staleNote };
348
+ touchHome(root);
349
+ return { g: loadGraph(picked.path, `${picked.kind}:${shown(root, picked.path)}`), note: staleNote };
313
350
  }
314
351
 
315
352
  function render(r: QueryResult, staleNote: string): number {
@@ -429,7 +466,8 @@ function isFlagValue(argv: string[], token: string): boolean {
429
466
  */
430
467
  function stateOf(argv: string[], answeredBy?: string): GraphState {
431
468
  const explicit = flag(argv, '--graph');
432
- return graphState(resolveRoot(argv), explicit ?? answeredBy);
469
+ const root = resolveRoot(argv);
470
+ return graphState(root, explicit ?? answeredBy ?? cacheFor(root));
433
471
  }
434
472
 
435
473
  /** The two histogram lines + the staleness line, in one place so the four
@@ -635,7 +673,7 @@ function cmdDetect(argv: string[]): number {
635
673
  }
636
674
  for (const v of verdicts) {
637
675
  const fresh = v.stale === undefined ? '' : v.stale ? ` · STALE${v.commits_behind !== undefined ? ` (${v.commits_behind} behind)` : ''}` : ' · fresh';
638
- process.stdout.write(`${v.kind.padEnd(9)} ${path.relative(root, v.path)}${fresh}${v.note ? ` · ${v.note}` : ''}\n`);
676
+ process.stdout.write(`${v.kind.padEnd(9)} ${shown(root, v.path)}${fresh}${v.note ? ` · ${v.note}` : ''}\n`);
639
677
  }
640
678
  const picked = pickSource(verdicts);
641
679
  if (picked) process.stdout.write(`→ query would use: ${picked.kind}\n`);
@@ -649,10 +687,21 @@ function budgetSecondsOf(argv: string[]): number {
649
687
  return Number.isFinite(n) ? n : DEFAULT_BUDGET_SECONDS;
650
688
  }
651
689
 
652
- /** Best-effort removal of a temp build output + its sidecar. Never throws —
653
- * cleanup failure must not mask the real result (budget-exceeded / error). */
654
- function cleanupTemp(tmpOut: string): void {
655
- for (const p of [tmpOut, sidecarPath(tmpOut)]) {
690
+ /** Best-effort removal of a temp build output, its sidecar, its SQLite twin and
691
+ * the twin's own half-written temp. Never throws — cleanup failure must not mask
692
+ * the real result (budget-exceeded / error). */
693
+ export function cleanupTemp(tmpOut: string): void {
694
+ const twin = sqliteTwinPath(tmpOut);
695
+ let twinTemps: string[] = [];
696
+ try {
697
+ twinTemps = fs
698
+ .readdirSync(path.dirname(tmpOut))
699
+ .filter((f) => f.startsWith(`${path.basename(twin)}.tmp-`))
700
+ .map((f) => path.join(path.dirname(tmpOut), f));
701
+ } catch {
702
+ /* the directory is gone, so nothing in it is left to remove */
703
+ }
704
+ for (const p of [tmpOut, sidecarPath(tmpOut), twin, ...twinTemps]) {
656
705
  try {
657
706
  fs.unlinkSync(p);
658
707
  } catch {
@@ -661,6 +710,33 @@ function cleanupTemp(tmpOut: string): void {
661
710
  }
662
711
  }
663
712
 
713
+ /**
714
+ * Remove what a refresh killed before its own cleanup left behind. A file is
715
+ * only an orphan when the pid in its name is gone AND it is older than a stale
716
+ * lock: the pid check alone would race a refresh that is starting right now.
717
+ */
718
+ function sweepOrphanedTemps(out: string): void {
719
+ const dir = path.dirname(out);
720
+ const prefix = `.${path.basename(out)}.refresh-tmp-`;
721
+ let names: string[];
722
+ try {
723
+ names = fs.readdirSync(dir);
724
+ } catch {
725
+ return;
726
+ }
727
+ for (const name of names) {
728
+ if (!name.startsWith(prefix)) continue;
729
+ const pid = Number(name.slice(prefix.length).split('-')[0]);
730
+ if (!Number.isInteger(pid) || pid <= 0 || pidAlive(pid)) continue;
731
+ const full = path.join(dir, name);
732
+ try {
733
+ if (Date.now() - fs.statSync(full).mtimeMs > STALE_LOCK_MS) fs.unlinkSync(full);
734
+ } catch {
735
+ /* raced another sweeper — fine */
736
+ }
737
+ }
738
+ }
739
+
664
740
  /**
665
741
  * Run `build` (optionally `--update`) as a CHILD process against a temp
666
742
  * `--out` path, under a hard wall-clock budget. Only on a clean, in-budget
@@ -736,6 +812,7 @@ function runBudgetedBuild(root: string, out: string, update: boolean, budgetSeco
736
812
  return 1;
737
813
  }
738
814
  const dir = path.dirname(out);
815
+ sweepOrphanedTemps(out);
739
816
  const tmpOut = path.join(dir, `.${path.basename(out)}.refresh-tmp-${process.pid}-${randomBytes(4).toString('hex')}.json`);
740
817
  if (update) {
741
818
  const realSidecar = sidecarPath(out);
@@ -795,7 +872,13 @@ function runBudgetedBuild(root: string, out: string, update: boolean, budgetSeco
795
872
  fs.renameSync(tmpOut, out); // same-dir → atomic; real cache only ever changes here
796
873
  const tmpSidecar = sidecarPath(tmpOut);
797
874
  if (fs.existsSync(tmpSidecar)) fs.renameSync(tmpSidecar, sidecarPath(out));
798
- process.stdout.write(`✅ code-graph refreshed — cache: ${path.relative(root, out)}\n`);
875
+ // The twin records the JSON's size and mtime; a rename keeps both, so the
876
+ // promoted twin matches the promoted JSON. Without a twin, the old one goes:
877
+ // it describes a JSON that no longer exists.
878
+ const tmpTwin = sqliteTwinPath(tmpOut);
879
+ if (fs.existsSync(tmpTwin)) fs.renameSync(tmpTwin, sqliteTwinPath(out));
880
+ else fs.rmSync(sqliteTwinPath(out), { force: true });
881
+ process.stdout.write(`✅ code-graph refreshed — cache: ${shown(root, out)}\n`);
799
882
  return 0;
800
883
  }
801
884
 
@@ -844,6 +927,7 @@ async function cmdRefresh(argv: string[]): Promise<number> {
844
927
  record('failed', `build exited ${rc}`);
845
928
  return rc;
846
929
  }
930
+ if (out === cacheFor(root)) registerUse(root, null, git(root, 'rev-parse', 'HEAD'));
847
931
  if (argv.includes('--map') && mapIsBehind(root, out)) {
848
932
  const mrc = await drawMap(['--root', root, '--no-build', '--no-verify']);
849
933
  record(mrc === 0 ? 'refreshed-with-map' : 'map-failed', mrc === 0 ? undefined : `map exited ${mrc}`);
@@ -864,8 +948,18 @@ async function cmdRefresh(argv: string[]): Promise<number> {
864
948
  function cmdAutoRefresh(argv: string[]): number {
865
949
  const root = resolveRoot(argv);
866
950
  const action = argv.find((a) => !a.startsWith('--') && !isFlagValue(argv, a)) ?? 'status';
867
- const marker = path.join(root, AUTO_REFRESH_REL);
951
+ const home = graphHome(root);
952
+ const marker = home.autoRefreshPath;
868
953
  if (action === 'on') {
954
+ if (home.kind === 'global') {
955
+ // The session concern serves project homes only; its chunk cannot carry the
956
+ // user-global resolver, so an opt-in here would be a promise nothing keeps.
957
+ process.stderr.write(
958
+ `code-graph: auto-refresh needs a project home (agents/ or a project install); ${root} has none.\n` +
959
+ 'Its graph lives in the user-global home — refresh it with `code-graph refresh`.\n',
960
+ );
961
+ return 1;
962
+ }
869
963
  fs.mkdirSync(path.dirname(marker), { recursive: true });
870
964
  fs.writeFileSync(marker, `${JSON.stringify({ enabled_at: new Date().toISOString() })}\n`);
871
965
  const cli = usableCli(root);
@@ -896,10 +990,10 @@ function cmdAutoRefresh(argv: string[]): number {
896
990
  const cli = usableCli(root);
897
991
  const last = readRefreshRecords(root);
898
992
  const line = (r: (typeof last)['refresh']): string => (r ? `${r.outcome} at ${r.at}${r.detail ? ` (${r.detail})` : ''}` : 'never');
899
- const mapDir = path.join(root, 'agents', 'map');
993
+ const mapDir = home.mapDir;
900
994
  const gen = graphGeneration(cacheFor(root));
901
995
  const drawnFrom = mapGeneration(mapDir);
902
- const mutex = reclaimMutexPath(path.join(root, LOCK_REL));
996
+ const mutex = reclaimMutexPath(home.lockPath);
903
997
  process.stdout.write(
904
998
  `auto-refresh: ${fs.existsSync(marker) ? 'on' : 'off'}\n` +
905
999
  `cli: ${'reason' in cli ? (cli.reason === 'no-cli' ? 'none usable — nothing will run' : `${cli.cli} ${cliPackage(cli.cli)?.version ?? '?'} — too old, nothing will run`) : `${cli.cli} ${cliPackage(cli.cli)?.version ?? '?'}`}\n` +
@@ -908,7 +1002,7 @@ function cmdAutoRefresh(argv: string[]): number {
908
1002
  `graph: ${computeVerdict(root, cacheFor(root)).verdict}\n` +
909
1003
  `map: ${!fs.existsSync(mapDir) ? 'absent' : drawnFrom === null ? 'present (generation unknown — redrawn on the next refresh)' : drawnFrom === gen ? 'present, drawn from the current graph' : 'present, drawn from an older graph'}\n` +
910
1004
  (fs.existsSync(mutex) && mutexAgeMs(mutex) > 60_000
911
- ? `reclaim mutex: ${path.relative(root, mutex)} left by a crash — stale locks stay held until it is removed by hand\n`
1005
+ ? `reclaim mutex: ${shown(root, mutex)} left by a crash — stale locks stay held until it is removed by hand\n`
912
1006
  : ''),
913
1007
  );
914
1008
  return 0;
@@ -928,7 +1022,7 @@ function mutexAgeMs(p: string): number {
928
1022
  * copied or restored file carries wrongly. No map → never draw one unbidden.
929
1023
  */
930
1024
  function mapIsBehind(root: string, cache: string): boolean {
931
- const mapDir = path.join(root, 'agents', 'map');
1025
+ const mapDir = graphHome(root).mapDir;
932
1026
  if (!fs.existsSync(mapDir)) return false;
933
1027
  const gen = graphGeneration(cache);
934
1028
  return gen !== null && mapGeneration(mapDir) !== gen;
@@ -940,6 +1034,7 @@ function mapIsBehind(root: string, cache: string): boolean {
940
1034
  * Builds or updates the graph first when it is absent or behind.
941
1035
  */
942
1036
  async function cmdMap(argv: string[]): Promise<number> {
1037
+ if (argv.includes('--estate')) return estateMapMain(argv);
943
1038
  const root = resolveRoot(argv);
944
1039
  if (!fs.existsSync(root)) {
945
1040
  process.stderr.write(`code-graph: root not found: ${root}\n`);
@@ -990,7 +1085,7 @@ async function drawMap(argv: string[]): Promise<number> {
990
1085
  ? ` diff ${r.model.diff.rev}: ${r.model.diff.changed.length} changed · ${r.model.diff.impacted.length} linked into them · ${r.model.diff.areas.length} area(s) touched\n`
991
1086
  : '') +
992
1087
  ` wrote: ${r.written.length} files under ${rel}/ — open ${path.join(rel, 'index.html')}\n` +
993
- (fs.existsSync(path.join(root, AUTO_REFRESH_REL))
1088
+ (fs.existsSync(graphHome(root).autoRefreshPath)
994
1089
  ? ''
995
1090
  : ' keep it current at every agent session start: agent-config code-graph auto-refresh on\n'),
996
1091
  );
@@ -1082,15 +1177,23 @@ export async function main(argv: string[] = process.argv.slice(2)): Promise<numb
1082
1177
  if (sub === 'untested') return cmdUntested(argv.slice(1));
1083
1178
  if (sub === 'dead') return cmdDead(argv.slice(1));
1084
1179
  if (sub === 'suggest-verb') return cmdSuggestVerb(argv.slice(1));
1180
+ if (sub === 'lineage' || (sub === 'map' && argv.includes('--lineage')))
1181
+ return await lineageMain(sub === 'lineage' ? argv.slice(1) : ['map', ...argv.slice(1)]);
1085
1182
  if (sub === 'map') return await cmdMap(argv.slice(1));
1086
1183
  if (sub === 'outcome') return cmdOutcome(argv.slice(1));
1087
1184
  if (sub === 'auto-refresh') return cmdAutoRefresh(argv.slice(1));
1185
+ if (sub === 'projects') return projectsMain(argv.slice(1));
1186
+ if (sub === 'estate') return estateMain(argv.slice(1));
1088
1187
  process.stderr.write(
1089
1188
  'usage: code_graph/cli.ts <build|validate|detect|refresh|query|explain|affected|path|' +
1090
- 'impact|tests-for|untested|dead|suggest-verb|map|outcome|auto-refresh> [options]\n',
1189
+ 'impact|tests-for|untested|dead|suggest-verb|map|outcome|auto-refresh|projects|estate|lineage> [options]\n',
1091
1190
  );
1092
1191
  return 2;
1093
1192
  } catch (e) {
1193
+ if (e instanceof UsageError) {
1194
+ process.stderr.write(`code-graph: ${e.message}\n`);
1195
+ return 1;
1196
+ }
1094
1197
  process.stderr.write(`code-graph: internal error: ${(e as Error).message}\n`);
1095
1198
  return 3;
1096
1199
  }
@@ -19,6 +19,21 @@ import type { SourceFile } from './build.js';
19
19
  import type { CodeEdge, CodeNode, ResolvedVia } from './types.js';
20
20
  import { EXT_LANG, MAX_FILE_BYTES } from './types.js';
21
21
 
22
+ /** A line that is a log, print or throw call up to this point: its literals are messages, not files. */
23
+ const MESSAGE_CALL =
24
+ /(?:\bconsole\.\w+|\blog(?:ger|ging)?\.\w+|\bLog::\w+|\b(?:print|println|printf|echo|error_log)\b|\bthrow\s+new\s+\w+)\s*\(?/;
25
+
26
+ /** `src` with comments and Python docstrings blanked to spaces, every offset and newline kept. */
27
+ function maskComments(src: string, lang: string | undefined): string {
28
+ const blank = (m: string): string => m.replace(/[^\n]/g, ' ');
29
+ let out = src
30
+ .replace(/\/\*[\s\S]*?\*\//g, blank)
31
+ .replace(/(^|[^:'"\\])(\/\/[^\n]*)/g, (_m, pre: string, c: string) => pre + blank(c));
32
+ if (lang === 'python' || lang === 'php') out = out.replace(/(^|[ \t])(#[^\n]*)/gm, (_m, pre: string, c: string) => pre + blank(c));
33
+ if (lang === 'python') out = out.replace(/("""|''')[\s\S]*?\1/g, blank);
34
+ return out;
35
+ }
36
+
22
37
  /** The text right before a JS/TS module specifier: `from`, `import`, `import(`, `require(`. */
23
38
  const IMPORT_POSITION = /(?:\bfrom|\bimport|\bimport\s*\(|\brequire\s*\()\s*$/;
24
39
 
@@ -177,8 +192,12 @@ export function conventionsDefault(input: DefaultInput): { nodes: CodeNode[]; ed
177
192
  const jsLike = lang === 'typescript' || lang === 'javascript';
178
193
  if (on['literal-path']) {
179
194
  // Single and double quotes only: a backtick span is usually a mention in a comment.
180
- for (const m of f.source.matchAll(/(['"])([^'"`\s\\${}()<>|,;]{3,200})\1/g)) {
195
+ // Comments and docstrings are blanked first, so a path they mention is never read.
196
+ const code = maskComments(f.source, lang);
197
+ for (const m of code.matchAll(/(['"])([^'"`\s\\${}()<>|,;]{3,200})\1/g)) {
181
198
  const lit = m[2] as string;
199
+ const lineStart = code.lastIndexOf('\n', m.index as number) + 1;
200
+ if (MESSAGE_CALL.test(code.slice(lineStart, m.index as number))) continue;
182
201
  // A module specifier belongs to the import tier: resolved, it is an
183
202
  // `imports` edge already; unresolved, it is an external module — a
184
203
  // read in neither case.
@@ -19,6 +19,7 @@ import * as path from 'node:path';
19
19
 
20
20
  import { load_agent_settings } from '../_lib/agent_settings.js';
21
21
  import { hardenedSpawnEnv } from '../_lib/spawn_env.js';
22
+ import { projectHome } from './graph_home.js';
22
23
  import { EXT_LANG } from './types.js';
23
24
  import { validateGraph } from './validate.js';
24
25
 
@@ -226,9 +227,6 @@ export function computeVerdict(root: string, nativeCache: string): VerdictJSON {
226
227
  };
227
228
  }
228
229
 
229
- /** Cache path a repository root's native graph lives at, repo-relative. */
230
- export const NATIVE_CACHE_REL = path.join('agents', 'runtime', 'state', 'code-graph-v1.json');
231
-
232
230
  /**
233
231
  * The four-state staleness token every consumer of the graph reports.
234
232
  *
@@ -328,10 +326,12 @@ function hasUncommittedIndexedEdit(root: string): boolean {
328
326
  * report `absent` while answering from that very file — the state of a cache
329
327
  * nobody asked about. Passing the graph the verb is actually reading makes the
330
328
  * printed staleness a statement about the answer rather than about a
331
- * convention.
329
+ * convention. The default is the project home only: the hooks that call this
330
+ * ship in the dispatch bundle, which cannot carry the user-global resolver, so
331
+ * a caller serving a user-global home passes `graphHome(root).cachePath`.
332
332
  */
333
333
  export function graphState(root: string, nativeCache?: string): GraphState {
334
- const picked = pickSource(detectSources(root, nativeCache ?? path.join(root, NATIVE_CACHE_REL)));
334
+ const picked = pickSource(detectSources(root, nativeCache ?? projectHome(root).cachePath));
335
335
  if (!picked) return 'absent';
336
336
  if (picked.stale === true) {
337
337
  const behind = picked.commits_behind;