@event4u/agent-config 14.21.0 → 14.23.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 (235) 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 -187
  5. package/CREDITS.md +37 -0
  6. package/README.md +1 -1
  7. package/dist/agent-src/commands/analyze/inbox.md +380 -5
  8. package/dist/agent-src/commands/analyze/repo.md +4 -2
  9. package/dist/agent-src/commands/pr/merge.md +47 -38
  10. package/dist/agent-src/commands/roadmap/next.md +8 -0
  11. package/dist/agent-src/contexts/communication/rules-auto/token-efficiency-mechanics.md +69 -0
  12. package/dist/agent-src/contexts/execution/auto-dispatch-classification.md +5 -5
  13. package/dist/agent-src/contexts/execution/autonomy-mechanics.md +12 -0
  14. package/dist/agent-src/rules/external-code-graph-interop.md +19 -14
  15. package/dist/agent-src/rules/notes-first-reasoning.md +36 -25
  16. package/dist/agent-src/rules/token-efficiency.md +26 -3
  17. package/dist/agent-src/scripts/_cli_entry.ts +51 -0
  18. package/dist/agent-src/scripts/archive_completed_roadmaps.ts +120 -27
  19. package/dist/agent-src/scripts/roadmap_gates.ts +4 -20
  20. package/dist/agent-src/scripts/roadmap_set_step.ts +5 -2
  21. package/dist/agent-src/scripts/stubs_due.ts +4 -18
  22. package/dist/agent-src/scripts/update_roadmap_progress.ts +7 -24
  23. package/dist/agent-src/skills/code-intelligence/SKILL.md +82 -1
  24. package/dist/agent-src/skills/git-workflow/references/push-closes-its-loop.md +9 -0
  25. package/dist/agent-src/skills/roadmap-writing/SKILL.md +17 -0
  26. package/dist/agent-src/skills/tailwind-engineer/SKILL.md +52 -2
  27. package/dist/agent-src/templates/agent-settings.md +0 -1
  28. package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
  29. package/dist/cli/registry.js +1 -1
  30. package/dist/cli-delegate/_cli_entry.js +8 -0
  31. package/dist/cli-delegate/_lib/pin_resolver.js +170 -0
  32. package/dist/cli-delegate/archival_sweep.js +8 -0
  33. package/dist/cli-delegate/archive_completed_roadmaps.js +542 -0
  34. package/dist/cli-delegate/check_update_banner.js +923 -0
  35. package/dist/cli-delegate/{chunk-ZVBOHSVF.js → chunk-2KKXJW25.js} +23 -9
  36. package/dist/cli-delegate/chunk-5EK5WQN6.js +28 -0
  37. package/dist/cli-delegate/chunk-7XGD2OEU.js +96 -0
  38. package/dist/cli-delegate/chunk-DBH4AHDK.js +155 -0
  39. package/dist/cli-delegate/{chunk-4FS4WRXP.js → chunk-IAI3XL43.js} +15 -3
  40. package/dist/cli-delegate/chunk-JO25G5H7.js +20 -0
  41. package/dist/cli-delegate/{chunk-643Z7FIC.js → chunk-KCNTUSPI.js} +7 -1
  42. package/dist/cli-delegate/chunk-LTYVIY76.js +7961 -0
  43. package/dist/cli-delegate/chunk-QAJS5YWY.js +999 -0
  44. package/dist/cli-delegate/{chunk-YS6XTLEK.js → chunk-TYSY33MD.js} +56 -6
  45. package/dist/cli-delegate/chunk-ULET4BYS.js +328 -0
  46. package/dist/cli-delegate/chunk-YDQSHTD7.js +187 -0
  47. package/dist/cli-delegate/chunk-YWVXLR44.js +48 -0
  48. package/dist/cli-delegate/chunk-ZZQBUHFK.js +273 -0
  49. package/dist/cli-delegate/cmd_conformance.js +2 -2
  50. package/dist/cli-delegate/cmd_export.js +2 -2
  51. package/dist/cli-delegate/cmd_handoff.js +45 -13
  52. package/dist/cli-delegate/cmd_prune.js +2 -2
  53. package/dist/cli-delegate/cmd_session_recycle.js +4 -14
  54. package/dist/cli-delegate/cmd_settings_migrate.js +2 -2
  55. package/dist/cli-delegate/cmd_settings_set.js +1 -1
  56. package/dist/cli-delegate/cmd_sync.js +2 -2
  57. package/dist/cli-delegate/cmd_uninstall.js +2 -2
  58. package/dist/cli-delegate/cmd_validate.js +2 -2
  59. package/dist/cli-delegate/dashboard_mode.js +16 -0
  60. package/dist/cli-delegate/gate_budget.js +24 -0
  61. package/dist/cli-delegate/gate_execute.js +25 -0
  62. package/dist/cli-delegate/guarded_baseline.js +18 -0
  63. package/dist/cli-delegate/resume_probe.js +22 -0
  64. package/dist/cli-delegate/roadmap_gates.js +638 -0
  65. package/dist/cli-delegate/roadmap_set_step.js +173 -0
  66. package/dist/cli-delegate/stub_queue.js +47 -0
  67. package/dist/cli-delegate/stubs_due.js +38 -0
  68. package/dist/cli-delegate/ticket_bundles.js +59 -0
  69. package/dist/cli-delegate/update_roadmap_progress.js +60 -0
  70. package/dist/discovery/deprecation-report.md +1 -1
  71. package/dist/discovery/discovery-manifest.json +13 -13
  72. package/dist/discovery/discovery-manifest.json.sha256 +1 -1
  73. package/dist/discovery/discovery-manifest.summary.md +1 -1
  74. package/dist/discovery/dormancy-report.md +1 -1
  75. package/dist/discovery/orphan-report.md +1 -1
  76. package/dist/discovery/packs.json +2 -2
  77. package/dist/discovery/trust-report.md +1 -1
  78. package/dist/discovery/workspaces.json +2 -2
  79. package/dist/hooks/dispatch.js +1813 -946
  80. package/dist/install/claudeLayerCarriage.js +237 -0
  81. package/dist/install/claudePathsPlan.js +79 -0
  82. package/dist/install/hostLayerFingerprint.js +1 -1
  83. package/dist/install/install.mjs +21 -3
  84. package/dist/install/partitionEligibility.js +231 -117
  85. package/dist/install/ruleLayerPartition.js +10 -7
  86. package/dist/mcp/registry-manifest.json +2 -2
  87. package/dist/mcp/server.json +2 -2
  88. package/dist/mcp/server.mjs +10870 -7641
  89. package/dist/scripts/_lib/lean_projection_mode.js +108 -0
  90. package/dist/scripts/_lib/router_match.js +243 -0
  91. package/dist/scripts/_lib/rule_injection.js +216 -0
  92. package/dist/scripts/condense.js +52 -45
  93. package/dist/scripts/project_thin_rules.js +139 -19
  94. package/dist/server/schemas/settings.js +16 -3
  95. package/docs/MIGRATION.md +105 -1
  96. package/docs/contracts/settings-classes.md +40 -8
  97. package/docs/guidelines/agent-infra/minimal-safe-diff-mechanics.md +73 -0
  98. package/docs/guidelines/agent-infra/notes-horizon-mechanics.md +90 -0
  99. package/llms.txt +1 -1
  100. package/package.json +7 -4
  101. package/src/agent-src/scripts/_cli_entry.ts +51 -0
  102. package/src/agent-src/scripts/archive_completed_roadmaps.ts +120 -27
  103. package/src/agent-src/scripts/roadmap_gates.ts +4 -20
  104. package/src/agent-src/scripts/roadmap_set_step.ts +5 -2
  105. package/src/agent-src/scripts/stubs_due.ts +4 -18
  106. package/src/agent-src/scripts/update_roadmap_progress.ts +7 -24
  107. package/src/config/agent-settings.template.yml +47 -1
  108. package/src/config/continuity-surface.json +516 -0
  109. package/src/config/estate-count-budget.json +1 -1
  110. package/src/config/evaluator-budgets.json +4 -4
  111. package/src/config/gate-coverage.yml +29 -8
  112. package/src/config/gate-violation-baselines.json +8 -8
  113. package/src/config/gitignore-block.txt +19 -0
  114. package/src/config/hook-token-budget.json +7 -6
  115. package/src/config/host-injection-effect.json +73 -0
  116. package/src/config/packed-binary-manifest.json +33 -0
  117. package/src/config/preamble-payload-budget.json +112 -105
  118. package/src/config/publish-surface.json +4 -2
  119. package/src/config/routing-coverage-seed.json +9 -2
  120. package/src/install/claudeLayerCarriage.ts +273 -0
  121. package/src/install/claudePathsPlan.ts +76 -0
  122. package/src/install/hostLayerFingerprint.ts +1 -1
  123. package/src/install/partitionEligibility.ts +261 -127
  124. package/src/install/ruleLayerPartition.ts +19 -23
  125. package/src/scripts/_cli/cmd_session_recycle.ts +2 -25
  126. package/src/scripts/_cli/handoff_sessions.ts +14 -44
  127. package/src/scripts/_dispatch.bash +77 -27
  128. package/src/scripts/_lib/auto_dispatch.ts +2 -2
  129. package/src/scripts/_lib/catalogue_layer_parity.ts +146 -0
  130. package/src/scripts/_lib/continuity_slot.ts +295 -0
  131. package/src/scripts/_lib/continuity_surface.ts +296 -0
  132. package/src/scripts/_lib/continuity_writer.ts +253 -0
  133. package/src/scripts/_lib/corpus_manifest.ts +26 -9
  134. package/src/scripts/_lib/hook_settings.ts +86 -1
  135. package/src/scripts/_lib/injection_effect.ts +151 -0
  136. package/src/scripts/_lib/layer_overlap_notice.ts +50 -6
  137. package/src/scripts/_lib/lean_projection_mode.ts +127 -0
  138. package/src/scripts/_lib/packed_binary_predicate.ts +262 -0
  139. package/src/scripts/_lib/pin_resolver.ts +13 -0
  140. package/src/scripts/_lib/recycle_envelope_paths.ts +109 -21
  141. package/src/scripts/_lib/regression_neighbourhood.ts +159 -22
  142. package/src/scripts/_lib/retired_status.ts +74 -0
  143. package/src/scripts/_lib/session_eol.ts +44 -0
  144. package/src/scripts/_lib/session_index_trust.ts +346 -0
  145. package/src/scripts/_lib/skill_catalogue.ts +156 -21
  146. package/src/scripts/_lib/standing_bound_ratchet.ts +154 -0
  147. package/src/scripts/_lib/subagent_capsule.ts +117 -8
  148. package/src/scripts/_lib/vendored_grammar_upstream.ts +280 -0
  149. package/src/scripts/ai_council/api_transport.ts +5 -0
  150. package/src/scripts/ai_council/deliberation_metrics.ts +353 -0
  151. package/src/scripts/ai_council/prompts.ts +9 -0
  152. package/src/scripts/ai_council/synthesis_disagreement.ts +206 -0
  153. package/src/scripts/audit_mcp_tools.ts +69 -25
  154. package/src/scripts/build_mcp_catalog.ts +24 -1
  155. package/src/scripts/capture_skill_catalogue.ts +24 -11
  156. package/src/scripts/check_candidate_lines.ts +499 -0
  157. package/src/scripts/check_continuity_surface.ts +475 -0
  158. package/src/scripts/check_dependency_floors.ts +13 -6
  159. package/src/scripts/check_estate_count.ts +40 -79
  160. package/src/scripts/check_host_tree_parity.ts +291 -0
  161. package/src/scripts/check_pack_size.ts +262 -23
  162. package/src/scripts/check_preamble_payload_budget.ts +136 -7
  163. package/src/scripts/check_roadmap_trackable.ts +19 -16
  164. package/src/scripts/check_rule_layer_partition.ts +54 -34
  165. package/src/scripts/check_rule_projection_integrity.ts +102 -2
  166. package/src/scripts/check_single_delivery.ts +97 -31
  167. package/src/scripts/check_update_banner.ts +13 -0
  168. package/src/scripts/ci_settle.ts +205 -5
  169. package/src/scripts/code_graph/build.ts +217 -15
  170. package/src/scripts/code_graph/cli.ts +334 -15
  171. package/src/scripts/code_graph/detect.ts +38 -0
  172. package/src/scripts/code_graph/extract.ts +11 -0
  173. package/src/scripts/code_graph/grammar_probe.ts +43 -0
  174. package/src/scripts/code_graph/loader.ts +88 -14
  175. package/src/scripts/code_graph/query.ts +144 -11
  176. package/src/scripts/code_graph/resolution_tiers.ts +238 -0
  177. package/src/scripts/code_graph/sqlite_store.ts +262 -6
  178. package/src/scripts/code_graph/types.ts +117 -3
  179. package/src/scripts/code_graph/validate.ts +26 -1
  180. package/src/scripts/code_graph/verbs.ts +590 -0
  181. package/src/scripts/condense.ts +54 -42
  182. package/src/scripts/corpus_manifest.ts +5 -5
  183. package/src/scripts/council_cli.ts +11 -12
  184. package/src/scripts/count_intent_disagreement.ts +426 -0
  185. package/src/scripts/handoff_context_hook.ts +118 -1
  186. package/src/scripts/hook_effect_doctor.ts +82 -1
  187. package/src/scripts/hook_manifest.json +1 -1
  188. package/src/scripts/hook_manifest.yaml +87 -12
  189. package/src/scripts/hooks/block_speaking_inbox_dir.ts +182 -16
  190. package/src/scripts/hooks/chain_nudge_hook.ts +282 -0
  191. package/src/scripts/hooks/code_graph_context_hook.ts +201 -0
  192. package/src/scripts/hooks/concern_registry.ts +4 -2
  193. package/src/scripts/hooks/host_semantics.ts +1 -1
  194. package/src/scripts/hooks/orchestration_record_hook.ts +2 -2
  195. package/src/scripts/hooks/push_settle_hook.ts +3 -2
  196. package/src/scripts/hooks/rule_inject_hook.ts +108 -21
  197. package/src/scripts/hooks/session_eol_hook.ts +120 -2
  198. package/src/scripts/hooks/skill_route_hook.ts +11 -4
  199. package/src/scripts/hot_context_hook.ts +62 -7
  200. package/src/scripts/inbox_source_census.ts +322 -0
  201. package/src/scripts/install-hooks.sh +82 -0
  202. package/src/scripts/{lint_carrier_integrity.ts → lint_deferral_integrity.ts} +60 -214
  203. package/src/scripts/lint_plan_risk_register.ts +13 -17
  204. package/src/scripts/lint_roadmap_blockers.ts +164 -1
  205. package/src/scripts/mcp_server/consumer_tool_catalog.json +152 -2
  206. package/src/scripts/mcp_server/graph_tools.ts +294 -0
  207. package/src/scripts/mcp_server/path_util.ts +31 -0
  208. package/src/scripts/mcp_server/suggest_skill.ts +72 -0
  209. package/src/scripts/mcp_server/tool_catalog_source.ts +7 -1
  210. package/src/scripts/mcp_server/tools.ts +5 -69
  211. package/src/scripts/media/lib/adapter-contract.md +7 -2
  212. package/src/scripts/media/lib/fixtures/flux/asset-0001.png +0 -0
  213. package/src/scripts/media/lib/fixtures/gemini-image/asset-0001.png +0 -0
  214. package/src/scripts/media/lib/fixtures/ideogram/asset-0001.png +0 -0
  215. package/src/scripts/model_rule_injection.ts +61 -4
  216. package/src/scripts/project_thin_rules.ts +149 -16
  217. package/src/scripts/rdp_candidates_delta.ts +276 -0
  218. package/src/scripts/rdp_quality_eval.ts +217 -19
  219. package/src/scripts/release_mix_taxonomy.json +3 -2
  220. package/src/scripts/report_host_injection_effect.ts +189 -0
  221. package/src/scripts/report_layer_overlap.ts +41 -0
  222. package/src/scripts/report_skill_menu_census.ts +341 -0
  223. package/src/scripts/report_standing_payload_by_host.ts +650 -0
  224. package/src/scripts/schemas/agent-settings.schema.json +7 -1
  225. package/src/scripts/session_memory_index.ts +81 -8
  226. package/src/scripts/skill_tools/score_skill_relevance.ts +70 -6
  227. package/src/scripts/skill_tools/suggest_skill_for_task.ts +37 -3
  228. package/src/server/schemas/settings.ts +23 -4
  229. package/src/vendor/grammars/README.md +86 -0
  230. package/src/vendor/grammars/tree-sitter-javascript.wasm +0 -0
  231. package/src/vendor/grammars/tree-sitter-php.wasm +0 -0
  232. package/src/vendor/grammars/tree-sitter-typescript.wasm +0 -0
  233. package/src/scripts/_lib/carrier_status.ts +0 -118
  234. package/src/scripts/_lib/estate_carrier_cases.ts +0 -72
  235. package/src/scripts/hooks/code_graph_nudge_hook.ts +0 -191
