@event4u/agent-config 14.7.0 → 14.8.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 (138) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/CHANGELOG.md +245 -388
  3. package/README.md +1 -1
  4. package/dist/agent-src/commands/evals/git-pr-merge.json +13 -0
  5. package/dist/agent-src/commands/evals/roadmap.json +17 -1
  6. package/dist/agent-src/commands/pr/create.md +14 -2
  7. package/dist/agent-src/commands/pr/merge.md +379 -0
  8. package/dist/agent-src/commands/roadmap/process-full.md +157 -3
  9. package/dist/agent-src/commands/roadmap.md +1 -1
  10. package/dist/agent-src/contexts/execution/auto-dispatch-classification.md +46 -36
  11. package/dist/agent-src/contexts/execution/autonomy-mechanics.md +37 -2
  12. package/dist/agent-src/contexts/execution/roadmap-execution-contract.md +64 -2
  13. package/dist/agent-src/contexts/execution/roadmap-process-loop.md +154 -2
  14. package/dist/agent-src/rules/artifact-drafting-protocol.md +5 -1
  15. package/dist/agent-src/rules/decision-revisit-gate.md +65 -33
  16. package/dist/agent-src/rules/recurring-criticism.md +1 -1
  17. package/dist/agent-src/rules/roadmap-progress-sync.md +19 -0
  18. package/dist/agent-src/rules/source-confidentiality.md +1 -1
  19. package/dist/agent-src/scripts/archive_completed_roadmaps.ts +7 -4
  20. package/dist/agent-src/scripts/dashboard_mode.ts +212 -0
  21. package/dist/agent-src/scripts/update_roadmap_progress.ts +47 -48
  22. package/dist/agent-src/skills/adr-create/SKILL.md +124 -5
  23. package/dist/agent-src/skills/decision-record/SKILL.md +61 -3
  24. package/dist/agent-src/skills/decision-review/SKILL.md +65 -0
  25. package/dist/agent-src/skills/merge-conflicts/SKILL.md +26 -0
  26. package/dist/agent-src/skills/security/SKILL.md +8 -0
  27. package/dist/agent-src/skills/security-audit/SKILL.md +12 -1
  28. package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
  29. package/dist/agent-src/templates/roadmaps.md +20 -2
  30. package/dist/cli/registry.js +1 -0
  31. package/dist/cli/registry.js.map +1 -1
  32. package/dist/cli-delegate/{chunk-JZ4NR2TW.js → chunk-7PRE6ZV2.js} +1 -1
  33. package/dist/cli-delegate/{chunk-7F3VYSM2.js → chunk-EEJUMCDZ.js} +14 -14
  34. package/dist/cli-delegate/{chunk-74E3TKWV.js → chunk-H4VIVD4C.js} +5 -5
  35. package/dist/cli-delegate/chunk-I3EIN2YG.js +36 -0
  36. package/dist/cli-delegate/{chunk-4VR4ORQ6.js → chunk-LPPWO5CE.js} +4 -3
  37. package/dist/cli-delegate/{chunk-UEZ7D35A.js → chunk-N7CDDGI3.js} +4 -23
  38. package/dist/cli-delegate/cmd_adr_effective.js +1162 -0
  39. package/dist/cli-delegate/cmd_conformance.js +14 -14
  40. package/dist/cli-delegate/cmd_converge.js +3 -3
  41. package/dist/cli-delegate/cmd_doctor.js +7 -7
  42. package/dist/cli-delegate/cmd_export.js +6 -6
  43. package/dist/cli-delegate/cmd_handoff.js +7 -6
  44. package/dist/cli-delegate/cmd_memory_get.js +2 -1
  45. package/dist/cli-delegate/cmd_migrate.js +2 -2
  46. package/dist/cli-delegate/cmd_preflight.js +5 -5
  47. package/dist/cli-delegate/cmd_prune.js +6 -6
  48. package/dist/cli-delegate/cmd_route_audit.js +6 -5
  49. package/dist/cli-delegate/cmd_route_explain.js +3 -3
  50. package/dist/cli-delegate/cmd_session_recycle.js +3 -3
  51. package/dist/cli-delegate/cmd_settings_get.js +3 -3
  52. package/dist/cli-delegate/cmd_settings_migrate.js +6 -6
  53. package/dist/cli-delegate/cmd_sync.js +6 -6
  54. package/dist/cli-delegate/cmd_uninstall.js +6 -6
  55. package/dist/cli-delegate/cmd_update.js +3 -3
  56. package/dist/cli-delegate/cmd_upgrade.js +3 -3
  57. package/dist/cli-delegate/cmd_validate.js +6 -6
  58. package/dist/cli-delegate/cmd_versions.js +3 -3
  59. package/dist/discovery/deprecation-report.md +1 -1
  60. package/dist/discovery/discovery-manifest.json +53 -22
  61. package/dist/discovery/discovery-manifest.json.sha256 +1 -1
  62. package/dist/discovery/discovery-manifest.summary.md +3 -3
  63. package/dist/discovery/dormancy-report.md +1 -1
  64. package/dist/discovery/orphan-report.md +1 -1
  65. package/dist/discovery/packs.json +7 -6
  66. package/dist/discovery/trust-report.md +2 -2
  67. package/dist/discovery/workspaces.json +6 -5
  68. package/dist/hooks/dispatch.js +806 -681
  69. package/dist/install/globalRuleLayers.js +169 -0
  70. package/dist/install/globalRuleLayers.js.map +1 -0
  71. package/dist/install/partitionEligibility.js +156 -0
  72. package/dist/install/partitionEligibility.js.map +1 -1
  73. package/dist/install/ruleLayerPartition.js +157 -0
  74. package/dist/install/ruleLayerPartition.js.map +1 -0
  75. package/dist/mcp/registry-manifest.json +3 -3
  76. package/dist/mcp/server.json +2 -2
  77. package/dist/mcp/server.mjs +654 -578
  78. package/dist/scripts/_lib/prune_empty_dirs.js +85 -0
  79. package/dist/scripts/_lib/prune_empty_dirs.js.map +1 -0
  80. package/dist/scripts/condense.js +52 -89
  81. package/dist/scripts/condense.js.map +1 -1
  82. package/docs/guidelines/agent-infra/artifact-drafting-protocol-mechanics.md +45 -1
  83. package/docs/guidelines/agent-infra/false-green.md +47 -1
  84. package/package.json +1 -1
  85. package/src/agent-src/scripts/archive_completed_roadmaps.ts +7 -4
  86. package/src/agent-src/scripts/dashboard_mode.ts +212 -0
  87. package/src/agent-src/scripts/update_roadmap_progress.ts +47 -48
  88. package/src/config/ci-local-parity.yml +21 -0
  89. package/src/config/estate-count-budget.json +275 -2
  90. package/src/config/evaluator-budgets.json +3 -3
  91. package/src/config/gate-coverage.yml +220 -12
  92. package/src/config/gate-violation-baselines.json +73 -63
  93. package/src/config/gitignore-block.txt +15 -2
  94. package/src/config/hook-latency-budget.json +5 -0
  95. package/src/config/rule-stub-ceilings.json +17 -3
  96. package/src/install/globalRuleLayers.ts +197 -0
  97. package/src/install/partitionEligibility.ts +177 -0
  98. package/src/install/ruleLayerPartition.ts +212 -0
  99. package/src/scripts/_cli/cmd_adr_effective.ts +484 -0
  100. package/src/scripts/_dispatch.bash +11 -0
  101. package/src/scripts/_lib/adr_frontmatter.test.ts +365 -0
  102. package/src/scripts/_lib/adr_frontmatter.ts +459 -0
  103. package/src/scripts/_lib/envelope_grounding.ts +11 -1
  104. package/src/scripts/_lib/layer_overlap_notice.ts +11 -2
  105. package/src/scripts/_lib/prune_empty_dirs.ts +80 -0
  106. package/src/scripts/adr/evidence_census.ts +899 -0
  107. package/src/scripts/adr/regenerate_index.ts +12 -32
  108. package/src/scripts/adr_cite_check.ts +540 -39
  109. package/src/scripts/audit_adr_coverage.ts +103 -17
  110. package/src/scripts/bench_hook_latency.ts +204 -0
  111. package/src/scripts/build_archive_index.ts +34 -0
  112. package/src/scripts/check_adr_frontmatter.ts +574 -39
  113. package/src/scripts/check_hook_bundle_content.ts +219 -0
  114. package/src/scripts/check_new_adr_evidence.ts +529 -0
  115. package/src/scripts/check_references.ts +18 -0
  116. package/src/scripts/check_rule_layer_partition.ts +531 -0
  117. package/src/scripts/check_single_delivery.ts +137 -4
  118. package/src/scripts/check_standing_rule_delivery.ts +8 -2
  119. package/src/scripts/condense.ts +61 -91
  120. package/src/scripts/external_sources_denylist.json +4 -1
  121. package/src/scripts/hooks/block_unauthorized_git.ts +22 -2
  122. package/src/scripts/hooks/dispatch_hook.ts +154 -187
  123. package/src/scripts/hooks/dispatch_issues.ts +38 -19
  124. package/src/scripts/hooks/fallback_yaml.ts +66 -0
  125. package/src/scripts/hooks/host_semantics.ts +21 -0
  126. package/src/scripts/hooks/injection_budget.ts +13 -3
  127. package/src/scripts/hooks/py_json_dumps.ts +103 -0
  128. package/src/scripts/hooks/state_io.ts +88 -1
  129. package/src/scripts/hooks/stdin_failure_policy.ts +96 -0
  130. package/src/scripts/injection_scan_hook.ts +137 -5
  131. package/src/scripts/lint_hook_manifest.ts +49 -0
  132. package/src/scripts/lint_provenance_vocabulary.ts +472 -4
  133. package/src/scripts/routing_doctor.ts +5 -1
  134. package/src/scripts/sync_pr_branch.ts +163 -20
  135. package/dist/cli-delegate/{chunk-PPQO4NGR.js → chunk-6RJA2F5A.js} +3 -3
  136. package/dist/cli-delegate/{chunk-6EOCNQAM.js → chunk-E4XIZDIB.js} +0 -0
  137. package/dist/cli-delegate/{chunk-FBKL6JWT.js → chunk-MMSGVZKV.js} +3 -3
  138. package/dist/cli-delegate/{chunk-GJ5BVHXM.js → chunk-PMEKGDED.js} +3 -3
