跳到正文
DEV Community· uehara·· 3 小時前AI 評分60

ELN workflow 重建 ADR 索引,讓 AI 智能體識別現行架構決策

Too many ADRs made our AI follow outdated decisions. The fix: keep them all, let an index name the current ones

AI 導讀

ELN workflow 重建 ADR 索引,每次直接解析 ADR 標頭欄位判定決策狀態,避免 Claude Code 智能體靠讀取正文推測哪些決策仍然有效。在 179 份 ADR 中,新索引辨識出 167 項現行決策,舊索引只能看到 139 項;啟動摘要為 1,186 bytes,仍在 1,200-byte 預算內。

正文

The obvious fix for "my AI agent keeps relying on superseded architecture decisions" is a small parser: read each ADR's Supersedes field and mark the old records as replaced. We built exactly that. Then we ran it on two other repositories of ours, with 270 and 93 ADRs, and it reported live decisions as dead. In the larger repository, every one of the 5 records it called "superseded" was a false alarm.

A parser that just picks up ADR numbers does not just miss things. It creates misreadings of its own, and an AI agent will trust them. This is a write-up of how we rebuilt the ADR index in ELN workflow, our in-house plugin suite for Claude Code, so that "which decisions are current?" is answered by one deterministic set of rules instead of by the model reading ADR bodies and guessing. It follows an earlier article, The real reason AI ignores existing decisions: designing an ADR context auto-injection hook, which ended with two known gaps: the injected list stopped at 30 entries, and 29 records written in another format were not read at all.

The hooks are Claude Code specific. The problem is not: any coding agent, Claude Code or Codex or another, that works in a repository with a few hundred ADRs has to find out which ones still hold.

The short version

  • Keep every ADR file; let an index answer what is current. One parser derives the effective status of every record each time it runs. Nothing is cached, so nothing drifts. Parsing 167 records takes 0.04 to 0.11 seconds.
  • A parser that just picks up numbers manufactures errors. We found four patterns, each wrong in its own way. "Supersedes: (none — amends ADR-xxxx)" turned a live record into a "superseded" one. Duplicate ADR numbers attached relationships to the wrong file. A lowercase status: label made an accepted record unreadable. Another repository's ADR number pointed at an unrelated local record as the replacement. Now the tool says "superseded" only when the header is written in a strict form.
  • What the AI was told pointed it at the oldest decisions. The two gaps the earlier article ended with were still there at 167 records: the session-start index listed only the 30 oldest decisions and told the agent to ls docs/adr/ for the rest, and 29 live decisions were invisible because their status line was bold. Only 3 records declared their replacement in a machine-readable header; 76 mentioned it in prose.
  • Four delivery paths. A newest-first brief with a superseded table at session start, a warning when the agent is about to read an old record (through the Read tool or through cat, and it never blocks), a CI check on the header lines a pull request changes, and one command that updates the old ADR, the new ADR and the index together.
  • Results. The startup brief went from 3,511 to 1,140 bytes, live decisions the index can see from 126 to 155, superseded rows shown from 0 to 5. At 179 records, the brief is 1,186 bytes against a 1,200-byte budget.

Four ways a number-picking ADR parser misreads

The first version picked up every ADR number in the Supersedes and Superseded by fields. Checked against the real files and run on the two other repositories, it got four kinds of header wrong.

  • A "none" with a note. Some records say Supersedes: (none — amends ADR-xxxx): nothing was replaced, this record amends another one. Picking up the number made the amended record look superseded. In our plugin's own repository, two live decisions would have been reported as replaced. The other repository had 7 records written as "none (does not overturn ADR-xxxx)".
  • Duplicate numbers. Two different files can carry the same ADR number (9 pairs in one repository, 4 in the other). Following the number alone attached the relationship to the wrong file.
  • A lowercase label. - status: Accepted was not read, so an accepted record counted as unknown.
  • Another repository's number. "Superseded by: some-other-repo ADR-xxxx" pointed at an unrelated local record with the same number as the replacement.

The rules that came out of this are short, and they are mostly about what the parser refuses to infer:

  • A record is superseded only when the field's value is a plain list of ADR-NNNN numbers. Any prose in the value makes it partial, shown but not applied.
  • A value that starts with none (or the Japanese "なし") creates no relationship, whatever numbers follow it.
  • If the declaring record is still Proposed, the old record stays live and the relationship is shown as pending. A new, unapproved ADR cannot erase a live one on its first day.
  • A number preceded by an identifier with a hyphen or underscore is external, displayed as text.
  • A relationship touching a duplicated number is not applied, because the tool cannot tell which file is meant.
  • A status the parser cannot read is counted as unknown and shown, never silently dropped.

What the agent was actually being told

