diff --git a/CLAUDE.md b/CLAUDE.md index 21f0973..1546601 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -19,7 +19,11 @@ that breaks same-date ties. The implementing PR sets `status: shipped` and fills `pr` / `outcome` in the branch, alongside the code and the `architecture/.md` promotion — that hand-edit is the only ship-time step; there is no folder move. The change listing is generated — run `just index` -(no committed Index). +(no committed 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`. **Three lanes.** Scale the artifact to the change. **Full** — a `design.md` + `plan.md` bundle — for real design judgment, a new file/module, a public-API diff --git a/planning/README.md b/planning/README.md index 4397ce9..3ef03e7 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,12 +68,22 @@ 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/)** — living capability prose; the truth + home updated in every implementing PR. +- **[decisions/](decisions/)** — design decisions taken (and alternatives + rejected), each with a revisit trigger, so reviews don't re-litigate them; + indexed by `just index`. 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 80ef1f4..d38dffd 100644 --- a/planning/index.py +++ b/planning/index.py @@ -1,11 +1,12 @@ # ruff: noqa: INP001 # planning/ is not a Python package; this is a standalone script """ -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 diff --git a/pyproject.toml b/pyproject.toml index d6ca200..72465da 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -24,7 +24,7 @@ dependencies = [ "pydantic-settings", "modern-di-typer", "httpx2", - "httpware[pydantic]>=0.12.0", + "httpware[pydantic]>=0.15.0", ] [project.scripts] diff --git a/semvertag/ioc.py b/semvertag/ioc.py index 446e474..dc42a8e 100644 --- a/semvertag/ioc.py +++ b/semvertag/ioc.py @@ -18,7 +18,7 @@ _GITHUB_ACCEPT: typing.Final = "application/vnd.github+json" _GITHUB_API_VERSION: typing.Final = "2022-11-28" _RETRY_STATUS_CODES: typing.Final = frozenset({408, 429, 500, 502, 503, 504}) -_MAX_ERROR_BODY_BYTES: typing.Final = 1024 * 1024 # 1 MiB — defensive cap on 4xx/5xx error bodies +_MAX_RESPONSE_BODY_BYTES: typing.Final = 1024 * 1024 # 1 MiB — defensive cap on response bodies def _build_gitlab_client(settings: Settings) -> httpware.Client: @@ -27,7 +27,7 @@ def _build_gitlab_client(settings: Settings) -> httpware.Client: timeout=settings.request_timeout, headers={_GITLAB_TOKEN_HEADER: settings.gitlab.token.get_secret_value()}, middleware=[httpware.Retry(retry_status_codes=_RETRY_STATUS_CODES)], - max_error_body_bytes=_MAX_ERROR_BODY_BYTES, + max_response_body_bytes=_MAX_RESPONSE_BODY_BYTES, ) @@ -41,7 +41,7 @@ def _build_github_client(settings: Settings) -> httpware.Client: "X-GitHub-Api-Version": _GITHUB_API_VERSION, }, middleware=[httpware.Retry(retry_status_codes=_RETRY_STATUS_CODES)], - max_error_body_bytes=_MAX_ERROR_BODY_BYTES, + max_response_body_bytes=_MAX_RESPONSE_BODY_BYTES, ) diff --git a/tests/unit/test_ioc.py b/tests/unit/test_ioc.py index dcce822..bb78d4c 100644 --- a/tests/unit/test_ioc.py +++ b/tests/unit/test_ioc.py @@ -74,11 +74,11 @@ def test_container_resolves_gitlab_provider_when_settings_provider_is_gitlab() - assert provider.name == "gitlab" -def test_gitlab_client_is_built_with_error_body_cap() -> None: +def test_gitlab_client_is_built_with_response_body_cap() -> None: client: typing.Final = ioc._build_gitlab_client(_settings()) - assert client._max_error_body_bytes == ioc._MAX_ERROR_BODY_BYTES + assert client._max_response_body_bytes == ioc._MAX_RESPONSE_BODY_BYTES -def test_github_client_is_built_with_error_body_cap() -> None: +def test_github_client_is_built_with_response_body_cap() -> None: client: typing.Final = ioc._build_github_client(_settings()) - assert client._max_error_body_bytes == ioc._MAX_ERROR_BODY_BYTES + assert client._max_response_body_bytes == ioc._MAX_RESPONSE_BODY_BYTES diff --git a/tests/unit/test_providers_errors.py b/tests/unit/test_providers_errors.py index 422ac80..692ea7f 100644 --- a/tests/unit/test_providers_errors.py +++ b/tests/unit/test_providers_errors.py @@ -288,7 +288,7 @@ def test_translate_create_tag_github_other_422_becomes_generic_config_error() -> def test_translate_gitlab_response_too_large_becomes_provider_api_error() -> None: - exc = httpware.ResponseTooLargeError(status_code=413, limit=1_048_576, content_length=5_000_000) + exc = httpware.ResponseTooLargeError(status_code=413, limit=1_048_576, content_length=5_000_000, reason="declared") result = translate_gitlab(exc, project_id=_PROJECT_ID) assert isinstance(result, ProviderAPIError) assert "GitLab" in str(result) @@ -297,7 +297,7 @@ def test_translate_gitlab_response_too_large_becomes_provider_api_error() -> Non def test_translate_github_response_too_large_becomes_provider_api_error() -> None: - exc = httpware.ResponseTooLargeError(status_code=413, limit=1_048_576, content_length=5_000_000) + exc = httpware.ResponseTooLargeError(status_code=413, limit=1_048_576, content_length=5_000_000, reason="declared") result = translate_github(exc, repo="owner/repo") assert isinstance(result, ProviderAPIError) assert "GitHub" in str(result) @@ -306,7 +306,7 @@ def test_translate_github_response_too_large_becomes_provider_api_error() -> Non def test_translate_response_too_large_with_undeclared_length_omits_byte_count() -> None: - exc = httpware.ResponseTooLargeError(status_code=413, limit=1_048_576, content_length=None) + exc = httpware.ResponseTooLargeError(status_code=413, limit=1_048_576, content_length=None, reason="streamed") result = translate_gitlab(exc, project_id=_PROJECT_ID) assert isinstance(result, ProviderAPIError) assert "undeclared number of bytes" in str(result)