@event4u/agent-config 14.7.0 → 14.9.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 (230) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/CHANGELOG.md +175 -390
  3. package/README.md +1 -1
  4. package/agents/templates/.ai-council.yml.example +34 -4
  5. package/dist/agent-src/commands/evals/git-pr-merge.json +13 -0
  6. package/dist/agent-src/commands/evals/roadmap.json +17 -1
  7. package/dist/agent-src/commands/pr/create.md +14 -2
  8. package/dist/agent-src/commands/pr/merge.md +383 -0
  9. package/dist/agent-src/commands/review/changes.md +95 -14
  10. package/dist/agent-src/commands/roadmap/process-full.md +157 -3
  11. package/dist/agent-src/commands/roadmap.md +1 -1
  12. package/dist/agent-src/contexts/execution/auto-dispatch-classification.md +46 -36
  13. package/dist/agent-src/contexts/execution/autonomy-mechanics.md +37 -2
  14. package/dist/agent-src/contexts/execution/host-capability-manifest.md +77 -0
  15. package/dist/agent-src/contexts/execution/roadmap-execution-contract.md +100 -2
  16. package/dist/agent-src/contexts/execution/roadmap-process-loop.md +154 -2
  17. package/dist/agent-src/contexts/execution/subagent-response-contract.md +80 -0
  18. package/dist/agent-src/rules/artifact-drafting-protocol.md +5 -1
  19. package/dist/agent-src/rules/decision-revisit-gate.md +65 -33
  20. package/dist/agent-src/rules/recurring-criticism.md +1 -1
  21. package/dist/agent-src/rules/roadmap-progress-sync.md +19 -0
  22. package/dist/agent-src/rules/source-confidentiality.md +1 -1
  23. package/dist/agent-src/rules/untrusted-input-defense.md +9 -3
  24. package/dist/agent-src/scripts/archive_completed_roadmaps.ts +7 -4
  25. package/dist/agent-src/scripts/dashboard_mode.ts +212 -0
  26. package/dist/agent-src/scripts/update_roadmap_progress.ts +47 -48
  27. package/dist/agent-src/skills/adr-create/SKILL.md +124 -5
  28. package/dist/agent-src/skills/code-review/evals/evals.json +87 -46
  29. package/dist/agent-src/skills/decision-record/SKILL.md +61 -3
  30. package/dist/agent-src/skills/decision-review/SKILL.md +65 -0
  31. package/dist/agent-src/skills/humanizer/SKILL.md +39 -0
  32. package/dist/agent-src/skills/humanizer/evals/strip_fixtures.json +57 -0
  33. package/dist/agent-src/skills/humanizer/references/fixtures.md +54 -0
  34. package/dist/agent-src/skills/judge-spec-compliance/SKILL.md +154 -0
  35. package/dist/agent-src/skills/judge-synthesis/SKILL.md +45 -0
  36. package/dist/agent-src/skills/merge-conflicts/SKILL.md +26 -0
  37. package/dist/agent-src/skills/roadmap-writing/SKILL.md +18 -18
  38. package/dist/agent-src/skills/security/SKILL.md +8 -0
  39. package/dist/agent-src/skills/security-audit/SKILL.md +12 -1
  40. package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
  41. package/dist/agent-src/templates/roadmaps.md +28 -2
  42. package/dist/cli/registry.js +3 -0
  43. package/dist/cli/registry.js.map +1 -1
  44. package/dist/cli-delegate/{chunk-FBKL6JWT.js → chunk-77GR3VD4.js} +68 -43
  45. package/dist/cli-delegate/{chunk-JZ4NR2TW.js → chunk-7PRE6ZV2.js} +1 -1
  46. package/dist/cli-delegate/{chunk-UEZ7D35A.js → chunk-CYH5SP6M.js} +4 -23
  47. package/dist/cli-delegate/{chunk-Q26HT4QT.js → chunk-E46KPN6K.js} +251 -150
  48. package/dist/cli-delegate/{chunk-74E3TKWV.js → chunk-H4VIVD4C.js} +5 -5
  49. package/dist/cli-delegate/chunk-I3EIN2YG.js +36 -0
  50. package/dist/cli-delegate/{chunk-4VR4ORQ6.js → chunk-LPPWO5CE.js} +4 -3
  51. package/dist/cli-delegate/{chunk-7F3VYSM2.js → chunk-PLSG6UL5.js} +14 -14
  52. package/dist/cli-delegate/cmd_adr_effective.js +1162 -0
  53. package/dist/cli-delegate/cmd_conformance.js +101 -25
  54. package/dist/cli-delegate/cmd_converge.js +3 -3
  55. package/dist/cli-delegate/cmd_doctor.js +7 -7
  56. package/dist/cli-delegate/cmd_export.js +6 -6
  57. package/dist/cli-delegate/cmd_handoff.js +7 -6
  58. package/dist/cli-delegate/cmd_memory_get.js +2 -1
  59. package/dist/cli-delegate/cmd_migrate.js +2 -2
  60. package/dist/cli-delegate/cmd_preflight.js +5 -5
  61. package/dist/cli-delegate/cmd_prune.js +6 -6
  62. package/dist/cli-delegate/cmd_route_audit.js +6 -5
  63. package/dist/cli-delegate/cmd_route_explain.js +3 -3
  64. package/dist/cli-delegate/cmd_session_recycle.js +3 -3
  65. package/dist/cli-delegate/cmd_settings_get.js +4 -4
  66. package/dist/cli-delegate/cmd_settings_migrate.js +6 -6
  67. package/dist/cli-delegate/cmd_settings_set.js +1 -1
  68. package/dist/cli-delegate/cmd_sync.js +6 -6
  69. package/dist/cli-delegate/cmd_uninstall.js +6 -6
  70. package/dist/cli-delegate/cmd_update.js +3 -3
  71. package/dist/cli-delegate/cmd_upgrade.js +3 -3
  72. package/dist/cli-delegate/cmd_validate.js +6 -6
  73. package/dist/cli-delegate/cmd_versions.js +3 -3
  74. package/dist/discovery/deprecation-report.md +1 -1
  75. package/dist/discovery/discovery-manifest.json +83 -30
  76. package/dist/discovery/discovery-manifest.json.sha256 +1 -1
  77. package/dist/discovery/discovery-manifest.summary.md +4 -4
  78. package/dist/discovery/dormancy-report.md +1 -1
  79. package/dist/discovery/orphan-report.md +1 -1
  80. package/dist/discovery/packs.json +11 -9
  81. package/dist/discovery/trust-report.md +2 -2
  82. package/dist/discovery/workspaces.json +9 -7
  83. package/dist/hooks/dispatch.js +1295 -812
  84. package/dist/install/globalRuleLayers.js +169 -0
  85. package/dist/install/globalRuleLayers.js.map +1 -0
  86. package/dist/install/partitionEligibility.js +156 -0
  87. package/dist/install/partitionEligibility.js.map +1 -1
  88. package/dist/install/ruleLayerPartition.js +157 -0
  89. package/dist/install/ruleLayerPartition.js.map +1 -0
  90. package/dist/mcp/registry-manifest.json +3 -3
  91. package/dist/mcp/server.json +2 -2
  92. package/dist/mcp/server.mjs +1358 -805
  93. package/dist/scripts/_lib/claude_plugin.js +162 -0
  94. package/dist/scripts/_lib/claude_plugin.js.map +1 -0
  95. package/dist/scripts/_lib/environment_detector.js +397 -0
  96. package/dist/scripts/_lib/environment_detector.js.map +1 -0
  97. package/dist/scripts/_lib/prune_empty_dirs.js +85 -0
  98. package/dist/scripts/_lib/prune_empty_dirs.js.map +1 -0
  99. package/dist/scripts/_lib/spawn_env.js +129 -0
  100. package/dist/scripts/_lib/spawn_env.js.map +1 -0
  101. package/dist/scripts/ai_council/modes.js +199 -0
  102. package/dist/scripts/ai_council/modes.js.map +1 -0
  103. package/dist/scripts/ai_council/transport_resolver.js +418 -0
  104. package/dist/scripts/ai_council/transport_resolver.js.map +1 -0
  105. package/dist/scripts/condense.js +52 -89
  106. package/dist/scripts/condense.js.map +1 -1
  107. package/dist/server/routes/wizard.js +3 -2
  108. package/dist/server/routes/wizard.js.map +1 -1
  109. package/dist/ui/assets/index-Cxgy63kz.js +26 -0
  110. package/dist/ui/assets/index-Cxgy63kz.js.map +1 -0
  111. package/dist/ui/index.html +1 -1
  112. package/docs/guidelines/agent-infra/artifact-drafting-protocol-mechanics.md +45 -1
  113. package/docs/guidelines/agent-infra/false-green.md +47 -1
  114. package/docs/guidelines/agent-infra/traceability-field-mechanics.md +50 -0
  115. package/docs/guidelines/agent-infra/untrusted-input-spotlighting.md +17 -0
  116. package/package.json +7 -3
  117. package/src/agent-src/scripts/archive_completed_roadmaps.ts +7 -4
  118. package/src/agent-src/scripts/dashboard_mode.ts +212 -0
  119. package/src/agent-src/scripts/update_roadmap_progress.ts +47 -48
  120. package/src/config/ci-local-parity.yml +21 -0
  121. package/src/config/estate-count-budget.json +13 -471
  122. package/src/config/evaluator-budgets.json +4 -4
  123. package/src/config/gate-coverage.yml +458 -12
  124. package/src/config/gate-violation-baselines.json +18 -8
  125. package/src/config/gitignore-block.txt +15 -2
  126. package/src/config/hook-latency-budget.json +5 -0
  127. package/src/config/publish-surface.json +60 -0
  128. package/src/config/requirements-trace-budget.json +32 -0
  129. package/src/config/rule-stub-ceilings.json +25 -4
  130. package/src/install/globalRuleLayers.ts +197 -0
  131. package/src/install/partitionEligibility.ts +177 -0
  132. package/src/install/ruleLayerPartition.ts +212 -0
  133. package/src/scripts/_cli/cmd_adr_effective.ts +484 -0
  134. package/src/scripts/_dispatch.bash +18 -0
  135. package/src/scripts/_lib/adr_frontmatter.test.ts +365 -0
  136. package/src/scripts/_lib/adr_frontmatter.ts +459 -0
  137. package/src/scripts/_lib/base_tree.ts +175 -0
  138. package/src/scripts/_lib/billing_grant.ts +165 -0
  139. package/src/scripts/_lib/billing_grant_cli.ts +123 -0
  140. package/src/scripts/_lib/council_fallback_posture.ts +53 -6
  141. package/src/scripts/_lib/council_fallback_wiring.ts +61 -1
  142. package/src/scripts/_lib/envelope_grounding.ts +11 -1
  143. package/src/scripts/_lib/host_capability.ts +59 -5
  144. package/src/scripts/_lib/layer_overlap_notice.ts +11 -2
  145. package/src/scripts/_lib/prune_empty_dirs.ts +80 -0
  146. package/src/scripts/_lib/quota_parked.ts +133 -0
  147. package/src/scripts/_lib/retrieval_sanitize.ts +62 -0
  148. package/src/scripts/_lib/review_telemetry.ts +196 -0
  149. package/src/scripts/_lib/subagent_response.ts +92 -0
  150. package/src/scripts/adr/evidence_census.ts +899 -0
  151. package/src/scripts/adr/regenerate_index.ts +25 -38
  152. package/src/scripts/adr_cite_check.ts +540 -39
  153. package/src/scripts/ai_council/clients.ts +12 -1
  154. package/src/scripts/ai_council/config.ts +72 -115
  155. package/src/scripts/ai_council/fallback_config.ts +19 -7
  156. package/src/scripts/ai_council/mid_flight_fallback.ts +88 -7
  157. package/src/scripts/ai_council/orchestrator.ts +2 -2
  158. package/src/scripts/ai_council/py_format.ts +124 -0
  159. package/src/scripts/ai_council/transport_resolver.ts +65 -2
  160. package/src/scripts/audit_adr_coverage.ts +134 -21
  161. package/src/scripts/bench_hook_latency.ts +204 -0
  162. package/src/scripts/build_archive_index.ts +34 -0
  163. package/src/scripts/check_adr_frontmatter.ts +574 -39
  164. package/src/scripts/check_council_pin_staleness.ts +300 -0
  165. package/src/scripts/check_estate_count.ts +420 -293
  166. package/src/scripts/check_gate_coverage.ts +91 -0
  167. package/src/scripts/check_hook_bundle_content.ts +219 -0
  168. package/src/scripts/check_new_adr_evidence.ts +529 -0
  169. package/src/scripts/check_no_stub_inventory_table.ts +292 -0
  170. package/src/scripts/check_pack_size.ts +234 -1
  171. package/src/scripts/check_publish_surface.ts +297 -0
  172. package/src/scripts/check_references.ts +18 -0
  173. package/src/scripts/check_requirements_trace.ts +378 -0
  174. package/src/scripts/check_rule_layer_partition.ts +531 -0
  175. package/src/scripts/check_secret_leak.ts +166 -12
  176. package/src/scripts/check_single_delivery.ts +137 -4
  177. package/src/scripts/check_standing_rule_delivery.ts +8 -2
  178. package/src/scripts/code_graph/cli.ts +44 -5
  179. package/src/scripts/code_graph/extract.ts +87 -0
  180. package/src/scripts/condense.ts +61 -91
  181. package/src/scripts/council_cli.ts +12 -16
  182. package/src/scripts/detect_ai_tells.ts +87 -0
  183. package/src/scripts/dispatch_r2_reviewer.ts +42 -0
  184. package/src/scripts/external_sources_denylist.json +4 -1
  185. package/src/scripts/generate_capabilities_index.ts +41 -0
  186. package/src/scripts/git_authorization_hook.ts +54 -4
  187. package/src/scripts/hooks/block_unauthorized_git.ts +41 -3
  188. package/src/scripts/hooks/dispatch_hook.ts +154 -187
  189. package/src/scripts/hooks/dispatch_issues.ts +38 -19
  190. package/src/scripts/hooks/fallback_yaml.ts +66 -0
  191. package/src/scripts/hooks/host_semantics.ts +21 -0
  192. package/src/scripts/hooks/injection_budget.ts +13 -3
  193. package/src/scripts/hooks/py_json_dumps.ts +103 -0
  194. package/src/scripts/hooks/run_continuation_hook.ts +3 -1
  195. package/src/scripts/hooks/state_io.ts +88 -1
  196. package/src/scripts/hooks/stdin_failure_policy.ts +96 -0
  197. package/src/scripts/hooks/subagent_ledger_hook.ts +76 -11
  198. package/src/scripts/injection_scan_hook.ts +257 -15
  199. package/src/scripts/lint_adr_sweep_routing.ts +365 -0
  200. package/src/scripts/lint_hook_manifest.ts +49 -0
  201. package/src/scripts/lint_provenance_vocabulary.ts +472 -4
  202. package/src/scripts/lint_workflow_security.ts +53 -10
  203. package/src/scripts/lint_workflow_security_allowlist.json +2 -18
  204. package/src/scripts/report_envelope_rate.ts +166 -0
  205. package/src/scripts/review_axis_report.ts +218 -0
  206. package/src/scripts/routing_doctor.ts +5 -1
  207. package/src/scripts/run_supervise.ts +46 -1
  208. package/src/scripts/schemas/evals.schema.json +5 -0
  209. package/src/scripts/sync_pr_branch.ts +170 -20
  210. package/dist/cli/commands/commands.candidates.test.js +0 -257
  211. package/dist/cli/commands/commands.candidates.test.js.map +0 -1
  212. package/dist/cli/commands/profiles.test.js +0 -88
  213. package/dist/cli/commands/profiles.test.js.map +0 -1
  214. package/dist/cli/commands/recordTriggerEval.test.js +0 -113
  215. package/dist/cli/commands/recordTriggerEval.test.js.map +0 -1
  216. package/dist/cli/configRoot.test.js +0 -97
  217. package/dist/cli/configRoot.test.js.map +0 -1
  218. package/dist/cli/mcp/content.test.js +0 -54
  219. package/dist/cli/mcp/content.test.js.map +0 -1
  220. package/dist/cli/mcp/dispatch.test.js +0 -108
  221. package/dist/cli/mcp/dispatch.test.js.map +0 -1
  222. package/dist/server/writeRoot.test.js +0 -91
  223. package/dist/server/writeRoot.test.js.map +0 -1
  224. package/dist/shared/capabilities.test.js +0 -39
  225. package/dist/shared/capabilities.test.js.map +0 -1
  226. package/dist/ui/assets/index-C1pBRm1f.js +0 -26
  227. package/dist/ui/assets/index-C1pBRm1f.js.map +0 -1
  228. package/dist/cli-delegate/{chunk-PPQO4NGR.js → chunk-6RJA2F5A.js} +3 -3
  229. package/dist/cli-delegate/{chunk-6EOCNQAM.js → chunk-E4XIZDIB.js} +0 -0
  230. package/dist/cli-delegate/{chunk-GJ5BVHXM.js → chunk-PMEKGDED.js} +3 -3