One suggestion was to write reading rules into a README. I did not trust a note the agent may or may not read without a mechanical guard, so I measured first. At that point the repository had 167 ADRs, 2,171,690 bytes in total, the largest 68,777 bytes.

  • The session-start index listed the 30 oldest live decisions (3,511 bytes) and said to check the rest with ls docs/adr/. The newer a decision, the less likely the agent was to see it. The ADR that introduced that index had said "revisit once there are more than 30"; 167 is 5.6 times that.
  • 29 live decisions were invisible. The index read - Status: Accepted but not - **Status**: Accepted. 30 records used the bold form and 29 of them were live. The index saw 126 live decisions; the real number was 155.
  • Supersession lived in prose. 3 records declared their replacement in a header field. 76 mentioned replacement somewhere in the text.
  • The agent read bodies through the shell more than through Read. Across 38 sessions over the four weeks before the measurement, ADR bodies were read 37 times with the Read tool (33 of them reading only a line range) and 80 times with cat, sed, head or tail. The existing large-file warning watched only the Read tool, and only above 1,000 lines. The longest ADR was 648 lines. It never fired.
  • The hand-written index table had stopped at the 122nd record, with 45 missing.

The behavior I was worried about, the agent cat-ing every ADR at once, happened zero times in those 38 sessions. What it did was list the folder (36 times), pick files by name and read them one at a time, which is exactly what the index told it to do.

Keep the files, rebuild the index every time

ADRs exist so the reasoning behind today's design can be traced; deleting old ones loses that. So the files stay as they are: nothing deleted, moved or split. The plugin's skills, hooks and scripts refer to ADRs by number and path, and at 167 records only one ADR was not referenced from anywhere. Moving files would break those references.

"Is this decision current?" is answered by an index that one parser builds from the header fields (status, date, supersedes, superseded by) every time. There is no committed index.json and no cache, because a stored index drifts away from the files the same way the hand-written table did.

The human-readable table in docs/adr/README.md is still there, but it is generated: eln-adr rewrites a marked block, and CI fails if the block differs from a fresh run. The brief and the read warning never use that table; they use the parse of the files.

Effective status Meaning Counted as current
live Accepted or Amended, not replaced yes
partial Partly replaced by a newer record yes, with the replacement shown
superseded Replaced by a newer record no
proposed Not yet accepted no
rejected An option we did not take no
unknown Status not in the fixed vocabulary no, and shown as a count

How the index reaches the agent

An index nobody reads changes nothing. There are four paths.

The brief at session start

When Claude Code starts, a hook (a script Claude Code runs automatically at fixed moments) puts this into the agent's context.

Terminal, recomposed from the real output at 179 records. The tool prints in Japanese; the labels are translated here and every number is unchanged. Line 1: "docs/adr 179 files: current 167". Line 2: the CLI to use before opening a body. Line 3, in yellow: the superseded table. Line 4: the 10 newest records by revision date (titles masked). Then the proposed count and where the index table lives. 1,186 bytes in total. The CLI path is shortened

The order is fixed: counts, how to use the CLI, the superseded table, the 10 newest records by revision date, the proposed count. The budget is 1,200 bytes, and the trimming order is fixed too (proposed down to a count, shorter titles, newest 8, then 6, the superseded table down to a count, even shorter titles, and finally no newest list). The counts line, the CLI line and the number of superseded records always stay. The brief never sends the agent to ls docs/adr/. After a compact or a resume, only a pointer of 300 bytes or less is injected: pushing a large block back in right after the context was shrunk would undo the shrink, and the index can be derived again at any time with the CLI.

A warning when the agent is about to read an ADR

A second hook runs before the agent reads an ADR, looks up that record's effective status, and speaks up only when it matters.

