31 added, 9 removed. Audit A to A.
---
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>]`.
+ `@skills/create-request/references/feature-context-resolution.md` — the single copy since
+ doc-review-phasing r2 merged the two that had drifted apart; `/tech-spec` now keeps its own
+ command-free native-cascade reference in its own bundle instead of a second copy of this one —
+ canonical implementation `scripts/lib/feature-resolver.js`, invoked as
+ `node scripts/resolve-feature.js [--feature <key>]`.
+ **`scan_error` gate.** `scan_error !== false` ⇒ the source sets are **unknown, not empty** —
+ report it and take the ⚠️ Need Human exit rather than recording a decision against a corpus you could not read — an ADR is a
+ time-stamped claim about what was true, and one written from an unreadable corpus is wrong forever. Gate on `!== false`, not
+ `=== true`: a `{}` payload from a shell fallback carries no such field at all, and a non-null `key`
+ is not evidence the sets are complete — `scan_error` rides alongside a resolved key.
+
+ **The wrapper, not the CLI.** `resolve-feature.js` is the single owner of the failure payload: it
+ exits 0 and emits the full shape with `scan_error: true` however the CLI fails — nonzero exit,
+ signal, partial write, or a payload that is not the agreed shape; it cannot cover `node` itself
+ being missing, since nothing running under node can — where the CLI invoked
+ directly can die mid-write and a `|| echo '{}'` fallback of your own emits a payload with no
+ `scan_error` field at all. This skill briefly carried an exemption on the grounds that its
+ `allowed-tools` could not reach `bash`. The fix was not to widen the tool list but to make the
+ entrypoint reachable: `resolve-feature.js` runs under the `Bash(node:*)` this skill already grants,
+ so there is one failure contract and no exemptions, at no cost in permissions. (`Bash(node:*)` is
+ not universal either — `/codex-code-review` grants bash and no node, and keeps the shell shim. The
+ rule is that a skill instructs the entrypoint *it* is permitted to run.) This skill reads `key`,
+ `confidence` and `docs_path` only and consumes none of the four source sets, so the `scan_error`
+ gate the research skills carry does not bind it — but a `{}` reply still means the invocation failed
+ and is never an empty corpus.
+
**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
+ (`scripts/lib/feature-resolver.js` § `probe`). 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
+ at `scripts/lib/feature-resolver.js:13` before it ever reaches `key`. Level 3b (a changed path
+ under `skills/<key>/`, from line 140) 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
+ change), otherwise to Level 5 (`key: null`). the resolver 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 |
+ | `key` is `null` (the resolver 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)
```