@@ -37,6 +37,18 @@ import { fileURLToPath, pathToFileURL } from 'node:url';
37
37
 
38
38
  import { GateLedger } from './_lib/gate_ledger.js';
39
39
  import { assertScanned, DeadScopeError } from './_lib/scan_scope.js';
40
+ import {
41
+ type AdrFrontmatter,
42
+ AUTHORITY_BASES,
43
+ AGENTIC_MODES,
44
+ DISCOVERY_STATES,
45
+ EVIDENCE_STRENGTHS,
46
+ PROVENANCE_KINDS,
47
+ evidenceOf,
48
+ provenanceOf,
49
+ readAdrFrontmatter,
50
+ readAdrFrontmatterScalars,
51
+ } from './_lib/adr_frontmatter.js';
40
52
 
41
53
  const _HERE = path.dirname(fileURLToPath(import.meta.url));
42
54
  const REPO_ROOT = path.resolve(_HERE, '..', '..');
@@ -150,36 +162,53 @@ export interface AdrFinding {
150
162
  file: string;
151
163
  level: 'error' | 'warn';
152
164
  message: string;
165
+ /**
166
+ * Optional finding class, set only by the supersession-link check.
167
+ *
168
+ * It exists because `main()`'s summary line labels its warn count
169
+ * "grandfathered without a revisit condition" — accurate while that was the
170
+ * only warn class in the file, and a lie the moment a second one lands. A
171
+ * new warn class must not silently inflate a count that names a different
172
+ * cause, and a warn nobody prints is a warn nobody acts on, so the tagged
173
+ * ones are counted and printed separately. Absent = the pre-existing
174
+ * grandfathering class; nothing else reads this field.
175
+ */
176
+ kind?: 'supersession_link' | 'supersession_qualifier';
153
177
  }
