@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.
- package/.claude-plugin/marketplace.json +1 -1
- package/CHANGELOG.md +128 -0
- package/dist/agent-src/commands/analyze/decision.md +7 -0
- package/dist/agent-src/commands/optimize/project.md +10 -0
- package/dist/agent-src/commands/roadmap/process-full.md +45 -3
- package/dist/agent-src/contexts/execution/roadmap-process-loop.md +130 -1
- package/dist/agent-src/rules/decision-revisit-gate.md +108 -7
- package/dist/agent-src/skills/adr-create/SKILL.md +25 -3
- package/dist/agent-src/skills/decision-review/SKILL.md +18 -0
- package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
- package/dist/cli-delegate/{chunk-ZPSXJTLI.js → chunk-P6LQDIWK.js} +15 -1
- package/dist/cli-delegate/cmd_conformance.js +1 -1
- package/dist/cli-delegate/cmd_handoff.js +1 -1
- package/dist/cli-delegate/cmd_session_recycle.js +1 -1
- package/dist/discovery/deprecation-report.md +1 -1
- package/dist/discovery/discovery-manifest.json +8 -8
- package/dist/discovery/discovery-manifest.json.sha256 +1 -1
- package/dist/discovery/discovery-manifest.summary.md +1 -1
- package/dist/discovery/dormancy-report.md +1 -1
- package/dist/discovery/orphan-report.md +1 -1
- package/dist/discovery/packs.json +2 -2
- package/dist/discovery/trust-report.md +1 -1
- package/dist/discovery/workspaces.json +2 -2
- package/dist/hooks/dispatch.js +334 -77
- package/dist/mcp/registry-manifest.json +2 -2
- package/dist/mcp/server.json +2 -2
- package/dist/router.json +1 -1
- package/docs/guidelines/agent-infra/engineering-memory-data-format.md +49 -0
- package/package.json +1 -1
- package/src/config/estate-count-budget.json +29 -1
- package/src/config/pack-size-budget.json +3 -2
- package/src/scripts/_lib/layer_overlap_notice.ts +85 -0
- package/src/scripts/_lib/scope_guard.sh +62 -3
- package/src/scripts/_lib/subagent_capsule.ts +81 -1
- package/src/scripts/adr/regenerate_index.ts +13 -2
- package/src/scripts/adr_cite_check.ts +424 -0
- package/src/scripts/check_adr_frontmatter.ts +187 -1
- package/src/scripts/check_single_delivery.ts +473 -0
- package/src/scripts/hooks/run_continuation_hook.ts +884 -68
- package/src/scripts/install.sh +12 -2
- package/src/scripts/memory_consolidation.ts +189 -0
- package/src/scripts/memory_eviction.ts +384 -0
- package/src/scripts/report_layer_overlap.ts +72 -0
- package/src/scripts/report_memory_pointers.ts +622 -0
- 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
|
-
#
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|