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.

  • nameadr-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.

~/.claude/skills/adr-records/SKILL.md

--- name: adr-records description: Writes and maintains Architecture Decision Records (ADRs) in the repository using the MADR or Nygard format, deciding when a decision… license: MIT ---

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 proposed during 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/ or docs/decisions/), reviewed through pull requests like code.

When to write one

Write an ADR if any of these hold:

SignalExample
Expensive or slow to reverseDatabase engine, primary language, cloud provider, monorepo vs. polyrepo
Affects several teams or servicesAPI style, auth approach, event schema conventions
Real alternatives were debatedChoosing Kafka over SQS after a spike
Deliberately accepting a known costEventual consistency on feeds, skipping multi-region
Constrains future work“All services emit OpenTelemetry traces”
Will be questioned laterAnything 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

  1. python3 scripts/adr.py check passes.
  2. A reader unfamiliar with the decision can answer: what was decided, why, what else was considered, and what we gave up.
  3. 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 225 lines · 7.2 KB
#!/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.

esc