@@ -33,20 +33,72 @@
33
33
  * trigger grammar as a prerequisite for exactly this reason. An `indeterminate`
34
34
  * result means: this may not be presented as an unqualified lock — route it.
35
35
  *
36
+ * It also prints the two descriptive axes (`provenance`, `evidence`) and
37
+ * `authority_basis`, and for an accepted E0/E1 record that does NOT carry
38
+ * `authority_basis: owner_intent` it prints
39
+ * `authority_effect: disabled-shadow-mode` — see LOW_EVIDENCE_NOTICE.
40
+ * Provenance is deliberately NOT part of that condition: the notice is about
41
+ * evidence strength, and a human snapshot has exactly as little of it.
42
+ *
43
+ * It prints three more record fields, so that a revisit read can be performed
44
+ * from this tool instead of from the file by hand: `evidence.basis` verbatim
45
+ * (with a `[found]` / `[MISSING]` marker on the refs that are repo paths — a
46
+ * URL and a `claim:` id are printed and never checked), the RESOLVED
47
+ * `reopen_policy` marked declared-or-defaulted, and `protected_dimensions`.
48
+ *
49
+ * What it deliberately does not print: whether the proposed transition is
50
+ * reversible. That is a property of the PROPOSAL, not of the record, so no
51
+ * reader of an ADR file can supply it — the citer does.
52
+ * Shadow mode means exactly what the word says: the axes are surfaced, and no
53
+ * grade changes who may act (`adr-layout § Provenance and evidence`).
54
+ *
36
55
  * Usage:
