diff --git a/CLAUDE.md b/CLAUDE.md index 0de10ce..b23c890 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -11,13 +11,14 @@ Guidance for AI agents (Claude Code, etc.) working in this repository. - [`architecture/`](architecture/) (repo root) — the per-capability living truth (overview, client, middleware, decoders, errors, resilience, optional extras, testing); the promotion target on every ship. **Read the relevant file before changing that capability.** - [`planning/README.md`](planning/README.md) — the planning conventions (two axes, change bundles, three lanes, frontmatter) + the change Index. - [`planning/changes//`](planning/changes/) — per-change bundles (`design.md` + `plan.md`, or `change.md` for the lightweight lane). +- [`planning/decisions/-.md`](planning/decisions/) — design decisions taken (esp. options rejected with a load-bearing reason), each with a Revisit trigger; listed by `just index`. - [`planning/audits/`](planning/audits/) — findings reports + `scripts/` tooling. - [`planning/retros/`](planning/retros/) — retrospectives. - [`planning/releases/`](planning/releases/) — per-version release notes (also published on GitHub Releases). - [`planning/deferred.md`](planning/deferred.md) — review-surfaced, not-yet-actionable items. - [`planning/_templates/`](planning/_templates/) — design/plan/change templates. -**Per-feature workflow:** brainstorming → `design.md` in `planning/changes//` → writing-plans → `plan.md` in the same bundle → executing-plans (or subagent-driven-development) → requesting-code-review → finishing-a-development-branch. On ship, promote the conclusions into the affected `architecture/.md` by hand and set `status: shipped` + `pr` + `outcome` in the implementing PR — there is no folder move. The change listing is generated: run `just index`. Topic slugs are kebab-case descriptions (`msgspec-decoder-adapter`), not story IDs. +**Per-feature workflow:** brainstorming → `design.md` in `planning/changes//` → writing-plans → `plan.md` in the same bundle → executing-plans (or subagent-driven-development) → requesting-code-review → finishing-a-development-branch. On ship, promote the conclusions into the affected `architecture/.md` by hand and set `status: shipped` + `pr` + `outcome` in the implementing PR — 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-.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`. Topic slugs are kebab-case descriptions (`msgspec-decoder-adapter`), not story IDs. ## Commands diff --git a/planning/README.md b/planning/README.md index 32c8953..401b955 100644 --- a/planning/README.md +++ b/planning/README.md @@ -53,6 +53,9 @@ into `design.md` + `plan.md`. - **`design.md`** — the spec: the *thinking* (why, design, trade-offs, scope). - **`plan.md`** — the plan: the *sequencing* (the executor's task checklist). - **`change.md`** — both, condensed, for the lightweight lane. +- **`decisions/-.md`** — one file per design decision taken + (especially options *rejected*), each with a revisit trigger, so reviews don't + re-litigate them; listed by `just index`. - **`releases/.md`** — per-release user-facing notes. - **`audits/-.md`** — findings from a code/docs/bug-hunt sweep; spawns fix changes. @@ -65,15 +68,17 @@ Templates live in [`_templates/`](_templates/). `design.md` / `change.md`: `status` (draft|approved|shipped|superseded), `date`, `slug`, `summary` (single line), `supersedes`, `superseded_by`, `pr`, -`outcome`. `plan.md`: `status`, `date`, `slug`, `spec`, `pr`. Files in -`architecture/` carry **no** frontmatter — living prose, dated by git. +`outcome`. `plan.md`: `status`, `date`, `slug`, `spec`, `pr`. +`decisions/*.md`: `status` (accepted|superseded), `date`, `slug`, `summary`, +`supersedes`, `superseded_by`, `pr`. +Files in `architecture/` carry **no** frontmatter — living prose, dated by git. ## Index -The change listing is **generated**, not maintained — run `just index` to -print it (grouped by `status`: In progress / Shipped / Superseded). The -frontmatter in each bundle is the single source of truth; there is no -committed copy to drift. +The listing is **generated**, not maintained — run `just index` to print it: +changes grouped by `status` (In progress / Shipped / Superseded), then +decisions newest-first. The frontmatter in each bundle / decision file is the +single source of truth; there is no committed copy to drift. ## Other @@ -81,6 +86,9 @@ committed copy to drift. per-capability truth (overview, client, middleware, decoders, errors, resilience, optional extras, testing). This is the promotion target on every ship. +- **[decisions/](decisions/)** — design decisions taken (and alternatives + rejected), each with a revisit trigger, so reviews don't re-litigate them; + indexed by `just index`. - **[audits/](audits/)** — findings reports (deep audit + delta audits) and their `scripts/` tooling. - **[deferred.md](deferred.md)** — real-but-unscheduled items with revisit diff --git a/planning/_templates/decision.md b/planning/_templates/decision.md new file mode 100644 index 0000000..940fb37 --- /dev/null +++ b/planning/_templates/decision.md @@ -0,0 +1,26 @@ +--- +status: accepted # accepted | superseded +date: YYYY-MM-DD +slug: my-decision +summary: One line — shown in `just index`. +supersedes: null +superseded_by: null +pr: null # PR/commit where the decision was made or recorded +--- + +# One-line capitalized title + +**Decision:** What was decided, in a sentence. + +## Context + +Why this came up; the options that were on the table. + +## Decision & rationale + +The call and why — including why the alternatives were rejected. Enough that a +future explorer doesn't re-litigate it. + +## Revisit trigger + +The concrete signal that should reopen this decision. diff --git a/planning/decisions/.gitkeep b/planning/decisions/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/planning/index.py b/planning/index.py index 6621d3f..392860a 100644 --- a/planning/index.py +++ b/planning/index.py @@ -1,11 +1,12 @@ # ruff: noqa: INP001, D212 # planning/ is not a Python package; D212/D213 conflict differs from faststream-outbox """ -Generate the planning change index from bundle frontmatter. +Generate the planning index from frontmatter. -Run via ``just index``. Globs ``planning/changes/*/``, reads each bundle's -``design.md`` (falling back to ``change.md``) frontmatter, and prints a -Markdown listing grouped by lifecycle status to stdout. Never writes a file: -the listing is a query over the bundles, not a committed artifact. +Run via ``just index``. Globs ``planning/changes/*/`` (each bundle's ``design.md``, +falling back to ``change.md``) and ``planning/decisions/*.md``, reads their +frontmatter, and prints a Markdown listing to stdout — changes grouped by lifecycle +status, then decisions newest-first. Never writes a file: the listing is a query over +the files, not a committed artifact. """ import pathlib @@ -13,6 +14,7 @@ CHANGES_DIR = pathlib.Path(__file__).parent / "changes" +DECISIONS_DIR = pathlib.Path(__file__).parent / "decisions" GROUPS: tuple[tuple[str, tuple[str, ...]], ...] = ( ("In progress", ("draft", "approved")), ("Shipped", ("shipped",)), @@ -55,8 +57,23 @@ def load_bundles() -> list[dict[str, str]]: return bundles +def load_decisions() -> list[dict[str, str]]: + """Read frontmatter from every decision file under ``DECISIONS_DIR``.""" + decisions: list[dict[str, str]] = [] + if not DECISIONS_DIR.is_dir(): + return decisions + for path in sorted(DECISIONS_DIR.glob("*.md")): + if path.name == "README.md" or path.name.startswith("_"): + continue + fields = parse_frontmatter(path.read_text(encoding="utf-8")) + fields["path"] = f"decisions/{path.name}" + fields["name"] = path.stem + decisions.append(fields) + return decisions + + def format_row(bundle: dict[str, str]) -> str: - """Render one bundle as a Markdown list item.""" + """Render one bundle or decision as a Markdown list item.""" slug = bundle.get("slug", "?") path = bundle.get("path", "") pr = bundle.get("pr") or "—" @@ -70,11 +87,11 @@ def format_row(bundle: dict[str, str]) -> str: return line -def render(bundles: list[dict[str, str]]) -> str: - """Render the full grouped Markdown listing.""" - out = ["# Change index", "", "_Generated by `just index` — do not edit._", ""] +def render(bundles: list[dict[str, str]], decisions: list[dict[str, str]]) -> str: + """Render the full Markdown listing: changes by status, then decisions.""" + out = ["# Planning index", "", "_Generated by `just index` — do not edit._", "", "## Changes", ""] for title, statuses in GROUPS: - out += [f"## {title}", ""] + out += [f"### {title}", ""] rows = sorted( (b for b in bundles if b.get("status") in statuses), key=lambda b: b.get("name", ""), @@ -82,12 +99,16 @@ def render(bundles: list[dict[str, str]]) -> str: ) out += [format_row(b) for b in rows] if rows else ["_None._"] out.append("") + out += ["## Decisions", ""] + decision_rows = sorted(decisions, key=lambda d: d.get("name", ""), reverse=True) + out += [format_row(d) for d in decision_rows] if decision_rows else ["_None._"] + out.append("") return "\n".join(out).rstrip() + "\n" def main() -> int: """Print the listing to stdout.""" - sys.stdout.write(render(load_bundles())) + sys.stdout.write(render(load_bundles(), load_decisions())) return 0