@@ -18,10 +18,20 @@
18
18
  * every agent's head: `not settled` and `settled` are different from `could not
19
19
  * read`, and only the first two may end a wait.
20
20
  *
21
- * Exit codes: 0 = settled, every check succeeded (or was skipped) ·
21
+ * A verdict also has to be ABOUT SOMETHING, which is the second half of the
22
+ * same rule and was missing until 2026-09-08: the rollup answers about the head
23
+ * the PR *record* carries, and that head stops tracking the branch when the PR
24
+ * is merged or when the record lags a push. Both then look like a healthy
25
+ * green. {@link classifyTarget} refuses those, before the wait and again at
26
+ * verdict time. Measured cost of not having it: a `SETTLED GREEN — 40 check(s)`
27
+ * for a head with literally zero check runs.
28
+ *
29
+ * Exit codes: 0 = settled green, and the head it is about is named ·
22
30
  * 1 = settled, at least one check failed ·
23
- * 2 = did not settle within the budget, or the API could not be
24
- * read — NEVER a verdict, and the reason is printed.
31
+ * 2 = did not settle within the budget, the API could not be read,
32
+ * or the target is not a PR a verdict can be about (merged,
33
+ * closed, or head-diverged) — NEVER a verdict, and the reason
34
+ * is printed.
25
35
  *
26
36
  * Usage: ./scripts-run src/scripts/ci_settle <pr> [--timeout-min N] [--interval-sec N]
27
37
  *
@@ -57,6 +67,121 @@ export type PollState =
57
67
  | { kind: 'pending'; total: number; done: number }
58
68
  | { kind: 'settled'; failing: string[]; total: number };
59
69
 
70
+ /**
71
+ * What `git ls-remote` said about the PR branch's tip.
72
+ *
73
+ * `absent` and `unreadable` are different answers and must not collapse: a
74
+ * cross-repo fork PR legitimately has no ref under this `origin`, while a
75
+ * transport failure means nobody knows. Collapsing them either disables the
76
+ * divergence check for forks silently, or blocks every fork PR forever.
77
+ */
78
+ export type RemoteTip =
79
+ | { kind: 'found'; sha: string }
80
+ | { kind: 'absent' }
81
+ | { kind: 'unreadable'; reason: string };
82
+
83
+ /**
84
+ * Whether the PR this wait is about still decides anything.
85
+ *
86
+ * `refuse` is NOT a verdict — it exits 2 like every other non-verdict, because
87
+ * the wait produced no readable statement about the head that matters.
88
+ */
89
+ export type TargetState =
90
+ | { kind: 'open'; head: string; ref: string; divergenceChecked: boolean }
91
+ | { kind: 'unreadable'; reason: string }
92
+ | { kind: 'refuse'; reason: string };
93
+
94
+ /**
95
+ * Decide whether a green from {@link classifyPoll} may be reported at all.
96
+ *
97
+ * WHY THIS EXISTS, measured 2026-09-08 on PR #1949 and paid for twice in one
98
+ * session. `gh pr view --json statusCheckRollup` answers about the head the PR
99
+ * *record* carries, and that head stops tracking the branch in two situations
100
+ * this script previously could not tell apart from a healthy wait:
101
+ *
102
+ * 1. **The PR is already merged or closed.** A push to its branch fires no
103
+ * `pull_request` event, so no checks are created for the new commits and the
104
+ * rollup keeps returning the merge-time head's checks. The script reported
105
+ * `SETTLED GREEN — 40 check(s)` for a head that had stopped mattering, and
106
+ * the only thing that exposed it was a hand-written
107
+ * `/commits/<sha>/check-runs` query answering `0`. Reported at face value
108
+ * that green authorises a merge of content nobody checked.
109
+ * 2. **The PR record lags the branch ref.** Same shape, different cause
110
+ * (recorded on PR #710): `git ls-remote` shows the new SHA while the record
111
+ * keeps the old one, and merging then lands the PR WITHOUT the newer
112
+ * commits — silently, which is what makes it worse than a red.
113
+ *
114
+ * Both are answered by the same two facts, which is why one function decides
115
+ * them: the PR's `state`, and whether its head equals the branch tip. Pure, so
116
+ * the failure is testable without a network — feed it a merged PR and it must
117
+ * answer `refuse`, never `open`.
118
+ */
119
+ export function classifyTarget(
120
+ stdout: string,
121
+ stderr: string,
122
+ status: number | null,
123
+ tip: RemoteTip,
124
+ ): TargetState {
125
+ const combined = `${stdout}\n${stderr}`.toLowerCase();
126
+ for (const marker of _UNREADABLE_MARKERS) {
127
+ if (combined.includes(marker.toLowerCase())) {
128
+ return { kind: 'unreadable', reason: marker };
129
+ }
130
+ }
131
+ if (status !== 0) {
132
+ return { kind: 'unreadable', reason: `gh exited ${String(status)}` };
133
+ }
134
+ let state: string;
135
+ let head: string;
136
+ let ref: string;
137
+ try {
138
+ const parsed = JSON.parse(stdout) as {
139
+ state?: unknown;
140
+ headRefOid?: unknown;
141
+ headRefName?: unknown;
142
+ };
143
+ if (
144
+ typeof parsed.state !== 'string' ||
145
+ typeof parsed.headRefOid !== 'string' ||
146
+ typeof parsed.headRefName !== 'string'
147
+ ) {
148
+ return { kind: 'unreadable', reason: 'no state / headRefOid / headRefName in response' };
149
+ }
150
+ state = parsed.state.toUpperCase();
151
+ head = parsed.headRefOid;
152
+ ref = parsed.headRefName;
153
+ } catch {
154
+ return { kind: 'unreadable', reason: 'unparseable JSON' };
155
+ }
156
+ if (state !== 'OPEN') {
157
+ return {
158
+ kind: 'refuse',
159
+ reason:
160
+ `the PR is ${state}, not OPEN. Its rollup describes head ${head.slice(0, 9)} as of ` +
161
+ 'that transition; pushes after it create no checks, so a green here would be about ' +
162
+ 'a head that no longer decides anything',
163
+ };
164
+ }
165
+ if (tip.kind === 'unreadable') {
166
+ return { kind: 'unreadable', reason: `branch tip: ${tip.reason}` };
167
+ }
168
+ if (tip.kind === 'absent') {
169
+ // A fork PR's ref is not under this origin. Say the check did not apply
170
+ // rather than pretending it passed.
171
+ return { kind: 'open', head, ref, divergenceChecked: false };
172
+ }
173
+ if (tip.sha !== head) {
174
+ return {
175
+ kind: 'refuse',
176
+ reason:
177
+ `the PR record is at head ${head.slice(0, 9)} but origin/${ref} is at ` +
178
+ `${tip.sha.slice(0, 9)}. Merging in this state lands the PR WITHOUT the newer ` +
179
+ 'commits (recorded on PR #710), and any verdict here is about the wrong head',
180
+ };
181
+ }
182
+ return { kind: 'open', head, ref, divergenceChecked: true };
183
+ }
184
+
60
185
  /** Markers that mean the API did not answer — not that nothing is pending. */
61
186
  const _UNREADABLE_MARKERS = [
62
187
  'error connecting to',
@@ -126,6 +251,41 @@ function poll(pr: string): PollState {
126
251
  return classifyPoll(r.stdout ?? '', r.stderr ?? '', r.status);
127
252
  }
128
253
 
254
+ /**
255
+ * Read the branch tip from the remote, not from a local ref.
256
+ *
257
+ * A local `origin/<ref>` is only as fresh as the last fetch, and the divergence
258
+ * this guards against is created by somebody else's push — so a stale local ref
259
+ * would report agreement exactly when it matters most.
260
+ */
261
+ function readTip(ref: string): RemoteTip {
262
+ const r = spawnSync('git', ['ls-remote', 'origin', `refs/heads/${ref}`], {
263
+ encoding: 'utf-8',
264
+ timeout: 60_000,
265
+ });
266
+ if (r.status !== 0) {
267
+ const why = (r.stderr ?? '').trim().split('\n')[0] ?? `git exited ${String(r.status)}`;
268
+ return { kind: 'unreadable', reason: why };
269
+ }
270
+ const line = (r.stdout ?? '').trim();
271
+ if (line === '') return { kind: 'absent' };
272
+ const sha = line.split(/\s+/)[0] ?? '';
273
+ return sha === '' ? { kind: 'unreadable', reason: 'empty sha in ls-remote output' } : { kind: 'found', sha };
274
+ }
275
+
276
+ function readTarget(pr: string): TargetState {
277
+ const r = spawnSync('gh', ['pr', 'view', pr, '--json', 'state,headRefOid,headRefName'], {
278
+ encoding: 'utf-8',
279
+ timeout: 60_000,
280
+ });
281
+ // The ref name comes from the same read, so the tip lookup needs it first;
282
+ // classifyTarget is called twice rather than plumbing a callback through a
283
+ // pure function.
284
+ const shape = classifyTarget(r.stdout ?? '', r.stderr ?? '', r.status, { kind: 'absent' });
285
+ if (shape.kind !== 'open') return shape;
286
+ return classifyTarget(r.stdout ?? '', r.stderr ?? '', r.status, readTip(shape.ref));
287
+ }
288
+
129
289
  function sleepSync(seconds: number): void {
130
290
  // Deliberately synchronous: this is a CLI whose whole job is to block.
131
291
  spawnSync(process.execPath, ['-e', `setTimeout(()=>{}, ${String(seconds * 1000)})`], {
@@ -164,18 +324,58 @@ export function main(argv: readonly string[]): number {
164
324
  }
165
325
  const intervalSec = num('--interval-sec', 60);
166
326
 
327
+ // Up front, because waiting nine minutes to learn the PR was merged before
328
+ // the wait began is the expensive way to find out.
329
+ const before = readTarget(pr);
330
+ if (before.kind === 'refuse') {
331
+ process.stdout.write(`ci_settle: REFUSING TO WAIT — ${before.reason}. No verdict is claimed.\n`);
332
+ return 2;
333
+ }
334
+ if (before.kind === 'unreadable') {
335
+ process.stdout.write(
336
+ `ci_settle: could not read the PR's target state (${before.reason}) — NOT a verdict, waiting anyway; ` +
337
+ 'the pre-verdict re-check below still applies.\n',
338
+ );
339
+ } else if (!before.divergenceChecked) {
340
+ process.stdout.write(
341
+ `ci_settle: origin carries no refs/heads/${before.ref} (a fork PR) — the head-divergence check does not apply here.\n`,
342
+ );
343
+ }
344
+
167
345
  const deadline = Date.now() + timeoutMin * 60_000;
