@event4u/agent-config 14.5.0 → 14.6.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 (45) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/CHANGELOG.md +128 -0
  3. package/dist/agent-src/commands/analyze/decision.md +7 -0
  4. package/dist/agent-src/commands/optimize/project.md +10 -0
  5. package/dist/agent-src/commands/roadmap/process-full.md +45 -3
  6. package/dist/agent-src/contexts/execution/roadmap-process-loop.md +130 -1
  7. package/dist/agent-src/rules/decision-revisit-gate.md +108 -7
  8. package/dist/agent-src/skills/adr-create/SKILL.md +25 -3
  9. package/dist/agent-src/skills/decision-review/SKILL.md +18 -0
  10. package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
  11. package/dist/cli-delegate/{chunk-ZPSXJTLI.js → chunk-P6LQDIWK.js} +15 -1
  12. package/dist/cli-delegate/cmd_conformance.js +1 -1
  13. package/dist/cli-delegate/cmd_handoff.js +1 -1
  14. package/dist/cli-delegate/cmd_session_recycle.js +1 -1
  15. package/dist/discovery/deprecation-report.md +1 -1
  16. package/dist/discovery/discovery-manifest.json +8 -8
  17. package/dist/discovery/discovery-manifest.json.sha256 +1 -1
  18. package/dist/discovery/discovery-manifest.summary.md +1 -1
  19. package/dist/discovery/dormancy-report.md +1 -1
  20. package/dist/discovery/orphan-report.md +1 -1
  21. package/dist/discovery/packs.json +2 -2
  22. package/dist/discovery/trust-report.md +1 -1
  23. package/dist/discovery/workspaces.json +2 -2
  24. package/dist/hooks/dispatch.js +334 -77
  25. package/dist/mcp/registry-manifest.json +2 -2
  26. package/dist/mcp/server.json +2 -2
  27. package/dist/router.json +1 -1
  28. package/docs/guidelines/agent-infra/engineering-memory-data-format.md +49 -0
  29. package/package.json +1 -1
  30. package/src/config/estate-count-budget.json +29 -1
  31. package/src/config/pack-size-budget.json +3 -2
  32. package/src/scripts/_lib/layer_overlap_notice.ts +85 -0
  33. package/src/scripts/_lib/scope_guard.sh +62 -3
  34. package/src/scripts/_lib/subagent_capsule.ts +81 -1
  35. package/src/scripts/adr/regenerate_index.ts +13 -2
  36. package/src/scripts/adr_cite_check.ts +424 -0
  37. package/src/scripts/check_adr_frontmatter.ts +187 -1
  38. package/src/scripts/check_single_delivery.ts +473 -0
  39. package/src/scripts/hooks/run_continuation_hook.ts +884 -68
  40. package/src/scripts/install.sh +12 -2
  41. package/src/scripts/memory_consolidation.ts +189 -0
  42. package/src/scripts/memory_eviction.ts +384 -0
  43. package/src/scripts/report_layer_overlap.ts +72 -0
  44. package/src/scripts/report_memory_pointers.ts +622 -0
  45. package/src/scripts/session_register_hook.ts +104 -15
@@ -12,13 +12,29 @@
12
12
  # Verdicts (per the roadmap contract):
13
13
  # OK — no install at the other scope; the install can proceed.
14
14
  # WARN — install at the other scope, SAME version as the one being installed.
15
- # Same content; duplicate registration but no drift.
15
+ # No drift — and NOT free. The host loads both registrations with no
16
+ # dedup, so every shared artefact is delivered twice. This verdict used
17
+ # to read "Same content; duplicate registration but no drift", which
18
+ # classified duplication by DRIFT RISK and never by CONTEXT COST, and
19
+ # so taught every reader that identical copies cost nothing. Measured
20
+ # 2026-08-19 on a freshly regenerated maintainer projection: 110 rules
21
+ # and 290 skills delivered twice, 203,873 tok of standing rule prose
22
+ # against a 110,000 cap. See ADR-236 and
23
+ # agents/evidence/analysis/single-delivery-partition-census.md.
16
24
  # DRIFT — install at the other scope, DIFFERENT version (or unreadable).
17
25
  # Drift will produce the 2026-05-25 failure mode (stale frontmatter
18
26
  # registered alongside fresh frontmatter).
19
27
  #
20
28
  # Output shape (line-oriented, parseable by install.sh):
