adr · git:20260807.187b0aa · 2026-08-07 · sha256 50aba929cd685363

adr git:20260807.187b0aaA

Immutable. This exact content is served forever at /api/v1/blob/50aba929cd685363.

---
name: adr
description: "Write an Architecture Decision Record (ADR) for a feature — Context / Decision / Status / Consequences / Alternatives, filed as docs/features/<feature>/adr-<NNN>-<title>.md with a 3-digit zero-padded number. Handles the Superseded case: bidirectional linking when a new ADR replaces an old one. Use when: recording why an architectural approach was chosen, documenting a decision so it doesn't get re-litigated, marking a prior decision as superseded. Not for: feature-level technical design (use /tech-spec), task progress tracking (use /create-request), bulk backfill of historical decisions (a separate request — this skill writes one ADR at a time)."
allowed-tools: Read, Grep, Glob, Write, Edit, Bash(node:*), AskUserQuestion
---

# ADR — Architecture Decision Record

## Trigger

- Keywords: ADR, architecture decision record, decision record, record a decision, why did we choose, 架構決策, 決策紀錄

## When NOT to Use

| Scenario | Alternative |
|----------|------------|
| Feature-wide technical design (components, data flow) | `/tech-spec` |
| Task progress / acceptance-criteria tracking | `/create-request` |
| Bulk backfill of decisions already made in the past | Separate request — this skill writes one ADR per invocation, not a batch |

## Workflow

```
Phase 1: Resolve feature  → shared feature-context resolution
Phase 2: Compute number   → scan root + archived/, numeric max + 1, zero-pad to 3 digits
Phase 3: Gather content   → Context / Decision / Status / Consequences / Alternatives
Phase 4: Write ADR        → fill references/template.md, write to docs/features/<key>/
Phase 4b: Superseded link → (only if this ADR supersedes an existing one) edit both files
Phase 5: Report           → path written, number assigned, links updated
```

### Phase 1: Resolve Feature