Terminal, recomposed from the real output, labels translated from Japanese. Top: the agent is about to Read one whole ADR that is partly superseded; the hook says the replacement is current for that part, notes a replacement mentioned in prose, and points out this is a full Read of 25,216 bytes, suggesting show. Bottom: the agent is about to run cat docs/adr/*.md; the hook says this is a bulk read and points to list, show and find. Neither call is blocked

  • It covers Read and the shell. Two thirds of body reads went through the shell, so the hook also checks whether cat, head, tail, sed, awk, bat, less or more has an ADR path in its arguments.
  • The message depends on the case. Superseded gets ⚠️ and the replacement. Partial, or a full read over 16 KiB, gets ℹ️ and a pointer to show. Three or more files, or a glob, is called out as a bulk read.
  • It does not block. The warnings are logged with whether they were right. Only after at least 25 logged warnings with a miss rate of 5% or less does it move up to asking a human before a superseded record is read.

Every warning includes the CLI's path. Subagents that Claude Code starts internally never receive the session-start brief, so for them this warning is the only pointer.

eln-adr show: the key points first

show returns one record's essentials in 2.5 KB or less.

Terminal, recomposed from the real output, labels translated from Japanese. eln-adr show prints the title, "Superseded → replacement (partial). Read the replacement for what is current", status and date, size (25,216 B, 186 lines, 1 amendment), the amendment list, headings with line numbers, and the start of the Decision section. The output is 878 bytes; the file is 25,216 bytes

Here a 25,216-byte record becomes 878 bytes. Status, replacement, amendments, heading line numbers and the opening of the decision are enough to decide whether to read more, and if so, which line range.

show also puts ⚠️ on a partly superseded record and marks the replacement "(partial)", telling the agent to read the replacement for that part. The record itself still counts as current; the status line says "effective: partial".

CI on changed lines, and one command per lifecycle step

A correct index is undone by the next badly written header. CI checks the ADRs a pull request adds or changes, and only the header lines that changed: the status word, a - Date: YYYY-MM-DD line, the format of the supersede fields, a supersede declared on only one side, new duplicate numbers. Each failure has a reason code. Untouched old records are never blocked, so the 167 existing files did not have to be rewritten at once.

Accepting, amending and superseding are one command each. eln-adr supersede <old> --by <new> writes the old record's "Superseded by", the new record's "Supersedes" and the index table together. It is a dry run by default and writes only with --write. Before writing, it parses the result again and writes nothing at all if any of these fails: lines outside the intended fields are unchanged, the CI check has zero blocking findings, running the same command again changes nothing, and every declaration that was already in the field is still there.

The numbers

Measured with the same commands, before at 167 records and after at 168 (the ADR for this change itself was added):

Before After
Text the agent receives at startup 3,511 B (30 oldest) 1,140 B (counts, superseded table, newest 10)
Live decisions visible in the index 126 155
Superseded table 0 rows 5 rows (plus 2 pending)
"Check the rest with ls docs/adr/" yes no

And again later, at 179 records:

Terminal, recomposed from the real output at 179 records, labels translated from Japanese. eln-adr stats: "docs/adr 179 files: current 167 (live 161, partly superseded 6) / Proposed 8 / Superseded 2 / Rejected 2 / unknown 0", the status formats, the relationship counts, the size distribution and amendment counts. In yellow: "Current as counted by the old hook (awk): 139 / effective 167". Last line: "brief 1186 B (budget 1200, newest 10) / pointer 232 B (budget 300)"

  • 167 of 179 records are current: 161 live and 6 partly superseded. 8 proposed, 2 superseded, 2 rejected, 0 unknown.
  • The old way of counting would see 139. 28 current decisions would be invisible to the old index. The new index counts all 167.
  • The brief is 1,186 bytes, still under its 1,200-byte budget after 12 more records. The compact pointer is 232 bytes, under 300.

Wrapping up

Keeping every ADR was the right call. The problem was leaving the model to infer "is this one current?" from the bodies. As long as it does, the startup list leans further toward old decisions as the count grows, the agent opens bodies one by one to check, and it spends tokens doing it. We moved the answer into an index built every time from one set of rules, delivered it at startup and at the moment an old record is about to be read, and kept new headers strict through CI and one-step commands. The rule that mattered most was deciding up front what the parser must not infer; without it, the parser itself reports live decisions as replaced.

Notes: terms and screens

  • ADR (architecture decision record). A short document that records one design decision, why it was made and what else was considered. ADRs are usually numbered and kept in docs/adr/. The header carries a status (Accepted, Amended, Proposed, Superseded, Rejected) and fields for what it supersedes and what superseded it.
  • Hooks. Scripts Claude Code runs automatically at fixed moments. SessionStart runs when a session starts; PreToolUse runs before a tool call. A hook's output goes into the agent's context.
  • Read and Bash. The two ways the agent reads files in Claude Code: the dedicated Read tool, or shell commands such as cat and sed through the Bash tool.
  • The screens. All four figures are real output of the hooks and of eln-adr in our plugin's repository, laid out as a terminal. The tool prints in Japanese, so the labels are translated in this English version; numbers, record numbers and byte counts are unchanged. Other changes: the CLI path, which contains my home folder, is shortened to <plugin>/scripts/eln-adr.py; the design record number of this mechanism itself, in the tag the hooks print, is shown as ADR-xxxx; and the titles in the brief's "newest" list, plus the title and text of the example record in figures 2 and 3, are masked with ■ because they name or describe internal systems. The record numbers are the real ones.
  • When the numbers were taken. The before-and-after comparison was measured at 167 records (before) and 168 records (after, with the ADR for this change added), and the current figures at 179 records, with the same commands. Session counts are tool-call inputs in the conversation logs.

Articles on ELN workflow — our in-house plugin suite that enforces AI development quality — will continue as a series. If you are interested, please like and follow.

Our products: https://www.eln.ne.jp/products


EarthLink Network builds its own products to run the whole company on AI — each one born from a need along the way. See the full index of what we build:

→ The 18 products EarthLink Network builds in-house

For more about the company and each product, visit www.eln.ne.jp.

來源:DEV Community · dev.to