diff --git a/.gitignore b/.gitignore index 74738ed..3d12363 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,26 @@ __pycache__/ *.pyc .pytest_cache/ *.egg-info/ +.coverage +htmlcov/ +dist/ +build/ + +# Virtual environments +.venv/ +venv/ +env/ + +# Editor/IDE +.vscode/ +.idea/ + +# OS +.DS_Store + +# Local secrets/config — never commit real credentials +.env +.env.*.local # Adapter install-time symlinks. Every adapter README tells the user to # create these inside this working tree; committing a real symlink breaks @@ -9,3 +29,9 @@ __pycache__/ adapters/codex/skills/adr-toolkit adapters/gemini-cli/skills/adr-toolkit adapters/antigravity/skills/adr-toolkit + +# Task-by-task implementation plans (superpowers subagent-driven-development +# artifacts) — kept locally for reference, not part of the public tree. +# docs/superpowers/specs/ (the design docs) stay tracked; only plans/ is +# excluded. History from before this commit still has them if ever needed. +docs/superpowers/plans/ diff --git a/README.md b/README.md index 3718c9e..c2b2255 100644 --- a/README.md +++ b/README.md @@ -1 +1,74 @@ -# ADR-toolkit \ No newline at end of file +# ADR Toolkit + +An agent-native Architecture Decision Record toolkit: it inspects your +repository and existing decisions before asking questions, records new +decisions with a human-approved MADR, and checks a diff against Accepted +decisions for structural conflicts — all before anything gets written. + +Kafka got introduced into a production system once, and the person who +made that call left the company. Nobody could say why Kafka was chosen +over the alternatives, what problem it solved, or what was consciously +traded away. ADR Toolkit exists so that, going forward, the *reason* +behind a structural decision survives as long as the code does — written +by inspecting the repository first, not invented, and findable by a +successor (human or agent) in under a minute. + +## What it does + +| Operation | What it does | +|---|---| +| **INIT** | Scaffolds `docs/decisions/` in a repo that has none yet — directory, template, and ADR-0001. | +| **DISCOVER** | Mines existing conventions (dependency manifests, code, git history) for past decisions that were never written down, and drafts retrospective ADRs for ones you approve. | +| **RECORD** | Captures a new decision — before or after implementing it — investigating the code first and asking only what it can't answer (max 3 questions). | +| **CHECK** | Matches a diff against Accepted ADRs' structured `constraints:` rules and reports Related / Review required / Verified violation / No applicable constraint — never picks a fix for you. | + +Every file write goes through a deterministic script and is shown to a +human before it happens. Judgment (what's significant, what to ask, how to +draft) is the agent's job; file writes, ID assignment, and validation +never are. See [`examples/quickstart.md`](examples/quickstart.md) for a +full INIT → RECORD → CHECK walkthrough with real command output, or +[`docs/decisions/`](docs/decisions/) for this toolkit's own dogfooded ADRs. + +## Install + +`skills/adr-toolkit/` is one self-contained, harness-agnostic package — +copy or symlink it wherever your harness looks for skills, then point one +of these adapters at it: + +| Harness | Depth | Install | +|---|---|---| +| [Claude Code](.claude-plugin/) | Full plugin, auto-discovered | Add this repo via `.claude-plugin/marketplace.json` | +| [Codex CLI](adapters/codex/) | Agent Plugins 1.0.0 manifest | [`adapters/codex/README.md`](adapters/codex/README.md) | +| [Gemini CLI](adapters/gemini-cli/) | Extension manifest | [`adapters/gemini-cli/README.md`](adapters/gemini-cli/README.md) | +| [Antigravity CLI](adapters/antigravity/) | Plugin manifest | [`adapters/antigravity/README.md`](adapters/antigravity/README.md) | +| Anything else | Generic fallback | [`adapters/generic/README.md`](adapters/generic/README.md) — needs only markdown-reading and shell | + +**No AI harness at all?** `create --interactive` runs the same interview +directly in a terminal — no agent required: + +```bash +python skills/adr-toolkit/scripts/adr.py create --interactive --dir docs/decisions +``` + +## Scope + +- MADR 4.x format, no new standard. +- CHECK's MVP conflict detection is structural evidence only + (`constraints:` blocks) — no semantic/AST analysis. See + [ADR-0002](docs/decisions/0002-limit-check-s-conflict-detection-to-structural-evidence-only.md). +- Runtime text ships in English, French, Japanese, Korean, and Chinese — + scoped to `index`'s generated output only, since agent-composed prose + needs no lookup table. See + [ADR-0003](docs/decisions/0003-localize-only-index-py-s-generated-strings-not-agent-composed-text.md). +- Everything explicitly deferred out of MVP is tracked in + [`project-roadmap.md`](project-roadmap.md), not silently dropped. + +## Contributing + +Read [`AGENTS.md`](AGENTS.md) first — it's the shared operating document +every harness (and every human) working in this repo follows, including +the branch policy. + +## License + +[MIT](LICENSE) diff --git a/changelog.md b/changelog.md index 8247122..480f840 100644 --- a/changelog.md +++ b/changelog.md @@ -37,3 +37,14 @@ Lightweight human-readable summary of meaningful repository changes. documentation, not guessed), a repo-root version-sync script, and a tag-triggered release workflow. This closes out Plan 4 of 4 — the full ADR Toolkit MVP is now complete on this branch. +- Released v0.1.0 and adopted a Git Flow branch policy + (`develop`/`master`/`feature`/`release`/`hotfix`, direct-tag release + automation), recorded in `AGENTS.md`. +- Dogfooded the toolkit on its own repo: initialized `docs/decisions/` + and recorded the four most significant architectural decisions made + while building it (CHECK's structural-only scope, i18n's index-only + scope, adapter packaging policy, the Git Flow adoption itself). +- Wrote the root `README.md` (previously a one-line stub) and + `examples/quickstart.md`, a full INIT → RECORD → CHECK walkthrough with + real command output, including CHECK catching a live rule violation and + clearing it after a fix. diff --git a/docs/decisions/0001-record-architecture-decisions.md b/docs/decisions/0001-record-architecture-decisions.md new file mode 100644 index 0000000..d4ee0ca --- /dev/null +++ b/docs/decisions/0001-record-architecture-decisions.md @@ -0,0 +1,44 @@ +--- +id: ADR-0001 +title: Record architecture decisions +status: accepted +date: 2026-08-30 +decision_makers: [] +related: [] +affected_paths: + - docs/decisions/ +tags: + - process +retrospective: false +--- + +# Record architecture decisions + +## Context and Problem Statement + +We need a consistent way to capture and communicate significant +architectural decisions so future contributors (human or agent) can find +the reasoning behind them. + +## Considered Options + +* No formal record, rely on commit messages and memory +* Wiki or external documentation tool +* Architecture Decision Records stored alongside the code + +## Decision Outcome + +Chosen option: **Architecture Decision Records stored alongside the code**, because they version with the code, stay close to what they describe, and are readable by both humans and coding agents. + +## Consequences + +* Good: decisions and their rationale are discoverable in the repository itself. +* Bad: requires discipline to keep records up to date as decisions evolve. + +## Confirmation + +* [ ] `docs/decisions/` exists with this file, a template, and an index. + +## Revisit Triggers + +* The team adopts a different documentation system project-wide. diff --git a/docs/decisions/0002-limit-check-s-conflict-detection-to-structural-evidence-only.md b/docs/decisions/0002-limit-check-s-conflict-detection-to-structural-evidence-only.md new file mode 100644 index 0000000..776491a --- /dev/null +++ b/docs/decisions/0002-limit-check-s-conflict-detection-to-structural-evidence-only.md @@ -0,0 +1,44 @@ +--- +id: ADR-0002 +title: Limit CHECK's conflict detection to structural evidence only +status: accepted +date: 2026-08-30 +decision_makers: [] +related: [] +affected_paths: + - skills/adr-toolkit/scripts/commands/check.py + - skills/adr-toolkit/scripts/rules/conflict.py +tags: + - check + - mvp-scope + - architecture +retrospective: false +--- + +# Limit CHECK's conflict detection to structural evidence only + +## Context and Problem Statement + +CHECK needs to flag conflicts between a diff and existing Accepted ADRs. The original PRD's 8-type conflict taxonomy includes types (Direct violation via SDK-call detection, Pattern divergence) that require semantic understanding of code intent, which cannot be reliably detected without deep static analysis (AST or import-graph tooling). Shipping unverified semantic detection in a brand-new tool's MVP risks false positives on day one, which is the fastest way to lose adopter trust. + +## Considered Options + +* Full semantic/AST-based conflict detection matching the original 8-type taxonomy +* Structural-evidence-only detection via a fixed `constraints:` YAML rule vocabulary (`forbidden_import`, `required_path`, `forbidden_path`, `dependency_forbidden`, `file_must_exist`, `test_must_exist`) + +## Decision Outcome + +Chosen option: **structural-evidence-only detection**, because false positives are the fastest way to lose trust in a brand-new open-source tool, and a fixed rule vocabulary keeps CHECK's core deterministic, matching the project's own Deterministic Core / Agentic Edge principle. + +## Consequences + +* Good: CHECK never has to make an ambiguous semantic judgment call; every finding is traceable to an explicit, author-written rule. +* Bad: CHECK cannot catch a conflict that requires understanding code intent unless an explicit structural rule was already written for it — semantic drift can slip through undetected. + +## Confirmation + +`skills/adr-toolkit/scripts/rules/conflict.py` implements exactly four structural matching mechanisms and nothing else; no test in `tests/unit/test_conflict.py` attempts semantic or AST-based matching. + +## Revisit Triggers + +* Real usage produces enough false-negative reports that users want semantic detection — tracked as "Full semantic conflict taxonomy" in `project-roadmap.md`. diff --git a/docs/decisions/0003-localize-only-index-py-s-generated-strings-not-agent-composed-text.md b/docs/decisions/0003-localize-only-index-py-s-generated-strings-not-agent-composed-text.md new file mode 100644 index 0000000..3c428ce --- /dev/null +++ b/docs/decisions/0003-localize-only-index-py-s-generated-strings-not-agent-composed-text.md @@ -0,0 +1,44 @@ +--- +id: ADR-0003 +title: Localize only index.py's generated strings, not agent-composed text +status: accepted +date: 2026-08-30 +decision_makers: [] +related: [] +affected_paths: + - skills/adr-toolkit/scripts/core/locale.py + - skills/adr-toolkit/scripts/i18n/ + - skills/adr-toolkit/scripts/commands/index.py +tags: + - i18n + - architecture +retrospective: false +--- + +# Localize only index.py's generated strings, not agent-composed text + +## Context and Problem Statement + +The product goal is five-language support (en/fr/ja/ko/zh). The original design's repo-structure diagram proposed a flat `scripts/i18n/{locale}.json` key-value file, but most of the toolkit's user-facing text — RECORD/DISCOVER/CHECK's interview questions and reports — is agent-composed natural language, not a fixed Python string. It is not obvious what a translation-key file would even contain for text that is never the same twice. + +## Considered Options + +* A comprehensive translation-key system covering every user-facing string, including agent-composed prose, requiring the agent to look up keys instead of composing text freely +* Localize only the deterministic, code-owned strings — `index.py`'s generated `README.md` headers and status labels — via a small JSON file, and instruct the agent (via `SKILL.md`) to detect the user's language and compose its own text in it + +## Decision Outcome + +Chosen option: **localize only index.py's generated strings**, because an LLM agent is already multilingual and needs no phrase-book to ask a question in French — building and maintaining a lookup table for text that was never a fixed string in the first place adds real translation-maintenance burden for no benefit. + +## Consequences + +* Good: only ten keys need translating and keeping in sync across five locales, not hundreds of agent-prose variants. +* Bad: the agent's exact wording is not guaranteed reproducible or testable per locale the way a translation table's output would be. + +## Confirmation + +`skills/adr-toolkit/scripts/i18n/*.json` has exactly ten keys per locale; `SKILL.md`'s Language section is the only agent-facing localization instruction, and it is prose, not a lookup table. + +## Revisit Triggers + +* The tool needs deterministic, exact-wording compliance per locale (for example, an accessibility or compliance audit) — revisit toward a full translation-key system at that point. diff --git a/docs/decisions/0004-adapter-packaging-manifest-only-directories-install-time-symlinks-verified-formats.md b/docs/decisions/0004-adapter-packaging-manifest-only-directories-install-time-symlinks-verified-formats.md new file mode 100644 index 0000000..9fb3d6c --- /dev/null +++ b/docs/decisions/0004-adapter-packaging-manifest-only-directories-install-time-symlinks-verified-formats.md @@ -0,0 +1,43 @@ +--- +id: ADR-0004 +title: Adapter packaging: manifest-only directories, install-time symlinks, verified formats +status: accepted +date: 2026-08-30 +decision_makers: [] +related: [] +affected_paths: + - adapters/ +tags: + - adapters + - harness-support +retrospective: false +--- + +# Adapter packaging: manifest-only directories, install-time symlinks, verified formats + +## Context and Problem Statement + +The toolkit ships as one self-contained `skills/adr-toolkit/` package usable by multiple harnesses (Claude Code, Codex CLI, Gemini CLI, Antigravity CLI). Every harness format actually investigated expects a sibling `skills/` directory next to its own manifest file, but duplicating the package once per harness would violate the single-source-of-truth goal that motivated the self-contained package in the first place. Separately, the exact manifest fields for three of the four harnesses were never verified against real documentation before Plan 4 — and guessing already produced a real bug once (Plan 1's Claude Code adapter guessed a `"skills"` key and a nested manifest path that turned out wrong, caught only by that plan's final review). + +## Considered Options + +* Copy the full `skills/adr-toolkit/` package into each `adapters//` directory +* Symlink `skills/adr-toolkit/` into each adapter directory and commit the symlink to git +* Document the symlink as a manual install-time step (never committed), and verify each harness's real manifest format against its actual documentation before writing it + +## Decision Outcome + +Chosen option: **document the symlink as an install-time step, with formats verified not guessed**, because copying reintroduces drift between copies, committing a symlink breaks on a Windows checkout without `core.symlinks` enabled (this repo's own CI matrix includes `windows-latest`), and guessing manifest formats already produced two real bugs (Claude Code in Plan 1, Codex CLI in Plan 4) that only an actual install-and-run caught. + +## Consequences + +* Good: one canonical copy of the skill package exists; no manifest format ships without being checked against real documentation or a real install attempt where a harness CLI was available. +* Bad: a user must run one extra manual `ln -s` step before an adapter works — installation cannot be a true zero-step "clone and go." + +## Confirmation + +`git ls-tree` on any `adapters//` directory shows no `120000` (symlink) mode entries; every adapter's `README.md` documents the symlink as an install step; `.gitignore` ignores each adapter's `skills/` path. + +## Revisit Triggers + +* A harness's real plugin format later supports referencing an external path directly, removing the need for that harness's symlink step — revisit that harness's adapter specifically, not this policy as a whole. diff --git a/docs/decisions/0005-adopt-git-flow-with-direct-tag-release-automation.md b/docs/decisions/0005-adopt-git-flow-with-direct-tag-release-automation.md new file mode 100644 index 0000000..0084b8a --- /dev/null +++ b/docs/decisions/0005-adopt-git-flow-with-direct-tag-release-automation.md @@ -0,0 +1,75 @@ +--- +id: ADR-0005 +title: Adopt Git Flow with direct-tag release automation +status: accepted +date: 2026-08-30 +decision_makers: [] +related: [] +affected_paths: + - AGENTS.md + - .github/workflows/ +tags: + - process + - release + - git-flow +retrospective: false +--- + +# Adopt Git Flow with direct-tag release automation + +## Context and Problem Statement + +The 4-plan MVP was built entirely on one long-lived feature branch with no branch policy; `master` held only the initial commit throughout. Now that the MVP is complete and released as v0.1.0, the project needs an explicit branch and release policy for future work, including how a version gets tagged and published. + +## Decision Drivers + +* A predictable integration point for future feature/fix/docs work +* Low operational overhead appropriate for a solo/small-team repository +* Reuse the release automation already built and verified in Plan 4, rather than replacing it speculatively + +## Considered Options + +* Git Flow (`develop`/`master`/`feature`/`release`/`hotfix` branches) with direct-tag release automation +* Git Flow with release-pr automation (Release Please, auto-generating release PRs from Conventional Commits) +* Trunk-based development (small changes merged frequently, feature flags for incomplete work) +* GitHub Flow (one production branch, short-lived feature branches, continuous deployment) + +## Decision Outcome + +Chosen option: **Git Flow with direct-tag release automation**, because the project already has more than one long-lived integration point in practice (this MVP's own multi-plan history, and future feature batches), and an explicit `develop`/`master` split matches that; direct-tag was chosen over release-pr because Plan 4 already built, task-reviewed, and whole-branch-reviewed a working tag-triggered `release.yml` — replacing tested automation with an unverified new mechanism for a solo/small-team repo trades working automation for unverified automation with no clear benefit yet. + +### Consequences + +* Good: reuses release automation that is already tested and was independently re-verified during its own final review; explicit branch roles reduce ambiguity for future contributors. +* Bad: Git Flow's extra branch layer (`develop` plus `release/*`) is more ceremony than Trunk-based or GitHub Flow would need for a project this size; direct-tag requires a human to remember to bump `VERSION` and tag correctly, rather than deriving a version automatically from commit history. + +### Confirmation + +`AGENTS.md`'s "Git Flow Branch and Release Policy" section states the policy in full; `.github/workflows/release.yml` triggers only on `v*` tag pushes and never auto-bumps a version. + +## Pros and Cons of the Options + +### Git Flow with direct-tag release automation + +* Good, because it reuses release automation already built, tested, and verified in this repository +* Bad, because a human must remember every version bump and tag correctly; nothing derives the version from commit history + +### Git Flow with release-pr automation + +* Good, because changelog and version bumps are generated automatically from Conventional Commit history, reducing human error +* Bad, because it requires disciplined Conventional Commit usage across all contributors and replaces working, verified automation with an unverified new mechanism + +### Trunk-based development + +* Good, because it minimizes branch ceremony and integration lag for a small, fast-moving codebase +* Bad, because it assumes continuous feature-flag discipline this project has not adopted, and offers no natural checkpoint for a deliberate, reviewed release like v0.1.0 + +### GitHub Flow + +* Good, because it is simpler than Git Flow with only one long-lived branch to reason about +* Bad, because it has no dedicated integration branch separate from production, which matters once multiple features are in flight at once — a scenario this project has already lived through with its 4-plan MVP + +## Revisit Triggers + +* The team grows beyond solo/small-team and reliable Conventional Commit discipline becomes realistic — revisit toward release-pr automation. +* `develop` and `release/*` prove to be pure overhead with no multi-stream benefit in practice — revisit toward GitHub Flow. diff --git a/docs/decisions/README.md b/docs/decisions/README.md new file mode 100644 index 0000000..370fca0 --- /dev/null +++ b/docs/decisions/README.md @@ -0,0 +1,78 @@ +# Decision Log + +## By status + +### Accepted +- [ADR-0001 — Record architecture decisions](0001-record-architecture-decisions.md) +- [ADR-0002 — Limit CHECK's conflict detection to structural evidence only](0002-limit-check-s-conflict-detection-to-structural-evidence-only.md) +- [ADR-0003 — Localize only index.py's generated strings, not agent-composed text](0003-localize-only-index-py-s-generated-strings-not-agent-composed-text.md) +- [ADR-0004 — Adapter packaging: manifest-only directories, install-time symlinks, verified formats](0004-adapter-packaging-manifest-only-directories-install-time-symlinks-verified-formats.md) +- [ADR-0005 — Adopt Git Flow with direct-tag release automation](0005-adopt-git-flow-with-direct-tag-release-automation.md) + +## By tag + +### adapters +- [ADR-0004 — Adapter packaging: manifest-only directories, install-time symlinks, verified formats](0004-adapter-packaging-manifest-only-directories-install-time-symlinks-verified-formats.md) + +### architecture +- [ADR-0002 — Limit CHECK's conflict detection to structural evidence only](0002-limit-check-s-conflict-detection-to-structural-evidence-only.md) +- [ADR-0003 — Localize only index.py's generated strings, not agent-composed text](0003-localize-only-index-py-s-generated-strings-not-agent-composed-text.md) + +### check +- [ADR-0002 — Limit CHECK's conflict detection to structural evidence only](0002-limit-check-s-conflict-detection-to-structural-evidence-only.md) + +### git-flow +- [ADR-0005 — Adopt Git Flow with direct-tag release automation](0005-adopt-git-flow-with-direct-tag-release-automation.md) + +### harness-support +- [ADR-0004 — Adapter packaging: manifest-only directories, install-time symlinks, verified formats](0004-adapter-packaging-manifest-only-directories-install-time-symlinks-verified-formats.md) + +### i18n +- [ADR-0003 — Localize only index.py's generated strings, not agent-composed text](0003-localize-only-index-py-s-generated-strings-not-agent-composed-text.md) + +### mvp-scope +- [ADR-0002 — Limit CHECK's conflict detection to structural evidence only](0002-limit-check-s-conflict-detection-to-structural-evidence-only.md) + +### process +- [ADR-0001 — Record architecture decisions](0001-record-architecture-decisions.md) +- [ADR-0005 — Adopt Git Flow with direct-tag release automation](0005-adopt-git-flow-with-direct-tag-release-automation.md) + +### release +- [ADR-0005 — Adopt Git Flow with direct-tag release automation](0005-adopt-git-flow-with-direct-tag-release-automation.md) + +## By affected path + +### `.github/workflows/` +- [ADR-0005 — Adopt Git Flow with direct-tag release automation](0005-adopt-git-flow-with-direct-tag-release-automation.md) + +### `AGENTS.md` +- [ADR-0005 — Adopt Git Flow with direct-tag release automation](0005-adopt-git-flow-with-direct-tag-release-automation.md) + +### `adapters/` +- [ADR-0004 — Adapter packaging: manifest-only directories, install-time symlinks, verified formats](0004-adapter-packaging-manifest-only-directories-install-time-symlinks-verified-formats.md) + +### `docs/decisions/` +- [ADR-0001 — Record architecture decisions](0001-record-architecture-decisions.md) + +### `skills/adr-toolkit/scripts/commands/check.py` +- [ADR-0002 — Limit CHECK's conflict detection to structural evidence only](0002-limit-check-s-conflict-detection-to-structural-evidence-only.md) + +### `skills/adr-toolkit/scripts/commands/index.py` +- [ADR-0003 — Localize only index.py's generated strings, not agent-composed text](0003-localize-only-index-py-s-generated-strings-not-agent-composed-text.md) + +### `skills/adr-toolkit/scripts/core/locale.py` +- [ADR-0003 — Localize only index.py's generated strings, not agent-composed text](0003-localize-only-index-py-s-generated-strings-not-agent-composed-text.md) + +### `skills/adr-toolkit/scripts/i18n/` +- [ADR-0003 — Localize only index.py's generated strings, not agent-composed text](0003-localize-only-index-py-s-generated-strings-not-agent-composed-text.md) + +### `skills/adr-toolkit/scripts/rules/conflict.py` +- [ADR-0002 — Limit CHECK's conflict detection to structural evidence only](0002-limit-check-s-conflict-detection-to-structural-evidence-only.md) + +## Chronological (newest first) + +- 2026-08-30 — [ADR-0001 — Record architecture decisions](0001-record-architecture-decisions.md) +- 2026-08-30 — [ADR-0002 — Limit CHECK's conflict detection to structural evidence only](0002-limit-check-s-conflict-detection-to-structural-evidence-only.md) +- 2026-08-30 — [ADR-0003 — Localize only index.py's generated strings, not agent-composed text](0003-localize-only-index-py-s-generated-strings-not-agent-composed-text.md) +- 2026-08-30 — [ADR-0004 — Adapter packaging: manifest-only directories, install-time symlinks, verified formats](0004-adapter-packaging-manifest-only-directories-install-time-symlinks-verified-formats.md) +- 2026-08-30 — [ADR-0005 — Adopt Git Flow with direct-tag release automation](0005-adopt-git-flow-with-direct-tag-release-automation.md) diff --git a/docs/decisions/adr-template.md b/docs/decisions/adr-template.md new file mode 100644 index 0000000..aaeb556 --- /dev/null +++ b/docs/decisions/adr-template.md @@ -0,0 +1,27 @@ +# {title} + +## Context and Problem Statement + +{problem and constraints} + +## Considered Options + +* {option one} +* {option two} + +## Decision Outcome + +Chosen option: **{chosen option}**, because {rationale}. + +## Consequences + +* Good: {positive consequence} +* Bad: {negative consequence} + +## Confirmation + +{how implementation will be verified} + +## Revisit Triggers + +* {condition that should reopen the decision} diff --git a/docs/superpowers/plans/2026-08-29-adr-toolkit-core-and-init.md b/docs/superpowers/plans/2026-08-29-adr-toolkit-core-and-init.md deleted file mode 100644 index 0b7420c..0000000 --- a/docs/superpowers/plans/2026-08-29-adr-toolkit-core-and-init.md +++ /dev/null @@ -1,2676 +0,0 @@ -# ADR Toolkit — Core Scripts + INIT (Plan 1 of 4) Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Ship a self-contained `skills/adr-toolkit/` package with the deterministic script layer (frontmatter, IDs, lifecycle, schema, discovery, index, validation) and two fully working operations — INIT (scaffolding) and DISCOVER (past-decision recovery, kept separate from INIT per user feedback) — usable three ways: through Claude Code, through any other harness via a generic fallback adapter, or directly from the terminal with no AI agent at all via an interactive CLI wizard. CI enforces all of it from the first commit. - -**Architecture:** Deterministic Python stdlib-only scripts under `skills/adr-toolkit/scripts/` (package name `scripts`, imported as `scripts.core.*` / `scripts.commands.*` / `scripts.evidence.*`) do all file I/O, ID assignment, and validation. `SKILL.md` carries the workflow contract the agent follows and never re-implements those deterministic steps in prose. A thin Claude Code adapter under `adapters/claude/` points at the skill folder; it carries no logic of its own. - -**Tech Stack:** Python 3.9+ standard library only (argparse, pathlib, json, re, shutil, datetime). pytest for tests. GitHub Actions for CI. - -**Spec:** `docs/superpowers/specs/2026-08-29-adr-toolkit-design.md` - -## Global Constraints - -- Python 3.9+, standard library only — no third-party pip packages anywhere under `skills/adr-toolkit/`. -- UTF-8 explicit on every file read/write: always pass `encoding="utf-8"`. -- No `shell=True` and no string-interpolated subprocess commands — pass argument lists. -- `init` and `create` (the two mutating commands in this plan) must support `--dry-run` and must refuse to overwrite an existing file or non-empty directory instead of silently overwriting it. -- Command modules never `print()` — they return a `dict`; only `scripts/adr.py`'s `main()` serializes to stdout as JSON. -- ADR IDs are 4-digit zero-padded, computed as `max(existing IDs) + 1` from filenames already on disk — never guessed, never reused. -- **Out of scope for this plan** (tracked in `project-roadmap.md` / later plans): RECORD workflow, CHECK workflow, i18n wiring, Codex/Gemini CLI/Antigravity adapters, release automation. `index.py`'s section headers are English-only in this plan; localizing them is Plan 4's job. -- Every task must leave `python -m pytest tests/unit tests/integration -v` green (from the repo root) before its commit step. -- Avoid `X | None` union-type syntax (Python 3.10+); use `typing.Optional` so the code runs on the Python 3.9 floor declared by `preflight`. - ---- - -### Task 1: Scaffold the self-contained skill package and test plumbing - -**Files:** -- Create: `skills/adr-toolkit/VERSION` -- Create: `skills/adr-toolkit/scripts/__init__.py` -- Create: `skills/adr-toolkit/scripts/core/__init__.py` -- Create: `skills/adr-toolkit/scripts/commands/__init__.py` -- Create: `skills/adr-toolkit/scripts/evidence/__init__.py` -- Create: `skills/adr-toolkit/templates/.gitkeep` -- Create: `skills/adr-toolkit/references/.gitkeep` -- Create: `skills/adr-toolkit/schemas/.gitkeep` -- Create: `tests/conftest.py` -- Create: `pytest.ini` -- Test: `tests/unit/test_scaffold.py` - -**Interfaces:** -- Consumes: nothing (first task). -- Produces: `sys.path` entry for `skills/adr-toolkit/` set up by `tests/conftest.py`, so every later test file can `import scripts.core.xxx` / `import scripts.commands.xxx` / `import scripts.evidence.xxx` without its own path hacking. - -- [ ] **Step 1: Write the failing test** - -```python -# tests/unit/test_scaffold.py -from pathlib import Path - -SKILL_ROOT = Path(__file__).resolve().parent.parent.parent / "skills" / "adr-toolkit" - - -def test_skill_root_layout_exists(): - assert (SKILL_ROOT / "VERSION").is_file() - assert (SKILL_ROOT / "scripts" / "__init__.py").is_file() - assert (SKILL_ROOT / "scripts" / "core" / "__init__.py").is_file() - assert (SKILL_ROOT / "scripts" / "commands" / "__init__.py").is_file() - assert (SKILL_ROOT / "scripts" / "evidence" / "__init__.py").is_file() - assert (SKILL_ROOT / "templates").is_dir() - assert (SKILL_ROOT / "references").is_dir() - assert (SKILL_ROOT / "schemas").is_dir() - - -def test_version_file_has_semver(): - version = (SKILL_ROOT / "VERSION").read_text(encoding="utf-8").strip() - parts = version.split(".") - assert len(parts) == 3 - assert all(part.isdigit() for part in parts) - - -def test_scripts_importable_via_conftest_path(): - import scripts # noqa: F401 — only importable if conftest.py set sys.path correctly -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: `python -m pytest tests/unit/test_scaffold.py -v` -Expected: FAIL / ERROR — files and directories don't exist yet, `tests/conftest.py` doesn't exist. - -- [ ] **Step 3: Create the scaffold** - -```python -# tests/conftest.py -import sys -from pathlib import Path - -SKILL_ROOT = Path(__file__).resolve().parent.parent / "skills" / "adr-toolkit" -sys.path.insert(0, str(SKILL_ROOT)) -``` - -```ini -# pytest.ini -[pytest] -testpaths = tests -``` - -Create the directories and empty `__init__.py` files listed under **Files** above (empty file contents), a `.gitkeep` in each of `templates/`, `references/`, `schemas/` (filled by later tasks), and: - -```text -skills/adr-toolkit/VERSION -0.1.0 -``` - -(no trailing content beyond the version string and a newline) - -- [ ] **Step 4: Run test to verify it passes** - -Run: `python -m pytest tests/unit/test_scaffold.py -v` -Expected: PASS (3 tests) - -- [ ] **Step 5: Commit** - -```bash -git add skills/adr-toolkit/VERSION skills/adr-toolkit/scripts skills/adr-toolkit/templates \ - skills/adr-toolkit/references skills/adr-toolkit/schemas tests/conftest.py pytest.ini \ - tests/unit/test_scaffold.py -git commit -m "feat: scaffold self-contained adr-toolkit skill package" -``` - ---- - -### Task 2: `core/frontmatter.py` — parse and serialize ADR YAML frontmatter - -**Files:** -- Create: `skills/adr-toolkit/scripts/core/frontmatter.py` -- Test: `tests/unit/test_frontmatter.py` - -**Interfaces:** -- Consumes: nothing. -- Produces: `parse(text: str) -> tuple[dict, str]`, `serialize(data: dict, body: str) -> str`, `FrontmatterError(ValueError)`. Used by Tasks 10 (create), 11 (index), 12 (validate). - -- [ ] **Step 1: Write the failing test** - -```python -# tests/unit/test_frontmatter.py -import pytest - -from scripts.core import frontmatter as fm - - -def test_parse_extracts_scalars_lists_and_body(): - text = ( - "---\n" - "id: ADR-0003\n" - "title: Use a provider port\n" - "status: accepted\n" - "date: 2026-08-29\n" - "decision_makers:\n" - " - Yangseunghyeon\n" - "related: []\n" - "affected_paths:\n" - " - src/providers/\n" - " - src/core/ports/\n" - "tags:\n" - " - architecture\n" - "retrospective: false\n" - "---\n" - "\n" - "# Use a provider port\n" - "\n" - "Body content.\n" - ) - data, body = fm.parse(text) - - assert data["id"] == "ADR-0003" - assert data["decision_makers"] == ["Yangseunghyeon"] - assert data["affected_paths"] == ["src/providers/", "src/core/ports/"] - assert data["retrospective"] is False - assert body.strip().startswith("# Use a provider port") - - -def test_parse_raises_without_frontmatter_block(): - with pytest.raises(fm.FrontmatterError): - fm.parse("# No frontmatter here\n") - - -def test_serialize_round_trips_through_parse(): - data = { - "id": "ADR-0001", - "title": "Record architecture decisions", - "status": "accepted", - "date": "2026-08-29", - "decision_makers": [], - "related": [], - "affected_paths": ["docs/decisions/"], - "tags": ["process"], - "retrospective": False, - } - text = fm.serialize(data, "# Record architecture decisions\n\nBody.\n") - parsed_data, parsed_body = fm.parse(text) - - assert parsed_data == data - assert parsed_body.strip() == "# Record architecture decisions\n\nBody." -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: `python -m pytest tests/unit/test_frontmatter.py -v` -Expected: FAIL — `scripts.core.frontmatter` doesn't exist. - -- [ ] **Step 3: Write minimal implementation** - -```python -# skills/adr-toolkit/scripts/core/frontmatter.py -"""Parse and serialize ADR YAML frontmatter without a YAML library dependency. - -Supports exactly the subset ADR Toolkit frontmatter needs: string scalars, -booleans, and flat string lists. Not a general YAML parser. -""" -import re - -FRONTMATTER_RE = re.compile(r"\A---\n(.*?)\n---\n(.*)\Z", re.DOTALL) - - -class FrontmatterError(ValueError): - pass - - -def parse(text: str) -> tuple: - match = FRONTMATTER_RE.match(text) - if not match: - raise FrontmatterError("No YAML frontmatter block found") - raw_yaml, body = match.group(1), match.group(2) - return _parse_simple_yaml(raw_yaml), body - - -def serialize(data: dict, body: str) -> str: - lines = ["---"] - for key, value in data.items(): - lines.append(_format_field(key, value)) - lines.append("---") - return "\n".join(lines) + "\n\n" + body - - -def _parse_simple_yaml(raw: str) -> dict: - data: dict = {} - current_list_key = None - for line in raw.splitlines(): - if not line.strip(): - continue - if line.startswith(" - "): - if current_list_key is None: - raise FrontmatterError(f"List item with no preceding key: {line!r}") - data[current_list_key].append(line.strip()[2:].strip()) - continue - if ":" not in line: - raise FrontmatterError(f"Malformed frontmatter line: {line!r}") - key, _, value = line.partition(":") - key = key.strip() - value = value.strip() - if value == "" or value == "[]": - data[key] = [] - current_list_key = key if value == "" else None - else: - current_list_key = None - if value.lower() in ("true", "false"): - data[key] = value.lower() == "true" - else: - data[key] = value.strip('"').strip("'") - return data - - -def _format_field(key: str, value) -> str: - if isinstance(value, list): - if not value: - return f"{key}: []" - item_lines = "\n".join(f" - {item}" for item in value) - return f"{key}:\n{item_lines}" - if isinstance(value, bool): - return f"{key}: {'true' if value else 'false'}" - return f"{key}: {value}" -``` - -- [ ] **Step 4: Run test to verify it passes** - -Run: `python -m pytest tests/unit/test_frontmatter.py -v` -Expected: PASS (3 tests) - -- [ ] **Step 5: Commit** - -```bash -git add skills/adr-toolkit/scripts/core/frontmatter.py tests/unit/test_frontmatter.py -git commit -m "feat: add hand-rolled frontmatter parser/serializer" -``` - ---- - -### Task 3: `core/identifiers.py` — ADR ID calculation, filenames, slugs - -**Files:** -- Create: `skills/adr-toolkit/scripts/core/identifiers.py` -- Test: `tests/unit/test_identifiers.py` - -**Interfaces:** -- Consumes: nothing. -- Produces: `parse_filename(filename: str) -> Optional[tuple]`, `next_id(adr_dir: Path) -> int`, `format_filename(adr_id: int, slug: str) -> str`, `slugify(title: str) -> str`. Used by Tasks 10 (create), 11 (index), 12 (validate). - -- [ ] **Step 1: Write the failing test** - -```python -# tests/unit/test_identifiers.py -from pathlib import Path - -from scripts.core import identifiers - - -def test_parse_filename_extracts_id_and_slug(): - assert identifiers.parse_filename("0003-use-provider-port.md") == (3, "use-provider-port") - - -def test_parse_filename_rejects_non_matching_names(): - assert identifiers.parse_filename("README.md") is None - assert identifiers.parse_filename("3-too-short.md") is None - - -def test_next_id_is_one_past_the_highest_existing(tmp_path): - (tmp_path / "0001-a.md").write_text("x", encoding="utf-8") - (tmp_path / "0003-b.md").write_text("x", encoding="utf-8") - assert identifiers.next_id(tmp_path) == 4 - - -def test_next_id_starts_at_one_for_empty_directory(tmp_path): - assert identifiers.next_id(tmp_path) == 1 - - -def test_format_filename_zero_pads_to_four_digits(): - assert identifiers.format_filename(7, "use-kafka") == "0007-use-kafka.md" - - -def test_slugify_lowercases_and_hyphenates(): - assert identifiers.slugify("Use Kafka for Domain Events!") == "use-kafka-for-domain-events" -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: `python -m pytest tests/unit/test_identifiers.py -v` -Expected: FAIL — module doesn't exist. - -- [ ] **Step 3: Write minimal implementation** - -```python -# skills/adr-toolkit/scripts/core/identifiers.py -"""ADR ID calculation, filename parsing, and title slugification.""" -import re -from pathlib import Path -from typing import Optional - -ADR_FILENAME_RE = re.compile(r"^(\d{4})-([a-z0-9]+(?:-[a-z0-9]+)*)\.md$") - - -def parse_filename(filename: str) -> Optional[tuple]: - match = ADR_FILENAME_RE.match(filename) - if not match: - return None - return int(match.group(1)), match.group(2) - - -def next_id(adr_dir: Path) -> int: - existing_ids = [] - for entry in adr_dir.glob("*.md"): - parsed = parse_filename(entry.name) - if parsed: - existing_ids.append(parsed[0]) - return max(existing_ids, default=0) + 1 - - -def format_filename(adr_id: int, slug: str) -> str: - return f"{adr_id:04d}-{slug}.md" - - -def slugify(title: str) -> str: - slug = re.sub(r"[^a-z0-9]+", "-", title.lower()).strip("-") - return re.sub(r"-+", "-", slug) -``` - -- [ ] **Step 4: Run test to verify it passes** - -Run: `python -m pytest tests/unit/test_identifiers.py -v` -Expected: PASS (6 tests) - -- [ ] **Step 5: Commit** - -```bash -git add skills/adr-toolkit/scripts/core/identifiers.py tests/unit/test_identifiers.py -git commit -m "feat: add ADR id/filename/slug calculation" -``` - ---- - -### Task 4: `core/lifecycle.py` — status transition rules - -**Files:** -- Create: `skills/adr-toolkit/scripts/core/lifecycle.py` -- Test: `tests/unit/test_lifecycle.py` - -**Interfaces:** -- Consumes: nothing. -- Produces: `STATUSES: set`, `validate_transition(current: str, target: str) -> None`, `InvalidTransitionError(ValueError)`. `STATUSES` is consumed by Task 5 (`core/schema.py`). - -- [ ] **Step 1: Write the failing test** - -```python -# tests/unit/test_lifecycle.py -import pytest - -from scripts.core import lifecycle - - -def test_proposed_can_become_accepted(): - lifecycle.validate_transition("proposed", "accepted") # must not raise - - -def test_proposed_can_become_rejected(): - lifecycle.validate_transition("proposed", "rejected") # must not raise - - -def test_accepted_cannot_go_back_to_proposed(): - with pytest.raises(lifecycle.InvalidTransitionError): - lifecycle.validate_transition("accepted", "proposed") - - -def test_rejected_is_terminal(): - with pytest.raises(lifecycle.InvalidTransitionError): - lifecycle.validate_transition("rejected", "accepted") - - -def test_unknown_status_is_rejected(): - with pytest.raises(lifecycle.InvalidTransitionError): - lifecycle.validate_transition("accepted", "archived") -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: `python -m pytest tests/unit/test_lifecycle.py -v` -Expected: FAIL — module doesn't exist. - -- [ ] **Step 3: Write minimal implementation** - -```python -# skills/adr-toolkit/scripts/core/lifecycle.py -"""ADR status lifecycle rules.""" - -STATUSES = {"proposed", "accepted", "rejected", "deprecated", "superseded"} - -ALLOWED_TRANSITIONS = { - "proposed": {"accepted", "rejected"}, - "accepted": {"deprecated", "superseded"}, - "rejected": set(), - "deprecated": set(), - "superseded": set(), -} - - -class InvalidTransitionError(ValueError): - pass - - -def validate_transition(current: str, target: str) -> None: - if current not in STATUSES: - raise InvalidTransitionError(f"Unknown current status: {current!r}") - if target not in STATUSES: - raise InvalidTransitionError(f"Unknown target status: {target!r}") - if target not in ALLOWED_TRANSITIONS[current]: - raise InvalidTransitionError(f"Cannot transition from {current!r} to {target!r}") -``` - -- [ ] **Step 4: Run test to verify it passes** - -Run: `python -m pytest tests/unit/test_lifecycle.py -v` -Expected: PASS (5 tests) - -- [ ] **Step 5: Commit** - -```bash -git add skills/adr-toolkit/scripts/core/lifecycle.py tests/unit/test_lifecycle.py -git commit -m "feat: add ADR lifecycle transition rules" -``` - ---- - -### Task 5: `core/schema.py` — frontmatter structural validation - -**Files:** -- Create: `skills/adr-toolkit/scripts/core/schema.py` -- Create: `skills/adr-toolkit/schemas/adr.schema.json` (human/tool-readable reference; `core/schema.py` is the enforced source of truth, kept in sync by hand) -- Test: `tests/unit/test_schema.py` - -**Interfaces:** -- Consumes: `scripts.core.lifecycle.STATUSES` (Task 4). -- Produces: `validate_frontmatter(data: dict) -> list`. Used by Tasks 10 (create) and 12 (validate). - -- [ ] **Step 1: Write the failing test** - -```python -# tests/unit/test_schema.py -from scripts.core.schema import validate_frontmatter - -VALID = { - "id": "ADR-0001", - "title": "Record architecture decisions", - "status": "accepted", - "date": "2026-08-29", - "decision_makers": [], - "related": [], - "affected_paths": ["docs/decisions/"], - "tags": ["process"], - "retrospective": False, -} - - -def test_valid_frontmatter_has_no_errors(): - assert validate_frontmatter(VALID) == [] - - -def test_missing_field_is_reported(): - data = dict(VALID) - del data["status"] - errors = validate_frontmatter(data) - assert any("status" in e for e in errors) - - -def test_bad_id_format_is_reported(): - data = dict(VALID, id="0001") - errors = validate_frontmatter(data) - assert any("id" in e for e in errors) - - -def test_unknown_status_is_reported(): - data = dict(VALID, status="archived") - errors = validate_frontmatter(data) - assert any("status" in e for e in errors) - - -def test_bad_date_format_is_reported(): - data = dict(VALID, date="29-08-2026") - errors = validate_frontmatter(data) - assert any("date" in e for e in errors) - - -def test_wrong_type_is_reported(): - data = dict(VALID, tags="process") # should be a list - errors = validate_frontmatter(data) - assert any("tags" in e for e in errors) -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: `python -m pytest tests/unit/test_schema.py -v` -Expected: FAIL — module doesn't exist. - -- [ ] **Step 3: Write minimal implementation** - -```python -# skills/adr-toolkit/scripts/core/schema.py -"""Structural validation for ADR frontmatter. - -schemas/adr.schema.json documents the same shape for external tools; this -module is the version actually enforced at runtime. -""" -from datetime import date as date_cls - -from scripts.core.lifecycle import STATUSES - -REQUIRED_FIELDS = { - "id": str, - "title": str, - "status": str, - "date": str, - "decision_makers": list, - "related": list, - "affected_paths": list, - "tags": list, - "retrospective": bool, -} - -import re - -ID_RE = re.compile(r"^ADR-\d{4}$") -DATE_RE = re.compile(r"^\d{4}-\d{2}-\d{2}$") - - -def validate_frontmatter(data: dict) -> list: - errors = [] - for field, expected_type in REQUIRED_FIELDS.items(): - if field not in data: - errors.append(f"missing required field: {field}") - continue - if not isinstance(data[field], expected_type): - errors.append( - f"field {field!r} must be {expected_type.__name__}, " - f"got {type(data[field]).__name__}" - ) - - if isinstance(data.get("id"), str) and not ID_RE.match(data["id"]): - errors.append(f"id {data['id']!r} does not match ADR-NNNN") - - if "status" in data and data["status"] not in STATUSES: - errors.append(f"status {data['status']!r} is not one of {sorted(STATUSES)}") - - if isinstance(data.get("date"), str): - if not DATE_RE.match(data["date"]): - errors.append(f"date {data['date']!r} is not YYYY-MM-DD") - else: - year, month, day = (int(part) for part in data["date"].split("-")) - try: - date_cls(year, month, day) - except ValueError: - errors.append(f"date {data['date']!r} is not a real calendar date") - - return errors -``` - -```json -// skills/adr-toolkit/schemas/adr.schema.json -{ - "$schema": "http://json-schema.org/draft-07/schema#", - "title": "ADR Frontmatter", - "type": "object", - "required": ["id", "title", "status", "date", "decision_makers", "related", "affected_paths", "tags", "retrospective"], - "properties": { - "id": { "type": "string", "pattern": "^ADR-[0-9]{4}$" }, - "title": { "type": "string" }, - "status": { "type": "string", "enum": ["proposed", "accepted", "rejected", "deprecated", "superseded"] }, - "date": { "type": "string", "format": "date" }, - "decision_makers": { "type": "array", "items": { "type": "string" } }, - "related": { "type": "array", "items": { "type": "string" } }, - "affected_paths": { "type": "array", "items": { "type": "string" } }, - "tags": { "type": "array", "items": { "type": "string" } }, - "retrospective": { "type": "boolean" } - } -} -``` - -Delete `skills/adr-toolkit/schemas/.gitkeep` now that the directory has a real file. - -- [ ] **Step 4: Run test to verify it passes** - -Run: `python -m pytest tests/unit/test_schema.py -v` -Expected: PASS (6 tests) - -- [ ] **Step 5: Commit** - -```bash -git rm skills/adr-toolkit/schemas/.gitkeep -git add skills/adr-toolkit/scripts/core/schema.py skills/adr-toolkit/schemas/adr.schema.json \ - tests/unit/test_schema.py -git commit -m "feat: add ADR frontmatter schema validation" -``` - ---- - -### Task 6: `commands/preflight.py` — environment and existing-convention check - -**Files:** -- Create: `skills/adr-toolkit/scripts/commands/preflight.py` -- Test: `tests/unit/test_preflight.py` - -**Interfaces:** -- Consumes: nothing. -- Produces: `run(args) -> dict` where `args` has an optional `.root` attribute (defaults to `"."`). Return shape: `{"ok": bool, "operation": "preflight", "python_version": str, "git_available": bool, "existing_adr_directory": Optional[str], "warnings": list, "errors": list}`. Used by Task 14 (CLI entrypoint) and Task 17 (fixture test). - -- [ ] **Step 1: Write the failing test** - -```python -# tests/unit/test_preflight.py -from types import SimpleNamespace - -from scripts.commands import preflight - - -def test_reports_no_existing_adr_directory(tmp_path): - result = preflight.run(SimpleNamespace(root=str(tmp_path))) - assert result["ok"] is True - assert result["existing_adr_directory"] is None - - -def test_detects_existing_docs_decisions_directory(tmp_path): - (tmp_path / "docs" / "decisions").mkdir(parents=True) - result = preflight.run(SimpleNamespace(root=str(tmp_path))) - assert result["existing_adr_directory"] == "docs/decisions" - - -def test_reports_git_availability(tmp_path): - result = preflight.run(SimpleNamespace(root=str(tmp_path))) - assert isinstance(result["git_available"], bool) -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: `python -m pytest tests/unit/test_preflight.py -v` -Expected: FAIL — module doesn't exist. - -- [ ] **Step 3: Write minimal implementation** - -```python -# skills/adr-toolkit/scripts/commands/preflight.py -"""Check that the environment has what ADR Toolkit needs to run.""" -import shutil -import sys -from pathlib import Path - -CANDIDATE_ADR_DIRS = ("docs/decisions", "docs/adr", "adr", "decisions") - - -def run(args) -> dict: - errors = [] - - if sys.version_info < (3, 9): - errors.append({"code": "PYTHON_TOO_OLD", "detail": sys.version}) - - git_path = shutil.which("git") - - root = Path(getattr(args, "root", ".")).resolve() - existing_dir = None - for candidate in CANDIDATE_ADR_DIRS: - if (root / candidate).is_dir(): - existing_dir = candidate - break - - warnings = [] - if git_path is None: - warnings.append({"code": "GIT_NOT_FOUND"}) - - return { - "ok": not errors, - "operation": "preflight", - "python_version": sys.version.split()[0], - "git_available": git_path is not None, - "existing_adr_directory": existing_dir, - "warnings": warnings, - "errors": errors, - } -``` - -- [ ] **Step 4: Run test to verify it passes** - -Run: `python -m pytest tests/unit/test_preflight.py -v` -Expected: PASS (3 tests) - -- [ ] **Step 5: Commit** - -```bash -git add skills/adr-toolkit/scripts/commands/preflight.py tests/unit/test_preflight.py -git commit -m "feat: add preflight environment check" -``` - ---- - -### Task 7: `evidence/dependency_scanner.py` — dependency manifest detection - -**Files:** -- Create: `skills/adr-toolkit/scripts/evidence/dependency_scanner.py` -- Test: `tests/unit/test_dependency_scanner.py` - -**Interfaces:** -- Consumes: nothing. -- Produces: `scan(root: Path) -> list` where each item is `{"ecosystem": str, "path": str}`. Used by Task 8 (`commands/discover.py`). - -- [ ] **Step 1: Write the failing test** - -```python -# tests/unit/test_dependency_scanner.py -from pathlib import Path - -from scripts.evidence import dependency_scanner - - -def test_detects_npm_manifest(tmp_path): - (tmp_path / "package.json").write_text("{}", encoding="utf-8") - findings = dependency_scanner.scan(tmp_path) - assert {"ecosystem": "npm", "path": "package.json"} in findings - - -def test_detects_multiple_manifests(tmp_path): - (tmp_path / "pyproject.toml").write_text("", encoding="utf-8") - (tmp_path / "go.mod").write_text("", encoding="utf-8") - findings = dependency_scanner.scan(tmp_path) - ecosystems = {f["ecosystem"] for f in findings} - assert ecosystems == {"python", "go"} - - -def test_no_manifests_returns_empty_list(tmp_path): - assert dependency_scanner.scan(tmp_path) == [] -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: `python -m pytest tests/unit/test_dependency_scanner.py -v` -Expected: FAIL — module doesn't exist. - -- [ ] **Step 3: Write minimal implementation** - -```python -# skills/adr-toolkit/scripts/evidence/dependency_scanner.py -"""Detect dependency manifest files that hint at technology choices.""" -from pathlib import Path - -MANIFEST_FILES = { - "package.json": "npm", - "pyproject.toml": "python", - "requirements.txt": "python", - "pom.xml": "maven", - "build.gradle": "gradle", - "build.gradle.kts": "gradle", - "go.mod": "go", - "Cargo.toml": "cargo", -} - - -def scan(root: Path) -> list: - findings = [] - for filename, ecosystem in MANIFEST_FILES.items(): - path = root / filename - if path.is_file(): - findings.append({"ecosystem": ecosystem, "path": filename}) - return findings -``` - -- [ ] **Step 4: Run test to verify it passes** - -Run: `python -m pytest tests/unit/test_dependency_scanner.py -v` -Expected: PASS (3 tests) - -- [ ] **Step 5: Commit** - -```bash -git add skills/adr-toolkit/scripts/evidence/dependency_scanner.py tests/unit/test_dependency_scanner.py -git commit -m "feat: add dependency manifest evidence scanner" -``` - ---- - -### Task 8: `commands/discover.py` — repository evidence collection - -**Files:** -- Create: `skills/adr-toolkit/scripts/commands/discover.py` -- Test: `tests/unit/test_discover.py` - -**Interfaces:** -- Consumes: `scripts.evidence.dependency_scanner.scan` (Task 7). -- Produces: `run(args) -> dict` where `args.root` defaults to `"."`. Return shape: `{"ok": True, "operation": "discover", "root": str, "dependencies": list, "warnings": list}`. Used by Task 14 (CLI) and Task 17 (fixture test). - -- [ ] **Step 1: Write the failing test** - -```python -# tests/unit/test_discover.py -from types import SimpleNamespace - -from scripts.commands import discover - - -def test_discover_reports_dependency_findings(tmp_path): - (tmp_path / "package.json").write_text("{}", encoding="utf-8") - result = discover.run(SimpleNamespace(root=str(tmp_path))) - assert result["ok"] is True - assert {"ecosystem": "npm", "path": "package.json"} in result["dependencies"] - - -def test_discover_on_empty_repo_reports_no_dependencies(tmp_path): - result = discover.run(SimpleNamespace(root=str(tmp_path))) - assert result["dependencies"] == [] -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: `python -m pytest tests/unit/test_discover.py -v` -Expected: FAIL — module doesn't exist. - -- [ ] **Step 3: Write minimal implementation** - -```python -# skills/adr-toolkit/scripts/commands/discover.py -"""Scan the repository for evidence of existing conventions and past decisions.""" -from pathlib import Path - -from scripts.evidence import dependency_scanner - - -def run(args) -> dict: - root = Path(getattr(args, "root", ".")).resolve() - dependencies = dependency_scanner.scan(root) - - return { - "ok": True, - "operation": "discover", - "root": str(root), - "dependencies": dependencies, - "warnings": [], - } -``` - -- [ ] **Step 4: Run test to verify it passes** - -Run: `python -m pytest tests/unit/test_discover.py -v` -Expected: PASS (2 tests) - -- [ ] **Step 5: Commit** - -```bash -git add skills/adr-toolkit/scripts/commands/discover.py tests/unit/test_discover.py -git commit -m "feat: add discover command orchestrating evidence scanners" -``` - ---- - -### Task 9: MADR templates (minimal and full) - -**Files:** -- Create: `skills/adr-toolkit/templates/madr-minimal.md` -- Create: `skills/adr-toolkit/templates/madr-full.md` -- Test: `tests/unit/test_templates.py` - -**Interfaces:** -- Consumes: nothing. -- Produces: two template files referenced by `SKILL.md` (Task 15) when the agent drafts ADR bodies. Not imported by Python code. - -- [ ] **Step 1: Write the failing test** - -```python -# tests/unit/test_templates.py -from pathlib import Path - -TEMPLATES = Path(__file__).resolve().parent.parent.parent / "skills" / "adr-toolkit" / "templates" - -REQUIRED_MINIMAL_SECTIONS = [ - "## Context and Problem Statement", - "## Considered Options", - "## Decision Outcome", - "## Consequences", - "## Confirmation", - "## Revisit Triggers", -] - -REQUIRED_FULL_SECTIONS = REQUIRED_MINIMAL_SECTIONS + [ - "## Decision Drivers", - "## Pros and Cons of the Options", -] - - -def test_minimal_template_has_required_sections(): - text = (TEMPLATES / "madr-minimal.md").read_text(encoding="utf-8") - for section in REQUIRED_MINIMAL_SECTIONS: - assert section in text, f"missing {section}" - - -def test_full_template_has_required_sections(): - text = (TEMPLATES / "madr-full.md").read_text(encoding="utf-8") - for section in REQUIRED_FULL_SECTIONS: - assert section in text, f"missing {section}" -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: `python -m pytest tests/unit/test_templates.py -v` -Expected: FAIL — files don't exist. - -- [ ] **Step 3: Write the template files** - -```markdown - -# {title} - -## Context and Problem Statement - -{problem and constraints} - -## Considered Options - -* {option one} -* {option two} - -## Decision Outcome - -Chosen option: **{chosen option}**, because {rationale}. - -## Consequences - -* Good: {positive consequence} -* Bad: {negative consequence} - -## Confirmation - -{how implementation will be verified} - -## Revisit Triggers - -* {condition that should reopen the decision} -``` - -```markdown - -# {title} - -## Context and Problem Statement - -{problem and constraints} - -## Decision Drivers - -* {driver one} -* {driver two} - -## Considered Options - -* {option one} -* {option two} -* {option three} - -## Decision Outcome - -Chosen option: **{chosen option}**, because {rationale}. - -### Consequences - -* Good: {positive consequence} -* Bad: {negative consequence} - -### Confirmation - -{how implementation will be verified} - -## Pros and Cons of the Options - -### {option one} - -* Good, because {argument} -* Bad, because {argument} - -### {option two} - -* Good, because {argument} -* Bad, because {argument} - -## Revisit Triggers - -* {condition that should reopen the decision} -``` - -Delete `skills/adr-toolkit/templates/.gitkeep`. - -- [ ] **Step 4: Run test to verify it passes** - -Run: `python -m pytest tests/unit/test_templates.py -v` -Expected: PASS (2 tests) - -- [ ] **Step 5: Commit** - -```bash -git rm skills/adr-toolkit/templates/.gitkeep -git add skills/adr-toolkit/templates/madr-minimal.md skills/adr-toolkit/templates/madr-full.md \ - tests/unit/test_templates.py -git commit -m "feat: add minimal and full MADR templates" -``` - ---- - -### Task 10: `commands/init.py` — scaffold a new ADR directory - -**Files:** -- Create: `skills/adr-toolkit/scripts/commands/init.py` -- Test: `tests/unit/test_init.py` - -**Interfaces:** -- Consumes: `skills/adr-toolkit/templates/madr-minimal.md` (Task 9, copied as-is into the new directory). -- Produces: `run(args) -> dict` where `args` has `.dir` (str) and optional `.dry_run` (bool). Return shape on success: `{"ok": True, "operation": "init", "dry_run": bool, "created"/"would_create": list}`; on conflict: `{"ok": False, "operation": "init", "errors": [{"code": "ADR_DIRECTORY_NOT_EMPTY", "path": str}]}`. Used by Task 14 (CLI) and Task 17 (fixture test). - -- [ ] **Step 1: Write the failing test** - -```python -# tests/unit/test_init.py -from types import SimpleNamespace - -from scripts.commands import init - - -def test_dry_run_reports_would_create_without_writing(tmp_path): - adr_dir = tmp_path / "docs" / "decisions" - result = init.run(SimpleNamespace(dir=str(adr_dir), dry_run=True)) - assert result["ok"] is True - assert result["dry_run"] is True - assert not adr_dir.exists() - - -def test_creates_directory_template_and_first_adr(tmp_path): - adr_dir = tmp_path / "docs" / "decisions" - result = init.run(SimpleNamespace(dir=str(adr_dir), dry_run=False)) - assert result["ok"] is True - assert (adr_dir / "adr-template.md").is_file() - first_adr = adr_dir / "0001-record-architecture-decisions.md" - assert first_adr.is_file() - content = first_adr.read_text(encoding="utf-8") - assert "id: ADR-0001" in content - assert "status: accepted" in content - - -def test_refuses_to_run_on_non_empty_directory(tmp_path): - adr_dir = tmp_path / "docs" / "decisions" - adr_dir.mkdir(parents=True) - (adr_dir / "existing.md").write_text("x", encoding="utf-8") - - result = init.run(SimpleNamespace(dir=str(adr_dir), dry_run=False)) - assert result["ok"] is False - assert result["errors"][0]["code"] == "ADR_DIRECTORY_NOT_EMPTY" -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: `python -m pytest tests/unit/test_init.py -v` -Expected: FAIL — module doesn't exist. - -- [ ] **Step 3: Write minimal implementation** - -```python -# skills/adr-toolkit/scripts/commands/init.py -"""Scaffold an ADR directory for a repository that has none yet.""" -import shutil -from datetime import date -from pathlib import Path - -TEMPLATE_SOURCE = Path(__file__).resolve().parent.parent.parent / "templates" / "madr-minimal.md" - -INITIAL_ADR_BODY = """# Record architecture decisions - -## Context and Problem Statement - -We need a consistent way to capture and communicate significant -architectural decisions so future contributors (human or agent) can find -the reasoning behind them. - -## Considered Options - -* No formal record, rely on commit messages and memory -* Wiki or external documentation tool -* Architecture Decision Records stored alongside the code - -## Decision Outcome - -Chosen option: **Architecture Decision Records stored alongside the code**, because they version with the code, stay close to what they describe, and are readable by both humans and coding agents. - -## Consequences - -* Good: decisions and their rationale are discoverable in the repository itself. -* Bad: requires discipline to keep records up to date as decisions evolve. - -## Confirmation - -* [ ] `docs/decisions/` exists with this file, a template, and an index. - -## Revisit Triggers - -* The team adopts a different documentation system project-wide. -""" - - -def run(args) -> dict: - adr_dir = Path(args.dir) - dry_run = getattr(args, "dry_run", False) - - if adr_dir.exists() and any(adr_dir.iterdir()): - return { - "ok": False, - "operation": "init", - "errors": [{"code": "ADR_DIRECTORY_NOT_EMPTY", "path": str(adr_dir)}], - } - - would_create = [ - str(adr_dir), - str(adr_dir / "adr-template.md"), - str(adr_dir / "0001-record-architecture-decisions.md"), - ] - - if dry_run: - return {"ok": True, "operation": "init", "dry_run": True, "would_create": would_create} - - adr_dir.mkdir(parents=True, exist_ok=True) - shutil.copyfile(TEMPLATE_SOURCE, adr_dir / "adr-template.md") - - frontmatter_block = ( - "---\n" - "id: ADR-0001\n" - "title: Record architecture decisions\n" - "status: accepted\n" - f"date: {date.today().isoformat()}\n" - "decision_makers: []\n" - "related: []\n" - "affected_paths:\n" - " - docs/decisions/\n" - "tags:\n" - " - process\n" - "retrospective: false\n" - "---\n\n" - ) - (adr_dir / "0001-record-architecture-decisions.md").write_text( - frontmatter_block + INITIAL_ADR_BODY, encoding="utf-8" - ) - - return {"ok": True, "operation": "init", "dry_run": False, "created": would_create} -``` - -- [ ] **Step 4: Run test to verify it passes** - -Run: `python -m pytest tests/unit/test_init.py -v` -Expected: PASS (3 tests) - -- [ ] **Step 5: Commit** - -```bash -git add skills/adr-toolkit/scripts/commands/init.py tests/unit/test_init.py -git commit -m "feat: add init command to scaffold ADR directory" -``` - ---- - -### Task 11: `commands/create.py` — write a new ADR from an approved draft - -**Files:** -- Create: `skills/adr-toolkit/scripts/commands/create.py` -- Test: `tests/unit/test_create.py` - -**Interfaces:** -- Consumes: `scripts.core.frontmatter.serialize` (Task 2), `scripts.core.identifiers.{next_id, slugify, format_filename}` (Task 3), `scripts.core.schema.validate_frontmatter` (Task 5). -- Produces: `run(args) -> dict` where `args` has `.input` (path to draft JSON), `.dir` (ADR directory), optional `.dry_run`. The draft JSON must have `title`, `status`, `body` (pre-rendered markdown from the agent); optional `date`, `decision_makers`, `related`, `affected_paths`, `tags`, `retrospective`. Return shape on success: `{"ok": True, "operation": "create", "dry_run": bool, "created"/"would_create": str, "id": str}`. Used by Task 14 (CLI) and Task 17 (fixture test). - -- [ ] **Step 1: Write the failing test** - -```python -# tests/unit/test_create.py -import json -from pathlib import Path -from types import SimpleNamespace - -from scripts.commands import create - - -def _write_draft(tmp_path, **overrides): - draft = { - "title": "Use Kafka for domain events", - "status": "accepted", - "body": "# Use Kafka for domain events\n\nBody text.\n", - } - draft.update(overrides) - draft_path = tmp_path / "draft.json" - draft_path.write_text(json.dumps(draft), encoding="utf-8") - return draft_path - - -def test_creates_file_with_next_id_and_valid_frontmatter(tmp_path): - adr_dir = tmp_path / "docs" / "decisions" - adr_dir.mkdir(parents=True) - draft_path = _write_draft(tmp_path) - - result = create.run(SimpleNamespace(input=str(draft_path), dir=str(adr_dir), dry_run=False)) - - assert result["ok"] is True - assert result["id"] == "ADR-0001" - created_file = Path(result["created"]) - assert created_file.name == "0001-use-kafka-for-domain-events.md" - assert "status: accepted" in created_file.read_text(encoding="utf-8") - - -def test_next_id_accounts_for_existing_adrs(tmp_path): - adr_dir = tmp_path / "docs" / "decisions" - adr_dir.mkdir(parents=True) - (adr_dir / "0001-existing.md").write_text("x", encoding="utf-8") - draft_path = _write_draft(tmp_path) - - result = create.run(SimpleNamespace(input=str(draft_path), dir=str(adr_dir), dry_run=False)) - - assert result["id"] == "ADR-0002" - - -def test_dry_run_does_not_write_file(tmp_path): - adr_dir = tmp_path / "docs" / "decisions" - adr_dir.mkdir(parents=True) - draft_path = _write_draft(tmp_path) - - result = create.run(SimpleNamespace(input=str(draft_path), dir=str(adr_dir), dry_run=True)) - - assert result["ok"] is True - assert result["dry_run"] is True - assert list(adr_dir.iterdir()) == [] - - -def test_missing_required_draft_field_is_an_error(tmp_path): - adr_dir = tmp_path / "docs" / "decisions" - adr_dir.mkdir(parents=True) - draft_path = tmp_path / "draft.json" - draft_path.write_text(json.dumps({"title": "Missing status and body"}), encoding="utf-8") - - result = create.run(SimpleNamespace(input=str(draft_path), dir=str(adr_dir), dry_run=False)) - - assert result["ok"] is False - assert result["errors"][0]["code"] == "MISSING_DRAFT_FIELD" -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: `python -m pytest tests/unit/test_create.py -v` -Expected: FAIL — module doesn't exist. - -- [ ] **Step 3: Write minimal implementation** - -```python -# skills/adr-toolkit/scripts/commands/create.py -"""Assign the next ADR ID and write a new ADR file from an approved draft.""" -import json -from datetime import date -from pathlib import Path - -from scripts.core import frontmatter as fm -from scripts.core import identifiers -from scripts.core.schema import validate_frontmatter - -REQUIRED_DRAFT_FIELDS = {"title", "status", "body"} - - -def run(args) -> dict: - draft_path = Path(args.input) - adr_dir = Path(args.dir) - dry_run = getattr(args, "dry_run", False) - - draft = json.loads(draft_path.read_text(encoding="utf-8")) - missing = REQUIRED_DRAFT_FIELDS - draft.keys() - if missing: - return { - "ok": False, - "operation": "create", - "errors": [{"code": "MISSING_DRAFT_FIELD", "fields": sorted(missing)}], - } - - adr_dir.mkdir(parents=True, exist_ok=True) - next_num = identifiers.next_id(adr_dir) - slug = identifiers.slugify(draft["title"]) - filename = identifiers.format_filename(next_num, slug) - target = adr_dir / filename - - if target.exists(): - return { - "ok": False, - "operation": "create", - "errors": [{"code": "FILE_ALREADY_EXISTS", "path": str(target)}], - } - - frontmatter_data = { - "id": f"ADR-{next_num:04d}", - "title": draft["title"], - "status": draft["status"], - "date": draft.get("date") or date.today().isoformat(), - "decision_makers": draft.get("decision_makers", []), - "related": draft.get("related", []), - "affected_paths": draft.get("affected_paths", []), - "tags": draft.get("tags", []), - "retrospective": draft.get("retrospective", False), - } - - schema_errors = validate_frontmatter(frontmatter_data) - if schema_errors: - return { - "ok": False, - "operation": "create", - "errors": [{"code": "SCHEMA_ERROR", "detail": e} for e in schema_errors], - } - - content = fm.serialize(frontmatter_data, draft["body"].strip() + "\n") - - if dry_run: - return {"ok": True, "operation": "create", "dry_run": True, "would_create": str(target)} - - target.write_text(content, encoding="utf-8") - - return { - "ok": True, - "operation": "create", - "dry_run": False, - "created": str(target), - "id": frontmatter_data["id"], - } -``` - -- [ ] **Step 4: Run test to verify it passes** - -Run: `python -m pytest tests/unit/test_create.py -v` -Expected: PASS (4 tests) - -- [ ] **Step 5: Commit** - -```bash -git add skills/adr-toolkit/scripts/commands/create.py tests/unit/test_create.py -git commit -m "feat: add create command to write approved ADR drafts" -``` - ---- - -### Task 12: `commands/index.py` — multi-view decision log - -**Files:** -- Create: `skills/adr-toolkit/scripts/commands/index.py` -- Test: `tests/unit/test_index.py` - -**Interfaces:** -- Consumes: `scripts.core.frontmatter.parse` (Task 2), `scripts.core.identifiers.parse_filename` (Task 3). -- Produces: `run(args) -> dict` where `args.dir` is the ADR directory. Return shape: `{"ok": True, "operation": "index", "count": int, "path": str}`. Writes `README.md` inside the ADR directory with By-status / By-tag / By-affected-path / Chronological sections. Used by Task 14 (CLI) and Task 17 (fixture test). - -- [ ] **Step 1: Write the failing test** - -```python -# tests/unit/test_index.py -from types import SimpleNamespace - -from scripts.commands import index - - -def _write_adr(adr_dir, filename, *, id_, title, status, date, tags, affected_paths): - tags_block = "\n".join(f" - {t}" for t in tags) or None - paths_block = "\n".join(f" - {p}" for p in affected_paths) or None - text = ( - "---\n" - f"id: {id_}\n" - f"title: {title}\n" - f"status: {status}\n" - f"date: {date}\n" - "decision_makers: []\n" - "related: []\n" - f"affected_paths:\n{paths_block}\n" - f"tags:\n{tags_block}\n" - "retrospective: false\n" - "---\n\n" - f"# {title}\n" - ) - (adr_dir / filename).write_text(text, encoding="utf-8") - - -def test_index_generates_readme_with_all_views(tmp_path): - adr_dir = tmp_path - _write_adr( - adr_dir, "0001-use-kafka.md", - id_="ADR-0001", title="Use Kafka", status="accepted", date="2026-08-01", - tags=["architecture"], affected_paths=["src/events/"], - ) - _write_adr( - adr_dir, "0002-use-postgres.md", - id_="ADR-0002", title="Use Postgres", status="proposed", date="2026-08-15", - tags=["architecture", "data"], affected_paths=["src/db/"], - ) - - result = index.run(SimpleNamespace(dir=str(adr_dir))) - - assert result["ok"] is True - assert result["count"] == 2 - readme = (adr_dir / "README.md").read_text(encoding="utf-8") - assert "## By status" in readme - assert "## By tag" in readme - assert "## By affected path" in readme - assert "## Chronological" in readme - assert "ADR-0001" in readme and "ADR-0002" in readme - assert "`src/events/`" in readme - - -def test_index_skips_readme_and_template_files(tmp_path): - (tmp_path / "adr-template.md").write_text("not an ADR", encoding="utf-8") - result = index.run(SimpleNamespace(dir=str(tmp_path))) - assert result["count"] == 0 -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: `python -m pytest tests/unit/test_index.py -v` -Expected: FAIL — module doesn't exist. - -- [ ] **Step 3: Write minimal implementation** - -```python -# skills/adr-toolkit/scripts/commands/index.py -"""Regenerate the multi-view ADR index (README.md) for a decision directory.""" -from pathlib import Path - -from scripts.core import frontmatter as fm -from scripts.core import identifiers - -SKIP_FILES = {"README.md", "adr-template.md"} - - -def run(args) -> dict: - adr_dir = Path(args.dir) - entries = [] - - for entry in sorted(adr_dir.glob("*.md")): - if entry.name in SKIP_FILES: - continue - parsed = identifiers.parse_filename(entry.name) - if parsed is None: - continue - data, _ = fm.parse(entry.read_text(encoding="utf-8")) - entries.append({ - "id": data.get("id", f"ADR-{parsed[0]:04d}"), - "filename": entry.name, - "title": data.get("title", parsed[1]), - "status": data.get("status", "unknown"), - "date": data.get("date", ""), - "tags": data.get("tags", []), - "affected_paths": data.get("affected_paths", []), - }) - - (adr_dir / "README.md").write_text(_render(entries), encoding="utf-8") - - return {"ok": True, "operation": "index", "count": len(entries), "path": str(adr_dir / "README.md")} - - -def _render(entries: list) -> str: - lines = ["# Decision Log", ""] - - lines.append("## By status") - lines.append("") - by_status: dict = {} - for entry in entries: - by_status.setdefault(entry["status"], []).append(entry) - for status in sorted(by_status): - lines.append(f"### {status.capitalize()}") - for entry in sorted(by_status[status], key=lambda e: e["filename"]): - lines.append(f"- [{entry['id']} — {entry['title']}]({entry['filename']})") - lines.append("") - - lines.append("## By tag") - lines.append("") - by_tag: dict = {} - for entry in entries: - for tag in entry["tags"]: - by_tag.setdefault(tag, []).append(entry) - for tag in sorted(by_tag): - lines.append(f"### {tag}") - for entry in sorted(by_tag[tag], key=lambda e: e["filename"]): - lines.append(f"- [{entry['id']} — {entry['title']}]({entry['filename']})") - lines.append("") - - lines.append("## By affected path") - lines.append("") - by_path: dict = {} - for entry in entries: - for path in entry["affected_paths"]: - by_path.setdefault(path, []).append(entry) - for path in sorted(by_path): - lines.append(f"### `{path}`") - for entry in sorted(by_path[path], key=lambda e: e["filename"]): - lines.append(f"- [{entry['id']} — {entry['title']}]({entry['filename']})") - lines.append("") - - lines.append("## Chronological (newest first)") - lines.append("") - for entry in sorted(entries, key=lambda e: e["date"], reverse=True): - lines.append(f"- {entry['date']} — [{entry['id']} — {entry['title']}]({entry['filename']})") - - return "\n".join(lines) + "\n" -``` - -- [ ] **Step 4: Run test to verify it passes** - -Run: `python -m pytest tests/unit/test_index.py -v` -Expected: PASS (2 tests) - -- [ ] **Step 5: Commit** - -```bash -git add skills/adr-toolkit/scripts/commands/index.py tests/unit/test_index.py -git commit -m "feat: add multi-view decision log index command" -``` - ---- - -### Task 13: `commands/validate.py` — structural integrity checks - -**Files:** -- Create: `skills/adr-toolkit/scripts/commands/validate.py` -- Test: `tests/unit/test_validate.py` - -**Interfaces:** -- Consumes: `scripts.core.frontmatter.parse` (Task 2), `scripts.core.identifiers.parse_filename` (Task 3), `scripts.core.schema.validate_frontmatter` (Task 5). -- Produces: `run(args) -> dict` where `args.dir` is the ADR directory. Return shape: `{"ok": bool, "operation": "validate", "checked": int, "errors": list}`. Used by Task 14 (CLI) and Task 17 (fixture test). - -- [ ] **Step 1: Write the failing test** - -```python -# tests/unit/test_validate.py -from types import SimpleNamespace - -from scripts.commands import validate - -VALID_ADR = ( - "---\n" - "id: ADR-0001\n" - "title: Record architecture decisions\n" - "status: accepted\n" - "date: 2026-08-29\n" - "decision_makers: []\n" - "related: []\n" - "affected_paths:\n" - " - docs/decisions/\n" - "tags:\n" - " - process\n" - "retrospective: false\n" - "---\n\n" - "# Record architecture decisions\n" -) - - -def test_valid_directory_passes(tmp_path): - (tmp_path / "0001-record-architecture-decisions.md").write_text(VALID_ADR, encoding="utf-8") - result = validate.run(SimpleNamespace(dir=str(tmp_path))) - assert result["ok"] is True - assert result["checked"] == 1 - assert result["errors"] == [] - - -def test_duplicate_ids_are_reported(tmp_path): - (tmp_path / "0001-a.md").write_text(VALID_ADR, encoding="utf-8") - duplicate = VALID_ADR.replace("title: Record architecture decisions", "title: A duplicate") - (tmp_path / "0002-b.md").write_text(duplicate, encoding="utf-8") - - result = validate.run(SimpleNamespace(dir=str(tmp_path))) - - assert result["ok"] is False - assert any(e["code"] == "DUPLICATE_ADR_ID" for e in result["errors"]) - - -def test_broken_related_link_is_reported(tmp_path): - broken = VALID_ADR.replace("related: []", "related:\n - ADR-0099") - (tmp_path / "0001-a.md").write_text(broken, encoding="utf-8") - - result = validate.run(SimpleNamespace(dir=str(tmp_path))) - - assert result["ok"] is False - assert any(e["code"] == "BROKEN_RELATED_LINK" for e in result["errors"]) - - -def test_bad_filename_is_reported(tmp_path): - (tmp_path / "not-a-valid-name.md").write_text(VALID_ADR, encoding="utf-8") - result = validate.run(SimpleNamespace(dir=str(tmp_path))) - assert any(e["code"] == "BAD_FILENAME" for e in result["errors"]) -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: `python -m pytest tests/unit/test_validate.py -v` -Expected: FAIL — module doesn't exist. - -- [ ] **Step 3: Write minimal implementation** - -```python -# skills/adr-toolkit/scripts/commands/validate.py -"""Validate ADR directory structural integrity: IDs, frontmatter, and links.""" -from pathlib import Path - -from scripts.core import frontmatter as fm -from scripts.core import identifiers -from scripts.core.schema import validate_frontmatter - -SKIP_FILES = {"README.md", "adr-template.md"} - - -def run(args) -> dict: - adr_dir = Path(args.dir) - errors = [] - - adr_files = sorted(p for p in adr_dir.glob("*.md") if p.name not in SKIP_FILES) - - parsed_entries = [] - seen_ids: dict = {} - - for path in adr_files: - if identifiers.parse_filename(path.name) is None: - errors.append({"code": "BAD_FILENAME", "file": path.name}) - continue - - try: - data, _ = fm.parse(path.read_text(encoding="utf-8")) - except fm.FrontmatterError as exc: - errors.append({"code": "BAD_FRONTMATTER", "file": path.name, "detail": str(exc)}) - continue - - for detail in validate_frontmatter(data): - errors.append({"code": "SCHEMA_ERROR", "file": path.name, "detail": detail}) - - adr_id = data.get("id") - if adr_id: - if adr_id in seen_ids: - errors.append({"code": "DUPLICATE_ADR_ID", "files": [seen_ids[adr_id], path.name]}) - else: - seen_ids[adr_id] = path.name - - parsed_entries.append((path.name, data)) - - known_ids = set(seen_ids.keys()) - for filename, data in parsed_entries: - for related_id in data.get("related", []): - if related_id not in known_ids: - errors.append({"code": "BROKEN_RELATED_LINK", "file": filename, "related_id": related_id}) - - return {"ok": not errors, "operation": "validate", "checked": len(adr_files), "errors": errors} -``` - -- [ ] **Step 4: Run test to verify it passes** - -Run: `python -m pytest tests/unit/test_validate.py -v` -Expected: PASS (4 tests) - -- [ ] **Step 5: Commit** - -```bash -git add skills/adr-toolkit/scripts/commands/validate.py tests/unit/test_validate.py -git commit -m "feat: add validate command for ADR structural integrity" -``` - ---- - -### Task 14: `scripts/adr.py` — single CLI entrypoint - -**Files:** -- Create: `skills/adr-toolkit/scripts/adr.py` -- Test: `tests/integration/test_cli.py` - -**Interfaces:** -- Consumes: `scripts.commands.{preflight, discover, init, create, index, validate}.run` (Tasks 6, 8, 10, 11, 12, 13). -- Produces: an executable script (`python scripts/adr.py [--json] ...`) printing one JSON object to stdout and exiting 0 on `ok: true`, 1 otherwise. This is what `SKILL.md` (Task 15) and the fixture test (Task 17) actually invoke. - -- [ ] **Step 1: Write the failing test** - -```python -# tests/integration/test_cli.py -import json -import subprocess -import sys -from pathlib import Path - -ADR_PY = Path(__file__).resolve().parent.parent.parent / "skills" / "adr-toolkit" / "scripts" / "adr.py" - - -def _run(args, cwd): - return subprocess.run( - [sys.executable, str(ADR_PY), *args], cwd=cwd, capture_output=True, text=True, - ) - - -def test_preflight_returns_valid_json(tmp_path): - result = _run(["preflight", "--json"], cwd=tmp_path) - assert result.returncode == 0 - payload = json.loads(result.stdout) - assert payload["operation"] == "preflight" - assert payload["ok"] is True - - -def test_init_then_validate_round_trip(tmp_path): - init_result = _run(["init", "--dir", "docs/decisions", "--json"], cwd=tmp_path) - assert init_result.returncode == 0 - - validate_result = _run(["validate", "--dir", "docs/decisions", "--json"], cwd=tmp_path) - payload = json.loads(validate_result.stdout) - assert validate_result.returncode == 0 - assert payload["ok"] is True - assert payload["checked"] == 1 - - -def test_unknown_subcommand_exits_nonzero(tmp_path): - result = _run(["not-a-real-command"], cwd=tmp_path) - assert result.returncode != 0 -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: `python -m pytest tests/integration/test_cli.py -v` -Expected: FAIL — `scripts/adr.py` doesn't exist. - -- [ ] **Step 3: Write minimal implementation** - -```python -#!/usr/bin/env python3 -# skills/adr-toolkit/scripts/adr.py -"""Single entrypoint for all ADR Toolkit deterministic operations.""" -import argparse -import json -import sys -from pathlib import Path - -sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) - -from scripts.commands import preflight, discover, init, create, index, validate - - -def build_parser() -> argparse.ArgumentParser: - parser = argparse.ArgumentParser(prog="adr.py") - sub = parser.add_subparsers(dest="operation", required=True) - - p_preflight = sub.add_parser("preflight") - p_preflight.add_argument("--json", action="store_true") - p_preflight.add_argument("--root", default=".") - - p_discover = sub.add_parser("discover") - p_discover.add_argument("--json", action="store_true") - p_discover.add_argument("--root", default=".") - - p_init = sub.add_parser("init") - p_init.add_argument("--dir", default="docs/decisions") - p_init.add_argument("--dry-run", dest="dry_run", action="store_true") - p_init.add_argument("--json", action="store_true") - - p_create = sub.add_parser("create") - p_create.add_argument("--input", required=True) - p_create.add_argument("--dir", default="docs/decisions") - p_create.add_argument("--dry-run", dest="dry_run", action="store_true") - p_create.add_argument("--json", action="store_true") - - p_index = sub.add_parser("index") - p_index.add_argument("--dir", default="docs/decisions") - p_index.add_argument("--json", action="store_true") - - p_validate = sub.add_parser("validate") - p_validate.add_argument("--dir", default="docs/decisions") - p_validate.add_argument("--json", action="store_true") - - return parser - - -HANDLERS = { - "preflight": preflight.run, - "discover": discover.run, - "init": init.run, - "create": create.run, - "index": index.run, - "validate": validate.run, -} - - -def main(argv=None) -> int: - parser = build_parser() - args = parser.parse_args(argv) - - result = HANDLERS[args.operation](args) - print(json.dumps(result, indent=2, ensure_ascii=False)) - return 0 if result.get("ok") else 1 - - -if __name__ == "__main__": - sys.exit(main()) -``` - -- [ ] **Step 4: Run test to verify it passes** - -Run: `python -m pytest tests/integration/test_cli.py -v` -Expected: PASS (3 tests) - -- [ ] **Step 5: Commit** - -```bash -git add skills/adr-toolkit/scripts/adr.py tests/integration/test_cli.py -git commit -m "feat: add single CLI entrypoint dispatching ADR subcommands" -``` - ---- - -### Task 15: `SKILL.md` and INIT-supporting references - -**Files:** -- Create: `skills/adr-toolkit/SKILL.md` -- Create: `skills/adr-toolkit/references/lifecycle.md` -- Create: `skills/adr-toolkit/references/madr-guide.md` -- Test: `tests/unit/test_skill_manifest.py` - -**Interfaces:** -- Consumes: `scripts.core.frontmatter.parse` (Task 2, reused by the test to check `SKILL.md`'s own frontmatter). -- Produces: the skill's user-facing contract. Consumed by Task 16 (Claude adapter) and by any harness that loads this skill. - -- [ ] **Step 1: Write the failing test** - -```python -# tests/unit/test_skill_manifest.py -from pathlib import Path - -from scripts.core import frontmatter as fm - -SKILL_MD = Path(__file__).resolve().parent.parent.parent / "skills" / "adr-toolkit" / "SKILL.md" -REFERENCES = SKILL_MD.parent / "references" - - -def test_skill_md_frontmatter_has_required_fields(): - data, body = fm.parse(SKILL_MD.read_text(encoding="utf-8")) - assert data["name"] == "adr-toolkit" - assert data["user-invocable"] is True - assert "description" in data - assert "version" in data - - -def test_skill_md_documents_the_workflow_stages(): - _, body = fm.parse(SKILL_MD.read_text(encoding="utf-8")) - for stage in ["PREFLIGHT", "DISCOVER", "CLASSIFY", "ASK-IF-NEEDED", "PLAN", "CONFIRM", "MUTATE", "VALIDATE", "REPORT"]: - assert stage in body - - -def test_skill_md_separates_init_and_discover_operations(): - _, body = fm.parse(SKILL_MD.read_text(encoding="utf-8")) - assert "## INIT" in body - assert "## DISCOVER" in body - assert "## What belongs in an ADR" in body - - -def test_skill_md_requires_evidence_inference_separation_for_retrospective_adrs(): - _, body = fm.parse(SKILL_MD.read_text(encoding="utf-8")) - assert "Confirmed Evidence" in body - assert "Inferred Rationale" in body - assert "## Unknown" in body - - -def test_reference_files_exist(): - assert (REFERENCES / "lifecycle.md").is_file() - assert (REFERENCES / "madr-guide.md").is_file() -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: `python -m pytest tests/unit/test_skill_manifest.py -v` -Expected: FAIL — files don't exist. - -- [ ] **Step 3: Write the content** - -```markdown - ---- -name: adr-toolkit -description: > - Initialize, record, and check Architecture Decision Records by inspecting - the repository and existing decisions before asking questions. -user-invocable: true -version: 0.1.0 ---- - -# ADR Toolkit - -## Workflow contract - -Every operation follows the same nine stages: - -```text -PREFLIGHT -→ DISCOVER -→ CLASSIFY -→ ASK-IF-NEEDED -→ PLAN -→ CONFIRM -→ MUTATE -→ VALIDATE -→ REPORT -``` - -## INIT (scaffolding only) - -Use INIT when a repository has no ADR directory yet. INIT does not mine -history — it only sets up the structure. Run DISCOVER afterward (or any -time later) to recover past decisions. - -1. **PREFLIGHT** — run `python scripts/adr.py preflight --json`. If - `existing_adr_directory` is set, stop and tell the user an ADR directory - already exists; do not scaffold a second one. -2. **CONFIRM** — show the user the exact directory that will be created, - before writing anything. -3. **MUTATE** — run `python scripts/adr.py init --dir docs/decisions` to - scaffold the directory, template, and ADR-0001. -4. **VALIDATE** — run `python scripts/adr.py validate --dir docs/decisions --json` - and `python scripts/adr.py index --dir docs/decisions --json`. -5. **REPORT** — tell the user INIT is done and that they can run DISCOVER - next if they want to recover past decisions from the repository's - history. - -## DISCOVER (past-decision recovery) - -Use DISCOVER on a repository that already has an ADR directory (run INIT -first if it doesn't). DISCOVER can be run once right after INIT, skipped -entirely, or re-run later to mine more of the history incrementally. - -1. **PREFLIGHT** — run `python scripts/adr.py preflight --json`. If - `existing_adr_directory` is `null`, stop and tell the user to run INIT - first. -2. **GATHER EVIDENCE** — run `python scripts/adr.py discover --json` from - the repository root. Read the `dependencies` list; each entry is - candidate evidence for a past architectural decision (e.g. `pom.xml` - suggests a JVM build-tool decision was made, even if undocumented). -3. **CLASSIFY** — for each finding, decide whether it looks structural (a - database driver, a message broker, a web framework) versus routine - tooling (a linter, a test runner), using the table below. Only - structural choices are candidates. -4. **ASK-IF-NEEDED** — for each candidate, ask the user at most one - question: why was this chosen, only if the reason is not evident from - comments, README, or commit history. Do not ask about anything - `discover` already reported as a fact. -5. **PLAN** — draft a `retrospective: true` MADR body for each ADR the user - wants recorded, following `templates/madr-minimal.md` (see - `references/madr-guide.md` for when to use the full template instead). - Every retrospective body MUST contain three separate subsections, never - merged into one narrative: - - ```markdown - ## Confirmed Evidence - - * {only facts `discover` or the user's own words actually established} - - ## Inferred Rationale - - * {the agent's best guess at *why*, explicitly labeled as a guess} - - ## Unknown - - * {anything about the original decision that cannot be recovered from this repository} - ``` - -6. **CONFIRM** — show the user each candidate's title, confirmed evidence, - and inferred rationale before writing anything; the user can drop any - candidate. -7. **MUTATE** — for each approved candidate, write a draft JSON file - (`title`, `status`, `body` — body includes the three-part structure - above — plus any of `date`/`decision_makers`/`related`/ - `affected_paths`/`tags`/`retrospective`) and run - `python scripts/adr.py create --input --dir docs/decisions`. -8. **VALIDATE** — same as INIT step 4. If validate reports errors, fix the - draft and re-run `create` — never hand-edit the generated file to patch - a validation error. -9. **REPORT** — tell the user what was created, in this order: facts - found, judgment, questions asked, files created, validation result, - remaining uncertainty. - -## What belongs in an ADR - -Not every structural-looking finding deserves a new file. Apply this table -during CLASSIFY, in both INIT/DISCOVER and (once built) RECORD: - -| Content | Where it belongs | -|---|---| -| Structural, long-lived decision | ADR | -| Feature implementation detail | Pull request | -| Routine code-change rationale | Commit message | -| Usage/behavior explanation | README or docs | -| Incident/outage response | Incident report / postmortem | -| A rule that must hold going forward | ADR's Implementation Constraints | - -## Prohibited - -- Creating `docs/decisions/` when `preflight` already found one. -- Running DISCOVER when `preflight` reports no ADR directory — tell the - user to run INIT first instead. -- Writing any ADR file before the user has seen and approved its title, - problem, and decision. -- Marking a retrospective ADR `status: accepted` without the user - confirming the reconstruction is accurate. -- Guessing a dependency's purpose instead of asking, when it's not evident - from the repository. -- Merging Confirmed Evidence, Inferred Rationale, and Unknown into a single - undifferentiated narrative for a retrospective ADR. - -## Script reference - -All mutating and validating operations are deterministic scripts under -`scripts/`; this skill never re-implements ID assignment, file writes, or -schema validation in prose — it only decides what to ask and what to draft. - -RECORD and CHECK workflows are not yet implemented (see `project-roadmap.md` -in the repository root and the design spec this skill is built from). -``` - -```markdown - -# ADR Lifecycle Reference - -## Statuses - -- `proposed` — drafted, not yet approved by a human. -- `accepted` — approved and currently in force. -- `rejected` — considered and explicitly declined. -- `deprecated` — no longer recommended, no direct replacement. -- `superseded` — replaced by a specific later ADR (`superseded_by` must be set). - -## Allowed transitions - -```text -proposed -> accepted | rejected -accepted -> deprecated | superseded -rejected -> (terminal) -deprecated -> (terminal) -superseded -> (terminal) -``` - -`scripts/core/lifecycle.py` enforces this table. If a user asks for a -transition that isn't in it, confirm they understand it's non-standard -before doing anything, and never write a status that violates the table. -``` - -```markdown - -# MADR Template Guide - -## When to use `madr-minimal.md` - -- The decision has two or fewer realistic alternatives. -- No conflicting quality attributes need a comparison table. -- The project is small enough that a short record is genuinely sufficient. - -## When to use `madr-full.md` - -- Three or more alternatives were seriously considered. -- Different options trade off against each other on quality attributes - (e.g. latency vs. operational complexity). -- The decision affects multiple teams, services, or systems. - -## Section meaning - -- **Context and Problem Statement** — the forces and constraints that made - a decision necessary; not a restatement of the chosen solution. -- **Considered Options** — every realistic option actually evaluated, not - a strawman list. -- **Decision Outcome** — the chosen option and the primary reason, stated - as a single sentence a newcomer could quote. -- **Consequences** — both the benefit and the accepted cost; a decision - with no listed downside has not been examined honestly. -- **Confirmation** — how someone (human or agent) can verify the decision - is actually being followed in the code today. -- **Revisit Triggers** — concrete conditions, not vague ones like "if - requirements change." -``` - -- [ ] **Step 4: Run test to verify it passes** - -Run: `python -m pytest tests/unit/test_skill_manifest.py -v` -Expected: PASS (3 tests) - -- [ ] **Step 5: Commit** - -```bash -git rm skills/adr-toolkit/references/.gitkeep -git add skills/adr-toolkit/SKILL.md skills/adr-toolkit/references/lifecycle.md \ - skills/adr-toolkit/references/madr-guide.md tests/unit/test_skill_manifest.py -git commit -m "feat: add SKILL.md workflow contract and INIT reference docs" -``` - ---- - -### Task 16: Claude Code adapter manifest - -**Files:** -- Create: `adapters/claude/.claude-plugin/plugin.json` -- Create: `adapters/claude/marketplace.json` -- Test: `tests/unit/test_claude_adapter.py` - -**Interfaces:** -- Consumes: `skills/adr-toolkit/SKILL.md` (Task 15) — the manifest's `skills` path must resolve to that directory. -- Produces: an installable Claude Code plugin. No Python interface — this task is manifest content only. - -- [ ] **Step 1: Write the failing test** - -```python -# tests/unit/test_claude_adapter.py -import json -from pathlib import Path - -ADAPTER_DIR = Path(__file__).resolve().parent.parent.parent / "adapters" / "claude" - - -def test_plugin_manifest_has_required_keys(): - manifest = json.loads((ADAPTER_DIR / ".claude-plugin" / "plugin.json").read_text(encoding="utf-8")) - assert manifest["name"] == "adr-toolkit" - assert "version" in manifest - assert manifest["skills"], "plugin.json must list at least one skill path" - - -def test_plugin_manifest_skill_path_resolves_to_real_skill(): - manifest = json.loads((ADAPTER_DIR / ".claude-plugin" / "plugin.json").read_text(encoding="utf-8")) - skill_path = (ADAPTER_DIR / ".claude-plugin" / manifest["skills"][0]).resolve() - assert skill_path.is_dir() - assert (skill_path / "SKILL.md").is_file() - - -def test_marketplace_manifest_lists_the_plugin(): - marketplace = json.loads((ADAPTER_DIR / "marketplace.json").read_text(encoding="utf-8")) - names = [p["name"] for p in marketplace["plugins"]] - assert "adr-toolkit" in names -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: `python -m pytest tests/unit/test_claude_adapter.py -v` -Expected: FAIL — manifests don't exist. - -- [ ] **Step 3: Write the manifests** - -```json -// adapters/claude/.claude-plugin/plugin.json -{ - "name": "adr-toolkit", - "version": "0.1.0", - "description": "Initialize, record, and check Architecture Decision Records by inspecting the repository before asking questions.", - "skills": [ - "../../../skills/adr-toolkit" - ] -} -``` - -```json -// adapters/claude/marketplace.json -{ - "name": "adr-toolkit-marketplace", - "plugins": [ - { - "name": "adr-toolkit", - "source": "./", - "description": "Agent-native ADR toolkit: inspect the repo, ask only what code can't answer, record and check architecture decisions." - } - ] -} -``` - -- [ ] **Step 4: Run test to verify it passes** - -Run: `python -m pytest tests/unit/test_claude_adapter.py -v` -Expected: PASS (3 tests) - -- [ ] **Step 5: Commit** - -```bash -git add adapters/claude/.claude-plugin/plugin.json adapters/claude/marketplace.json \ - tests/unit/test_claude_adapter.py -git commit -m "feat: add Claude Code adapter manifest for adr-toolkit skill" -``` - ---- - -### Task 17: End-to-end INIT fixture and golden test - -**Files:** -- Create: `tests/fixtures/init_no_adr_js_project/package.json` -- Create: `tests/integration/test_init_workflow.py` - -**Interfaces:** -- Consumes: `scripts/adr.py` (Task 14) via subprocess, exercising `preflight`, `discover`, `init`, `validate`, `index` end to end. -- Produces: the golden proof that the INIT workflow this plan builds actually works on a realistic repository, not just in isolated unit tests. - -- [ ] **Step 1: Write the failing test** - -```json -// tests/fixtures/init_no_adr_js_project/package.json -{ - "name": "sample-app", - "version": "1.0.0", - "dependencies": { - "express": "^4.18.0" - } -} -``` - -```python -# tests/integration/test_init_workflow.py -import json -import shutil -import subprocess -import sys -from pathlib import Path - -FIXTURE = Path(__file__).resolve().parent.parent / "fixtures" / "init_no_adr_js_project" -ADR_PY = Path(__file__).resolve().parent.parent.parent / "skills" / "adr-toolkit" / "scripts" / "adr.py" - - -def _run(args, cwd): - result = subprocess.run( - [sys.executable, str(ADR_PY), *args], cwd=cwd, capture_output=True, text=True, - ) - assert result.returncode in (0, 1), result.stderr - return json.loads(result.stdout) - - -def test_full_init_flow_on_js_fixture(tmp_path): - repo = tmp_path / "repo" - shutil.copytree(FIXTURE, repo) - - preflight = _run(["preflight", "--json"], cwd=repo) - assert preflight["ok"] is True - assert preflight["existing_adr_directory"] is None - - discovered = _run(["discover", "--json"], cwd=repo) - assert {"ecosystem": "npm", "path": "package.json"} in discovered["dependencies"] - - init_result = _run(["init", "--dir", "docs/decisions", "--json"], cwd=repo) - assert init_result["ok"] is True - - validate_result = _run(["validate", "--dir", "docs/decisions", "--json"], cwd=repo) - assert validate_result["ok"] is True - assert validate_result["checked"] == 1 - - index_result = _run(["index", "--dir", "docs/decisions", "--json"], cwd=repo) - assert index_result["count"] == 1 - - readme = (repo / "docs" / "decisions" / "README.md").read_text(encoding="utf-8") - assert "ADR-0001" in readme - assert "Accepted" in readme - - second_preflight = _run(["preflight", "--json"], cwd=repo) - assert second_preflight["existing_adr_directory"] == "docs/decisions" -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: `python -m pytest tests/integration/test_init_workflow.py -v` -Expected: FAIL — fixture doesn't exist yet (it's created in Step 1 above alongside the test; if you're following strict step order, running before creating `package.json` fails with a "fixture not found" style error since `discover` finds nothing). - -- [ ] **Step 3: Nothing further to implement** - -This task's "implementation" is the fixture file itself, created in Step 1. No production code changes — this task proves Tasks 1–16 already compose correctly end to end. - -- [ ] **Step 4: Run test to verify it passes** - -Run: `python -m pytest tests/integration/test_init_workflow.py -v` -Expected: PASS (1 test) - -- [ ] **Step 5: Commit** - -```bash -git add tests/fixtures/init_no_adr_js_project/package.json tests/integration/test_init_workflow.py -git commit -m "test: add end-to-end INIT fixture and golden test" -``` - ---- - -### Task 18: Generic harness fallback adapter - -**Files:** -- Create: `adapters/generic/README.md` -- Test: `tests/unit/test_generic_adapter.py` - -**Interfaces:** -- Consumes: nothing (documentation only). -- Produces: a manual-install path that works for any harness not covered by Task 16's Claude adapter — including harnesses that don't have a plugin/skill manifest system at all, as long as they read project instructions and can run shell commands. - -- [ ] **Step 1: Write the failing test** - -```python -# tests/unit/test_generic_adapter.py -from pathlib import Path - -GENERIC_README = Path(__file__).resolve().parent.parent.parent / "adapters" / "generic" / "README.md" - - -def test_generic_adapter_readme_exists_and_documents_symlink_install(): - text = GENERIC_README.read_text(encoding="utf-8") - assert "skills/adr-toolkit" in text - assert "AGENTS.md" in text - assert "SKILL.md" in text -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: `python -m pytest tests/unit/test_generic_adapter.py -v` -Expected: FAIL — file doesn't exist. - -- [ ] **Step 3: Write the fallback guide** - -```markdown - -# Generic harness adapter - -If your AI coding harness isn't Claude Code, Codex, Gemini CLI, or -Antigravity CLI, ADR Toolkit still works — `SKILL.md` only assumes your -harness can read project instructions and run shell commands, which -covers effectively every current coding agent, including ones this -project hasn't been tested against yet. - -## Install - -1. Copy or symlink `skills/adr-toolkit/` into your project, at whatever - path your harness scans for instructions or skills: - - ```bash - mkdir -p .agents/skills - ln -s ../../path/to/adr-toolkit/skills/adr-toolkit .agents/skills/adr-toolkit - ``` - -2. Add one line to your project's `AGENTS.md` (or whatever instruction - file your harness reads first): - - ```markdown - For architecture decisions (introducing, recording, or checking ADRs), - follow `.agents/skills/adr-toolkit/SKILL.md`. - ``` - -3. That's it. `SKILL.md` never assumes a plugin manifest, a hook, or any - harness-specific configuration — only that something can read markdown - and run `python scripts/adr.py ...`. - -## No agent at all? - -You don't need one. Every deterministic operation is a plain CLI command -you can run yourself: `python skills/adr-toolkit/scripts/adr.py init`, -`... validate`, `... index`, and `... create --interactive` (see Task 19) -for a guided prompt sequence that needs no AI harness whatsoever. -``` - -- [ ] **Step 4: Run test to verify it passes** - -Run: `python -m pytest tests/unit/test_generic_adapter.py -v` -Expected: PASS (1 test) - -- [ ] **Step 5: Commit** - -```bash -git add adapters/generic/README.md tests/unit/test_generic_adapter.py -git commit -m "docs: add generic harness fallback adapter" -``` - ---- - -### Task 19: `create.py --interactive` — terminal wizard for humans with no agent - -**Files:** -- Modify: `skills/adr-toolkit/scripts/commands/create.py` (Task 11) -- Modify: `skills/adr-toolkit/scripts/adr.py` (Task 14) — `--input` becomes optional, `--interactive` added -- Test: `tests/unit/test_create_interactive.py` - -**Interfaces:** -- Consumes: nothing beyond what Task 11 already consumes. -- Produces: `gather_draft_interactively(input_fn=input) -> dict` (same draft shape Task 11's `run()` already expects: `title`, `status`, `body`). `run(args)` now accepts an `args.interactive` flag as an alternative to `args.input` — everything downstream (ID assignment, schema validation, file write) is unchanged. - -- [ ] **Step 1: Write the failing test** - -```python -# tests/unit/test_create_interactive.py -from types import SimpleNamespace - -from scripts.commands import create - - -def test_gather_draft_interactively_builds_valid_minimal_body(): - answers = iter([ - "Use Kafka for domain events", - "Order processing and downstream work are tightly coupled", - "synchronous HTTP, RabbitMQ, Kafka", - "Kafka", - "it isolates failures and allows reprocessing", - "failures in one consumer don't block others", - "operational complexity increases", - "no direct SDK calls appear outside the events module", - "message volume exceeds what a single queue can handle", - ]) - draft = create.gather_draft_interactively(input_fn=lambda _prompt: next(answers)) - - assert draft["title"] == "Use Kafka for domain events" - assert draft["status"] == "proposed" - assert "## Context and Problem Statement" in draft["body"] - assert "Kafka" in draft["body"] - - -def test_create_run_supports_interactive_mode_end_to_end(tmp_path, monkeypatch): - adr_dir = tmp_path / "docs" / "decisions" - adr_dir.mkdir(parents=True) - - answers = iter([ - "Use Kafka for domain events", "Problem text", "HTTP, Kafka", "Kafka", - "reason", "good thing", "bad thing", "verification note", "revisit condition", - ]) - monkeypatch.setattr("builtins.input", lambda _prompt="": next(answers)) - - result = create.run(SimpleNamespace(interactive=True, input=None, dir=str(adr_dir), dry_run=False)) - - assert result["ok"] is True - assert result["id"] == "ADR-0001" - - -def test_create_run_without_input_or_interactive_is_an_error(tmp_path): - adr_dir = tmp_path / "docs" / "decisions" - adr_dir.mkdir(parents=True) - - result = create.run(SimpleNamespace(interactive=False, input=None, dir=str(adr_dir), dry_run=False)) - - assert result["ok"] is False - assert result["errors"][0]["code"] == "MISSING_INPUT_OR_INTERACTIVE" -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: `python -m pytest tests/unit/test_create_interactive.py -v` -Expected: FAIL — `gather_draft_interactively` doesn't exist; `run()` doesn't accept `interactive`/`None` input. - -- [ ] **Step 3: Modify `create.py`** - -Add this function anywhere above `run`: - -```python -def _prompt(input_fn, question: str) -> str: - return input_fn(f"{question}\n> ").strip() - - -def gather_draft_interactively(input_fn=input) -> dict: - title = _prompt(input_fn, "Title of the decision?") - problem = _prompt(input_fn, "What problem or constraint made this decision necessary?") - options_raw = _prompt(input_fn, "Options considered (comma-separated)?") - options = [o.strip() for o in options_raw.split(",") if o.strip()] - decision = _prompt(input_fn, "Which option was chosen?") - rationale = _prompt(input_fn, "Why was it chosen?") - good = _prompt(input_fn, "One good consequence?") - bad = _prompt(input_fn, "One accepted downside?") - confirmation = _prompt(input_fn, "How will this be verified in the code?") - revisit = _prompt(input_fn, "What condition should reopen this decision?") - - options_block = "\n".join(f"* {o}" for o in options) if options else f"* {decision}" - - body = ( - f"# {title}\n\n" - "## Context and Problem Statement\n\n" - f"{problem}\n\n" - "## Considered Options\n\n" - f"{options_block}\n\n" - "## Decision Outcome\n\n" - f"Chosen option: **{decision}**, because {rationale}.\n\n" - "## Consequences\n\n" - f"* Good: {good}\n" - f"* Bad: {bad}\n\n" - "## Confirmation\n\n" - f"{confirmation}\n\n" - "## Revisit Triggers\n\n" - f"* {revisit}\n" - ) - - return {"title": title, "status": "proposed", "body": body} -``` - -Replace the start of `run(args)` (everything up to `adr_dir.mkdir`) with: - -```python -def run(args) -> dict: - dry_run = getattr(args, "dry_run", False) - - if getattr(args, "interactive", False): - draft = gather_draft_interactively() - else: - input_path = getattr(args, "input", None) - if not input_path: - return { - "ok": False, - "operation": "create", - "errors": [{"code": "MISSING_INPUT_OR_INTERACTIVE"}], - } - draft = json.loads(Path(input_path).read_text(encoding="utf-8")) - - adr_dir = Path(args.dir) - missing = REQUIRED_DRAFT_FIELDS - draft.keys() - if missing: - return { - "ok": False, - "operation": "create", - "errors": [{"code": "MISSING_DRAFT_FIELD", "fields": sorted(missing)}], - } -``` - -(the rest of `run` — ID assignment, schema validation, file write — is unchanged from Task 11) - -- [ ] **Step 4: Modify `adr.py`'s `create` subparser** - -In `build_parser()`, replace: - -```python - p_create.add_argument("--input", required=True) -``` - -with: - -```python - p_create.add_argument("--input") - p_create.add_argument("--interactive", action="store_true") -``` - -- [ ] **Step 5: Run tests to verify they pass** - -Run: `python -m pytest tests/unit/test_create.py tests/unit/test_create_interactive.py -v` -Expected: PASS (7 tests total — Task 11's 4 plus this task's 3). Task 11's tests must still pass unchanged, since `getattr(args, "interactive", False)` defaults to `False` for their `SimpleNamespace` instances. - -- [ ] **Step 6: Commit** - -```bash -git add skills/adr-toolkit/scripts/commands/create.py skills/adr-toolkit/scripts/adr.py \ - tests/unit/test_create_interactive.py -git commit -m "feat: add interactive create wizard for use without an AI agent" -``` - ---- - -### Task 20: CI workflow - -**Files:** -- Create: `.github/workflows/test.yml` - -**Interfaces:** -- Consumes: nothing new — runs the full test suite built by Tasks 1–19. -- Produces: CI enforcement on every push and PR. - -- [ ] **Step 1: Write the workflow file** - -```yaml -# .github/workflows/test.yml -name: test - -on: - push: - pull_request: - -jobs: - pytest: - runs-on: ${{ matrix.os }} - strategy: - matrix: - os: [ubuntu-latest, macos-latest, windows-latest] - python-version: ["3.9", "3.12"] - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-python@v5 - with: - python-version: ${{ matrix.python-version }} - - name: Run tests - run: python -m pytest tests/unit tests/integration -v -``` - -- [ ] **Step 2: Run the equivalent command locally to confirm it would pass in CI** - -Run: `python -m pytest tests/unit tests/integration -v` -Expected: PASS (every test added in Tasks 1–19) - -- [ ] **Step 3: Commit** - -```bash -git add .github/workflows/test.yml -git commit -m "ci: run pytest across unit and integration tests on every push and PR" -``` - ---- - -## Revision notes - -**Revision 1** — After this plan was first written, the user reviewed a -separate improvement proposal and asked to fold in several low-cost -refinements before execution began (no code had been written yet). -Applied: INIT and DISCOVER split into two SKILL.md operations instead of -one bundled flow (Task 15), retrospective ADRs now require a Confirmed -Evidence/Inferred Rationale/Unknown structure (Task 15), and a "what -belongs in an ADR" classification table was added (Task 15, and spec §6.1). -No script-level tasks changed — `discover.py` (Task 8) already existed as -its own command, so this was purely a SKILL.md/documentation restructuring. -Structured `constraints:` YAML blocks and CHECK's four-way finding -classification were accepted too, but recorded only in the design spec -(§6.1, §7) since CHECK isn't implemented until Plan 3. - -**Revision 2** — The user asked whether the design was actually natural to -use both for AI harnesses and for plain human developers. Two real gaps -surfaced: (1) the Codex/Gemini CLI/Antigravity adapter manifests planned -for Plan 4 rest on assumed, unverified schemas, and the original PRD's -harness-agnostic "Generic Agent Skills" fallback had been dropped when the -harness list was narrowed to four named ones; (2) `create.py` only accepted -a pre-built JSON draft, which is natural for an agent but not for a human -running the CLI directly with no AI harness at all — undermining the -stated goal of easy adoption by other developers. Added Task 18 (a -zero-maintenance generic fallback adapter — symlink + one `AGENTS.md` line -— that works for any harness, including ones not yet accounted for) and -Task 19 (`create --interactive`, a terminal wizard that needs no agent). -Both are documentation/CLI-only changes with no effect on Tasks 1–17. - -## Plan self-review notes - -- **Spec coverage:** §5 (repo structure) → Tasks 1, 16; §6 (ADR document format) → Tasks 2, 5, 9, 10, 11; §6.1 (what belongs in an ADR) → Task 15; §10 (multi-view index) → Task 12; §11 INIT/DISCOVER data flow → Tasks 6, 8, 10, 11, 13, 14, 15; §12 CI → Task 20; §8 Claude Code depth → Tasks 16, 17. Harness-naturalness and human-usability (raised in review, not in the original spec numbering) → Tasks 18 (generic adapter) and 19 (interactive wizard). RECORD (§11), CHECK (§7, §11), i18n (§9), and the Codex/Gemini CLI/Antigravity adapters (§8) are explicitly out of scope for this plan — see Plans 2–4. -- **Type consistency checked:** `identifiers.next_id`/`format_filename`/`slugify`/`parse_filename` signatures match across Tasks 3, 10, 11, 12, 13. `frontmatter.parse`/`serialize` signatures match across Tasks 2, 11, 12, 13, 15. `schema.validate_frontmatter` signature matches across Tasks 5, 11, 13. -- **No placeholders found** — every step has runnable code or literal file content; nothing deferred with "TODO" inside this plan's own tasks. - -## Open item for the user - -The design spec this plan implements (`docs/superpowers/specs/2026-08-29-adr-toolkit-design.md`) has not yet had a line-by-line review from the project owner — they approved the direction conversationally and asked me to keep moving while away. Two things in the spec worth their explicit attention when they're back, before this plan is executed: - -1. §13 of the spec proposes MIT as the license — decided in their absence, needs confirmation before any public release. -2. §7's conflict-detection scope (structural/path evidence only, semantic taxonomy deferred) — discussed and agreed to, but worth a final look now that the concrete rule table exists. diff --git a/docs/superpowers/plans/2026-08-30-adr-toolkit-check.md b/docs/superpowers/plans/2026-08-30-adr-toolkit-check.md deleted file mode 100644 index 69f432a..0000000 --- a/docs/superpowers/plans/2026-08-30-adr-toolkit-check.md +++ /dev/null @@ -1,1651 +0,0 @@ -# ADR Toolkit — CHECK (Plan 3 of 4) Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Add the CHECK workflow — a `diff` command that wraps `git diff` for three range modes, and a `check` command that matches a diff against Accepted ADRs' structured `constraints:` rules and Superseded ADRs' `affected_paths`, producing a four-way classified finding report with five resolution options on every verified violation. - -**Architecture:** Four new deterministic layers, each independently testable: `core/globs.py` (a `**`-aware glob matcher, stdlib-only since `fnmatch`/`PurePath.match` don't handle `**`), `core/constraints.py` (extracts the fenced `constraints:` YAML block from an ADR body — a small fixed-shape parser, not a general YAML parser, mirroring `core/frontmatter.py`'s hand-rolled-subset precedent), `scripts/rules/conflict.py` (pure functions implementing the four matching mechanisms the six `constraints:` rule kinds collapse into, mirroring `scripts/rules/significance.py`'s no-I/O style), and `scripts/commands/{diff,check}.py` (the two new CLI commands — `diff` shells out to git, `check` orchestrates everything else). `SKILL.md` gains a CHECK section describing how the agent runs `check` and presents the five resolution options; no conflict-detection judgment is hardcoded in Python beyond the six structural rule kinds. - -**Tech Stack:** Same as Plans 1–2 — Python 3.9+ standard library only (including `subprocess` for git, `re`, `json`), pytest. - -**Spec:** `docs/superpowers/specs/2026-08-29-adr-toolkit-design.md` (§6 ADR document format, §7 CHECK scope, §11 CHECK workflow data flow, §16 Plan 3 implementation decisions) - -## Global Constraints - -- Same as Plans 1–2: Python 3.9+ stdlib only; UTF-8 explicit; no `shell=True`; command modules never `print()`; avoid `X | None` union syntax, use `typing.Optional`. -- `diff`/`check` never write any file — CHECK is read-only by design (§7); no `--dry-run` flag is needed because there is nothing to preview before writing. -- `git` is invoked only via `subprocess.run` with argument lists (`["git", "-C", str(root), ...]`), never `shell=True`, never a string-interpolated command. -- `constraints:` rules are evaluated only against ADRs with `status: accepted` (§16.4); superseded ADRs are handled by the separate superseded-reference pass, never by re-running their old `constraints:` block. -- Malformed ADR frontmatter or a malformed `constraints:` block is caught per-file/per-ADR and degrades to a `warnings` entry — `check` never aborts because one ADR is broken, matching the pattern the Plan 2 closeout review established in `related.py`/`status.py`/`supersede.py`. -- A `diff` failure (not a git repo, unknown ref) returns a specific error code (`NOT_A_GIT_REPO`, `INVALID_REF`, `GIT_DIFF_FAILED`) — never a raw `subprocess` traceback surfaced through `adr.py main()`'s `INTERNAL_ERROR` fallback. -- Out of scope for this plan (tracked in `project-roadmap.md` / Plan 4): i18n wiring, Codex/Gemini CLI/Antigravity CLI adapters, release automation, full semantic/AST conflict detection. -- Every task must leave `python -m pytest tests/unit tests/integration -v` green before its commit step. - ---- - -### Task 1: `core/globs.py` — a `**`-aware glob matcher - -**Files:** -- Create: `skills/adr-toolkit/scripts/core/globs.py` -- Test: `tests/unit/test_globs.py` - -**Interfaces:** -- Consumes: nothing. -- Produces: `match(pattern: str, path: str) -> bool`. Used by Task 4 (`rules/conflict.py`). - -- [ ] **Step 1: Write the failing test** - -```python -# tests/unit/test_globs.py -from scripts.core import globs - - -def test_exact_match(): - assert globs.match("src/events/producer.py", "src/events/producer.py") - assert not globs.match("src/events/producer.py", "src/events/consumer.py") - - -def test_single_star_matches_within_one_segment(): - assert globs.match("src/events/*.py", "src/events/producer.py") - assert not globs.match("src/events/*.py", "src/events/sub/producer.py") - - -def test_double_star_matches_across_segments(): - assert globs.match("src/features/**", "src/features/x.py") - assert globs.match("src/features/**", "src/features/sub/y.py") - assert not globs.match("src/features/**", "src/other/x.py") - - -def test_double_star_prefix_matches_any_depth(): - assert globs.match("**/test_*.py", "test_foo.py") - assert globs.match("**/test_*.py", "src/deep/test_foo.py") - assert not globs.match("**/test_*.py", "src/deep/foo_test.py") - - -def test_question_mark_matches_single_character(): - assert globs.match("src/adr-?.md", "src/adr-1.md") - assert not globs.match("src/adr-?.md", "src/adr-10.md") -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: `python -m pytest tests/unit/test_globs.py -v` -Expected: FAIL — module doesn't exist. - -- [ ] **Step 3: Write minimal implementation** - -```python -# skills/adr-toolkit/scripts/core/globs.py -"""A **-aware glob matcher, stdlib-only. - -Python's fnmatch/PurePath.match don't treat ** as "any number of path -segments" the way ArchUnit-style path rules need, so this implements just -that one extension on top of literal/*/? matching. -""" -import re - - -def match(pattern: str, path: str) -> bool: - return re.match(_translate(pattern), path) is not None - - -def _translate(pattern: str) -> str: - parts = [] - i, n = 0, len(pattern) - while i < n: - char = pattern[i] - if pattern[i:i + 2] == "**": - if i + 2 < n and pattern[i + 2] == "/": - parts.append(r"(?:.*/)?") - i += 3 - else: - parts.append(r".*") - i += 2 - elif char == "*": - parts.append(r"[^/]*") - i += 1 - elif char == "?": - parts.append(r"[^/]") - i += 1 - else: - parts.append(re.escape(char)) - i += 1 - return "^" + "".join(parts) + "$" -``` - -- [ ] **Step 4: Run test to verify it passes** - -Run: `python -m pytest tests/unit/test_globs.py -v` -Expected: PASS (5 tests) - -- [ ] **Step 5: Commit** - -```bash -git add skills/adr-toolkit/scripts/core/globs.py tests/unit/test_globs.py -git commit -m "feat: add **-aware glob matcher for constraint path patterns" -``` - ---- - -### Task 2: `core/constraints.py` — extract `constraints:` rules from an ADR body - -**Files:** -- Create: `skills/adr-toolkit/scripts/core/constraints.py` -- Test: `tests/unit/test_constraints.py` - -**Interfaces:** -- Consumes: nothing. -- Produces: `extract_constraints(body: str) -> list`, `ConstraintsError(ValueError)`. Used by Task 6 (`check.py`). - -- [ ] **Step 1: Write the failing test** - -```python -# tests/unit/test_constraints.py -import pytest - -from scripts.core.constraints import ConstraintsError, extract_constraints - -BODY_WITH_CONSTRAINTS = """# Use a provider port - -## Implementation Constraints - -Feature modules must go through the LLM port, never call a provider SDK -directly. - -```yaml -constraints: - - id: no-provider-sdk-in-feature - kind: forbidden_import - paths: ["src/features/**"] - pattern: ["openai", "anthropic"] - severity: major - message: "Feature modules must use the LLM port." - - id: registry-required - kind: required_path - paths: ["src/events/**"] - pattern: ["src/events/registry.py"] - severity: minor - message: "New event types must be registered." -``` -""" - -BODY_WITHOUT_CONSTRAINTS = "# Use a provider port\n\nNo fenced block here.\n" - -BODY_WITH_MALFORMED_CONSTRAINTS = """# Bad ADR - -```yaml -constraints: - - id: broken - kind forbidden_import -``` -""" - - -def test_extracts_all_rules_with_correct_fields(): - rules = extract_constraints(BODY_WITH_CONSTRAINTS) - assert len(rules) == 2 - assert rules[0]["id"] == "no-provider-sdk-in-feature" - assert rules[0]["kind"] == "forbidden_import" - assert rules[0]["paths"] == ["src/features/**"] - assert rules[0]["pattern"] == ["openai", "anthropic"] - assert rules[0]["severity"] == "major" - assert rules[1]["id"] == "registry-required" - assert rules[1]["kind"] == "required_path" - - -def test_no_fenced_block_returns_empty_list(): - assert extract_constraints(BODY_WITHOUT_CONSTRAINTS) == [] - - -def test_malformed_line_raises_constraints_error(): - with pytest.raises(ConstraintsError): - extract_constraints(BODY_WITH_MALFORMED_CONSTRAINTS) -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: `python -m pytest tests/unit/test_constraints.py -v` -Expected: FAIL — module doesn't exist. - -- [ ] **Step 3: Write minimal implementation** - -```python -# skills/adr-toolkit/scripts/core/constraints.py -"""Extract structured `constraints:` rules from an ADR body's fenced block. - -Not a general YAML parser — supports exactly the fixed shape the design -spec defines (§7, §16.2): a top-level `constraints:` list of mappings with -a small set of known fields, list-valued fields written as a JSON-style -array on one line. Mirrors core/frontmatter.py's hand-rolled-subset -approach rather than adding a YAML dependency. -""" -import json -import re - -FENCE_RE = re.compile(r"```ya?ml\n(.*?)\n```", re.DOTALL) - -KNOWN_FIELDS = {"id", "kind", "paths", "pattern", "severity", "message"} -LIST_FIELDS = {"paths", "pattern"} - - -class ConstraintsError(ValueError): - pass - - -def extract_constraints(body: str) -> list: - rules = [] - for fence_match in FENCE_RE.finditer(body): - lines = fence_match.group(1).splitlines() - if not lines or not lines[0].strip().startswith("constraints:"): - continue - rules.extend(_parse_rules(lines[1:])) - return rules - - -def _parse_rules(lines) -> list: - rules = [] - current = None - for raw_line in lines: - if not raw_line.strip(): - continue - if raw_line.startswith(" - "): - if current is not None: - rules.append(current) - current = {} - - stripped = raw_line.strip() - if stripped.startswith("- "): - stripped = stripped[2:].strip() - - if ":" not in stripped: - raise ConstraintsError(f"Malformed constraints line: {raw_line!r}") - key, _, value = stripped.partition(":") - key, value = key.strip(), value.strip() - - if current is None: - raise ConstraintsError(f"Constraints field with no preceding '- ': {raw_line!r}") - if key not in KNOWN_FIELDS: - raise ConstraintsError(f"Unknown constraints field: {key!r}") - - if key in LIST_FIELDS: - try: - parsed_value = json.loads(value) - except json.JSONDecodeError as exc: - raise ConstraintsError(f"Field {key!r} must be a JSON-style list, got {value!r}") from exc - if not isinstance(parsed_value, list): - raise ConstraintsError(f"Field {key!r} must be a list, got {value!r}") - current[key] = parsed_value - else: - current[key] = value.strip('"').strip("'") - - if current is not None: - rules.append(current) - return rules -``` - -- [ ] **Step 4: Run test to verify it passes** - -Run: `python -m pytest tests/unit/test_constraints.py -v` -Expected: PASS (3 tests) - -- [ ] **Step 5: Commit** - -```bash -git add skills/adr-toolkit/scripts/core/constraints.py tests/unit/test_constraints.py -git commit -m "feat: extract constraints: rules from an ADR body's fenced block" -``` - ---- - -### Task 3: `commands/diff.py` — git diff wrapper + CLI wiring - -**Files:** -- Create: `skills/adr-toolkit/scripts/commands/diff.py` -- Modify: `skills/adr-toolkit/scripts/adr.py` -- Test: `tests/unit/test_diff.py` - -**Interfaces:** -- Consumes: `subprocess` (git binary on PATH). -- Produces: `run(args) -> dict` where `args` has optional `.staged` (bool), `.uncommitted` (bool), `.since` (str), `.root` (str, default `"."`). Success shape: `{"ok": True, "operation": "diff", "mode": "staged"|"uncommitted"|"since", "ref": Optional[str], "files": [{"path": str, "change_type": "added"|"modified"|"deleted", "added_lines": list, "removed_lines": list}]}`. Used by Task 6 (`check.py`). - -- [ ] **Step 1: Write the failing test** - -```python -# tests/unit/test_diff.py -import subprocess -from types import SimpleNamespace - -from scripts.commands import diff - - -def _git(args, cwd): - subprocess.run(["git", *args], cwd=cwd, check=True, capture_output=True) - - -def _init_repo(tmp_path): - _git(["init", "-q", "-b", "master"], tmp_path) - _git(["config", "user.email", "test@example.com"], tmp_path) - _git(["config", "user.name", "Test"], tmp_path) - (tmp_path / "existing.py").write_text("x = 1\n", encoding="utf-8") - _git(["add", "existing.py"], tmp_path) - _git(["commit", "-q", "-m", "initial"], tmp_path) - - -def test_not_a_git_repo_returns_specific_error(tmp_path): - result = diff.run(SimpleNamespace(root=str(tmp_path), staged=False, uncommitted=False, since=None)) - assert result["ok"] is False - assert result["errors"][0]["code"] == "NOT_A_GIT_REPO" - - -def test_uncommitted_mode_reports_added_and_removed_lines(tmp_path): - _init_repo(tmp_path) - (tmp_path / "existing.py").write_text("x = 1\nimport openai\n", encoding="utf-8") - - result = diff.run(SimpleNamespace(root=str(tmp_path), staged=False, uncommitted=True, since=None)) - - assert result["ok"] is True - assert result["mode"] == "uncommitted" - entry = next(f for f in result["files"] if f["path"] == "existing.py") - assert entry["change_type"] == "modified" - assert "import openai" in entry["added_lines"] - - -def test_staged_mode_only_sees_staged_changes(tmp_path): - _init_repo(tmp_path) - (tmp_path / "new_file.py").write_text("y = 2\n", encoding="utf-8") - _git(["add", "new_file.py"], tmp_path) - (tmp_path / "existing.py").write_text("unstaged change\n", encoding="utf-8") - - result = diff.run(SimpleNamespace(root=str(tmp_path), staged=True, uncommitted=False, since=None)) - - paths = {f["path"] for f in result["files"]} - assert paths == {"new_file.py"} - assert result["files"][0]["change_type"] == "added" - - -def test_since_ref_diffs_against_head(tmp_path): - _init_repo(tmp_path) - _git(["checkout", "-q", "-b", "feature"], tmp_path) - (tmp_path / "existing.py").write_text("x = 1\nimport openai\n", encoding="utf-8") - _git(["commit", "-q", "-am", "add import"], tmp_path) - - result = diff.run(SimpleNamespace(root=str(tmp_path), staged=False, uncommitted=False, since="master")) - - assert result["ok"] is True - assert result["mode"] == "since" - assert result["ref"] == "master" - entry = next(f for f in result["files"] if f["path"] == "existing.py") - assert "import openai" in entry["added_lines"] - - -def test_invalid_ref_returns_specific_error(tmp_path): - _init_repo(tmp_path) - result = diff.run(SimpleNamespace(root=str(tmp_path), staged=False, uncommitted=False, since="no-such-ref")) - assert result["ok"] is False - assert result["errors"][0]["code"] == "INVALID_REF" - - -def test_deleted_file_reports_removed_lines(tmp_path): - _init_repo(tmp_path) - (tmp_path / "existing.py").unlink() - - result = diff.run(SimpleNamespace(root=str(tmp_path), staged=False, uncommitted=True, since=None)) - - entry = next(f for f in result["files"] if f["path"] == "existing.py") - assert entry["change_type"] == "deleted" - assert "x = 1" in entry["removed_lines"] - - -def test_uncommitted_mode_includes_untracked_new_files(tmp_path): - _init_repo(tmp_path) - (tmp_path / "brand_new.py").write_text("import openai\n", encoding="utf-8") - - result = diff.run(SimpleNamespace(root=str(tmp_path), staged=False, uncommitted=True, since=None)) - - entry = next(f for f in result["files"] if f["path"] == "brand_new.py") - assert entry["change_type"] == "added" - assert "import openai" in entry["added_lines"] -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: `python -m pytest tests/unit/test_diff.py -v` -Expected: FAIL — module doesn't exist. - -- [ ] **Step 3: Write minimal implementation** - -```python -# skills/adr-toolkit/scripts/commands/diff.py -"""Wrap `git diff` for CHECK: staged, uncommitted, or since-a-ref changes.""" -import subprocess -from pathlib import Path - - -def run(args) -> dict: - root = Path(getattr(args, "root", ".")).resolve() - - repo_check = subprocess.run( - ["git", "-C", str(root), "rev-parse", "--is-inside-work-tree"], - capture_output=True, text=True, - ) - if repo_check.returncode != 0: - return { - "ok": False, - "operation": "diff", - "errors": [{"code": "NOT_A_GIT_REPO", "detail": repo_check.stderr.strip()}], - } - - since = getattr(args, "since", None) - if getattr(args, "staged", False): - mode, diff_args = "staged", ["--cached"] - elif since: - mode, diff_args = "since", [f"{since}..HEAD"] - else: - mode, diff_args = "uncommitted", [] - - name_status = subprocess.run( - ["git", "-C", str(root), "diff", "--name-status", *diff_args], - capture_output=True, text=True, - ) - if name_status.returncode != 0: - code = "INVALID_REF" if since else "GIT_DIFF_FAILED" - return {"ok": False, "operation": "diff", "errors": [{"code": code, "detail": name_status.stderr.strip()}]} - - patch = subprocess.run( - ["git", "-C", str(root), "diff", "--unified=0", *diff_args], - capture_output=True, text=True, - ) - - files = _parse_name_status(name_status.stdout) - _attach_line_content(files, patch.stdout) - - if mode == "uncommitted": - files.extend(_untracked_files(root)) - - return {"ok": True, "operation": "diff", "mode": mode, "ref": since, "files": files} - - -def _untracked_files(root: Path) -> list: - # `git diff` never shows untracked files, but a brand-new file is exactly - # the kind of change CHECK needs to see (e.g. a new module that violates - # a forbidden_import rule) — surface it as if it were entirely "added". - listing = subprocess.run( - ["git", "-C", str(root), "ls-files", "--others", "--exclude-standard"], - capture_output=True, text=True, - ) - entries = [] - for rel_path in listing.stdout.splitlines(): - if not rel_path.strip(): - continue - try: - content_lines = (root / rel_path).read_text(encoding="utf-8").splitlines() - except (UnicodeDecodeError, OSError): - content_lines = [] - entries.append({ - "path": rel_path, - "change_type": "added", - "added_lines": content_lines, - "removed_lines": [], - }) - return entries - - -def _parse_name_status(output: str) -> list: - status_map = {"A": "added", "M": "modified", "D": "deleted"} - files = [] - for line in output.splitlines(): - if not line.strip(): - continue - parts = line.split("\t") - status_char, path = parts[0][0], parts[-1] - files.append({ - "path": path, - "change_type": status_map.get(status_char, "modified"), - "added_lines": [], - "removed_lines": [], - }) - return files - - -def _attach_line_content(files: list, patch_output: str) -> None: - by_path = {f["path"]: f for f in files} - current = None - for line in patch_output.splitlines(): - if line.startswith("--- "): - src = line[6:] if line.startswith("--- a/") else None - current = by_path.get(src) if src else current - continue - if line.startswith("+++ "): - dst = line[6:] if line.startswith("+++ b/") else None - current = by_path.get(dst) if dst else current - continue - if current is None: - continue - if line.startswith("+"): - current["added_lines"].append(line[1:]) - elif line.startswith("-"): - current["removed_lines"].append(line[1:]) -``` - -In `scripts/adr.py`, add: - -```python -from scripts.commands import diff # alongside other imports -``` - -```python - p_diff = sub.add_parser("diff") - p_diff.add_argument("--staged", action="store_true") - p_diff.add_argument("--uncommitted", action="store_true") - p_diff.add_argument("--since") - p_diff.add_argument("--root", default=".") - p_diff.add_argument("--json", action="store_true") -``` - -Add `"diff": diff.run` to `HANDLERS`. - -- [ ] **Step 4: Run test to verify it passes** - -Run: `python -m pytest tests/unit/test_diff.py -v` -Expected: PASS (7 tests) - -- [ ] **Step 5: Commit** - -```bash -git add skills/adr-toolkit/scripts/commands/diff.py skills/adr-toolkit/scripts/adr.py \ - tests/unit/test_diff.py -git commit -m "feat: add diff command wrapping git diff for three range modes" -``` - ---- - -### Task 4: `rules/conflict.py` — the four constraint-matching mechanisms - -**Files:** -- Create: `skills/adr-toolkit/scripts/rules/conflict.py` -- Test: `tests/unit/test_conflict.py` - -**Interfaces:** -- Consumes: `scripts.core.globs.match` (Task 1). -- Produces: `evaluate_rule(rule: dict, diff_files: list, existing_paths: set) -> Optional[dict]`, `affected_paths_overlap(diff_files: list, affected_paths: list) -> bool`. `evaluate_rule` returns `None` for no violation, else `{"rule_id", "kind": "verified_violation", "severity", "message", "file", "evidence"}`. Used by Task 6 (`check.py`). - -- [ ] **Step 1: Write the failing test** - -```python -# tests/unit/test_conflict.py -from scripts.rules import conflict - -DIFF_FILES = [ - {"path": "src/features/x.py", "change_type": "modified", - "added_lines": ["import openai", "def handler(): pass"], "removed_lines": []}, - {"path": "src/events/producer.py", "change_type": "added", - "added_lines": ["class Producer: pass"], "removed_lines": []}, -] - - -def test_content_pattern_fires_on_matching_added_line(): - rule = {"id": "no-sdk", "kind": "forbidden_import", "paths": ["src/features/**"], - "pattern": ["openai"], "severity": "major", "message": "no direct SDK"} - result = conflict.evaluate_rule(rule, DIFF_FILES, set()) - assert result["kind"] == "verified_violation" - assert result["rule_id"] == "no-sdk" - assert result["file"] == "src/features/x.py" - - -def test_content_pattern_does_not_fire_without_a_match(): - rule = {"id": "no-sdk", "kind": "forbidden_import", "paths": ["src/features/**"], - "pattern": ["anthropic"], "severity": "major", "message": "no direct SDK"} - assert conflict.evaluate_rule(rule, DIFF_FILES, set()) is None - - -def test_required_companion_path_fires_when_companion_missing(): - rule = {"id": "registry", "kind": "required_path", "paths": ["src/events/**"], - "pattern": ["src/events/registry.py"], "severity": "minor", "message": "register it"} - result = conflict.evaluate_rule(rule, DIFF_FILES, set()) - assert result["kind"] == "verified_violation" - - -def test_required_companion_path_satisfied_by_existing_paths(): - rule = {"id": "registry", "kind": "required_path", "paths": ["src/events/**"], - "pattern": ["src/events/registry.py"], "severity": "minor", "message": "register it"} - result = conflict.evaluate_rule(rule, DIFF_FILES, {"src/events/registry.py"}) - assert result is None - - -def test_forbidden_companion_path_fires_when_both_touched(): - rule = {"id": "no-db-in-features", "kind": "forbidden_path", "paths": ["src/features/**"], - "pattern": ["src/events/**"], "severity": "major", "message": "boundary violation"} - result = conflict.evaluate_rule(rule, DIFF_FILES, set()) - assert result["kind"] == "verified_violation" - - -def test_forbidden_companion_path_does_not_fire_when_companion_absent(): - rule = {"id": "no-db-in-features", "kind": "forbidden_path", "paths": ["src/features/**"], - "pattern": ["src/db/**"], "severity": "major", "message": "boundary violation"} - assert conflict.evaluate_rule(rule, DIFF_FILES, set()) is None - - -def test_existence_check_fires_when_path_missing(): - rule = {"id": "must-exist", "kind": "file_must_exist", "paths": ["src/events/registry.py"], - "severity": "major", "message": "registry must exist"} - assert conflict.evaluate_rule(rule, DIFF_FILES, set())["kind"] == "verified_violation" - - -def test_existence_check_satisfied_when_path_present(): - rule = {"id": "must-exist", "kind": "test_must_exist", "paths": ["tests/test_x.py"], - "severity": "major", "message": "tests must exist"} - assert conflict.evaluate_rule(rule, DIFF_FILES, {"tests/test_x.py"}) is None - - -def test_unknown_kind_produces_no_finding(): - rule = {"id": "mystery", "kind": "not_a_real_kind", "paths": ["src/**"]} - assert conflict.evaluate_rule(rule, DIFF_FILES, set()) is None - - -def test_affected_paths_overlap_matches_directory_prefix(): - assert conflict.affected_paths_overlap(DIFF_FILES, ["src/features/"]) - - -def test_affected_paths_overlap_matches_glob(): - assert conflict.affected_paths_overlap(DIFF_FILES, ["src/events/**"]) - - -def test_affected_paths_overlap_false_when_no_match(): - assert not conflict.affected_paths_overlap(DIFF_FILES, ["src/unrelated/"]) -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: `python -m pytest tests/unit/test_conflict.py -v` -Expected: FAIL — module doesn't exist. - -- [ ] **Step 3: Write minimal implementation** - -```python -# skills/adr-toolkit/scripts/rules/conflict.py -"""Pure structural constraint matching for CHECK (§16.2). - -No file or git I/O — callers pass in an already-parsed diff (Task 3's -diff.py `files` list) and the set of paths that exist in the working tree -after the diff. The six constraints: rule kinds collapse into four -mechanisms here; which kind maps to which mechanism is fixed by the design -spec, not configurable. -""" -import re - -from scripts.core import globs - -CONTENT_PATTERN_KINDS = {"forbidden_import", "dependency_forbidden"} -REQUIRED_PATH_KINDS = {"required_path"} -FORBIDDEN_PATH_KINDS = {"forbidden_path"} -EXISTENCE_KINDS = {"file_must_exist", "test_must_exist"} - - -def evaluate_rule(rule: dict, diff_files: list, existing_paths: set): - kind = rule.get("kind") - if kind in CONTENT_PATTERN_KINDS: - return _content_pattern(rule, diff_files) - if kind in REQUIRED_PATH_KINDS: - return _required_companion_path(rule, diff_files, existing_paths) - if kind in FORBIDDEN_PATH_KINDS: - return _forbidden_companion_path(rule, diff_files) - if kind in EXISTENCE_KINDS: - return _existence_check(rule, existing_paths) - return None - - -def affected_paths_overlap(diff_files: list, affected_paths: list) -> bool: - touched = _touched_paths(diff_files) - return any( - diff_path == ap or diff_path.startswith(ap) or globs.match(ap, diff_path) - for diff_path in touched - for ap in affected_paths - ) - - -def _touched_paths(diff_files: list) -> set: - return {f["path"] for f in diff_files} - - -def _files_matching(diff_files: list, path_patterns: list) -> list: - return [f for f in diff_files if any(globs.match(p, f["path"]) for p in path_patterns)] - - -def _content_pattern(rule: dict, diff_files: list): - regexes = [re.compile(p) for p in rule.get("pattern", [])] - for file_entry in _files_matching(diff_files, rule.get("paths", [])): - for line in file_entry.get("added_lines", []): - for regex in regexes: - if regex.search(line): - return _violation(rule, file=file_entry["path"], evidence={"line": line, "pattern": regex.pattern}) - return None - - -def _required_companion_path(rule: dict, diff_files: list, existing_paths: set): - trigger_files = _files_matching(diff_files, rule.get("paths", [])) - if not trigger_files: - return None - companion_patterns = rule.get("pattern", []) - candidates = _touched_paths(diff_files) | existing_paths - if any(globs.match(cp, path) for cp in companion_patterns for path in candidates): - return None - return _violation(rule, file=trigger_files[0]["path"], evidence={"missing_companion": companion_patterns}) - - -def _forbidden_companion_path(rule: dict, diff_files: list): - trigger_files = _files_matching(diff_files, rule.get("paths", [])) - if not trigger_files: - return None - companion_patterns = rule.get("pattern", []) - touched = _touched_paths(diff_files) - hits = sorted(p for p in touched if any(globs.match(cp, p) for cp in companion_patterns)) - if not hits: - return None - return _violation(rule, file=trigger_files[0]["path"], evidence={"forbidden_companion": hits}) - - -def _existence_check(rule: dict, existing_paths: set): - required_patterns = rule.get("paths", []) - if any(globs.match(p, path) for p in required_patterns for path in existing_paths): - return None - return _violation(rule, file=None, evidence={"missing_paths": required_patterns}) - - -def _violation(rule: dict, *, file, evidence) -> dict: - return { - "rule_id": rule.get("id"), - "kind": "verified_violation", - "severity": rule.get("severity", "major"), - "message": rule.get("message", ""), - "file": file, - "evidence": evidence, - } -``` - -- [ ] **Step 4: Run test to verify it passes** - -Run: `python -m pytest tests/unit/test_conflict.py -v` -Expected: PASS (12 tests) - -- [ ] **Step 5: Commit** - -```bash -git add skills/adr-toolkit/scripts/rules/conflict.py tests/unit/test_conflict.py -git commit -m "feat: add pure constraint-matching mechanisms for CHECK" -``` - ---- - -### Task 5: `commands/check.py` — core orchestration (Accepted-ADR constraint evaluation) + CLI wiring - -**Files:** -- Create: `skills/adr-toolkit/scripts/commands/check.py` -- Modify: `skills/adr-toolkit/scripts/adr.py` -- Test: `tests/unit/test_check.py` - -**Interfaces:** -- Consumes: `scripts.commands.diff.run` (Task 3), `scripts.core.constraints.{extract_constraints, ConstraintsError}` (Task 2), `scripts.rules.conflict.{evaluate_rule, affected_paths_overlap}` (Task 4), `scripts.core.frontmatter.parse`/`FrontmatterError`, `scripts.core.identifiers.parse_filename`. -- Produces: `run(args) -> dict` where `args` has `.dir` (ADR directory), plus `diff`'s own `.staged`/`.uncommitted`/`.since`/`.root`. Success shape: `{"ok": True, "operation": "check", "diff": {...}, "findings": [...], "warnings": [...]}`. This task covers `Verified violation` / `Related` / `No applicable constraint` for Accepted ADRs; Task 6 extends it with `Review required` and `Superseded reference`. - -- [ ] **Step 1: Write the failing test** - -```python -# tests/unit/test_check.py -import subprocess -from types import SimpleNamespace - -from scripts.commands import check - -RESOLUTIONS = ["fix_code", "supersede_adr", "adjust_scope", "register_exception", "false_positive"] - - -def _git(args, cwd): - subprocess.run(["git", *args], cwd=cwd, check=True, capture_output=True) - - -def _init_repo(tmp_path): - _git(["init", "-q", "-b", "master"], tmp_path) - _git(["config", "user.email", "test@example.com"], tmp_path) - _git(["config", "user.name", "Test"], tmp_path) - - -ACCEPTED_ADR_WITH_RULE = """--- -id: ADR-0001 -title: Use a provider port -status: accepted -date: 2026-08-01 -decision_makers: [] -related: [] -affected_paths: - - src/features/ -tags: [] -retrospective: false ---- - -# Use a provider port - -## Implementation Constraints - -```yaml -constraints: - - id: no-provider-sdk-in-feature - kind: forbidden_import - paths: ["src/features/**"] - pattern: ["openai"] - severity: major - message: "Feature modules must use the LLM port." -``` -""" - -ACCEPTED_ADR_NO_CONSTRAINTS = """--- -id: ADR-0002 -title: Use flat decision directory -status: accepted -date: 2026-08-02 -decision_makers: [] -related: [] -affected_paths: - - docs/decisions/ -tags: [] -retrospective: false ---- - -# Use flat decision directory - -No constraints block here. -""" - - -def _args(tmp_path, adr_dir): - return SimpleNamespace(dir=str(adr_dir), staged=False, uncommitted=True, since=None, root=str(tmp_path)) - - -def test_verified_violation_when_rule_fires(tmp_path): - _init_repo(tmp_path) - adr_dir = tmp_path / "docs" / "decisions" - adr_dir.mkdir(parents=True) - (adr_dir / "0001-use-a-provider-port.md").write_text(ACCEPTED_ADR_WITH_RULE, encoding="utf-8") - _git(["add", "-A"], tmp_path) - _git(["commit", "-q", "-m", "init"], tmp_path) - - (tmp_path / "src" / "features").mkdir(parents=True) - (tmp_path / "src" / "features" / "x.py").write_text("import openai\n", encoding="utf-8") - - result = check.run(_args(tmp_path, adr_dir)) - - assert result["ok"] is True - violation = next(f for f in result["findings"] if f["kind"] == "verified_violation") - assert violation["adr_id"] == "ADR-0001" - assert violation["rule_id"] == "no-provider-sdk-in-feature" - assert violation["resolutions"] == RESOLUTIONS - - -def test_related_when_rule_present_but_does_not_fire(tmp_path): - _init_repo(tmp_path) - adr_dir = tmp_path / "docs" / "decisions" - adr_dir.mkdir(parents=True) - (adr_dir / "0001-use-a-provider-port.md").write_text(ACCEPTED_ADR_WITH_RULE, encoding="utf-8") - _git(["add", "-A"], tmp_path) - _git(["commit", "-q", "-m", "init"], tmp_path) - - (tmp_path / "src" / "features").mkdir(parents=True) - (tmp_path / "src" / "features" / "x.py").write_text("def handler(): pass\n", encoding="utf-8") - - result = check.run(_args(tmp_path, adr_dir)) - - finding = next(f for f in result["findings"] if f["adr_id"] == "ADR-0001") - assert finding["kind"] == "related" - - -def test_no_applicable_constraint_when_adr_has_no_constraints_block(tmp_path): - _init_repo(tmp_path) - adr_dir = tmp_path / "docs" / "decisions" - adr_dir.mkdir(parents=True) - (adr_dir / "0002-use-flat-decision-directory.md").write_text(ACCEPTED_ADR_NO_CONSTRAINTS, encoding="utf-8") - _git(["add", "-A"], tmp_path) - _git(["commit", "-q", "-m", "init"], tmp_path) - - (tmp_path / "docs" / "decisions" / "0003-new.md").write_text("placeholder\n", encoding="utf-8") - - result = check.run(_args(tmp_path, adr_dir)) - - finding = next(f for f in result["findings"] if f["adr_id"] == "ADR-0002") - assert finding["kind"] == "no_applicable_constraint" - - -def test_adr_with_no_affected_path_overlap_produces_no_finding(tmp_path): - _init_repo(tmp_path) - adr_dir = tmp_path / "docs" / "decisions" - adr_dir.mkdir(parents=True) - (adr_dir / "0001-use-a-provider-port.md").write_text(ACCEPTED_ADR_WITH_RULE, encoding="utf-8") - _git(["add", "-A"], tmp_path) - _git(["commit", "-q", "-m", "init"], tmp_path) - - (tmp_path / "unrelated.py").write_text("x = 1\n", encoding="utf-8") - - result = check.run(_args(tmp_path, adr_dir)) - - assert result["findings"] == [] - - -def test_malformed_adr_frontmatter_degrades_to_warning(tmp_path): - _init_repo(tmp_path) - adr_dir = tmp_path / "docs" / "decisions" - adr_dir.mkdir(parents=True) - (adr_dir / "0001-use-a-provider-port.md").write_text(ACCEPTED_ADR_WITH_RULE, encoding="utf-8") - (adr_dir / "0009-broken.md").write_text("not frontmatter at all", encoding="utf-8") - _git(["add", "-A"], tmp_path) - _git(["commit", "-q", "-m", "init"], tmp_path) - - (tmp_path / "unrelated.py").write_text("x = 1\n", encoding="utf-8") - - result = check.run(_args(tmp_path, adr_dir)) - - assert result["ok"] is True - assert any(w["code"] == "BAD_FRONTMATTER" and w["file"] == "0009-broken.md" for w in result["warnings"]) -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: `python -m pytest tests/unit/test_check.py -v` -Expected: FAIL — module doesn't exist. - -- [ ] **Step 3: Write minimal implementation** - -```python -# skills/adr-toolkit/scripts/commands/check.py -"""Match a git diff against Accepted ADRs' constraints: rules and -Superseded ADRs' affected_paths, per §7/§16 of the design spec.""" -from pathlib import Path - -from scripts.commands import diff as diff_command -from scripts.core import frontmatter as fm -from scripts.core import identifiers -from scripts.core.constraints import ConstraintsError, extract_constraints -from scripts.rules import conflict - -SKIP_FILES = {"README.md", "adr-template.md"} -RESOLUTIONS = ["fix_code", "supersede_adr", "adjust_scope", "register_exception", "false_positive"] - - -def run(args) -> dict: - diff_result = diff_command.run(args) - if not diff_result["ok"]: - return {"ok": False, "operation": "check", "errors": diff_result["errors"]} - diff_files = diff_result["files"] - - root = Path(getattr(args, "root", ".")).resolve() - existing_paths = _existing_paths(root) - - adr_dir = Path(args.dir) - entries, warnings = _load_adrs(adr_dir) - - findings = [] - for entry in entries: - data, body = entry["data"], entry["body"] - if data.get("status") != "accepted": - continue - if not conflict.affected_paths_overlap(diff_files, data.get("affected_paths", [])): - continue - - adr_id = data.get("id") - try: - rules = extract_constraints(body) - except ConstraintsError as exc: - warnings.append({"code": "BAD_CONSTRAINTS", "adr_id": adr_id, "detail": str(exc)}) - rules = [] - - produced_any = False - for rule in rules: - violation = conflict.evaluate_rule(rule, diff_files, existing_paths) - if violation: - findings.append({**violation, "adr_id": adr_id, "resolutions": RESOLUTIONS}) - produced_any = True - - if not produced_any: - findings.append({"adr_id": adr_id, "kind": "related" if rules else "no_applicable_constraint"}) - - return { - "ok": True, - "operation": "check", - "diff": {"mode": diff_result["mode"], "ref": diff_result.get("ref"), "files_changed": len(diff_files)}, - "findings": findings, - "warnings": warnings, - } - - -def _load_adrs(adr_dir: Path) -> tuple: - entries, warnings = [], [] - for entry in sorted(adr_dir.glob("*.md")): - if entry.name in SKIP_FILES or identifiers.parse_filename(entry.name) is None: - continue - try: - data, body = fm.parse(entry.read_text(encoding="utf-8")) - except fm.FrontmatterError as exc: - warnings.append({"code": "BAD_FRONTMATTER", "file": entry.name, "detail": str(exc)}) - continue - entries.append({"data": data, "body": body}) - return entries, warnings - - -def _existing_paths(root: Path) -> set: - paths = set() - for entry in root.rglob("*"): - if not entry.is_file(): - continue - relative = entry.relative_to(root) - if ".git" in relative.parts: - continue - paths.add(relative.as_posix()) - return paths -``` - -In `scripts/adr.py`, add: - -```python -from scripts.commands import check # alongside other imports -``` - -```python - p_check = sub.add_parser("check") - p_check.add_argument("--staged", action="store_true") - p_check.add_argument("--uncommitted", action="store_true") - p_check.add_argument("--since") - p_check.add_argument("--root", default=".") - p_check.add_argument("--dir", default="docs/decisions") - p_check.add_argument("--json", action="store_true") -``` - -Add `"check": check.run` to `HANDLERS`. - -- [ ] **Step 4: Run test to verify it passes** - -Run: `python -m pytest tests/unit/test_check.py -v` -Expected: PASS (5 tests) - -- [ ] **Step 5: Commit** - -```bash -git add skills/adr-toolkit/scripts/commands/check.py skills/adr-toolkit/scripts/adr.py \ - tests/unit/test_check.py -git commit -m "feat: add check command orchestrating diff against Accepted ADR constraints" -``` - ---- - -### Task 6: extend `check.py` — missing-realization heuristic + superseded-reference pass - -**Files:** -- Modify: `skills/adr-toolkit/scripts/commands/check.py` -- Test: `tests/unit/test_check.py` (add cases) - -**Interfaces:** -- Consumes: Task 5's `check.py` internals (`_load_adrs`, `_existing_paths`, `RESOLUTIONS`). -- Produces: two additional finding kinds on `run(args)`'s output: `review_required` (from a Verification-checklist scan, independent of whether a `constraints:` block exists) and `verified_violation` with `rule_id: "superseded_reference"` (from Superseded ADRs' `affected_paths` overlap). Both described in §16.3. - -- [ ] **Step 1: Write the failing test** - -Append to `tests/unit/test_check.py`: - -```python -ACCEPTED_ADR_WITH_VERIFICATION = """--- -id: ADR-0003 -title: Add event replay -status: accepted -date: 2026-08-03 -decision_makers: [] -related: [] -affected_paths: - - src/events/ -tags: [] -retrospective: false ---- - -# Add event replay - -## Verification - -* `src/events/replay.py` implements the replay handler. -""" - -SUPERSEDED_ADR = """--- -id: ADR-0004 -title: Use RabbitMQ -status: superseded -superseded_by: ADR-0005 -date: 2026-07-01 -decision_makers: [] -related: [] -affected_paths: - - src/queue/ -tags: [] -retrospective: false ---- - -# Use RabbitMQ - -Superseded. -""" - - -def test_review_required_when_verification_reference_is_removed(tmp_path): - _init_repo(tmp_path) - adr_dir = tmp_path / "docs" / "decisions" - adr_dir.mkdir(parents=True) - (adr_dir / "0003-add-event-replay.md").write_text(ACCEPTED_ADR_WITH_VERIFICATION, encoding="utf-8") - (tmp_path / "src" / "events").mkdir(parents=True) - (tmp_path / "src" / "events" / "replay.py").write_text("def replay(): pass\n", encoding="utf-8") - _git(["add", "-A"], tmp_path) - _git(["commit", "-q", "-m", "init"], tmp_path) - - (tmp_path / "src" / "events" / "replay.py").unlink() - - result = check.run(_args(tmp_path, adr_dir)) - - finding = next(f for f in result["findings"] if f["kind"] == "review_required") - assert finding["adr_id"] == "ADR-0003" - assert "src/events/replay.py" in finding["evidence"]["removed_paths"] - - -def test_superseded_reference_fires_on_affected_path_overlap(tmp_path): - _init_repo(tmp_path) - adr_dir = tmp_path / "docs" / "decisions" - adr_dir.mkdir(parents=True) - (adr_dir / "0004-use-rabbitmq.md").write_text(SUPERSEDED_ADR, encoding="utf-8") - _git(["add", "-A"], tmp_path) - _git(["commit", "-q", "-m", "init"], tmp_path) - - (tmp_path / "src" / "queue").mkdir(parents=True) - (tmp_path / "src" / "queue" / "client.py").write_text("x = 1\n", encoding="utf-8") - - result = check.run(_args(tmp_path, adr_dir)) - - finding = next(f for f in result["findings"] if f["rule_id"] == "superseded_reference") - assert finding["adr_id"] == "ADR-0004" - assert finding["kind"] == "verified_violation" - assert finding["evidence"]["superseded_by"] == "ADR-0005" - assert finding["resolutions"] == RESOLUTIONS -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: `python -m pytest tests/unit/test_check.py -v` -Expected: FAIL — the two new finding kinds aren't produced yet. - -- [ ] **Step 3: Extend the implementation** - -In `skills/adr-toolkit/scripts/commands/check.py`, add near the top: - -```python -import re - -VERIFICATION_HEADING_RE = re.compile(r"^#+\s*Verification\s*$", re.IGNORECASE | re.MULTILINE) -NEXT_HEADING_RE = re.compile(r"^#+\s", re.MULTILINE) -PATH_TOKEN_RE = re.compile(r"`([^`]+\.[a-zA-Z0-9]+)`") -``` - -Replace the Accepted-ADR loop body's `if not produced_any:` block with (keeping everything above it unchanged): - -```python - review = _missing_realization(body, diff_files) - if review: - findings.append({"adr_id": adr_id, "kind": "review_required", **review}) - produced_any = True - - if not produced_any: - findings.append({"adr_id": adr_id, "kind": "related" if rules else "no_applicable_constraint"}) -``` - -After the Accepted-ADR loop, before the `return`, add the Superseded-ADR pass: - -```python - for entry in entries: - data = entry["data"] - if data.get("status") != "superseded": - continue - if not conflict.affected_paths_overlap(diff_files, data.get("affected_paths", [])): - continue - findings.append({ - "adr_id": data.get("id"), - "kind": "verified_violation", - "rule_id": "superseded_reference", - "severity": "major", - "message": ( - f"This path is governed by {data.get('id')}, which is superseded " - f"by {data.get('superseded_by')}. Review the replacement decision " - f"before proceeding." - ), - "file": None, - "evidence": {"superseded_by": data.get("superseded_by")}, - "resolutions": RESOLUTIONS, - }) -``` - -Add the missing-realization heuristic function: - -```python -def _missing_realization(body: str, diff_files: list): - heading = VERIFICATION_HEADING_RE.search(body) - if not heading: - return None - next_heading = NEXT_HEADING_RE.search(body, heading.end()) - section = body[heading.end():next_heading.start() if next_heading else len(body)] - - referenced_paths = PATH_TOKEN_RE.findall(section) - if not referenced_paths: - return None - - by_path = {f["path"]: f for f in diff_files} - removed = [p for p in referenced_paths if by_path.get(p, {}).get("change_type") == "deleted"] - if not removed: - return None - - return { - "message": f"Verification references {removed} which this diff removes.", - "evidence": {"removed_paths": removed}, - } -``` - -- [ ] **Step 4: Run test to verify it passes** - -Run: `python -m pytest tests/unit/test_check.py -v` -Expected: PASS (7 tests — the 5 from Task 5 plus these 2) - -- [ ] **Step 5: Commit** - -```bash -git add skills/adr-toolkit/scripts/commands/check.py tests/unit/test_check.py -git commit -m "feat: add missing-realization and superseded-reference findings to check" -``` - ---- - -### Task 7: `references/conflict-rules.md` — constraint-authoring reference - -**Files:** -- Create: `skills/adr-toolkit/references/conflict-rules.md` -- Test: `tests/unit/test_conflict_rules_reference.py` - -**Interfaces:** -- Consumes: nothing (content only, read by the agent when drafting or explaining `constraints:` blocks and when presenting CHECK findings). -- Produces: the human/agent-readable explanation of the six rule kinds, the four-way classification, and the five resolution options. - -- [ ] **Step 1: Write the failing test** - -```python -# tests/unit/test_conflict_rules_reference.py -from pathlib import Path - -REFERENCE = ( - Path(__file__).resolve().parent.parent.parent - / "skills" / "adr-toolkit" / "references" / "conflict-rules.md" -) - - -def test_reference_documents_all_six_rule_kinds(): - text = REFERENCE.read_text(encoding="utf-8") - for kind in [ - "forbidden_import", "required_path", "forbidden_path", - "dependency_forbidden", "file_must_exist", "test_must_exist", - ]: - assert kind in text - - -def test_reference_documents_the_four_classifications(): - text = REFERENCE.read_text(encoding="utf-8") - for label in ["Related", "Review required", "Verified violation", "No applicable constraint"]: - assert label in text - - -def test_reference_documents_the_five_resolutions(): - text = REFERENCE.read_text(encoding="utf-8") - for resolution in ["fix_code", "supersede_adr", "adjust_scope", "register_exception", "false_positive"]: - assert resolution in text -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: `python -m pytest tests/unit/test_conflict_rules_reference.py -v` -Expected: FAIL — file doesn't exist. - -- [ ] **Step 3: Write the content** - -```markdown - -# Conflict Rules Reference - -CHECK matches a diff against Accepted ADRs' `constraints:` blocks and -Superseded ADRs' `affected_paths` — structural evidence only, never -semantic/AST analysis (see the design spec §7). - -## Writing a `constraints:` block - -Add a fenced YAML block to an ADR's `Implementation Constraints` section: - -```yaml -constraints: - - id: no-provider-sdk-in-feature - kind: forbidden_import - paths: ["src/features/**"] - pattern: ["openai\\.", "anthropic\\."] - severity: major - message: "Feature modules must use the LLM port." -``` - -Each rule needs `id`, `kind`, `paths` (glob list, `**` matches any depth), -`severity`, and `message`; `pattern` is required by every kind except the -existence-check kinds. `paths`/`pattern` values must be a JSON-style array -on one line — not YAML block-list syntax. - -## The six rule kinds - -- `forbidden_import` — fires if an added line in a file matching `paths` - matches any regex in `pattern`. Use for "this module must never import X." -- `dependency_forbidden` — mechanically identical to `forbidden_import`; - scope `paths` to a dependency manifest (`requirements.txt`, - `package.json`) instead of source files. -- `required_path` — fires if the diff touches a file matching `paths` but - no file matching `pattern` is touched by the diff or already exists in - the repository. Use for "touching X requires also touching/having Y." -- `forbidden_path` — fires if the diff touches a file matching `paths` AND - also touches a file matching `pattern`. Use for boundary violations - ("features must never touch db migrations directly"). -- `file_must_exist` / `test_must_exist` — fires if none of the paths - matching `paths` exist in the working tree after the diff. Identical - mechanism; `test_must_exist` is a naming convention for the ADR author, - not different logic. - -An ADR with no `constraints:` block has nothing CHECK can mechanically -enforce — that's a legitimate state, not an error. - -## Four-way classification - -- **Related** — the diff touches a path the ADR names, and its - `constraints:` block was evaluated but nothing fired. -- **Review required** — the ADR's `Verification` checklist references a - path or test the diff removes; this is prose-scanning, not a - `constraints:` rule, so it needs a human look rather than a mechanical - yes/no. -- **Verified violation** — a `constraints:` rule fired with direct - structural evidence, or the diff touches a path a Superseded ADR - governs (`rule_id: superseded_reference`). -- **No applicable constraint** — the diff touches a path the ADR names, - but the ADR has neither a `constraints:` block nor a matching - Verification reference. - -## Resolving a Verified violation - -Present all five options; never default to "revert the code": - -1. `fix_code` — change the diff to comply with the existing decision. -2. `supersede_adr` — the old decision no longer holds; record a new ADR - that supersedes it (`adr.py supersede`). -3. `adjust_scope` — the ADR's `affected_paths` or `constraints:` are too - broad or too narrow; edit them instead of the code. -4. `register_exception` — this specific case is a deliberate, documented - exception to an otherwise-still-valid rule. -5. `false_positive` — the rule fired but there is no real conflict; note - why so the rule can be tightened. -``` - -- [ ] **Step 4: Run test to verify it passes** - -Run: `python -m pytest tests/unit/test_conflict_rules_reference.py -v` -Expected: PASS (3 tests) - -- [ ] **Step 5: Commit** - -```bash -git add skills/adr-toolkit/references/conflict-rules.md tests/unit/test_conflict_rules_reference.py -git commit -m "docs: add conflict rules reference for CHECK" -``` - ---- - -### Task 8: `SKILL.md` — CHECK section - -**Files:** -- Modify: `skills/adr-toolkit/SKILL.md` -- Test: `tests/unit/test_skill_manifest.py` (add cases) - -**Interfaces:** -- Consumes: `diff`/`check` (Tasks 3, 5, 6), `references/conflict-rules.md` (Task 7). -- Produces: the CHECK operation contract an agent follows, replacing the "CHECK is not yet implemented" note in `## Script reference`. - -- [ ] **Step 1: Write the failing test** - -Append to `tests/unit/test_skill_manifest.py`: - -```python -def test_skill_md_documents_check(): - _, body = fm.parse(SKILL_MD.read_text(encoding="utf-8")) - assert "## CHECK" in body - assert "adr.py check" in body - assert "Verified violation" in body - assert "CHECK is not yet implemented" not in body -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: `python -m pytest tests/unit/test_skill_manifest.py -v` -Expected: FAIL — section doesn't exist yet, and the stale note is still present. - -- [ ] **Step 3: Add the section** - -Insert into `skills/adr-toolkit/SKILL.md`, after `## Lifecycle operations` and before `## Prohibited`: - -```markdown -## CHECK - -Use CHECK to look for structural conflicts between a diff and existing -Accepted/Superseded ADRs before or during a change — never after merging, -since CHECK is read-only and never fixes anything itself. - -1. **PREFLIGHT** — run - `python skills/adr-toolkit/scripts/adr.py preflight --json`. If - `existing_adr_directory` is `null`, stop and tell the user to run INIT - first. -2. **GATHER EVIDENCE** — run - `python skills/adr-toolkit/scripts/adr.py check --uncommitted --dir docs/decisions --json` - (or `--staged`, or `--since ` for a branch/commit range — pick the - mode that matches what the user asked to check). Read - `references/conflict-rules.md` if you need to explain a finding or help - the user write a `constraints:` block. -3. **CLASSIFY** — each finding already carries its classification - (`related`, `review_required`, `verified_violation`, - `no_applicable_constraint`); do not re-judge it, report it as returned. -4. **REPORT** — group findings by classification. For every - `verified_violation`, present all five resolutions from - `references/conflict-rules.md` (`fix_code`, `supersede_adr`, - `adjust_scope`, `register_exception`, `false_positive`) — never assume - which one the user wants. -5. **ASK-IF-NEEDED / MUTATE** — CHECK itself never writes anything. If the - user picks `fix_code`, that's a normal code edit, not an ADR Toolkit - operation. If they pick `supersede_adr` or `adjust_scope`, follow the - RECORD or Lifecycle operations flow above — never hand-edit - `constraints:`, `affected_paths`, or `status`. -6. Any `check` `warnings` entries (e.g. `BAD_FRONTMATTER`, - `BAD_CONSTRAINTS`) mean one ADR was skipped, not that CHECK failed — - report them, but don't block on them. -``` - -Also, in `## Script reference`, replace: - -```markdown -CHECK is not yet implemented (see `project-roadmap.md` in the repository root -and the design spec this skill is built from). -``` - -with: - -```markdown -CHECK's conflict detection is deliberately limited to structural evidence -from `constraints:` blocks (see `references/conflict-rules.md`) — it never -attempts semantic or AST-level analysis; see `project-roadmap.md` for what -that fuller scope would look like. -``` - -- [ ] **Step 4: Run test to verify it passes** - -Run: `python -m pytest tests/unit/test_skill_manifest.py -v` -Expected: PASS (all prior cases plus this one) - -- [ ] **Step 5: Commit** - -```bash -git add skills/adr-toolkit/SKILL.md tests/unit/test_skill_manifest.py -git commit -m "feat: document CHECK operation in SKILL.md" -``` - ---- - -### Task 9: End-to-end RECORD-then-CHECK fixture and golden test - -**Files:** -- Create: `tests/fixtures/check_provider_port/docs/decisions/0001-use-a-provider-port.md` -- Create: `tests/integration/test_check_workflow.py` - -**Interfaces:** -- Consumes: `check`, `diff` via subprocess (Tasks 3, 5, 6), following the `_run` diagnostic-assertion pattern established in `tests/integration/test_record_workflow.py`. -- Produces: the golden proof that CHECK's scripted steps compose correctly against a repo with a real git history and a constraint-bearing Accepted ADR. - -- [ ] **Step 1: Write the failing test** - -```markdown - ---- -id: ADR-0001 -title: Use a provider port for LLM calls -status: accepted -date: 2026-08-01 -decision_makers: [] -related: [] -affected_paths: - - src/features/ -tags: - - architecture -retrospective: false ---- - -# Use a provider port for LLM calls - -## Implementation Constraints - -Feature modules must go through the LLM port; they must never import a -provider SDK directly. - -```yaml -constraints: - - id: no-provider-sdk-in-feature - kind: forbidden_import - paths: ["src/features/**"] - pattern: ["openai", "anthropic"] - severity: major - message: "Feature modules must use the LLM port, not a provider SDK directly." -``` -``` - -```python -# tests/integration/test_check_workflow.py -import json -import shutil -import subprocess -import sys -from pathlib import Path - -FIXTURE = Path(__file__).resolve().parent.parent / "fixtures" / "check_provider_port" -ADR_PY = Path(__file__).resolve().parent.parent.parent / "skills" / "adr-toolkit" / "scripts" / "adr.py" - - -def _run(args, cwd): - command = [sys.executable, str(ADR_PY), *args] - result = subprocess.run(command, cwd=cwd, capture_output=True, text=True) - assert result.returncode == 0, ( - f"CLI command failed: {' '.join(command)}\n" - f"cwd: {cwd}\nstdout:\n{result.stdout}\nstderr:\n{result.stderr}" - ) - try: - payload = json.loads(result.stdout) - except json.JSONDecodeError as exc: - raise AssertionError( - f"CLI command did not return JSON: {' '.join(command)}\n" - f"cwd: {cwd}\nstdout:\n{result.stdout}\nstderr:\n{result.stderr}" - ) from exc - assert payload.get("ok") is True, ( - f"CLI command reported failure: {' '.join(command)}\ncwd: {cwd}\npayload: {payload!r}" - ) - return payload - - -def _git(args, cwd): - subprocess.run(["git", *args], cwd=cwd, check=True, capture_output=True) - - -def test_check_flags_a_violation_then_clears_after_the_fix(tmp_path): - repo = tmp_path / "repo" - shutil.copytree(FIXTURE, repo) - _git(["init", "-q", "-b", "master"], repo) - _git(["config", "user.email", "test@example.com"], repo) - _git(["config", "user.name", "Test"], repo) - _git(["add", "-A"], repo) - _git(["commit", "-q", "-m", "init"], repo) - - features_dir = repo / "src" / "features" - features_dir.mkdir(parents=True) - (features_dir / "chat.py").write_text("import openai\n\ndef handler():\n pass\n", encoding="utf-8") - - violating_result = _run( - ["check", "--uncommitted", "--dir", "docs/decisions", "--json"], cwd=repo, - ) - violation = next(f for f in violating_result["findings"] if f["kind"] == "verified_violation") - assert violation["adr_id"] == "ADR-0001" - assert violation["rule_id"] == "no-provider-sdk-in-feature" - assert set(violation["resolutions"]) == { - "fix_code", "supersede_adr", "adjust_scope", "register_exception", "false_positive", - } - - (features_dir / "chat.py").write_text( - "from src.core.ports.llm import LLMPort\n\ndef handler():\n pass\n", encoding="utf-8", - ) - - fixed_result = _run( - ["check", "--uncommitted", "--dir", "docs/decisions", "--json"], cwd=repo, - ) - assert all(f["kind"] != "verified_violation" for f in fixed_result["findings"]) - related = next(f for f in fixed_result["findings"] if f["adr_id"] == "ADR-0001") - assert related["kind"] == "related" -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: `python -m pytest tests/integration/test_check_workflow.py -v` -Expected: FAIL until Step 1's fixture file exists. - -- [ ] **Step 3: Nothing further to implement** - -This task's "implementation" is the fixture itself. No production code changes — it proves Tasks 1–8 already compose correctly end to end, including that fixing the violation clears it on a second `check` run. - -- [ ] **Step 4: Run test to verify it passes** - -Run: `python -m pytest tests/integration/test_check_workflow.py -v` -Expected: PASS (1 test) - -Then run the full suite: - -Run: `python -m pytest tests/unit tests/integration -v` -Expected: PASS (all prior tests plus this plan's new ones) - -- [ ] **Step 5: Commit** - -```bash -git add tests/fixtures/check_provider_port tests/integration/test_check_workflow.py -git commit -m "test: add end-to-end CHECK fixture and golden test" -``` - ---- - -## Plan self-review notes - -- **Spec coverage:** §7 (six rule kinds, four-way classification, five resolutions) → Tasks 2, 4, 5, 6, 7. §11 CHECK data flow → Tasks 3, 5, 6, 8. §16.1 module split → Tasks 1–6 (`globs.py`/`constraints.py` under `core/`, `conflict.py` under `rules/`, `diff.py`/`check.py` under `commands/`). §16.2 matching mechanisms → Task 4. §16.3 classification algorithm → Tasks 5–6. §16.4 constraints scope (Accepted only) → Task 5's `if data.get("status") != "accepted": continue`. §16.5 diff CLI shape → Task 3. §16.6 output/error handling → Tasks 3, 5 (specific error codes; `warnings` degrade path). §16.7 testing strategy → every task's own unit tests plus Task 9's golden test. -- **Type consistency checked:** `diff.run`'s `files` shape (`{path, change_type, added_lines, removed_lines}`, Task 3) matches exactly what `conflict.py` (Task 4) and `check.py` (Tasks 5–6) consume. `conflict.evaluate_rule`'s return shape (`rule_id`/`kind`/`severity`/`message`/`file`/`evidence`) matches how Task 5 merges it into a finding (`{**violation, "adr_id", "resolutions"}`). `extract_constraints`'s rule dicts (Task 2: `id`/`kind`/`paths`/`pattern`/`severity`/`message`) match the field names `conflict.py` reads. `RESOLUTIONS` is defined once in `check.py` and reused for both `constraints:`-sourced and `superseded_reference` violations, not duplicated. -- **No placeholders found.** diff --git a/docs/superpowers/plans/2026-08-30-adr-toolkit-i18n-adapters-release.md b/docs/superpowers/plans/2026-08-30-adr-toolkit-i18n-adapters-release.md deleted file mode 100644 index 95e9ee8..0000000 --- a/docs/superpowers/plans/2026-08-30-adr-toolkit-i18n-adapters-release.md +++ /dev/null @@ -1,1142 +0,0 @@ -# ADR Toolkit — i18n, Adapters, Release Automation (Plan 4 of 4) Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Close out the MVP — localize `index.py`'s generated `README.md` into 5 languages, add light adapters for Codex CLI, Gemini CLI, and Antigravity CLI (verified manifest formats, not guessed), and add version synchronization plus a tag-triggered release workflow. - -**Architecture:** Three independent layers, matching the plan's three sections. i18n: a small `core/locale.py` loader (English-fallback merge) plus five flat `scripts/i18n/{locale}.json` files, consumed only by `index.py`'s `--locale` flag — no other module needs translation, since everything else is agent-composed prose. Adapters: three new `adapters//` directories, each holding only a manifest file plus a README documenting a symlink-based install (mirroring `adapters/generic/README.md`) — no symlink is committed to the repo itself, since a real symlink checked into git breaks on Windows CI (`windows-latest` is in `test.yml`'s matrix) unless `core.symlinks` is enabled, which cannot be assumed. Release: a repo-root `scripts/sync_version.py` (tooling for the repo, not part of the distributable `skills/adr-toolkit/` package) plus a CI drift-check and a new tag-triggered `release.yml`. - -**Tech Stack:** Same as Plans 1–3 — Python 3.9+ standard library only, pytest, GitHub Actions. - -**Spec:** `docs/superpowers/specs/2026-08-29-adr-toolkit-design.md` (§17 Plan 4 implementation decisions — primary source; §8 harness support strategy; §9 internationalization scope) - -## Global Constraints - -- Same as Plans 1–3: Python 3.9+ stdlib only; UTF-8 explicit; no `shell=True`; command modules never `print()`; avoid `X | None` union syntax, use `typing.Optional`. -- i18n covers only `index.py`'s generated strings (§17.1) — never SKILL.md's own agent instructions, never RECORD/DISCOVER/CHECK's agent-composed questions/reports. A locale file or key that's missing falls back to English, never a crash and never a raw key name shown to a user. -- No adapter directory commits an actual symlink to git — every adapter's `skills/adr-toolkit` link is created at install time per that adapter's README, exactly like `adapters/generic/README.md` already documents. This avoids Windows CI (`test.yml`'s `windows-latest` matrix leg) breaking on an uncommitted-symlink-support assumption. -- `scripts/sync_version.py` lives at the repo root, not under `skills/adr-toolkit/` — it is tooling for this repository, not part of the distributable skill package. -- Manual end-to-end adapter verification (§17.2) only happens where a real harness CLI is present in this environment (`codex` 0.151.0, `gemini` 0.46.0 — confirmed installed). The Antigravity task is scoped to structural/schema verification only; its report must say so plainly, never claim an end-to-end run that didn't happen. -- Every task must leave `python -m pytest tests/unit tests/integration -v` green before its commit step. -- Out of scope for this plan: the CHECK follow-ups tracked in `project-roadmap.md`'s "CHECK follow-ups" section, and `adr.py`'s `--json`-always-emits-JSON standing risk (tracked in `handoff.md`, untouched here). - ---- - -### Task 1: `core/locale.py` + the five locale JSON files - -**Files:** -- Create: `skills/adr-toolkit/scripts/core/locale.py` -- Create: `skills/adr-toolkit/scripts/i18n/en.json` -- Create: `skills/adr-toolkit/scripts/i18n/fr.json` -- Create: `skills/adr-toolkit/scripts/i18n/ja.json` -- Create: `skills/adr-toolkit/scripts/i18n/ko.json` -- Create: `skills/adr-toolkit/scripts/i18n/zh.json` -- Test: `tests/unit/test_locale.py` - -**Interfaces:** -- Consumes: nothing. -- Produces: `load_locale(locale: str) -> dict`, `DEFAULT_LOCALE = "en"`. Used by Task 2 (`commands/index.py`). - -- [ ] **Step 1: Write the failing test** - -```python -# tests/unit/test_locale.py -from scripts.core.locale import DEFAULT_LOCALE, load_locale - -REQUIRED_KEYS = { - "decision_log_title", "by_status", "by_tag", "by_affected_path", - "chronological", "status.proposed", "status.accepted", - "status.rejected", "status.deprecated", "status.superseded", -} - - -def test_default_locale_is_english(): - assert DEFAULT_LOCALE == "en" - - -def test_english_locale_has_all_required_keys(): - strings = load_locale("en") - assert REQUIRED_KEYS.issubset(strings.keys()) - - -def test_every_shipped_locale_has_all_required_keys(): - for locale in ("en", "fr", "ja", "ko", "zh"): - strings = load_locale(locale) - missing = REQUIRED_KEYS - strings.keys() - assert not missing, f"{locale} is missing keys: {missing}" - - -def test_french_translates_a_known_string(): - strings = load_locale("fr") - assert strings["by_status"] == "Par statut" - - -def test_missing_locale_falls_back_to_english(): - strings = load_locale("xx") - assert strings["by_status"] == "By status" - - -def test_missing_key_in_a_present_locale_falls_back_to_english(tmp_path, monkeypatch): - import scripts.core.locale as locale_module - partial_dir = tmp_path / "i18n" - partial_dir.mkdir() - (partial_dir / "en.json").write_text('{"by_status": "By status"}', encoding="utf-8") - (partial_dir / "xx.json").write_text('{}', encoding="utf-8") - monkeypatch.setattr(locale_module, "I18N_DIR", partial_dir) - - strings = locale_module.load_locale("xx") - - assert strings["by_status"] == "By status" -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: `python -m pytest tests/unit/test_locale.py -v` -Expected: FAIL — `scripts.core.locale` doesn't exist. - -- [ ] **Step 3: Write minimal implementation** - -```python -# skills/adr-toolkit/scripts/core/locale.py -"""Load i18n locale files for index.py's generated strings. - -Every other user-facing string in this toolkit (RECORD/DISCOVER/CHECK's -interview questions and reports) is composed by the agent, not a fixed -Python string, so it needs no translation table — only index.py's -generated README.md content is a translation target (design spec §17.1). -""" -import json -from pathlib import Path - -I18N_DIR = Path(__file__).resolve().parent.parent / "i18n" -DEFAULT_LOCALE = "en" - - -def load_locale(locale: str) -> dict: - base = _load_json(DEFAULT_LOCALE) - if locale == DEFAULT_LOCALE: - return base - overlay = _load_json(locale) - return {**base, **overlay} - - -def _load_json(locale: str) -> dict: - path = I18N_DIR / f"{locale}.json" - if not path.is_file(): - return {} - return json.loads(path.read_text(encoding="utf-8")) -``` - -```json -// skills/adr-toolkit/scripts/i18n/en.json -{ - "decision_log_title": "Decision Log", - "by_status": "By status", - "by_tag": "By tag", - "by_affected_path": "By affected path", - "chronological": "Chronological (newest first)", - "status.proposed": "Proposed", - "status.accepted": "Accepted", - "status.rejected": "Rejected", - "status.deprecated": "Deprecated", - "status.superseded": "Superseded" -} -``` - -```json -// skills/adr-toolkit/scripts/i18n/fr.json -{ - "decision_log_title": "Journal des décisions", - "by_status": "Par statut", - "by_tag": "Par étiquette", - "by_affected_path": "Par chemin concerné", - "chronological": "Chronologique (plus récent d'abord)", - "status.proposed": "Proposé", - "status.accepted": "Accepté", - "status.rejected": "Rejeté", - "status.deprecated": "Déprécié", - "status.superseded": "Remplacé" -} -``` - -```json -// skills/adr-toolkit/scripts/i18n/ja.json -{ - "decision_log_title": "決定記録", - "by_status": "ステータス別", - "by_tag": "タグ別", - "by_affected_path": "影響パス別", - "chronological": "時系列(新しい順)", - "status.proposed": "提案中", - "status.accepted": "承認済み", - "status.rejected": "却下", - "status.deprecated": "非推奨", - "status.superseded": "置き換え済み" -} -``` - -```json -// skills/adr-toolkit/scripts/i18n/ko.json -{ - "decision_log_title": "결정 기록", - "by_status": "상태별", - "by_tag": "태그별", - "by_affected_path": "영향 경로별", - "chronological": "시간순 (최신순)", - "status.proposed": "제안됨", - "status.accepted": "승인됨", - "status.rejected": "거부됨", - "status.deprecated": "폐기됨", - "status.superseded": "대체됨" -} -``` - -```json -// skills/adr-toolkit/scripts/i18n/zh.json -{ - "decision_log_title": "决策日志", - "by_status": "按状态", - "by_tag": "按标签", - "by_affected_path": "按受影响路径", - "chronological": "按时间顺序(最新优先)", - "status.proposed": "已提议", - "status.accepted": "已接受", - "status.rejected": "已拒绝", - "status.deprecated": "已弃用", - "status.superseded": "已取代" -} -``` - -- [ ] **Step 4: Run test to verify it passes** - -Run: `python -m pytest tests/unit/test_locale.py -v` -Expected: PASS (6 tests) - -- [ ] **Step 5: Commit** - -```bash -git add skills/adr-toolkit/scripts/core/locale.py skills/adr-toolkit/scripts/i18n \ - tests/unit/test_locale.py -git commit -m "feat: add locale loader and five i18n string files" -``` - ---- - -### Task 2: `index.py --locale` — localized index generation - -**Files:** -- Modify: `skills/adr-toolkit/scripts/commands/index.py` -- Modify: `skills/adr-toolkit/scripts/adr.py` -- Test: `tests/unit/test_index.py` (add cases) - -**Interfaces:** -- Consumes: `scripts.core.locale.load_locale` (Task 1). -- Produces: `run(args) -> dict` where `args` gains an optional `.locale` attribute (default `"en"` when absent). `_render(entries, strings)` now takes the loaded locale dict as a second parameter. Used by Task 3 (`SKILL.md`, documentation only — no code dependency). - -- [ ] **Step 1: Write the failing test** - -Append to `tests/unit/test_index.py`: - -```python -def test_index_renders_french_headers_and_status_labels(tmp_path): - _write_adr( - tmp_path, "0001-use-kafka.md", - id_="ADR-0001", title="Use Kafka", status="accepted", date="2026-08-01", - tags=["architecture"], affected_paths=["src/events/"], - ) - - result = index.run(SimpleNamespace(dir=str(tmp_path), locale="fr")) - - assert result["ok"] is True - readme = (tmp_path / "README.md").read_text(encoding="utf-8") - assert "Journal des décisions" in readme - assert "Par statut" in readme - assert "Accepté" in readme - assert "Par étiquette" in readme - assert "Par chemin concerné" in readme - assert "Chronologique (plus récent d'abord)" in readme - - -def test_index_defaults_to_english_when_locale_omitted(tmp_path): - _write_adr( - tmp_path, "0001-use-kafka.md", - id_="ADR-0001", title="Use Kafka", status="accepted", date="2026-08-01", - tags=["architecture"], affected_paths=["src/events/"], - ) - - result = index.run(SimpleNamespace(dir=str(tmp_path))) - - assert result["ok"] is True - readme = (tmp_path / "README.md").read_text(encoding="utf-8") - assert "# Decision Log" in readme - assert "## By status" in readme - - -def test_index_unknown_status_falls_back_to_capitalized_label(tmp_path): - _write_adr( - tmp_path, "0001-use-kafka.md", - id_="ADR-0001", title="Use Kafka", status="unknown", date="2026-08-01", - tags=[], affected_paths=[], - ) - - result = index.run(SimpleNamespace(dir=str(tmp_path), locale="fr")) - - assert result["ok"] is True - readme = (tmp_path / "README.md").read_text(encoding="utf-8") - assert "### Unknown" in readme -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: `python -m pytest tests/unit/test_index.py -v` -Expected: FAIL — `index.run` doesn't accept/use a locale yet, headers stay English-only. - -- [ ] **Step 3: Extend the implementation** - -In `skills/adr-toolkit/scripts/commands/index.py`, add the import: - -```python -from scripts.core.locale import load_locale -``` - -Replace `run`'s body up to the `README.md` write: - -```python -def run(args) -> dict: - adr_dir = Path(args.dir) - locale = getattr(args, "locale", None) or "en" - strings = load_locale(locale) - entries = [] - warnings = [] - - for entry in sorted(adr_dir.glob("*.md")): - if entry.name in SKIP_FILES: - continue - parsed = identifiers.parse_filename(entry.name) - if parsed is None: - continue - try: - data, _ = fm.parse(entry.read_text(encoding="utf-8")) - except fm.FrontmatterError as exc: - warnings.append({"code": "BAD_FRONTMATTER", "file": entry.name, "detail": str(exc)}) - continue - entries.append({ - "id": data.get("id", f"ADR-{parsed[0]:04d}"), - "filename": entry.name, - "title": data.get("title", parsed[1]), - "status": data.get("status", "unknown"), - "date": data.get("date", ""), - "tags": data.get("tags", []), - "affected_paths": data.get("affected_paths", []), - }) - - (adr_dir / "README.md").write_text(_render(entries, strings), encoding="utf-8") - - return { - "ok": True, - "operation": "index", - "count": len(entries), - "path": str(adr_dir / "README.md"), - "warnings": warnings, - } -``` - -Replace `_render` to take and use `strings`: - -```python -def _render(entries: list, strings: dict) -> str: - lines = [f"# {strings['decision_log_title']}", ""] - - lines.append(f"## {strings['by_status']}") - lines.append("") - by_status: dict = {} - for entry in entries: - by_status.setdefault(entry["status"], []).append(entry) - for status in sorted(by_status): - label = strings.get(f"status.{status}", status.capitalize()) - lines.append(f"### {label}") - for entry in sorted(by_status[status], key=lambda e: e["filename"]): - lines.append(f"- [{entry['id']} — {entry['title']}]({entry['filename']})") - lines.append("") - - lines.append(f"## {strings['by_tag']}") - lines.append("") - by_tag: dict = {} - for entry in entries: - for tag in entry["tags"]: - by_tag.setdefault(tag, []).append(entry) - for tag in sorted(by_tag): - lines.append(f"### {tag}") - for entry in sorted(by_tag[tag], key=lambda e: e["filename"]): - lines.append(f"- [{entry['id']} — {entry['title']}]({entry['filename']})") - lines.append("") - - lines.append(f"## {strings['by_affected_path']}") - lines.append("") - by_path: dict = {} - for entry in entries: - for path in entry["affected_paths"]: - by_path.setdefault(path, []).append(entry) - for path in sorted(by_path): - lines.append(f"### `{path}`") - for entry in sorted(by_path[path], key=lambda e: e["filename"]): - lines.append(f"- [{entry['id']} — {entry['title']}]({entry['filename']})") - lines.append("") - - lines.append(f"## {strings['chronological']}") - lines.append("") - for entry in sorted(entries, key=lambda e: e["date"], reverse=True): - lines.append(f"- {entry['date']} — [{entry['id']} — {entry['title']}]({entry['filename']})") - - return "\n".join(lines) + "\n" -``` - -In `skills/adr-toolkit/scripts/adr.py`, find the `p_index` subparser and add a `--locale` argument: - -```python - p_index = sub.add_parser("index") - p_index.add_argument("--dir", default="docs/decisions") - p_index.add_argument("--locale", default="en") - p_index.add_argument("--json", action="store_true") -``` - -- [ ] **Step 4: Run test to verify it passes** - -Run: `python -m pytest tests/unit/test_index.py -v` -Expected: PASS (all prior cases plus these 3) - -Then run the full suite: - -Run: `python -m pytest tests/unit tests/integration -v` -Expected: PASS (no regressions — `test_check_workflow.py` and `test_record_workflow.py` both call `index` without `--locale`, which must still default to English) - -- [ ] **Step 5: Commit** - -```bash -git add skills/adr-toolkit/scripts/commands/index.py skills/adr-toolkit/scripts/adr.py \ - tests/unit/test_index.py -git commit -m "feat: add --locale to index for localized README generation" -``` - ---- - -### Task 3: `SKILL.md` — agent language-detection instruction - -**Files:** -- Modify: `skills/adr-toolkit/SKILL.md` -- Test: `tests/unit/test_skill_manifest.py` (add case) - -**Interfaces:** -- Consumes: `index --locale` (Task 2, referenced by name only — this is a documentation task, not a code dependency). -- Produces: the agent-facing instruction that closes the loop between "the agent detects a language" and "the agent tells `index` to use it." - -- [ ] **Step 1: Write the failing test** - -Append to `tests/unit/test_skill_manifest.py`: - -```python -def test_skill_md_documents_locale_detection(): - _, body = fm.parse(SKILL_MD.read_text(encoding="utf-8")) - assert "--locale" in body - assert "fr" in body and "ja" in body and "ko" in body and "zh" in body -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: `python -m pytest tests/unit/test_skill_manifest.py -v` -Expected: FAIL — no locale instruction exists yet. - -- [ ] **Step 3: Add the instruction** - -In `skills/adr-toolkit/SKILL.md`, add a new subsection right after `## Workflow contract` and before `## INIT (scaffolding only)`: - -```markdown -## Language - -Detect the language of the user's own request (English, French, Japanese, -Korean, or Chinese) and compose every question and report you write in -that language — English is the default when the language can't be -determined. This applies only to what you write yourself; it does not -change any file content the user themselves wrote in a different -language, and it does not translate `SKILL.md`'s own instructions. - -When you run `index`, pass the same locale as a two-letter code so the -generated `docs/decisions/README.md` headers match -(`--locale en|fr|ja|ko|zh`, defaulting to `en` when omitted): - -```bash -python skills/adr-toolkit/scripts/adr.py index --dir docs/decisions --locale fr --json -``` -``` - -- [ ] **Step 4: Run test to verify it passes** - -Run: `python -m pytest tests/unit/test_skill_manifest.py -v` -Expected: PASS (all prior cases plus this one) - -- [ ] **Step 5: Commit** - -```bash -git add skills/adr-toolkit/SKILL.md tests/unit/test_skill_manifest.py -git commit -m "docs: instruct the agent to detect language and pass index --locale" -``` - ---- - -### Task 4: Codex CLI adapter — manifest, README, structural test, manual verification - -**Files:** -- Create: `adapters/codex/.codex-plugin/plugin.json` -- Create: `adapters/codex/README.md` -- Test: `tests/unit/test_codex_adapter.py` - -**Interfaces:** -- Consumes: nothing (manifest is static JSON). -- Produces: nothing consumed by later tasks — adapters are independent leaves. - -- [ ] **Step 1: Write the failing test** - -```python -# tests/unit/test_codex_adapter.py -import json -from pathlib import Path - -MANIFEST = ( - Path(__file__).resolve().parent.parent.parent - / "adapters" / "codex" / ".codex-plugin" / "plugin.json" -) - - -def test_manifest_is_valid_json_with_required_fields(): - data = json.loads(MANIFEST.read_text(encoding="utf-8")) - assert data["name"] == "adr-toolkit" - - -def test_manifest_has_no_extra_undocumented_top_level_keys(): - data = json.loads(MANIFEST.read_text(encoding="utf-8")) - assert set(data.keys()) <= {"$schema", "name", "description"} -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: `python -m pytest tests/unit/test_codex_adapter.py -v` -Expected: FAIL — manifest doesn't exist. - -- [ ] **Step 3: Write the manifest and README** - -```json -// adapters/codex/.codex-plugin/plugin.json -{ - "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json", - "name": "adr-toolkit", - "description": "Initialize, record, and check Architecture Decision Records by inspecting the repository and existing decisions before asking questions." -} -``` - -```markdown - -# Codex CLI adapter - -Follows the cross-vendor Agent Plugins 1.0.0 standard -(`github.com/agentplugins/agent-plugins-spec`), which Codex CLI adopted -2026-08-07. This manifest needs only `name` — no `"skills"` key, since -Agent Plugins auto-discovers a sibling `skills/` directory. - -## Install - -1. Copy this repo's `skills/adr-toolkit/` package somewhere Codex can - reach it, then symlink it under this adapter's plugin directory as - `skills/adr-toolkit` (a sibling of `plugin.json`, inside - `.codex-plugin/`): - - ```bash - mkdir -p adapters/codex/.codex-plugin/skills - ln -s "$(pwd)/skills/adr-toolkit" adapters/codex/.codex-plugin/skills/adr-toolkit - ``` - -2. Point Codex at `adapters/codex/.codex-plugin/` per Codex's own plugin - install documentation (`codex --help` documents the exact - install/reload subcommand — it may change as the Agent Plugins - standard matures, since this adapter shipped within days of the - standard's own release). -3. Confirm Codex lists `adr-toolkit` as an available skill, then run - `python skills/adr-toolkit/scripts/adr.py preflight --json` from your - target repository to confirm the script layer works standalone. - -The symlink is created at install time, not committed to this repo — -committing a real symlink breaks on Windows checkouts that don't have -`core.symlinks` enabled, which this project's CI can't assume. -``` - -- [ ] **Step 4: Run test to verify it passes** - -Run: `python -m pytest tests/unit/test_codex_adapter.py -v` -Expected: PASS (2 tests) - -- [ ] **Step 5: Manual end-to-end verification** - -`codex` CLI (0.151.0) is installed in this environment — actually perform this install and record the real transcript in your task report (not a hypothetical): - -```bash -mkdir -p /tmp/adr-toolkit-codex-verify && cd /tmp/adr-toolkit-codex-verify -git init -q -mkdir -p .codex-plugin/skills -ln -s "/skills/adr-toolkit" .codex-plugin/skills/adr-toolkit -cp "/adapters/codex/.codex-plugin/plugin.json" .codex-plugin/plugin.json -codex --help -# ^ read this output to find the actual plugin-list/plugin-install subcommand — -# do not guess a subcommand name; Codex's plugin CLI surface is new as of -# 2026-08-07 and this plan cannot assert its exact flags with confidence. -# Use whatever subcommand codex --help documents to confirm the adapter -# is discoverable, then run the toolkit directly to prove the symlinked -# skill actually works end to end: -python .codex-plugin/skills/adr-toolkit/scripts/adr.py preflight --json -python .codex-plugin/skills/adr-toolkit/scripts/adr.py init --dir docs/decisions --json -python .codex-plugin/skills/adr-toolkit/scripts/adr.py validate --dir docs/decisions --json -``` - -Record in the task report: the exact `codex --help` output relevant to plugin discovery, whether Codex actually lists `adr-toolkit`, and the `preflight`/`init`/`validate` JSON output proving the symlinked skill runs correctly from inside a Codex-style plugin layout. If Codex's plugin CLI doesn't expose a discovery command yet (the standard is very new), say so plainly and rely on the direct script execution as the verification instead — do not claim a Codex-side discovery check that didn't happen. - -- [ ] **Step 6: Commit** - -```bash -git add adapters/codex tests/unit/test_codex_adapter.py -git commit -m "feat: add Codex CLI adapter manifest and install docs" -``` - ---- - -### Task 5: Gemini CLI adapter — manifest, README, structural test, manual verification - -**Files:** -- Create: `adapters/gemini-cli/gemini-extension.json` -- Create: `adapters/gemini-cli/README.md` -- Test: `tests/unit/test_gemini_cli_adapter.py` - -**Interfaces:** -- Consumes: nothing. -- Produces: nothing consumed by later tasks. - -- [ ] **Step 1: Write the failing test** - -```python -# tests/unit/test_gemini_cli_adapter.py -import json -from pathlib import Path - -MANIFEST = ( - Path(__file__).resolve().parent.parent.parent - / "adapters" / "gemini-cli" / "gemini-extension.json" -) - - -def test_manifest_is_valid_json_with_required_fields(): - data = json.loads(MANIFEST.read_text(encoding="utf-8")) - assert data["name"] == "adr-toolkit" - - -def test_manifest_name_uses_dashes_not_underscores_or_spaces(): - data = json.loads(MANIFEST.read_text(encoding="utf-8")) - assert " " not in data["name"] - assert "_" not in data["name"] -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: `python -m pytest tests/unit/test_gemini_cli_adapter.py -v` -Expected: FAIL — manifest doesn't exist. - -- [ ] **Step 3: Write the manifest and README** - -```json -// adapters/gemini-cli/gemini-extension.json -{ - "name": "adr-toolkit", - "version": "0.1.0", - "description": "Initialize, record, and check Architecture Decision Records by inspecting the repository and existing decisions before asking questions." -} -``` - -```markdown - -# Gemini CLI adapter - -Gemini CLI extensions declare themselves via a root `gemini-extension.json` -(see `geminicli.com/docs/extensions/reference/`), expecting a sibling -`skills/` directory holding `SKILL.md` files. - -## Install - -1. Symlink this repo's `skills/adr-toolkit/` package under this adapter's - directory: - - ```bash - mkdir -p adapters/gemini-cli/skills - ln -s "$(pwd)/skills/adr-toolkit" adapters/gemini-cli/skills/adr-toolkit - ``` - -2. Install the extension per Gemini CLI's own extension-install command - (`gemini extensions install ` at the time - this adapter was verified — confirm against `gemini --help` / - `gemini extensions --help`, since command surfaces move faster than - this file). -3. Confirm Gemini CLI lists `adr-toolkit` as an installed extension, then - run `python skills/adr-toolkit/scripts/adr.py preflight --json` from - your target repository to confirm the script layer works standalone. - -The symlink is created at install time, not committed to this repo — -committing a real symlink breaks on Windows checkouts that don't have -`core.symlinks` enabled, which this project's CI can't assume. -``` - -- [ ] **Step 4: Run test to verify it passes** - -Run: `python -m pytest tests/unit/test_gemini_cli_adapter.py -v` -Expected: PASS (2 tests) - -- [ ] **Step 5: Manual end-to-end verification** - -`gemini` CLI (0.46.0) is installed in this environment — actually perform this install and record the real transcript in your task report: - -```bash -mkdir -p /tmp/adr-toolkit-gemini-verify && cd /tmp/adr-toolkit-gemini-verify -git init -q -mkdir -p skills -ln -s "/skills/adr-toolkit" skills/adr-toolkit -cp "/adapters/gemini-cli/gemini-extension.json" gemini-extension.json -gemini extensions --help -# ^ read this output to find the actual install/list subcommand — do not -# guess; confirm the real syntax before running it. -# Then, whatever the real install command is, run it against this directory -# and confirm gemini lists adr-toolkit as an installed extension. Finally -# prove the symlinked skill runs correctly end to end: -python skills/adr-toolkit/scripts/adr.py preflight --json -python skills/adr-toolkit/scripts/adr.py init --dir docs/decisions --json -python skills/adr-toolkit/scripts/adr.py validate --dir docs/decisions --json -``` - -Record in the task report: the exact `gemini extensions --help` output relevant to install/discovery, whether Gemini CLI actually lists `adr-toolkit` after installing, and the `preflight`/`init`/`validate` JSON output. - -- [ ] **Step 6: Commit** - -```bash -git add adapters/gemini-cli tests/unit/test_gemini_cli_adapter.py -git commit -m "feat: add Gemini CLI adapter manifest and install docs" -``` - ---- - -### Task 6: Antigravity CLI adapter — manifest, README, structural test only - -**Files:** -- Create: `adapters/antigravity/plugin.json` -- Create: `adapters/antigravity/README.md` -- Test: `tests/unit/test_antigravity_adapter.py` - -**Interfaces:** -- Consumes: nothing. -- Produces: nothing consumed by later tasks. - -- [ ] **Step 1: Write the failing test** - -```python -# tests/unit/test_antigravity_adapter.py -import json -import re -from pathlib import Path - -MANIFEST = ( - Path(__file__).resolve().parent.parent.parent - / "adapters" / "antigravity" / "plugin.json" -) - -NAME_RE = re.compile(r"^[A-Za-z0-9_-]+$") - - -def test_manifest_is_valid_json_with_required_fields(): - data = json.loads(MANIFEST.read_text(encoding="utf-8")) - assert data["name"] == "adr-toolkit" - - -def test_manifest_name_matches_antigravity_naming_rule(): - data = json.loads(MANIFEST.read_text(encoding="utf-8")) - assert NAME_RE.match(data["name"]), "name must be alphanumeric, hyphens, or underscores only" - - -def test_manifest_schema_field_points_at_antigravity_schema(): - data = json.loads(MANIFEST.read_text(encoding="utf-8")) - assert data["$schema"] == "https://antigravity.google/schemas/v1/plugin.json" -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: `python -m pytest tests/unit/test_antigravity_adapter.py -v` -Expected: FAIL — manifest doesn't exist. - -- [ ] **Step 3: Write the manifest and README** - -```json -// adapters/antigravity/plugin.json -{ - "$schema": "https://antigravity.google/schemas/v1/plugin.json", - "name": "adr-toolkit", - "description": "Initialize, record, and check Architecture Decision Records by inspecting the repository and existing decisions before asking questions." -} -``` - -```markdown - -# Antigravity CLI adapter - -Antigravity plugins are a `plugin.json` marker file plus optional sibling -directories (`skills/`, `agents/`, `rules/`), per -`antigravity.google/docs/cli/plugins/`. This manifest needs only `name`. - -## Install - -1. Symlink this repo's `skills/adr-toolkit/` package under this adapter's - directory: - - ```bash - mkdir -p adapters/antigravity/skills - ln -s "$(pwd)/skills/adr-toolkit" adapters/antigravity/skills/adr-toolkit - ``` - -2. Install per Antigravity's own plugin-install documentation. -3. Confirm Antigravity lists `adr-toolkit`, then run - `python skills/adr-toolkit/scripts/adr.py preflight --json` from your - target repository to confirm the script layer works standalone. - -**Verification status:** this adapter's manifest and directory layout are -verified against Antigravity's published schema, but — unlike the Codex -and Gemini CLI adapters — no end-to-end install-and-run was performed, -because no `antigravity` CLI binary was available in the development -environment that built this adapter. Treat it as unverified until someone -with the actual CLI confirms it, and update this note when they do. - -The symlink is created at install time, not committed to this repo — -committing a real symlink breaks on Windows checkouts that don't have -`core.symlinks` enabled, which this project's CI can't assume. -``` - -- [ ] **Step 4: Run test to verify it passes** - -Run: `python -m pytest tests/unit/test_antigravity_adapter.py -v` -Expected: PASS (3 tests) - -- [ ] **Step 5: Commit** - -```bash -git add adapters/antigravity tests/unit/test_antigravity_adapter.py -git commit -m "feat: add Antigravity CLI adapter manifest (structural verification only)" -``` - ---- - -### Task 7: `scripts/sync_version.py` — repo-root version synchronization - -**Files:** -- Create: `scripts/sync_version.py` -- Test: `tests/unit/test_sync_version.py` - -**Interfaces:** -- Consumes: nothing (reads `VERSION` and manifest paths passed as arguments, for testability against fixtures rather than the real repo tree). -- Produces: `sync(version_file: Path, manifest_specs: list, check_only: bool) -> list` where `manifest_specs` is a list of `(path, json_key_path)` tuples, and the return value is the list of paths that were (or would be) changed; `sync_skill_md(version_file: Path, skill_md_path: Path, check_only: bool) -> bool` for the one non-JSON duplicate (`SKILL.md`'s YAML frontmatter `version:` line — `scripts/sync_version.py` lives at the repo root, outside `skills/adr-toolkit/`, so it cannot import that package's own `core.frontmatter` module without an ambiguous `scripts.*` import path collision between the two same-named `scripts/` directories; a small targeted regex substitution on just the `version:` line avoids that entirely and avoids re-serializing — and risking reformatting — the rest of the file). `main(argv=None) -> int` for CLI use. Used by Task 8 (CI wiring — invoked as a subprocess, not imported). - -- [ ] **Step 1: Write the failing test** - -```python -# tests/unit/test_sync_version.py -import json - -from scripts.sync_version import sync - -MANIFEST_SPECS = [("plugin.json", ["version"]), ("nested.json", ["meta", "version"])] - - -def _write(tmp_path, name, data): - path = tmp_path / name - path.write_text(json.dumps(data), encoding="utf-8") - return path - - -def test_sync_writes_matching_version_into_every_manifest(tmp_path): - version_file = tmp_path / "VERSION" - version_file.write_text("1.2.3\n", encoding="utf-8") - plugin = _write(tmp_path, "plugin.json", {"name": "x", "version": "0.0.0"}) - nested = _write(tmp_path, "nested.json", {"meta": {"version": "0.0.0"}}) - - changed = sync(version_file, [(plugin, ["version"]), (nested, ["meta", "version"])], check_only=False) - - assert json.loads(plugin.read_text())["version"] == "1.2.3" - assert json.loads(nested.read_text())["meta"]["version"] == "1.2.3" - assert set(changed) == {plugin, nested} - - -def test_sync_is_idempotent_when_already_synced(tmp_path): - version_file = tmp_path / "VERSION" - version_file.write_text("1.2.3\n", encoding="utf-8") - plugin = _write(tmp_path, "plugin.json", {"name": "x", "version": "1.2.3"}) - - changed = sync(version_file, [(plugin, ["version"])], check_only=False) - - assert changed == [] - - -def test_check_only_mode_reports_drift_without_writing(tmp_path): - version_file = tmp_path / "VERSION" - version_file.write_text("1.2.3\n", encoding="utf-8") - plugin = _write(tmp_path, "plugin.json", {"name": "x", "version": "0.0.0"}) - - changed = sync(version_file, [(plugin, ["version"])], check_only=True) - - assert changed == [plugin] - assert json.loads(plugin.read_text())["version"] == "0.0.0" - - -def test_manifest_missing_a_version_key_is_skipped_not_added(tmp_path): - version_file = tmp_path / "VERSION" - version_file.write_text("1.2.3\n", encoding="utf-8") - frontmatter_like = _write(tmp_path, "nokey.json", {"name": "x"}) - - changed = sync(version_file, [(frontmatter_like, ["version"])], check_only=False) - - assert changed == [] - assert "version" not in json.loads(frontmatter_like.read_text()) - - -def test_sync_skill_md_updates_frontmatter_version_line(tmp_path): - from scripts.sync_version import sync_skill_md - - version_file = tmp_path / "VERSION" - version_file.write_text("1.2.3\n", encoding="utf-8") - skill_md = tmp_path / "SKILL.md" - skill_md.write_text( - "---\nname: adr-toolkit\nversion: 0.0.0\n---\n\n# ADR Toolkit\n", encoding="utf-8", - ) - - changed = sync_skill_md(version_file, skill_md, check_only=False) - - assert changed is True - updated = skill_md.read_text(encoding="utf-8") - assert "version: 1.2.3" in updated - assert "name: adr-toolkit" in updated # untouched fields survive unchanged - - -def test_sync_skill_md_check_only_does_not_write(tmp_path): - from scripts.sync_version import sync_skill_md - - version_file = tmp_path / "VERSION" - version_file.write_text("1.2.3\n", encoding="utf-8") - skill_md = tmp_path / "SKILL.md" - skill_md.write_text("---\nversion: 0.0.0\n---\n\nBody\n", encoding="utf-8") - - changed = sync_skill_md(version_file, skill_md, check_only=True) - - assert changed is True - assert "version: 0.0.0" in skill_md.read_text(encoding="utf-8") - - -def test_sync_skill_md_idempotent_when_already_synced(tmp_path): - from scripts.sync_version import sync_skill_md - - version_file = tmp_path / "VERSION" - version_file.write_text("1.2.3\n", encoding="utf-8") - skill_md = tmp_path / "SKILL.md" - skill_md.write_text("---\nversion: 1.2.3\n---\n\nBody\n", encoding="utf-8") - - changed = sync_skill_md(version_file, skill_md, check_only=False) - - assert changed is False -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: `python -m pytest tests/unit/test_sync_version.py -v` -Expected: FAIL — module doesn't exist. - -- [ ] **Step 3: Write minimal implementation** - -```python -# scripts/sync_version.py -#!/usr/bin/env python3 -"""Sync skills/adr-toolkit/VERSION into every manifest that duplicates it. - -Repo tooling — not part of the distributable skills/adr-toolkit/ package. -""" -import argparse -import json -import re -import sys -from pathlib import Path - -REPO_ROOT = Path(__file__).resolve().parent.parent -VERSION_FILE = REPO_ROOT / "skills" / "adr-toolkit" / "VERSION" -SKILL_MD_PATH = REPO_ROOT / "skills" / "adr-toolkit" / "SKILL.md" - -# (manifest path relative to REPO_ROOT, key path within its JSON) -# Only manifests confirmed to carry their own "version" field are listed — -# a manifest with no such field is never modified to add one. -MANIFEST_SPECS = [ - (REPO_ROOT / ".claude-plugin" / "plugin.json", ["version"]), - (REPO_ROOT / "adapters" / "gemini-cli" / "gemini-extension.json", ["version"]), -] - -VERSION_LINE_RE = re.compile(r"^version:\s*\S+$", re.MULTILINE) - - -def sync(version_file: Path, manifest_specs: list, check_only: bool) -> list: - version = version_file.read_text(encoding="utf-8").strip() - changed = [] - for path, key_path in manifest_specs: - if not path.is_file(): - continue - data = json.loads(path.read_text(encoding="utf-8")) - target = data - for key in key_path[:-1]: - if key not in target: - target = None - break - target = target[key] - if target is None or key_path[-1] not in target: - continue - if target[key_path[-1]] == version: - continue - changed.append(path) - if not check_only: - target[key_path[-1]] = version - path.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8") - return changed - - -def sync_skill_md(version_file: Path, skill_md_path: Path, check_only: bool) -> bool: - if not skill_md_path.is_file(): - return False - version = version_file.read_text(encoding="utf-8").strip() - text = skill_md_path.read_text(encoding="utf-8") - match = VERSION_LINE_RE.search(text) - if match is None or match.group() == f"version: {version}": - return False - if not check_only: - new_text = VERSION_LINE_RE.sub(f"version: {version}", text, count=1) - skill_md_path.write_text(new_text, encoding="utf-8") - return True - - -def main(argv=None) -> int: - parser = argparse.ArgumentParser() - parser.add_argument("--check", action="store_true") - args = parser.parse_args(argv) - - changed = sync(VERSION_FILE, MANIFEST_SPECS, check_only=args.check) - if sync_skill_md(VERSION_FILE, SKILL_MD_PATH, check_only=args.check): - changed.append(SKILL_MD_PATH) - - if args.check and changed: - for path in changed: - print(f"drift: {path.relative_to(REPO_ROOT)}", file=sys.stderr) - return 1 - for path in changed: - print(f"synced: {path.relative_to(REPO_ROOT)}") - return 0 - - -if __name__ == "__main__": - sys.exit(main()) -``` - -- [ ] **Step 4: Run test to verify it passes** - -Run: `python -m pytest tests/unit/test_sync_version.py -v` -Expected: PASS (7 tests) - -- [ ] **Step 5: Commit** - -```bash -git add scripts/sync_version.py tests/unit/test_sync_version.py -git commit -m "feat: add version sync script for duplicated manifest version fields" -``` - ---- - -### Task 8: CI drift check + `release.yml` - -**Files:** -- Modify: `.github/workflows/test.yml` -- Create: `.github/workflows/release.yml` -- Test: none (GitHub Actions workflow YAML isn't unit-testable in this stack; reviewed by reading it against `test.yml`'s existing conventions, per spec §17.4) - -**Interfaces:** -- Consumes: `scripts/sync_version.py --check` (Task 7), invoked as a subprocess by CI, not imported. -- Produces: nothing consumed by a later task — this is the plan's last task. - -- [ ] **Step 1: Add the drift-check step to `test.yml`** - -In `.github/workflows/test.yml`, add a step after `Run tests` in the existing `pytest` job: - -```yaml - - name: Run tests - run: python -m pytest tests/unit tests/integration -v - - name: Check manifest versions are in sync - run: python scripts/sync_version.py --check -``` - -- [ ] **Step 2: Verify the check step behaves correctly, locally** - -Run: `python scripts/sync_version.py --check` -Expected: exit code 0, no output — the repo's real manifests are already in sync with `VERSION` (`0.1.0`), since Task 7 only reads/checks and Task 8 doesn't change any version value. - -- [ ] **Step 3: Write `release.yml`** - -```yaml -# .github/workflows/release.yml -name: release - -on: - push: - tags: - - "v*" - -jobs: - release: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-python@v5 - with: - python-version: "3.12" - - name: Install dependencies - run: pip install pytest - - name: Run tests - run: python -m pytest tests/unit tests/integration -v - - name: Verify manifest versions match the tag - run: python scripts/sync_version.py --check - - name: Create GitHub Release - uses: softprops/action-gh-release@v2 - with: - generate_release_notes: true -``` - -- [ ] **Step 4: Confirm the workflow is syntactically valid YAML** - -Run: `python -c "import yaml, sys; yaml.safe_load(open('.github/workflows/release.yml'))"` -Expected: no output, exit code 0 (`pyyaml` ships with most CI runner images; if it's not installed locally, `python -c "import json,sys; import yaml"` failing with `ModuleNotFoundError` is fine to note in the task report — the workflow's real validation happens when GitHub Actions parses it on the next tag push, which this task cannot trigger) - -- [ ] **Step 5: Run the full suite one last time and commit** - -Run: `python -m pytest tests/unit tests/integration -v` -Expected: PASS (all tests from Plans 1-4) - -```bash -git add .github/workflows/test.yml .github/workflows/release.yml -git commit -m "feat: add version-drift CI check and tag-triggered release workflow" -``` - ---- - -## Plan self-review notes - -- **Spec coverage:** §17.1 (i18n scope/mechanism) → Tasks 1, 2, 3. §17.2 (adapter formats, symlink pattern, manual verification split by CLI availability) → Tasks 4, 5, 6. §17.3 (version sync, release automation) → Tasks 7, 8. §17.4 (testing strategy) → each task's own test file; adapter manual-verification steps map to §17.4's "not an automated pytest case" note. §8/§9's original product framing is fully covered by §17's refinement, so no separate tasks needed beyond what §17 already resolves. -- **Type consistency checked:** `load_locale`'s return type (`dict`, Task 1) matches `index.py`'s `strings` parameter (Task 2) exactly, including the `status.` key convention used consistently in both the JSON files and `_render`'s lookup. `sync_version.sync`'s `(path, key_path)` tuple shape (Task 7) matches `MANIFEST_SPECS`'s own construction in the same file — no second, incompatible shape introduced elsewhere. No adapter task (4, 5, 6) is consumed by any other task, matching their "independent leaves" Interfaces note — verified none of Tasks 1-3 or 7-8 import anything from `adapters/`. -- **Self-caught during review:** the spec's §17.3 names three duplicate version locations (`VERSION`, `.claude-plugin/plugin.json`, `SKILL.md`'s frontmatter), but the plan's first draft of `MANIFEST_SPECS` covered only the two JSON manifests — `sync()`'s JSON-only design can't touch `SKILL.md`, which is markdown with YAML frontmatter, not JSON. Fixed by adding a separate `sync_skill_md()` using a targeted regex substitution on just the `version:` line, rather than importing `skills/adr-toolkit/`'s own `core.frontmatter` module (which would collide with `scripts/sync_version.py`'s own `scripts.*` import path — two same-named `scripts/` directories at different roots) or risking a full frontmatter re-serialize reformatting fields outside this task's scope. Also caught and fixed a regex subtlety in that same substitution: a trailing `\s*` before `$` combined with `\s`'s newline-matching under `re.MULTILINE` could have let the match creep across blank lines; the final pattern (`^version:\s*\S+$`, no trailing `\s*`) doesn't have that failure mode. -- **No placeholders found.** The adapter tasks' "read `codex --help`/`gemini extensions --help` before running the real subcommand" steps are deliberate — this plan cannot assert an unverified CLI flag as fact (that mistake already cost Plan 1 a fix round on the Claude Code adapter), so the concrete, runnable instruction is "investigate via `--help`, then use what it says," not a placeholder for missing code. diff --git a/docs/superpowers/plans/2026-08-30-adr-toolkit-record-and-lifecycle.md b/docs/superpowers/plans/2026-08-30-adr-toolkit-record-and-lifecycle.md deleted file mode 100644 index f93cb28..0000000 --- a/docs/superpowers/plans/2026-08-30-adr-toolkit-record-and-lifecycle.md +++ /dev/null @@ -1,1296 +0,0 @@ -# ADR Toolkit — RECORD + Lifecycle (Plan 2 of 4) Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Add the RECORD workflow (significance scoring, related-ADR search, forward-looking and retrospective-in-branch decision capture) and lifecycle operations (`status`, `supersede`, `deprecate`) on top of Plan 1's deterministic core. - -**Architecture:** Two new deterministic layers — `scripts/rules/significance.py` (pure arithmetic classification the agent feeds its own per-criterion judgments into) and three new `scripts/commands/*.py` modules (`related`, `significance`, `status`, `supersede`) plus a `deprecate` subcommand that is a thin alias over `status`. `SKILL.md` gains RECORD and Lifecycle sections describing how the agent orchestrates these scripts; no new agent-side judgment logic is hardcoded in Python. - -**Tech Stack:** Same as Plan 1 — Python 3.9+ standard library only, pytest. - -**Spec:** `docs/superpowers/specs/2026-08-29-adr-toolkit-design.md` (§6.1, §11 RECORD, §11 Lifecycle operations) - -## Global Constraints - -- Same as Plan 1: Python 3.9+ stdlib only; UTF-8 explicit; no `shell=True`; mutating commands (`status`, `supersede`) support `--dry-run` and never silently overwrite; command modules never `print()`; avoid `X | None` union syntax, use `typing.Optional`. -- `status`/`supersede`/`deprecate` write ADR frontmatter only through `core.frontmatter.serialize` + `core.lifecycle.validate_transition` — never hand-format YAML, never skip the transition check. -- `deprecate ` is not a new command module — it is an `adr.py` subparser that calls `commands.status.run` with `to="deprecated"` pre-set via `set_defaults`, avoiding duplicated logic. -- Out of scope for this plan (tracked in `project-roadmap.md` / Plan 3/4): CHECK workflow, i18n wiring, Codex/Gemini CLI/Antigravity CLI adapters, release automation. -- Every task must leave `python -m pytest tests/unit tests/integration -v` green before its commit step. - ---- - -### Task 1: `core/identifiers.find_by_number` — locate an ADR file by its numeric ID - -**Files:** -- Modify: `skills/adr-toolkit/scripts/core/identifiers.py` -- Test: `tests/unit/test_identifiers.py` (add cases) - -**Interfaces:** -- Consumes: nothing new. -- Produces: `find_by_number(adr_dir: Path, number: int) -> Optional[Path]`. Used by Tasks 6 (status) and 7 (supersede). - -- [ ] **Step 1: Write the failing test** - -Append to `tests/unit/test_identifiers.py`: - -```python -def test_find_by_number_locates_matching_file(tmp_path): - (tmp_path / "0003-use-kafka.md").write_text("x", encoding="utf-8") - found = identifiers.find_by_number(tmp_path, 3) - assert found is not None - assert found.name == "0003-use-kafka.md" - - -def test_find_by_number_returns_none_when_missing(tmp_path): - assert identifiers.find_by_number(tmp_path, 7) is None -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: `python -m pytest tests/unit/test_identifiers.py -v` -Expected: FAIL — `find_by_number` doesn't exist. - -- [ ] **Step 3: Add the function** - -Append to `skills/adr-toolkit/scripts/core/identifiers.py`: - -```python -def find_by_number(adr_dir: Path, number: int) -> Optional[Path]: - for entry in adr_dir.glob(f"{number:04d}-*.md"): - return entry - return None -``` - -- [ ] **Step 4: Run test to verify it passes** - -Run: `python -m pytest tests/unit/test_identifiers.py -v` -Expected: PASS (8 tests — 6 from Plan 1 plus these 2) - -- [ ] **Step 5: Commit** - -```bash -git add skills/adr-toolkit/scripts/core/identifiers.py tests/unit/test_identifiers.py -git commit -m "feat: add find_by_number for looking up an ADR by its ID" -``` - ---- - -### Task 2: `rules/significance.py` — deterministic significance classification - -**Files:** -- Create: `skills/adr-toolkit/scripts/rules/__init__.py` (empty) -- Create: `skills/adr-toolkit/scripts/rules/significance.py` -- Test: `tests/unit/test_significance.py` - -**Interfaces:** -- Consumes: nothing. -- Produces: `CRITERIA: tuple`, `score(criteria_scores: dict) -> int`, `classify(total: int) -> str`. Used by Task 3 (`commands/significance.py`). - -- [ ] **Step 1: Write the failing test** - -```python -# tests/unit/test_significance.py -import pytest - -from scripts.rules import significance - -FULL_SCORES = {c: 2 for c in significance.CRITERIA} -ZERO_SCORES = {c: 0 for c in significance.CRITERIA} - - -def test_score_sums_all_seven_criteria(): - assert significance.score(FULL_SCORES) == 14 - assert significance.score(ZERO_SCORES) == 0 - - -def test_score_defaults_missing_criteria_to_zero(): - assert significance.score({}) == 0 - - -def test_score_rejects_out_of_range_value(): - with pytest.raises(ValueError): - significance.score({"reversal_cost": 3}) - - -def test_classify_bands(): - assert significance.classify(0) == "not_needed" - assert significance.classify(3) == "not_needed" - assert significance.classify(4) == "optional" - assert significance.classify(6) == "optional" - assert significance.classify(7) == "recommended" - assert significance.classify(14) == "recommended" -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: `python -m pytest tests/unit/test_significance.py -v` -Expected: FAIL — module doesn't exist. - -- [ ] **Step 3: Write minimal implementation** - -```python -# skills/adr-toolkit/scripts/rules/significance.py -"""Deterministic significance classification for RECORD. - -The agent scores each of the 7 criteria itself (0, 1, or 2) based on its own -reading of the code and conversation — that judgment is not scriptable. This -module only sums and bands the result deterministically, so the same 7 -scores always produce the same recommendation. -""" - -CRITERIA = ( - "reversal_cost", - "alternatives_considered", - "quality_attribute_impact", - "boundary_or_pattern_change", - "multi_developer_relevance", - "ops_security_data_impact", - "future_rationale_query_likelihood", -) - - -def score(criteria_scores: dict) -> int: - total = 0 - for criterion in CRITERIA: - value = criteria_scores.get(criterion, 0) - if value not in (0, 1, 2): - raise ValueError(f"{criterion} must be 0, 1, or 2, got {value!r}") - total += value - return total - - -def classify(total: int) -> str: - if total <= 3: - return "not_needed" - if total <= 6: - return "optional" - return "recommended" -``` - -- [ ] **Step 4: Run test to verify it passes** - -Run: `python -m pytest tests/unit/test_significance.py -v` -Expected: PASS (4 tests) - -- [ ] **Step 5: Commit** - -```bash -git add skills/adr-toolkit/scripts/rules tests/unit/test_significance.py -git commit -m "feat: add deterministic significance scoring/classification" -``` - ---- - -### Task 3: `commands/significance.py` — CLI wrapper + wire into `adr.py` - -**Files:** -- Create: `skills/adr-toolkit/scripts/commands/significance.py` -- Modify: `skills/adr-toolkit/scripts/adr.py` -- Test: `tests/unit/test_significance_command.py`, `tests/integration/test_cli.py` (add one case) - -**Interfaces:** -- Consumes: `scripts.rules.significance.{score, classify}` (Task 2). -- Produces: `run(args) -> dict` where `args.input` is a path to a JSON file of per-criterion scores. Used by `SKILL.md`'s RECORD flow (Task 10) and the CLI. - -- [ ] **Step 1: Write the failing test** - -```python -# tests/unit/test_significance_command.py -import json -from types import SimpleNamespace - -from scripts.commands import significance - - -def test_significance_command_returns_total_and_classification(tmp_path): - input_path = tmp_path / "scores.json" - input_path.write_text(json.dumps({"reversal_cost": 2, "boundary_or_pattern_change": 2}), encoding="utf-8") - - result = significance.run(SimpleNamespace(input=str(input_path))) - - assert result["ok"] is True - assert result["total"] == 4 - assert result["classification"] == "optional" - - -def test_significance_command_rejects_bad_score(tmp_path): - input_path = tmp_path / "scores.json" - input_path.write_text(json.dumps({"reversal_cost": 9}), encoding="utf-8") - - result = significance.run(SimpleNamespace(input=str(input_path))) - - assert result["ok"] is False - assert result["errors"][0]["code"] == "INVALID_SCORE" -``` - -Append to `tests/integration/test_cli.py`: - -```python -def test_significance_via_cli(tmp_path): - scores_path = tmp_path / "scores.json" - scores_path.write_text('{"reversal_cost": 2, "quality_attribute_impact": 2}', encoding="utf-8") - result = _run(["significance", "--input", str(scores_path), "--json"], cwd=tmp_path) - payload = json.loads(result.stdout) - assert result.returncode == 0 - assert payload["total"] == 4 -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: `python -m pytest tests/unit/test_significance_command.py tests/integration/test_cli.py -v` -Expected: FAIL — `commands.significance` and the `significance` subcommand don't exist. - -- [ ] **Step 3: Write minimal implementation** - -```python -# skills/adr-toolkit/scripts/commands/significance.py -"""Deterministic CLI wrapper around scripts.rules.significance.""" -import json -from pathlib import Path - -from scripts.rules import significance - - -def run(args) -> dict: - criteria_scores = json.loads(Path(args.input).read_text(encoding="utf-8")) - - try: - total = significance.score(criteria_scores) - except ValueError as exc: - return { - "ok": False, - "operation": "significance", - "errors": [{"code": "INVALID_SCORE", "detail": str(exc)}], - } - - return { - "ok": True, - "operation": "significance", - "total": total, - "classification": significance.classify(total), - } -``` - -In `skills/adr-toolkit/scripts/adr.py`, add the import and subparser: - -```python -from scripts.commands import significance # add alongside existing command imports -``` - -```python - p_significance = sub.add_parser("significance") - p_significance.add_argument("--input", required=True) - p_significance.add_argument("--json", action="store_true") -``` - -Add `"significance": significance.run` to the `HANDLERS` dict. - -- [ ] **Step 4: Run test to verify it passes** - -Run: `python -m pytest tests/unit/test_significance_command.py tests/integration/test_cli.py -v` -Expected: PASS (2 new unit tests, 1 new integration test, all prior tests still green) - -- [ ] **Step 5: Commit** - -```bash -git add skills/adr-toolkit/scripts/commands/significance.py skills/adr-toolkit/scripts/adr.py \ - tests/unit/test_significance_command.py tests/integration/test_cli.py -git commit -m "feat: expose significance scoring as a CLI subcommand" -``` - ---- - -### Task 4: `commands/related.py` — related-ADR search + wire into `adr.py` - -**Files:** -- Create: `skills/adr-toolkit/scripts/commands/related.py` -- Modify: `skills/adr-toolkit/scripts/adr.py` -- Test: `tests/unit/test_related.py` - -**Interfaces:** -- Consumes: `scripts.core.frontmatter.parse` (Plan 1 Task 2), `scripts.core.identifiers.parse_filename` (Plan 1 Task 3). -- Produces: `run(args) -> dict` where `args` has optional `.paths` (list), `.tags` (list), `.keyword` (str), and `.dir`. Used by `SKILL.md`'s RECORD flow (Task 10). - -- [ ] **Step 1: Write the failing test** - -```python -# tests/unit/test_related.py -from types import SimpleNamespace - -from scripts.commands import related - -ADR_WITH_PATH = ( - "---\n" - "id: ADR-0001\n" - "title: Use Kafka for domain events\n" - "status: accepted\n" - "date: 2026-08-01\n" - "decision_makers: []\n" - "related: []\n" - "affected_paths:\n" - " - src/events/\n" - "tags:\n" - " - architecture\n" - "retrospective: false\n" - "---\n\n" - "# Use Kafka for domain events\n" -) - -ADR_UNRELATED = ADR_WITH_PATH.replace("0001", "0002").replace( - "Use Kafka for domain events", "Use Postgres" -).replace("src/events/", "src/db/").replace("architecture", "data") - - -def test_finds_match_by_affected_path(tmp_path): - (tmp_path / "0001-use-kafka.md").write_text(ADR_WITH_PATH, encoding="utf-8") - (tmp_path / "0002-use-postgres.md").write_text(ADR_UNRELATED, encoding="utf-8") - - result = related.run(SimpleNamespace(dir=str(tmp_path), paths=["src/events/"], tags=None, keyword=None)) - - assert result["ok"] is True - assert result["count"] == 1 - assert result["matches"][0]["id"] == "ADR-0001" - assert "affected_paths overlap" in result["matches"][0]["reasons"][0] - - -def test_finds_match_by_tag(tmp_path): - (tmp_path / "0002-use-postgres.md").write_text(ADR_UNRELATED, encoding="utf-8") - - result = related.run(SimpleNamespace(dir=str(tmp_path), paths=None, tags=["data"], keyword=None)) - - assert result["count"] == 1 - assert result["matches"][0]["id"] == "ADR-0002" - - -def test_no_match_returns_empty_list(tmp_path): - (tmp_path / "0001-use-kafka.md").write_text(ADR_WITH_PATH, encoding="utf-8") - - result = related.run(SimpleNamespace(dir=str(tmp_path), paths=["src/unrelated/"], tags=None, keyword=None)) - - assert result["count"] == 0 - assert result["matches"] == [] -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: `python -m pytest tests/unit/test_related.py -v` -Expected: FAIL — module doesn't exist. - -- [ ] **Step 3: Write minimal implementation** - -```python -# skills/adr-toolkit/scripts/commands/related.py -"""Find existing ADRs related to a set of paths, tags, or a keyword.""" -from pathlib import Path - -from scripts.core import frontmatter as fm -from scripts.core import identifiers - -SKIP_FILES = {"README.md", "adr-template.md"} - - -def run(args) -> dict: - adr_dir = Path(args.dir) - query_paths = set(getattr(args, "paths", None) or []) - query_tags = set(getattr(args, "tags", None) or []) - keyword = (getattr(args, "keyword", None) or "").lower() - - matches = [] - for entry in sorted(adr_dir.glob("*.md")): - if entry.name in SKIP_FILES or identifiers.parse_filename(entry.name) is None: - continue - - data, _ = fm.parse(entry.read_text(encoding="utf-8")) - reasons = [] - - path_overlap = query_paths & set(data.get("affected_paths", [])) - if path_overlap: - reasons.append(f"affected_paths overlap: {sorted(path_overlap)}") - - tag_overlap = query_tags & set(data.get("tags", [])) - if tag_overlap: - reasons.append(f"tag overlap: {sorted(tag_overlap)}") - - if keyword and keyword in data.get("title", "").lower(): - reasons.append("title keyword match") - - if reasons: - matches.append({ - "id": data.get("id"), - "filename": entry.name, - "title": data.get("title"), - "status": data.get("status"), - "reasons": reasons, - }) - - return {"ok": True, "operation": "related", "count": len(matches), "matches": matches} -``` - -In `scripts/adr.py`, add: - -```python -from scripts.commands import related # alongside other imports -``` - -```python - p_related = sub.add_parser("related") - p_related.add_argument("--paths", nargs="*") - p_related.add_argument("--tags", nargs="*") - p_related.add_argument("--keyword") - p_related.add_argument("--dir", default="docs/decisions") - p_related.add_argument("--json", action="store_true") -``` - -Add `"related": related.run` to `HANDLERS`. - -- [ ] **Step 4: Run test to verify it passes** - -Run: `python -m pytest tests/unit/test_related.py -v` -Expected: PASS (3 tests) - -- [ ] **Step 5: Commit** - -```bash -git add skills/adr-toolkit/scripts/commands/related.py skills/adr-toolkit/scripts/adr.py \ - tests/unit/test_related.py -git commit -m "feat: add related-ADR search by path/tag/keyword" -``` - ---- - -### Task 5: extend `core/schema.py` with optional `supersedes`/`superseded_by` - -**Files:** -- Modify: `skills/adr-toolkit/scripts/core/schema.py` -- Test: `tests/unit/test_schema.py` (add cases) - -**Interfaces:** -- Consumes: nothing new. -- Produces: `validate_frontmatter` now also accepts (but does not require) `supersedes: list` and `superseded_by: str`. Used by Task 7 (`commands/supersede.py`). - -- [ ] **Step 1: Write the failing test** - -Append to `tests/unit/test_schema.py`: - -```python -def test_optional_supersedes_field_is_allowed_when_a_list(): - data = dict(VALID, supersedes=["ADR-0002"]) - assert validate_frontmatter(data) == [] - - -def test_optional_supersedes_field_wrong_type_is_reported(): - data = dict(VALID, supersedes="ADR-0002") # should be a list - errors = validate_frontmatter(data) - assert any("supersedes" in e for e in errors) - - -def test_optional_superseded_by_field_is_allowed_when_a_string(): - data = dict(VALID, superseded_by="ADR-0009") - assert validate_frontmatter(data) == [] - - -def test_absence_of_optional_fields_is_not_an_error(): - assert validate_frontmatter(VALID) == [] # VALID has neither field, must still pass -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: `python -m pytest tests/unit/test_schema.py -v` -Expected: FAIL on the wrong-type case (currently silently ignored since the field isn't in `REQUIRED_FIELDS` at all — the "wrong type is reported" test is the one that actually fails; the others already pass by accident, which is the point of this task: make the check explicit rather than absent). - -- [ ] **Step 3: Extend the implementation** - -In `skills/adr-toolkit/scripts/core/schema.py`, add alongside `REQUIRED_FIELDS`: - -```python -OPTIONAL_FIELDS = { - "supersedes": list, - "superseded_by": str, -} -``` - -In `validate_frontmatter`, after the `REQUIRED_FIELDS` loop, add: - -```python - for field, expected_type in OPTIONAL_FIELDS.items(): - if field in data and not isinstance(data[field], expected_type): - errors.append( - f"field {field!r} must be {expected_type.__name__}, " - f"got {type(data[field]).__name__}" - ) -``` - -- [ ] **Step 4: Run test to verify it passes** - -Run: `python -m pytest tests/unit/test_schema.py -v` -Expected: PASS (10 tests — 6 from Plan 1 plus these 4) - -- [ ] **Step 5: Commit** - -```bash -git add skills/adr-toolkit/scripts/core/schema.py tests/unit/test_schema.py -git commit -m "feat: validate optional supersedes/superseded_by fields" -``` - ---- - -### Task 6: `commands/status.py` — validated status transition, plus `deprecate` alias - -**Files:** -- Create: `skills/adr-toolkit/scripts/commands/status.py` -- Modify: `skills/adr-toolkit/scripts/adr.py` -- Test: `tests/unit/test_status.py` - -**Interfaces:** -- Consumes: `scripts.core.identifiers.find_by_number` (Task 1), `scripts.core.frontmatter.{parse, serialize}` (Plan 1 Task 2), `scripts.core.lifecycle.{validate_transition, InvalidTransitionError}` (Plan 1 Task 4). -- Produces: `run(args) -> dict` where `args` has `.adr_number` (int), `.to` (str), `.dir`, optional `.dry_run`. Used by Task 7 (`supersede.py` reuses the same transition-checking pattern, not this module directly) and by `adr.py`'s `deprecate` subcommand (same function, `to="deprecated"` preset). - -- [ ] **Step 1: Write the failing test** - -```python -# tests/unit/test_status.py -from types import SimpleNamespace - -from scripts.commands import status - -ACCEPTED_ADR = ( - "---\n" - "id: ADR-0001\n" - "title: Use Kafka\n" - "status: proposed\n" - "date: 2026-08-01\n" - "decision_makers: []\n" - "related: []\n" - "affected_paths: []\n" - "tags: []\n" - "retrospective: false\n" - "---\n\n" - "# Use Kafka\n" -) - - -def test_valid_transition_updates_status(tmp_path): - (tmp_path / "0001-use-kafka.md").write_text(ACCEPTED_ADR, encoding="utf-8") - - result = status.run(SimpleNamespace(adr_number=1, to="accepted", dir=str(tmp_path), dry_run=False)) - - assert result["ok"] is True - updated = (tmp_path / "0001-use-kafka.md").read_text(encoding="utf-8") - assert "status: accepted" in updated - - -def test_invalid_transition_is_rejected(tmp_path): - (tmp_path / "0001-use-kafka.md").write_text(ACCEPTED_ADR, encoding="utf-8") - - result = status.run(SimpleNamespace(adr_number=1, to="superseded", dir=str(tmp_path), dry_run=False)) - - assert result["ok"] is False - assert result["errors"][0]["code"] == "INVALID_TRANSITION" - - -def test_missing_adr_is_reported(tmp_path): - result = status.run(SimpleNamespace(adr_number=99, to="accepted", dir=str(tmp_path), dry_run=False)) - - assert result["ok"] is False - assert result["errors"][0]["code"] == "ADR_NOT_FOUND" - - -def test_dry_run_does_not_write(tmp_path): - (tmp_path / "0001-use-kafka.md").write_text(ACCEPTED_ADR, encoding="utf-8") - - result = status.run(SimpleNamespace(adr_number=1, to="accepted", dir=str(tmp_path), dry_run=True)) - - assert result["ok"] is True - assert result["dry_run"] is True - unchanged = (tmp_path / "0001-use-kafka.md").read_text(encoding="utf-8") - assert "status: proposed" in unchanged -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: `python -m pytest tests/unit/test_status.py -v` -Expected: FAIL — module doesn't exist. - -- [ ] **Step 3: Write minimal implementation** - -```python -# skills/adr-toolkit/scripts/commands/status.py -"""Change an ADR's status through the deterministic lifecycle state machine.""" -from pathlib import Path - -from scripts.core import frontmatter as fm -from scripts.core import identifiers -from scripts.core.lifecycle import InvalidTransitionError, validate_transition - - -def run(args) -> dict: - adr_dir = Path(args.dir) - target_file = identifiers.find_by_number(adr_dir, args.adr_number) - - if target_file is None: - return { - "ok": False, - "operation": "status", - "errors": [{"code": "ADR_NOT_FOUND", "id": args.adr_number}], - } - - data, body = fm.parse(target_file.read_text(encoding="utf-8")) - - try: - validate_transition(data["status"], args.to) - except InvalidTransitionError as exc: - return { - "ok": False, - "operation": "status", - "errors": [{"code": "INVALID_TRANSITION", "detail": str(exc)}], - } - - if getattr(args, "dry_run", False): - return { - "ok": True, - "operation": "status", - "dry_run": True, - "would_update": str(target_file), - "to": args.to, - } - - data["status"] = args.to - target_file.write_text(fm.serialize(data, body.strip() + "\n"), encoding="utf-8") - - return {"ok": True, "operation": "status", "dry_run": False, "updated": str(target_file), "to": args.to} -``` - -In `scripts/adr.py`, add: - -```python -from scripts.commands import status # alongside other imports -``` - -```python - p_status = sub.add_parser("status") - p_status.add_argument("adr_number", type=int) - p_status.add_argument("--to", required=True) - p_status.add_argument("--dir", default="docs/decisions") - p_status.add_argument("--dry-run", dest="dry_run", action="store_true") - p_status.add_argument("--json", action="store_true") - - p_deprecate = sub.add_parser("deprecate") - p_deprecate.add_argument("adr_number", type=int) - p_deprecate.add_argument("--dir", default="docs/decisions") - p_deprecate.add_argument("--dry-run", dest="dry_run", action="store_true") - p_deprecate.add_argument("--json", action="store_true") - p_deprecate.set_defaults(to="deprecated") -``` - -Add both `"status": status.run` and `"deprecate": status.run` to `HANDLERS`. - -- [ ] **Step 4: Run test to verify it passes** - -Run: `python -m pytest tests/unit/test_status.py -v` -Expected: PASS (4 tests) - -- [ ] **Step 5: Commit** - -```bash -git add skills/adr-toolkit/scripts/commands/status.py skills/adr-toolkit/scripts/adr.py \ - tests/unit/test_status.py -git commit -m "feat: add status transition command with a deprecate alias" -``` - ---- - -### Task 7: `commands/supersede.py` — bidirectional supersede link - -**Files:** -- Create: `skills/adr-toolkit/scripts/commands/supersede.py` -- Modify: `skills/adr-toolkit/scripts/adr.py` -- Test: `tests/unit/test_supersede.py` - -**Interfaces:** -- Consumes: `scripts.core.identifiers.find_by_number` (Task 1), `scripts.core.frontmatter.{parse, serialize}` (Plan 1 Task 2), `scripts.core.lifecycle.{validate_transition, InvalidTransitionError}` (Plan 1 Task 4), the `supersedes`/`superseded_by` fields validated by Task 5. -- Produces: `run(args) -> dict` where `args` has `.adr_number` (the old ADR, int), `.by` (the new ADR, int), `.dir`, optional `.dry_run`. - -- [ ] **Step 1: Write the failing test** - -```python -# tests/unit/test_supersede.py -from types import SimpleNamespace - -from scripts.commands import supersede - -OLD_ADR = ( - "---\n" - "id: ADR-0001\n" - "title: Use RabbitMQ\n" - "status: accepted\n" - "date: 2026-08-01\n" - "decision_makers: []\n" - "related: []\n" - "affected_paths: []\n" - "tags: []\n" - "retrospective: false\n" - "---\n\n" - "# Use RabbitMQ\n" -) - -NEW_ADR = ( - "---\n" - "id: ADR-0002\n" - "title: Use Kafka\n" - "status: accepted\n" - "date: 2026-08-15\n" - "decision_makers: []\n" - "related: []\n" - "affected_paths: []\n" - "tags: []\n" - "retrospective: false\n" - "---\n\n" - "# Use Kafka\n" -) - - -def test_supersede_updates_both_files(tmp_path): - (tmp_path / "0001-use-rabbitmq.md").write_text(OLD_ADR, encoding="utf-8") - (tmp_path / "0002-use-kafka.md").write_text(NEW_ADR, encoding="utf-8") - - result = supersede.run(SimpleNamespace(adr_number=1, by=2, dir=str(tmp_path), dry_run=False)) - - assert result["ok"] is True - old_text = (tmp_path / "0001-use-rabbitmq.md").read_text(encoding="utf-8") - new_text = (tmp_path / "0002-use-kafka.md").read_text(encoding="utf-8") - assert "status: superseded" in old_text - assert "superseded_by: ADR-0002" in old_text - assert "ADR-0001" in new_text # appears in the new ADR's supersedes list - - -def test_supersede_missing_old_adr_is_reported(tmp_path): - (tmp_path / "0002-use-kafka.md").write_text(NEW_ADR, encoding="utf-8") - - result = supersede.run(SimpleNamespace(adr_number=1, by=2, dir=str(tmp_path), dry_run=False)) - - assert result["ok"] is False - assert result["errors"][0]["code"] == "ADR_NOT_FOUND" - - -def test_supersede_dry_run_writes_nothing(tmp_path): - (tmp_path / "0001-use-rabbitmq.md").write_text(OLD_ADR, encoding="utf-8") - (tmp_path / "0002-use-kafka.md").write_text(NEW_ADR, encoding="utf-8") - - result = supersede.run(SimpleNamespace(adr_number=1, by=2, dir=str(tmp_path), dry_run=True)) - - assert result["dry_run"] is True - unchanged = (tmp_path / "0001-use-rabbitmq.md").read_text(encoding="utf-8") - assert "status: accepted" in unchanged -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: `python -m pytest tests/unit/test_supersede.py -v` -Expected: FAIL — module doesn't exist. - -- [ ] **Step 3: Write minimal implementation** - -```python -# skills/adr-toolkit/scripts/commands/supersede.py -"""Mark one ADR as superseded by another, updating both files' frontmatter.""" -from pathlib import Path - -from scripts.core import frontmatter as fm -from scripts.core import identifiers -from scripts.core.lifecycle import InvalidTransitionError, validate_transition - - -def run(args) -> dict: - adr_dir = Path(args.dir) - old_file = identifiers.find_by_number(adr_dir, args.adr_number) - new_file = identifiers.find_by_number(adr_dir, args.by) - - if old_file is None: - return {"ok": False, "operation": "supersede", "errors": [{"code": "ADR_NOT_FOUND", "id": args.adr_number}]} - if new_file is None: - return {"ok": False, "operation": "supersede", "errors": [{"code": "ADR_NOT_FOUND", "id": args.by}]} - - old_data, old_body = fm.parse(old_file.read_text(encoding="utf-8")) - - try: - validate_transition(old_data["status"], "superseded") - except InvalidTransitionError as exc: - return {"ok": False, "operation": "supersede", "errors": [{"code": "INVALID_TRANSITION", "detail": str(exc)}]} - - if getattr(args, "dry_run", False): - return { - "ok": True, - "operation": "supersede", - "dry_run": True, - "would_update": [str(old_file), str(new_file)], - } - - new_data, new_body = fm.parse(new_file.read_text(encoding="utf-8")) - - old_data["status"] = "superseded" - old_data["superseded_by"] = new_data["id"] - old_file.write_text(fm.serialize(old_data, old_body.strip() + "\n"), encoding="utf-8") - - supersedes_list = new_data.get("supersedes", []) - if old_data["id"] not in supersedes_list: - supersedes_list.append(old_data["id"]) - new_data["supersedes"] = supersedes_list - new_file.write_text(fm.serialize(new_data, new_body.strip() + "\n"), encoding="utf-8") - - return { - "ok": True, - "operation": "supersede", - "dry_run": False, - "old": old_data["id"], - "new": new_data["id"], - } -``` - -In `scripts/adr.py`, add: - -```python -from scripts.commands import supersede # alongside other imports -``` - -```python - p_supersede = sub.add_parser("supersede") - p_supersede.add_argument("adr_number", type=int) - p_supersede.add_argument("--by", type=int, required=True) - p_supersede.add_argument("--dir", default="docs/decisions") - p_supersede.add_argument("--dry-run", dest="dry_run", action="store_true") - p_supersede.add_argument("--json", action="store_true") -``` - -Add `"supersede": supersede.run` to `HANDLERS`. - -- [ ] **Step 4: Run test to verify it passes** - -Run: `python -m pytest tests/unit/test_supersede.py -v` -Expected: PASS (3 tests) - -- [ ] **Step 5: Commit** - -```bash -git add skills/adr-toolkit/scripts/commands/supersede.py skills/adr-toolkit/scripts/adr.py \ - tests/unit/test_supersede.py -git commit -m "feat: add supersede command with bidirectional link update" -``` - ---- - -### Task 8: `references/significance-rules.md` - -**Files:** -- Create: `skills/adr-toolkit/references/significance-rules.md` -- Test: `tests/unit/test_significance_reference.py` - -**Interfaces:** -- Consumes: nothing (content only, read by the agent during RECORD). -- Produces: the human/agent-readable explanation of the 7 criteria `rules/significance.py` (Task 2) scores arithmetically. - -- [ ] **Step 1: Write the failing test** - -```python -# tests/unit/test_significance_reference.py -from pathlib import Path - -REFERENCE = ( - Path(__file__).resolve().parent.parent.parent - / "skills" / "adr-toolkit" / "references" / "significance-rules.md" -) - - -def test_reference_documents_all_seven_criteria(): - text = REFERENCE.read_text(encoding="utf-8") - for criterion in [ - "reversal_cost", "alternatives_considered", "quality_attribute_impact", - "boundary_or_pattern_change", "multi_developer_relevance", - "ops_security_data_impact", "future_rationale_query_likelihood", - ]: - assert criterion in text - - -def test_reference_documents_the_three_bands(): - text = REFERENCE.read_text(encoding="utf-8") - assert "not_needed" in text - assert "optional" in text - assert "recommended" in text -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: `python -m pytest tests/unit/test_significance_reference.py -v` -Expected: FAIL — file doesn't exist. - -- [ ] **Step 3: Write the content** - -```markdown - -# Significance Scoring Reference - -RECORD scores a candidate decision against 7 criteria, each 0/1/2, then -calls `python scripts/adr.py significance --input scores.json --json` to -get a deterministic band. The agent decides each score; the script only -sums and bands them — same inputs always produce the same recommendation. - -## The 7 criteria - -- `reversal_cost` — how expensive would it be to undo this later? -- `alternatives_considered` — were there multiple realistic options, or - effectively one obvious choice? -- `quality_attribute_impact` — does this meaningfully affect performance, - reliability, security, or another quality attribute? -- `boundary_or_pattern_change` — does this change a system boundary or a - pattern other code is expected to follow? -- `multi_developer_relevance` — will other developers (or agents) need to - follow this going forward? -- `ops_security_data_impact` — does this affect operations, security, or - data consistency? -- `future_rationale_query_likelihood` — is someone likely to ask "why did - we do it this way" months from now? - -Score each 0 (no), 1 (somewhat), or 2 (clearly yes) based on the actual -code and conversation — never guess a score to force a particular band. - -## Bands - -- 0–3: `not_needed` — recommend a commit message or code comment instead. -- 4–6: `optional` — mention the option to the user, let them decide. -- 7–14: `recommended` — proceed to drafting an ADR. - -The score is a decision aid, not a verdict the user can't override — if a -user insists on recording something scored `not_needed`, defer to them -rather than refusing. -``` - -- [ ] **Step 4: Run test to verify it passes** - -Run: `python -m pytest tests/unit/test_significance_reference.py -v` -Expected: PASS (2 tests) - -- [ ] **Step 5: Commit** - -```bash -git add skills/adr-toolkit/references/significance-rules.md tests/unit/test_significance_reference.py -git commit -m "docs: add significance scoring reference" -``` - ---- - -### Task 9: `references/interview-guide.md` - -**Files:** -- Create: `skills/adr-toolkit/references/interview-guide.md` -- Test: `tests/unit/test_interview_guide.py` - -**Interfaces:** -- Consumes: nothing (content only). -- Produces: the question-asking policy RECORD (and DISCOVER) follow. - -- [ ] **Step 1: Write the failing test** - -```python -# tests/unit/test_interview_guide.py -from pathlib import Path - -REFERENCE = ( - Path(__file__).resolve().parent.parent.parent - / "skills" / "adr-toolkit" / "references" / "interview-guide.md" -) - - -def test_guide_states_the_three_question_cap(): - text = REFERENCE.read_text(encoding="utf-8") - assert "3" in text and "question" in text.lower() - - -def test_guide_lists_things_not_to_ask(): - text = REFERENCE.read_text(encoding="utf-8") - assert "## What not to ask" in text -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: `python -m pytest tests/unit/test_interview_guide.py -v` -Expected: FAIL — file doesn't exist. - -- [ ] **Step 3: Write the content** - -```markdown - -# Interview Guide - -RECORD and DISCOVER ask at most 3 questions per round. Fewer is better — -never ask a question `discover`/`related`/the repository already answered. - -## Priority order (ask the highest-priority unanswered question first) - -1. What problem or constraint made this decision necessary? -2. What realistic alternatives were considered? -3. Why was this option chosen over the others — what was the primary driver? -4. What negative consequence was knowingly accepted? -5. What condition should cause this decision to be revisited? - -## What not to ask - -- A library name or version already visible in a dependency manifest — - `discover`/`related` already reported it. -- A policy already stated in an existing ADR — cite it instead. -- A preference with no effect on the file that gets written (e.g. writing - style opinions). -- Anything the user already stated in their own request. - -## If the user's answer is ambiguous - -Ask one focused follow-up rather than guessing — but that follow-up still -counts against the 3-question cap for the round. -``` - -- [ ] **Step 4: Run test to verify it passes** - -Run: `python -m pytest tests/unit/test_interview_guide.py -v` -Expected: PASS (2 tests) - -- [ ] **Step 5: Commit** - -```bash -git add skills/adr-toolkit/references/interview-guide.md tests/unit/test_interview_guide.py -git commit -m "docs: add interview question-asking guide" -``` - ---- - -### Task 10: `SKILL.md` — RECORD and Lifecycle sections - -**Files:** -- Modify: `skills/adr-toolkit/SKILL.md` -- Test: `tests/unit/test_skill_manifest.py` (add cases) - -**Interfaces:** -- Consumes: all commands built in Tasks 1–9, plus Plan 1's `create`/`validate`/`index`. -- Produces: the RECORD and Lifecycle operation contracts an agent follows. - -- [ ] **Step 1: Write the failing test** - -Append to `tests/unit/test_skill_manifest.py`: - -```python -def test_skill_md_documents_record_and_lifecycle(): - _, body = fm.parse(SKILL_MD.read_text(encoding="utf-8")) - assert "## RECORD" in body - assert "## Lifecycle operations" in body - assert "significance" in body - assert "adr.py supersede" in body - assert "adr.py deprecate" in body -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: `python -m pytest tests/unit/test_skill_manifest.py -v` -Expected: FAIL — sections don't exist yet. - -- [ ] **Step 3: Add the sections** - -Insert into `skills/adr-toolkit/SKILL.md`, after the `## What belongs in an ADR` section and before `## Prohibited`: - -```markdown -## RECORD - -Use RECORD both before implementing something (a forward-looking question) -and after (e.g. "find what in this branch should become an ADR" — same -flow, the trigger is a diff/branch range instead of a question). - -1. **PREFLIGHT** — confirm an ADR directory exists (`preflight --json`); if - not, tell the user to run INIT first. -2. **GATHER EVIDENCE** — run - `python scripts/adr.py related --paths --tags --json` - to find existing ADRs that might already cover this, or that this new - decision might conflict with or supersede. -3. **CLASSIFY** — use the `## What belongs in an ADR` table. Score the - candidate against the 7 criteria in `references/significance-rules.md`, - write the scores to a JSON file, and run - `python scripts/adr.py significance --input scores.json --json`. - - `not_needed` → recommend a commit message or code comment instead; - do not draft an ADR unless the user insists. - - `optional` → mention the option, let the user decide. - - `recommended` → continue. -4. **ASK-IF-NEEDED** — at most 3 questions, following - `references/interview-guide.md`'s priority order. Skip anything - `related`/`discover`/the user's own request already answered. -5. **PLAN** — draft a MADR body (minimal or full, per - `references/madr-guide.md`). If this decision replaces an existing - Accepted ADR, note which one — that becomes a `supersede` call after - approval, not a manual edit. -6. **CONFIRM** — show title, problem, options, decision, primary driver, - accepted downside, and affected paths before writing anything. -7. **MUTATE** — write the draft JSON and run - `python scripts/adr.py create --input --dir docs/decisions`. - If this decision supersedes an existing one, follow with - `python scripts/adr.py supersede --by ` only - after the user confirms that's intended. -8. **VALIDATE** — `validate --json` then `index --json`. -9. **REPORT** — facts found, judgment (including the significance score), - questions asked, files created/updated, validation result, remaining - uncertainty. - -## Lifecycle operations - -Status changes are explicit, user-triggered, and always go through the -deterministic transition check — never hand-edit a `status:` field. - -| User says | Run | -|---|---| -| "이 ADR을 승인 상태로 변경해줘" / "mark this ADR accepted" | `python scripts/adr.py status --to accepted` | -| "ADR-0012는 ADR-0021로 대체됐어" / "this supersedes ADR-0012" | `python scripts/adr.py supersede 12 --by 21` | -| "이 결정은 더 이상 적용되지 않아" / "deprecate this ADR" | `python scripts/adr.py deprecate ` | - -Show the user the change (old status → new status, or which two ADRs link) -before running the non-dry-run command. If the script returns -`INVALID_TRANSITION`, explain why (per `references/lifecycle.md`) rather -than retrying with a different status. -``` - -- [ ] **Step 4: Run test to verify it passes** - -Run: `python -m pytest tests/unit/test_skill_manifest.py -v` -Expected: PASS (all prior cases plus this one) - -- [ ] **Step 5: Commit** - -```bash -git add skills/adr-toolkit/SKILL.md tests/unit/test_skill_manifest.py -git commit -m "feat: document RECORD and Lifecycle operations in SKILL.md" -``` - ---- - -### Task 11: End-to-end RECORD fixture and golden test - -**Files:** -- Create: `tests/fixtures/record_existing_adr/docs/decisions/0001-use-rabbitmq.md` -- Create: `tests/integration/test_record_workflow.py` - -**Interfaces:** -- Consumes: `related`, `significance`, `create`, `validate`, `index` via subprocess (Tasks 3, 4, and Plan 1's `create`/`validate`/`index`). -- Produces: the golden proof that RECORD's scripted steps compose correctly against a repo with pre-existing ADRs. - -- [ ] **Step 1: Write the failing test** - -```markdown - ---- -id: ADR-0001 -title: Use RabbitMQ for async processing -status: accepted -date: 2026-07-01 -decision_makers: [] -related: [] -affected_paths: - - src/queue/ -tags: - - architecture -retrospective: false ---- - -# Use RabbitMQ for async processing - -Body. -``` - -```python -# tests/integration/test_record_workflow.py -import json -import shutil -import subprocess -import sys -from pathlib import Path - -FIXTURE = Path(__file__).resolve().parent.parent / "fixtures" / "record_existing_adr" -ADR_PY = Path(__file__).resolve().parent.parent.parent / "skills" / "adr-toolkit" / "scripts" / "adr.py" - - -def _run(args, cwd): - result = subprocess.run([sys.executable, str(ADR_PY), *args], cwd=cwd, capture_output=True, text=True) - assert result.returncode in (0, 1), result.stderr - return json.loads(result.stdout) - - -def test_record_finds_related_scores_and_creates_new_adr(tmp_path): - repo = tmp_path / "repo" - shutil.copytree(FIXTURE, repo) - - related_result = _run(["related", "--tags", "architecture", "--dir", "docs/decisions", "--json"], cwd=repo) - assert related_result["count"] == 1 - assert related_result["matches"][0]["id"] == "ADR-0001" - - scores_path = repo / "scores.json" - scores_path.write_text(json.dumps({ - "reversal_cost": 2, "boundary_or_pattern_change": 2, - "multi_developer_relevance": 2, "ops_security_data_impact": 1, - }), encoding="utf-8") - sig_result = _run(["significance", "--input", "scores.json", "--json"], cwd=repo) - assert sig_result["classification"] == "recommended" - - draft_path = repo / "draft.json" - draft_path.write_text(json.dumps({ - "title": "Use Kafka instead of RabbitMQ", - "status": "accepted", - "body": "# Use Kafka instead of RabbitMQ\n\nBody.\n", - "related": ["ADR-0001"], - }), encoding="utf-8") - create_result = _run(["create", "--input", "draft.json", "--dir", "docs/decisions", "--json"], cwd=repo) - assert create_result["id"] == "ADR-0002" - - validate_result = _run(["validate", "--dir", "docs/decisions", "--json"], cwd=repo) - assert validate_result["ok"] is True - assert validate_result["checked"] == 2 - - supersede_result = _run(["supersede", "1", "--by", "2", "--dir", "docs/decisions", "--json"], cwd=repo) - assert supersede_result["ok"] is True - - old_text = (repo / "docs" / "decisions" / "0001-use-rabbitmq.md").read_text(encoding="utf-8") - assert "status: superseded" in old_text - assert "superseded_by: ADR-0002" in old_text -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: `python -m pytest tests/integration/test_record_workflow.py -v` -Expected: FAIL — fixture doesn't exist until Step 1's file is created. - -- [ ] **Step 3: Nothing further to implement** - -This task's "implementation" is the fixture itself. No production code changes — it proves Tasks 1–10 already compose correctly end to end. - -- [ ] **Step 4: Run test to verify it passes** - -Run: `python -m pytest tests/integration/test_record_workflow.py -v` -Expected: PASS (1 test) - -- [ ] **Step 5: Commit** - -```bash -git add tests/fixtures/record_existing_adr tests/integration/test_record_workflow.py -git commit -m "test: add end-to-end RECORD/supersede fixture and golden test" -``` - ---- - -## Plan self-review notes - -- **Spec coverage:** §11 RECORD → Tasks 2, 3, 4, 8, 9, 10, 11; §11 Lifecycle operations → Tasks 1, 5, 6, 7, 10; §6.1 (what belongs in an ADR, reused by RECORD) → Task 10 (references it, no new implementation needed — already built in Plan 1). CHECK, i18n, and the remaining harness adapters are explicitly out of scope — Plans 3 and 4. -- **Type consistency checked:** `identifiers.find_by_number` signature (Task 1) matches its use in Tasks 6 and 7. `schema.validate_frontmatter`'s new optional-field handling (Task 5) doesn't change its existing signature or break any Plan 1 caller. `status.run` is reused unmodified as the `deprecate` handler via `set_defaults(to="deprecated")`, not a second copy of the transition logic. -- **No placeholders found.** - -## Execution status - -- Completed on 2026-08-30 via subagent-driven-development: all 11 tasks were - implemented and task-reviewed, including fix rounds for issues found during - review. The resulting unit and integration suite has 105 passing tests. -- The remaining user decisions are the public-release license and whether the - final MVP retains five languages and four named harnesses. Neither blocked - Plan 2. -- Codex `quick_validate.py` compatibility with the existing cross-harness - `user-invocable` and `version` skill frontmatter keys remains tracked in - `improvements.md` as a metadata/validator compatibility risk. diff --git a/examples/README.md b/examples/README.md new file mode 100644 index 0000000..0ab1540 --- /dev/null +++ b/examples/README.md @@ -0,0 +1,11 @@ +# Examples + +- [`quickstart.md`](quickstart.md) — INIT a repo, RECORD a decision with a + machine-checkable constraint, then watch CHECK catch a real violation of + it and clear once the code is fixed. Every command and its output in + this walkthrough was actually run, not written from memory — copy any + command and it will do exactly what's shown. + +For a second, larger example, this repository dogfoods itself: see +[`../docs/decisions/`](../docs/decisions/) for the real ADRs recording the +architectural decisions made while building this toolkit. diff --git a/examples/quickstart.md b/examples/quickstart.md new file mode 100644 index 0000000..f5e3cbf --- /dev/null +++ b/examples/quickstart.md @@ -0,0 +1,216 @@ +# Quickstart: INIT → RECORD → CHECK + +This walks through the three core operations against a small example +service, from an empty repo to CHECK catching a real rule violation. Every +command and JSON block below is real output from actually running these +commands — nothing here is hypothetical. + +All commands assume `skills/adr-toolkit/` is on your path somehow (see the +root [README](../README.md) for per-harness install, or just call the +script directly as shown here: `python skills/adr-toolkit/scripts/adr.py`). + +## Setup + +A tiny Node service with no ADRs yet: + +```text +example-service/ +├── package.json +└── src/db/connection.js +``` + +## 1. INIT — scaffold the ADR directory + +```bash +python skills/adr-toolkit/scripts/adr.py preflight --json +``` + +```json +{ + "ok": true, + "operation": "preflight", + "python_version": "3.9.6", + "git_available": true, + "existing_adr_directory": null, + "warnings": [], + "errors": [] +} +``` + +`existing_adr_directory` is `null`, so it's safe to scaffold: + +```bash +python skills/adr-toolkit/scripts/adr.py init --dir docs/decisions --json +``` + +```json +{ + "ok": true, + "operation": "init", + "dry_run": false, + "created": [ + "docs/decisions", + "docs/decisions/adr-template.md", + "docs/decisions/0001-record-architecture-decisions.md" + ] +} +``` + +## 2. RECORD — capture a decision with a checkable rule + +Before drafting, check for anything already covering this area: + +```bash +python skills/adr-toolkit/scripts/adr.py related \ + --paths src/db/ --tags database persistence --dir docs/decisions --json +``` + +```json +{"ok": true, "operation": "related", "count": 0, "matches": [], "warnings": []} +``` + +Nothing exists yet. Score the decision's significance against the seven +criteria (`references/significance-rules.md` explains each one) and check +whether it clears the bar for recording at all: + +```bash +python skills/adr-toolkit/scripts/adr.py significance --input scores.json --json +``` + +```json +{"ok": true, "operation": "significance", "total": 12, "classification": "recommended"} +``` + +`recommended` — draft it. This decision also gets a structured +`constraints:` block in its Implementation Constraints section, so CHECK +can enforce it mechanically later, not just document it in prose: + +````markdown +## Implementation Constraints + +```yaml +constraints: + - id: no-mongodb-driver + kind: forbidden_import + paths: ["src/db/**"] + pattern: ["mongodb", "mongoose"] + severity: major + message: "This service uses PostgreSQL for persistence (see this ADR) — do not add a MongoDB driver." +``` +```` + +Write the full draft (title, body, tags, `affected_paths`) to a JSON file +and create it: + +```bash +python skills/adr-toolkit/scripts/adr.py create --input draft.json --dir docs/decisions --json +``` + +```json +{ + "ok": true, + "operation": "create", + "dry_run": false, + "created": "docs/decisions/0002-use-postgresql-for-persistence.md", + "id": "ADR-0002" +} +``` + +Then validate and regenerate the index: + +```bash +python skills/adr-toolkit/scripts/adr.py validate --dir docs/decisions --json +python skills/adr-toolkit/scripts/adr.py index --dir docs/decisions --locale en --json +``` + +Both report `"ok": true`, and `docs/decisions/README.md` now reads: + +```markdown +# Decision Log + +## By status + +### Accepted +- [ADR-0001 — Record architecture decisions](0001-record-architecture-decisions.md) +- [ADR-0002 — Use PostgreSQL for persistence](0002-use-postgresql-for-persistence.md) + +## By tag + +### database +- [ADR-0002 — Use PostgreSQL for persistence](0002-use-postgresql-for-persistence.md) +... +``` + +## 3. CHECK — catch a real violation + +Commit the ADR, then a developer (unaware of it) adds a MongoDB driver: + +```js +// src/db/connection.js +const mongodb = require("mongodb"); + +module.exports = { connect: () => mongodb.connect(process.env.DB_URL) }; +``` + +```bash +python skills/adr-toolkit/scripts/adr.py check --uncommitted --dir docs/decisions --json +``` + +```json +{ + "ok": true, + "operation": "check", + "diff": {"mode": "uncommitted", "ref": null, "files_changed": 1}, + "findings": [ + { + "rule_id": "no-mongodb-driver", + "kind": "verified_violation", + "severity": "major", + "message": "This service uses PostgreSQL for persistence (see this ADR) — do not add a MongoDB driver.", + "file": "src/db/connection.js", + "evidence": {"line": "const mongodb = require(\"mongodb\");", "pattern": "mongodb"}, + "adr_id": "ADR-0002", + "resolutions": ["fix_code", "supersede_adr", "adjust_scope", "register_exception", "false_positive"] + } + ], + "warnings": [] +} +``` + +CHECK never picks a resolution for you — it always hands back all five and +lets a human decide. Here the developer picks `fix_code`: + +```js +// src/db/connection.js +const { Pool } = require("pg"); + +module.exports = { pool: new Pool({ connectionString: process.env.DB_URL }) }; +``` + +```bash +python skills/adr-toolkit/scripts/adr.py check --uncommitted --dir docs/decisions --json +``` + +```json +{ + "ok": true, + "operation": "check", + "diff": {"mode": "uncommitted", "ref": null, "files_changed": 1}, + "findings": [{"adr_id": "ADR-0002", "kind": "related"}], + "warnings": [] +} +``` + +The violation is gone — `related` means the diff still touches a path this +ADR governs, but the rule that fired before no longer does. + +## Next steps + +- `--since ` and `--staged` are the other two `check`/`diff` range + modes, for a branch/commit range or a staged-changes review — see + `SKILL.md`'s CHECK section. +- `references/conflict-rules.md` documents all six `constraints:` rule + kinds (`forbidden_import` was the only one used here) and the four-way + finding classification in full. +- No AI harness at all? `create --interactive` runs the same interview in + a plain terminal — see the root [README](../README.md). diff --git a/improvements.md b/improvements.md index 5f6223d..59254f8 100644 --- a/improvements.md +++ b/improvements.md @@ -12,11 +12,6 @@ the current session. — left over from before the local rename to `feat/adr-toolkit-mvp-implement`; delete it on `origin` once confirmed it's not needed (e.g. no open PR references it). -- [ ] **Write a real root `README.md`.** Still a one-line stub at - MVP-complete. Should point a new user at the four - `adapters/*/README.md` install guides and at `create --interactive`'s - no-agent-needed path. Do this before any public release, alongside the - license decision below. - [ ] `scripts/adr.py`'s `--json` flag is parsed on every subcommand but never read (output is always JSON regardless). Either implement a non-JSON mode or drop the flag.