Reuse the shared cascade — do not re-derive it here:
`@skills/tech-spec/references/feature-context-resolution.md` (the canonical copy —
`skills/create-request/references/`'s copy is a documented sync duplicate and has already drifted),
canonical implementation `scripts/lib/feature-resolver.js`, CLI `node scripts/resolve-feature-cli.js
[--feature <key>]`.

**The gate below checks the directory (and the confidence), not `key` alone.** For Levels 1–3
(explicit `--feature` with a valid slug, branch `feat/<x>`, or a changed path under
`docs/features/<key>/`), `resolveFeatureContext` returns a non-null `key` with
`confidence: "high"` or `"medium"` even when `docs/features/<key>/` does not exist on disk — it
only probes the directory to enrich the result, never to invalidate it
(`scripts/lib/feature-resolver.js:57-93`). An explicit `--feature` value that fails the
case-insensitive slug pattern (`/^[a-z0-9][a-z0-9._-]*$/i`, e.g. `--feature ../evil`) is rejected
at line 59 before it ever reaches `key`. Level 3b (a changed path under `skills/<key>/`,
line 85-93) only returns when `probe()` finds the directory; on a miss it falls through — to
Level 4 if `docs/features/` has **exactly one** subdirectory (returns that directory's name as
`key` anyway, `source: "single_dir"`, `confidence: "low"` — a guess, not a match on the actual
change), otherwise to Level 5 (`key: null`). `resolve-feature-cli.js` prints the **full result
object** in the null case, e.g. `{"key":null,"source":"none",...}` — a bare `{}` means something
else entirely (no git root, or the CLI itself threw). A typo'd `--feature` value is the likelier
failure and does **not** produce a null key (Level 1 still returns it with `confidence: "high"`),
so gating on `key` alone silently creates a bogus feature directory instead of asking:

| Result | Action |
|--------|--------|
| `key` resolved, `confidence` is `"high"` or `"medium"`, **and** `docs/features/<key>/` exists | Continue to Phase 2 |
| `key` resolved but `docs/features/<key>/` does not exist (check with `node -e "process.exit(require('fs').existsSync(process.argv[1])?0:1)" "docs/features/<key>"` — this skill's `allowed-tools` has no general `Bash`, only `Bash(node:*)`) | **Gate: Need Human** — confirm this is really a new feature directory the user wants created; do not silently write into a typo'd path |
| `confidence` is `"low"` (`source: "single_dir"`) | **Gate: Need Human** — this is a guess ("only one feature directory exists"), not a match on the actual change; confirm it's the right one before writing into it |
| `key` is `null` (`resolve-feature-cli.js` prints the full object, e.g. `{"key":null,"source":"none",...}`) | **Gate: Need Human** — ask which feature this ADR belongs to; do not guess |

### Phase 2: Compute the Number

Scan **both** the feature's root directory **and** its `archived/` subdirectory for existing
`adr-*.md` files. `rules/docs-numbering.md` § Ancillary docs defines the `adr-<number>-<title>.md`
filename pattern but says nothing about `archived/` — that convention lives in
`scripts/lib/doc-classifier.js` (`scanFeatureDocs`, which skips directories named `archived` at any
depth when building its *live* doc inventory). A number retired there is still taken, so this scan
is deliberately broader than that inventory — building the live-doc list and computing the next
free number are different purposes.

Run `skills/adr/scripts/next-adr-number.js` — do not hand-apply the max.
Numeric max, not lexical sort: string-sorting `adr-9-...` after `adr-10-...` would collide, which
is exactly the bug that shipping this as an unexecuted prose pin would miss.

```bash
node skills/adr/scripts/next-adr-number.js docs/features/<key>
```

(paths are repo-root relative, matching every other path in this skill). The function itself
(`nextAdrNumber`, exported for direct unit testing —
`test/skills/adr.test.js` exercises it against real temp directories, not just a prose pin):

```js
function nextAdrNumber(featureDir) {
  let max = 0;
  for (const dir of [featureDir, path.join(featureDir, 'archived')]) {
    let entries;
    try { entries = fs.readdirSync(dir); } catch { continue; }
    for (const name of entries) {
      const m = name.match(/^adr-(\d+)-/i);
      if (m) max = Math.max(max, parseInt(m[1], 10));
    }
  }
  return String(max + 1).padStart(3, '0');
}
```

The regex is case-insensitive (`/i`) — a hand-written `ADR-006-x.md` on a case-insensitive
filesystem still counts toward the max, avoiding a reissued number. The first ADR in a feature
(both directories empty or missing) produces `adr-001-<title>.md`.

### Phase 3: Gather Content

If not already supplied via `$ARGUMENTS`, ask for:

1. **Title** — short, kebab-case (becomes the filename's `<title>` segment)
2. **Context** — what forces are at play, what problem prompted the decision
3. **Decision** — what was decided, stated as a decision, not a description
4. **Status** — `Proposed` or `Accepted` (default `Proposed`; a *new* ADR is never created as
   `Superseded` — that value is only ever set on an *existing* ADR, by Phase 4b, on a later ADR
   superseding it). If the user states this ADR supersedes an existing one, ask which of the two
   the new ADR should carry — `Accepted` is the common case, but `Proposed` is valid too — then run
   Phase 4b after Phase 4
5. **Consequences** — what becomes easier or harder as a result, including negative tradeoffs
6. **Alternatives considered** — what else was on the table and why it lost

### Phase 4: Write the ADR

Fill `references/template.md` and write to
`docs/features/<key>/adr-<NNN>-<title>.md`. The **H1 must literally contain the string `ADR`**
(e.g. `# ADR-001: <Title>`) — `doc-taxonomy.json`'s `heading_signals` for the `adr` type is
`["Decision Record", "ADR", "架構決策"]`. For a well-formed `adr-<NNN>-<title>.md` filename,
`classifyByPath`'s `semantic_pattern` alone already reaches `medium` confidence, so the two signals
are not combined to jointly lift confidence — `doc-classifier.js` only consults `heading_signals`
when `scanFeatureDocs` is called with its `deep` option (not a CLI flag; the only production
caller, `scripts/lib/feature-resolver.js:29`, does not pass it, so this path is currently exercised
only by direct calls and tests), and only when `classifyByPath` already returned the fallback type.
Even then, a correct H1 is **necessary but not sufficient**: `classifyByHeading` lowercases the
first 20 lines and returns the *first* taxonomy type (in array order) whose signal appears anywhere
in them — ten types precede `adr` (index 10 in `doc-taxonomy.json`): the five lifecycle types plus
`review-log`, `fp-brief`, `tech-brief`, `checklist`, and `runbook`. The last two matter most here,
since they are two of the four the classification guard below defends against on the *filename* path
— an ADR about operational tooling plausibly has "runbook", "checklist", or "SOP" in its own
Context, misclassifying it on the *heading* path even with a perfect H1. This claim is pinned by a
test, not left as prose alone (`test/skills/adr.test.js` calls `classifyByHeading` directly), since
it depends on `doc-taxonomy.json`'s array order and would silently go stale on a reorder. The H1
requirement is worth keeping as a best-effort signal for the fallback case, not a guarantee.

Remove `references/template.md`'s two commented-out placeholder blocks (the `<!-- -->` slots
documenting where a future Supersedes/Superseded-by line goes) — unless Phase 4b runs in the same
pass, in which case replace the relevant one with the real line instead of deleting it blank.

**Classification guard — run before writing, not after.** `doc-taxonomy.json`'s type list is
checked in array order, and four types sit *before* `adr` (index 10) with patterns a free-text
`<title>` can actually hit: `checklist` and `runbook` carry **unanchored** substring patterns
(`^checklist-|確認事項|checklist`, `^runbook-|操作手冊|runbook` — the word anywhere in the filename
matches, e.g. `adr-002-runbook-automation.md`), and `fp-brief`/`tech-brief` carry **suffix**
patterns (`-fp-brief\.md$`, `-tech-brief\.md$`) that match if the title happens to end in that
phrase, e.g. `adr-003-vendor-tech-brief.md`. All four classify silently as that other type instead
of `adr`. `<title>` is free text from Phase 3, so this is reachable, not theoretical.

**Pass the bare filename only, never the full write path.** `classifyByPath` has no
`basename()` step and is path-sensitive: run it against the *full* target path
(`docs/features/<key>/adr-<NNN>-<title>.md`) and two independent things break — the leading
`docs/features/` segment defeats the `^adr-` anchor entirely (falls to the taxonomy fallback
type), and if `<key>` itself contains a colliding word (e.g. a feature directory named
`deploy-runbook`) the *directory* name — not the title — decides the result. The guard command
below strips to the basename itself with `path.basename()` so it gives the same, correct answer
whether the value passed happens to be the bare filename or the full path — verified both ways:

```bash
node -e "const {basename}=require('path'); console.log(require('./scripts/lib/doc-classifier').classifyByPath(basename(process.argv[1])).type)" \
  "adr-<NNN>-<title>.md"
```

If the printed type is not `adr`, do **not** write the file under that name. Ask the user to
rephrase the title to avoid the colliding word, recompute, and re-check — up to 3 attempts. Never
silently write a misclassified ADR; this is not the same failure as an unresolved feature, so it
does not route through the Phase 1 Gate: Need Human on the first miss — it is a title-collision
retry loop, not a missing-input one. After 3 failed rephrasings, stop retrying and escalate:
**Gate: Need Human** — a user who keeps proposing colliding titles is the case this bounds; the
`path.basename()` fix above already rules out the directory-name-collision case (the feature key
itself contains the colliding word), so this cap only needs to bound retitling attempts, not an
unfixable path.

### Phase 4b: Superseded Linking (only when this ADR replaces an existing one)

Bidirectional — both files change, in the same pass. Both edits target the `> **Status**` /
`> **Created**` blockquote at the top of `references/template.md` — never the `## Status` section
further down, which is a static legend explaining the three enum values, not a per-ADR value slot:

| File | Edit |
|------|------|
| New ADR (just written) | Add `> **Supersedes**: [adr-<OLD>](<path>)` as a new blockquote line, directly below `> **Created**` |
| Old ADR (existing file) | Change the existing `> **Status**: <value>` line to `> **Status**: Superseded`; add `> **Superseded by**: [adr-<NEW>](<path>)` as a new blockquote line below it. If the old ADR predates this skill and has no `> **Status**:` line at all, add one rather than assuming it exists |

`<path>` is relative to the file doing the linking, and the old ADR may resolve to either
directory found in Phase 2's scan — get this from wherever Phase 2 actually found the old number,
don't assume root:

| Old ADR found in | New ADR's `<path>` | Old ADR's own `<path>` (self-referencing the new one) |
|-------------------|---------------------|----------------------------------------------------------|
| feature root | `./adr-<OLD>-<old-title>.md` | `./adr-<NEW>-<new-title>.md` |
| `archived/` | `./archived/adr-<OLD>-<old-title>.md` | `../adr-<NEW>-<new-title>.md` |

If the old ADR named as superseded does not exist at either location, **Gate: Need Human** — do
not silently skip the link or guess a different file. If the old ADR is already `Superseded` by a
third ADR, **Gate: Need Human** — do not overwrite the existing link or append a second one.

### Phase 5: Report

Output the path written, the assigned number, and (if Phase 4b ran) both files touched with a
one-line diff summary of the added link.

## Numbering & Classification — Verification

- `adr-001-<title>.md` is the first-ADR filename shape (AC edge case)
- Number is a **numeric** max over `^adr-(\d+)-` (case-insensitive) across root + `archived/`,
  computed by `skills/adr/scripts/next-adr-number.js`, never hand-applied or a string sort
- `docs/features/<key>/adr-<NNN>-<title>.md` classifies via `doc-classifier.js`'s `semantic_pattern`
  (`^adr-|decision`) at step 4 — `medium` confidence, **not** the step-7 fallback type — **only when
  the title contains no word matching another type's pattern**; the Phase 4 classification guard
  exists because titles containing `runbook`, `checklist`, or ending in `-fp-brief`/`-tech-brief`
  demonstrably do not

## Output

- New ADR file at `docs/features/<feature>/adr-<NNN>-<title>.md`
- If superseding: the prior ADR file also modified (Status flip + Superseded-by link)

## Verification Checklist

- [ ] Filename matches `adr-<NNN>-<title>.md`, 3-digit zero-padded
- [ ] Number computed via `skills/adr/scripts/next-adr-number.js` (numeric max across root + `archived/`, not
      lexical sort, not hand-applied)
- [ ] Classification guard run before writing: `classifyByPath(filename).type === 'adr'`
- [ ] H1 heading contains `ADR`
- [ ] Template fields present: Context, Decision, Status, Consequences, Alternatives considered
- [ ] If Superseded: both the new and the old ADR cross-link, in the same pass, using the path
      table for whichever directory Phase 2 actually found the old ADR in
- [ ] No feature resolved → Gate: Need Human, not a guess

## After Creation

New to this feature's docs directory? Register it once via `/tech-spec` or `/create-request` so the
feature has a tech spec to link the ADR back to (`adr` is not one of `doc-classifier.js`'s
`canonical_roles`, so no tooling does this automatically) — an ADR with no owning tech spec is
still valid, just harder for a reader to trace to the feature's
broader design.

## References

- `rules/docs-numbering.md` § Ancillary docs — the `adr-<number>-<title>.md` pattern this skill implements
- `scripts/config/doc-taxonomy.json` — `adr` type entry (`ancillary` namespace, `semantic_pattern`, `heading_signals`); also where `runbook`/`checklist`'s unanchored patterns and `fp-brief`/`tech-brief`'s suffix patterns live, the reason for the Phase 4 classification guard
- `scripts/lib/doc-classifier.js` — `classifyByPath`, used by the Phase 4 classification guard
- `references/template.md` — the ADR template this skill fills
- `skills/adr/scripts/next-adr-number.js` — the numbering function Phase 2 runs, unit-tested directly by `test/skills/adr.test.js`

## Examples

```
Input: /adr --feature auth Title: Use JWT over session cookies
Action:
  1. Resolve feature → docs/features/auth/
  2. Scan root + archived/ for adr-* → none found → next number 001
  3. Gather Context/Decision/Status/Consequences/Alternatives
  4. Write docs/features/auth/adr-001-use-jwt-over-session-cookies.md
  5. Report: adr-001 written, Status: Proposed
```

```
Input: /adr --feature auth Title: Use opaque tokens (supersedes adr-001)
Action:
  1. Resolve feature → docs/features/auth/
  2. Scan root + archived/ → adr-001 exists → next number 002
  3. Gather content, Status: Accepted (this ADR), supersedes adr-001
  4. Write docs/features/auth/adr-002-use-opaque-tokens.md with "Supersedes: adr-001" line
  4b. Edit adr-001-use-jwt-over-session-cookies.md: Status → Superseded, add "Superseded by: adr-002" line
  5. Report: adr-002 written, adr-001 updated (Status + link)
```