37
56
  * ./scripts-run src/scripts/adr_cite_check ADR-211
38
57
  * ./scripts-run src/scripts/adr_cite_check ADR-001 ADR-035 --json
58
+ * ./scripts-run src/scripts/adr_cite_check --cited # the CI gate
59
+ *
60
+ * `--cited` is the CI invocation: it resolves every ADR citation found in
61
+ * `src/rules/`, `src/skills/`, `src/domains/` and `docs/` against the corpus
62
+ * and reds when one points nowhere. Until it existed no workflow ran this tool
63
+ * at all — only a test imported it — so every "enforced at cite time" claim
64
+ * about this file was model-carried.
39
65
  *
40
66
  * Exit codes: 0 every reference resolved · 1 at least one did not · 2 usage.
41
67
  */
42
68
 
43
69
  import * as fs from 'node:fs';
70
+ import * as os from 'node:os';
44
71
  import * as path from 'node:path';
45
72
  import { fileURLToPath, pathToFileURL } from 'node:url';
73
+ import {
74
+ type AdrFrontmatter,
75
+ authorityBasisOf,
76
+ evidenceOf,
77
+ isLowEvidenceAccepted,
78
+ provenanceOf,
79
+ readAdrFrontmatter,
80
+ readAdrFrontmatterScalars,
81
+ } from './_lib/adr_frontmatter.js';
82
+ import { GateLedger } from './_lib/gate_ledger.js';
83
+ import { runGateCli, runSelfTest } from './_lib/gate_self_test.js';
84
+ import { DeadScopeError, reportScanned } from './_lib/scan_scope.js';
46
85
 
47
86
  const _HERE = path.dirname(fileURLToPath(import.meta.url));
48
87
  const REPO_ROOT = path.resolve(_HERE, '..', '..');
49
88
 
89
+ /**
90
+ * The tree the corpus mode reads, overridable for `--self-test`.
91
+ *
92
+ * The self-test drives the REAL binary — argv parsing, entry guard and all —
93
+ * over a synthetic tree, which is the whole point of the harness
94
+ * (`_lib/gate_self_test.ts`): an in-process call would skip exactly the layers
95
+ * that have silently no-opped in this repository before. Driving it needs one
96
+ * seam, and this is it.
97
+ */
98
+ function scan_root(): string {
99
+ return process.env['ADR_CITE_CHECK_ROOT'] ?? REPO_ROOT;
100
+ }
101
+
50
102
  /**
51
103
  * The surfaces a decision can live on. Six exist in this tree; this tool reads
52
104
  * the two that carry numbered ADRs and can therefore be cited as `ADR-NNN`.
@@ -67,7 +119,9 @@ const REPO_ROOT = path.resolve(_HERE, '..', '..');
67
119
  * about, committed by this very file. It resolves by path and by the
68
120
  * `ADR-<area>-NNNN` citation form now; a BARE number still cannot address one,
69
121
  * because per-area numbering restarts per area and five files here are `0001`.
70
- * See `PARTIAL_COVERAGE` for what a per-area hit does and does not carry.
122
+ * A per-area hit now carries the same metadata as a flat one: all seven
123
+ * records gained YAML frontmatter on 2026-08-21, so `PARTIAL_COVERAGE` is
124
+ * empty and no residual limit is claimed for this surface.
71
125
  */
72
126
  export const ADR_DIRS = ['docs/decisions', 'docs/adrs'] as const;
73
127
 
@@ -78,17 +132,22 @@ export const SURFACES_NOT_SCANNED = [
78
132
  ] as const;
79
133
 
80
134
  /**
81
- * Partial coverage, stated because a silent partial is the failure this tool
82
- * exists to avoid: `docs/adrs/<area>/` resolves by PATH and by the
83
- * `ADR-<area>-NNNN` citation form, but those files carry a quote-block header
84
- * rather than YAML frontmatter, so `status` / `review_trigger` / the link
85
- * fields read as absent. A per-area result is an accurate location and an
86
- * honestly empty metadata set — never a claim that the fields are empty in the
87
- * document.
135
+ * Partial coverage — empty, and the KEY is kept deliberately.
136
+ *
137
+ * The single entry this held is gone. It said `docs/adrs/<area>/` resolves by
138
+ * PATH and by the `ADR-<area>-NNNN` citation form while those files carry a
139
+ * quote-block header rather than YAML, so `status` / `review_trigger` / the
140
+ * link fields read as absent. All seven per-area records carry real
141
+ * frontmatter as of 2026-08-21, so that gap closed — and its replacement text
142
+ * was prose asserting the absence of partial coverage inside a field named for
143
+ * its presence, plus a "remove on the next pass" TODO, shipped into `--json`.
144
+ *
145
+ * The key stays. `partial_coverage` is a published output shape (see the
146
+ * `--json` payload), so removing it breaks any consumer that reads it, while
147
+ * an empty list states exactly what is true: no partial coverage is known
148
+ * today. This is the place to record the next one.
88
149
  */
89
- export const PARTIAL_COVERAGE = [
90
- 'docs/adrs/<area>/ — resolves, but the header is a quote block, not YAML: metadata reads empty',
91
- ] as const;
150
+ export const PARTIAL_COVERAGE: readonly string[] = [];
92
151
 
93
152
  /** A trigger state. `none` means the ADR never recorded a reopen condition. */
94
153
  export type TriggerState = 'none' | 'indeterminate' | 'fired' | 'not-fired';