21
- # <verdict>\t<tool-id>\t<other-scope-path>\t<other-version>\t<this-version>
29
+ # <verdict>\t<tool-id>\t<other-scope-path>\t<other-version>\t<this-version>[\t<overlap>]
30
+ #
31
+ # Field 6 (<overlap>) is emitted on WARN lines only: the number of entry names
32
+ # present in BOTH scope directories — what a same-version duplicate registration
33
+ # actually delivers twice. It is APPENDED rather than inserted, so every existing
34
+ # field-indexed consumer keeps working unchanged. `-1` means the count could not
35
+ # be taken — the scope being installed to does not exist yet, or a directory is
36
+ # unreadable — which is reported rather than silently rendered as 0. See
37
+ # `count_overlap` for why the two are different facts.
22
38
  #
23
39
  # A final summary line is emitted:
24
40
  # SUMMARY\t<verdict>\t<count-OK>\t<count-WARN>\t<count-DRIFT>
@@ -67,6 +83,44 @@ dir_nonempty() {
67
83
  [[ -d "$1" ]] && [[ -n "$(ls -A "$1" 2>/dev/null | head -1)" ]]
68
84
  }
69
85
 
86
+ # Count entry names present in BOTH scope directories — what a same-version
87
+ # duplicate registration actually delivers twice, which is the cost the WARN
88
+ # verdict used to omit.
89
+ #
90
+ # Returns -1 rather than 0 when the count cannot be taken, because the two are
91
+ # different facts and reporting "0 overlap" for "could not look" is how a gate
92
+ # starts reading as coverage.
93
+ #
94
+ # R2 review corrected the docstring twice over. The copilot probe was named here
95
+ # as the concrete -1 case; it is UNREACHABLE, because `dir_nonempty`'s `[[ -d ]]`
96
+ # test short-circuits a single-file path to OK before probe_tool ever reaches a
97
+ # version comparison. The reachable cases are: the scope being installed TO does
98
+ # not exist yet (a first install at this scope — genuinely no overlap to count,
99
+ # not a failure), and a directory that exists but cannot be read.
100
+ #
101
+ # The unreadable case used to return 0 — the precise failure this function's own
102
+ # docstring claimed to prevent, because `cd` failed inside the process
103
+ # substitution and `comm` compared two empty streams. It is now detected before
104
+ # the comparison.
105
+ count_overlap() {
106
+ local a="$1" b="$2" n
107
+ if [[ ! -d "$a" ]] || [[ ! -d "$b" ]]; then
108
+ printf '%s' '-1'
109
+ return 0
110
+ fi
111
+ # Readability is checked explicitly: `ls` on an unreadable directory fails,
112
+ # and inside `<(...)` that failure is invisible to the caller.
113
+ if ! ls -A "$a" >/dev/null 2>&1 || ! ls -A "$b" >/dev/null 2>&1; then
114
+ printf '%s' '-1'
115
+ return 0
116
+ fi
117
+ n="$(comm -12 \
118
+ <(ls -A "$a" 2>/dev/null | sort) \
119
+ <(ls -A "$b" 2>/dev/null | sort) \
120
+ 2>/dev/null | wc -l | tr -d ' ')" || n=''
121
+ printf '%s' "${n:--1}"
122
+ }
123
+
70
124
  # Per-tool probe definitions: tool-id, user-scope path, project-scope path.
71
125
  # Project-scope path is relative to "$TARGET_DIR" (or "$HOME/<proj>" for user).
