diff --git a/CLAUDE.md b/CLAUDE.md index 847ee4c..3497d51 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -63,7 +63,7 @@ See `[project.optional-dependencies]` in `pyproject.toml` for the full extras ma ## Workflow -Per-feature: brainstorming → spec in `planning/changes/YYYY-MM-DD.NN-/design.md` → writing-plans → plan in `planning/changes/YYYY-MM-DD.NN-/plan.md` → executing-plans / subagent-driven-development → requesting-code-review → finishing-a-development-branch. Each change is a folder bundle; `` 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/.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. +Per-feature: brainstorming → spec in `planning/changes/YYYY-MM-DD.NN-/design.md` → writing-plans → plan in `planning/changes/YYYY-MM-DD.NN-/plan.md` → executing-plans / subagent-driven-development → requesting-code-review → finishing-a-development-branch. Each change is a folder bundle; `` 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/.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-.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. **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. diff --git a/planning/README.md b/planning/README.md index fa80468..075ab36 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,21 +68,26 @@ 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 +`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 - **[`architecture/`](../architecture/)** at the repo root — the living capability truth (config model, instruments, bootstrappers). 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 (2026-05-31 bug+refactor audit, 2026-06-05 bug audit v2). - **[retros/](retros/)** — what we learned after a body of work. 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