@@ -110,10 +169,91 @@ export interface CiteResult {
110
169
  amendment_blocks: string[];
111
170
  /** Other ADR files whose text names this one. */
112
171
  referenced_by: string[];
172
+ /** `provenance.kind` — who decided. Absent when the record carries no axis. */
173
+ provenance_kind?: string;
174
+ /** `provenance.agentic_mode` — descriptive shape of an agentic decision. */
175
+ provenance_agentic_mode?: string;
176
+ /** `evidence.strength` — E0–E4, claim-relative. */
177
+ evidence_strength?: string;
178
+ /** `evidence.discovery` — `complete` | `incomplete`; required on E0. */
179
+ evidence_discovery?: string;
180
+ /**
181
+ * `evidence.basis` — the refs the grade rests on, VERBATIM.
182
+ *
183
+ * Present only when the record declares at least one. The tool prints them;
184
+ * whether each one still supports the claim is the citer's read, not a
185
+ * verdict this file may issue.
186
+ */
187
+ evidence_basis?: string[];
188
+ /**
189
+ * The subset of `evidence_basis` that looks like a repo path and does NOT
190
+ * exist in this checkout — the one existence question that is cheap and
191
+ * decidable here. A URL or a `claim:` id is never listed: liveness of a URL
192
+ * needs the network, and a claim id resolves against the claim registry,
193
+ * neither of which this tool touches. Set (possibly empty) whenever
194
+ * `evidence_basis` is set, so an empty list means "checked, all present"
195
+ * rather than "not checked".
196
+ */
197
+ evidence_basis_unresolved?: string[];
198
+ /** `authority_basis` — `evidence` (default) or `owner_intent`. */
199
+ authority_basis?: string;
200
+ /**
201
+ * `reopen_policy`, RESOLVED — `directional` | `owner` | `unclassified`.
202
+ *
203
+ * Never blank on a resolved record: `adr-layout § Reopen authority` states
204
+ * that an absent field resolves to `unclassified`, and printing a blank
205
+ * would make an agent re-derive a default the contract already fixed.
206
+ * `reopen_policy_defaulted` says which of the two it was, because
207
+ * "nobody classified this" and "someone classified it as unclassified" are
208
+ * the same value and not the same fact.
209
+ */
210
+ reopen_policy?: string;
211
+ /** True when `reopen_policy` was absent and resolved to `unclassified`. */
212
+ reopen_policy_defaulted?: boolean;
213
+ /**
214
+ * `protected_dimensions` — the reserved interests this record touches.
215
+ * Empty list when the record declares none; that is not the same as
216
+ * "no dimension is reserved for the transition being proposed", which is a
217
+ * property of the proposal and outside anything a record can say.
218
+ */
219
+ protected_dimensions?: string[];
220
+ /**
221
+ * What the axes say a cite-time reader may draw from this record.
222
+ *
223
+ * Present only as `disabled-shadow-mode`, and only for an accepted E0/E1
224
+ * record that does NOT carry `authority_basis: owner_intent`.
225
+ *
226
+ * Provenance is deliberately NOT part of that condition, and this docstring
227
+ * said "accepted + agentic + E0/E1" after the predicate had stopped
228
+ * checking provenance — caught in completion review. The notice is about
229
+ * evidence strength, which a human snapshot has exactly as little of; the
230
+ * real exemption is an owner purpose statement, whose alternatives are
231
+ * foreclosed by ownership rather than by evidence.
232
+ *
233
+ * The literal is deliberate: no grade authorizes anything here, so the
234
+ * field reports a *disabled* effect rather than asserting a permission
235
+ * that does not exist. Whether one ever may is Phase 7's open question.
236
+ */
237
+ authority_effect?: string;
113
238
  /** Why this may or may not be cited as a live lock. */
114
239
  verdict: string;
115
240
  }
116
241
 
242
+ /**
243
+ * The block printed for a record whose own metadata says it may not establish
244
+ * that the alternatives remain invalid.
245
+ *
246
+ * Emitted verbatim and unindented so the whole block is greppable as one
247
+ * literal — the string `disabled-shadow-mode` is the load-bearing token and a
248
+ * reader (or a downstream check) must be able to match it without knowing this
249
+ * renderer's indentation.
250
+ */
251
+ export const LOW_EVIDENCE_NOTICE = [
252
+ 'authority_effect: disabled-shadow-mode',
253
+ 'This record documents the prior choice. It does not by itself',
254
+ 'establish that alternatives remain invalid.',
255
+ ].join('\n');
256
+
117
257
  /**
118
258
  * A parsed citation. `area` is null for the flat surface.
119
259
  *
@@ -175,33 +315,141 @@ export function adr_files(repo_root: string = REPO_ROOT): string[] {
175
315
  return ADR_DIRS.flatMap((d) => walk_md(path.join(repo_root, d)));
176
316
  }
177
317
 
318
+ // ---------------------------------------------------------------------------
319
+ // The CI corpus: every ADR the tree cites at someone.
320
+ //
321
+ // WHAT THE GATE FAILS ON, and why it is this and not something else.
322
+ //
323
+ // A malformed axis (`evidence.strength: E9`, an E0 with no `discovery`) is
324
+ // already `check_adr_frontmatter`'s job, and duplicating it here would buy a
325
+ // second red for one defect while leaving this gate's real subject unguarded.
326
+ // The condition only THIS tool computes is an **unresolvable citation**: a
327
+ // rule, skill, domain or doc that names `ADR-NNN` where no decision record
328
+ // carries that number on a scanned surface. That is a live defect class in this
329
+ // tree — a lock cited by number that a reader cannot open is the same
330
+ // false-absence failure `verdict_for` was written for, seen from the citing
331
+ // side — and `cite_check` already decides it (`resolved: false` → exit 1).
332
+ //
333
+ // So the CI invocation resolves every citation in `src/rules/`, `src/skills/`,
334
+ // `src/domains/` and `docs/` against the corpus, and reds when any one of them
335
+ // points nowhere.
336
+ // ---------------------------------------------------------------------------
337
+
338
+ /** The trees whose ADR citations the CI invocation resolves. */
339
+ export const CITATION_ROOTS = ['src/rules', 'src/skills', 'src/domains', 'docs'] as const;
340
+
341
+ const CITATION_FILE_EXTS = new Set(['.md', '.ts', '.yml', '.yaml']);
342
+
343
+ /**
344
+ * A citation, in the two forms `normalise_ref` can resolve.
345
+ *
346
+ * The trailing `(?![0-9A-Za-z])` rejects the template placeholders the corpus
347
+ * really contains — `ADR-0N` in `producing-the-review.md` and `ADR-206`, which
348
+ * would otherwise be discovered as a citation to `ADR-0`.
349
+ *
350
+ * The per-area alternative requires the area to START with a letter, which is
351
+ * what `normalise_ref`'s own `byArea` pattern requires and is not cosmetic:
352
+ * without it, the filename reference `ADR-082-410-one-click-relaunch.md` is
353
+ * discovered as `ADR-082-410` (area `082`), and prose like a dated sweep name
354
+ * would be discovered as a citation to an area that does not exist — a
355
+ * manufactured red. A per-area citation whose area contains a hyphen is
356
+ * therefore NOT discovered by this scan; all six live areas are single words,
357
+ * and `normalise_ref` still resolves such a citation when it is passed
358
+ * explicitly. Stated rather than left as a silent narrowing.
359
+ */
360
+ const CITATION_RE = /ADR-(?:[a-z][a-z0-9]*-\d{1,4}|\d{1,4})(?![0-9A-Za-z])/g;
361
+
362
+ function walk_citable(dir: string): string[] {
363
+ if (!fs.existsSync(dir)) return [];
364
+ const out: string[] = [];
365
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
366
+ const full = path.join(dir, entry.name);
367
+ if (entry.isDirectory()) out.push(...walk_citable(full));
368
+ else if (CITATION_FILE_EXTS.has(path.extname(entry.name))) out.push(full);
369
+ }
370
+ return out.sort();
371
+ }
372
+
373
+ /** Every distinct ADR reference the citation roots name, sorted. */
374
+ export function cited_refs(repo_root: string = REPO_ROOT): string[] {
375
+ const found = new Set<string>();
376
+ for (const root of CITATION_ROOTS) {
377
+ for (const file of walk_citable(path.join(repo_root, root))) {
378
+ for (const m of fs.readFileSync(file, 'utf-8').matchAll(CITATION_RE)) {
379
+ found.add(m[0]);
380
+ }
381
+ }
382
+ }
383
+ return [...found].sort();
384
+ }
385
+
178
386
  /**
179
387
  * Minimal frontmatter reader — folded (`>-`) values are joined, which the
180
388
  * corpus needs: every `review_trigger` in it is a folded multi-line string.
181
389
  */
