Skip to content

Commit efdf21a

Browse files
authored
chore(planning): adopt decisions/ ADRs (#128)
1 parent 1eed3d7 commit efdf21a

5 files changed

Lines changed: 72 additions & 17 deletions

File tree

CLAUDE.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,7 @@ See `[project.optional-dependencies]` in `pyproject.toml` for the full extras ma
6363

6464
## Workflow
6565

66-
Per-feature: brainstorming → spec in `planning/changes/YYYY-MM-DD.NN-<slug>/design.md` → writing-plans → plan in `planning/changes/YYYY-MM-DD.NN-<slug>/plan.md` → executing-plans / subagent-driven-development → requesting-code-review → finishing-a-development-branch. Each change is a folder bundle; `<slug>` is a kebab-case description, not a story ID; `.NN` is a zero-padded intra-day counter that breaks same-date ties so the timeline sorts stably. The implementing PR sets `status: shipped` and fills `pr` / `outcome` in the branch, alongside the code and promotes its conclusions into the affected `architecture/<capability>.md` — that hand-edit keeps `architecture/` true and is the only ship-time step; there is no folder move. The change listing is generated — run `just index`. See [`planning/README.md`](planning/README.md) for the conventions and [`planning/_templates/`](planning/_templates/) for copy-and-fill starting points.
66+
Per-feature: brainstorming → spec in `planning/changes/YYYY-MM-DD.NN-<slug>/design.md` → writing-plans → plan in `planning/changes/YYYY-MM-DD.NN-<slug>/plan.md` → executing-plans / subagent-driven-development → requesting-code-review → finishing-a-development-branch. Each change is a folder bundle; `<slug>` is a kebab-case description, not a story ID; `.NN` is a zero-padded intra-day counter that breaks same-date ties so the timeline sorts stably. The implementing PR sets `status: shipped` and fills `pr` / `outcome` in the branch, alongside the code and promotes its conclusions into the affected `architecture/<capability>.md` — that hand-edit keeps `architecture/` true and is the only ship-time step; there is no folder move. The change listing is generated — run `just index`. A design decision taken without a code change — especially a candidate rejected with a load-bearing reason — is recorded as `planning/decisions/YYYY-MM-DD-<slug>.md` (the `decision.md` template, frontmatter `status: accepted|superseded`), each with a **Revisit trigger** so future reviews don't re-litigate it; listed by `just index`. See [`planning/README.md`](planning/README.md) for the conventions and [`planning/_templates/`](planning/_templates/) for copy-and-fill starting points.
6767

6868
**Spec** (`design.md`) captures the *thinking* — why, what the design is, trade-offs, scope. Written before code; rarely revised after merge. **Plan** (`plan.md`) captures the *sequencing* — the ordered checklist an executor walks; references the spec for the "why". **`architecture/`** captures the *invariants* of shipped systems — the living truth, promoted in the implementing PR alongside the code. A plan paragraph that would still read correctly with all task numbers and checkboxes removed is design content and belongs in the spec.
6969

planning/README.md

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,9 @@ into `design.md` + `plan.md`.
5353
- **`design.md`** — the spec: the *thinking* (why, design, trade-offs, scope).
5454
- **`plan.md`** — the plan: the *sequencing* (the executor's task checklist).
5555
- **`change.md`** — both, condensed, for the lightweight lane.
56+
- **`decisions/<YYYY-MM-DD>-<slug>.md`** — one file per design decision taken
57+
(especially options *rejected*), each with a revisit trigger, so reviews don't
58+
re-litigate them; listed by `just index`.
5659
- **`releases/<semver>.md`** — per-release user-facing notes.
5760
- **`audits/<date>-<slug>.md`** — findings from a code/docs/bug-hunt sweep;
5861
spawns fix changes.
@@ -65,21 +68,26 @@ Templates live in [`_templates/`](_templates/).
6568

6669
`design.md` / `change.md`: `status` (draft|approved|shipped|superseded),
6770
`date`, `slug`, `summary` (single line), `supersedes`, `superseded_by`, `pr`,
68-
`outcome`. `plan.md`: `status`, `date`, `slug`, `spec`, `pr`. Files in
71+
`outcome`. `plan.md`: `status`, `date`, `slug`, `spec`, `pr`.
72+
`decisions/*.md`: `status` (accepted|superseded), `date`, `slug`, `summary`,
73+
`supersedes`, `superseded_by`, `pr`. Files in
6974
`architecture/` carry **no** frontmatter — living prose, dated by git.
7075

7176
## Index
7277

73-
The change listing is **generated**, not maintained — run `just index` to
74-
print it (grouped by `status`: In progress / Shipped / Superseded). The
75-
frontmatter in each bundle is the single source of truth; there is no
76-
committed copy to drift.
78+
The listing is **generated**, not maintained — run `just index` to print it:
79+
changes grouped by `status` (In progress / Shipped / Superseded), then
80+
decisions newest-first. The frontmatter in each bundle / decision file is the
81+
single source of truth; there is no committed copy to drift.
7782

7883
## Other
7984

8085
- **[`architecture/`](../architecture/)** at the repo root — the living
8186
capability truth (config model, instruments, bootstrappers). The promotion
8287
target on every ship.
88+
- **[decisions/](decisions/)** — design decisions taken (and alternatives
89+
rejected), each with a revisit trigger, so reviews don't re-litigate them;
90+
indexed by `just index`.
8391
- **[audits/](audits/)** — findings reports (2026-05-31 bug+refactor audit,
8492
2026-06-05 bug audit v2).
8593
- **[retros/](retros/)** — what we learned after a body of work.

planning/_templates/decision.md

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
---
2+
status: accepted # accepted | superseded
3+
date: YYYY-MM-DD
4+
slug: my-decision
5+
summary: One line — shown in `just index`.
6+
supersedes: null
7+
superseded_by: null
8+
pr: null # PR/commit where the decision was made or recorded
9+
---
10+
11+
# One-line capitalized title
12+
13+
**Decision:** What was decided, in a sentence.
14+
15+
## Context
16+
17+
Why this came up; the options that were on the table.
18+
19+
## Decision & rationale
20+
21+
The call and why — including why the alternatives were rejected. Enough that a
22+
future explorer doesn't re-litigate it.
23+
24+
## Revisit trigger
25+
26+
The concrete signal that should reopen this decision.

planning/decisions/.gitkeep

Whitespace-only changes.

planning/index.py

Lines changed: 32 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,20 @@
11
# ruff: noqa: INP001, D212 # planning/ is not a Python package; D212/D213 conflict differs from faststream-outbox
22
"""
3-
Generate the planning change index from bundle frontmatter.
3+
Generate the planning index from frontmatter.
44
5-
Run via ``just index``. Globs ``planning/changes/*/``, reads each bundle's
6-
``design.md`` (falling back to ``change.md``) frontmatter, and prints a
7-
Markdown listing grouped by lifecycle status to stdout. Never writes a file:
8-
the listing is a query over the bundles, not a committed artifact.
5+
Run via ``just index``. Globs ``planning/changes/*/`` (each bundle's ``design.md``,
6+
falling back to ``change.md``) and ``planning/decisions/*.md``, reads their
7+
frontmatter, and prints a Markdown listing to stdout — changes grouped by lifecycle
8+
status, then decisions newest-first. Never writes a file: the listing is a query over
9+
the files, not a committed artifact.
910
"""
1011

1112
import pathlib
1213
import sys
1314

1415

1516
CHANGES_DIR = pathlib.Path(__file__).parent / "changes"
17+
DECISIONS_DIR = pathlib.Path(__file__).parent / "decisions"
1618
GROUPS: tuple[tuple[str, tuple[str, ...]], ...] = (
1719
("In progress", ("draft", "approved")),
1820
("Shipped", ("shipped",)),
@@ -55,8 +57,23 @@ def load_bundles() -> list[dict[str, str]]:
5557
return bundles
5658

5759

60+
def load_decisions() -> list[dict[str, str]]:
61+
"""Read frontmatter from every decision file under ``DECISIONS_DIR``."""
62+
decisions: list[dict[str, str]] = []
63+
if not DECISIONS_DIR.is_dir():
64+
return decisions
65+
for path in sorted(DECISIONS_DIR.glob("*.md")):
66+
if path.name == "README.md" or path.name.startswith("_"):
67+
continue
68+
fields = parse_frontmatter(path.read_text(encoding="utf-8"))
69+
fields["path"] = f"decisions/{path.name}"
70+
fields["name"] = path.stem
71+
decisions.append(fields)
72+
return decisions
73+
74+
5875
def format_row(bundle: dict[str, str]) -> str:
59-
"""Render one bundle as a Markdown list item."""
76+
"""Render one bundle or decision as a Markdown list item."""
6077
slug = bundle.get("slug", "?")
6178
path = bundle.get("path", "")
6279
pr = bundle.get("pr") or "—"
@@ -70,24 +87,28 @@ def format_row(bundle: dict[str, str]) -> str:
7087
return line
7188

7289

73-
def render(bundles: list[dict[str, str]]) -> str:
74-
"""Render the full grouped Markdown listing."""
75-
out = ["# Change index", "", "_Generated by `just index` — do not edit._", ""]
90+
def render(bundles: list[dict[str, str]], decisions: list[dict[str, str]]) -> str:
91+
"""Render the full Markdown listing: changes by status, then decisions."""
92+
out = ["# Planning index", "", "_Generated by `just index` — do not edit._", "", "## Changes", ""]
7693
for title, statuses in GROUPS:
77-
out += [f"## {title}", ""]
94+
out += [f"### {title}", ""]
7895
rows = sorted(
7996
(b for b in bundles if b.get("status") in statuses),
8097
key=lambda b: b.get("name", ""),
8198
reverse=True,
8299
)
83100
out += [format_row(b) for b in rows] if rows else ["_None._"]
84101
out.append("")
102+
out += ["## Decisions", ""]
103+
decision_rows = sorted(decisions, key=lambda d: d.get("name", ""), reverse=True)
104+
out += [format_row(d) for d in decision_rows] if decision_rows else ["_None._"]
105+
out.append("")
85106
return "\n".join(out).rstrip() + "\n"
86107

87108

88109
def main() -> int:
89110
"""Print the listing to stdout."""
90-
sys.stdout.write(render(load_bundles()))
111+
sys.stdout.write(render(load_bundles(), load_decisions()))
91112
return 0
92113

93114

0 commit comments

Comments
 (0)