ADR Records skill for Claude Code
Decisions your team can still explain next year. Claude writes one ADR per decision, with measurable drivers, honest options including do-nothing, consequences that list the costs, and a way to confirm the decision holds. A bundled script numbers new records, links superseded ones in both directions, and lints the folder, so the decision log in your repo stays consistent as it grows.
- name
adr-records - size157 lines · ≈1,600 tokens
- files2
- licenseMIT
- updated
What Claude sees before loading it
This description is the only part of the skill that sits in context all the time. Claude reads it to decide when to load the full playbook below.
description: Writes and maintains Architecture Decision Records (ADRs) in the repository using the MADR or Nygard format, deciding when a decision deserves an ADR, capturing context, drivers, considered options, consequences, and a confirmation check, and managing the status lifecycle (proposed, accepted, rejected, deprecated, superseded). Includes a script to create numbered ADRs, supersede old ones, list them, and lint the folder. Use when a team makes or revisits a technical decision, when someone asks "why did we choose X", when setting up docs/adr, or when reviewing an ADR pull request. Try asking
- Write an ADR for choosing SQS over Kafka based on the spike results in this thread
- Set up docs/adr in this repo with a template and our first decision
- We're replacing the decision in ADR 0007. Supersede it properly
Use it when
- A team is making or revisiting a hard-to-reverse technical decision
- Someone asks why the system was built a certain way
- Setting up a decision log in a repository
- Reviewing an ADR pull request
Skip it when
- You need a full proposal with rollout plan and open questions: use design-docs-rfc
- You are still investigating options with prototypes: use technical-spike first
How to check the result
The ADR lint script passes, superseded records link to their replacements both ways, and a newcomer can name the decision, the drivers, the rejected options and the accepted cost.
The common mistake it prevents
Editing an accepted ADR to describe a new decision instead of writing a superseding one, which erases the history the record exists to keep.
An ADR captures one decision, the forces behind it, the options rejected, and the cost accepted, short enough to read in five minutes. Its value comes a year later, when someone asks “why is it like this?” and the answer is in the repo next to the code.
Ground rules
- One decision per ADR. “Adopt event sourcing and Kafka and a new team structure” is three ADRs.
- Write it when the decision is made (status
proposedduring discussion), not months later from memory. - Record real alternatives, including “do nothing”. An ADR with one option is an announcement, not a decision record.
- Accepted ADRs are immutable in substance. To change a decision, write a new ADR that supersedes the old one. Fix typos and broken links freely.
- Keep ADRs in the repository (
docs/adr/ordocs/decisions/), reviewed through pull requests like code.
When to write one
Write an ADR if any of these hold:
| Signal | Example |
|---|---|
| Expensive or slow to reverse | Database engine, primary language, cloud provider, monorepo vs. polyrepo |
| Affects several teams or services | API style, auth approach, event schema conventions |
| Real alternatives were debated | Choosing Kafka over SQS after a spike |
| Deliberately accepting a known cost | Eventual consistency on feeds, skipping multi-region |
| Constrains future work | “All services emit OpenTelemetry traces” |
| Will be questioned later | Anything a new hire would ask about in month one |
Skip ADRs for reversible, local choices (a utility library, naming inside one module) and for decisions already dictated by an existing ADR.
Workflow
- [ ] 1. Find or set up the ADR directory; read related existing ADRs
- [ ] 2. Create the next numbered ADR from the template
- [ ] 3. Write context and decision drivers (measurable where possible)
- [ ] 4. List 2–4 options with honest pros and cons
- [ ] 5. State the decision, consequences, and how compliance is confirmed
- [ ] 6. Open a PR; status proposed → accepted/rejected on merge
- [ ] 7. Supersede, never rewrite, when the decision changes
- [ ] 8. Lint the folder
1–2. Set up and create
python3 scripts/adr.py list # existing ADRs and statuses
python3 scripts/adr.py new "Use PostgreSQL for the event store"
python3 scripts/adr.py new "Move event store to Kafka" --supersedes 7
python3 scripts/adr.py check # lint numbering, status, links
new picks the next number, writes NNNN-kebab-title.md from a MADR-style template
with today’s date and status proposed, and with --supersedes N links both ADRs and
updates the old one’s status. check exits 1 on duplicate numbers, missing or invalid
statuses, bad file names, or broken supersede links. If the team already uses adr-tools
or log4brains, keep using their conventions; the script reads Nygard ## Status
sections too.
Add a docs/adr/README.md index explaining the format and how to propose one.
3. Context and drivers
- State the problem as forces in tension, with numbers: “Write volume is 4k events/s, growing 3x/year; current Postgres primary is at 70% CPU at peak.”
- Link the trigger: incident, spike result, design doc, issue.
- Drivers are the criteria options are judged by. Rank them if they conflict.
4. Options
For each option, two to five bullets of pros and cons tied to the drivers. Include the cost of migration, operational burden, and team skills. If a spike produced data, summarize it and link the code or results.
5. Decision and consequences
## Decision Outcome
Chosen option: "Partitioned PostgreSQL tables with a polling outbox", because it meets
the 10k events/s target from the spike (driver 1) with tools the team already operates
(driver 2), and defers Kafka until write volume exceeds that.
### Consequences
* Good, because no new infrastructure to run or secure this year.
* Good, because transactional outbox keeps events consistent with state.
* Bad, because consumers poll; end-to-end latency is ~1 s instead of ~100 ms.
* Bad, because we must revisit when sustained writes pass 8k/s (alert added).
### Confirmation
Load test in CI asserts 10k events/s on the staging profile; dashboard alert on
sustained write rate > 8k/s links to this ADR.
Consequences must include the bad ones. An ADR with only upsides was written to sell, not to record.
6–7. Status lifecycle
proposed ──► accepted ──► deprecated
│ │
▼ └──► superseded by ADR-NNNN
rejected
proposed: under discussion in a PR.accepted: merged and in force.rejected: keep it; the reasoning prevents the same debate next year.deprecated: no longer relevant (the system was retired), with no replacement.superseded by ADR-NNNN: replaced; link the new ADR, and the new one says what it supersedes.
Writing style
- 1–2 pages. If it needs more, link a design doc for detail and keep the ADR as the decision summary (see design-docs-rfc).
- Plain past/present tense facts; no persuasion in the context section.
- Name decision-makers so readers know whom to ask, not to assign blame.
- Add a revisit trigger when the decision depends on scale or a vendor state: “Revisit if monthly cost exceeds $5k or if the vendor drops SOC 2.”
Reviewing an ADR PR
- Is it one decision, and is it ADR-worthy?
- Are the drivers specific and are options judged against them?
- Are the negative consequences real and acknowledged?
- Is there a confirmation mechanism (lint, test, alert, checklist)?
- Does it conflict with an accepted ADR without superseding it?
Gotchas
- Editing an accepted ADR to reflect a new decision destroys the history. Supersede.
- ADRs in a wiki drift away from code and die. Keep them in the repo.
- Numbering collisions happen when two PRs create ADRs at once; renumber the later PR
before merging and run
check. - Writing ADRs for every small choice causes nobody to read them. Use the signal table.
Verification
python3 scripts/adr.py checkpasses.- A reader unfamiliar with the decision can answer: what was decided, why, what else was considered, and what we gave up.
- Superseded ADRs link forward, and the new ADR links back.
Bundled files
Loaded only when a step in the skill needs them. Review them here before installing.
scripts/adr.py
#!/usr/bin/env python3
"""Create, list, and check Architecture Decision Records (MADR-style Markdown).
Usage:
python3 scripts/adr.py new "Use PostgreSQL for the event store" [--dir docs/adr] [--supersedes 7]
python3 scripts/adr.py list [--dir docs/adr]
python3 scripts/adr.py check [--dir docs/adr]
Without --dir, the first existing of docs/adr, docs/decisions, doc/adr, adr is used
(docs/adr is created for `new` if none exists).
ADR files are named NNNN-title-in-kebab-case.md. Status is read from YAML front matter
(`status: accepted`) or from a Nygard-style `## Status` section.
Exit codes: 0 = ok, 1 = check found problems or a referenced ADR is missing.
"""
import argparse
import datetime as dt
import os
import re
import sys
CANDIDATE_DIRS = ["docs/adr", "docs/decisions", "doc/adr", "adr"]
VALID = re.compile(r"^(proposed|accepted|rejected|deprecated|superseded by .+)$", re.I)
NAME = re.compile(r"^(\d{4})-([a-z0-9-]+)\.md$")
TEMPLATE = """---
status: {status}
date: {date}
decision-makers: {{list who decided}}
consulted: {{optional}}
informed: {{optional}}
---
# {title}
## Context and Problem Statement
{{Describe the situation and the question being decided in 2-5 sentences. Link the issue,
incident, or design doc that triggered it.}}
## Decision Drivers
* {{driver, e.g. p95 latency under 200 ms at 3k req/s}}
* {{driver, e.g. team already operates PostgreSQL}}
## Considered Options
* {{option 1}}
* {{option 2}}
* {{option 3, including "do nothing" when realistic}}
## Decision Outcome
Chosen option: "{{option}}", because {{justification tied to the drivers}}.
{supersedes}
### Consequences
* Good, because {{positive consequence}}
* Bad, because {{negative consequence or accepted cost}}
### Confirmation
{{How compliance is checked: a lint rule, a fitness function, a review checklist item,
a load test result.}}
## Pros and Cons of the Options
### {{option 1}}
* Good, because {{...}}
* Bad, because {{...}}
### {{option 2}}
* Good, because {{...}}
* Bad, because {{...}}
## More Information
{{Links, revisit trigger (e.g. "revisit if write volume exceeds 5k/s"), related ADRs.}}
"""
def find_dir(explicit, create):
if explicit:
if create:
os.makedirs(explicit, exist_ok=True)
return explicit
for d in CANDIDATE_DIRS:
if os.path.isdir(d):
return d
if create:
os.makedirs(CANDIDATE_DIRS[0], exist_ok=True)
return CANDIDATE_DIRS[0]
print("error: no ADR directory found (looked for " + ", ".join(CANDIDATE_DIRS) + ")", file=sys.stderr)
sys.exit(1)
def adrs(directory):
out = []
for f in sorted(os.listdir(directory)):
m = NAME.match(f)
if m:
out.append((int(m.group(1)), f))
return out
def slugify(title):
s = re.sub(r"[^a-z0-9]+", "-", title.lower()).strip("-")
return re.sub(r"-{2,}", "-", s)[:60].rstrip("-")
def read_meta(path):
text = open(path, encoding="utf-8").read()
status = None
fm = re.match(r"^---\n(.*?)\n---\n", text, re.S)
if fm:
m = re.search(r"^status:\s*(.+)$", fm.group(1), re.M)
if m:
status = m.group(1).strip().strip('"')
if status is None:
m = re.search(r"^##\s*Status\s*\n+\s*(.+)$", text, re.M | re.I)
if m:
status = m.group(1).strip()
tm = re.search(r"^#\s+(.+)$", text, re.M)
title = tm.group(1).strip() if tm else "(no title)"
return text, status, title
def set_status(path, new_status):
text = open(path, encoding="utf-8").read()
fm = re.match(r"^---\n(.*?)\n---\n", text, re.S)
if fm and re.search(r"^status:", fm.group(1), re.M):
body = re.sub(r"^status:.*$", f"status: {new_status}", fm.group(1), count=1, flags=re.M)
text = f"---\n{body}\n---\n" + text[fm.end():]
elif re.search(r"^##\s*Status\s*\n+\s*.+$", text, re.M | re.I):
text = re.sub(r"(^##\s*Status\s*\n+\s*).+$", lambda m: m.group(1) + new_status, text, count=1, flags=re.M | re.I)
else:
return False
open(path, "w", encoding="utf-8").write(text)
return True
def cmd_new(args):
d = find_dir(args.dir, create=True)
existing = adrs(d)
num = (existing[-1][0] + 1) if existing else 1
fname = f"{num:04d}-{slugify(args.title)}.md"
path = os.path.join(d, fname)
sup_line = ""
old_path = None
if args.supersedes is not None:
match = [f for n, f in existing if n == args.supersedes]
if not match:
print(f"error: ADR {args.supersedes:04d} not found in {d}", file=sys.stderr)
sys.exit(1)
old_path = os.path.join(d, match[0])
sup_line = f"\nSupersedes [ADR-{args.supersedes:04d}]({match[0]}).\n"
content = TEMPLATE.format(status=args.status, date=dt.date.today().isoformat(),
title=args.title, supersedes=sup_line)
with open(path, "x", encoding="utf-8") as fh:
fh.write(content)
print(f"created {path}")
if old_path:
if set_status(old_path, f"superseded by [ADR-{num:04d}]({fname})"):
print(f"updated status of {old_path}")
else:
print(f"warn: could not find a status line in {old_path}; update it by hand", file=sys.stderr)
def cmd_list(args):
d = find_dir(args.dir, create=False)
for num, f in adrs(d):
_, status, title = read_meta(os.path.join(d, f))
shown = re.sub(r"\[([^\]]+)\]\([^)]+\)", r"\1", status or "?")
print(f"{num:04d} {shown[:28]:28} {title}")
def cmd_check(args):
d = find_dir(args.dir, create=False)
problems = []
seen = {}
names = {f for _, f in adrs(d)}
for num, f in adrs(d):
if num in seen:
problems.append(f"duplicate number {num:04d}: {seen[num]} and {f}")
seen[num] = f
_, status, title = read_meta(os.path.join(d, f))
if not status:
problems.append(f"{f}: no status")
elif not VALID.match(status):
problems.append(f"{f}: invalid status '{status}'")
elif status.lower().startswith("superseded"):
link = re.search(r"\(([^)]+\.md)\)", status)
if not link or link.group(1) not in names:
problems.append(f"{f}: superseded but link to the new ADR is missing or broken")
if title == "(no title)":
problems.append(f"{f}: no '# Title' heading")
for f in os.listdir(d):
if f.endswith(".md") and not NAME.match(f) and f.lower() not in ("readme.md", "index.md", "template.md"):
problems.append(f"{f}: name does not match NNNN-kebab-title.md")
for p in problems:
print("PROBLEM", p)
print(f"{len(seen)} ADR(s) checked, {len(problems)} problem(s)")
sys.exit(1 if problems else 0)
def main():
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
sub = ap.add_subparsers(dest="cmd", required=True)
n = sub.add_parser("new")
n.add_argument("title")
n.add_argument("--dir")
n.add_argument("--status", default="proposed")
n.add_argument("--supersedes", type=int)
for name in ("list", "check"):
s = sub.add_parser(name)
s.add_argument("--dir")
args = ap.parse_args()
{"new": cmd_new, "list": cmd_list, "check": cmd_check}[args.cmd](args)
if __name__ == "__main__":
main()
About this skill
Written and maintained by the Claude Skills editors to the same editorial standard as every skill in the directory: a selective trigger, a real workflow, known pitfalls and a verification step. First published , last updated . MIT licensed.