Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<capability>.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-<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`.

**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
Expand Down
23 changes: 18 additions & 5 deletions planning/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<YYYY-MM-DD>-<slug>.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/<semver>.md`** — per-release user-facing notes.
- **`audits/<date>-<slug>.md`** — findings from a code/docs/bug-hunt sweep;
spawns fix changes.
Expand All @@ -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`.
26 changes: 26 additions & 0 deletions planning/_templates/decision.md
Original file line number Diff line number Diff line change
@@ -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.
Empty file added planning/decisions/.gitkeep
Empty file.
43 changes: 32 additions & 11 deletions planning/index.py
Original file line number Diff line number Diff line change
@@ -1,18 +1,20 @@
# 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
import sys


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",)),
Expand Down Expand Up @@ -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 "—"
Expand All @@ -70,24 +87,28 @@ 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", ""),
reverse=True,
)
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


Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ dependencies = [
"pydantic-settings",
"modern-di-typer",
"httpx2",
"httpware[pydantic]>=0.12.0",
"httpware[pydantic]>=0.15.0",
]

[project.scripts]
Expand Down
6 changes: 3 additions & 3 deletions semvertag/ioc.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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,
)


Expand All @@ -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,
)


Expand Down
8 changes: 4 additions & 4 deletions tests/unit/test_ioc.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
6 changes: 3 additions & 3 deletions tests/unit/test_providers_errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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)
Expand All @@ -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)
Expand Down
Loading