@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
@@ -0,0 +1,899 @@
1
+ #!/usr/bin/env tsx
2
+ /**
3
+ * ADR evidence census — a PROPOSAL-ONLY pre-grader for `provenance` + `evidence`.
4
+ *
5
+ * It reads every ADR record, greps for evidence markers, and emits a *proposed*
6
+ * `provenance.kind` and `evidence.strength` per record with the matched
7
+ * `file:line` for each marker. That is the whole output. It writes no ADR
8
+ * frontmatter, and it is not a gate.
9
+ *
10
+ * ## Why proposal-only, and why that constraint is the point
11
+ *
12
+ * `adr-layout § Provenance and evidence` closes with "a census may **propose** a
13
+ * value; it never writes one", and the `No bulk classification` clause exists
14
+ * because an agent grading its own homework unreviewed is the failure mode the
15
+ * corpus already paid for once: 44 engine-shaped REJECT records accumulated
16
+ * under a council-carried interpretation, and only a measurement disposed of
17
+ * the feature (`engine-reclassification-2026-07.md`;
18
+ * `claims:code-graph-retrieval-null`, recall 0.365 vs disciplined grep 0.797).
19
+ * A tool that both assigns a grade and lands it in the tree reproduces that
20
+ * failure with better metadata. So the output is one review artifact plus
21
+ * stdout, and a human moves any of it into a record.
22
+ *
23
+ * ## Two absolute rules, taken straight from the contract
24
+ *
25
+ * 1. **A council marker alone never raises the grade above E0.** Council
26
+ * consensus is not evidence — it is correlated agreement. Implemented
27
+ * structurally rather than as a special case: the grade is computed from the
28
+ * NON-council marker set only, so no amount of council signal can move it.
29
+ * 2. **Defaults are conservative.** `provenance: unknown`, `strength: E0`,
30
+ * `discovery: incomplete`.
31
+ *
32
+ * ## `discovery` is ALWAYS `incomplete`, by construction
33
+ *
34
+ * The contract permits `complete` only "until a defined evidence search has run
35
+ * and found nothing". This script runs a marker grep over the record's own text.
36
+ * That is not a defined exhaustive search of the tree, the claims ledger, the
37
+ * benchmark corpus, or the external sources a record might have cited
38
+ * elsewhere — so the honest value is `incomplete` for every record, at every
39
+ * grade, and this script has no code path that emits `complete`. If a future
40
+ * version wants `complete`, it must first state in code what exhaustive search
41
+ * justified it.
42
+ *
43
+ * ## `E4` is never proposed
44
+ *
45
+ * E4 is an external constraint — protocol compatibility with real consumers, a
46
+ * legal obligation, a demonstrated security invariant. None of those are
47
+ * decidable from marker presence: whether a consumer actually exists is a fact
48
+ * about the world, not about the text. The ceiling here is E3.
49
+ *
50
+ * ## The grades are heuristic — biased LOW, EXCEPT for two classes biased HIGH
51
+ *
52
+ * A marker is a proxy for a citation, not the citation. For most classes the
53
+ * bias direction was chosen deliberately downward: nothing acts on these numbers
54
+ * (no authority is derived from a grade — `adr-layout`, and the roadmap's
55
+ * Architecture table), so an under-graded proposal costs a reviewer one upgrade,
56
+ * while an over-graded one would put unearned "this is measured" text into the
57
+ * tree. `measurement` needs a figure or a dated verb; `repeated` needs something
58
+ * measured to compare; `prereg` needs a benchmark corpus beside it; `owner` and
59
+ * `council` raise nothing at all.
60
+ *
61
+ * **Two classes are biased HIGH by construction, and the sentence above is false
62
+ * for them.** `claim` and `external_standard` short-circuit to `E3` — the
63
+ * second-highest grade in the scale, and the highest this script proposes at
64
+ * all — on a bare textual match, ahead of every other rule and with no
65
+ * companion marker required. Both are the FIRST and THIRD branches of
66
+ * `proposeStrength`, so they pre-empt the measurement
67
+ * rules rather than competing with them. `EXTERNAL_STANDARD_RE` in particular
68
+ * matches a plain word-bounded `GDPR`, so naming a regulation as the SUBJECT of
69
+ * a decision reads identically to citing it as the evidence FOR one.
70
+ *
71
+ * Worked example, checkable against the corpus: `ADR-107` line 35 writes
72
+ * "EU DPA / GDPR Art. 28" to scope which legal domain its eval fixtures cover.
73
+ * The census proposes `E3 — cites a named external standard or vendor guidance`;
74
+ * the tranche reviewer reading the same line adjudicated `E2`. The grading rules
75
+ * are deliberately UNCHANGED — this paragraph exists so the artifact's own
76
+ * description of its bias is not contradicted by two of its own rules, and so a
77
+ * reviewer knows to check an `E3` sourced from `claim` or `external_standard`
78
+ * DOWNWARD rather than upward. Every proposal carries its matched lines so a
79
+ * reviewer checks the claim rather than the grade.
80
+ *
81
+ * Usage:
82
+ * ./scripts-run src/scripts/adr/evidence_census # write the artifact
83
+ * ./scripts-run src/scripts/adr/evidence_census --json # machine output
84
+ * ./scripts-run src/scripts/adr/evidence_census --check # scan-completeness only
85
+ * ./scripts-run src/scripts/adr/evidence_census --out <path>
86
+ *
87
+ * Exit codes: 0 ok · 1 the scan could not complete (an unreadable record, or an
88
+ * empty corpus) · 2 usage error. Never non-zero on a grade — a report is not a
89
+ * gate.
90
+ */
91
+
92
+ import * as fs from 'node:fs';
93
+ import * as path from 'node:path';
94
+ import process from 'node:process';
95
+ import { fileURLToPath, pathToFileURL } from 'node:url';
96
+
97
+ import { GateLedger } from '../_lib/gate_ledger.js';
98
+ import { assertScanned, DeadScopeError } from '../_lib/scan_scope.js';
99
+ import {
100
+ type AdrFrontmatter,
101
+ evidenceOf,
102
+ provenanceOf,
103
+ readAdrFrontmatter,
104
+ } from '../_lib/adr_frontmatter.js';
105
+
106
+ const _HERE = path.dirname(fileURLToPath(import.meta.url));
107
+ export const REPO_ROOT = path.resolve(_HERE, '..', '..', '..');
108
+ const FLAT_DIR = path.join('docs', 'decisions');
109
+ const AREA_DIR = path.join('docs', 'adrs');
110
+
111
+ export const DEFAULT_OUT = path.join(
112
+ 'agents',
113
+ 'evidence',
114
+ 'analysis',
115
+ 'adr-evidence-census-2026-08.md',
116
+ );
117
+
118
+ // ---------------------------------------------------------------------------
119
+ // Marker taxonomy
120
+ // ---------------------------------------------------------------------------
121
+
122
+ /**
123
+ * The marker classes. `council` is listed with the others and then excluded
124
+ * from grading — keeping it in the taxonomy is what makes "this record's only
125
+ * support is agreement" a *reportable* state rather than an absence.
126
+ */
127
+ export type MarkerClass =
128
+ | 'claim'
129
+ | 'benchmark'
130
+ | 'prereg'
131
+ | 'measurement'
132
+ | 'repeated'
133
+ | 'external_standard'
134
+ | 'external_citation'
135
+ | 'council'
136
+ | 'owner';
137
+
138
+ /** Council markers carry provenance signal and never contribute to the grade. */
139
+ const COUNCIL_CLASSES: ReadonlySet<MarkerClass> = new Set<MarkerClass>(['council']);
140
+
141
+ export interface Marker {
142
+ cls: MarkerClass;
143
+ /** 1-based line number in the record. */
144
+ line: number;
145
+ /** The matched text, trimmed — evidence for the proposal. */
146
+ excerpt: string;
147
+ }
148
+
149
+ /** A `docs/CLAIMS.md` reference or a `claim:`/`claims:` id. */
150
+ const CLAIM_RE = /docs\/CLAIMS\.md(?:#[a-z0-9-]+)?|\bclaims?:\s?[a-z][a-z0-9-]{3,}/i;
151
+
152
+ /** A benchmark corpus or report path. */
153
+ const BENCHMARK_RE = /docs\/benchmark\.md|\binternal\/(?:bench|reports)\/[\w./-]*|\bPREREG[\w.-]*/;
154
+
155
+ /** Pre-registration language — the discipline, not the artifact. */
156
+ const PREREG_RE = /\bpre-?regist(?:er|ered|ration)\b|\bpre-?registered\b/i;
157
+
158
+ /**
159
+ * A named external standard. Deliberately separate from a bare URL: the
160
+ * contract's E3 row names "established community standard" and "applicable
161
+ * vendor guidance", and a link is not by itself either of those.
162
+ */
163
+ const EXTERNAL_STANDARD_RE =
164
+ /\bPSR-\d+\b|\bRFC\s?\d{3,4}\b|\bWCAG\b|\bISO[\s-]?\d{4,5}\b|\bOWASP\b|\bPHP-FIG\b|\bMADR\b|\bSemVer\b|\bDTCG\b|\bC2PA\b|\bGDPR\b|\bconforms to the [\w -]*standard\b/;
165
+
166
+ /** A bare external citation — real, weaker than a named standard. */
167
+ const EXTERNAL_CITATION_RE = /https?:\/\/\S+/;
168
+
169
+ /**
170
+ * Council / multi-model agreement, including bare model ids.
171
+ *
172
+ * The model-id patterns are family-scoped, not prefix-scoped. A bare
173
+ * `claude-[\w.-]+` matched `claude-code`, `.claude-plugin/marketplace.json`,
174
+ * `claude-desktop.md` and `claude-code-rules-dir-contract.md` — host names and
175
+ * file paths, none of which is a model that decided anything. Measured: it put
176
+ * 8 records into `agentic`/`mixed` on a filename.
177
+ */
178
+ const COUNCIL_RE =
179
+ /\bAI council\b|\bcouncil (?:pass|passes|session|verdict|ruling|seat|seats|convergen\w*)\b|\bboth seats\b|\btwo-seat\b|\bconvergen\w+\b|\b(?:anthropic|openai|google)\/[\w.-]+|\bclaude-(?:sonnet|opus|haiku|fable|instant|\d)[\w.-]*|\bgpt-[\do][\w.-]*/i;
180
+
181
+ /**
182
+ * An owner / maintainer / operator decision. Matched on a DECLARATION, never on
183
+ * the bare noun: the contract names "the word 'maintainer' appears in
184
+ * Consequences" as the false positive to avoid, so `maintainer` alone is not a
185
+ * marker and Consequences sections are excluded from this class entirely (see
186
+ * `sectionOf`).
187
+ */
188
+ const OWNER_RE =
189
+ /\b(?:owner|maintainer|maintainer's|operator|operator's)\s+(?:decision|directive|instruction|ruling|requirement|intent|call|choice)\b|\bOwner decision\b|\bthe (?:owner|maintainer|operator) (?:decided|ruled|directed|instructed|requires|required)\b|\bowner-reserved ruling\b|\bauthority_basis:\s*owner_intent\b/i;
190
+
191
+ /**
192
+ * Repeated / comparative language. On its own this proves nothing, so E2
193
+ * additionally requires a measurement, benchmark, or claim marker — which is
194
+ * what the contract's E2 row actually says ("reproducible comparison, multiple
195
+ * independent incidents, bounded A/B"): the comparison has to be OF something.
196
+ *
197
+ * `sweep` is QUALIFIED rather than bare, and the qualifier is the whole point.
198
+ * In this tree the word covers two unrelated things: a parameter sweep (`docs/
199
+ * benchmark.md § Cost-factor sweep` — multiple measured points, genuinely
200
+ * comparative) and a corpus pass (`ADR-214`'s "package-wide consistency sweep",
201
+ * `ADR-216`'s `adoption-anchor sweep`), which compares nothing. Measured: a bare
202
+ * `\bsweep\b` put 7 records into E2 on a naming coincidence.
203
+ */
204
+ const REPEATED_RE =
205
+ /\bmeasured (?:twice|three times|four times|\d+ times)\b|\bre-?measured\b|\breproducib\w+\b|\btwo independent\b|\bmultiple independent\b|\bA\/B\b|\bboth arms\b|\bacross (?:two|three|four|\d+) (?:runs|sessions|incidents|hosts|repos|repositories)\b|\bcomparative\b|\b(?:parameter|cost-factor|factor|benchmark|measurement|token|threshold|band)[\s-]sweep\b/i;
206
+
207
+ /**
208
+ * A digit that opens a QUANTITY rather than sitting inside an identifier.
209
+ *
210
+ * `\b` before `\d` matches inside one, so `Tier-3 host`, `P3.2 ADR` and
211
+ * `ADR-098` all read as figures ("3 host", "2 ADR"). Measured on the corpus:
212
+ * that shape put `ADR-001` and `ADR-071` at E1 on a tier label and a table cell.
213
+ */
214
+ const NOT_IDENTIFIER = String.raw`(?<![\w.-])`;
215
+
216
+ /**
217
+ * A percentage or a percentage-point figure.
218
+ *
219
+ * The `\b` sits INSIDE the alternation, on `pp` only, and that placement is the
220
+ * whole pattern. A trailing `\b` after `(?:%|pp)` made this regex dead code: `%`
221
+ * is a non-word character, so a boundary after it requires the NEXT character to
222
+ * be a word character — and a percentage is followed by a space, a `)`, or a
223
+ * `*` essentially always. `10 %`, `(179.1 %)` and `3.1 % of its allowance` all
224
+ * failed, which is why the corpus's percentage tables were being detected (when
225
+ * they were detected at all) through the unit and ratio patterns instead. `pp`
226
+ * keeps its boundary because `pp` is word characters and `450 ppm` is not a
227
+ * percentage-point figure.
228
+ */
229
+ const PERCENT_RE = new RegExp(`${NOT_IDENTIFIER}\\d+(?:\\.\\d+)?\\s?(?:%|pp\\b)`);
230
+
231
+ /** A ratio or multiplier: `11.7x`, `0.365 vs 0.797`, `4 of 12`. */
232
+ const RATIO_RE = new RegExp(
233
+ [
234
+ `${NOT_IDENTIFIER}\\d+(?:\\.\\d+)?\\s?x\\b`,
235
+ `${NOT_IDENTIFIER}\\d+(?:\\.\\d+)?\\s+vs\\.?\\s+\\d+(?:\\.\\d+)?\\b`,
236
+ `${NOT_IDENTIFIER}\\d+\\s+of\\s+\\d+\\b`,
237
+ ].join('|'),
238
+ );
239
+
240
+ /**
241
+ * A figure with a counted unit. The unit list is closed on purpose: an open
242
+ * `\d+\s+\w+` matched version strings (`6.0.0-D`), section numbers, and dates,
243
+ * so every one of those became a "measurement".
244
+ */
245
+ const UNIT_RE = new RegExp(
246
+ `${NOT_IDENTIFIER}(?:~|\u2248|>|<|\u2265|\u2264)?\\s?\\d[\\d,]*\\s+` +
247
+ '(?:commands?|rules?|skills?|ADRs?|records?|files?|lines?|chars?|characters?|tokens?|' +
248
+ 'sessions?|runs?|entries|hosts?|seats?|jobs?|gates?|checks?|PRs?|consumers?|projects?|' +
249
+ 'packages?|incidents?|minutes?|seconds?|hours?|days?|ms|MB|KB|GB|occurrences?|' +
250
+ 'violations?|findings?)\\b',
251
+ 'i',
252
+ );
253
+
254
+ /**
255
+ * A measurement VERB, for a line that reports an observation without printing a
256
+ * figure. Needs a dated / PR'd / sha'd anchor nearby to count.
257
+ *
258
+ * Two exclusions, both from corpus false positives. `\b` before `measur` makes
259
+ * `unmeasured` a non-match — "the redundancy assumption is unmeasured"
260
+ * (`ADR-106:38`) is the ABSENCE of a measurement, and must never grade as one.
261
+ * `(?!-)` after the verb makes `measure-then-decide` and `measure-first` a
262
+ * non-match: those name a GATE that has not run yet, which is the same absence
263
+ * wearing a different suffix (`ADR-100:15`).
264
+ */
265
+ const MEASURE_VERB_RE =
266
+ /\bmeasur(?:e|ed|es|ing|ement|ements)\b(?!-)|\bbenchmarked\b|\brecall\b|\bprecision\b|\blatency\b|\bthroughput\b|\bobserved\b|\bcounted\b/i;
267
+
268
+ /**
269
+ * The co-location anchors that date a bare measurement VERB: an ISO date, a PR
270
+ * number, or a commit sha / run id.
271
+ */
272
+ const ANCHOR_RE = /\b\d{4}-\d{2}-\d{2}\b|\bPR\s?#?\d{3,5}\b|#\d{3,5}\b|\b[0-9a-f]{9,40}\b/;
273
+
274
+ /**
275
+ * How many lines either side of a measurement verb may carry the date / PR /
276
+ * sha anchor.
277
+ *
278
+ * Two, not zero: real records put the provenance on the sentence's first line
279
+ * and the reference on its second (`ADR-229:52-53` — "**measured twice** on
280
+ * this repository (PR" then "#1277/#1280 and #1280/#1281"). A same-line
281
+ * requirement read that as undated.
282
+ */
283
+ export const ANCHOR_WINDOW = 2;
284
+
285
+ // ---------------------------------------------------------------------------
286
+ // Detection
287
+ // ---------------------------------------------------------------------------
288
+
289
+ /**
290
+ * Which `## Heading` a line sits under, lower-cased. Used only to exclude
291
+ * Consequences from the owner class — the contract's named false positive.
292
+ */
293
+ export function sectionOf(lines: readonly string[], index: number): string {
294
+ for (let i = index; i >= 0; i -= 1) {
295
+ const line = lines[i];
296
+ if (line === undefined) continue;
297
+ const m = /^#{2,3}\s+(.*)$/.exec(line);
298
+ if (m?.[1] !== undefined) return m[1].trim().toLowerCase();
299
+ }
300
+ return '';
301
+ }
302
+
303
+ function anchoredNear(lines: readonly string[], index: number): boolean {
304
+ const from = Math.max(0, index - ANCHOR_WINDOW);
305
+ const to = Math.min(lines.length - 1, index + ANCHOR_WINDOW);
306
+ for (let i = from; i <= to; i += 1) {
307
+ const line = lines[i];
308
+ if (line !== undefined && ANCHOR_RE.test(line)) return true;
309
+ }
310
+ return false;
311
+ }
312
+
313
+ function excerpt(line: string): string {
314
+ const t = line.trim();
315
+ return t.length <= 120 ? t : `${t.slice(0, 117)}\u2026`;
316
+ }
317
+
318
+ /**
319
+ * The 0-based index of the line AFTER the closing `---`, or 0 when there is no
320
+ * frontmatter.
321
+ *
322
+ * Measurement detection starts here rather than at line 0, and that boundary
323
+ * fixed a false positive class: `decision: recursive-verification-benchmark-gate`
324
+ * (`ADR-106:5`) matched the benchmark verb, and the `date:` two lines above it
325
+ * supplied the anchor, so a SLUG graded as a dated measurement. A slug names the
326
+ * decision; it reports nothing. Every other marker class still scans the whole
327
+ * file, because a `basis:` list citing `docs/CLAIMS.md` and an
328
+ * `authority_basis: owner_intent` line are both real signal and both live in
329
+ * frontmatter.
330
+ */
331
+ export function bodyStart(lines: readonly string[]): number {
332
+ if (lines[0] !== '---') return 0;
333
+ for (let i = 1; i < lines.length; i += 1) {
334
+ if (lines[i] === '---') return i + 1;
335
+ }
336
+ return 0;
337
+ }
338
+
339
+ /** Every evidence marker in one record, with its 1-based line number. */
340
+ export function detectMarkers(text: string): Marker[] {
341
+ const lines = text.split('\n');
342
+ const body = bodyStart(lines);
343
+ const out: Marker[] = [];
344
+
345
+ lines.forEach((line, index) => {
346
+ const push = (cls: MarkerClass): void => {
347
+ out.push({ cls, line: index + 1, excerpt: excerpt(line) });
348
+ };
349
+
350
+ // Whole-file classes CITE a source or DECLARE an authority, and both
351
+ // legitimately appear in frontmatter — a `basis:` list pointing at
352
+ // `docs/CLAIMS.md`, an `authority_basis: owner_intent` line.
353
+ if (CLAIM_RE.test(line)) push('claim');
354
+ if (BENCHMARK_RE.test(line)) push('benchmark');
355
+ if (PREREG_RE.test(line)) push('prereg');
356
+ if (EXTERNAL_STANDARD_RE.test(line)) push('external_standard');
357
+ if (EXTERNAL_CITATION_RE.test(line)) push('external_citation');
358
+ if (OWNER_RE.test(line) && !sectionOf(lines, index).startsWith('consequence')) {
359
+ push('owner');
360
+ }
361
+
362
+ // Body-only classes DESCRIBE how the work was done, and a slug or a
363
+ // `phase:` name is a label rather than a description. Measured:
364
+ // `ADR-216`'s `phase: … adoption-anchor sweep` (`:8`) matched the
365
+ // repeated/comparative class from frontmatter alone, which would have
366
+ // priced a naming coincidence as a comparison.
367
+ if (index < body) return;
368
+ if (COUNCIL_RE.test(line)) push('council');
369
+ if (REPEATED_RE.test(line)) push('repeated');
370
+
371
+ // A FIGURE — a percentage, a ratio, or a closed-list counted unit — is a
372
+ // quantified observation on its own. It does NOT need a date beside it:
373
+ // the record's own `date:` dates it, and requiring a second, adjacent
374
+ // date deleted real measurements rather than filtering noise. Measured
375
+ // on the corpus: `ADR-227` prints exact-BPE token counts against a
376
+ // 106,704 baseline (`:37`), a `179.1 %` figure (`:40`), `25 of 116`
377
+ // (`:66`) and a three-row percentage table (`:107-109`) — and every one
378
+ // of them was dropped, because that record's only dates are its
379
+ // frontmatter and its Status line. It graded E0. The literal
380
+ // co-location rule was the defect, so it now binds only the verb case
381
+ // below, where there is no figure to quantify anything.
382
+ //
383
+ // The noise the anchor was supposed to stop is stopped by the figure
384
+ // patterns instead: a percentage, an `Nx` / `N vs M` / `N of M` ratio,
385
+ // and a number followed by one of a CLOSED unit list. An open
386
+ // `\d+\s+\w+` matched version strings (`6.0.0-D`), phase numbers, and
387
+ // dates, which is why the list is closed rather than inferred.
388
+ if (PERCENT_RE.test(line) || RATIO_RE.test(line) || UNIT_RE.test(line)) {
389
+ push('measurement');
390
+ return;
391
+ }
392
+ // A verb with no figure reports an observation without printing one, so
393
+ // it still needs a date / PR / sha nearby to be a dated observation
394
+ // rather than the word "measured" in a sentence about measuring.
395
+ if (MEASURE_VERB_RE.test(line) && anchoredNear(lines, index)) push('measurement');
396
+ });
397
+
398
+ return out;
399
+ }
400
+
401
+ // ---------------------------------------------------------------------------
402
+ // Proposal
403
+ // ---------------------------------------------------------------------------
404
+
405
+ export interface Proposal {
406
+ provenance: string;
407
+ agenticMode: string | null;
408
+ strength: string;
409
+ discovery: 'incomplete';
410
+ rationale: string;
411
+ }
412
+
413
+ function classes(markers: readonly Marker[]): Set<MarkerClass> {
414
+ return new Set(markers.map((m) => m.cls));
415
+ }
416
+
417
+ /**
418
+ * Why a marker class that IS present still raised nothing.
419
+ *
420
+ * Four classes can be detected and grade to E0 on their own, because every rule
421
+ * that reads them requires a companion: `repeated` needs something measured to
422
+ * compare, `prereg` needs a benchmark corpus, `benchmark` needs a measurement or
423
+ * a pre-registration, and `owner` is a provenance input that the grading path
424
+ * never reads at all. Before this table existed those records printed
425
+ * "no evidence marker found" beside a Matched-markers cell that listed markers
426
+ * — 12 of 186 rows in the shipped artifact, `ADR-032` / `056` / `085` / `093` /
427
+ * `101` / `104` / `108` / `111` / `112` / `130` / `211` / `230`. The grade was
428
+ * right and the sentence was false, which is worse than a wrong grade: a
429
+ * reviewer who trusts the rationale stops reading the citation column.
430
+ */
431
+ const UNGRADED_REASON: Readonly<Partial<Record<MarkerClass, string>>> = {
432
+ owner: 'an owner or maintainer declaration is provenance, not evidence',
433
+ repeated: 'comparative language with nothing measured to compare (E2 needs a measurement or a benchmark)',
434
+ prereg: 'pre-registration language with no benchmark corpus beside it (E3 needs both)',
435
+ benchmark: 'a benchmark path with no measurement or pre-registration beside it (E2 needs one)',
436
+ };
437
+
438
+ /**
439
+ * The ungraded classes, named, in first-appearance order — `classes()` builds
440
+ * its Set from the marker list, so the order is the record's own and is stable
441
+ * across runs.
442
+ */
443
+ export function describeUngraded(present: ReadonlySet<MarkerClass>): string {
444
+ const parts = [...present]
445
+ .filter((cls) => !COUNCIL_CLASSES.has(cls))
446
+ .map((cls) => `${cls} (${UNGRADED_REASON[cls] ?? 'no rule raises the grade on this class alone'})`);
447
+ return parts.join('; ');
448
+ }
449
+
450
+ /**
451
+ * Propose `evidence.strength` from the NON-council marker set.
452
+ *
453
+ * The council exclusion is the structure, not a branch: `present` is built from
454
+ * a filtered list, so rule 1 ("a council marker alone never raises the grade
455
+ * above E0") cannot be defeated by adding another council pattern later.
456
+ */
457
+ export function proposeStrength(markers: readonly Marker[]): { strength: string; rationale: string } {
458
+ const graded = markers.filter((m) => !COUNCIL_CLASSES.has(m.cls));
459
+ const present = classes(graded);
460
+ const councilOnly = graded.length === 0 && markers.length > 0;
461
+
462
+ if (present.has('claim')) {
463
+ return { strength: 'E3', rationale: 'cites a CLAIMS.md claim id (pre-registered and hash-bound in this repo)' };
464
+ }
465
+ if (present.has('prereg') && present.has('benchmark')) {
466
+ return { strength: 'E3', rationale: 'pre-registered benchmark' };
467
+ }
468
+ if (present.has('external_standard')) {
469
+ return { strength: 'E3', rationale: 'cites a named external standard or vendor guidance' };
470
+ }
471
+ if (present.has('repeated') && (present.has('measurement') || present.has('benchmark'))) {
472
+ return { strength: 'E2', rationale: 'repeated or comparative measurement' };
473
+ }
474
+ if (present.has('measurement') && (present.has('benchmark') || present.has('prereg'))) {
475
+ return { strength: 'E2', rationale: 'measurement against a benchmark or pre-registered threshold' };
476
+ }
477
+ if (present.has('measurement')) {
478
+ return { strength: 'E1', rationale: 'one dated local observation' };
479
+ }
480
+ if (present.has('external_citation')) {
481
+ return { strength: 'E1', rationale: 'one external citation, no named standard' };
482
+ }
483
+ if (councilOnly) {
484
+ return {
485
+ strength: 'E0',
486
+ rationale: 'council / agreement markers only — consensus is not evidence',
487
+ };
488
+ }
489
+ // Markers WERE found and none of them is graded by any rule above. This
490
+ // branch exists because the fall-through used to claim the opposite; see
491
+ // `UNGRADED_REASON`.
492
+ if (graded.length > 0) {
493
+ return {
494
+ strength: 'E0',
495
+ rationale: `markers present, none graded: ${describeUngraded(present)}`,
496
+ };
497
+ }
498
+ return { strength: 'E0', rationale: 'no evidence marker found' };
499
+ }
500
+
501
+ /**
502
+ * Propose `provenance.kind`.
503
+ *
504
+ * Owner and council markers are the only two inputs, and `reopen_policy: owner`
505
+ * is deliberately NOT one of them: the contract's own Iron Law says an ADR's
506
+ * historical decision-maker does not determine its reopen venue, and reading
507
+ * that backwards — venue implies author — is the same conflation in the other
508
+ * direction.
509
+ */
510
+ export function proposeProvenance(markers: readonly Marker[]): {
511
+ provenance: string;
512
+ agenticMode: string | null;
513
+ } {
514
+ const present = classes(markers);
515
+ const human = present.has('owner');
516
+ const agent = present.has('council');
517
+ const mode = agent ? 'council' : null;
518
+
519
+ if (human && agent) return { provenance: 'mixed', agenticMode: mode };
520
+ if (human) return { provenance: 'human', agenticMode: null };
521
+ if (agent) return { provenance: 'agentic', agenticMode: mode };
522
+ return { provenance: 'unknown', agenticMode: null };
523
+ }
524
+
525
+ export function propose(markers: readonly Marker[]): Proposal {
526
+ const { strength, rationale } = proposeStrength(markers);
527
+ const { provenance, agenticMode } = proposeProvenance(markers);
528
+ return { provenance, agenticMode, strength, discovery: 'incomplete', rationale };
529
+ }
530
+
531
+ // ---------------------------------------------------------------------------
532
+ // Corpus
533
+ // ---------------------------------------------------------------------------
534
+
535
+ export interface CensusRecord {
536
+ /** Repo-relative path. */
537
+ record: string;
538
+ surface: 'flat' | 'area';
539
+ /** `present`, or `absent` with what stands in for it. */
540
+ frontmatter: 'present' | 'absent';
541
+ /** What the record carries today on the two axes, for the diff column. */
542
+ current: string;
543
+ proposed: Proposal;
544
+ markers: Marker[];
545
+ }
546
+
547
+ export interface CensusResult {
548
+ scanned: number;
549
+ records: CensusRecord[];
550
+ /** Records that could not be read at all — the only `--check` failure. */
551
+ unreadable: { record: string; error: string }[];
552
+ }
553
+
554
+ /** Enumerate the corpus: 177 flat `ADR-*.md` plus the per-area `NNNN-*.md`. */
555
+ export function enumerateRecords(root: string = REPO_ROOT): string[] {
556
+ const out: string[] = [];
557
+
558
+ const flat = path.join(root, FLAT_DIR);
559
+ if (fs.existsSync(flat)) {
560
+ for (const name of fs.readdirSync(flat).sort()) {
561
+ if (/^ADR-.*\.md$/.test(name)) out.push(path.join(FLAT_DIR, name));
562
+ }
563
+ }
564
+
565
+ const areas = path.join(root, AREA_DIR);
566
+ if (fs.existsSync(areas)) {
567
+ const walk = (dir: string, rel: string): void => {
568
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
569
+ const abs = path.join(dir, entry.name);
570
+ const next = path.join(rel, entry.name);
571
+ if (entry.isDirectory()) {
572
+ walk(abs, next);
573
+ continue;
574
+ }
575
+ // Every `README.md` is excluded — an area index is not a record.
576
+ if (entry.name === 'README.md') continue;
577
+ if (/^\d+.*\.md$/.test(entry.name)) out.push(next);
578
+ }
579
+ };
580
+ walk(areas, AREA_DIR);
581
+ }
582
+
583
+ return out;
584
+ }
585
+
586
+ /**
587
+ * The per-area metadata line, which is a blockquote rather than frontmatter:
588
+ * `> Area: \`cost\` · Status: accepted · Date: 2026-05-16 · Type: retrospective`.
589
+ *
590
+ * Read for the report's "current" column only. A sibling change is converting
591
+ * these to real frontmatter; until it lands, reporting the blockquote is more
592
+ * useful than reporting nothing, and it is labelled as blockquote-derived so
593
+ * nobody mistakes it for a parsed axis.
594
+ */
595
+ export function blockquoteMeta(text: string): string | null {
596
+ for (const line of text.split('\n').slice(0, 12)) {
597
+ if (/^>\s*Area:/.test(line)) return line.replace(/^>\s*/, '').trim();
598
+ }
599
+ return null;
600
+ }
601
+
602
+ /** Render what the record carries on the two axes today. */
603
+ function currentAxes(fm: AdrFrontmatter | null, text: string): string {
604
+ if (fm === null) {
605
+ const meta = blockquoteMeta(text);
606
+ return meta === null
607
+ ? 'no frontmatter, no blockquote metadata'
608
+ : `no frontmatter; blockquote: ${meta}`;
609
+ }
610
+ const prov = provenanceOf(fm);
611
+ const ev = evidenceOf(fm);
612
+ const parts: string[] = [];
613
+ parts.push(prov?.kind === undefined || prov?.kind === null ? 'provenance: —' : `provenance: ${prov.kind}`);
614
+ parts.push(ev?.strength === undefined || ev?.strength === null ? 'evidence: —' : `evidence: ${ev.strength}`);
615
+ const basis = fm.scalars['authority_basis'];
616
+ if (basis !== undefined && basis !== '') parts.push(`authority_basis: ${basis}`);
617
+ return parts.join(' · ');
618
+ }
619
+
620
+ export function runCensus(root: string = REPO_ROOT, ledger?: GateLedger): CensusResult {
621
+ const rels = enumerateRecords(root);
622
+ ledger?.plan(rels);
623
+
624
+ const records: CensusRecord[] = [];
625
+ const unreadable: { record: string; error: string }[] = [];
626
+
627
+ for (const rel of rels) {
628
+ let text: string;
629
+ try {
630
+ text = fs.readFileSync(path.join(root, rel), 'utf-8');
631
+ } catch (exc) {
632
+ unreadable.push({ record: rel, error: exc instanceof Error ? exc.message : String(exc) });
633
+ ledger?.fail(rel, 'unreadable');
634
+ continue;
635
+ }
636
+ const fm = readAdrFrontmatter(text);
637
+ const markers = detectMarkers(text);
638
+ records.push({
639
+ record: rel,
640
+ surface: rel.startsWith(FLAT_DIR) ? 'flat' : 'area',
641
+ frontmatter: fm === null ? 'absent' : 'present',
642
+ current: currentAxes(fm, text),
643
+ proposed: propose(markers),
644
+ markers,
645
+ });
646
+ ledger?.complete(rel);
647
+ }
648
+
649
+ return { scanned: records.length, records, unreadable };
650
+ }
651
+
652
+ // ---------------------------------------------------------------------------
653
+ // Report
654
+ // ---------------------------------------------------------------------------
655
+
656
+ export function distribution(result: CensusResult): {
657
+ strength: Record<string, number>;
658
+ provenance: Record<string, number>;
659
+ frontmatter_absent: number;
660
+ } {
661
+ const strength: Record<string, number> = {};
662
+ const provenance: Record<string, number> = {};
663
+ let absent = 0;
664
+ for (const r of result.records) {
665
+ strength[r.proposed.strength] = (strength[r.proposed.strength] ?? 0) + 1;
666
+ provenance[r.proposed.provenance] = (provenance[r.proposed.provenance] ?? 0) + 1;
667
+ if (r.frontmatter === 'absent') absent += 1;
668
+ }
669
+ return { strength, provenance, frontmatter_absent: absent };
670
+ }
671
+
672
+ /** `docs/decisions/ADR-048-x.md:30 council` — the citation, deduped per class. */
673
+ function renderMarkers(markers: readonly Marker[], record: string): string {
674
+ if (markers.length === 0) return '_none_';
675
+ const byClass = new Map<MarkerClass, number[]>();
676
+ for (const m of markers) {
677
+ const seen = byClass.get(m.cls) ?? [];
678
+ if (!seen.includes(m.line)) seen.push(m.line);
679
+ byClass.set(m.cls, seen);
680
+ }
681
+ const base = path.basename(record);
682
+ return [...byClass.entries()]
683
+ .map(([cls, lines]) => `${cls} @ ${lines.slice(0, 6).map((l) => `${base}:${String(l)}`).join(', ')}`)
684
+ .join('<br>');
685
+ }
686
+
687
+ export function renderArtifact(result: CensusResult): string {
688
+ const dist = distribution(result);
689
+ const L: string[] = [];
690
+
691
+ // `analysis` per `docs/contracts/evidence-artifact-types.md`: "a
692
+ // measurement, census, investigation, or report … never re-bound". Emitted
693
+ // as line 1 because `lint_evidence_artifacts` fires on any artifact ADDED
694
+ // under `agents/evidence/` that resolves no type, and it reads only the
695
+ // first 40 lines. A generator that writes an undeclared artifact reds that
696
+ // gate on the commit, not on the run — which is exactly the class of defect
697
+ // that is cheap here and expensive later.
698
+ L.push('<!-- evidence-type: analysis -->');
699
+ L.push('');
700
+ L.push('# ADR evidence census — proposed provenance and evidence grades');
701
+ L.push('');
702
+ L.push(
703
+ `Generated by \`src/scripts/adr/evidence_census.ts\`. Records examined: **${String(result.scanned)}** ` +
704
+ `(${String(result.records.filter((r) => r.surface === 'flat').length)} flat, ` +
705
+ `${String(result.records.filter((r) => r.surface === 'area').length)} per-area).`,
706
+ );
707
+ L.push('');
708
+ L.push('## What this artifact is NOT');
709
+ L.push('');
710
+ L.push(
711
+ 'It is a **proposal set for human review**. It wrote no ADR frontmatter, and it has no code ' +
712
+ 'path that can: `adr-layout § Provenance and evidence` says a census may propose a value and ' +
713
+ 'never writes one, and the `No bulk classification` clause is why. The grades are **heuristic** ' +
714
+ '— a marker grep over each record\'s own text, not a citation audit.',
715
+ );
716
+ L.push('');
717
+ L.push('### Which way each class is biased');
718
+ L.push('');
719
+ L.push(
720
+ 'Most classes are biased **LOW** on purpose: an under-graded proposal costs a reviewer one ' +
721
+ 'upgrade, while an over-graded one puts unearned "this is measured" text into the tree. ' +
722
+ '`measurement` needs a figure or a dated verb, `repeated` needs something measured to compare, ' +
723
+ '`prereg` needs a benchmark corpus beside it, and `owner` / `council` raise nothing at all.',
724
+ );
725
+ L.push('');
726
+ L.push(
727
+ '**Two classes are biased HIGH by construction, and the paragraph above is false for them.** ' +
728
+ '`claim` and `external_standard` short-circuit to `E3` — the second-highest grade in the ' +
729
+ 'scale, and the highest this script proposes at all — on a bare textual match, ahead of ' +
730
+ 'every other rule and with no companion marker ' +
731
+ 'required: they are the first and third branches of `proposeStrength`, so they pre-empt the ' +
732
+ 'measurement rules rather than competing with them. The standard pattern matches a plain ' +
733
+ 'word-bounded `GDPR`, so naming a regulation as the *subject* of a decision reads identically ' +
734
+ 'to citing it as the *evidence for* one.',
735
+ );
736
+ L.push('');
737
+ L.push(
738
+ 'Worked example, checkable in this table: `ADR-107` line 35 writes "EU DPA / GDPR Art. 28" to ' +
739
+ 'scope which legal domain its eval fixtures cover. This census proposes ' +
740
+ '`E3 — cites a named external standard or vendor guidance`; the tranche reviewer reading the ' +
741
+ 'same line adjudicated `E2`. The grading rules are deliberately unchanged — so read an `E3` ' +
742
+ 'whose only non-council marker is `claim` or `external_standard` as a **ceiling to check ' +
743
+ 'downward**, not as a floor.',
744
+ );
745
+ L.push('');
746
+ L.push(
747
+ '`discovery` is `incomplete` for every row, at every grade, and this script cannot emit ' +
748
+ '`complete`. A marker grep is not the defined exhaustive evidence search the contract requires ' +
749
+ 'before absence may be asserted. **E4 is never proposed** — an external constraint is a fact ' +
750
+ 'about real consumers, contracts, or law, and marker presence cannot establish one.',
751
+ );
752
+ L.push('');
753
+ L.push(
754
+ 'A council marker never raises a grade above `E0`. That is structural here, not a special case: ' +
755
+ 'the grade is computed from the non-council marker set, so no future council pattern can move it.',
756
+ );
757
+ L.push('');
758
+ L.push('## Proposed distribution');
759
+ L.push('');
760
+ L.push('| Axis | Value | Records |');
761
+ L.push('|---|---|---|');
762
+ for (const key of ['E0', 'E1', 'E2', 'E3', 'E4']) {
763
+ L.push(`| \`evidence.strength\` | \`${key}\` | ${String(dist.strength[key] ?? 0)} |`);
764
+ }
765
+ for (const key of ['human', 'agentic', 'mixed', 'unknown']) {
766
+ L.push(`| \`provenance.kind\` | \`${key}\` | ${String(dist.provenance[key] ?? 0)} |`);
767
+ }
768
+ L.push(`| frontmatter | absent | ${String(dist.frontmatter_absent)} |`);
769
+ L.push('');
770
+
771
+ if (result.unreadable.length > 0) {
772
+ L.push('## Unreadable records');
773
+ L.push('');
774
+ for (const u of result.unreadable) L.push(`- \`${u.record}\` — ${u.error}`);
775
+ L.push('');
776
+ }
777
+
778
+ L.push('## Per-record proposals');
779
+ L.push('');
780
+ L.push(
781
+ '| Record | Proposed provenance | Proposed strength | Discovery | Matched markers (file:line) | Current frontmatter |',
782
+ );
783
+ L.push('|---|---|---|---|---|---|');
784
+ for (const r of result.records) {
785
+ const prov =
786
+ r.proposed.agenticMode === null
787
+ ? `\`${r.proposed.provenance}\``
788
+ : `\`${r.proposed.provenance}\` (\`agentic_mode: ${r.proposed.agenticMode}\`)`;
789
+ L.push(
790
+ `| \`${r.record}\` | ${prov} | \`${r.proposed.strength}\` — ${r.proposed.rationale} | ` +
791
+ `\`${r.proposed.discovery}\` | ${renderMarkers(r.markers, r.record)} | ${r.current} |`,
792
+ );
793
+ }
794
+ L.push('');
795
+ return L.join('\n');
796
+ }
797
+
798
+ // ---------------------------------------------------------------------------
799
+ // CLI
800
+ // ---------------------------------------------------------------------------
801
+
802
+ function main(argv: string[]): number {
803
+ const asJson = argv.includes('--json');
804
+ const checkOnly = argv.includes('--check');
805
+ const outIndex = argv.indexOf('--out');
806
+ if (outIndex !== -1 && argv[outIndex + 1] === undefined) {
807
+ process.stderr.write('usage: evidence_census [--json] [--check] [--out <path>]\n');
808
+ return 2;
809
+ }
810
+ const outRel = outIndex === -1 ? DEFAULT_OUT : (argv[outIndex + 1] as string);
811
+
812
+ const ledger = new GateLedger('adr_evidence_census');
813
+ const result = runCensus(REPO_ROOT, ledger);
814
+ const tally = ledger.finalize();
815
+
816
+ try {
817
+ assertScanned({
818
+ gate: 'adr_evidence_census',
819
+ scanned: result.scanned,
820
+ units: 'ADR record(s)',
821
+ roots: [FLAT_DIR, AREA_DIR],
822
+ });
823
+ } catch (exc) {
824
+ if (exc instanceof DeadScopeError) {
825
+ process.stderr.write(`❌ ${exc.message}\n`);
826
+ return 1;
827
+ }
828
+ throw exc;
829
+ }
830
+
831
+ if (asJson) {
832
+ process.stdout.write(
833
+ JSON.stringify(
834
+ {
835
+ scanned: result.scanned,
836
+ distribution: distribution(result),
837
+ unreadable: result.unreadable,
838
+ records: result.records,
839
+ ledger: tally,
840
+ },
841
+ null,
842
+ 2,
843
+ ) + '\n',
844
+ );
845
+ return result.unreadable.length > 0 ? 1 : 0;
846
+ }
847
+
848
+ const dist = distribution(result);
849
+ // `path.join(REPO_ROOT, outRel)` silently rewrote an ABSOLUTE `--out` into
850
+ // the repo — `--out /tmp/x.md` wrote `<repo>/tmp/x.md` and then printed
851
+ // `/tmp/x.md`, so the caller was told a path that held nothing. Resolve
852
+ // instead, and print what was actually written. Found in completion review.
853
+ const abs = path.isAbsolute(outRel) ? outRel : path.join(REPO_ROOT, outRel);
854
+ if (!checkOnly) {
855
+ fs.mkdirSync(path.dirname(abs), { recursive: true });
856
+ fs.writeFileSync(abs, renderArtifact(result), 'utf-8');
857
+ }
858
+
859
+ process.stdout.write(`scanned: ${String(result.scanned)}\n`);
860
+ process.stdout.write(
861
+ `ADR evidence census: ` +
862
+ (['E0', 'E1', 'E2', 'E3', 'E4'].map((k) => `${k}=${String(dist.strength[k] ?? 0)}`).join(' ')) +
863
+ ` · ` +
864
+ (['human', 'agentic', 'mixed', 'unknown'].map((k) => `${k}=${String(dist.provenance[k] ?? 0)}`).join(' ')) +
865
+ `\n`,
866
+ );
867
+ ledger.report();
868
+ if (!checkOnly) process.stdout.write(`proposal artifact: ${path.relative(REPO_ROOT, abs) || abs}\n`);
869
+
870
+ if (result.unreadable.length > 0) {
871
+ for (const u of result.unreadable) {
872
+ process.stderr.write(` ❌ ${u.record}: ${u.error}\n`);
873
+ }
874
+ process.stderr.write(
875
+ `\n❌ adr_evidence_census: ${String(result.unreadable.length)} record(s) could not be read — the scan is incomplete.\n`,
876
+ );
877
+ return 1;
878
+ }
879
+
880
+ // Deliberately never non-zero on a grade. This is a report; the proposals
881
+ // are reviewed by a human, and a low grade is a finding, not a violation.
882
+ process.stdout.write('✅ adr_evidence_census: scan complete (proposals only — nothing was written to any ADR)\n');
883
+ return 0;
884
+ }
885
+
886
+ if (process.argv[1] !== undefined) {
887
+ try {
888
+ const here = fs.realpathSync(fileURLToPath(import.meta.url));
889
+ const argv1 = fs.realpathSync(path.resolve(process.argv[1]));
890
+ if (here === argv1) {
891
+ process.exit(main(process.argv.slice(2)));
892
+ }
893
+ } catch {
894
+ const argvUrl = pathToFileURL(path.resolve(process.argv[1])).href;
895
+ if (import.meta.url === argvUrl) {
896
+ process.exit(main(process.argv.slice(2)));
897
+ }
898
+ }
899
+ }