390
+ /**
391
+ * Read the leading `---` block.
392
+ *
393
+ * Delegates to the shared reader (`_lib/adr_frontmatter.ts`); this function was
394
+ * one of the three divergent copies that reader replaces. Kept as a named
395
+ * export because the test suite imports it.
396
+ */
182
397
  export function parse_frontmatter(text: string): Record<string, string> | null {
183
- if (!text.startsWith('---\n')) return null;
184
- const end = text.indexOf('\n---\n', 4);
185
- if (end === -1) return null;
186
- const out: Record<string, string> = {};
187
- let key: string | null = null;
188
- for (const raw of text.slice(4, end).split('\n')) {
189
- const line = raw.replace(/\s+$/, '');
190
- if (!line || line.trimStart().startsWith('#')) continue;
191
- if (/^\s/.test(line) && key !== null) {
192
- out[key] = `${out[key]} ${line.trim()}`.trim();
193
- continue;
194
- }
195
- const idx = line.indexOf(':');
196
- if (idx === -1) continue;
197
- key = line.slice(0, idx).trim();
198
- out[key] = line
199
- .slice(idx + 1)
200
- .trim()
201
- .replace(/^["'](.*)["']$/, '$1')
202
- .replace(/^>-?$/, '');
398
+ return readAdrFrontmatterScalars(text);
399
+ }
400
+
401
+ /**
402
+ * `protected_dimensions` off the shared reader's node, narrowed — not parsed.
403
+ *
404
+ * The field is written inline (`protected_dimensions: [governance]`), so the
405
+ * shared reader hands it back under `nested` as a string list. This only
406
+ * narrows that node; adding a second parser for a field one reader already
407
+ * reads is how the tree ended up with three divergent ADR parsers.
408
+ */
409
+ export function protected_dimensions_of(fm: AdrFrontmatter): string[] {
410
+ const node = fm.nested['protected_dimensions'];
411
+ if (Array.isArray(node)) return node;
412
+ if (typeof node === 'string' && node !== '') return [node];
413
+ return [];
414
+ }
415
+
416
+ /**
417
+ * Which `evidence.basis` refs are repo paths that are not there any more.
418
+ *
419
+ * Deliberately narrow. A ref is checked ONLY when it looks like a repo-relative
420
+ * path — no scheme, no `claim:` prefix. A trailing `(YYYY-MM-DD)` access note
421
+ * and a `:LINE` / `:LINE-LINE` anchor are stripped before the check, because
422
+ * both appear in the corpus and neither is part of the filename.
423
+ *
424
+ * What this does NOT do, stated because the difference is the whole point: it
425
+ * does not fetch a URL, does not resolve a `claim:` id against the claim
426
+ * registry, and does not judge whether a ref that EXISTS still supports the
427
+ * grade. "The file is present" is the only claim made.
428
+ */
429
+ export function unresolved_basis_refs(basis: readonly string[], repo_root: string): string[] {
430
+ const missing: string[] = [];
431
+ for (const ref of basis) {
432
+ if (basis_ref_kind(ref) !== 'path') continue;
433
+ if (!fs.existsSync(path.join(repo_root, basis_ref_path(ref)))) missing.push(ref);
203
434
  }
204
- return out;
435
+ return missing;
436
+ }
437
+
438
+ /** `url` and `claim` refs are printed and never checked; `path` refs are. */
439
+ export function basis_ref_kind(ref: string): 'url' | 'claim' | 'path' {
440
+ const t = ref.trim();
441
+ if (/^[a-z][a-z0-9+.-]*:\/\//i.test(t)) return 'url';
442
+ if (/^claim:/i.test(t)) return 'claim';
443
+ return 'path';
444
+ }
445
+
446
+ /** Strip a trailing `(note)` and a `:LINE[-LINE]` anchor off a path-shaped ref. */
447
+ function basis_ref_path(ref: string): string {
448
+ return ref
449
+ .trim()
450
+ .replace(/\s*\([^)]*\)\s*$/, '')
451
+ .replace(/:\d+(-\d+)?$/, '')
452
+ .trim();
205
453
  }
206
454
 
207
455
  /**
@@ -229,6 +477,14 @@ export function amendment_blocks(body: string): string[] {
229
477
  export function trigger_state(fm: Record<string, string>): TriggerState {
230
478
  const t = (fm['review_trigger'] ?? '').trim();
231
479
  if (t === '') return 'none';
480
+ // The transitional migration value records that no condition has been
481
+ // written yet, so it is `none` rather than `indeterminate`. Mapping it to
482
+ // `indeterminate` would report "a condition exists, its state is unknown"
483
+ // for a record that has no condition at all — the opposite of what the
484
+ // staging is for, and indistinguishable at a glance from a real trigger
485
+ // nobody has evaluated. Found in completion review; the field did not
486
+ // exist in this file's corpus when the mapping was written.
487
+ if (t.toLowerCase() === 'unclassified') return 'none';
232
488
  return 'indeterminate';
233
489
  }
234
490
 
@@ -305,6 +561,26 @@ export function cite_check(refs: string[], repo_root: string = REPO_ROOT): CiteR
305
561
 
306
562
  const text = contents.get(match) ?? '';
307
563
  const fm = parse_frontmatter(text) ?? {};
564
+ // The two nested axes need the structured reader; `parse_frontmatter`
565
+ // folds them back to a string for scalar-only callers. `null` here is
566
+ // the honest answer for a record predating the axes — no longer for a
567
+ // per-area record: all seven carry real frontmatter as of 2026-08-21
568
+ // and read through exactly the same path as a flat one.
569
+ const structured = readAdrFrontmatter(text);
570
+ const provenance = structured === null ? null : provenanceOf(structured);
571
+ const evidence = structured === null ? null : evidenceOf(structured);
572
+ const basis = structured === null ? null : authorityBasisOf(structured);
573
+ const evidence_basis = evidence?.basis ?? [];
574
+ // `adr-layout § Reopen authority`: absent resolves to `unclassified`,
575
+ // and deliberately NOT to `owner` — fail-closed there would encode the
576
+ // existing blockage into the schema. A record with no frontmatter at
577
+ // all resolves the same way, for the same reason.
578
+ const declared_policy = fm['reopen_policy'];
579
+ const reopen_policy =
580
+ declared_policy !== undefined && declared_policy !== '' ? declared_policy : 'unclassified';
581
+ const reopen_policy_defaulted = declared_policy === undefined || declared_policy === '';
582
+ const protected_dimensions = structured === null ? [] : protected_dimensions_of(structured);
583
+ const provisional = structured !== null && isLowEvidenceAccepted(structured);
308
584
  const bodyStart = text.indexOf('\n---\n', 4);
309
585
  const body = bodyStart === -1 ? text : text.slice(bodyStart + 5);
310
586
 
@@ -337,6 +613,29 @@ export function cite_check(refs: string[], repo_root: string = REPO_ROOT): CiteR
337
613
  ...(fm['amended_by'] !== undefined ? { amended_by: fm['amended_by'] } : {}),
338
614
  amendment_blocks: amendment_blocks(body),
339
615
  referenced_by,
616
+ ...(provenance?.kind !== undefined && provenance.kind !== null
617
+ ? { provenance_kind: provenance.kind }
618
+ : {}),
619
+ ...(provenance?.agenticMode !== undefined && provenance.agenticMode !== null
620
+ ? { provenance_agentic_mode: provenance.agenticMode }
621
+ : {}),
622
+ ...(evidence?.strength !== undefined && evidence.strength !== null
623
+ ? { evidence_strength: evidence.strength }
624
+ : {}),
625
+ ...(evidence?.discovery !== undefined && evidence.discovery !== null
626
+ ? { evidence_discovery: evidence.discovery }
627
+ : {}),
628
+ ...(evidence_basis.length > 0
629
+ ? {
630
+ evidence_basis,
631
+ evidence_basis_unresolved: unresolved_basis_refs(evidence_basis, repo_root),
632
+ }
633
+ : {}),
634
+ ...(basis !== null ? { authority_basis: basis } : {}),
635
+ reopen_policy,
636
+ reopen_policy_defaulted,
637
+ protected_dimensions,
638
+ ...(provisional ? { authority_effect: 'disabled-shadow-mode' } : {}),
340
639
  };
341
640
  return { ...partial, verdict: verdict_for(partial) };
342
641
  });
@@ -363,37 +662,230 @@ function render(results: CiteResult[]): string {
363
662
  lines.push(` amendments ${String(r.amendment_blocks.length)}: ${r.amendment_blocks.join(' · ')}`);
364
663
  if (r.referenced_by.length > 0)
365
664
  lines.push(` referenced by ${String(r.referenced_by.length)} other ADR(s): ${r.referenced_by.join(', ')}`);
665
+
666
+ // The two descriptive axes. Printed for every resolved record, absent
667
+ // or not: "the axis is missing" is itself the thing a citer needs to
668
+ // see — a record with no grade has not been assessed, which is not the
669
+ // same as a record assessed as weak.
670
+ const provenanceLine = r.provenance_kind ?? '— (no provenance axis)';
671
+ const mode = r.provenance_agentic_mode !== undefined ? ` · mode ${r.provenance_agentic_mode}` : '';
672
+ lines.push(` provenance ${provenanceLine}${mode}`);
673
+ lines.push(
674
+ ` evidence ${r.evidence_strength ?? '— (ungraded)'}` +
675
+ ` · discovery ${r.evidence_discovery ?? '—'}`,
676
+ );
677
+ lines.push(` authority_basis ${r.authority_basis ?? '— (absent → evidence)'}`);
678
+
679
+ // The basis refs, verbatim, one per line with an existence marker. A
680
+ // grade with no readable basis is the thing a citer has to see, so the
681
+ // absent case prints too rather than dropping the line.
682
+ if (r.evidence_basis === undefined || r.evidence_basis.length === 0) {
683
+ lines.push(' evidence_basis — (none recorded)');
684
+ } else {
685
+ const unresolved = new Set(r.evidence_basis_unresolved ?? []);
686
+ lines.push(
687
+ ` evidence_basis ${String(r.evidence_basis.length)} ref(s), ` +
688
+ `${String(unresolved.size)} unresolved:`,
689
+ );
690
+ for (const ref of r.evidence_basis) {
691
+ const kind = basis_ref_kind(ref);
692
+ const marker =
693
+ kind !== 'path'
694
+ ? `[not checked: ${kind}]`
695
+ : unresolved.has(ref)
696
+ ? '[MISSING]'
697
+ : '[found]';
698
+ lines.push(` ${marker} ${ref}`);
699
+ }
700
+ }
701
+
702
+ // The two reserved-authority fields, printed together because the
703
+ // routing question reads them together. `protected_dimensions` names
704
+ // what the RECORD reserves; whether a given transition weakens one is a
705
+ // property of the proposal and is nothing this tool can print.
706
+ const policyNote = r.reopen_policy_defaulted === true ? ' (absent → default)' : ' (declared)';
707
+ const dims =
708
+ r.protected_dimensions !== undefined && r.protected_dimensions.length > 0
709
+ ? r.protected_dimensions.join(', ')
710
+ : '— (none declared)';
711
+ lines.push(` reopen_policy ${r.reopen_policy ?? 'unclassified'}${policyNote}`);
712
+ lines.push(` protected dims ${dims}`);
366
713
  lines.push(` → ${r.verdict}`);
714
+ // Verbatim + unindented, on purpose: see LOW_EVIDENCE_NOTICE.
715
+ if (r.authority_effect === 'disabled-shadow-mode') lines.push(LOW_EVIDENCE_NOTICE);
367
716
  }
368
717
  return lines.join('\n');
369
718
  }
370
719
 
720
+
721
+ /**
722
+ * `--self-test` — drives the REAL binary over synthetic trees.
723
+ *
724
+ * Not optional for a new gate: `gate-self-test:registered-non-adopters`
725
+ * (`check_gate_coverage.list_self_test_non_adopters`) is a shrink-only count
726
+ * over the enforced manifest, so registering this gate without a self-test
727
+ * would raise it and red CI. That ratchet is working as designed — the cheapest
728
+ * moment to prove a gate discriminates is when it is written, and an enforced
729
+ * `scanned:` floor only proves the gate READ something.
730
+ *
731
+ * Both reject cases were run by hand before they were written down, and both
732
+ * were seen red: a planted `ADR-993` citation exited 1, and a citation root
733
+ * with no ADR reference in it exited 1 through `DeadScopeError`. A case never
734
+ * seen red has unknown sensitivity.
735
+ */
736
+ export function selfTest(): number {
737
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'acc-'));
738
+
739
+ /** Build a tree with one flat ADR and one citing rule; return its root. */
740
+ const mk = (adr: string | null, citation: string | null): string => {
741
+ const root = fs.mkdtempSync(path.join(tmp, 'root-'));
742
+ fs.mkdirSync(path.join(root, 'docs', 'decisions'), { recursive: true });
743
+ fs.mkdirSync(path.join(root, 'src', 'rules'), { recursive: true });
744
+ if (adr !== null) {
745
+ fs.writeFileSync(
746
+ path.join(root, 'docs', 'decisions', `ADR-${adr}-probe.md`),
747
+ `---\nstatus: accepted\ndate: 2026-08-21\ndecision: probe\n---\n\n# Probe\n`,
748
+ 'utf-8',
749
+ );
750
+ }
751
+ fs.writeFileSync(
752
+ path.join(root, 'src', 'rules', 'probe.md'),
753
+ citation === null ? '# Probe\n\nNo decision is named here.\n' : `# Probe\n\nSee ${citation}.\n`,
754
+ 'utf-8',
755
+ );
756
+ return root;
757
+ };
758
+
759
+ const run = (root: string): number => {
760
+ process.env['ADR_CITE_CHECK_ROOT'] = root;
761
+ try {
762
+ return runGateCli(REPO_ROOT, 'src/scripts/adr_cite_check.ts', ['--cited'], REPO_ROOT);
763
+ } finally {
764
+ delete process.env['ADR_CITE_CHECK_ROOT'];
765
+ }
766
+ };
767
+
768
+ try {
769
+ return runSelfTest({
770
+ gate: 'adr_cite_check',
771
+ minCases: 3,
772
+ minRejectCases: 2,
773
+ cases: [
774
+ {
775
+ name: 'a citation whose ADR exists passes',
776
+ expect: 'accept',
777
+ run: () => run(mk('001', 'ADR-001')),
778
+ },
779
+ {
780
+ name: 'a citation to an ADR that does not exist is rejected — the gate\'s whole subject',
781
+ expect: 'reject',
782
+ run: () => run(mk('001', 'ADR-993')),
783
+ },
784
+ {
785
+ name: 'citation roots that name no ADR at all are rejected as a dead scope, never green',
786
+ expect: 'reject',
787
+ run: () => run(mk('001', null)),
788
+ },
789
+ ],
790
+ });
791
+ } finally {
792
+ fs.rmSync(tmp, { recursive: true, force: true });
793
+ }
794
+ }
795
+
371
796
  function main(argv: string[]): number {
372
797
  const as_json = argv.includes('--json');
373
- const refs = argv.filter((a) => !a.startsWith('--'));
374
- if (refs.length === 0) {
798
+ if (argv.includes('--self-test')) return selfTest();
799
+ const corpus_mode = argv.includes('--cited');
800
+ const explicit = argv.filter((a) => !a.startsWith('--'));
801
+ if (!corpus_mode && explicit.length === 0) {
375
802
  process.stderr.write(
376
803
  'usage: adr_cite_check <ADR-NNN> [ADR-NNN …] [--json]\n' +
377
- ' evaluate a decision before citing it as a reason not to act\n',
804
+ ' adr_cite_check --cited [--json]\n' +
805
+ ' evaluate a decision before citing it as a reason not to act\n' +
806
+ ' --cited: resolve every ADR citation in ' +
807
+ `${CITATION_ROOTS.join(', ')} (the CI gate)\n`,
378
808
  );
379
809
  return 2;
380
810
  }
381
811
 
382
- const results = cite_check(refs);
812
+ const root = scan_root();
813
+ const refs = corpus_mode ? cited_refs(root) : explicit;
814
+ const results = cite_check(refs, root);
815
+
816
+ // The ledger and the `scanned:` line exist only in corpus mode, and the
817
+ // asymmetry is deliberate. In explicit-ref mode the refs come from argv, so
818
+ // there is no scan scope to assert and a published count would be a floor
819
+ // under a constant — the false-count shape `gate-coverage.yml` rejects. In
820
+ // corpus mode the denominator is real: it collapses to 0 if either the
821
+ // citation roots or the decision surfaces move, which is exactly the
822
+ // dead-scan-root failure the assertion is for.
823
+ let ledger: GateLedger | null = null;
824
+ if (corpus_mode) {
825
+ ledger = new GateLedger('adr_cite_check');
826
+ ledger.plan(refs);
827
+ for (const r of results) {
828
+ if (r.resolved) ledger.complete(r.ref);
829
+ else ledger.fail(r.ref, 'citation resolves to no decision record');
830
+ }
831
+ }
832
+
383
833
  if (as_json) {
834
+ // The tally rides in the payload rather than on stdout: printing the
835
+ // ledger line beside the JSON would break every parser of this mode.
384
836
  process.stdout.write(
385
837
  JSON.stringify(
386
838
  {
387
839
  results,
388
840
  surfaces_not_scanned: SURFACES_NOT_SCANNED,
389
841
  partial_coverage: PARTIAL_COVERAGE,
842
+ ...(ledger !== null ? { ledger: ledger.finalize() } : {}),
390
843
  },
391
844
  null,
392
845
  2,
393
846
  ) + '\n',
394
847
  );
395
848
  } else {
396
- process.stdout.write(render(results) + '\n');
849
+ // Corpus mode renders only the unresolved records. The full per-record
850
+ // surfacing is 178 records × ~10 lines today — a CI step that prints
851
+ // 1,900 lines of green hides the one red line that matters, and the
852
+ // surfacing exists for a human citing ONE decision, not for the gate.
853
+ // `--json` still carries every record for a machine consumer.
854
+ const shown = corpus_mode ? results.filter((r) => !r.resolved) : results;
855
+ if (corpus_mode) {
856
+ process.stdout.write(
857
+ `ADR citations: ${String(results.length)} distinct reference(s) across ` +
858
+ `${CITATION_ROOTS.join(', ')} · ` +
859
+ `${String(results.filter((r) => !r.resolved).length)} unresolved\n`,
860
+ );
861
+ }
862
+ if (shown.length > 0) process.stdout.write(render(shown) + '\n');
863
+ if (ledger !== null) {
864
+ try {
865
+ // The count is references RESOLVED AGAINST the corpus, not the
866
+ // subset that resolved successfully — deliberately the same
867
+ // number the ledger's own `scanned=` reports, because two
868
+ // different values printed under the same word is the drift
869
+ // `reportScanned` was written to prevent. It still guards both
870
+ // roots: a moved citation root drops this to 0 and throws
871
+ // here, and a moved decision surface makes every reference
872
+ // unresolved, which reds on exit 1 below with 178 failures in
873
+ // the ledger. Neither failure can present as green.
874
+ reportScanned({
875
+ gate: 'adr_cite_check',
876
+ scanned: results.length,
877
+ units: 'cited ADR reference(s)',
878
+ roots: [...CITATION_ROOTS, ...ADR_DIRS],
879
+ });
880
+ } catch (exc) {
881
+ if (exc instanceof DeadScopeError) {
882
+ process.stderr.write(`❌ ${exc.message}\n`);
883
+ return 1;
884
+ }
885
+ throw exc;
886
+ }
887
+ ledger.report();
888
+ }
397
889
  }
398
890
 
399
891
  const unresolved = results.filter((r) => !r.resolved);
@@ -408,17 +900,26 @@ function main(argv: string[]): number {
408
900
  }
409
901
 
410
902
  // Main-guard (realpath-compared, mirrors the repo convention).
903
+ //
904
+ // `process.exitCode`, never `process.exit()` — and the difference is a real
905
+ // defect this change would otherwise have shipped. `--cited --json` writes a
906
+ // ~167 KB payload; `process.exit()` tears the process down before Node has
907
+ // flushed an async stdout, so piping that mode into a parser delivered exactly
908
+ // 65,536 bytes and a JSONDecodeError, while redirecting to a file delivered all
909
+ // of it. Measured before the fix, on this branch. Setting the code and letting
910
+ // the process end naturally flushes first. Twenty-odd sibling gates already use
911
+ // this shape (`audit_skill_overlap`, `apply_modules_config`, …).
411
912
  if (process.argv[1] !== undefined) {
412
913
  try {
413
914
  const here = fs.realpathSync(fileURLToPath(import.meta.url));
414
915
  const argv1 = fs.realpathSync(path.resolve(process.argv[1]));
415
916
  if (here === argv1) {
416
- process.exit(main(process.argv.slice(2)));
917
+ process.exitCode = main(process.argv.slice(2));
417
918
  }
418
919
  } catch {
419
920
  const argvUrl = pathToFileURL(path.resolve(process.argv[1])).href;
420
921
  if (import.meta.url === argvUrl) {
421
- process.exit(main(process.argv.slice(2)));
922
+ process.exitCode = main(process.argv.slice(2));
422
923
  }
423
924
  }
424
925
  }