154
178
 
179
+ /**
180
+ * Read the leading `---` block.
181
+ *
182
+ * Delegates to the shared reader (`_lib/adr_frontmatter.ts`) rather than
183
+ * carrying a fourth copy: the nested `provenance` / `evidence` axes cannot be
184
+ * read by the scalar-only parser this function used to be, and a corpus
185
+ * equivalence test holds the shared reader to this one's exact scalar output
186
+ * for every ADR in the tree. Re-exported because the test suite imports it.
187
+ */
155
188
  export function parse_frontmatter(text: string): Record<string, string> | null {
156
- if (!text.startsWith('---\n')) return null;
157
- const end = text.indexOf('\n---\n', 4);
158
- if (end === -1) return null;
159
- const out: Record<string, string> = {};
160
- let key: string | null = null;
161
- for (const raw of text.slice(4, end).split('\n')) {
162
- const line = raw.replace(/\s+$/, '');
163
- if (!line || line.trimStart().startsWith('#')) continue;
164
- // Continuation of a folded/indented value.
165
- if (/^\s/.test(line) && key) {
166
- out[key] = `${out[key]} ${line.trim()}`.trim();
167
- continue;
168
- }
169
- const idx = line.indexOf(':');
170
- if (idx === -1) continue;
171
- key = line.slice(0, idx).trim();
172
- out[key] = line
173
- .slice(idx + 1)
174
- .trim()
175
- .replace(/^["'](.*)["']$/, '$1');
176
- }
177
- return out;
189
+ return readAdrFrontmatterScalars(text);
178
190
  }
179
191
 
192
+ /**
193
+ * The transitional migration value, legal on an existing accepted record only.
194
+ *
195
+ * It has to be named here as well as in `check_descriptive_axes`, and the reason
196
+ * is a contract-versus-validator contradiction this change nearly shipped:
197
+ * `adr-layout` blesses `review_trigger: unclassified` for an existing record,
198
+ * while `trigger_is_meaningful` rejects anything under 20 characters and
199
+ * `unclassified` is 12. The contradiction was latent — no flat ADR carries the
200
+ * value yet and this gate scans `docs/decisions/` only — and would have fired
201
+ * on the first backfill onto a post-`REVIEW_TRIGGER_SINCE` record, i.e. exactly
202
+ * when someone followed the contract. A contract and its validator disagreeing
203
+ * is worse than either rule alone, because the tree then teaches the opposite
204
+ * of what the document says.
205
+ */
206
+ const TRANSITIONAL_TRIGGER = 'unclassified';
207
+
180
208
  /** A trigger must name a condition. A bare cadence is the failure mode. */
181
209
  export function trigger_is_meaningful(value: string): boolean {
182
210
  const v = value.trim();
211
+ if (v.toLowerCase() === TRANSITIONAL_TRIGGER) return true;
183
212
  if (v.length < 20) return false;
184
213
  // "annually" / "every 6 months" / a bare date is a calendar, not a condition.
185
214
  if (/^(annually|yearly|quarterly|monthly|every\s+\d+\s+\w+|\d{4}-\d{2}-\d{2})\.?$/i.test(v)) {
@@ -229,15 +258,268 @@ export function check_one(rel: string, text: string): AdrFinding[] {
229
258
  }
230
259
  check_reopen_authority(rel, fm, findings);
231
260
  check_amendment_shape(rel, fm, findings);
261
+ const parsed = readAdrFrontmatter(text);
262
+ if (parsed !== null) check_descriptive_axes(rel, parsed, findings);
263
+ return findings;
264
+ }
265
+
266
+ /**
267
+ * The permanence vocabulary, checked on the `decision` slug and on the value of
268
+ * any frontmatter key.
269
+ *
270
+ * ADR-208 is why this is a frontmatter check and not only a prose one: its
271
+ * `decision:` slug is literally `dist-agent-src-keep-forever`, so the
272
+ * permanence claim is in the metadata a tool reads, not only in the body a
273
+ * human reads.
274
+ */
275
+ const PERMANENCE_RE = /\b(forever|permanently|permanent|never revisit|never reconsider|settled forever)\b/i;
276
+
277
+ /** `terminal`, `none` and their kin are permanence wearing a field name. */
278
+ const INVALID_TRIGGER_VALUES = new Set(['terminal', 'none', 'n/a', 'na', '-', '—', 'never']);
279
+
280
+ /**
281
+ * Validate `provenance`, `evidence`, `authority_basis` and the trigger's
282
+ * transitional vocabulary.
283
+ *
284
+ * **This function validates the SHAPE of an axis that is present. It does not
285
+ * require the axes at all**, and an earlier version of this docstring said "a
286
+ * NEW record must carry the axes", which nothing here enforces. Completion
287
+ * review disproved it directly: a fresh record with no `provenance`, no
288
+ * `evidence` and no `## Evidence` section passed every gate in this tree.
289
+ *
290
+ * The gap is structural rather than an omission, and it is why it was not closed
291
+ * HERE. Requiring the axes needs a notion of NEW, and a single-file linter has
292
+ * none — it sees a `date:` the author typed, not whether the record is new to the
293
+ * repository. `date:` is the proxy used for the `review_trigger` staging below,
294
+ * and it is a weak one: a backdated record slips through it.
295
+ *
296
+ * **The gap is now closed, in `check_new_adr_evidence.ts`** — the two-ref diff
297
+ * this paragraph used to call "buildable and not built" (review finding 4). It
298
+ * enumerates records ADDED against a base ref and requires all three of
299
+ * `provenance.kind`, `evidence.strength` and an `## Evidence` section of any
300
+ * added record whose status is `accepted`. Nothing moved out of this file: the
301
+ * split is one axis, one owner — that gate decides WHETHER a new record must
302
+ * disclose, this function decides whether what it wrote is well-formed, and the
303
+ * pre-existing corpus stays out of scope on both sides.
304
+ *
305
+ * The `review_trigger` staging IS enforced, on that same date proxy. 88 of the
306
+ * 147 accepted records carry no trigger, so a same-day hard requirement would
307
+ * have made the tree invalid on the day this landed and forced the schema and
308
+ * the backfill into one unreviewable change. What is rejected at every stage is
309
+ * a value asserting permanence — `terminal` is not a migration state, it is the
310
+ * thing the staging exists to stop becoming permanent.
311
+ *
312
+ * Everything here is shape validation. Nothing in this function grants an
313
+ * agent authority over anything, and `authority_basis` is checked precisely so
314
+ * it cannot be used to route around the owner.
315
+ */
316
+ export function check_descriptive_axes(
317
+ rel: string,
318
+ fm: AdrFrontmatter,
319
+ findings: AdrFinding[],
320
+ ): AdrFinding[] {
321
+ const status = fm.scalars['status'] ?? '';
322
+ const historical = status === 'superseded' || status === 'rejected' || status === 'deprecated';
323
+
324
+ // --- review_trigger vocabulary: invalid values are invalid at every stage.
325
+ const trigger = (fm.scalars['review_trigger'] ?? '').trim();
326
+ if (trigger !== '' && !historical) {
327
+ // The staged half. `unclassified` is the migration value for a record
328
+ // that already existed; a record authored now has no migration to be
329
+ // in, and letting it through would turn a transitional state into the
330
+ // permanent default — which is the failure the staging exists to avoid.
331
+ // "Existing" is read from the same `REVIEW_TRIGGER_SINCE` date this
332
+ // file already uses for grandfathering, so there is one notion of
333
+ // old-versus-new rather than two that can drift apart.
334
+ if (trigger.toLowerCase() === TRANSITIONAL_TRIGGER) {
335
+ const recordDate = fm.scalars['date'] ?? '';
336
+ const preexisting = recordDate !== '' && recordDate < REVIEW_TRIGGER_SINCE;
337
+ if (!preexisting) {
338
+ findings.push({
339
+ file: rel,
340
+ level: 'error',
341
+ message: `\`review_trigger: unclassified\` is the migration value for a record that already existed (dated before ${REVIEW_TRIGGER_SINCE}). This record is dated ${recordDate || 'undated'} — name the condition that would reopen it.`,
342
+ });
343
+ }
344
+ }
345
+ if (INVALID_TRIGGER_VALUES.has(trigger.toLowerCase())) {
346
+ findings.push({
347
+ file: rel,
348
+ level: 'error',
349
+ message: `\`review_trigger: ${trigger}\` is permanence under a field name. Every accepted decision has a conceivable reopening condition — write a narrow one, or \`unclassified\` while the migration runs.`,
350
+ });
351
+ } else if (trigger.toLowerCase() !== 'unclassified' && PERMANENCE_RE.test(trigger)) {
352
+ findings.push({
353
+ file: rel,
354
+ level: 'error',
355
+ message: `\`review_trigger\` asserts permanence ("${trigger.slice(0, 60)}…"). A trigger names the condition under which the decision stops holding.`,
356
+ });
357
+ }
358
+ }
359
+
360
+ // --- provenance
361
+ const provenance = provenanceOf(fm);
362
+ // Key-presence is read from BOTH maps, not from `scalars` alone. The shared
363
+ // reader routes an inline list (`provenance: [human]`) into `nested`, so a
364
+ // scalars-only guard left the whole malformed-list class silently accepted:
365
+ // `provenanceOf` returns null via its `Array.isArray` branch, no branch
366
+ // fired, and every downstream consumer then read the axis as ABSENT. Found
367
+ // in completion review with a direct probe — `provenance: [human]` +
368
+ // `evidence: [E9]` on an otherwise valid accepted record produced zero
369
+ // findings. The list shape is a plausible slip precisely because
370
+ // `protected_dimensions: [purpose]` in the same block is a list.
371
+ if (axisPresent(fm, 'provenance') && provenance === null) {
372
+ findings.push({
373
+ file: rel,
374
+ level: 'error',
375
+ message: '`provenance` must be a map with a `kind:` key — a scalar or a list is not a map',
376
+ });
377
+ }
378
+ if (provenance !== null) {
379
+ if (provenance.kind === null) {
380
+ findings.push({ file: rel, level: 'error', message: '`provenance` is present but carries no `kind`' });
381
+ } else if (!(PROVENANCE_KINDS as readonly string[]).includes(provenance.kind)) {
382
+ findings.push({
383
+ file: rel,
384
+ level: 'error',
385
+ message: `provenance.kind \`${provenance.kind}\` is not one of: ${PROVENANCE_KINDS.join(', ')}. A council is \`agentic\` with \`agentic_mode: council\` — it is not its own kind.`,
386
+ });
387
+ }
388
+ if (
389
+ provenance.agenticMode !== null &&
390
+ !(AGENTIC_MODES as readonly string[]).includes(provenance.agenticMode)
391
+ ) {
392
+ findings.push({
393
+ file: rel,
394
+ level: 'error',
395
+ message: `provenance.agentic_mode \`${provenance.agenticMode}\` is not one of: ${AGENTIC_MODES.join(', ')}`,
396
+ });
397
+ }
398
+ }
399
+
400
+ // --- evidence
401
+ const evidence = evidenceOf(fm);
402
+ if (axisPresent(fm, 'evidence') && evidence === null) {
403
+ findings.push({
404
+ file: rel,
405
+ level: 'error',
406
+ message: '`evidence` must be a map with a `strength:` key — a scalar or a list is not a map',
407
+ });
408
+ }
409
+ if (evidence !== null) {
410
+ if (evidence.strength === null) {
411
+ findings.push({ file: rel, level: 'error', message: '`evidence` is present but carries no `strength`' });
412
+ } else if (!(EVIDENCE_STRENGTHS as readonly string[]).includes(evidence.strength)) {
413
+ findings.push({
414
+ file: rel,
415
+ level: 'error',
416
+ message: `evidence.strength \`${evidence.strength}\` is not one of: ${EVIDENCE_STRENGTHS.join(', ')}`,
417
+ });
418
+ }
419
+ // `discovery` is required on E0 and only on E0. A bare E0 cannot
420
+ // distinguish "no evidence exists" from "nobody looked", and the
421
+ // second reading is the cheap way to manufacture a reopenable lock.
422
+ if (evidence.strength === 'E0' && evidence.discovery === null) {
423
+ findings.push({
424
+ file: rel,
425
+ level: 'error',
426
+ message: '`evidence.strength: E0` requires `discovery: complete | incomplete` — an unsearched absence is not an established one',
427
+ });
428
+ }
429
+ if (
430
+ evidence.discovery !== null &&
431
+ !(DISCOVERY_STATES as readonly string[]).includes(evidence.discovery)
432
+ ) {
433
+ findings.push({
434
+ file: rel,
435
+ level: 'error',
436
+ message: `evidence.discovery \`${evidence.discovery}\` is not one of: ${DISCOVERY_STATES.join(', ')}`,
437
+ });
438
+ }
439
+ // A grade above E1 asserts a source. Saying so without naming one is
440
+ // the evidence-theater failure the contract names.
441
+ if (
442
+ evidence.strength !== null &&
443
+ ['E2', 'E3', 'E4'].includes(evidence.strength) &&
444
+ evidence.basis.length === 0
445
+ ) {
446
+ findings.push({
447
+ file: rel,
448
+ level: 'error',
449
+ message: `evidence.strength \`${evidence.strength}\` cites no \`basis\`. A grade above E1 asserts a source; name it or grade it lower.`,
450
+ });
451
+ }
452
+ }
453
+
454
+ // --- authority_basis
455
+ const basis = fm.scalars['authority_basis'];
456
+ if (basis !== undefined && !(AUTHORITY_BASES as readonly string[]).includes(basis)) {
457
+ findings.push({
458
+ file: rel,
459
+ level: 'error',
460
+ message: `authority_basis \`${basis}\` is not one of: ${AUTHORITY_BASES.join(', ')}`,
461
+ });
462
+ }
463
+ // `owner_intent` is the honest form for a purpose decision, and it pairs
464
+ // with E0 by design — the authority comes from owning the purpose, not
465
+ // from pretending the preference is empirical.
466
+ if (basis === 'owner_intent' && evidence !== null && evidence.strength !== null) {
467
+ if (['E2', 'E3', 'E4'].includes(evidence.strength) && evidence.basis.length === 0) {
468
+ findings.push({
469
+ file: rel,
470
+ level: 'error',
471
+ message: 'an `owner_intent` record claims an empirical grade with no basis — record it as `E0` + `owner_intent` instead of dressing intent as measurement',
472
+ });
473
+ }
474
+ }
475
+
232
476
  return findings;
233
477
  }
234
478
 
235
479
  /** `ADR-035`, `035`, `35` → `35`. Returns null when nothing resolves. */
480
+ /**
481
+ * Is a descriptive axis PRESENT in the frontmatter, in any shape?
482
+ *
483
+ * The typed accessors (`provenanceOf` / `evidenceOf`) return null for three
484
+ * different situations — absent, scalar, list — and only the first is legal.
485
+ * Distinguishing them needs the raw key, and the raw key can live in either
486
+ * map: the reader keeps a scalar in `scalars` and BOTH a map and an inline
487
+ * list in `nested`. Asking one map is how the malformed-list class escaped.
488
+ */
489
+ export function axisPresent(fm: AdrFrontmatter, key: 'provenance' | 'evidence'): boolean {
490
+ return fm.scalars[key] !== undefined || fm.nested[key] !== undefined;
491
+ }
492
+
236
493
  export function adr_number(raw: string): string | null {
237
494
  const m = /(\d{1,4})/.exec(raw.trim());
238
495
  return m?.[1] === undefined ? null : String(Number(m[1]));
239
496
  }
240
497
 
498
+ /**
499
+ * Index a corpus by ADR number for the reciprocal-link checks — FLAT records
500
+ * only.
501
+ *
502
+ * A bare `ADR-NNN` reference denotes a flat record: that is the only surface
503
+ * whose numbers are globally unique. Per-area numbers are unique only within
504
+ * their area (`docs/adrs/<area>/0001`), so admitting them here would make one
505
+ * Map key mean several files — six per-area records carry `adr: 0001` today.
506
+ * Cross-surface links are therefore reported as unresolvable rather than
507
+ * resolved to an arbitrary sibling; a per-area record still gets every
508
+ * single-file check, and a real cross-surface reference needs a qualified
509
+ * reference form that `parse_adr_refs` does not yet have.
510
+ */
511
+ function indexByNumber(
512
+ files: { rel: string; fm: Record<string, string>; perArea?: boolean }[],
513
+ ): Map<string, { rel: string; fm: Record<string, string> }> {
514
+ const byNumber = new Map<string, { rel: string; fm: Record<string, string> }>();
515
+ for (const f of files) {
516
+ if (f.perArea === true) continue;
517
+ const n = f.fm['adr'] === undefined ? null : adr_number(f.fm['adr']);
518
+ if (n !== null) byNumber.set(n, f);
519
+ }
520
+ return byNumber;
521
+ }
522
+
241
523
  /**
242
524
  * Validate `amends:` / `amended_by:` shape. The reciprocal half is checked in
243
525
  * `check_amendment_links` (it needs the whole corpus); this one only rejects a
@@ -266,20 +548,20 @@ export function check_amendment_shape(
266
548
 
267
549
  /**
268
550
  * Corpus-level check: every `amends:` has its reciprocal `amended_by:` and vice
269
- * versa. This is the half the `supersedes:` / `superseded_by:` pair never had —
270
- * and its absence is exactly how ADR-035 kept asserting a rejection ADR-232 had
271
- * reopened. A one-sided link is invisible from the side that is stale, which is
272
- * the side a reader lands on first.
551
+ * versa. Its absence for the `supersedes:` / `superseded_by:` pair is exactly
552
+ * how ADR-035 kept asserting a rejection ADR-232 had reopened. A one-sided link
553
+ * is invisible from the side that is stale, which is the side a reader lands on
554
+ * first.
555
+ *
556
+ * That pair now has the same half, in `check_supersession_links` below — this
557
+ * docstring used to say it "never had" one, which was true when it was written
558
+ * and is the gap it documented rather than closed.
273
559
  */
274
560
  export function check_amendment_links(
275
- files: { rel: string; fm: Record<string, string> }[],
561
+ files: { rel: string; fm: Record<string, string>; perArea?: boolean }[],
276
562
  ): AdrFinding[] {
277
563
  const findings: AdrFinding[] = [];
278
- const byNumber = new Map<string, { rel: string; fm: Record<string, string> }>();
279
- for (const f of files) {
280
- const n = f.fm['adr'] === undefined ? null : adr_number(f.fm['adr']);
281
- if (n !== null) byNumber.set(n, f);
282
- }
564
+ const byNumber = indexByNumber(files);
283
565
 
284
566
  const reciprocal = { amends: 'amended_by', amended_by: 'amends' } as const;
285
567
  for (const f of files) {
@@ -322,6 +604,176 @@ export function check_amendment_links(
322
604
  return findings;
323
605
  }
324
606
 
607
+ /** The none-tokens a link field uses to say "no link". `—` is the corpus form. */
608
+ const LINK_PLACEHOLDERS = new Set(['—', '–', '-', 'none', 'n/a']);
609
+
610
+ /** One parsed member of a `supersedes:` / `superseded_by:` list. */
611
+ export interface AdrRef {
612
+ /** The segment as written, trimmed — what an error message should quote. */
613
+ raw: string;
614
+ /** The resolved ADR number, or null when the segment names none. */
615
+ number: string | null;
616
+ /** Free text found inside `(…)`, or null. A partial-supersession marker. */
617
+ qualifier: string | null;
618
+ }
619
+
620
+ /**
621
+ * Parse an ADR link field into its members. List-aware and qualifier-aware,
622
+ * because both shapes are already in the corpus and a first-number parser reads
623
+ * them wrong in opposite directions.
624
+ *
625
+ * ADR-206 carries sixteen targets in one `supersedes:` field, so anything that
626
+ * takes the first number and stops reports the other fifteen back-links as
627
+ * one-sided — 15 of the 19 findings a naive parser produces on this corpus are
628
+ * that artifact, not a defect in the tree. ADR-124 carries
629
+ * `ADR-088 (engine-adoption interpretation only), ADR-094 (…)`, so the split
630
+ * has to respect parentheses (a qualifier may itself contain a comma) and the
631
+ * number has to be read with the qualifier removed rather than from the
632
+ * concatenated text — `ADR-030 (Decision 2 — …)` is one reference with a note,
633
+ * not two.
634
+ *
635
+ * `check_amendment_shape` / `check_amendment_links` keep their naive
636
+ * `split(',')`: no `amends:` value in the corpus carries a comma or a list, so
637
+ * rewriting them here would be an unrequested behaviour change to code under
638
+ * test. The shape is identical if that ever stops being true.
639
+ */
640
+ export function parse_adr_refs(raw: string | undefined): AdrRef[] {
641
+ if (raw === undefined) return [];
642
+ const whole = raw.trim();
643
+ if (whole === '' || LINK_PLACEHOLDERS.has(whole.toLowerCase())) return [];
644
+
645
+ // Split on top-level commas only — a comma inside `(…)` belongs to the note.
646
+ const segments: string[] = [];
647
+ let depth = 0;
648
+ let cur = '';
649
+ for (const ch of whole) {
650
+ if (ch === '(') depth += 1;
651
+ else if (ch === ')') depth = Math.max(0, depth - 1);
652
+ else if (ch === ',' && depth === 0) {
653
+ segments.push(cur);
654
+ cur = '';
655
+ continue;
656
+ }
657
+ cur += ch;
658
+ }
659
+ segments.push(cur);
660
+
661
+ const refs: AdrRef[] = [];
662
+ for (const segment of segments) {
663
+ const seg = segment.trim();
664
+ if (seg === '' || LINK_PLACEHOLDERS.has(seg.toLowerCase())) continue;
665
+ const qm = /\(([^)]*)\)/.exec(seg);
666
+ // Read the number from the reference only. A qualifier can contain
667
+ // digits ("Decision 2"), so stripping first is what keeps the match
668
+ // order-independent instead of relying on the note trailing the ref.
669
+ const bare = seg.replace(/\([^)]*\)/g, ' ');
670
+ refs.push({
671
+ raw: seg,
672
+ number: adr_number(bare),
673
+ qualifier: qm?.[1] === undefined ? null : qm[1].trim(),
674
+ });
675
+ }
676
+ return refs;
677
+ }
678
+
679
+ /**
680
+ * Corpus-level check: every `supersedes:` has its reciprocal `superseded_by:`
681
+ * and vice versa — the half this pair never had, which is what
682
+ * `check_amendment_links` was built for `amends:` / `amended_by:` and this one
683
+ * closes for the pair whose absence produced the ADR-035 case in the first
684
+ * place. A one-sided link is invisible from the stale side, and the stale side
685
+ * is the one a reader lands on: ADR-030 reads as live while ADR-209 has
686
+ * superseded part of it.
687
+ *
688
+ * Levels are split by whether a human has to decide anything.
689
+ *
690
+ * A dangling or unparseable reference is an **error**: it cannot be a content
691
+ * decision, only a typo, and it points at nothing a reader can follow. Zero
692
+ * occurrences today, so it reds nothing on landing and reds the right thing
693
+ * later.
694
+ *
695
+ * A one-sided link is a **warn**, deliberately, and this is the choice worth
696
+ * stating. There are 4 in the tree, and each one is a content decision rather
697
+ * than a typo: someone has to choose the direction. ADR-209 supersedes ADR-030
698
+ * only in its "Decision 2" carve-out, so marking ADR-030 wholly
699
+ * `superseded_by` would assert more than happened, while leaving it blank
700
+ * asserts less — the fix is a judgement about the record, not a field to fill
701
+ * in mechanically. Erroring would red the build on landing and force those four
702
+ * judgements into an unrelated change; warning surfaces them so the next change
703
+ * can decide each one and ratchet the count. The repo's baseline-ratchet
704
+ * mechanism is the usual home for exactly this, and belongs here once someone
705
+ * owns those four decisions.
706
+ *
707
+ * The free-text qualifier is its own warn class. `supersedes: ADR-088
708
+ * (engine-adoption interpretation only)` encodes a PARTIAL supersession in
709
+ * prose inside a field that nothing parses as partial: every consumer of the
710
+ * frontmatter reads it as total. That is a different problem from a broken
711
+ * link — the link is correct and reciprocal — so it is reported separately
712
+ * rather than folded into the same count.
713
+ */
714
+ export function check_supersession_links(
715
+ files: { rel: string; fm: Record<string, string>; perArea?: boolean }[],
716
+ ): AdrFinding[] {
717
+ const findings: AdrFinding[] = [];
718
+ const byNumber = indexByNumber(files);
719
+
720
+ const reciprocal = { supersedes: 'superseded_by', superseded_by: 'supersedes' } as const;
721
+ for (const f of files) {
722
+ const self = f.fm['adr'] === undefined ? null : adr_number(f.fm['adr']);
723
+ for (const key of ['supersedes', 'superseded_by'] as const) {
724
+ for (const ref of parse_adr_refs(f.fm[key])) {
725
+ if (ref.qualifier !== null) {
726
+ findings.push({
727
+ file: f.rel,
728
+ level: 'warn',
729
+ kind: 'supersession_qualifier',
730
+ message:
731
+ `\`${key}: ${ref.raw}\` records a PARTIAL supersession as free text. ` +
732
+ `Every reader of this field parses it as total — the scope lives only ` +
733
+ `in the prose.`,
734
+ });
735
+ }
736
+ if (ref.number === null) {
737
+ findings.push({
738
+ file: f.rel,
739
+ level: 'error',
740
+ kind: 'supersession_link',
741
+ message: `\`${key}\` value \`${ref.raw}\` names no ADR number`,
742
+ });
743
+ continue;
744
+ }
745
+ const other = byNumber.get(ref.number);
746
+ if (other === undefined) {
747
+ findings.push({
748
+ file: f.rel,
749
+ level: 'error',
750
+ kind: 'supersession_link',
751
+ message: `\`${key}: ${ref.raw}\` points at an ADR that does not exist`,
752
+ });
753
+ continue;
754
+ }
755
+ const names_self =
756
+ self !== null &&
757
+ parse_adr_refs(other.fm[reciprocal[key]])
758
+ .map((r) => r.number)
759
+ .includes(self);
760
+ if (!names_self) {
761
+ findings.push({
762
+ file: f.rel,
763
+ level: 'warn',
764
+ kind: 'supersession_link',
765
+ message:
766
+ `\`${key}: ${ref.raw}\` is one-sided — ${other.rel} carries no ` +
767
+ `reciprocal \`${reciprocal[key]}\`. A one-sided supersession link is ` +
768
+ `invisible from the stale side, which is the side a reader lands on.`,
769
+ });
770
+ }
771
+ }
772
+ }
773
+ }
774
+ return findings;
775
+ }
776
+
325
777
  /**
326
778
  * Check every ADR in `dir`, optionally recording per-file completeness.
327
779
  *
@@ -334,16 +786,70 @@ export function check_amendment_links(
334
786
  * A `warn`-level finding resolves as `complete`: warnings are allowed by this
335
787
  * gate's exit-code contract, so `fail` is reserved for what actually reds it.
336
788
  */
789
+ /** A per-area record under `docs/adrs/<area>/NNNN-*.md`, excluding READMEs. */
790
+ export function listPerAreaRecords(root: string = REPO_ROOT): { abs: string; rel: string }[] {
791
+ const base = path.join(root, 'docs', 'adrs');
792
+ if (!fs.existsSync(base)) return [];
793
+ const out: { abs: string; rel: string }[] = [];
794
+ for (const area of fs.readdirSync(base).sort()) {
795
+ const dir = path.join(base, area);
796
+ if (!fs.statSync(dir).isDirectory()) continue;
797
+ for (const name of fs.readdirSync(dir).sort()) {
798
+ if (!/^\d{4}-.*\.md$/.test(name)) continue;
799
+ const abs = path.join(dir, name);
800
+ out.push({ abs, rel: path.relative(root, abs) });
801
+ }
802
+ }
803
+ return out;
804
+ }
805
+
337
806
  export function check(dir: string = ADR_DIR, ledger?: GateLedger): AdrFinding[] {
338
807
  const findings: AdrFinding[] = [];
339
808
  if (!fs.existsSync(dir)) return findings;
340
809
 
810
+ // Flat records, plus the per-area records under `docs/adrs/<area>/`.
811
+ //
812
+ // The per-area half was MISSING and that was this gate's largest hole, found
813
+ // in completion review: three sibling tools in the same change read both
814
+ // roots (`adr_cite_check.ADR_DIRS`, `audit_adr_coverage`, `evidence_census`)
815
+ // and this one read only the flat tree — so the seven per-area records that
816
+ // had just been given frontmatter were validated by nothing. The consequence
817
+ // was already in the tree rather than hypothetical:
818
+ // `docs/adrs/telegraph/0002` carried `review_trigger: unclassified` on a
819
+ // post-cutoff record, which this file's own rule classifies as an error.
820
+ //
821
+ // A gate that does not read a surface cannot be cited as covering it, and
822
+ // step 1.5's stated rationale — "check_adr_frontmatter needs
823
+ // machine-readable fields" — was unrealised until this call site changed.
341
824
  const candidates = fs.readdirSync(dir).filter((n) => n.endsWith('.md')).sort();
342
- ledger?.plan(candidates);
343
-
344
- // Collected for the corpus-level reciprocal-amendment check below, which
345
- // cannot be decided from one file.
346
- const corpus: { rel: string; fm: Record<string, string> }[] = [];
825
+ // The per-area root is derived from `dir` so the whole check is hermetic.
826
+ // It used to call `listPerAreaRecords()` with no argument, which always read
827
+ // the REAL `REPO_ROOT/docs/adrs` — so a call with a temp dir silently mixed
828
+ // the live corpus into `findings`, into `corpus` (both reciprocal-link
829
+ // checks) and into the `assertScanned` denominator. That last one is the
830
+ // damaging half: with the live corpus always contributing, an emptied or
831
+ // moved `docs/decisions` could never trip `DeadScopeError`, which is the one
832
+ // thing that assertion exists for. Found in completion review; the `root`
833
+ // parameter existed and had no caller.
834
+ const perArea = listPerAreaRecords(path.resolve(dir, '..', '..'));
835
+ ledger?.plan([...candidates, ...perArea.map((r) => r.rel)]);
836
+
837
+ // Collected for the two corpus-level reciprocal-link checks below
838
+ // (amendment, supersession), neither of which can be decided from one file.
839
+ //
840
+ // `perArea: true` keeps per-area records out of the number index those
841
+ // checks build. Without it their `adr: 0001` collapses onto flat ADR-001 —
842
+ // and, worse, onto EACH OTHER: six of the seven per-area records carry
843
+ // `adr: 0001`, so a single Map keyed on the number kept exactly one of
844
+ // seven. Completion review found the flat half with a probe (`supersedes:
845
+ // ADR-001` blamed `docs/adrs/telegraph/0001` for a missing reciprocal —
846
+ // the wrong file); the six-way per-area collision is the same defect,
847
+ // wider, and it turned up only because the fix asked how many records
848
+ // shared a key. The false-negative direction is the dangerous one: a
849
+ // per-area record's reciprocal would mask a genuinely broken flat link.
850
+ // Latent today because nothing cites ADR-001/002, and introduced by folding
851
+ // per-area records into the shared corpus in this same change.
852
+ const corpus: { rel: string; fm: Record<string, string>; perArea?: boolean }[] = [];
347
853
 
348
854
  for (const name of candidates) {
349
855
  if (!/^ADR-.*\.md$/.test(name)) {
@@ -364,7 +870,23 @@ export function check(dir: string = ADR_DIR, ledger?: GateLedger): AdrFinding[]
364
870
  ledger?.complete(name);
365
871
  }
366
872
  }
873
+ // Per-area records: same per-file checks, same corpus for the reciprocal
874
+ // link checks. A supersession crosses surfaces (adr-layout § Frontmatter),
875
+ // so keeping them out of `corpus` would make a cross-surface link look
876
+ // one-sided.
877
+ for (const rec of perArea) {
878
+ const text = fs.readFileSync(rec.abs, 'utf-8');
879
+ const parsed = parse_frontmatter(text);
880
+ if (parsed !== null) corpus.push({ rel: rec.rel, fm: parsed, perArea: true });
881
+ const own = check_one(rec.rel, text);
882
+ findings.push(...own);
883
+ const errs = own.filter((f) => f.level === 'error').length;
884
+ if (errs > 0) ledger?.fail(rec.rel, `${String(errs)} frontmatter error(s)`);
885
+ else ledger?.complete(rec.rel);
886
+ }
887
+
367
888
  findings.push(...check_amendment_links(corpus));
889
+ findings.push(...check_supersession_links(corpus));
368
890
  return findings;
369
891
  }
370
892
 
@@ -390,7 +912,7 @@ function main(argv: string[]): number {
390
912
  gate: 'check_adr_frontmatter',
391
913
  scanned: total,
392
914
  units: 'ADR file(s)',
393
- roots: ['docs/decisions'],
915
+ roots: ['docs/decisions', 'docs/adrs'],
394
916
  });
395
917
  } catch (exc) {
396
918
  if (exc instanceof DeadScopeError) {
@@ -409,13 +931,26 @@ function main(argv: string[]): number {
409
931
 
410
932
  const errors = findings.filter((f) => f.level === 'error');
411
933
  const warns = findings.filter((f) => f.level === 'warn');
934
+ // The untagged warns are the grandfathering class the summary label names;
935
+ // there are ~114 of them, so they stay a count and are not printed. The
936
+ // link classes are few and each needs a decision, so they are printed by
937
+ // name — folding them into the grandfathered count would both mislabel them
938
+ // and bury them.
939
+ const grandfathered = warns.filter((f) => f.kind === undefined);
940
+ const linkWarns = warns.filter((f) => f.kind === 'supersession_link');
941
+ const partialWarns = warns.filter((f) => f.kind === 'supersession_qualifier');
412
942
 
413
943
  process.stdout.write(
414
944
  `ADR frontmatter: ${total} ADR(s) · ${errors.length} error(s) · ` +
415
- `${warns.length} grandfathered without a revisit condition\n`,
945
+ `${grandfathered.length} grandfathered without a revisit condition · ` +
946
+ `${linkWarns.length} one-sided supersession link(s) · ` +
947
+ `${partialWarns.length} free-text partial supersession(s)\n`,
416
948
  );
417
949
  ledger.report();
418
950
  for (const e of errors) process.stderr.write(` ❌ ${e.file}: ${e.message}\n`);
951
+ for (const w of [...linkWarns, ...partialWarns]) {
952
+ process.stdout.write(` ⚠️ ${w.file}: ${w.message}\n`);
953
+ }
419
954
 
420
955
  if (errors.length > 0) {
421
956
  process.stderr.write(