@event4u/agent-config 17.2.0 → 17.3.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 (152) 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/.claude-plugin/skills/install-agent-config/SKILL.md +9 -9
  5. package/CHANGELOG.md +174 -0
  6. package/dist/agent-src/skills/code-intelligence/SKILL.md +13 -12
  7. package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
  8. package/dist/agent-src/templates/scripts/check_memory.ts +1 -1
  9. package/dist/cli/commands/recordTriggerEval.js +1 -1
  10. package/dist/cli-delegate/{chunk-KR5ZIFPO.js → chunk-DHSSP7V3.js} +1 -1
  11. package/dist/cli-delegate/chunk-E7ACTJTJ.js +6792 -0
  12. package/dist/cli-delegate/{chunk-BZJ4Y3CI.js → chunk-MT2WOOCY.js} +1 -1
  13. package/dist/cli-delegate/{chunk-CMZEKSNN.js → chunk-ORJPBER2.js} +2 -2
  14. package/dist/cli-delegate/{chunk-VYZGJLCA.js → chunk-RKNT4LIK.js} +22 -1286
  15. package/dist/cli-delegate/cmd_conformance.js +20 -14
  16. package/dist/cli-delegate/cmd_doctor.js +3 -3
  17. package/dist/cli-delegate/cmd_doctor_neighbours.js +2 -2
  18. package/dist/cli-delegate/cmd_explain.js +1 -1
  19. package/dist/cli-delegate/cmd_export.js +2 -2
  20. package/dist/cli-delegate/cmd_prune.js +2 -2
  21. package/dist/cli-delegate/cmd_settings_migrate.js +2 -2
  22. package/dist/cli-delegate/cmd_settings_set.js +8 -4
  23. package/dist/cli-delegate/cmd_sync.js +2 -2
  24. package/dist/cli-delegate/cmd_uninstall.js +2 -2
  25. package/dist/cli-delegate/cmd_validate.js +2 -2
  26. package/dist/discovery/deprecation-report.md +1 -1
  27. package/dist/discovery/discovery-manifest.json +3 -3
  28. package/dist/discovery/discovery-manifest.json.sha256 +1 -1
  29. package/dist/discovery/discovery-manifest.summary.md +1 -1
  30. package/dist/discovery/dormancy-report.md +1 -1
  31. package/dist/discovery/orphan-report.md +1 -1
  32. package/dist/discovery/packs.json +2 -2
  33. package/dist/discovery/trust-report.md +1 -1
  34. package/dist/discovery/workspaces.json +2 -2
  35. package/dist/hooks/code_graph_refresh.chunk.cjs +414 -0
  36. package/dist/hooks/code_graph_refresh.chunk.cjs.sha256 +1 -0
  37. package/dist/hooks/dispatch.js +21 -11
  38. package/dist/hooks/dispatch.sha256 +1 -1
  39. package/dist/hooks/egress_engine.chunk.cjs +1 -1
  40. package/dist/hooks/egress_engine.chunk.cjs.sha256 +1 -1
  41. package/dist/install/install.mjs +6974 -6037
  42. package/dist/mcp/mcp-cloudflare-catalogue.json +1 -0
  43. package/dist/mcp/registry-manifest.json +3 -2
  44. package/dist/mcp/server.json +13 -4
  45. package/dist/mcp/server.mjs +5561 -2463
  46. package/dist/scripts/_lib/egress_rules.js +8 -2
  47. package/dist/scripts/condense.js +9 -9
  48. package/dist/server/routes/discovery.js +1 -1
  49. package/dist/server/routes/install.js +1 -1
  50. package/dist/server/routes/ping.js +1 -1
  51. package/dist/server/routes/schema.js +4 -4
  52. package/dist/server/routes/settings.js +2 -6
  53. package/dist/server/routes/wizard.js +6 -6
  54. package/dist/server/schemas/settings.js +42 -42
  55. package/dist/shared/settingsSurface.js +1 -1
  56. package/dist/shared/userMd/schema.js +1 -1
  57. package/dist/shared/zodJsonSchema.js +47 -0
  58. package/dist/ui/assets/index-B0bcIgi0.js +77 -0
  59. package/dist/ui/index.html +1 -1
  60. package/package.json +3 -3
  61. package/src/agent-src/templates/scripts/check_memory.ts +1 -1
  62. package/src/config/ci-local-parity.yml +11 -0
  63. package/src/config/continuity-surface.json +9 -2
  64. package/src/config/gate-violation-baselines.json +3 -3
  65. package/src/config/gitignore-block.txt +6 -5
  66. package/src/config/hook-bundle-budget.json +16 -1
  67. package/src/config/pack-size-budget.json +12 -1
  68. package/src/config/packed-binary-manifest.json +9 -0
  69. package/src/scripts/_cli/cmd_settings_set.ts +3 -2
  70. package/src/scripts/_dispatch.bash +1 -1
  71. package/src/scripts/_lib/egress_rules.ts +8 -2
  72. package/src/scripts/_lib/listing_manifests.ts +85 -0
  73. package/src/scripts/_lib/neighbour_census.ts +1 -0
  74. package/src/scripts/_lib/release_settings_surface.ts +2 -6
  75. package/src/scripts/adoption_status.ts +231 -27
  76. package/src/scripts/build_mcp_registry_manifest.ts +23 -12
  77. package/src/scripts/check_dependency_floors.ts +7 -0
  78. package/src/scripts/check_release_manifest_content.ts +17 -2
  79. package/src/scripts/check_release_pr_shape.ts +5 -0
  80. package/src/scripts/code_graph/build.ts +80 -44
  81. package/src/scripts/code_graph/cli.ts +260 -16
  82. package/src/scripts/code_graph/conventions_default.ts +228 -0
  83. package/src/scripts/code_graph/extract.ts +5 -2
  84. package/src/scripts/code_graph/extract_more.ts +105 -1
  85. package/src/scripts/code_graph/loader.ts +10 -2
  86. package/src/scripts/code_graph/map.ts +174 -41
  87. package/src/scripts/code_graph/map_model.ts +53 -15
  88. package/src/scripts/code_graph/map_verify.ts +12 -0
  89. package/src/scripts/code_graph/map_viewer/viewer.css +6 -0
  90. package/src/scripts/code_graph/map_viewer/viewer.js +114 -13
  91. package/src/scripts/code_graph/passes.ts +173 -0
  92. package/src/scripts/code_graph/providers/angular.ts +55 -0
  93. package/src/scripts/code_graph/providers/aspnetcore.ts +182 -0
  94. package/src/scripts/code_graph/providers/catalog.ts +290 -0
  95. package/src/scripts/code_graph/providers/django.ts +163 -0
  96. package/src/scripts/code_graph/providers/express.ts +25 -0
  97. package/src/scripts/code_graph/providers/fastapi.ts +49 -0
  98. package/src/scripts/code_graph/providers/flask.ts +99 -0
  99. package/src/scripts/code_graph/providers/go_http.ts +121 -0
  100. package/src/scripts/code_graph/providers/index.ts +101 -0
  101. package/src/scripts/code_graph/{laravel_overlay.ts → providers/laravel.ts} +113 -7
  102. package/src/scripts/code_graph/providers/nestjs.ts +34 -0
  103. package/src/scripts/code_graph/providers/nextjs.ts +91 -0
  104. package/src/scripts/code_graph/providers/nuxt.ts +55 -0
  105. package/src/scripts/code_graph/providers/react-router.ts +58 -0
  106. package/src/scripts/code_graph/providers/shared/detect.ts +78 -0
  107. package/src/scripts/code_graph/providers/shared/extractors.ts +249 -0
  108. package/src/scripts/code_graph/providers/shared/js.ts +252 -0
  109. package/src/scripts/code_graph/providers/shared/kit.ts +210 -0
  110. package/src/scripts/code_graph/providers/shared/php.ts +64 -0
  111. package/src/scripts/code_graph/providers/shared/python.ts +223 -0
  112. package/src/scripts/code_graph/providers/spring.ts +138 -0
  113. package/src/scripts/code_graph/providers/sqlalchemy.ts +62 -0
  114. package/src/scripts/code_graph/providers/sveltekit.ts +59 -0
  115. package/src/scripts/code_graph/providers/symfony.ts +142 -0
  116. package/src/scripts/code_graph/providers/typeorm.ts +58 -0
  117. package/src/scripts/code_graph/providers/types.ts +56 -0
  118. package/src/scripts/code_graph/providers/vue-router.ts +21 -0
  119. package/src/scripts/code_graph/providers/wordpress.ts +128 -0
  120. package/src/scripts/code_graph/refresh_cli.ts +81 -0
  121. package/src/scripts/code_graph/refresh_lock.ts +193 -0
  122. package/src/scripts/code_graph/resolution_tiers.ts +19 -7
  123. package/src/scripts/code_graph/scip_reader.ts +2 -2
  124. package/src/scripts/code_graph/sqlite_store.ts +10 -4
  125. package/src/scripts/code_graph/types.ts +75 -41
  126. package/src/scripts/code_graph/validate.ts +6 -8
  127. package/src/scripts/condense.ts +9 -9
  128. package/src/scripts/generate_settings_reference.ts +2 -6
  129. package/src/scripts/hook_chunks.mjs +4 -0
  130. package/src/scripts/hook_manifest.json +1 -1
  131. package/src/scripts/hook_manifest.yaml +24 -7
  132. package/src/scripts/hooks/code_graph_refresh_hook.ts +160 -0
  133. package/src/scripts/hooks/lazy_concern.ts +4 -0
  134. package/src/scripts/install-hooks.sh +54 -33
  135. package/src/scripts/install.ts +2 -6
  136. package/src/scripts/lint_hook_manifest.ts +2 -0
  137. package/src/scripts/lint_marketplace.ts +33 -0
  138. package/src/scripts/lint_mcp_registry_manifest.ts +36 -0
  139. package/src/scripts/mcp_server/consumer_tool_catalog.json +1 -0
  140. package/src/scripts/mcp_server/graph_tools.ts +5 -1
  141. package/src/scripts/release.ts +2 -5
  142. package/src/scripts/release_drill.ts +4 -1
  143. package/src/scripts/release_env.ts +17 -0
  144. package/src/scripts/release_mix_taxonomy.json +3 -0
  145. package/src/server/schemas/settings.ts +42 -42
  146. package/src/shared/settingsSurface.ts +1 -1
  147. package/src/shared/userMd/schema.ts +1 -1
  148. package/src/shared/zodJsonSchema.ts +41 -0
  149. package/src/vendor/grammars/README.md +6 -5
  150. package/src/vendor/grammars/tree-sitter-c_sharp.wasm +0 -0
  151. package/dist/cli-delegate/chunk-XIAPY44N.js +0 -4587
  152. package/dist/ui/assets/index-w70Ado09.js +0 -23