168
346
  let unreadableStreak = 0;
169
347
 
170
348
  for (;;) {
171
349
  const state = poll(pr);
172
350
  if (state.kind === 'settled') {
351
+ // The rollup is about the record's head. Re-read that head at
352
+ // VERDICT time, not only at start: a push or a merge during the
353
+ // wait makes the rollup describe something the caller is not about
354
+ // to act on, and this is the last moment a wrong verdict is still
355
+ // preventable.
356
+ const at = readTarget(pr);
357
+ if (at.kind === 'refuse') {
358
+ process.stdout.write(
359
+ `ci_settle: WITHHOLDING A VERDICT — ${at.reason}. The checks that settled are not about ` +
360
+ 'the head that matters now.\n',
361
+ );
362
+ return 2;
363
+ }
364
+ if (at.kind === 'unreadable') {
365
+ process.stdout.write(
366
+ `ci_settle: WITHHOLDING A VERDICT — could not confirm the target head (${at.reason}). ` +
367
+ 'A verdict needs to name the head it is about.\n',
368
+ );
369
+ return 2;
370
+ }
173
371
  if (state.failing.length === 0) {
174
- process.stdout.write(`ci_settle: SETTLED GREEN — ${String(state.total)} check(s)\n`);
372
+ process.stdout.write(
373
+ `ci_settle: SETTLED GREEN — ${String(state.total)} check(s) on ${at.head.slice(0, 9)}\n`,
374
+ );
175
375
  return 0;
176
376
  }
177
377
  process.stdout.write(
178
- `ci_settle: SETTLED RED — ${String(state.failing.length)} of ${String(state.total)} failing:\n` +
378
+ `ci_settle: SETTLED RED — ${String(state.failing.length)} of ${String(state.total)} failing on ${at.head.slice(0, 9)}:\n` +
179
379
  state.failing.map((f) => ` ${f}\n`).join(''),
180
380
  );
181
381
  return 1;
@@ -16,6 +16,17 @@ import * as path from 'node:path';
16
16
 
17
17
  import { write_atomic } from '../_lib/fs_atomic.js';
18
18
  import { extractFile, type FileExtract, type RawEdge } from './extract.js';
19
+ import {
20
+ ALIAS_VIA,
21
+ aliasRulesFrom,
22
+ EMPTY_RESOLUTION_CONFIG,
23
+ parseJsonc,
24
+ PSR4_VIA,
25
+ psr4RulesFrom,
26
+ type ResolutionConfig,
27
+ resolveAlias,
28
+ resolvePsr4,
29
+ } from './resolution_tiers.js';
19
30
  import { emitSqliteTwin } from './sqlite_store.js';
20
31
  import {
21
32
  type CodeEdge,
@@ -23,7 +34,9 @@ import {
23
34
  type EdgeConfidence,
24
35
  EXPECTED_GRAMMAR_ABI,
25
36
  EXT_LANG,
37
+ isStatedResolution,
26
38
  type Lang,
39
+ type ResolvedVia,
27
40
  SCHEMA_VERSION,
28
41
  } from './types.js';
29
42
 
@@ -145,7 +158,9 @@ function satisfies(kind: string | undefined, want: Capability): boolean {
145
158
  */
146
159
  interface FileScope {
147
160
  locals: Map<string, string[]>;
148
- imports: Map<string, { target: string; exact: boolean }>;
161
+ /** `via` records WHICH tier produced the binding, so an edge built from it
162
+ * reports the mechanism instead of assuming `import-specifier`. */
163
+ imports: Map<string, { target: string; exact: boolean; via: ResolvedVia }>;
149
164
  }
150
165
 
151
166
  const REL_EXTS = ['.ts', '.tsx', '.mts', '.cts', '.js', '.jsx', '.mjs', '.cjs'];
@@ -199,11 +214,40 @@ function push(map: Map<string, string[]>, k: string, v: string): void {
199
214
  } else map.set(k, [v]);
200
215
  }
201
216
 
217
+ /**
218
+ * Read the project's declared resolution tiers from `root` (2.3).
219
+ *
220
+ * IO lives here rather than in `buildGraph`, which stays pure so identical
221
+ * source plus identical config yields identical bytes. A missing or
222
+ * unparseable config is not an error — it degrades to no tiers, which is the
223
+ * behaviour every build had before this existed.
224
+ *
225
+ * `tsconfig.json` is read as JSONC: it is JSONC by convention and this
226
+ * repository's own carries `//` comments, so `JSON.parse` on it throws.
227
+ */
228
+ export function readResolutionConfig(root: string): ResolutionConfig {
229
+ const read = (name: string): unknown | null => {
230
+ try {
231
+ return parseJsonc(fs.readFileSync(path.join(root, name), 'utf-8'));
232
+ } catch {
233
+ return null;
234
+ }
235
+ };
236
+ return {
237
+ aliases: aliasRulesFrom(read('tsconfig.json')),
238
+ psr4: psr4RulesFrom(read('composer.json')),
239
+ };
240
+ }
241
+
202
242
  /**
203
243
  * Pure, deterministic graph build over an in-memory file set. Exposed for
204
244
  * tests (no IO). `buildFromRepo` is the IO wrapper.
205
245
  */
206
- export function buildGraph(files: readonly SourceFile[], extracts: readonly FileExtract[]): CodeGraph {
246
+ export function buildGraph(
247
+ files: readonly SourceFile[],
248
+ extracts: readonly FileExtract[],
249
+ resolution: ResolutionConfig = EMPTY_RESOLUTION_CONFIG,
250
+ ): CodeGraph {
207
251
  const nodes = extracts.flatMap((e) => e.nodes);
208
252
  const nodeIds = new Set(nodes.map((n) => n.id));
209
253
 
@@ -277,7 +321,7 @@ export function buildGraph(files: readonly SourceFile[], extracts: readonly File
277
321
  for (const ex of extracts) {
278
322
  const locals = new Map<string, string[]>();
279
323
  for (const n of ex.nodes) if (n.kind !== 'file') push(locals, n.label, n.id);
280
- const imports = new Map<string, { target: string; exact: boolean }>();
324
+ const imports = new Map<string, { target: string; exact: boolean; via: ResolvedVia }>();
281
325
  for (const r of ex.rawEdges) {
282
326
  if (r.relation !== 'imports') continue;
283
327
  const local = r.localName ?? r.targetName;
@@ -285,29 +329,58 @@ export function buildGraph(files: readonly SourceFile[], extracts: readonly File
285
329
  const spec = r.moduleSpecifier;
286
330
  if (spec === undefined) {
287
331
  // PHP `use A\B\C` — a fully-qualified NAME, not a module path.
288
- // Resolved by base name without checking the namespace, so the
289
- // binding is a lookup, not a syntactic fact: `exact: false`.
332
+ //
333
+ // TIER 1 (2.3): composer PSR-4 maps the NAMESPACE to a
334
+ // directory, so the class file is named outright. That is a
335
+ // declared mapping, hence `exact: true` — and it is the only
336
+ // rung that can tell two same-named classes in different
337
+ // namespaces apart.
338
+ const fq = r.fqName ?? r.targetName;
339
+ const psr4File = resolvePsr4(fq, resolution, fileIds);
340
+ if (psr4File !== null) {
341
+ const member = `${psr4File}#${r.targetName}`;
342
+ imports.set(local, {
343
+ target: nodeKind.has(member) ? member : psr4File,
344
+ exact: true,
345
+ via: PSR4_VIA,
346
+ });
347
+ continue;
348
+ }
349
+ // TIER 2: base name, namespace discarded. A lookup, not a
350
+ // syntactic fact: `exact: false`.
290
351
  const hit =
291
352
  sym.classByName.get(k(ex.lang, r.targetName)) ??
292
353
  (sym.byName.get(k(ex.lang, r.targetName)) ?? []).find((id) => id.includes('#') && !id.includes('::'));
293
- if (hit) imports.set(local, { target: hit, exact: false });
354
+ if (hit) imports.set(local, { target: hit, exact: false, via: 'name-lookup' });
294
355
  continue;
295
356
  }
296
- const file = resolveSpecifier(ex.file, spec, fileIds);
357
+ // TIER 0: a relative specifier — the file says where it points.
358
+ // TIER 1 (2.3): tsconfig `paths`. `@shared/x` IS `src/shared/x`
359
+ // because the project declares it, so this is a fact and not a
360
+ // guess. Without it the specifier is non-relative, `resolveSpecifier`
361
+ // returns null, and the import binds to `external:@shared/x` — a
362
+ // real module, correctly named, and the wrong one.
363
+ const relFile = resolveSpecifier(ex.file, spec, fileIds);
364
+ const aliasFile = relFile === null ? resolveAlias(spec, resolution, fileIds) : null;
365
+ const file = relFile ?? aliasFile;
297
366
  if (file === null) {
298
367
  // Bare (`node:path`, `zod`) or relative-but-out-of-root. Either
299
368
  // way the module is real and NAMED, and it is not this
300
369
  // repository's same-named symbol. `import * as path from
301
370
  // 'node:path'` ends here, which is the whole of 1.1.
302
371
  const named = r.importKind === 'named';
303
- imports.set(local, { target: named ? `external:${spec}#${r.targetName}` : `external:${spec}`, exact: true });
372
+ imports.set(local, {
373
+ target: named ? `external:${spec}#${r.targetName}` : `external:${spec}`,
374
+ exact: true,
375
+ via: 'import-specifier',
376
+ });
304
377
  continue;
305
378
  }
306
379
  // A namespace or default import binds the MODULE, never a
307
380
  // same-named export inside it.
308
381
  const member = `${file}#${r.targetName}`;
309
382
  const target = r.importKind === 'named' && nodeKind.has(member) ? member : file;
310
- imports.set(local, { target, exact: true });
383
+ imports.set(local, { target, exact: true, via: aliasFile === null ? 'import-specifier' : ALIAS_VIA });
311
384
  }
312
385
  scopes.set(ex.file, { locals, imports });
313
386
  }
@@ -327,13 +400,23 @@ export function buildGraph(files: readonly SourceFile[], extracts: readonly File
327
400
  want: Capability,
328
401
  lang: Lang,
329
402
  scopeOnly = false,
330
- ): { target: string; confidence: EdgeConfidence } | null => {
403
+ ): { target: string; confidence: EdgeConfidence; via: ResolvedVia } | null => {
331
404
  const scope = scopes.get(file) ?? emptyScope;
405
+ // The ladder's rungs ARE the mechanism taxonomy, so `via` is read off
406
+ // the branch that succeeded rather than inferred afterwards.
332
407
  for (const id of scope.locals.get(name) ?? [])
333
- if (satisfies(nodeKind.get(id), want)) return { target: id, confidence: 'EXTRACTED' };
408
+ if (satisfies(nodeKind.get(id), want))
409
+ return { target: id, confidence: 'EXTRACTED', via: 'same-file' };
334
410
  const bound = scope.imports.get(name);
335
411
  if (bound && satisfies(nodeKind.get(bound.target), want))
336
- return { target: bound.target, confidence: bound.exact ? 'EXTRACTED' : 'INFERRED' };
412
+ return {
413
+ target: bound.target,
414
+ confidence: bound.exact ? 'EXTRACTED' : 'INFERRED',
415
+ // The binding knows how it was made — a tsconfig alias and a
416
+ // PSR-4 namespace are not import specifiers and must not be
417
+ // reported as ones.
418
+ via: bound.via,
419
+ };
337
420
  if (scopeOnly) return null;
338
421
  const wide =
339
422
  want === 'constructible'
@@ -344,10 +427,19 @@ export function buildGraph(files: readonly SourceFile[], extracts: readonly File
344
427
  // Reached only by matching a name across the whole repository, with no
345
428
  // binding in this file to justify it. That is not a syntactic fact and
346
429
  // no longer claims to be one.
347
- if (wide) return { target: wide, confidence: 'INFERRED' };
430
+ if (wide) return { target: wide, confidence: 'INFERRED', via: 'name-lookup' };
348
431
  return null;
349
432
  };
350
433
 
434
+ /**
435
+ * The mechanism for an edge whose target did not resolve.
436
+ *
437
+ * A `symbol:` pseudo-target is the residue of a name lookup that found
438
+ * nothing, so `name-lookup` is what actually happened. `confidence` already
439
+ * carries whether it resolved; this field carries what was tried.
440
+ */
441
+ const UNRESOLVED_VIA: ResolvedVia = 'name-lookup';
442
+
351
443
  // Deduped the same way emitted edges are, so the count is comparable to
352
444
  // `edges.length` rather than being a raw call-site tally that reads as a
353
445
  // larger loss than the graph actually took.
@@ -372,6 +464,8 @@ export function buildGraph(files: readonly SourceFile[], extracts: readonly File
372
464
  target: hit?.target ?? `symbol:${inh.parentName}`,
373
465
  relation: 'inherits',
374
466
  confidence: hit?.confidence ?? 'EXTRACTED',
467
+ resolved_via: hit?.via ?? UNRESOLVED_VIA,
468
+ provider: 'native',
375
469
  });
376
470
  }
377
471
  for (const r of ex.rawEdges) resolveRawEdge(r, ex);
@@ -386,6 +480,11 @@ export function buildGraph(files: readonly SourceFile[], extracts: readonly File
386
480
  target: nodeIds.has(target) ? target : `symbol:${r.targetName}`,
387
481
  relation: 'member',
388
482
  confidence: 'EXTRACTED',
483
+ // A member id is DERIVED from its own source node's id, so the
484
+ // declaration is in the same file by construction — no lookup
485
+ // of any kind happens here.
486
+ resolved_via: nodeIds.has(target) ? 'same-file' : UNRESOLVED_VIA,
487
+ provider: 'native',
389
488
  });
390
489
  return;
391
490
  }
@@ -400,6 +499,8 @@ export function buildGraph(files: readonly SourceFile[], extracts: readonly File
400
499
  target: bound?.target ?? `symbol:${r.targetName}`,
401
500
  relation: 'imports',
402
501
  confidence: bound ? (bound.exact ? 'EXTRACTED' : 'INFERRED') : 'EXTRACTED',
502
+ resolved_via: bound ? bound.via : UNRESOLVED_VIA,
503
+ provider: 'native',
403
504
  });
404
505
  return;
405
506
  }
@@ -416,6 +517,8 @@ export function buildGraph(files: readonly SourceFile[], extracts: readonly File
416
517
  target: hit?.target ?? `symbol:${r.targetName}`,
417
518
  relation: 'uses',
418
519
  confidence: hit?.confidence ?? 'EXTRACTED',
520
+ resolved_via: hit?.via ?? UNRESOLVED_VIA,
521
+ provider: 'native',
419
522
  });
420
523
  return;
421
524
  }