72
126
  probe_tool() {
@@ -114,7 +168,12 @@ probe_tool() {
114
168
  if [[ "$other_ver" == "unknown" ]] || [[ "$this_ver" == "unknown" ]] || [[ "$other_ver" != "$this_ver" ]]; then
115
169
  printf 'DRIFT\t%s\t%s\t%s\t%s\n' "$tool" "$other_path" "$other_ver" "$this_ver"
116
170
  else
117
- printf 'WARN\t%s\t%s\t%s\t%s\n' "$tool" "$other_path" "$other_ver" "$this_ver"
171
+ # Same version, so no drift — but the duplicate registration is delivered
172
+ # twice, and field 6 is what makes that cost visible at the moment of the
173
+ # decision instead of only in a token census nobody runs.
174
+ local overlap
175
+ overlap="$(count_overlap "$other_path" "$this_scope_path")"
176
+ printf 'WARN\t%s\t%s\t%s\t%s\t%s\n' "$tool" "$other_path" "$other_ver" "$this_ver" "$overlap"
118
177
  fi
119
178
  }
120
179
 
@@ -120,6 +120,76 @@ function isShortLine(value: unknown, max: number): value is string {
120
120
  return typeof value === 'string' && value.length > 0 && value.length <= max && !value.includes('\n');
121
121
  }
122
122
 
123
+ /**
124
+ * Is this a path ref rather than prose?
125
+ *
126
+ * The discriminator is **whitespace**, and it is deliberately the only one.
127
+ * A path ref is one token; a prose sentence is several words. Anything
128
+ * narrower — a character class, a required extension, a mandatory `/` —
129
+ * rejects entries this tree really uses: a bare directory (`docs`), a sibling
130
+ * worktree (`../other-worktree/`), a dotfile. Anything wider stops telling the
131
+ * two shapes apart at all.
132
+ *
133
+ * This is a SHAPE check, never an existence or semantics check. A single-token
134
+ * value that is not a real path passes and then simply never matches anything,
135
+ * which is correct: the failure this closes is a field whose own documentation
136
+ * says "a list of path refs" silently accepting full sentences.
137
+ *
138
+ * **It is not a matcher, and a consumer must not mistake it for one.** It
139
+ * answers "is this one token", never "does this entry cover that write target".
140
+ * It admits a bare directory, a trailing-slash relative ref, a glob and a
141
+ * `file:line` ref; normalisation, directory-prefix matching and glob matching
142
+ * are deliberately unspecified here, because they are a separate decision that
143
+ * belongs to whatever ships the comparison. Reuse this for what VALIDATES;
144
+ * decide separately what MATCHES.
145
+ *
146
+ * **Stated default, not a measured optimum:** zero tracked paths in this repo
147
+ * contain whitespace, so the rule costs nothing here. *Revisit-if* a consumer
148
+ * legitimately needs a path containing a space — that needs a quoting
149
+ * convention, not a wider character class.
150
+ */
151
+ export function isPathRef(value: unknown): value is string {
152
+ return typeof value === 'string' && value.length > 0 && !/\s/.test(value);
153
+ }
154
+
155
+ /**
156
+ * `checkList` plus the path-ref shape, for a field whose own documentation
157
+ * calls its entries path refs. Names the offending entries: "one of your
158
+ * twelve entries is wrong" is not an actionable error.
159
+ *
160
+ * The shape report is suppressed for exactly one reason — the same entries
161
+ * already failed the per-entry budget, where reporting both says the same thing
162
+ * twice. It is deliberately NOT suppressed by the entry-COUNT error, which is
163
+ * orthogonal: a 41-entry list of prose sentences must report both problems in
164
+ * one pass, or the author trims to 40, re-validates, and only then learns the
165
+ * entries were prose — an extra refuse-and-repair round-trip on the write path
166
+ * this contract points at as the repair mechanism.
167
+ */
168
+ function checkPathRefList(
169
+ errors: string[],
170
+ field: string,
171
+ value: unknown,
172
+ max: number,
173
+ required: boolean,
174
+ ): void {
175
+ checkList(errors, field, value, max, required);
176
+ if (!Array.isArray(value)) return;
177
+ // Same-entry duplicate only: an entry that failed the budget is already
178
+ // reported, and its shape adds nothing a reader can act on separately.
179
+ if (value.some((x) => !isShortLine(x, max))) return;
180
+
181
+ const bad = value.filter((x) => !isPathRef(x));
182
+ if (bad.length === 0) return;
183
+
184
+ const shown = bad.slice(0, 3).map((x) => JSON.stringify(String(x))).join(', ');
185
+ errors.push(
186
+ `${field} carries ${bad.length} entr${bad.length === 1 ? 'y' : 'ies'} that are prose, not path refs: ` +
187
+ `${shown}${bad.length > 3 ? ', …' : ''} — write the path (src/generated/api.ts, ../other-worktree/) ` +
188
+ 'or drop the entry; a sentence naming a file cannot be compared against a write target, and being ' +
189
+ 'comparable is the whole reason this is a field rather than a line of prose',
190
+ );
191
+ }
192
+
123
193
  function checkList(
124
194
  errors: string[],
125
195
  field: string,
@@ -257,6 +327,14 @@ export interface MainSessionRecycleEnvelope {
257
327
  * adjudication set (a field whose presence is checkable survives where a
258
328
  * doctrine whose effect is unmeasured did not).
259
329
  *
330
+ * The path-ref shape is **enforced** (`isPathRef`), not merely documented.
331
+ * It was documented-only until 2026-08-19, and the gap was not academic:
332
+ * the validator applied the ref CHARACTER BUDGET and no shape rule, so the
333
+ * one real non-empty producer — a composing model — wrote prose sentences
334
+ * naming files, and every one of them validated. A field that accepts
335
+ * whatever prose the writer felt like is `constraints` with extra steps,
336
+ * and nothing downstream can compare it to a write target.
337
+ *
260
338
  * Optional: an envelope with nothing off limits states nothing here, and an
261
339
  * empty list is not a claim that everything is writable.
262
340
  */
@@ -596,7 +674,9 @@ export function validateRecycleEnvelope(input: unknown): string[] {
596
674
  errors.push(...decisionTagErrors(e['decisions']));
597
675
  checkList(errors, 'constraints', e['constraints'], MAX_LINE_CHARS, false);
598
676
  // Path refs, so the REF budget applies — not the prose one `constraints` uses.
599
- checkList(errors, 'do_not_touch', e['do_not_touch'], MAX_REF_CHARS, false);
677
+ // And the SHAPE is checked, not only the budget: the budget alone accepted a
678
+ // prose sentence naming a file, which is what the only real producer wrote.
679
+ checkPathRefList(errors, 'do_not_touch', e['do_not_touch'], MAX_REF_CHARS, false);
600
680
  checkList(errors, 'open_worker_envelopes', e['open_worker_envelopes'], MAX_REF_CHARS, false);
601
681
  checkList(errors, 'artifact_paths', e['artifact_paths'], MAX_REF_CHARS, false);
602
682
  checkList(errors, 'suggested_skills', e['suggested_skills'], MAX_REF_CHARS, false);
@@ -23,7 +23,17 @@ const NAMED = /^ADR-(\d{3})-([a-z0-9-]+)\.md$/;
23
23
  const FM = /^---\n([\s\S]*?)\n---/;
24
24
  // ^([a-z_]+):\s*(.+?)\s*$ (MULTILINE)
25
25
  const FIELD = /^([a-z_]+):[ \t]*(.+?)[ \t]*$/gm;
26
- const HEAD = '| # | Title | Status | Date | Supersedes |\n|---|---|---|---|---|';
26
+ /**
27
+ * The index used to render `| # | Title | Status | Date | Supersedes |`, which
28
+ * is forward-only: a dead ADR showed `superseded | —` and the reader learned it
29
+ * was dead but not by what. `superseded_by` was read nowhere despite being a
30
+ * documented field, and `amended_by` had no representation at all — 18 ADRs
31
+ * carry an amendment block and the index could not distinguish one with five
32
+ * amendments from one never touched.
33
+ */
34
+ const HEAD =
35
+ '| # | Title | Status | Date | Supersedes | Superseded by | Amended by |\n' +
36
+ '|---|---|---|---|---|---|---|';
27
37
 
28
38
  type Meta = Record<string, string>;
29
39
  interface Row extends Meta {
@@ -182,7 +192,8 @@ export function row(r: Row): string {
182
192
  const label = 'num' in r ? `ADR-${r.num}` : (r.path as string).slice(0, -3);
183
193
  return (
184
194
  `| [${label}](${r.path}) | ${title} | ${r.status ?? '—'} ` +
185
- `| ${r.date ?? '—'} | ${r.supersedes ?? '—'} |`
195
+ `| ${r.date ?? '—'} | ${r.supersedes ?? '—'} | ${r.superseded_by ?? '—'} ` +
196
+ `| ${r.amended_by ?? '—'} |`
186
197
  );
187
198
  }
188
199
 
@@ -0,0 +1,424 @@
1
+ #!/usr/bin/env tsx
2
+ /**
3
+ * ADR cite-check — evaluate a decision BEFORE citing it as a reason not to act.
4
+ *
5
+ * The defect this closes is one of ordering, not of permission. Measured across
6
+ * 26 days of transcripts: in at least 13 passages a change was parked or refused
7
+ * citing a named ADR, and in zero cases did the agent refuse an explicit
8
+ * overturn instruction. The lock held, the owner had to notice and void it
9
+ * retroactively, and only then did the work resume. Nothing in the tree turned
10
+ * "this ADR blocks the change" into "is this ADR still true?".
11
+ *
12
+ * So this tool answers exactly that question, and only from what is mechanically
13
+ * decidable:
14
+ *
15
+ * - **status** — a `superseded` / `deprecated` ADR is not a live lock at all.
16
+ * This alone resolves a class of blockage with certainty. `rejected` is
17
+ * deliberately excluded: on an ADR it records a rejected PROPOSAL, so the
18
+ * rejection is the live decision.
19
+ * - **successors and predecessors** — `superseded_by` / `supersedes`, plus the
20
+ * `amended_by` / `amends` pair.
21
+ * - **amendment blocks in the body** — three unreconciled conventions exist in
22
+ * the corpus, so all three are matched.
23
+ * - **back-references** — other ADRs that name this one. A decision reopened
24
+ * by a later ADR that never linked back is invisible from the ADR's own file
25
+ * (measured: ADR-035 still asserts a rejection ADR-232 reopened).
26
+ * - **review_trigger** — verbatim, plus a state.
27
+ *
28
+ * `indeterminate` is a FIRST-CLASS trigger result, not a failure. Every
29
+ * `review_trigger` in the corpus is a semantic condition ("reopen when the
30
+ * capacity premise changes", "if a fifth band appears"), so forcing them to a
31
+ * boolean would convert uncertainty into either permission or blockage. Both
32
+ * council seats (2026-08-19) independently rejected building a machine-readable
33
+ * trigger grammar as a prerequisite for exactly this reason. An `indeterminate`
34
+ * result means: this may not be presented as an unqualified lock — route it.
35
+ *
36
+ * Usage:
37
+ * ./scripts-run src/scripts/adr_cite_check ADR-211
38
+ * ./scripts-run src/scripts/adr_cite_check ADR-001 ADR-035 --json
39
+ *
40
+ * Exit codes: 0 every reference resolved · 1 at least one did not · 2 usage.
41
+ */
42
+
43
+ import * as fs from 'node:fs';
44
+ import * as path from 'node:path';
45
+ import { fileURLToPath, pathToFileURL } from 'node:url';
46
+
47
+ const _HERE = path.dirname(fileURLToPath(import.meta.url));
48
+ const REPO_ROOT = path.resolve(_HERE, '..', '..');
49
+
50
+ /**
51
+ * The surfaces a decision can live on. Six exist in this tree; this tool reads
52
+ * the two that carry numbered ADRs and can therefore be cited as `ADR-NNN`.
53
+ *
54
+ * Deliberately NOT covered, stated rather than silently skipped:
55
+ * `docs/contracts/adr-*.md` (15 files) and `agents/settings/contexts/adr-*.md`
56
+ * (6) are named-slug contracts with their own status vocabulary (`Decided`,
57
+ * `locked`) and no ADR number, so a numeric reference can never resolve to one;
58
+ * `agents/decisions/` is a ledger of rows, not of files. A tool that silently
59
+ * saw one of six surfaces would report a real lock as unknown, which reads as
60
+ * "no constraint" and is worse than the stall it replaces — so the gap is
61
+ * printed in `--json` under `surfaces_not_scanned` and named here.
62
+ *
63
+ * The per-area surface took two passes to get honest. Its files are
64
+ * `<area>/NNNN-<slug>.md` with no `ADR-` prefix, so the flat filename pattern
65
+ * could never match one — the directory was listed as scanned and resolved
66
+ * nothing, which is the same false "not found" the paragraph above warns
67
+ * about, committed by this very file. It resolves by path and by the
68
+ * `ADR-<area>-NNNN` citation form now; a BARE number still cannot address one,
69
+ * 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.
71
+ */
72
+ export const ADR_DIRS = ['docs/decisions', 'docs/adrs'] as const;
73
+
74
+ export const SURFACES_NOT_SCANNED = [
75
+ 'docs/contracts/adr-*.md — slug-named contracts, no ADR number to resolve against',
76
+ 'agents/settings/contexts/adr-*.md — shadow notes, own status vocabulary',
77
+ 'agents/decisions/ — row ledger, not per-decision files',
78
+ ] as const;
79
+
80
+ /**
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.
88
+ */
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;
92
+
93
+ /** A trigger state. `none` means the ADR never recorded a reopen condition. */
94
+ export type TriggerState = 'none' | 'indeterminate' | 'fired' | 'not-fired';
95
+
96
+ export interface CiteResult {
97
+ ref: string;
98
+ resolved: boolean;
99
+ file?: string;
100
+ status?: string;
101
+ date?: string;
102
+ decision?: string;
103
+ review_trigger?: string;
104
+ trigger_state: TriggerState;
105
+ supersedes?: string;
106
+ superseded_by?: string;
107
+ amends?: string;
108
+ amended_by?: string;
109
+ /** Amendment headings found in the body, any of the three conventions. */
110
+ amendment_blocks: string[];
111
+ /** Other ADR files whose text names this one. */
112
+ referenced_by: string[];
113
+ /** Why this may or may not be cited as a live lock. */
114
+ verdict: string;
115
+ }
116
+
117
+ /**
118
+ * A parsed citation. `area` is null for the flat surface.
119
+ *
120
+ * Per-area numbering restarts at `0001` inside every area, so a bare number
121
+ * cannot address one: five files in this tree are `0001`. A per-area citation
122
+ * must therefore name its area — `ADR-cost-0001`, the form `adr-layout.md`
123
+ * already specifies for cross-surface `supersedes:` values.
124
+ */
125
+ export interface ParsedRef {
126
+ id: string;
127
+ area: string | null;
128
+ num: string;
129
+ }
130
+
131
+ /**
132
+ * `ADR-211` · `adr-211` · `211` · a path → the flat surface.
133
+ * `ADR-cost-0001` · a `docs/adrs/<area>/NNNN-…` path → that area.
134
+ */
135
+ export function normalise_ref(ref: string): ParsedRef | null {
136
+ const raw = ref.trim();
137
+
138
+ // A per-area path carries its area in the directory, and its file never
139
+ // has an `ADR-` prefix — `docs/adrs/cost/0001-hard-stop-hook.md`.
140
+ const byPath = /(?:^|\/)docs\/adrs\/([a-z0-9-]+)\/(\d{1,4})-/i.exec(raw);
141
+ if (byPath?.[1] !== undefined && byPath[2] !== undefined) {
142
+ const num = byPath[2].padStart(4, '0');
143
+ return { id: `ADR-${byPath[1]}-${num}`, area: byPath[1], num };
144
+ }
145
+
146
+ // `ADR-<area>-NNNN` — the citation form for a per-area decision.
147
+ const byArea = /^adr-([a-z][a-z0-9-]*?)-(\d{1,4})$/i.exec(raw.replace(/\.md$/i, ''));
148
+ if (byArea?.[1] !== undefined && byArea[2] !== undefined) {
149
+ const area = byArea[1].toLowerCase();
150
+ const num = byArea[2].padStart(4, '0');
151
+ return { id: `ADR-${area}-${num}`, area, num };
152
+ }
153
+
154
+ const m = /(?:^|[^0-9])(\d{1,4})(?:[^0-9]|$)/.exec(raw.replace(/^.*\//, ''));
155
+ if (m?.[1] === undefined) return null;
156
+ return { id: `ADR-${m[1].padStart(3, '0')}`, area: null, num: String(Number(m[1])) };
157
+ }
158
+
159
+ function walk_md(dir: string): string[] {
160
+ if (!fs.existsSync(dir)) return [];
161
+ const out: string[] = [];
162
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
163
+ const full = path.join(dir, entry.name);
164
+ if (entry.isDirectory()) out.push(...walk_md(full));
165
+ // INDEX.md is generated FROM these files and names every one of them, so
166
+ // leaving it in makes every ADR look referenced by one more decision
167
+ // than it is — noise that hides the single real back-reference.
168
+ else if (entry.name.endsWith('.md') && entry.name !== 'INDEX.md') out.push(full);
169
+ }
170
+ return out.sort();
171
+ }
172
+
173
+ /** Every candidate decision file across the scanned surfaces. */
174
+ export function adr_files(repo_root: string = REPO_ROOT): string[] {
175
+ return ADR_DIRS.flatMap((d) => walk_md(path.join(repo_root, d)));
176
+ }
177
+
178
+ /**
179
+ * Minimal frontmatter reader — folded (`>-`) values are joined, which the
180
+ * corpus needs: every `review_trigger` in it is a folded multi-line string.
181
+ */
182
+ 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(/^>-?$/, '');
203
+ }
204
+ return out;
205
+ }
206
+
207
+ /**
208
+ * The three amendment conventions the corpus actually uses. No convention is
209
+ * canonical yet, so all three are matched rather than one being assumed.
210
+ */
211
+ export function amendment_blocks(body: string): string[] {
212
+ const found: string[] = [];
213
+ for (const line of body.split('\n')) {
214
+ if (/^#{2,3}\s+Amendment\b/i.test(line) || /^\*\*Amended\b/i.test(line)) {
215
+ found.push(line.trim().replace(/^#+\s*/, '').replace(/\*\*/g, ''));
216
+ }
217
+ }
218
+ return found;
219
+ }
220
+
221
+ /**
222
+ * A trigger's state, from what is mechanically decidable and nothing else.
223
+ *
224
+ * There is no clause that returns `fired` or `not-fired` from prose, and that
225
+ * absence is the design: every trigger in the corpus is semantic. The states
226
+ * exist in the type because a future structured trigger can carry them; today
227
+ * the honest answers are `none` and `indeterminate`.
228
+ */
229
+ export function trigger_state(fm: Record<string, string>): TriggerState {
230
+ const t = (fm['review_trigger'] ?? '').trim();
231
+ if (t === '') return 'none';
232
+ return 'indeterminate';
233
+ }
234
+
235
+ /** The one-line answer to "may I cite this as a reason not to act?". */
236
+ export function verdict_for(r: Omit<CiteResult, 'verdict'>): string {
237
+ if (!r.resolved) return 'UNRESOLVED — no ADR file carries this number on a scanned surface.';
238
+ const status = (r.status ?? '').toLowerCase();
239
+ // `rejected` is deliberately NOT in this set. On an ADR it means "the
240
+ // proposal was rejected" — the record is a LIVE lock stating that
241
+ // rejection, not a dead decision. Folding it in with `superseded` would
242
+ // make the tool clear a lock that still binds, which is a worse failure
243
+ // than the stall it replaces. Found by running this tool over the eleven
244
+ // ADRs the transcripts show blocking work: ADR-054 came back "not a live
245
+ // lock" and it is one.
246
+ if (status === 'superseded' || status === 'deprecated') {
247
+ const by = r.superseded_by !== undefined && r.superseded_by !== '—' ? ` (by ${r.superseded_by})` : '';
248
+ return `NOT A LIVE LOCK — status is \`${status}\`${by}. Citing it as a blocker is a stale-state claim.`;
249
+ }
250
+ if (status === 'rejected') {
251
+ return (
252
+ 'LIVE — status `rejected` records a REJECTED PROPOSAL, so the rejection is the ' +
253
+ 'decision and it still binds. Reopening it means showing the premise it rejected on ' +
254
+ 'has changed; it does not mean the record is stale.'
255
+ );
256
+ }
257
+ if (r.amendment_blocks.length > 0 || (r.amended_by !== undefined && r.amended_by !== '—')) {
258
+ return 'AMENDED — read the amendment before citing the original decision; the text above it may no longer hold.';
259
+ }
260
+ if (r.trigger_state === 'none') {
261
+ return 'LIVE, NO REOPEN CONDITION — the ADR records no `review_trigger`, so nothing would ever reopen it on its own. Treat that as a defect in the ADR, not as strength of the lock.';
262
+ }
263
+ return 'LIVE, TRIGGER INDETERMINATE — the reopen condition is semantic and this tool cannot decide it. Not an unqualified lock: evaluate the condition against the current tree and route the result.';
264
+ }
265
+
266
+ export function cite_check(refs: string[], repo_root: string = REPO_ROOT): CiteResult[] {
267
+ const files = adr_files(repo_root);
268
+ const contents = new Map<string, string>();
269
+ for (const f of files) contents.set(f, fs.readFileSync(f, 'utf-8'));
270
+
271
+ return refs.map((ref) => {
272
+ const parsed = normalise_ref(ref);
273
+ if (parsed === null) {
274
+ const empty: Omit<CiteResult, 'verdict'> = {
275
+ ref,
276
+ resolved: false,
277
+ trigger_state: 'none',
278
+ amendment_blocks: [],
279
+ referenced_by: [],
280
+ };
281
+ return { ...empty, verdict: verdict_for(empty) };
282
+ }
283
+ const { id, area, num } = parsed;
284
+
285
+ // Two filename conventions, and the flat one's `ADR-` prefix is exactly
286
+ // what a per-area file does NOT have (`docs/adrs/cost/0001-…`). Matching
287
+ // both against one pattern was the defect: the tool advertised
288
+ // `docs/adrs` as covered and could never resolve a citation to it.
289
+ const match =
290
+ area === null
291
+ ? files.find((f) => new RegExp(`(^|/)ADR-0*${num}[-.]`, 'i').test(f))
292
+ : files.find((f) =>
293
+ new RegExp(`/docs/adrs/${area}/0*${Number(num)}-`, 'i').test(f),
294
+ );
295
+ if (match === undefined) {
296
+ const empty: Omit<CiteResult, 'verdict'> = {
297
+ ref: id,
298
+ resolved: false,
299
+ trigger_state: 'none',
300
+ amendment_blocks: [],
301
+ referenced_by: [],
302
+ };
303
+ return { ...empty, verdict: verdict_for(empty) };
304
+ }
305
+
306
+ const text = contents.get(match) ?? '';
307
+ const fm = parse_frontmatter(text) ?? {};
308
+ const bodyStart = text.indexOf('\n---\n', 4);
309
+ const body = bodyStart === -1 ? text : text.slice(bodyStart + 5);
310
+
311
+ // Back-references: any OTHER decision file naming this number. This is
312
+ // the only way to see a one-sided reopen — the amending ADR links back,
313
+ // the amended one does not.
314
+ // A per-area decision is cited as `ADR-<area>-NNNN`; a flat one by number.
315
+ const cited =
316
+ area === null
317
+ ? new RegExp(`ADR-0*${num}\\b`, 'i')
318
+ : new RegExp(`ADR-${area}-0*${Number(num)}\\b`, 'i');
319
+ const referenced_by = files
320
+ .filter((f) => f !== match && cited.test(contents.get(f) ?? ''))
321
+ .map((f) => path.relative(repo_root, f));
322
+
323
+ const partial: Omit<CiteResult, 'verdict'> = {
324
+ ref: id,
325
+ resolved: true,
326
+ file: path.relative(repo_root, match),
327
+ ...(fm['status'] !== undefined ? { status: fm['status'] } : {}),
328
+ ...(fm['date'] !== undefined ? { date: fm['date'] } : {}),
329
+ ...(fm['decision'] !== undefined ? { decision: fm['decision'] } : {}),
330
+ ...(fm['review_trigger'] !== undefined && fm['review_trigger'] !== ''
331
+ ? { review_trigger: fm['review_trigger'] }
332
+ : {}),
333
+ trigger_state: trigger_state(fm),
334
+ ...(fm['supersedes'] !== undefined ? { supersedes: fm['supersedes'] } : {}),
335
+ ...(fm['superseded_by'] !== undefined ? { superseded_by: fm['superseded_by'] } : {}),
336
+ ...(fm['amends'] !== undefined ? { amends: fm['amends'] } : {}),
337
+ ...(fm['amended_by'] !== undefined ? { amended_by: fm['amended_by'] } : {}),
338
+ amendment_blocks: amendment_blocks(body),
339
+ referenced_by,
340
+ };
341
+ return { ...partial, verdict: verdict_for(partial) };
342
+ });
343
+ }
344
+
345
+ function render(results: CiteResult[]): string {
346
+ const lines: string[] = [];
347
+ for (const r of results) {
348
+ lines.push(`\n${r.ref}${r.file !== undefined ? ` · ${r.file}` : ''}`);
349
+ if (!r.resolved) {
350
+ lines.push(` ❌ ${r.verdict}`);
351
+ continue;
352
+ }
353
+ lines.push(` status ${r.status ?? '—'} date ${r.date ?? '—'}`);
354
+ if (r.decision !== undefined) lines.push(` decision ${r.decision}`);
355
+ lines.push(` review_trigger ${r.review_trigger ?? '— (none recorded)'}`);
356
+ lines.push(` trigger state ${r.trigger_state}`);
357
+ if (r.supersedes !== undefined && r.supersedes !== '—') lines.push(` supersedes ${r.supersedes}`);
358
+ if (r.superseded_by !== undefined && r.superseded_by !== '—')
359
+ lines.push(` superseded_by ${r.superseded_by}`);
360
+ if (r.amends !== undefined && r.amends !== '—') lines.push(` amends ${r.amends}`);
361
+ if (r.amended_by !== undefined && r.amended_by !== '—') lines.push(` amended_by ${r.amended_by}`);
362
+ if (r.amendment_blocks.length > 0)
363
+ lines.push(` amendments ${String(r.amendment_blocks.length)}: ${r.amendment_blocks.join(' · ')}`);
364
+ if (r.referenced_by.length > 0)
365
+ lines.push(` referenced by ${String(r.referenced_by.length)} other ADR(s): ${r.referenced_by.join(', ')}`);
366
+ lines.push(` → ${r.verdict}`);
367
+ }
368
+ return lines.join('\n');
369
+ }
370
+
371
+ function main(argv: string[]): number {
372
+ const as_json = argv.includes('--json');
373
+ const refs = argv.filter((a) => !a.startsWith('--'));
374
+ if (refs.length === 0) {
375
+ process.stderr.write(
376
+ 'usage: adr_cite_check <ADR-NNN> [ADR-NNN …] [--json]\n' +
377
+ ' evaluate a decision before citing it as a reason not to act\n',
378
+ );
379
+ return 2;
380
+ }
381
+
382
+ const results = cite_check(refs);
383
+ if (as_json) {
384
+ process.stdout.write(
385
+ JSON.stringify(
386
+ {
387
+ results,
388
+ surfaces_not_scanned: SURFACES_NOT_SCANNED,
389
+ partial_coverage: PARTIAL_COVERAGE,
390
+ },
391
+ null,
392
+ 2,
393
+ ) + '\n',
394
+ );
395
+ } else {
396
+ process.stdout.write(render(results) + '\n');
397
+ }
398
+
399
+ const unresolved = results.filter((r) => !r.resolved);
400
+ if (unresolved.length > 0) {
401
+ process.stderr.write(
402
+ `\n❌ ${String(unresolved.length)} reference(s) did not resolve on a scanned surface. ` +
403
+ `Not scanned: ${SURFACES_NOT_SCANNED.join(' · ')}\n`,
404
+ );
405
+ return 1;
406
+ }
407
+ return 0;
408
+ }
409
+
410
+ // Main-guard (realpath-compared, mirrors the repo convention).
411
+ if (process.argv[1] !== undefined) {
412
+ try {
413
+ const here = fs.realpathSync(fileURLToPath(import.meta.url));
414
+ const argv1 = fs.realpathSync(path.resolve(process.argv[1]));
415
+ if (here === argv1) {
416
+ process.exit(main(process.argv.slice(2)));
417
+ }
418
+ } catch {
419
+ const argvUrl = pathToFileURL(path.resolve(process.argv[1])).href;
420
+ if (import.meta.url === argvUrl) {
421
+ process.exit(main(process.argv.slice(2)));
422
+ }
423
+ }
424
+ }