@@ -17,8 +17,9 @@ import { fileURLToPath } from 'node:url';
17
17
 
18
18
  import { write_atomic } from '../_lib/fs_atomic.js';
19
19
  import { extractFile, type FileExtract, type RawEdge } from './extract.js';
20
- import { buildLaravelOverlay } from './laravel_overlay.js';
21
20
  import { grammarWasmPath } from './loader.js';
21
+ import { type PassReport, runPasses } from './passes.js';
22
+ import { EMPTY_PROBE, PROBE_MARKERS, type ProjectProbe } from './providers/index.js';
22
23
  import {
23
24
  ALIAS_VIA,
24
25
  aliasRulesFrom,
@@ -30,8 +31,9 @@ import {
30
31
  resolveAlias,
31
32
  resolvePsr4,
32
33
  } from './resolution_tiers.js';
33
- import { DOC_EXTS, docsLayer } from './docs_layer.js';
34
- import { readScipIndex, type ScipDocument, scipEdges, supersededByScip } from './scip_reader.js';
34
+ import { templateBase } from './conventions_default.js';
35
+ import { DOC_EXTS } from './docs_layer.js';
36
+ import { readScipIndex, type ScipDocument } from './scip_reader.js';
35
37
  import { emitSqliteTwin } from './sqlite_store.js';
36
38
  import {
37
39
  type CodeEdge,
@@ -78,6 +80,10 @@ const CODE_EXTS: ReadonlySet<string> = new Set(Object.keys(EXT_LANG));
78
80
  const GENERATED_DIRS: ReadonlySet<string> = new Set(['agents/map', 'agents/runtime']);
79
81
 
80
82
  export function discoverFiles(root: string, exts: ReadonlySet<string> = CODE_EXTS): string[] {
83
+ return walkFiles(root, (name) => exts.has(path.extname(name).toLowerCase()));
84
+ }
85
+
86
+ function walkFiles(root: string, accept: (name: string) => boolean): string[] {
81
87
  const abs = path.resolve(root);
82
88
  // Resolve the root itself first: a repo checked out under a symlinked
83
89
  // path (e.g. macOS /tmp → /private/tmp) must still confine correctly —
@@ -106,8 +112,7 @@ export function discoverFiles(root: string, exts: ReadonlySet<string> = CODE_EXT
106
112
  continue;
107
113
  }
108
114
  if (!e.isFile()) continue;
109
- const ext = path.extname(e.name).toLowerCase();
110
- if (!exts.has(ext)) continue;
115
+ if (!accept(e.name)) continue;
111
116
  let real: string;
112
117
  try {
113
118
  real = fs.realpathSync(full);
@@ -122,6 +127,33 @@ export function discoverFiles(root: string, exts: ReadonlySet<string> = CODE_EXT
122
127
  return out.sort();
123
128
  }
124
129
 
130
+ /** 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)$/;
132
+
133
+ /** The project's manifests and marker files, for provider detection (ADR-298). */
134
+ export function readProjectProbe(root: string): ProjectProbe {
135
+ const abs = path.resolve(root);
136
+ const manifests = new Map<string, string>();
137
+ const markers = new Set<string>();
138
+ const paths: string[] = [];
139
+ for (const full of walkFiles(abs, () => true)) {
140
+ const rel = toPosixRel(abs, full);
141
+ paths.push(rel);
142
+ const name = path.basename(full);
143
+ if (!MANIFEST_NAME.test(name) && !PROBE_MARKERS.has(name)) continue;
144
+ if (PROBE_MARKERS.has(name)) {
145
+ markers.add(rel);
146
+ continue;
147
+ }
148
+ try {
149
+ manifests.set(rel, fs.readFileSync(full, 'utf-8'));
150
+ } catch {
151
+ continue;
152
+ }
153
+ }
154
+ return { manifests, markers, paths };
155
+ }
156
+
125
157
  function toPosixRel(root: string, full: string): string {
126
158
  return path.relative(root, full).split(path.sep).join('/');
127
159
  }
@@ -234,10 +266,19 @@ function resolveSpecifier(fromFile: string, spec: string, fileIds: ReadonlySet<s
234
266
  * go `example.com/x/mod` → every non-test `.go` file of the most specific
235
267
  * directory whose path ends the import path — a Go import names a
236
268
  * package, and a package is all of its files.
269
+ * csharp `using A.B` → the one file declaring namespace `A.B` (`namespaces`).
270
+ * A namespace declared by several files is left to name lookup: it
271
+ * binds no file, since a `using` names no single one.
237
272
  *
238
273
  * A suffix that matches two files is refused rather than guessed.
239
274
  */
240
- export function resolveLangModule(lang: Lang, fromFile: string, spec: string, fileIds: readonly string[]): string[] {
275
+ export function resolveLangModule(
276
+ lang: Lang,
277
+ fromFile: string,
278
+ spec: string,
279
+ fileIds: readonly string[],
280
+ namespaces: ReadonlyMap<string, readonly string[]> = new Map(),
281
+ ): string[] {
241
282
  const unique = (suffixes: string[]): string[] => {
242
283
  const hits = fileIds.filter((f) => suffixes.some((s) => f === s || f.endsWith(`/${s}`)));
243
284
  return hits.length === 1 ? hits : [];
@@ -264,6 +305,7 @@ export function resolveLangModule(lang: Lang, fromFile: string, spec: string, fi
264
305
  }
265
306
  return best ? goFiles.filter((f) => idDirname(f) === best).sort() : [];
266
307
  }
308
+ if (lang === 'csharp') return [...(namespaces.get(spec) ?? [])];
267
309
  return [];
268
310
  }
269
311
 
@@ -309,6 +351,8 @@ export function buildGraph(
309
351
  resolution: ResolutionConfig = EMPTY_RESOLUTION_CONFIG,
310
352
  scip: readonly ScipDocument[] = [],
311
353
  docs: readonly SourceFile[] = [],
354
+ probe: ProjectProbe = EMPTY_PROBE,
355
+ report?: { passes?: PassReport },
312
356
  ): CodeGraph {
313
357
  const nodes = extracts.flatMap((e) => e.nodes);
314
358
  const nodeIds = new Set(nodes.map((n) => n.id));
@@ -383,6 +427,8 @@ export function buildGraph(
383
427
  const fileIdList = [...fileIds].sort();
384
428
  /** Go imports name a package of several files; the binding takes the first, these carry the rest. */
385
429
  const extraImports: Array<{ source: string; target: string }> = [];
430
+ const namespaceFiles = new Map<string, string[]>();
431
+ for (const ex of extracts) for (const ns of ex.namespaces ?? []) push(namespaceFiles, ns, ex.file);
386
432
  for (const ex of extracts) {
387
433
  const locals = new Map<string, string[]>();
388
434
  for (const n of ex.nodes) if (n.kind !== 'file') push(locals, n.label, n.id);
@@ -419,8 +465,9 @@ export function buildGraph(
419
465
  if (hit) imports.set(local, { target: hit, exact: false, via: 'name-lookup' });
420
466
  continue;
421
467
  }
422
- if (ex.lang === 'python' || ex.lang === 'java' || ex.lang === 'go') {
423
- const hits = resolveLangModule(ex.lang, ex.file, spec, fileIdList);
468
+ 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') {
470
+ const hits = resolveLangModule(ex.lang, ex.file, spec, fileIdList, namespaceFiles);
424
471
  const first = hits[0];
425
472
  if (first === undefined) {
426
473
  imports.set(local, {
@@ -754,39 +801,9 @@ export function buildGraph(
754
801
  });
755
802
  }
756
803
 
757
- // Framework links no import states (routes, views, tables); rule-read,
758
- // emitted only when the target exists. Also drops the base edges it
759
- // proves false: a fluent `->middleware()` bound by name to some
760
- // unrelated in-repo `middleware()`.
761
- const overlay = buildLaravelOverlay(files, nodes, resolution);
762
- let routeBuilderCalls = 0;
763
- if (overlay.nodes.length > 0 || overlay.edges.length > 0) {
764
- const kept = edges.filter((e) => !overlay.dropEdge(e));
765
- routeBuilderCalls = edges.length - kept.length;
766
- // Element-wise: a spread of ~200k edges overflows the call stack.
767
- edges.length = 0;
768
- for (const e of kept) edges.push(e);
769
- for (const e of overlay.edges) edges.push(e);
770
- for (const n of overlay.nodes) nodes.push(n);
771
- }
772
-
773
- if (docs.length > 0) {
774
- const allFiles = new Set([...files.map((f) => f.path), ...docs.map((d) => d.path)]);
775
- const layer = docsLayer(docs, allFiles);
776
- for (const n of layer.nodes) nodes.push(n);
777
- for (const e of layer.edges) edges.push(e);
778
- }
779
-
780
- // A project's own SCIP index states references the compiler resolved;
781
- // each one supersedes the native name guess it duplicates.
782
- if (scip.length > 0) {
783
- const stated = scipEdges(scip, nodes);
784
- const dropped = supersededByScip(stated, nodes);
785
- const kept = edges.filter((e) => !dropped(e));
786
- edges.length = 0;
787
- for (const e of kept) edges.push(e);
788
- for (const e of stated) edges.push(e);
789
- }
804
+ // Everything after resolution: convention providers, docs, SCIP (passes.ts).
805
+ const passes = runPasses({ files, docs, scip, resolution, probe }, nodes, edges);
806
+ if (report) report.passes = passes;
790
807
 
791
808
  // deterministic ordering
792
809
  nodes.sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
@@ -800,7 +817,9 @@ export function buildGraph(
800
817
  for (const e of edges) counts[e.confidence] += 1;
801
818
  const suppressed: CodeGraph['suppressed_edge_counts'] = {
802
819
  dynamic_no_candidate: suppressedKeys.size,
803
- ...(routeBuilderCalls > 0 ? { route_builder_call: routeBuilderCalls } : {}),
820
+ ...(Object.keys(passes.suppressed).length > 0
821
+ ? { contested_by_provider: Object.fromEntries(Object.entries(passes.suppressed).sort()) }
822
+ : {}),
804
823
  };
805
824
 
806
825
  const languages = [...new Set(extracts.map((e) => e.lang))].sort() as Lang[];
@@ -819,6 +838,18 @@ export function buildGraph(
819
838
  h.update(createHash('sha256').update(d.source, 'utf-8').digest('hex'));
820
839
  h.update('\n');
821
840
  }
841
+ // Manifests and markers decide which providers run: a changed manifest is
842
+ // a changed input even when no source file moved.
843
+ for (const [p, text] of [...probe.manifests].sort((a, b) => (a[0] < b[0] ? -1 : 1))) {
844
+ h.update(`manifest\0${p}\0`);
845
+ h.update(createHash('sha256').update(text, 'utf-8').digest('hex'));
846
+ h.update('\n');
847
+ }
848
+ for (const p of [...probe.markers].sort()) h.update(`marker\0${p}\n`);
849
+ // Template files are no source, yet their presence decides `renders` and
850
+ // default-pass edges: they are inputs too. Other non-code paths (caches,
851
+ // logs, assets) are not read and stay out of the identity.
852
+ for (const p of probe.paths) if (templateBase(p) !== null) h.update(`template\0${p}\n`);
822
853
  // The SCIP index is an input like any source file: a new index must not
823
854
  // read as the same graph.
824
855
  for (const d of [...scip].sort((a, b) => (a.path < b.path ? -1 : 1))) {
@@ -833,6 +864,7 @@ export function buildGraph(
833
864
  grammar_abi: EXPECTED_GRAMMAR_ABI,
834
865
  edge_confidence_counts: counts,
835
866
  suppressed_edge_counts: suppressed,
867
+ ...(passes.providers.length > 0 ? { providers: passes.providers } : {}),
836
868
  nodes,
837
869
  edges,
838
870
  };
@@ -908,6 +940,7 @@ export function serializeGraph(g: CodeGraph): string {
908
940
  'grammar_abi',
909
941
  'edge_confidence_counts',
910
942
  'suppressed_edge_counts',
943
+ 'providers',
911
944
  'nodes',
912
945
  'edges',
913
946
  ];
@@ -936,13 +969,14 @@ export function serializeGraph(g: CodeGraph): string {
936
969
  // byte what it was before this field existed.
937
970
  if (e.ambiguity_reason) base['ambiguity_reason'] = e.ambiguity_reason;
938
971
  if (e.candidates) base['candidates'] = e.candidates;
972
+ if (e.provider_id) base['provider_id'] = e.provider_id;
939
973
  return base;
940
974
  };
941
975
  const ordered: Record<string, unknown> = {};
942
976
  for (const k of key_order) {
943
977
  if (k === 'nodes') ordered[k] = g.nodes.map(node);
944
978
  else if (k === 'edges') ordered[k] = g.edges.map(edge);
945
- else ordered[k] = g[k];
979
+ else if (g[k] !== undefined) ordered[k] = g[k];
946
980
  }
947
981
  return JSON.stringify(ordered, null, 2) + '\n';
948
982
  }
@@ -1086,7 +1120,9 @@ export async function buildFromRepo(
1086
1120
  continue;
1087
1121
  }
1088
1122
  }
1089
- const graph = buildGraph(files, extracts, readResolutionConfig(root), scip, docs);
1123
+ const report: { passes?: PassReport } = {};
1124
+ const graph = buildGraph(files, extracts, readResolutionConfig(root), scip, docs, readProjectProbe(abs), report);
1125
+ for (const f of report.passes?.failures ?? []) process.stderr.write(`code-graph: pass ${f.pass} failed — ${f.reason}\n`);
1090
1126
  const json = serializeGraph(graph);
1091
1127
  if (cachePath) {
1092
1128
  write_atomic(cachePath, json);
@@ -6,8 +6,12 @@
6
6
  * validate [--graph P] schema-gate a graph.json
7
7
  * detect [--root P] [--format json] three-state freshness verdict
8
8
  * refresh [--root P] [--out P] [--budget-seconds N]
9
- * build --update (STALE) / no-op
10
- * (FRESH) / full build (ABSENT)
9
+ * [--single-flight] [--map] build --update (STALE) / no-op
10
+ * (FRESH) / full build (ABSENT);
11
+ * a result built while HEAD moved
12
+ * is discarded and retried once
13
+ * auto-refresh on|off|status [--root P] opt this project in to the
14
+ * session-start background refresh
11
15
  *
12
16
  * `detect --format json` (Phase 2, road-to-reachable-code-memory) emits a
13
17
  * single-line, stable-key-order JSON verdict on stdout — no timestamps, safe
@@ -62,7 +66,7 @@
62
66
  * graph.
63
67
  */
64
68
  import { execFileSync, spawnSync } from 'node:child_process';
65
- import { randomBytes } from 'node:crypto';
69
+ import { createHash, randomBytes } from 'node:crypto';
66
70
  import * as fs from 'node:fs';
67
71
  import * as path from 'node:path';
68
72
  import process from 'node:process';
@@ -70,7 +74,8 @@ import { fileURLToPath } from 'node:url';
70
74
 
71
75
  import { resolve_project_root } from '../_lib/agent_settings.js';
72
76
  import { hardenedSpawnEnv } from '../_lib/spawn_env.js';
73
- import { buildFromRepo, isTestFile, sidecarPath } from './build.js';
77
+ import { buildFromRepo, discoverFiles, isTestFile, sidecarPath } from './build.js';
78
+ import { DOC_EXTS } from './docs_layer.js';
74
79
  import {
75
80
  computeVerdict,
76
81
  detectSources,
@@ -80,7 +85,16 @@ import {
80
85
  pickSource,
81
86
  } from './detect.js';
82
87
  import { suggestVerb } from './intent.js';
83
- import { buildMap, recordOutcome } from './map.js';
88
+ import { buildMap, graphGeneration, mapGeneration, recordOutcome } from './map.js';
89
+ import { cliPackage, usableCli } from './refresh_cli.js';
90
+ import {
91
+ AUTO_REFRESH_REL,
92
+ LOCK_REL,
93
+ acquire as acquireRefreshLock,
94
+ readRefreshRecords,
95
+ reclaimMutexPath,
96
+ recordRefresh,
97
+ } from './refresh_lock.js';
84
98
  import { verifyMap } from './map_verify.js';
85
99
  import {
86
100
  affected,
@@ -655,6 +669,64 @@ function cleanupTemp(tmpOut: string): void {
655
669
  * itself uses. Any other outcome (timeout, non-zero exit, missing output)
656
670
  * cleans up the temp files and leaves `out` untouched.
657
671
  */
672
+ function git(root: string, ...args: string[]): string | null {
673
+ try {
674
+ return execFileSync('git', ['-C', root, ...args], { encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'], env: hardenedSpawnEnv() }).trim();
675
+ } catch {
676
+ return null;
677
+ }
678
+ }
679
+
680
+ /**
681
+ * HEAD; `''` when no ancestor of the CANONICAL root holds `.git`; `null` when
682
+ * one does and HEAD cannot be read, or when the root or an ancestor cannot be
683
+ * inspected. "Not a repository" is read from the filesystem, never from a git
684
+ * probe, and is walked from the realpath so a symlinked root cannot hide the
685
+ * repository git itself would find.
686
+ */
687
+ function headOf(root: string): string | null {
688
+ let dir: string;
689
+ try {
690
+ dir = fs.realpathSync(root);
691
+ } catch {
692
+ return null;
693
+ }
694
+ for (;;) {
695
+ try {
696
+ fs.lstatSync(path.join(dir, '.git'));
697
+ return git(dir, 'rev-parse', 'HEAD');
698
+ } catch (e) {
699
+ if ((e as NodeJS.ErrnoException).code !== 'ENOENT') return null;
700
+ }
701
+ if (path.dirname(dir) === dir) return '';
702
+ dir = path.dirname(dir);
703
+ }
704
+ }
705
+
706
+ /**
707
+ * A stamp of the files a build reads — path, size and modification time of every
708
+ * source and document file. HEAD alone misses an edit to the working tree made
709
+ * while the build ran (a second agent saving a file), which leaves a graph of
710
+ * content that never existed together. Best-effort, not definitive: an edit
711
+ * that keeps the size and lands within the filesystem's mtime resolution (or
712
+ * restores the mtime) is not seen; hashing every file's content would be.
713
+ */
714
+ function treeStamp(root: string): string {
715
+ const h = createHash('sha256');
716
+ for (const full of [...discoverFiles(root), ...discoverFiles(root, DOC_EXTS)]) {
717
+ try {
718
+ const st = fs.statSync(full);
719
+ h.update(`${full}\0${st.size}\0${st.mtimeMs}\n`);
720
+ } catch {
721
+ h.update(`${full}\0gone\n`);
722
+ }
723
+ }
724
+ return h.digest('hex');
725
+ }
726
+
727
+ /** Returned by {@link runBudgetedBuild} when HEAD or the working tree moved while the child built. */
728
+ const HEAD_MOVED = -1;
729
+
658
730
  function runBudgetedBuild(root: string, out: string, update: boolean, budgetSeconds: number): number {
659
731
  if (!(budgetSeconds > 0)) {
660
732
  // Node's own `timeout` option treats 0 as "no limit" — a genuinely
@@ -682,6 +754,8 @@ function runBudgetedBuild(root: string, out: string, update: boolean, budgetSeco
682
754
  const [cmd, cmdArgs] = fs.existsSync(TSX_BIN)
683
755
  ? [TSX_BIN, [_HERE, ...args]]
684
756
  : [process.execPath, [...process.execArgv, _HERE, ...args]];
757
+ const headBefore = headOf(root);
758
+ const treeBefore = treeStamp(root);
685
759
  const res = spawnSync(cmd, cmdArgs, {
686
760
  env: hardenedSpawnEnv(),
687
761
  stdio: 'ignore',
@@ -705,6 +779,19 @@ function runBudgetedBuild(root: string, out: string, update: boolean, budgetSeco
705
779
  process.stderr.write('code-graph: refresh produced no output — old cache kept\n');
706
780
  return 3;
707
781
  }
782
+ // A checkout or rebase that lands mid-build leaves a graph of a tree that
783
+ // existed only in passing; publishing it would label mixed content fresh.
784
+ // An unreadable HEAD is not evidence it held still, so it fails the same way.
785
+ if (headBefore === null || headOf(root) !== headBefore) {
786
+ cleanupTemp(tmpOut);
787
+ process.stderr.write('code-graph: HEAD moved (or could not be read) during the refresh — result discarded\n');
788
+ return HEAD_MOVED;
789
+ }
790
+ if (treeStamp(root) !== treeBefore) {
791
+ cleanupTemp(tmpOut);
792
+ process.stderr.write('code-graph: files changed during the refresh — result discarded\n');
793
+ return HEAD_MOVED;
794
+ }
708
795
  fs.renameSync(tmpOut, out); // same-dir → atomic; real cache only ever changes here
709
796
  const tmpSidecar = sidecarPath(tmpOut);
710
797
  if (fs.existsSync(tmpSidecar)) fs.renameSync(tmpSidecar, sidecarPath(out));
@@ -712,7 +799,12 @@ function runBudgetedBuild(root: string, out: string, update: boolean, budgetSeco
712
799
  return 0;
713
800
  }
714
801
 
715
- function cmdRefresh(argv: string[]): number {
802
+ /**
803
+ * `--single-flight` takes the lock the git-hook refresh block takes and exits 0
804
+ * when another refresh holds it. `--map` redraws `agents/map/` afterwards, only
805
+ * when the project already has one and it is older than the cache.
806
+ */
807
+ async function cmdRefresh(argv: string[]): Promise<number> {
716
808
  const root = resolveRoot(argv);
717
809
  const out = flag(argv, '--out') ?? cacheFor(root);
718
810
  const budgetSeconds = budgetSecondsOf(argv);
@@ -720,12 +812,126 @@ function cmdRefresh(argv: string[]): number {
720
812
  process.stderr.write(`code-graph: root not found: ${root}\n`);
721
813
  return 1;
722
814
  }
723
- const v = computeVerdict(root, out);
724
- if (v.verdict === 'FRESH') {
725
- process.stdout.write('fresh — nothing to do\n');
815
+ // `--map` writes the same files a manual `map` does, so it is never drawn unlocked.
816
+ const locked = argv.includes('--single-flight') || argv.includes('--map');
817
+ const release = locked ? acquireRefreshLock(root) : () => {};
818
+ const record = (outcome: string, detail?: string): void => {
819
+ if (locked) recordRefresh(root, { by: 'refresh', outcome, ...(detail ? { detail } : {}) });
820
+ };
821
+ if (release === null) {
822
+ process.stdout.write('another refresh is running — nothing to do\n');
823
+ record('locked');
824
+ return 0;
825
+ }
826
+ try {
827
+ let rc = HEAD_MOVED;
828
+ // One retry: a second move inside one build window means the tree is
829
+ // still in motion, and the next hook or session start will refresh it.
830
+ for (let attempt = 0; attempt < 2 && rc === HEAD_MOVED; attempt++) {
831
+ const v = computeVerdict(root, out);
832
+ if (v.verdict === 'FRESH') {
833
+ process.stdout.write('fresh — nothing to do\n');
834
+ rc = 0;
835
+ break;
836
+ }
837
+ rc = runBudgetedBuild(root, out, v.verdict === 'STALE', budgetSeconds);
838
+ }
839
+ if (rc === HEAD_MOVED) {
840
+ record('tree-moved', 'HEAD or files changed during both attempts; the next refresh retries');
841
+ return 1;
842
+ }
843
+ if (rc !== 0) {
844
+ record('failed', `build exited ${rc}`);
845
+ return rc;
846
+ }
847
+ if (argv.includes('--map') && mapIsBehind(root, out)) {
848
+ const mrc = await drawMap(['--root', root, '--no-build', '--no-verify']);
849
+ record(mrc === 0 ? 'refreshed-with-map' : 'map-failed', mrc === 0 ? undefined : `map exited ${mrc}`);
850
+ return mrc;
851
+ }
852
+ record('refreshed');
853
+ return rc;
854
+ } finally {
855
+ release();
856
+ }
857
+ }
858
+
859
+ /**
860
+ * `auto-refresh on|off|status` — the explicit, local, per-project opt-in the
861
+ * `code-graph-refresh` session concern requires before it starts anything
862
+ * (ratification round of 2026-10-23: an existing cache is not consent).
863
+ */
864
+ function cmdAutoRefresh(argv: string[]): number {
865
+ const root = resolveRoot(argv);
866
+ const action = argv.find((a) => !a.startsWith('--') && !isFlagValue(argv, a)) ?? 'status';
867
+ const marker = path.join(root, AUTO_REFRESH_REL);
868
+ if (action === 'on') {
869
+ fs.mkdirSync(path.dirname(marker), { recursive: true });
870
+ fs.writeFileSync(marker, `${JSON.stringify({ enabled_at: new Date().toISOString() })}\n`);
871
+ const cli = usableCli(root);
872
+ process.stdout.write(
873
+ 'auto-refresh on — each agent session start refreshes the graph, and agents/map/ when it exists,\n' +
874
+ 'in the background (POSIX hosts). `code-graph auto-refresh off` stops it.\n' +
875
+ 'The opt-in is per working tree: run it again in each new git worktree.\n' +
876
+ (fs.existsSync(cacheFor(root)) ? '' : 'No graph yet: `code-graph build` or `code-graph map` creates one.\n'),
877
+ );
878
+ if ('reason' in cli) {
879
+ process.stderr.write(
880
+ cli.reason === 'no-cli'
881
+ ? 'warning: no agent-config outside this repository is on PATH, so nothing will run yet — install it globally (npm i -g @event4u/agent-config).\n'
882
+ : `warning: ${cli.cli} (version ${cliPackage(cli.cli)?.version ?? 'unknown'}) is too old for the single-flight refresh, so nothing will run yet — update it (npm i -g @event4u/agent-config).\n`,
883
+ );
884
+ }
726
885
  return 0;
727
886
  }
728
- return runBudgetedBuild(root, out, v.verdict === 'STALE', budgetSeconds);
887
+ if (action === 'off') {
888
+ fs.rmSync(marker, { force: true });
889
+ process.stdout.write('auto-refresh off\n');
890
+ return 0;
891
+ }
892
+ if (action !== 'status') {
893
+ process.stderr.write('usage: code_graph/cli.ts auto-refresh on|off|status [--root P]\n');
894
+ return 2;
895
+ }
896
+ const cli = usableCli(root);
897
+ const last = readRefreshRecords(root);
898
+ 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');
900
+ const gen = graphGeneration(cacheFor(root));
901
+ const drawnFrom = mapGeneration(mapDir);
902
+ const mutex = reclaimMutexPath(path.join(root, LOCK_REL));
903
+ process.stdout.write(
904
+ `auto-refresh: ${fs.existsSync(marker) ? 'on' : 'off'}\n` +
905
+ `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` +
906
+ `last session start: ${line(last['session-start'])}\n` +
907
+ `last refresh: ${line(last.refresh)}\n` +
908
+ `graph: ${computeVerdict(root, cacheFor(root)).verdict}\n` +
909
+ `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
+ (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`
912
+ : ''),
913
+ );
914
+ return 0;
915
+ }
916
+
917
+ function mutexAgeMs(p: string): number {
918
+ try {
919
+ return Date.now() - fs.statSync(p).mtimeMs;
920
+ } catch {
921
+ return 0;
922
+ }
923
+ }
924
+
925
+ /**
926
+ * Whether the map was drawn from a different graph than the cache now holds —
927
+ * decided by the generation both record, never by modification times, which a
928
+ * copied or restored file carries wrongly. No map → never draw one unbidden.
929
+ */
930
+ function mapIsBehind(root: string, cache: string): boolean {
931
+ const mapDir = path.join(root, 'agents', 'map');
932
+ if (!fs.existsSync(mapDir)) return false;
933
+ const gen = graphGeneration(cache);
934
+ return gen !== null && mapGeneration(mapDir) !== gen;
729
935
  }
730
936
 
731
937
  /**
@@ -739,6 +945,33 @@ async function cmdMap(argv: string[]): Promise<number> {
739
945
  process.stderr.write(`code-graph: root not found: ${root}\n`);
740
946
  return 1;
741
947
  }
948
+ // A background refresh may be redrawing the same files; two writers would
949
+ // interleave them. Wait for it — a session start is exactly when someone asks
950
+ // for the map — but not beyond one refresh budget plus a redraw.
951
+ let release = acquireRefreshLock(root);
952
+ const deadline = Date.now() + MAP_LOCK_WAIT_MS;
953
+ if (release === null) process.stderr.write('code-graph: waiting for a background refresh to finish…\n');
954
+ while (release === null && Date.now() < deadline) {
955
+ await new Promise((r) => setTimeout(r, 250));
956
+ release = acquireRefreshLock(root);
957
+ }
958
+ if (release === null) {
959
+ process.stderr.write('code-graph: a refresh is still redrawing the map — try again in a moment\n');
960
+ return 1;
961
+ }
962
+ try {
963
+ return await drawMap(argv);
964
+ } finally {
965
+ release();
966
+ }
967
+ }
968
+
969
+ /** How long `map` waits for a background refresh to release the lock. */
970
+ const MAP_LOCK_WAIT_MS = Number(process.env['AGENT_CONFIG_MAP_LOCK_WAIT_MS'] ?? 90 * 1000);
971
+
972
+ /** The map itself; callers hold the refresh lock. */
973
+ async function drawMap(argv: string[]): Promise<number> {
974
+ const root = resolveRoot(argv);
742
975
  const out = flag(argv, '--out');
743
976
  const diffRev = flag(argv, '--diff');
744
977
  const r = await buildMap(root, {
@@ -756,15 +989,25 @@ async function cmdMap(argv: string[]): Promise<number> {
756
989
  (r.model.diff
757
990
  ? ` 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`
758
991
  : '') +
759
- ` wrote: ${r.written.length} files under ${rel}/ — open ${path.join(rel, 'index.html')}\n`,
992
+ ` wrote: ${r.written.length} files under ${rel}/ — open ${path.join(rel, 'index.html')}\n` +
993
+ (fs.existsSync(path.join(root, AUTO_REFRESH_REL))
994
+ ? ''
995
+ : ' keep it current at every agent session start: agent-config code-graph auto-refresh on\n'),
760
996
  );
761
997
  let rc = 0;
998
+ const renderFile = path.join(r.outDir, 'map-render.json');
762
999
  if (argv.includes('--no-verify')) {
763
- // A check from an earlier map must not sit beside this one as if it were about it.
764
- fs.rmSync(path.join(r.outDir, 'map-render.json'), { force: true });
1000
+ // A check that names the generation it checked can stay beside a newer
1001
+ // map without being read as about it; one that names none cannot.
1002
+ try {
1003
+ const prev = JSON.parse(fs.readFileSync(renderFile, 'utf-8')) as { graph_generation?: unknown };
1004
+ if (typeof prev.graph_generation !== 'string') fs.rmSync(renderFile, { force: true });
1005
+ } catch {
1006
+ fs.rmSync(renderFile, { force: true });
1007
+ }
765
1008
  } else {
766
1009
  const check = await verifyMap(path.join(r.outDir, 'index.html'));
767
- fs.writeFileSync(path.join(r.outDir, 'map-render.json'), `${JSON.stringify(check, null, 2)}\n`);
1010
+ fs.writeFileSync(renderFile, `${JSON.stringify({ ...check, graph_generation: r.meta.graph_generation }, null, 2)}\n`);
768
1011
  const problems = check.viewports
769
1012
  .flatMap((v) => [
770
1013
  ...(v.pageErrors ? [`${v.name}: ${v.pageErrors} page error(s)`] : []),
@@ -831,7 +1074,7 @@ export async function main(argv: string[] = process.argv.slice(2)): Promise<numb
831
1074
  if (sub === 'build') return await cmdBuild(argv.slice(1));
832
1075
  if (sub === 'validate') return cmdValidate(argv.slice(1));
833
1076
  if (sub === 'detect') return cmdDetect(argv.slice(1));
834
- if (sub === 'refresh') return cmdRefresh(argv.slice(1));
1077
+ if (sub === 'refresh') return await cmdRefresh(argv.slice(1));
835
1078
  if (sub === 'query' || sub === 'explain' || sub === 'affected' || sub === 'path')
836
1079
  return cmdQuery(sub, argv.slice(1));
837
1080
  if (sub === 'impact') return cmdImpact(argv.slice(1));
@@ -841,9 +1084,10 @@ export async function main(argv: string[] = process.argv.slice(2)): Promise<numb
841
1084
  if (sub === 'suggest-verb') return cmdSuggestVerb(argv.slice(1));
842
1085
  if (sub === 'map') return await cmdMap(argv.slice(1));
843
1086
  if (sub === 'outcome') return cmdOutcome(argv.slice(1));
1087
+ if (sub === 'auto-refresh') return cmdAutoRefresh(argv.slice(1));
844
1088
  process.stderr.write(
845
1089
  'usage: code_graph/cli.ts <build|validate|detect|refresh|query|explain|affected|path|' +
846
- 'impact|tests-for|untested|dead|suggest-verb|map|outcome> [options]\n',
1090
+ 'impact|tests-for|untested|dead|suggest-verb|map|outcome|auto-refresh> [options]\n',
847
1091
  );
848
1092
  return 2;
849
1093
  } catch (e) {