@@ -433,13 +536,25 @@ export function buildGraph(files: readonly SourceFile[], extracts: readonly File
433
536
  // EXTRACTED is a claim about a SPECIFIC in-repo node reached by
434
537
  // name alone.
435
538
  confidence: hit?.confidence ?? 'EXTRACTED',
539
+ resolved_via: hit?.via ?? UNRESOLVED_VIA,
540
+ provider: 'native',
436
541
  });
437
542
  return;
438
543
  }
439
544
  if (r.callStyle === 'this' || r.callStyle === 'self' || r.callStyle === 'static' || r.callStyle === 'parent') {
440
545
  const hit = r.enclosingClassId ? resolveHierMethod(r.enclosingClassId, r.targetName) : null;
441
546
  if (hit) {
442
- emit({ source: r.sourceId, target: hit, relation: 'calls', confidence: 'INFERRED' });
547
+ emit({
548
+ source: r.sourceId,
549
+ target: hit,
550
+ relation: 'calls',
551
+ confidence: 'INFERRED',
552
+ // Walked the class hierarchy, which is same-file only when
553
+ // the declaring class is; the honest mechanism for a
554
+ // hierarchy walk is the repo-wide method table.
555
+ resolved_via: 'name-lookup',
556
+ provider: 'native',
557
+ });
443
558
  } else {
444
559
  // enclosing class extends an out-of-repo base → honest AMBIGUOUS
445
560
  const cands = methodCandidates(r.targetName, ex.lang);
@@ -448,6 +563,8 @@ export function buildGraph(files: readonly SourceFile[], extracts: readonly File
448
563
  target: ambiguousTarget(cands, r.targetName),
449
564
  relation: 'calls',
450
565
  confidence: 'AMBIGUOUS',
566
+ resolved_via: 'dynamic',
567
+ provider: 'native',
451
568
  // The enclosing class extends a base outside this
452
569
  // repository, so the method is not findable HERE — a
453
570
  // different cause from a call whose receiver is unknown,
@@ -486,6 +603,8 @@ export function buildGraph(files: readonly SourceFile[], extracts: readonly File
486
603
  target: ambiguousTarget(cands, r.targetName),
487
604
  relation: 'calls',
488
605
  confidence: 'AMBIGUOUS',
606
+ resolved_via: 'dynamic',
607
+ provider: 'native',
489
608
  // Receiver type unknown: `$obj->m()` or an unresolved `C::m()`.
490
609
  // Resolving this needs type inference the extractor does not do,
491
610
  // which is exactly what 1.2 has to decide about rather than assume.
@@ -494,6 +613,52 @@ export function buildGraph(files: readonly SourceFile[], extracts: readonly File
494
613
  });
495
614
  }
496
615
 
616
+ // DERIVED `tests` edges (3.2).
617
+ //
618
+ // A test file that imports an in-repo symbol from a NON-test file is
619
+ // asserted to test it. Derived here rather than extracted because the
620
+ // predicate is a property of the two PATHS, which no grammar can see, and
621
+ // because `buildGraph` already holds every `imports` edge plus the node
622
+ // table needed to reject cross-test and out-of-repo targets.
623
+ //
624
+ // Three exclusions, each of which would otherwise manufacture a false
625
+ // `tests` edge:
626
+ // · target outside the repository (`external:*` / `symbol:*`) — there is
627
+ // no subject to test;
628
+ // · target in another TEST file — a shared test helper is not a subject;
629
+ // · target is the subject FILE node rather than a symbol in it, which is
630
+ // kept: a test that imports a module tests that module, and the file
631
+ // node is the only handle a caller has for a module with no exported
632
+ // symbol the extractor named;
633
+ // · THE SOURCE IMPORT IS ITSELF A GUESS. Added after an independent review
634
+ // demonstrated the laundering: a PHP `use Two\Mailer` with no
635
+ // `composer.json` binds by BASE NAME, so the `imports` edge is
636
+ // `name-lookup` and can point at `One\Mailer` — and a `tests` edge
637
+ // derived from it, stamped `test-import`, made that wrong target
638
+ // TRUSTWORTHY to every consumer of the accepted-edge filter. Measured
639
+ // consequences on a two-namespace fixture: `tests-for One\Mailer`
640
+ // named a test that does not test it, `tests-for Two\Mailer` reported
641
+ // none, `untested` inverted the pair, and `dead` called the tested class
642
+ // dead. A derived edge may never be more trustworthy than the edge it
643
+ // was derived from.
644
+ const nodeSourceFile = new Map(nodes.map((n) => [n.id, n.source_file]));
645
+ for (const e of [...edges]) {
646
+ if (e.relation !== 'imports') continue;
647
+ if (!isStatedResolution(e.resolved_via)) continue;
648
+ const from = nodeSourceFile.get(e.source);
649
+ if (from === undefined || !isTestFile(from)) continue;
650
+ const to = nodeSourceFile.get(e.target);
651
+ if (to === undefined || isTestFile(to)) continue;
652
+ emit({
653
+ source: e.source,
654
+ target: e.target,
655
+ relation: 'tests',
656
+ confidence: 'INFERRED',
657
+ resolved_via: 'test-import',
658
+ provider: 'native',
659
+ });
660
+ }
661
+
497
662
  // deterministic ordering
498
663
  nodes.sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
499
664
  edges.sort((a, b) => {
@@ -559,6 +724,38 @@ function ambiguousTarget(candidates: readonly string[], targetName: string): str
559
724
  return `symbol:${targetName}`;
560
725
  }
561
726
 
727
+ /**
728
+ * Is `relPath` a test file?
729
+ *
730
+ * A deterministic PATH predicate — no content read, no heuristic on the file's
731
+ * body. It is the input to the derived `tests` relation (3.2), so it has to be
732
+ * conservative in one direction only: a subject wrongly classified as a test
733
+ * silently loses its `tests` edges, while a test wrongly classified as a
734
+ * subject would make a test file look like a tested surface. Both are wrong;
735
+ * the second is worse, because `untested` would then report a real subject as
736
+ * covered.
737
+ *
738
+ * The four conventions are the ones this launch set actually uses, and each is
739
+ * anchored rather than substring-matched so a directory called
740
+ * `src/latest/thing.ts` is not a test:
741
+ *
742
+ * · a `tests/` or `test/` path segment (this repository's own layout);
743
+ * · a `__tests__/` segment (the JS convention);
744
+ * · a `.test.` / `.spec.` filename infix (Vitest / Jest / Pest-JS);
745
+ * · a `Test.php` / `test.php` filename suffix, or a `_test.php` infix
746
+ * (PHPUnit's `*Test.php` and Go-style `_test` both appear in PHP trees).
747
+ */
748
+ export function isTestFile(relPath: string): boolean {
749
+ const p = relPath.split(path.sep).join('/');
750
+ const segments = p.split('/');
751
+ if (segments.some((seg) => seg === 'tests' || seg === 'test' || seg === '__tests__')) return true;
752
+ const base = segments[segments.length - 1] ?? '';
753
+ if (/\.(test|spec)\./i.test(base)) return true;
754
+ if (/(^|[._-])test\.php$/i.test(base)) return true;
755
+ if (/Test\.php$/.test(base)) return true;
756
+ return false;
757
+ }
758
+
562
759
  export function serializeGraph(g: CodeGraph): string {
563
760
  const key_order: (keyof CodeGraph)[] = [
564
761
  'schema_version',
@@ -583,6 +780,11 @@ export function serializeGraph(g: CodeGraph): string {
583
780
  target: e.target,
584
781
  relation: e.relation,
585
782
  confidence: e.confidence,
783
+ // Unconditional, unlike the two optional fields below: these are
784
+ // required on every edge, so emitting them conditionally would make
785
+ // an absent field mean "not set" instead of being impossible.
786
+ resolved_via: e.resolved_via,
787
+ provider: e.provider,
586
788
  };
587
789
  // Ordered before `candidates` deliberately: the reason is the coarser
588
790
  // axis and a reader scanning the serialized graph sees WHY before HOW
@@ -684,7 +886,7 @@ export async function buildFromRepo(
684
886
  nextSidecar.files[f.path] = { hash, extract };
685
887
  }
686
888
 
687
- const graph = buildGraph(files, extracts);
889
+ const graph = buildGraph(files, extracts, readResolutionConfig(root));
688
890
  const json = serializeGraph(graph);
689
891
  if (cachePath) {
690
892
  write_atomic(cachePath, json);