From 418fd76b3514504bca0506266fab8530d15488a0 Mon Sep 17 00:00:00 2001 From: shcommit Date: Sun, 30 Aug 2026 16:12:36 +0900 Subject: [PATCH 01/76] docs: dogfood the ADR toolkit on its own repo Initialize docs/decisions/ via the toolkit's own INIT command, then record the four most significant architectural decisions made while building it, via RECORD (evidence gathering, significance scoring, and human approval before writing): - ADR-0002: CHECK's conflict detection stays structural-evidence-only, never semantic/AST-based. - ADR-0003: i18n covers only index.py's generated strings; agent- composed text stays untranslated, governed by a SKILL.md instruction instead of a lookup table. - ADR-0004: harness adapters ship manifest-only with install-time symlinks (never committed to git), and manifest formats are verified against real documentation, never guessed. - ADR-0005: Git Flow branch policy with direct-tag release automation, adopted today alongside the branch/release work itself. All four scored "recommended" (7-12 of 14) on the toolkit's own significance rubric. Full suite: 212/212 passing, unaffected. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01LZwXoWN1hSTy9dmgXi1gf8 --- .../0001-record-architecture-decisions.md | 44 +++++++++++ ...t-detection-to-structural-evidence-only.md | 44 +++++++++++ ...nerated-strings-not-agent-composed-text.md | 44 +++++++++++ ...-install-time-symlinks-verified-formats.md | 43 ++++++++++ ...flow-with-direct-tag-release-automation.md | 75 ++++++++++++++++++ docs/decisions/README.md | 78 +++++++++++++++++++ docs/decisions/adr-template.md | 27 +++++++ 7 files changed, 355 insertions(+) create mode 100644 docs/decisions/0001-record-architecture-decisions.md create mode 100644 docs/decisions/0002-limit-check-s-conflict-detection-to-structural-evidence-only.md create mode 100644 docs/decisions/0003-localize-only-index-py-s-generated-strings-not-agent-composed-text.md create mode 100644 docs/decisions/0004-adapter-packaging-manifest-only-directories-install-time-symlinks-verified-formats.md create mode 100644 docs/decisions/0005-adopt-git-flow-with-direct-tag-release-automation.md create mode 100644 docs/decisions/README.md create mode 100644 docs/decisions/adr-template.md 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} From 0aff20e5b3549e54c62692771138495d779065ee Mon Sep 17 00:00:00 2001 From: shcommit Date: Sun, 30 Aug 2026 16:15:00 +0900 Subject: [PATCH 02/76] docs: write the root README and an INIT/RECORD/CHECK quickstart example README.md was a one-line stub since the initial commit. Replace it with the pitch, the four operations, a per-harness install table linking each adapter's README, the no-agent create --interactive path, MVP scope (linking the two ADRs that define it), and a pointer to AGENTS.md. Add examples/quickstart.md: a full INIT -> RECORD -> CHECK walkthrough against a small example service, including a real constraints: rule that CHECK catches a violation of and then confirms clears after a fix. Every command and JSON block in it is real output from actually running the commands in a scratch repo, not written from memory. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01LZwXoWN1hSTy9dmgXi1gf8 --- README.md | 75 +++++++++++++- examples/README.md | 11 +++ examples/quickstart.md | 216 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 301 insertions(+), 1 deletion(-) create mode 100644 examples/README.md create mode 100644 examples/quickstart.md 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/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). From 93f37b937b5a72f137d383305820dba2eec0b7b1 Mon Sep 17 00:00:00 2001 From: shcommit Date: Sun, 30 Aug 2026 16:15:37 +0900 Subject: [PATCH 03/76] docs: record the README/examples/dogfooding work in changelog Mark the README-stub item done in improvements.md's Open list, and add a changelog entry for the v0.1.0 release, Git Flow adoption, the dogfooded ADRs, and the new README/quickstart example. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01LZwXoWN1hSTy9dmgXi1gf8 --- changelog.md | 11 +++++++++++ improvements.md | 5 ----- 2 files changed, 11 insertions(+), 5 deletions(-) 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/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. From 57d0481778eb674e68d8a7b2934516478e0caca8 Mon Sep 17 00:00:00 2001 From: shcommit Date: Sun, 30 Aug 2026 16:29:07 +0900 Subject: [PATCH 04/76] chore: untrack docs/superpowers/plans/, keep specs/ public MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Task-by-task implementation plans (subagent-driven-development artifacts: exact code snippets, TDD steps, agent review process) add little ongoing reference value now that the code itself is the source of truth, and their volume (6700+ lines across 4 files) would clutter a first-time visitor's browse of the repo once it goes public. Kept: docs/superpowers/specs/ — the design spec, which explains product decisions the way docs/decisions/'s ADRs do, and is genuinely useful to a reader. Untracked, not deleted: the plan files stay on disk locally and are still recoverable from git history before this commit; only future changes stop being tracked. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01LZwXoWN1hSTy9dmgXi1gf8 --- .../2026-08-29-adr-toolkit-core-and-init.md | 2676 ----------------- .../plans/2026-08-30-adr-toolkit-check.md | 1651 ---------- ...08-30-adr-toolkit-i18n-adapters-release.md | 1142 ------- ...-08-30-adr-toolkit-record-and-lifecycle.md | 1296 -------- 4 files changed, 6765 deletions(-) delete mode 100644 docs/superpowers/plans/2026-08-29-adr-toolkit-core-and-init.md delete mode 100644 docs/superpowers/plans/2026-08-30-adr-toolkit-check.md delete mode 100644 docs/superpowers/plans/2026-08-30-adr-toolkit-i18n-adapters-release.md delete mode 100644 docs/superpowers/plans/2026-08-30-adr-toolkit-record-and-lifecycle.md 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. From 6090b674b0d2c69382a233f8ede6511db7c72192 Mon Sep 17 00:00:00 2001 From: shcommit Date: Sun, 30 Aug 2026 16:29:21 +0900 Subject: [PATCH 05/76] chore: add the .gitignore rule for docs/superpowers/plans/ MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Missed staging this alongside the untrack commit (57d0481) — without it, the plan files would just get re-added on the next git add -A. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01LZwXoWN1hSTy9dmgXi1gf8 --- .gitignore | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.gitignore b/.gitignore index 74738ed..83a2424 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,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/ From 8c327661a66c42dbc8bec427690785bdc089881f Mon Sep 17 00:00:00 2001 From: shcommit Date: Sun, 30 Aug 2026 16:33:00 +0900 Subject: [PATCH 06/76] chore: broaden .gitignore for common local artifacts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add .DS_Store (found one already loose on disk, uncommitted — this would have been swept up by the next git add -A), Python virtualenv directories, editor/IDE folders, .env/.env.*.local (never commit real credentials), and coverage/build artifacts. None of these are present in the tracked tree today; this is preventive. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01LZwXoWN1hSTy9dmgXi1gf8 --- .gitignore | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/.gitignore b/.gitignore index 83a2424..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 From 7b9bb8c46a7caf549143bad4e12e7b6cf8c11a37 Mon Sep 17 00:00:00 2001 From: shcommit Date: Sun, 30 Aug 2026 19:09:27 +0900 Subject: [PATCH 07/76] docs: design multilingual ADR readiness work --- ...olkit-localization-and-readiness-design.md | 457 ++++++++++++++++++ 1 file changed, 457 insertions(+) create mode 100644 docs/superpowers/specs/2026-08-30-adr-toolkit-localization-and-readiness-design.md diff --git a/docs/superpowers/specs/2026-08-30-adr-toolkit-localization-and-readiness-design.md b/docs/superpowers/specs/2026-08-30-adr-toolkit-localization-and-readiness-design.md new file mode 100644 index 0000000..f8802cf --- /dev/null +++ b/docs/superpowers/specs/2026-08-30-adr-toolkit-localization-and-readiness-design.md @@ -0,0 +1,457 @@ +# ADR Toolkit localization and pre-0.1.1 readiness design + +## Status + +Approved in conversation on 2026-08-30. This design defines the work that +must be planned before implementation. It does not itself authorize a +`v0.1.1` release. + +## Problem statement + +The MVP proves that ADR Toolkit can initialize, discover, record, validate, +index, and check Architecture Decision Records. The next release must prove +something stricter: the toolkit must produce useful ADRs in real repositories, +serve users in their chosen language, and have a credible path from a +single-maintainer GitHub repository to team and enterprise governance. + +The current repository exposes four gaps that should be addressed before +`v0.1.1`: + +1. Runtime localization covers only five locales and only the generated + decision-log index. INIT, interactive CREATE, and MADR section headings + remain English. +2. Non-ASCII-only titles cannot be created because filename slug generation + accepts only `[a-z0-9]` and returns an empty slug for titles such as + `결제 시스템 분리`. +3. The dogfooded ADRs pass structural validation but do not yet demonstrate + the full RECORD quality contract: decision ownership is empty, some + retrospective decisions are marked as prospective, and affected paths and + confirmations are narrower than the decisions they describe. +4. `handoff.md`, `improvements.md`, and `project-roadmap.md` contain stale or + overlapping state. Work selected for implementation needs to move into + `improvements.md`; it should no longer remain an unscheduled roadmap item. + +## Goals + +- Generate ADRs in English, Korean, Japanese, Simplified Chinese, French, + Spanish, German, and Brazilian Portuguese. +- Let the user choose a locale explicitly and let an agent infer it from the + user's request when no explicit choice exists. +- Preserve user-authored text instead of applying opaque machine translation. +- Keep filenames portable across Git, macOS, Linux, Windows, URLs, and common + agent harnesses. +- Correct and strengthen this repository's unreleased dogfooded ADR set. +- Publish a Korean, evidence-backed readiness and enterprise-adoption report. +- Give `handoff.md`, `improvements.md`, `project-roadmap.md`, and the new report + non-overlapping responsibilities. +- Define public-repository governance as a planned transition, not misclassify + its present absence as a defect in a deliberately private repository. + +## Non-goals + +- Maintaining eight translated copies of every ADR. +- Translating arbitrary user prose after it has been written. +- Translating the full project README and contributor documentation into eight + separate documents in this change. +- Building a GitHub App, central decision portal, vector database, or multi-repo + graph before the local workflow and GitHub Action integration are proven. +- Enabling GitHub branch protection while the private repository's current plan + does not expose that feature. + +## Objective baseline assessment + +Scores use a five-point scale: 1 means a prototype with material adoption +barriers, 3 means usable by a small team with known manual controls, and 5 +means repeatable enterprise operation with measured controls. These are +readiness scores, not code-quality grades. + +| Dimension | Current | Evidence | Pre-0.1.1 target | +|---|---:|---|---:| +| Deterministic core | 4.0 | 212 tests pass; ID, lifecycle, validation, index, diff, and release paths are scripted | 4.3 | +| ADR content quality | 2.5 | Five ADRs validate, but all omit decision makers and the retrospective contract is not consistently represented | 4.0 | +| Internationalization | 2.0 | Five index locales exist; prompts, templates, and non-ASCII title creation are incomplete | 4.0 | +| CHECK correctness | 3.0 | Structural rules work; rename handling, ignored-path discovery, and a second subprocess failure remain open | 3.8 | +| Developer experience | 3.5 | Root README and executable quickstart exist; CLI output semantics and locale behavior remain inconsistent | 4.2 | +| Distribution parity | 2.8 | Four harness paths are documented, but only some were exercised end to end | 3.3 | +| Repository governance | 2.5 | Git Flow and CI exist; the private repository intentionally has no enforceable branch rules, CODEOWNERS, or review template | 3.0 before public, 4.0 after public | +| Enterprise scalability | 1.8 | No organization ruleset, cross-repo catalog, RBAC model, exception workflow, audit export, or adoption metrics | 2.2 | + +The target is deliberately incremental. `v0.1.1` should make the local product +and its own ADRs trustworthy; it should not pretend to complete enterprise +governance in one release. + +## Chosen approach + +Extend the existing deterministic scripts and locale catalog. Do not add a +separate translation program and do not create translated ADR replicas. + +The alternatives were: + +1. Add three index locale files only. This is cheap but leaves the actual ADR + creation workflow English-only and does not solve non-ASCII titles. +2. Localize the complete generation boundary while preserving user prose. + This is the selected approach because it covers both agent-mediated and + standalone CLI usage without introducing translation drift. +3. Generate and synchronize eight complete translations of every ADR. This is + rejected because approval, supersession, links, and content corrections + would diverge across replicas. + +## Locale model + +### Supported locales + +The canonical locale set is: + +```text +en, ko, ja, zh, fr, es, de, pt-BR +``` + +`zh` means Simplified Chinese. A future Traditional Chinese locale must use a +distinct code such as `zh-TW` rather than changing the meaning of `zh`. + +### Selection precedence + +For agent-mediated workflows: + +```text +explicit language requested by the user +→ language detected from the user's request +→ en +``` + +For standalone CLI workflows: + +```text +explicit --locale +→ en +``` + +The standalone CLI does not guess from the operating-system locale. That would +make automation depend on the machine running it and would make CI output less +reproducible. + +An unsupported locale is a user error. It must not silently become English. +A shipped locale file that is missing or malformed may degrade to the complete +English base at runtime, but the test suite and release checks must reject that +state before publishing. + +### What is localized + +- INIT's generated ADR body and repository ADR template. +- Interactive CREATE prompts and generated Minimal MADR headings. +- Minimal and Full MADR section labels used by agent guidance. +- Decision-log headings and status display labels. +- Human-readable README examples and locale documentation. + +Machine contracts remain stable: + +- CLI command names and flags stay English. +- JSON field names, operation names, error codes, status values, and rule kinds + stay locale-neutral English identifiers. +- File encoding remains UTF-8. + +### ADR metadata + +Newly generated ADRs carry an optional `locale` frontmatter field: + +```yaml +locale: ko +``` + +It is optional for backward compatibility with ADRs created by earlier +versions or by other tools. ADR Toolkit-generated files always include it. +Validation rejects an unsupported value when the field exists. The external +JSON Schema and runtime validator must remain in parity. + +The locale records the language selected for generated structure. It does not +claim that every code identifier, quotation, or linked document in the body is +written in that language. + +## Portable filename policy + +ADR titles and bodies support Unicode. Filenames remain ASCII because Unicode +normalization and tool behavior can differ across platforms and integrations. + +Creation follows this order: + +1. Use an explicitly supplied ASCII `slug` when present and valid. +2. Otherwise derive the existing ASCII slug from the title when non-empty. +3. Otherwise use the locale-neutral fallback `decision`. + +Examples: + +```text +title: Use Kafka for events → 0006-use-kafka-for-events.md +title: 결제 시스템 분리 → 0006-decision.md +title: 결제 시스템 분리, slug: separate-payment-system + → 0006-separate-payment-system.md +``` + +The numeric ADR ID guarantees uniqueness, so a repeated fallback slug is not a +collision. The parser continues to accept the established ASCII filename +contract. + +## Component changes + +### Locale catalog + +`scripts/i18n/*.json` becomes the single catalog for deterministic text. The +English file defines the complete key set; each other locale must match it +exactly. Keys cover index labels, statuses, MADR headings, INIT boilerplate, +and interactive prompts. + +### Locale loader + +`scripts/core/locale.py` owns the canonical locale list, catalog loading, +fallback behavior, and completeness checks. CLI parsers import the same list +instead of duplicating choices. + +### Template rendering + +A focused template-rendering module converts locale strings and user-provided +answers into Minimal or Full MADR Markdown. Interactive CREATE and INIT call +this renderer rather than maintaining English prose inline. Agent-authored +draft bodies remain accepted as opaque approved Markdown. + +### Command behavior + +- `init --locale ` localizes generated files and records locale metadata. +- `create --interactive --locale ` localizes prompts and structure. +- `create --input` accepts optional `locale` and `slug` draft fields. +- `index --locale ` keeps its existing responsibility and gains the + three new locales. +- `validate` checks a present locale and all existing relationship invariants. + +## Dogfooding corrections + +The five current ADR files were added after the `v0.1.0` tag and have not yet +shipped from `master`. They may therefore be corrected before their first +release without rewriting a released decision log. + +The correction set is: + +- Record `YangSeungHyun` as the decision maker because the repository owner + explicitly approved the decisions before commit. +- Add `locale: en` to the existing English ADRs. +- Keep every status `accepted`. +- Mark ADR-0002 through ADR-0004 retrospective and separate confirmed + evidence, inferred rationale, and unknowns as required by the toolkit. +- Treat ADR-0005 as contemporaneous unless contrary evidence is supplied. +- Expand ADR-0004 to Full MADR because it compares three realistic options. +- Correct affected paths, relationships, confirmation evidence, and the INIT + ADR's visibly incomplete confirmation checkbox. +- Evaluate missing core decisions—Deterministic Core / Agentic Edge, no silent + mutation, and lifecycle/link invariants—using the significance rubric before + deciding whether to add ADRs. +- Add CHECK constraints only where the rule vocabulary can prove the policy + without misleading false confidence. + +After release, Accepted ADR content becomes append-only: a changed decision is +recorded as a new ADR and linked through supersession. Narrow factual metadata +corrections need an explicit documented policy rather than silent edits. + +## Readiness and enterprise-adoption report + +Create: + +```text +docs/adr-toolkit-readiness-and-enterprise-adoption-report.md +``` + +The report is Korean and distinguishes four evidence classes: + +1. Repository fact, linked to a file, test, command result, commit, PR, or + workflow run. +2. External practice, linked to an official primary source. +3. Inference, explicitly labeled and tied to its evidence. +4. Recommendation, including priority, cost, dependency, and acceptance + signal. + +Its sections are: + +- Executive assessment and maturity table. +- Product architecture and dependency boundaries. +- ADR dogfooding quality audit. +- `improvements.md`, `handoff.md`, and roadmap reconciliation. +- Reliability, security, portability, and developer-experience gaps. +- Public GitHub transition checklist. +- Team and enterprise adoption model. +- Prioritized release gates and longer-term investments. +- Risks, invalidation triggers, and measurable success criteria. + +## Work-tracking document boundaries + +### `handoff.md` + +Contains only the active task, touched files, next step, and open risk. Remove +the stale statement that the MVP feature branch is not integrated. + +### `improvements.md` + +Contains concrete implementation work selected for execution. Add the +localization, dogfooding corrections, report, CHECK correctness work, and +public-transition preparation that have actionable acceptance criteria. + +### `project-roadmap.md` + +Contains unscheduled product bets. Remove items promoted to +`improvements.md`, including localized MADR headings. Keep genuine later bets +such as semantic conflict analysis, relationship visualization, multi-repo +graphs, central portals, and external-system integrations. + +### Readiness report + +Captures the assessment snapshot and why the priorities were chosen. It does +not become a second task tracker. + +## GitHub governance transition + +### Current private phase + +The repository is private. Its current plan returns HTTP 403 for branch +protection and repository ruleset APIs. This is a platform-plan constraint and +an intentional pre-public state, not evidence that the documented Git Flow +policy was forgotten. + +CI has run successfully on the first pull request and on `develop`, `master`, +and the release tag. The limitation is enforcement: GitHub cannot currently +prevent a direct push or an unreviewed merge. + +### Public transition gate + +When the owner makes the repository public, apply and verify: + +- Branch protection or repository rulesets for `master` and `develop`. +- Pull requests required before merge. +- Required ADR validation, test, and version-drift checks. +- Required conversation resolution. +- Force-push and deletion blocking. +- `CODEOWNERS` coverage for `docs/decisions/`, schemas, lifecycle code, and + release workflows. +- A pull-request template with an ADR impact declaration. +- Tag protection or a tag ruleset for `v*` releases. +- A documented bypass policy for emergencies. +- A deliberate decision on signed commits rather than enabling them without a + bot and contributor migration plan. + +GitHub settings must be verified through the API after configuration. A policy +written only in `AGENTS.md` is not an enforcement signal. + +### Team adoption + +Before presenting the toolkit as team-ready, add: + +- Explicit ADR owner and stakeholder semantics. +- Proposed → review → accepted workflow guidance. +- CODEOWNER review for decision-log changes. +- An ADR-impact field in pull requests: not applicable, covered by an existing + ADR, proposes a new ADR, or conflicts with an ADR. +- A CI job that runs validate, detects stale generated indexes, and runs CHECK + against the pull-request base. +- An exception record with owner, reason, scope, and expiry when a verified + violation is intentionally tolerated. +- Review cadence and stale-decision reporting without automatically mutating + Accepted ADRs. + +### Enterprise adoption + +Enterprise capabilities remain phased roadmap work until team usage produces +evidence that they are needed: + +- Organization-level rulesets and reusable workflows. +- Central discovery across repositories without destroying repository-local + ownership. +- RBAC mapping for proposer, owner, reviewer, approver, and exception authority. +- Audit-log export and decision/review correlation. +- Controlled taxonomy for tags, domains, risk, and compliance mappings. +- Policy-as-code checks and architecture fitness functions. +- SSO-aware GitHub App or service integration only after a GitHub Action proves + the review workflow. +- Adoption metrics: time to decision, review latency, supersession rate, + unresolved violations, exception age, and stale-decision review coverage. + +The design deliberately rejects building a central portal first. A portal +would centralize weak or inconsistent records rather than fix their quality. + +## External evidence baseline + +- AWS Prescriptive Guidance defines ADR ownership, Proposed-state review, + stakeholder recording on acceptance, immutable accepted records, and + supersession instead of overwriting: + +- AWS recommends distributed ownership, preserved history, regular review, + central accessibility, and explicit handling of non-compliant legacy code: + +- Microsoft recommends an append-only decision log, retrospective recovery for + brownfield systems, confidence recording, consistent anatomy, and standalone + rationale: + +- GitHub documents required pull requests, CODEOWNER approval, required status + checks, code scanning, force-push controls, and tag/branch rulesets: + +- GitHub organization rulesets can apply governance across repositories and + expose evaluation insights: + + +## Verification plan + +### Localization + +- Every locale has exactly the English catalog's required keys. +- CLI accepts all eight locale codes, rejects unknown codes, and defaults to + English when omitted. +- Each locale passes INIT → interactive CREATE → VALIDATE → INDEX in a scratch + repository. +- Each generated body contains the expected localized headings. +- Non-ASCII-only, mixed-script, and explicit-slug titles create stable files. +- ADRs without locale remain valid; unsupported present locales fail. +- JSON Schema and runtime validation remain in parity. + +### Dogfooding and documentation + +- All repository ADRs validate. +- The decision index regenerates without a diff. +- Confirmation claims are checked against actual files and commands. +- CHECK runs against a representative conforming and violating change. +- README commands execute as written. +- `handoff.md`, `improvements.md`, and `project-roadmap.md` contain no duplicate + active items and no claims contradicted by the Git graph. + +### Repository + +- Full pytest suite passes freshly. +- Version sync check passes. +- Workflow YAML remains valid. +- Working tree contains only intended changes. +- Public GitHub protections are not claimed complete until API verification + succeeds after the repository is made public. + +## Delivery order + +1. Update work-tracking documents and write the readiness report. +2. Implement locale catalog and rendering boundaries with tests. +3. Implement portable slug fallback and locale metadata with compatibility + tests. +4. Update README, examples, and agent guidance. +5. Correct the unreleased dogfooded ADRs and regenerate their index. +6. Fix pre-release CHECK correctness items selected in `improvements.md`. +7. Run complete verification and perform a final pre-`0.1.1` review. +8. After the repository becomes public, configure and API-verify GitHub + protections as a separate operational task. + +## Decision and revisit triggers + +Adopt complete generation-boundary localization with one approved ADR per +decision, not translated replicas. Keep deterministic machine contracts in +English and portable filenames in ASCII. + +Revisit this design if: + +- Users require bilingual ADRs rather than one chosen language. +- A supported harness cannot read UTF-8 bodies reliably. +- Generic fallback filenames materially harm navigation at scale. +- Translation maintenance produces repeated correctness defects. +- Team adoption demonstrates that locale belongs at repository level rather + than per ADR. +- Public or enterprise GitHub settings materially change the available + enforcement model. From b69a7846d1226717155913f270795412d8df8205 Mon Sep 17 00:00:00 2001 From: shcommit Date: Sun, 30 Aug 2026 19:45:24 +0900 Subject: [PATCH 08/76] docs: refine v0.2.0 readiness design --- ...olkit-localization-and-readiness-design.md | 376 +++++++++++++----- 1 file changed, 276 insertions(+), 100 deletions(-) diff --git a/docs/superpowers/specs/2026-08-30-adr-toolkit-localization-and-readiness-design.md b/docs/superpowers/specs/2026-08-30-adr-toolkit-localization-and-readiness-design.md index f8802cf..e891ced 100644 --- a/docs/superpowers/specs/2026-08-30-adr-toolkit-localization-and-readiness-design.md +++ b/docs/superpowers/specs/2026-08-30-adr-toolkit-localization-and-readiness-design.md @@ -1,10 +1,10 @@ -# ADR Toolkit localization and pre-0.1.1 readiness design +# ADR Toolkit v0.2.0 localization and readiness design ## Status -Approved in conversation on 2026-08-30. This design defines the work that -must be planned before implementation. It does not itself authorize a -`v0.1.1` release. +The initial direction was approved in conversation on 2026-08-30. This revision +incorporates review feedback and awaits final user approval before planning. +The design does not itself authorize a `v0.2.0` release. ## Problem statement @@ -15,7 +15,7 @@ serve users in their chosen language, and have a credible path from a single-maintainer GitHub repository to team and enterprise governance. The current repository exposes four gaps that should be addressed before -`v0.1.1`: +`v0.2.0`: 1. Runtime localization covers only five locales and only the generated decision-log index. INIT, interactive CREATE, and MADR section headings @@ -31,6 +31,53 @@ The current repository exposes four gaps that should be addressed before overlapping state. Work selected for implementation needs to move into `improvements.md`; it should no longer remain an unscheduled roadmap item. +## Design principles + +### Deterministic Core, Agentic Edge + +ADR Toolkit allows an agent to help with judgment, but never makes repository +governance depend on an agent's unvalidated output. + +The deterministic core owns: + +- IDs and portable filenames. +- Lifecycle transitions and relationship invariants. +- Runtime and JSON Schema validation. +- Locale configuration and catalogs. +- Index generation and CHECK rule evaluation. +- Every mutation of repository ADR state. + +The agentic edge may assist with: + +- Inferring the user's requested language. +- Drafting ADR prose and extracting rationale from evidence. +- Suggesting a semantic ASCII filename slug. +- Finding related decisions and assessing significance. +- Explaining findings and presenting alternatives to a human. + +Agent output crosses a deterministic validation boundary before it becomes +repository state. + +### Human-localized, machine-stable + +Humans and agents author prose directly in the chosen language. Machine +contracts—IDs, statuses, JSON keys, error codes, rule kinds, and filenames—stay +stable across locales. ADR Toolkit does not maintain opaque automatic +translations as parallel sources of truth. + +### Repository-local ownership + +The repository remains the source of truth for its decisions, configuration, +owners, and history. Future central discovery may index repository-local ADRs; +it must not replace or silently mutate them. + +### No false governance confidence + +CHECK reports only what explicit evidence can prove. A passing structural rule +does not mean that an entire architecture is compliant. Policies that cannot be +evaluated deterministically remain visibly unverifiable and require human +review. + ## Goals - Generate ADRs in English, Korean, Japanese, Simplified Chinese, French, @@ -41,7 +88,7 @@ The current repository exposes four gaps that should be addressed before - Keep filenames portable across Git, macOS, Linux, Windows, URLs, and common agent harnesses. - Correct and strengthen this repository's unreleased dogfooded ADR set. -- Publish a Korean, evidence-backed readiness and enterprise-adoption report. +- Publish separate Korean readiness and enterprise-adoption reports. - Give `handoff.md`, `improvements.md`, `project-roadmap.md`, and the new report non-overlapping responsibilities. - Define public-repository governance as a planned transition, not misclassify @@ -60,26 +107,75 @@ The current repository exposes four gaps that should be addressed before ## Objective baseline assessment -Scores use a five-point scale: 1 means a prototype with material adoption -barriers, 3 means usable by a small team with known manual controls, and 5 -means repeatable enterprise operation with measured controls. These are -readiness scores, not code-quality grades. - -| Dimension | Current | Evidence | Pre-0.1.1 target | -|---|---:|---|---:| -| Deterministic core | 4.0 | 212 tests pass; ID, lifecycle, validation, index, diff, and release paths are scripted | 4.3 | -| ADR content quality | 2.5 | Five ADRs validate, but all omit decision makers and the retrospective contract is not consistently represented | 4.0 | -| Internationalization | 2.0 | Five index locales exist; prompts, templates, and non-ASCII title creation are incomplete | 4.0 | -| CHECK correctness | 3.0 | Structural rules work; rename handling, ignored-path discovery, and a second subprocess failure remain open | 3.8 | -| Developer experience | 3.5 | Root README and executable quickstart exist; CLI output semantics and locale behavior remain inconsistent | 4.2 | -| Distribution parity | 2.8 | Four harness paths are documented, but only some were exercised end to end | 3.3 | -| Repository governance | 2.5 | Git Flow and CI exist; the private repository intentionally has no enforceable branch rules, CODEOWNERS, or review template | 3.0 before public, 4.0 after public | -| Enterprise scalability | 1.8 | No organization ruleset, cross-repo catalog, RBAC model, exception workflow, audit export, or adoption metrics | 2.2 | - -The target is deliberately incremental. `v0.1.1` should make the local product -and its own ADRs trustworthy; it should not pretend to complete enterprise +Maturity uses reproducible integer levels rather than reviewer-selected +decimal scores: + +| Level | Meaning | +|---:|---| +| 1 | Prototype or manual experiment; core outcome is not repeatable. | +| 2 | Feature exists, but an important correctness, portability, or workflow gap remains. | +| 3 | Repeatable for an individual or small team with documented manual controls. | +| 4 | Repeatably verified by CI and enforceable repository governance. | +| 5 | Organization-wide policy, audit, exception handling, and measured outcomes exist. | + +Each dimension advances only when all of its listed signals are present. The +readiness report records the command, test, workflow, or repository setting +that proves each signal. + +| Dimension | Current | Evidence | v0.2.0 target signals | +|---|---:|---|---| +| Deterministic core | 3 | 212 tests pass; ID, lifecycle, validation, index, diff, and release paths are scripted | Level 4: all P0 commands and mutations have CI-covered success and failure paths | +| ADR content quality | 2 | Five ADRs validate, but all omit decision makers and retrospective reconstruction is inconsistent | Level 4: RECORD contract, ownership, evidence separation, index stability, and confirmations are verified | +| Internationalization | 2 | Five index locales exist; prompts, templates, config, and non-ASCII title creation are incomplete | Level 4: eight locale catalogs plus INIT/CREATE/INDEX E2E, fallback, schema parity, and Unicode-title tests | +| CHECK correctness | 3 | Structural rules work; rename handling, ignored paths, and subprocess failure handling remain open | Level 4: no known false-confidence defect and all four evidence outcomes have regression coverage | +| Developer experience | 3 | Root README and executable quickstart exist; locale and config behavior are not documented or consistent | Level 4: repository config, override examples, and executable README commands are verified | +| Distribution parity | 2 | Four harness paths are documented, but only some were exercised end to end | Level 3: limitations are exact and every manifest has structural verification | +| Repository governance | 2 | Git Flow and CI exist; the private repository intentionally lacks enforceable branch rules | Level 3 before public through documented controls; Level 4 only after public rules are API-verified | +| Enterprise scalability | 1 | No organization ruleset, cross-repo catalog, RBAC, audit export, or adoption metrics | Remains post-v0.2.0; do not inflate the release scope | + +The target is deliberately incremental. `v0.2.0` makes the local product and +its own ADRs trustworthy; it does not pretend to complete enterprise governance in one release. +## v0.2.0 release scope and gates + +The new locale set, repository configuration, and multilingual generation are +backward-compatible feature additions, so Semantic Versioning requires a minor +release (`v0.2.0`), not a patch release (`v0.1.1`). + +### P0 — release blocking + +- Non-ASCII-only and mixed-script titles create portable files reliably. +- The locale catalog, loader, and repository config resolution are + deterministic. +- INIT, interactive CREATE, input CREATE, and INDEX support the canonical + locale set. +- Runtime validation and JSON Schema remain consistent. +- Existing unreleased ADRs satisfy the minimum RECORD quality contract. +- CHECK has no known correctness defect that can produce false confidence. +- README documents repository defaults, per-command overrides, and executable + examples for the released locale behavior. +- The full test suite and version-sync check pass freshly. + +### P1 — strongly recommended before release + +- Publish the Korean v0.2.0 readiness report. +- Add extended Korean and non-Latin quickstart examples. +- Improve dogfooding ADR content beyond the minimum contract where evidence is + available. +- Reconcile `handoff.md`, `improvements.md`, and `project-roadmap.md`. + +P1 work may move after the release only through an explicit owner decision +recorded in the readiness report; it must not silently disappear. + +### P2 — post-release + +- Apply public-repository GitHub governance after the visibility change. +- Add CODEOWNERS and review policy when more than one independent reviewer can + satisfy it. +- Evaluate organization rulesets, enterprise adoption metrics, and + cross-repository discovery from actual adoption evidence. + ## Chosen approach Extend the existing deterministic scripts and locale catalog. Do not add a @@ -116,13 +212,16 @@ For agent-mediated workflows: ```text explicit language requested by the user → language detected from the user's request +→ repository default → en ``` For standalone CLI workflows: ```text -explicit --locale +explicit CLI --locale +→ locale supplied by an approved input draft +→ repository default → en ``` @@ -130,6 +229,38 @@ The standalone CLI does not guess from the operating-system locale. That would make automation depend on the machine running it and would make CI output less reproducible. +Argparse uses `None` for an omitted `--locale`; setting its parser default to +`en` would erase the distinction between an explicit CLI override and the +absence of one before repository configuration can be consulted. + +### Repository configuration + +The repository defines its normal ADR language once at its root: + +```json +{ + "schema_version": 1, + "locale": "ko" +} +``` + +The canonical filename is `.adr-toolkit.json`. JSON is selected instead of +YAML because the toolkit supports Python 3.9, has no YAML dependency, and must +not grow another incomplete hand-written YAML parser for two scalar settings. +TOML is not selected because `tomllib` is unavailable in Python 3.9's standard +library. + +Commands resolve the file from the repository working directory. The README +continues to require running commands from the repository root. A malformed +config, unsupported `schema_version`, unknown field, or unsupported locale is a +visible configuration error rather than a silent English fallback. + +INIT includes `.adr-toolkit.json` in its dry-run and creates it when absent, +using the explicit `--locale` or `en`. It never overwrites an existing config. +Existing repositories may add the small file directly; a dedicated config +mutation command is deferred until real usage shows that manual creation is a +problem. + An unsupported locale is a user error. It must not silently become English. A shipped locale file that is missing or malformed may degrade to the complete English base at runtime, but the test suite and release checks must reject that @@ -191,6 +322,24 @@ The numeric ADR ID guarantees uniqueness, so a repeated fallback slug is not a collision. The parser continues to accept the established ASCII filename contract. +The fallback is a safety net, not the preferred agent experience. In an agent +workflow, the agentic edge proposes a short semantic ASCII slug, shows it with +the title during CONFIRM, and supplies it to CREATE. The deterministic core +does not translate or transliterate; it only validates the supplied slug and +uses `decision` when no valid semantic slug exists. + +```text +Korean title +→ agent suggests `separate-payment-system` +→ human approves title and slug +→ core validates `[a-z0-9-]+` +→ core writes `0006-separate-payment-system.md` +``` + +Standalone users may supply the same optional slug. This preserves useful file +navigation without introducing a nondeterministic translation library into the +core. + ## Component changes ### Locale catalog @@ -206,6 +355,12 @@ and interactive prompts. fallback behavior, and completeness checks. CLI parsers import the same list instead of duplicating choices. +### Repository config loader + +A focused config module loads `.adr-toolkit.json`, rejects unknown or malformed +state, and resolves locale precedence without writing files. INIT owns initial +config creation so config reads and mutations remain separate and testable. + ### Template rendering A focused template-rendering module converts locale strings and user-provided @@ -216,11 +371,16 @@ draft bodies remain accepted as opaque approved Markdown. ### Command behavior - `init --locale ` localizes generated files and records locale metadata. -- `create --interactive --locale ` localizes prompts and structure. -- `create --input` accepts optional `locale` and `slug` draft fields. +- `create --interactive --locale [--slug ]` localizes + prompts and structure and accepts an optional user-approved semantic slug. +- `create --input` accepts optional `locale` and `slug` draft fields; an + explicit CLI locale overrides the draft locale. - `index --locale ` keeps its existing responsibility and gains the three new locales. -- `validate` checks a present locale and all existing relationship invariants. +- Omitted locale flags resolve through `.adr-toolkit.json` before falling back + to English. +- `validate` checks a present locale, config validity, and all existing + relationship invariants. ## Dogfooding corrections @@ -250,15 +410,39 @@ After release, Accepted ADR content becomes append-only: a changed decision is recorded as a new ADR and linked through supersession. Narrow factual metadata corrections need an explicit documented policy rather than silent edits. -## Readiness and enterprise-adoption report +## CHECK confidence contract + +CHECK validates explicit structural evidence; it never certifies an entire +architecture. The user-facing model distinguishes four meanings: + +| Meaning | Interpretation | +|---|---| +| VERIFIED | Applicable deterministic rules were evaluated and no violation was found for this diff. | +| VIOLATED | Direct structural evidence proves that an explicit rule was violated. | +| UNVERIFIABLE | The ADR is relevant, but prose, missing rules, or ambiguous evidence prevents deterministic verification. | +| NOT_APPLICABLE | The ADR does not govern the changed scope. | + +For backward compatibility, `v0.2.0` keeps the existing JSON finding kinds +(`related`, `review_required`, `verified_violation`, and +`no_applicable_constraint`). Documentation maps those kinds to the confidence +model and avoids calling an empty finding list “architecture verified.” A +future API normalization requires its own compatibility design. + +The release-blocking correctness work is narrower and more urgent: rename +handling, ignored-path discovery, and every git subprocess failure must avoid a +false clean result. CHECK constraints are added to dogfooded ADRs only when the +rule vocabulary can prove the policy. + +## Separate reports Create: ```text -docs/adr-toolkit-readiness-and-enterprise-adoption-report.md +docs/adr-toolkit-v0.2.0-readiness-report.md +docs/enterprise-adoption.md ``` -The report is Korean and distinguishes four evidence classes: +Both reports are Korean and distinguish four evidence classes: 1. Repository fact, linked to a file, test, command result, commit, PR, or workflow run. @@ -267,18 +451,27 @@ The report is Korean and distinguishes four evidence classes: 4. Recommendation, including priority, cost, dependency, and acceptance signal. -Its sections are: +The readiness report stays release-focused: - Executive assessment and maturity table. - Product architecture and dependency boundaries. - ADR dogfooding quality audit. - `improvements.md`, `handoff.md`, and roadmap reconciliation. - Reliability, security, portability, and developer-experience gaps. -- Public GitHub transition checklist. -- Team and enterprise adoption model. -- Prioritized release gates and longer-term investments. +- P0/P1 release gate evidence. - Risks, invalidation triggers, and measurable success criteria. +The enterprise report owns the broader adoption path: + +- Team ownership, proposal, review, acceptance, and exception workflow. +- Public GitHub transition checklist. +- CODEOWNERS, rulesets, required checks, and auditability. +- Organization-level governance, RBAC, taxonomy, and metrics. +- Cross-repository discovery and integration sequencing. + +This split keeps enterprise analysis available without making every enterprise +capability appear to be part of `v0.2.0`. + ## Work-tracking document boundaries ### `handoff.md` @@ -289,24 +482,26 @@ the stale statement that the MVP feature branch is not integrated. ### `improvements.md` Contains concrete implementation work selected for execution. Add the -localization, dogfooding corrections, report, CHECK correctness work, and -public-transition preparation that have actionable acceptance criteria. +repository config, localization, dogfooding corrections, reports, and CHECK +correctness work that have actionable `v0.2.0` acceptance criteria. Replace +umbrella links back to the roadmap with explicit tasks, owners or prerequisites, +priority, and a testable completion signal. ### `project-roadmap.md` Contains unscheduled product bets. Remove items promoted to -`improvements.md`, including localized MADR headings. Keep genuine later bets -such as semantic conflict analysis, relationship visualization, multi-repo -graphs, central portals, and external-system integrations. - -### Readiness report +`improvements.md`, including localized MADR headings and the concrete CHECK and +i18n/adapter/release follow-up lists. Keep genuine later bets such as semantic +conflict analysis, relationship visualization, multi-repo graphs, +public/enterprise governance automation, central portals, and external-system +integrations. -Captures the assessment snapshot and why the priorities were chosen. It does -not become a second task tracker. +### Reports -## GitHub governance transition +Capture the assessment snapshot and why the priorities were chosen. They do not +become second task trackers. -### Current private phase +## Post-v0.2.0 adoption boundary The repository is private. Its current plan returns HTTP 403 for branch protection and repository ruleset APIs. This is a platform-plan constraint and @@ -315,11 +510,10 @@ policy was forgotten. CI has run successfully on the first pull request and on `develop`, `master`, and the release tag. The limitation is enforcement: GitHub cannot currently -prevent a direct push or an unreviewed merge. - -### Public transition gate +prevent a direct push or an unreviewed merge. This does not block `v0.2.0`. -When the owner makes the repository public, apply and verify: +When the owner makes the repository public, the enterprise-adoption report +requires the operational task to apply and verify: - Branch protection or repository rulesets for `master` and `develop`. - Pull requests required before merge. @@ -335,43 +529,10 @@ When the owner makes the repository public, apply and verify: bot and contributor migration plan. GitHub settings must be verified through the API after configuration. A policy -written only in `AGENTS.md` is not an enforcement signal. - -### Team adoption - -Before presenting the toolkit as team-ready, add: - -- Explicit ADR owner and stakeholder semantics. -- Proposed → review → accepted workflow guidance. -- CODEOWNER review for decision-log changes. -- An ADR-impact field in pull requests: not applicable, covered by an existing - ADR, proposes a new ADR, or conflicts with an ADR. -- A CI job that runs validate, detects stale generated indexes, and runs CHECK - against the pull-request base. -- An exception record with owner, reason, scope, and expiry when a verified - violation is intentionally tolerated. -- Review cadence and stale-decision reporting without automatically mutating - Accepted ADRs. - -### Enterprise adoption - -Enterprise capabilities remain phased roadmap work until team usage produces -evidence that they are needed: - -- Organization-level rulesets and reusable workflows. -- Central discovery across repositories without destroying repository-local - ownership. -- RBAC mapping for proposer, owner, reviewer, approver, and exception authority. -- Audit-log export and decision/review correlation. -- Controlled taxonomy for tags, domains, risk, and compliance mappings. -- Policy-as-code checks and architecture fitness functions. -- SSO-aware GitHub App or service integration only after a GitHub Action proves - the review workflow. -- Adoption metrics: time to decision, review latency, supersession rate, - unresolved violations, exception age, and stale-decision review coverage. - -The design deliberately rejects building a central portal first. A portal -would centralize weak or inconsistent records rather than fix their quality. +written only in `AGENTS.md` is not an enforcement signal. Team workflow, RBAC, +audit export, organization rulesets, metrics, and central discovery remain in +the separate enterprise report and post-release roadmap until usage evidence +promotes them into `improvements.md`. ## External evidence baseline @@ -398,14 +559,21 @@ would centralize weak or inconsistent records rather than fix their quality. ### Localization - Every locale has exactly the English catalog's required keys. -- CLI accepts all eight locale codes, rejects unknown codes, and defaults to - English when omitted. +- CLI accepts all eight locale codes and rejects unknown codes. +- Locale resolution proves CLI → draft → repository config → English + precedence, including the agent-mediated precedence documented in SKILL.md. +- Missing config defaults to English; malformed, unknown-version, unknown-key, + and unsupported-locale configs fail visibly. +- INIT dry-run reports config creation, INIT creates config when absent, and + never overwrites an existing file. - Each locale passes INIT → interactive CREATE → VALIDATE → INDEX in a scratch repository. - Each generated body contains the expected localized headings. - Non-ASCII-only, mixed-script, and explicit-slug titles create stable files. - ADRs without locale remain valid; unsupported present locales fail. - JSON Schema and runtime validation remain in parity. +- Agent guidance proposes an optional semantic ASCII slug and the core rejects + invalid supplied slugs without attempting translation. ### Dogfooding and documentation @@ -413,6 +581,10 @@ would centralize weak or inconsistent records rather than fix their quality. - The decision index regenerates without a diff. - Confirmation claims are checked against actual files and commands. - CHECK runs against a representative conforming and violating change. +- CHECK regression tests prove that rename handling, ignored paths, and every + git subprocess failure cannot produce a false clean result. +- User documentation distinguishes verified rules from unverifiable policy and + never equates an empty finding list with full architecture compliance. - README commands execute as written. - `handoff.md`, `improvements.md`, and `project-roadmap.md` contain no duplicate active items and no claims contradicted by the Git graph. @@ -428,15 +600,19 @@ would centralize weak or inconsistent records rather than fix their quality. ## Delivery order -1. Update work-tracking documents and write the readiness report. -2. Implement locale catalog and rendering boundaries with tests. -3. Implement portable slug fallback and locale metadata with compatibility - tests. -4. Update README, examples, and agent guidance. -5. Correct the unreleased dogfooded ADRs and regenerate their index. -6. Fix pre-release CHECK correctness items selected in `improvements.md`. -7. Run complete verification and perform a final pre-`0.1.1` review. -8. After the repository becomes public, configure and API-verify GitHub +1. Reconcile work-tracking documents and write the two assessment reports. +2. Implement repository config, locale resolution, and catalog completeness + with tests. +3. Implement localized rendering boundaries across INIT, CREATE, and INDEX. +4. Implement portable slug fallback, semantic-slug input, and locale metadata + with compatibility tests. +5. Update README, examples, agent guidance, and CHECK confidence language. +6. Correct the unreleased dogfooded ADRs and regenerate their index. +7. Fix release-blocking CHECK correctness defects selected in + `improvements.md`. +8. Run every P0 verification signal and perform a final pre-`v0.2.0` review. +9. Complete or explicitly defer each P1 item in the readiness report. +10. After the repository becomes public, configure and API-verify GitHub protections as a separate operational task. ## Decision and revisit triggers @@ -451,7 +627,7 @@ Revisit this design if: - A supported harness cannot read UTF-8 bodies reliably. - Generic fallback filenames materially harm navigation at scale. - Translation maintenance produces repeated correctness defects. -- Team adoption demonstrates that locale belongs at repository level rather - than per ADR. +- Teams need repository config beyond locale and the minimal versioned JSON + shape no longer covers a proven use case. - Public or enterprise GitHub settings materially change the available enforcement model. From 9a86f5fa675a191d21b9ff33db4d9ad5508346ca Mon Sep 17 00:00:00 2001 From: shcommit Date: Sun, 30 Aug 2026 20:04:35 +0900 Subject: [PATCH 09/76] docs: align v0.2.0 work tracking --- handoff.md | 69 ++++++++++------- improvements.md | 130 +++++++++++++++++++++++--------- project-roadmap.md | 183 ++++++++++++--------------------------------- 3 files changed, 186 insertions(+), 196 deletions(-) diff --git a/handoff.md b/handoff.md index 3361371..8ae5b35 100644 --- a/handoff.md +++ b/handoff.md @@ -1,28 +1,45 @@ # handoff.md -## Current state (as of 2026-08-30) - -The full 4-plan ADR Toolkit MVP is complete and pushed to `origin` on -branch `feat/adr-toolkit-mvp-implement` (renamed from -`SHcommit/feat-plan-adr-toolkit`; the old name still exists as a stale -remote branch that was never deleted). Not yet merged to `master`, no PR -open — see `improvements.md`'s `## Open` list for what's pending. - -For what shipped, see `changelog.md`. For deferred/follow-up work, see -`improvements.md` and `project-roadmap.md` — this file no longer repeats -that history; keeping it in three places was how it drifted. - -## Standing risks - -- Codex `quick_validate.py` rejects the existing cross-harness - `user-invocable` and `version` skill frontmatter keys. The repository - tests require those keys today; resolve validator/metadata - compatibility deliberately rather than removing them silently. -- `scripts/adr.py` accepts `--json` on every subcommand but always emits - JSON; deliberately left untouched by Plan 4 (design spec §17.1). -- The Codex adapter's documented install path only exercises this repo's - root-level `.claude-plugin/marketplace.json` — the `.codex-plugin/` - manifest and its sibling `skills/` symlink (spec §17.2) are present and - correctly laid out but not exercised by Codex's own `plugin marketplace - add` in the verified flow. Revisit once Codex's Agent Plugins 1.0.0 - support matures. +## Current task (2026-08-30) + +Prepare ADR Toolkit `v0.2.0` as a minor feature release: repository-configured +eight-locale ADR generation, portable multilingual filenames, CHECK +false-confidence fixes, dogfooded ADR quality corrections, and separate Korean +readiness/enterprise-adoption reports. + +The work runs in the linked worktree `develop-2`. Its base is the current +`origin/develop` history plus the approved design commits: + +- `7b9bb8c` — initial localization/readiness design +- `b69a784` — v0.2.0 release gates, repository config, CHECK confidence, and + report separation + +Baseline verification before implementation: `212 passed` on 2026-08-30. + +## Touched files + +- `docs/superpowers/specs/2026-08-30-adr-toolkit-localization-and-readiness-design.md` +- `handoff.md` +- `improvements.md` +- `project-roadmap.md` + +Local execution plans live under ignored `docs/superpowers/plans/`. + +## Next step + +Implement `.adr-toolkit.json`, locale resolution, eight catalogs, localized +rendering, and semantic-slug validation with TDD. Then harden CHECK, correct the +existing ADRs, request the lifecycle checkpoint for ADR-0003 supersession, and +finish reports/documentation. + +## Open risks + +- Locale precedence must distinguish an omitted CLI flag from explicit English. +- Translation catalogs need exact key parity and human-readable terminology. +- ADR-0003's MVP decision is replaced by v0.2.0 and must be superseded through + the lifecycle command, not silently rewritten. +- Known CHECK rename/path/subprocess defects can produce false confidence until + fixed and regression-tested. +- GitHub protection is intentionally unavailable in the current private plan; + configure and API-verify it after the repository becomes public. +- Version bump, release branch, push, and tag are not authorized by this task. diff --git a/improvements.md b/improvements.md index 59254f8..787b1ca 100644 --- a/improvements.md +++ b/improvements.md @@ -1,42 +1,104 @@ # improvements.md -Backlog for follow-up improvements that are useful but not required to resume -the current session. +Concrete implementation backlog. Unscheduled product bets belong in +`project-roadmap.md`; current-session state belongs in `handoff.md`. ## Open -- [ ] **Decide branch integration.** `feat/adr-toolkit-mvp-implement` is - pushed to `origin` but not merged to `master`, and no PR is open yet. - Options: merge locally, open a PR, or leave as-is longer. -- [ ] **Clean up the stale remote branch** `origin/SHcommit/feat-plan-adr-toolkit` - — 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). -- [ ] `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. -- [ ] Codex's `quick_validate.py` rejects the existing ADR Toolkit `SKILL.md` - frontmatter keys `user-invocable` and `version`, while the repository's - cross-harness contract and tests currently require them. Make a deliberate - metadata/validator compatibility decision before changing either side; - do not silently delete the keys to satisfy one harness. -- [ ] **CHECK follow-ups** — see `project-roadmap.md`'s "CHECK follow-ups" - section: a `git ls-files`-based existing-paths check (perf + - `.gitignore` correctness), collapsing the `SKIP_FILES`/ADR-loading loop - duplicated across 4 command modules, documenting that `pattern` syntax - differs by rule kind, `diff.py` rename handling, and two narrow - `diff.py` edge cases. None blocking. -- [ ] **i18n/adapters/release follow-ups** — see `project-roadmap.md`'s - "i18n, adapters, release follow-ups" section: the Codex adapter README - slightly overclaims which manifest its verification exercised (see the - standing risk in `handoff.md`), its install step 1 (the `skills/` - symlink) is currently orphaned relative to the documented working - install path, description text has drifted across 4 manifests with no - sync mechanism, and a few narrower `sync_version.py`/CI polish items. - None blocking. +### P0 — v0.2.0 release blocking + +- [ ] **Repository locale configuration and precedence.** Add the versioned + root `.adr-toolkit.json` contract and resolve locale as explicit CLI → + approved input draft → repository default → `en`. Agent workflows resolve + explicit request → request language → repository default → `en`. + **Done when:** missing config defaults to English; malformed, unknown-version, + unknown-key, and unsupported-locale configs fail visibly; precedence has + focused tests. +- [ ] **Eight-locale deterministic generation.** Support + `en/ko/ja/zh/fr/es/de/pt-BR` across INIT, interactive CREATE, input CREATE, + templates, and INDEX without translating user prose. + **Done when:** all catalogs exactly match the English key set and every locale + passes INIT → CREATE → VALIDATE → INDEX in an integration matrix. +- [ ] **Portable multilingual filenames.** Preserve Unicode titles/bodies, + accept an optional human-approved semantic ASCII slug, and fall back to + `decision` when no ASCII slug can be derived. + **Done when:** non-ASCII-only, mixed-script, explicit valid slug, and invalid + slug cases have regression coverage on the ASCII filename contract. +- [ ] **ADR locale schema parity.** Add optional `locale` metadata without + breaking existing ADRs. + **Done when:** runtime validation and `adr.schema.json` accept the same eight + values, reject unsupported present values, and keep locale-less ADRs valid. +- [ ] **CHECK must fail closed on incomplete git evidence.** Check both diff + subprocess return codes, surface untracked-listing failures, preserve old and + new rename paths, use `git ls-files --cached --others --exclude-standard` for + existing paths, reject conflicting diff modes, and document regex versus glob + patterns. + **Done when:** each prior false-clean path has a failing-then-passing + regression test and the complete CHECK suite passes. +- [ ] **Dogfooded ADR minimum quality contract.** Record the owner-approved + decision maker, add `locale: en`, represent retrospective reconstruction + truthfully, strengthen affected paths/confirmations, and supersede ADR-0003 + with the approved v0.2.0 localization/config decision. + **Done when:** repository ADR quality tests, `validate`, lifecycle links, and + a regenerated zero-diff index all pass. +- [ ] **Released behavior is documented.** Explain repository defaults, + per-command overrides, eight locales, semantic slug confirmation, and CHECK's + limited verification confidence in README/SKILL/quickstart. + **Done when:** documentation-contract tests pass and every shown CLI command + has been executed successfully in a scratch repository. +- [ ] **Fresh release verification.** Run the full suite, version-drift check, + repository ADR validation/index stability, and diff whitespace checks. + **Done when:** the readiness report contains command evidence for every P0 + gate and recommends GO; version bump/tag still require separate approval. + +### P1 — strongly recommended + +- [ ] **Publish the Korean readiness and enterprise-adoption reports.** Keep + release evidence separate from post-release team/enterprise governance. + **Done when:** facts, external practices, inferences, recommendations, costs, + and measurable signals are visibly separated and cited. +- [ ] **Add a non-Latin quickstart.** Demonstrate repository-default locale, + Unicode title, approved semantic slug, and localized index with real output. + **Done when:** the example commands execute byte-for-byte as documented. +- [ ] **Centralize ADR directory loading.** Replace duplicated + `SKIP_FILES`/filename/frontmatter loops in related/index/validate/check with a + focused shared iterator. + **Done when:** all four commands retain their warning semantics and tests pass. +- [ ] **Correct Codex adapter claims.** Scope the README to the root + `.claude-plugin/marketplace.json` flow actually exercised and explain why the + adapter-local symlink remains relevant to native `.codex-plugin` discovery. + **Done when:** every claim maps to a verified command or is explicitly marked + structural-only. +- [ ] **Harden version synchronization.** Fail when a tracked manifest loses + its version key, preserve intended `SystemExit` outside the repo root, and use + `ensure_ascii=False` for JSON writes. + **Done when:** focused regression tests cover all three paths. +- [ ] **Decide manifest-description ownership.** Description text has drifted + across manifests while only versions are synchronized. + **Done when:** one canonical source and a check are implemented, or an ADR + explicitly accepts independent descriptions. +- [ ] **Avoid redundant version-drift CI work.** Run the repository-invariant + sync check once instead of on every OS/Python matrix leg. + **Done when:** test coverage remains cross-platform and one required job owns + the drift check. +- [ ] **Resolve `--json` semantics.** Every subcommand parses `--json` but always + emits JSON. + **Done when:** either a human-readable mode exists with compatibility tests or + a future breaking-release decision removes the inert flag. +- [ ] **Resolve Codex metadata compatibility.** Codex `quick_validate.py` + rejects `user-invocable` and `version`, while the cross-harness contract and + repository tests require them. + **Done when:** a deliberate compatibility decision and verified adapter path + replace the standing ambiguity. +- [ ] **Clean stale remote branches after reference checks.** Candidates are + `origin/SHcommit/feat-plan-adr-toolkit` and + `origin/feat/adr-toolkit-mvp-implement`. + **Done when:** no open PR or release reference needs them and the owner + explicitly approves deletion. ## Done -Historical detail now lives in `changelog.md` (what shipped) and git log -(how) — this section stays empty going forward rather than duplicating -that record across three files. +- [x] Integrated the MVP feature work through `develop` and released `v0.1.0` + from `master`. +- [x] Merged PR #1 into `develop`, adding dogfooded ADRs, the root README, + executable quickstart, and local-artifact ignore rules. diff --git a/project-roadmap.md b/project-roadmap.md index f86fa6a..4389a66 100644 --- a/project-roadmap.md +++ b/project-roadmap.md @@ -1,155 +1,66 @@ # project-roadmap.md -Features and improvements that are valuable but deliberately excluded from -the MVP defined in `docs/superpowers/specs/2026-08-29-adr-toolkit-design.md`. -Nothing here is scheduled — it moves into a real design (brainstorming → -spec → plan) only once the MVP has proven the core loop with real usage. +Unscheduled product capabilities that need usage evidence or a separate design +before implementation. Concrete selected work belongs in `improvements.md`. ## Conflict detection depth -- Full semantic conflict taxonomy: Direct violation (e.g. SDK called - directly where a Provider Port was decided) and Pattern divergence - (diverges from a documented common pattern without touching a named - path). Needs AST/import-graph analysis beyond MVP's structural, - path/dependency-based rules. -- Confidence scoring calibrated against a larger golden fixture set once - real conflicts (and false positives) from actual usage are available. +- Full semantic conflict taxonomy: Direct violation (for example, an SDK called + directly where a Provider Port was decided) and Pattern divergence. This + requires AST/import-graph analysis beyond deterministic path/dependency rules. +- Confidence scoring calibrated against a larger golden fixture set after real + conflicts and false positives are available. +- Architecture fitness-function integrations that can verify richer policies + without presenting heuristic guesses as proof. ## Harness parity -- Full cross-harness fixture/golden test matrix for Codex, Gemini CLI, and - Antigravity CLI, matching the depth Claude Code gets in MVP. -- Harness-specific hook support beyond Claude Code's SessionStart, if the - other harnesses expose an equivalent trigger. +- Full cross-harness fixture/golden matrix for Codex, Gemini CLI, and + Antigravity CLI at the depth currently available for Claude Code. +- Harness-specific hook support beyond Claude Code SessionStart when equivalent + stable extension points exist. -## ADR navigation and structure +## ADR navigation and scale -- ADR relationship graph (related/supersedes) rendered as a visual graph, - not just index list views. -- Investigate whether large decision sets (500+) need anything beyond the - flat-directory + multi-view-index model chosen for MVP. +- Render `related`/`supersedes` relationships as a graph, not only index lists. +- Test whether 500+ decisions require sharding, alternate indexes, or search + beyond the flat-directory model. +- Improve related-decision discovery beyond path/tag/keyword only after real + misses demonstrate the need for semantic retrieval. ## Internationalization -- Localized MADR template section headers (currently English regardless of - locale). -- Localized project documentation — README, CONTRIBUTING — in the same 5 - languages as the skill's runtime text (en/fr/ja/ko/zh). Wait for real - non-English/Korean contributors before investing here. +- Translate complete project documentation such as README and CONTRIBUTING + after non-English contributors can review terminology and maintenance cost. +- Add Traditional Chinese as `zh-TW` only through a separate catalog and review; + keep `zh` defined as Simplified Chinese. +- Evaluate bilingual ADR presentation only if teams need one decision rendered + in more than one human language; do not create parallel sources of truth by + default. -## Ecosystem integration - -- Pull request review integration / GitHub App / automated PR comments on - ADR conflicts. -- ArchUnit-style static enforcement tied to `Implementation Constraints`. -- C4 / arc42 Section 9 export. -- Multi-repo decision graph. -- Vector DB-backed semantic search over ADRs. -- Central decision portal / web viewer. -- Slack / Jira / Notion integrations. - -## Open items to revisit once MVP ships +## Public and enterprise governance -- Whether `retrospective` should become a first-class status instead of - metadata-only, once enough retrospective ADRs exist to see how they're - actually used. -- Whether related-ADR search needs more than keyword/tag/affected-path - matching (e.g. embedding-based similarity) — only worth it once keyword - search demonstrably misses real cases. +- After the repository becomes public, apply and API-verify branch/tag + protections, required checks, conversation resolution, force-push/deletion + controls, and a documented bypass policy. +- Add CODEOWNERS and mandatory independent review when the contributor model can + actually satisfy it. +- Organization-level rulesets, reusable workflows, RBAC, audit export, + controlled taxonomy, exception governance, and adoption metrics. -## CHECK follow-ups (deferred from Plan 3's final review) - -Minor/deferred findings from Plan 3's closeout review, not fixed in the -fix wave since they're either narrow edge cases or refactors better done -deliberately rather than folded into a fix commit: - -- `check.py`'s `_existing_paths` walks the whole working tree via - `rglob("*")`, skipping only `.git`. `diff.py` already shells out to - `git ls-files`; switching to - `git ls-files --cached --others --exclude-standard` would be faster on - large repos and correctly honor `.gitignore` (currently a gitignored - build artifact could satisfy a `required_path`/`file_must_exist` rule). -- `SKIP_FILES = {"README.md", "adr-template.md"}` and the - glob/parse-filename/frontmatter-parse-with-per-file-warning loop are now - duplicated across `related.py`, `index.py`, `validate.py`, and - `check.py`. A shared `core/adr_dir.py::iter_adrs(adr_dir) -> (entries, - warnings)` would collapse all four call sites. -- `references/conflict-rules.md` doesn't warn that `pattern`'s syntax - differs by rule kind (regex for `forbidden_import`/`dependency_forbidden`, - glob for `required_path`/`forbidden_path`) — an author copying the - `paths` glob style into a `forbidden_import` rule's `pattern` field gets - a regex where `**` silently never matches as expected. -- `diff.py`'s `--name-status` parsing takes only the destination path for - a renamed file (git status char `R`), dropping the old path entirely — - a `constraints:` rule scoped to the old location won't fire on a rename, - and a Verification/Confirmation-referenced file that was renamed away - isn't recognized as removed. -- `diff.py`'s second subprocess call (the `--unified=0` patch content) - doesn't independently check its own returncode — if it fails while the - first `--name-status` call succeeded, files get correct `change_type` - but empty `added_lines`/`removed_lines`, silently passing every - content-pattern rule instead of surfacing an error. -- `diff.py`'s `INVALID_REF` vs `GIT_DIFF_FAILED` error-code selection - checks the raw `since` argument's truthiness rather than - `mode == "since"` — near-unreachable in practice (would need - `--staged --since ` together, which the CLI doesn't prevent), - but worth tightening to `mode == "since"` if `diff.py` is touched again. +## Ecosystem integration -## i18n, adapters, release follow-ups (deferred from Plan 4's final review) +- Pull-request review integration, initially as a GitHub Action and only later + as a GitHub App if permissioned service behavior is justified. +- C4 / arc42 Section 9 export. +- Multi-repository decision discovery and graphing. +- Central decision portal or web viewer. +- Slack, Jira, and Notion integrations. +- Vector-backed semantic search after deterministic search has measured misses. -Minor/deferred findings from Plan 4's closeout review, not fixed in the -fix wave since they're narrow polish or genuinely need Codex's Agent -Plugins support to mature before a good fix exists: +## Lifecycle research -- `adapters/codex/README.md`'s opening line slightly overclaims which - manifest the verification exercised — it reads as endorsing - `.codex-plugin/plugin.json`'s sibling-`skills/` auto-discovery, but the - verified working path is Codex reading the repo-root - `.claude-plugin/marketplace.json` instead; `.codex-plugin/`'s own - auto-discovery was never actually exercised. Reword to scope the claim - to what was tested. -- Relatedly, the Codex README's install step 1 (create the - `adapters/codex/skills/adr-toolkit` symlink, per spec §17.2) is - currently orphaned — the documented working install (steps 2-4) never - touches it, since Codex reads the repo-root marketplace file instead. - Kept because the spec mandates the symlink exist, but the README should - explain why step 1 still matters once Codex's own Agent Plugins support - (shipped 2026-08-07, very new) starts reading `.codex-plugin/` the way - the standard describes. -- `codex plugin add` snapshots the entire repo root — including `.git/`, - `.superpowers/`, `.pytest_cache/` — into its plugin cache. Not a defect - in this repo's adapter, but worth knowing before ever publishing this as - a real, standalone marketplace listing (a dedicated distribution repo - might be warranted instead of pointing Codex at the full dev repo). -- `scripts/sync_version.py`'s `sync()` still silently skips a manifest - that exists but has lost its declared `version` key (only a missing - *file* is now a hard error via `require_known_paths()`) — covered by a - test asserting the real manifests currently have the key, but the - script itself doesn't enforce it going forward. -- `scripts/sync_version.py:104`'s `path.relative_to(REPO_ROOT)` in an - error-reporting path would raise `ValueError` instead of the intended - `SystemExit` if a future `MANIFEST_SPECS` entry were ever added outside - `REPO_ROOT` — only reachable via a deliberately malformed entry today, - not user-triggerable. -- `SKILL.md`'s line documenting the lifecycle `index --locale` invocation - is noticeably longer than the file's otherwise-consistent ~72-column - wrap. Cosmetic. -- Description text for the toolkit ("Initialize, record, and check - Architecture Decision Records...") is now duplicated across four - manifests (`.claude-plugin/plugin.json`, `SKILL.md`'s frontmatter, the - three adapter manifests, `.claude-plugin/marketplace.json`) with slight - wording drift between them and no sync mechanism — `sync_version.py` - only syncs the version field, not descriptions. -- `sync_version.py`'s `json.dumps(..., indent=2)` defaults to - `ensure_ascii=True`; harmless today since all synced values are ASCII, - but a future non-ASCII description would get mangled into `\uXXXX` - escapes on the next sync. Pass `ensure_ascii=False` if that becomes a - real concern. -- `.github/workflows/test.yml`'s version-drift check step runs redundantly - on all 5 matrix legs (3 OS × 2 Python versions minus one exclusion) — - it's a repo-invariant check, not a platform-specific one, so one leg - would suffice. Harmless, just slightly wasteful CI time. -- Root `README.md` is still a one-line stub at MVP-complete. Nothing - points a new user at the four `adapters/*/README.md` install guides or - at `create --interactive`'s no-agent-needed path. Worth writing a real - README before any public release, alongside the license decision. +- Revisit whether `retrospective` should become a first-class status rather than + metadata after enough retrospective ADRs exist to show a lifecycle need. +- Define a narrow factual-correction policy for Accepted ADR metadata without + weakening append-only decision history. From 732238024457ca6c774344d46f77a6ca631ae401 Mon Sep 17 00:00:00 2001 From: shcommit Date: Sun, 30 Aug 2026 20:05:50 +0900 Subject: [PATCH 10/76] feat: add repository locale configuration --- skills/adr-toolkit/scripts/core/config.py | 49 +++++++++++++++++++++++ skills/adr-toolkit/scripts/core/locale.py | 1 + tests/unit/test_config.py | 48 ++++++++++++++++++++++ tests/unit/test_locale.py | 8 +++- 4 files changed, 105 insertions(+), 1 deletion(-) create mode 100644 skills/adr-toolkit/scripts/core/config.py create mode 100644 tests/unit/test_config.py diff --git a/skills/adr-toolkit/scripts/core/config.py b/skills/adr-toolkit/scripts/core/config.py new file mode 100644 index 0000000..d2c77e2 --- /dev/null +++ b/skills/adr-toolkit/scripts/core/config.py @@ -0,0 +1,49 @@ +"""Load and validate repository-owned ADR Toolkit configuration.""" +import json +from pathlib import Path +from typing import Optional + +from scripts.core.locale import DEFAULT_LOCALE, SUPPORTED_LOCALES + +CONFIG_FILENAME = ".adr-toolkit.json" +CONFIG_SCHEMA_VERSION = 1 +ALLOWED_KEYS = {"schema_version", "locale"} + + +class ConfigError(ValueError): + """The repository configuration is malformed or unsupported.""" + + +def load_repository_config(root: Path) -> dict: + path = Path(root) / CONFIG_FILENAME + if not path.is_file(): + return {} + try: + data = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as exc: + raise ConfigError(f"Invalid {CONFIG_FILENAME}: {exc}") from exc + if not isinstance(data, dict): + raise ConfigError(f"{CONFIG_FILENAME} must contain a JSON object") + unknown = set(data) - ALLOWED_KEYS + if unknown: + raise ConfigError(f"Unknown config fields: {sorted(unknown)}") + if data.get("schema_version") != CONFIG_SCHEMA_VERSION: + raise ConfigError( + f"Unsupported schema_version: {data.get('schema_version')!r}" + ) + if data.get("locale") not in SUPPORTED_LOCALES: + raise ConfigError(f"Unsupported locale: {data.get('locale')!r}") + return data + + +def resolve_locale( + *, + cli_locale: Optional[str], + draft_locale: Optional[str], + root: Path, +) -> str: + config = load_repository_config(Path(root)) + locale = cli_locale or draft_locale or config.get("locale") or DEFAULT_LOCALE + if locale not in SUPPORTED_LOCALES: + raise ConfigError(f"Unsupported locale: {locale!r}") + return locale diff --git a/skills/adr-toolkit/scripts/core/locale.py b/skills/adr-toolkit/scripts/core/locale.py index 9cd6492..4c2b8d9 100644 --- a/skills/adr-toolkit/scripts/core/locale.py +++ b/skills/adr-toolkit/scripts/core/locale.py @@ -9,6 +9,7 @@ from pathlib import Path I18N_DIR = Path(__file__).resolve().parent.parent / "i18n" +SUPPORTED_LOCALES = ("en", "ko", "ja", "zh", "fr", "es", "de", "pt-BR") DEFAULT_LOCALE = "en" diff --git a/tests/unit/test_config.py b/tests/unit/test_config.py new file mode 100644 index 0000000..de15b7b --- /dev/null +++ b/tests/unit/test_config.py @@ -0,0 +1,48 @@ +import json + +import pytest + +from scripts.core.config import ConfigError, load_repository_config, resolve_locale + + +def test_missing_config_is_empty(tmp_path): + assert load_repository_config(tmp_path) == {} + + +def test_loads_versioned_repository_locale(tmp_path): + (tmp_path / ".adr-toolkit.json").write_text( + json.dumps({"schema_version": 1, "locale": "ko"}), encoding="utf-8" + ) + + assert load_repository_config(tmp_path)["locale"] == "ko" + + +@pytest.mark.parametrize( + "payload", + [ + {"schema_version": 2, "locale": "ko"}, + {"schema_version": 1, "locale": "xx"}, + {"schema_version": 1, "locale": "ko", "extra": True}, + ], +) +def test_invalid_config_fails_visibly(tmp_path, payload): + (tmp_path / ".adr-toolkit.json").write_text( + json.dumps(payload), encoding="utf-8" + ) + + with pytest.raises(ConfigError): + load_repository_config(tmp_path) + + +def test_locale_precedence_is_cli_then_draft_then_repo_then_english(tmp_path): + config_path = tmp_path / ".adr-toolkit.json" + config_path.write_text( + json.dumps({"schema_version": 1, "locale": "ko"}), encoding="utf-8" + ) + + assert resolve_locale(cli_locale="ja", draft_locale="fr", root=tmp_path) == "ja" + assert resolve_locale(cli_locale=None, draft_locale="fr", root=tmp_path) == "fr" + assert resolve_locale(cli_locale=None, draft_locale=None, root=tmp_path) == "ko" + + config_path.unlink() + assert resolve_locale(cli_locale=None, draft_locale=None, root=tmp_path) == "en" diff --git a/tests/unit/test_locale.py b/tests/unit/test_locale.py index 1295542..ffd3745 100644 --- a/tests/unit/test_locale.py +++ b/tests/unit/test_locale.py @@ -1,4 +1,4 @@ -from scripts.core.locale import DEFAULT_LOCALE, load_locale +from scripts.core.locale import DEFAULT_LOCALE, SUPPORTED_LOCALES, load_locale REQUIRED_KEYS = { "decision_log_title", "by_status", "by_tag", "by_affected_path", @@ -11,6 +11,12 @@ def test_default_locale_is_english(): assert DEFAULT_LOCALE == "en" +def test_supported_locales_are_canonical_and_ordered(): + assert SUPPORTED_LOCALES == ( + "en", "ko", "ja", "zh", "fr", "es", "de", "pt-BR", + ) + + def test_english_locale_has_all_required_keys(): strings = load_locale("en") assert REQUIRED_KEYS.issubset(strings.keys()) From 966b29b1ddaedab04d76b64abefbcc6ede57a44d Mon Sep 17 00:00:00 2001 From: shcommit Date: Sun, 30 Aug 2026 20:11:43 +0900 Subject: [PATCH 11/76] feat: add eight-locale ADR rendering --- skills/adr-toolkit/scripts/core/rendering.py | 81 ++++++++++++++++++++ skills/adr-toolkit/scripts/i18n/de.json | 44 +++++++++++ skills/adr-toolkit/scripts/i18n/en.json | 34 +++++++- skills/adr-toolkit/scripts/i18n/es.json | 44 +++++++++++ skills/adr-toolkit/scripts/i18n/fr.json | 34 +++++++- skills/adr-toolkit/scripts/i18n/ja.json | 34 +++++++- skills/adr-toolkit/scripts/i18n/ko.json | 34 +++++++- skills/adr-toolkit/scripts/i18n/pt-BR.json | 44 +++++++++++ skills/adr-toolkit/scripts/i18n/zh.json | 34 +++++++- tests/unit/test_locale.py | 26 ++++++- tests/unit/test_rendering.py | 49 ++++++++++++ 11 files changed, 452 insertions(+), 6 deletions(-) create mode 100644 skills/adr-toolkit/scripts/core/rendering.py create mode 100644 skills/adr-toolkit/scripts/i18n/de.json create mode 100644 skills/adr-toolkit/scripts/i18n/es.json create mode 100644 skills/adr-toolkit/scripts/i18n/pt-BR.json create mode 100644 tests/unit/test_rendering.py diff --git a/skills/adr-toolkit/scripts/core/rendering.py b/skills/adr-toolkit/scripts/core/rendering.py new file mode 100644 index 0000000..7673dfa --- /dev/null +++ b/skills/adr-toolkit/scripts/core/rendering.py @@ -0,0 +1,81 @@ +"""Render deterministic ADR Markdown from localized structural strings.""" +from scripts.core.locale import load_locale + +PROMPT_KEYS = ( + "prompt.title", "prompt.problem", "prompt.options", "prompt.decision", + "prompt.rationale", "prompt.good", "prompt.bad", "prompt.confirmation", + "prompt.revisit", +) + + +def render_minimal(locale: str, values: dict) -> str: + strings = load_locale(locale) + options = "\n".join(f"* {option}" for option in values["options"]) + return ( + f"# {values['title']}\n\n" + f"## {strings['heading.context']}\n\n{values['problem']}\n\n" + f"## {strings['heading.considered_options']}\n\n{options}\n\n" + f"## {strings['heading.decision_outcome']}\n\n" + f"{strings['label.chosen_option']}: **{values['decision']}**, " + f"{strings['label.because']} {values['rationale']}.\n\n" + f"## {strings['heading.consequences']}\n\n" + f"* {strings['label.good']}: {values['good']}\n" + f"* {strings['label.bad']}: {values['bad']}\n\n" + f"## {strings['heading.confirmation']}\n\n{values['confirmation']}\n\n" + f"## {strings['heading.revisit_triggers']}\n\n* {values['revisit']}\n" + ) + + +def interactive_prompts(locale: str) -> tuple: + strings = load_locale(locale) + return tuple(strings[key] for key in PROMPT_KEYS) + + +def render_initial_adr(locale: str, adr_dir: str) -> tuple: + strings = load_locale(locale) + values = { + "title": strings["init.title"], + "problem": strings["init.problem"], + "options": [strings["init.option.none"], strings["init.option.wiki"], strings["init.option.adr"]], + "decision": strings["init.decision"], + "rationale": strings["init.rationale"], + "good": strings["init.good"], + "bad": strings["init.bad"], + "confirmation": strings["init.confirmation"].format(adr_dir=adr_dir), + "revisit": strings["init.revisit"], + } + return values["title"], render_minimal(locale, values) + + +def render_template(locale: str, *, full: bool) -> str: + if full: + return _render_full_template(load_locale(locale)) + return render_minimal(locale, { + "title": "{title}", "problem": "{problem and constraints}", + "options": ["{option one}", "{option two}"], + "decision": "{chosen option}", "rationale": "{rationale}", + "good": "{positive consequence}", "bad": "{negative consequence}", + "confirmation": "{how implementation will be verified}", + "revisit": "{condition that should reopen the decision}", + }) + + +def _render_full_template(strings: dict) -> str: + return ( + "# {title}\n\n" + f"## {strings['heading.context']}\n\n{{problem and constraints}}\n\n" + f"## {strings['heading.decision_drivers']}\n\n* {{driver one}}\n* {{driver two}}\n\n" + f"## {strings['heading.considered_options']}\n\n* {{option one}}\n* {{option two}}\n* {{option three}}\n\n" + f"## {strings['heading.decision_outcome']}\n\n" + f"{strings['label.chosen_option']}: **{{chosen option}}**, {strings['label.because']} {{rationale}}.\n\n" + f"### {strings['heading.consequences']}\n\n" + f"* {strings['label.good']}: {{positive consequence}}\n* {strings['label.bad']}: {{negative consequence}}\n\n" + f"### {strings['heading.confirmation']}\n\n{{how implementation will be verified}}\n\n" + f"## {strings['heading.pros_cons']}\n\n### {{option one}}\n\n" + f"* {strings['label.good']}, {strings['label.because']} {{argument}}\n" + f"* {strings['label.bad']}, {strings['label.because']} {{argument}}\n\n" + "### {option two}\n\n" + f"* {strings['label.good']}, {strings['label.because']} {{argument}}\n" + f"* {strings['label.bad']}, {strings['label.because']} {{argument}}\n\n" + f"## {strings['heading.revisit_triggers']}\n\n* {{condition that should reopen the decision}}\n" + ) diff --git a/skills/adr-toolkit/scripts/i18n/de.json b/skills/adr-toolkit/scripts/i18n/de.json new file mode 100644 index 0000000..82ef082 --- /dev/null +++ b/skills/adr-toolkit/scripts/i18n/de.json @@ -0,0 +1,44 @@ +{ + "decision_log_title": "Entscheidungsprotokoll", + "by_status": "Nach Status", + "by_tag": "Nach Tag", + "by_affected_path": "Nach betroffenem Pfad", + "chronological": "Chronologisch (neueste zuerst)", + "status.proposed": "Vorgeschlagen", + "status.accepted": "Akzeptiert", + "status.rejected": "Abgelehnt", + "status.deprecated": "Veraltet", + "status.superseded": "Ersetzt", + "heading.context": "Kontext und Problemstellung", + "heading.decision_drivers": "Entscheidungstreiber", + "heading.considered_options": "Betrachtete Optionen", + "heading.decision_outcome": "Entscheidung", + "heading.consequences": "Konsequenzen", + "heading.confirmation": "Bestätigung", + "heading.pros_cons": "Vor- und Nachteile", + "heading.revisit_triggers": "Auslöser für Neubewertung", + "label.chosen_option": "Gewählte Option", + "label.because": "weil", + "label.good": "Vorteil", + "label.bad": "Nachteil", + "prompt.title": "Wie lautet der Titel der Entscheidung?", + "prompt.problem": "Welches Problem oder welche Einschränkung machte diese Entscheidung erforderlich?", + "prompt.options": "Welche Optionen wurden betrachtet? (durch Kommas getrennt)", + "prompt.decision": "Welche Option wurde gewählt?", + "prompt.rationale": "Warum wurde sie gewählt?", + "prompt.good": "Was ist eine positive Konsequenz?", + "prompt.bad": "Welcher Nachteil wird akzeptiert?", + "prompt.confirmation": "Wie wird diese Entscheidung im Code überprüft?", + "prompt.revisit": "Welche Bedingung soll diese Entscheidung wieder öffnen?", + "init.title": "Architekturentscheidungen dokumentieren", + "init.problem": "Wir benötigen eine einheitliche Methode, um wichtige Architekturentscheidungen festzuhalten und zu kommunizieren, damit spätere Mitwirkende (Menschen oder Agenten) deren Begründung nachvollziehen können.", + "init.option.none": "Keine formale Dokumentation und stattdessen auf Commit-Nachrichten und Erinnerung vertrauen", + "init.option.wiki": "Ein Wiki oder externes Dokumentationswerkzeug verwenden", + "init.option.adr": "Architekturentscheidungen zusammen mit dem Code speichern", + "init.decision": "Architekturentscheidungen zusammen mit dem Code speichern", + "init.rationale": "sie mit dem Code versioniert werden, nahe am beschriebenen Gegenstand bleiben und sowohl für Menschen als auch für Coding-Agenten lesbar sind", + "init.good": "Entscheidungen und ihre Begründungen sind im Repository auffindbar", + "init.bad": "die Dokumentation muss bei Änderungen der Entscheidungen diszipliniert aktuell gehalten werden", + "init.confirmation": "* [ ] `{adr_dir}` enthält diese Datei, eine Vorlage und einen Index.", + "init.revisit": "Das Team führt projektweit ein anderes Dokumentationssystem ein." +} diff --git a/skills/adr-toolkit/scripts/i18n/en.json b/skills/adr-toolkit/scripts/i18n/en.json index 71376ef..0e9dd49 100644 --- a/skills/adr-toolkit/scripts/i18n/en.json +++ b/skills/adr-toolkit/scripts/i18n/en.json @@ -8,5 +8,37 @@ "status.accepted": "Accepted", "status.rejected": "Rejected", "status.deprecated": "Deprecated", - "status.superseded": "Superseded" + "status.superseded": "Superseded", + "heading.context": "Context and Problem Statement", + "heading.decision_drivers": "Decision Drivers", + "heading.considered_options": "Considered Options", + "heading.decision_outcome": "Decision Outcome", + "heading.consequences": "Consequences", + "heading.confirmation": "Confirmation", + "heading.pros_cons": "Pros and Cons of the Options", + "heading.revisit_triggers": "Revisit Triggers", + "label.chosen_option": "Chosen option", + "label.because": "because", + "label.good": "Good", + "label.bad": "Bad", + "prompt.title": "Title of the decision?", + "prompt.problem": "What problem or constraint made this decision necessary?", + "prompt.options": "Options considered (comma-separated)?", + "prompt.decision": "Which option was chosen?", + "prompt.rationale": "Why was it chosen?", + "prompt.good": "One good consequence?", + "prompt.bad": "One accepted downside?", + "prompt.confirmation": "How will this be verified in the code?", + "prompt.revisit": "What condition should reopen this decision?", + "init.title": "Record architecture decisions", + "init.problem": "We need a consistent way to capture and communicate significant architectural decisions so future contributors (human or agent) can find the reasoning behind them.", + "init.option.none": "No formal record, rely on commit messages and memory", + "init.option.wiki": "Wiki or external documentation tool", + "init.option.adr": "Architecture Decision Records stored alongside the code", + "init.decision": "Architecture Decision Records stored alongside the code", + "init.rationale": "they version with the code, stay close to what they describe, and are readable by both humans and coding agents", + "init.good": "decisions and their rationale are discoverable in the repository itself", + "init.bad": "requires discipline to keep records up to date as decisions evolve", + "init.confirmation": "* [ ] `{adr_dir}` exists with this file, a template, and an index.", + "init.revisit": "The team adopts a different documentation system project-wide." } diff --git a/skills/adr-toolkit/scripts/i18n/es.json b/skills/adr-toolkit/scripts/i18n/es.json new file mode 100644 index 0000000..aa34be2 --- /dev/null +++ b/skills/adr-toolkit/scripts/i18n/es.json @@ -0,0 +1,44 @@ +{ + "decision_log_title": "Registro de decisiones", + "by_status": "Por estado", + "by_tag": "Por etiqueta", + "by_affected_path": "Por ruta afectada", + "chronological": "Cronológico (más reciente primero)", + "status.proposed": "Propuesta", + "status.accepted": "Aceptada", + "status.rejected": "Rechazada", + "status.deprecated": "Obsoleta", + "status.superseded": "Sustituida", + "heading.context": "Contexto y problema", + "heading.decision_drivers": "Impulsores de la decisión", + "heading.considered_options": "Opciones consideradas", + "heading.decision_outcome": "Resultado de la decisión", + "heading.consequences": "Consecuencias", + "heading.confirmation": "Confirmación", + "heading.pros_cons": "Ventajas y desventajas", + "heading.revisit_triggers": "Condiciones de revisión", + "label.chosen_option": "Opción elegida", + "label.because": "porque", + "label.good": "Ventaja", + "label.bad": "Desventaja", + "prompt.title": "¿Cuál es el título de la decisión?", + "prompt.problem": "¿Qué problema o restricción hizo necesaria esta decisión?", + "prompt.options": "¿Qué opciones se consideraron? (separadas por comas)", + "prompt.decision": "¿Qué opción se eligió?", + "prompt.rationale": "¿Por qué se eligió?", + "prompt.good": "¿Cuál es una consecuencia positiva?", + "prompt.bad": "¿Cuál es una desventaja aceptada?", + "prompt.confirmation": "¿Cómo se verificará esta decisión en el código?", + "prompt.revisit": "¿Qué condición debería reabrir esta decisión?", + "init.title": "Registrar las decisiones de arquitectura", + "init.problem": "Necesitamos una forma coherente de registrar y comunicar las decisiones de arquitectura importantes para que futuros colaboradores (personas o agentes) puedan encontrar sus fundamentos.", + "init.option.none": "No mantener un registro formal y depender de los mensajes de commit y la memoria", + "init.option.wiki": "Usar una wiki o una herramienta de documentación externa", + "init.option.adr": "Guardar los registros de decisiones de arquitectura junto al código", + "init.decision": "Guardar los registros de decisiones de arquitectura junto al código", + "init.rationale": "se versionan con el código, permanecen cerca de lo que describen y pueden leerlos tanto personas como agentes de programación", + "init.good": "las decisiones y sus fundamentos se pueden encontrar en el propio repositorio", + "init.bad": "se requiere disciplina para mantener los registros actualizados cuando evolucionan las decisiones", + "init.confirmation": "* [ ] `{adr_dir}` contiene este archivo, una plantilla y un índice.", + "init.revisit": "El equipo adopta otro sistema de documentación para todo el proyecto." +} diff --git a/skills/adr-toolkit/scripts/i18n/fr.json b/skills/adr-toolkit/scripts/i18n/fr.json index baad8e9..05db63e 100644 --- a/skills/adr-toolkit/scripts/i18n/fr.json +++ b/skills/adr-toolkit/scripts/i18n/fr.json @@ -8,5 +8,37 @@ "status.accepted": "Accepté", "status.rejected": "Rejeté", "status.deprecated": "Déprécié", - "status.superseded": "Remplacé" + "status.superseded": "Remplacé", + "heading.context": "Contexte et problème", + "heading.decision_drivers": "Facteurs de décision", + "heading.considered_options": "Options envisagées", + "heading.decision_outcome": "Décision", + "heading.consequences": "Conséquences", + "heading.confirmation": "Confirmation", + "heading.pros_cons": "Avantages et inconvénients", + "heading.revisit_triggers": "Conditions de réexamen", + "label.chosen_option": "Option retenue", + "label.because": "car", + "label.good": "Avantage", + "label.bad": "Inconvénient", + "prompt.title": "Quel est le titre de la décision ?", + "prompt.problem": "Quel problème ou quelle contrainte a rendu cette décision nécessaire ?", + "prompt.options": "Quelles options ont été envisagées ? (séparées par des virgules)", + "prompt.decision": "Quelle option a été retenue ?", + "prompt.rationale": "Pourquoi a-t-elle été retenue ?", + "prompt.good": "Quelle est une conséquence positive ?", + "prompt.bad": "Quel inconvénient est accepté ?", + "prompt.confirmation": "Comment cette décision sera-t-elle vérifiée dans le code ?", + "prompt.revisit": "Quelle condition doit conduire à réexaminer cette décision ?", + "init.title": "Consigner les décisions d’architecture", + "init.problem": "Nous avons besoin d’une méthode cohérente pour consigner et communiquer les décisions d’architecture importantes afin que les futurs contributeurs (humains ou agents) puissent en retrouver la justification.", + "init.option.none": "Ne tenir aucun registre formel et s’appuyer sur les messages de commit et la mémoire", + "init.option.wiki": "Utiliser un wiki ou un outil de documentation externe", + "init.option.adr": "Stocker les décisions d’architecture avec le code", + "init.decision": "Stocker les décisions d’architecture avec le code", + "init.rationale": "elles sont versionnées avec le code, restent proches de ce qu’elles décrivent et sont lisibles par les humains comme par les agents de développement", + "init.good": "les décisions et leur justification sont accessibles dans le dépôt", + "init.bad": "il faut maintenir les enregistrements à jour lorsque les décisions évoluent", + "init.confirmation": "* [ ] `{adr_dir}` contient ce fichier, un modèle et un index.", + "init.revisit": "L’équipe adopte un autre système de documentation pour l’ensemble du projet." } diff --git a/skills/adr-toolkit/scripts/i18n/ja.json b/skills/adr-toolkit/scripts/i18n/ja.json index a8f0ffc..7562278 100644 --- a/skills/adr-toolkit/scripts/i18n/ja.json +++ b/skills/adr-toolkit/scripts/i18n/ja.json @@ -8,5 +8,37 @@ "status.accepted": "承認済み", "status.rejected": "却下", "status.deprecated": "非推奨", - "status.superseded": "置き換え済み" + "status.superseded": "置き換え済み", + "heading.context": "コンテキストと問題", + "heading.decision_drivers": "決定要因", + "heading.considered_options": "検討した選択肢", + "heading.decision_outcome": "決定結果", + "heading.consequences": "影響", + "heading.confirmation": "確認方法", + "heading.pros_cons": "選択肢の長所と短所", + "heading.revisit_triggers": "再検討条件", + "label.chosen_option": "選択した案", + "label.because": "理由", + "label.good": "長所", + "label.bad": "短所", + "prompt.title": "決定のタイトルは何ですか?", + "prompt.problem": "どのような問題または制約により、この決定が必要になりましたか?", + "prompt.options": "検討した選択肢は何ですか?(カンマ区切り)", + "prompt.decision": "どの選択肢を採用しましたか?", + "prompt.rationale": "なぜその選択肢を採用しましたか?", + "prompt.good": "期待される長所を一つ挙げてください。", + "prompt.bad": "受け入れる短所を一つ挙げてください。", + "prompt.confirmation": "コード上でこの決定をどのように確認しますか?", + "prompt.revisit": "どの条件でこの決定を再検討しますか?", + "init.title": "アーキテクチャ上の決定を記録する", + "init.problem": "重要なアーキテクチャ上の決定を一貫して記録・共有し、将来の貢献者(人間またはエージェント)がその根拠を確認できるようにする必要がある。", + "init.option.none": "正式な記録を残さず、コミットメッセージと記憶に頼る", + "init.option.wiki": "Wikiまたは外部ドキュメントツールを使う", + "init.option.adr": "コードと共にアーキテクチャ決定記録を保存する", + "init.decision": "コードと共にアーキテクチャ決定記録を保存する", + "init.rationale": "コードと一緒にバージョン管理され、対象の近くに置かれ、人間とコーディングエージェントの双方が読めるため", + "init.good": "リポジトリ内で決定と根拠を確認できる", + "init.bad": "決定の変更に合わせて記録を最新に保つ規律が必要になる", + "init.confirmation": "* [ ] `{adr_dir}`にこのファイル、テンプレート、インデックスが存在する。", + "init.revisit": "チームがプロジェクト全体で別のドキュメントシステムを採用する。" } diff --git a/skills/adr-toolkit/scripts/i18n/ko.json b/skills/adr-toolkit/scripts/i18n/ko.json index e26020b..3114563 100644 --- a/skills/adr-toolkit/scripts/i18n/ko.json +++ b/skills/adr-toolkit/scripts/i18n/ko.json @@ -8,5 +8,37 @@ "status.accepted": "승인됨", "status.rejected": "거부됨", "status.deprecated": "폐기됨", - "status.superseded": "대체됨" + "status.superseded": "대체됨", + "heading.context": "맥락 및 문제 설명", + "heading.decision_drivers": "의사결정 동인", + "heading.considered_options": "검토한 대안", + "heading.decision_outcome": "결정 결과", + "heading.consequences": "결과 및 영향", + "heading.confirmation": "확인 방법", + "heading.pros_cons": "대안별 장단점", + "heading.revisit_triggers": "재검토 조건", + "label.chosen_option": "선택한 대안", + "label.because": "그 이유는", + "label.good": "장점", + "label.bad": "단점", + "prompt.title": "결정의 제목은 무엇인가요?", + "prompt.problem": "어떤 문제나 제약 때문에 이 결정이 필요한가요?", + "prompt.options": "검토한 대안은 무엇인가요? (쉼표로 구분)", + "prompt.decision": "어떤 대안을 선택했나요?", + "prompt.rationale": "왜 이 대안을 선택했나요?", + "prompt.good": "기대하는 장점 한 가지는 무엇인가요?", + "prompt.bad": "감수하기로 한 단점 한 가지는 무엇인가요?", + "prompt.confirmation": "코드에서 이 결정을 어떻게 확인할 수 있나요?", + "prompt.revisit": "어떤 조건에서 이 결정을 재검토해야 하나요?", + "init.title": "아키텍처 결정을 기록한다", + "init.problem": "중요한 아키텍처 결정을 일관되게 기록하고 공유하여, 이후 기여자(사람 또는 에이전트)가 그 근거를 찾을 수 있어야 한다.", + "init.option.none": "공식 기록 없이 커밋 메시지와 기억에 의존한다", + "init.option.wiki": "위키 또는 외부 문서 도구를 사용한다", + "init.option.adr": "코드와 함께 아키텍처 결정 기록을 저장한다", + "init.decision": "코드와 함께 아키텍처 결정 기록을 저장한다", + "init.rationale": "코드와 함께 버전 관리되고 설명 대상 가까이에 있으며 사람과 코딩 에이전트 모두 읽을 수 있기 때문이다", + "init.good": "저장소에서 결정과 근거를 직접 찾을 수 있다", + "init.bad": "결정이 바뀔 때 기록도 최신 상태로 유지하는 규율이 필요하다", + "init.confirmation": "* [ ] `{adr_dir}`에 이 파일, 템플릿, 인덱스가 존재한다.", + "init.revisit": "팀이 프로젝트 전체에 다른 문서 시스템을 도입한다." } diff --git a/skills/adr-toolkit/scripts/i18n/pt-BR.json b/skills/adr-toolkit/scripts/i18n/pt-BR.json new file mode 100644 index 0000000..9d23e92 --- /dev/null +++ b/skills/adr-toolkit/scripts/i18n/pt-BR.json @@ -0,0 +1,44 @@ +{ + "decision_log_title": "Registro de decisões", + "by_status": "Por status", + "by_tag": "Por etiqueta", + "by_affected_path": "Por caminho afetado", + "chronological": "Cronológico (mais recente primeiro)", + "status.proposed": "Proposta", + "status.accepted": "Aceita", + "status.rejected": "Rejeitada", + "status.deprecated": "Obsoleta", + "status.superseded": "Substituída", + "heading.context": "Contexto e problema", + "heading.decision_drivers": "Direcionadores da decisão", + "heading.considered_options": "Opções consideradas", + "heading.decision_outcome": "Resultado da decisão", + "heading.consequences": "Consequências", + "heading.confirmation": "Confirmação", + "heading.pros_cons": "Prós e contras", + "heading.revisit_triggers": "Condições para reavaliação", + "label.chosen_option": "Opção escolhida", + "label.because": "porque", + "label.good": "Vantagem", + "label.bad": "Desvantagem", + "prompt.title": "Qual é o título da decisão?", + "prompt.problem": "Qual problema ou restrição tornou esta decisão necessária?", + "prompt.options": "Quais opções foram consideradas? (separadas por vírgulas)", + "prompt.decision": "Qual opção foi escolhida?", + "prompt.rationale": "Por que ela foi escolhida?", + "prompt.good": "Qual é uma consequência positiva?", + "prompt.bad": "Qual é uma desvantagem aceita?", + "prompt.confirmation": "Como esta decisão será verificada no código?", + "prompt.revisit": "Qual condição deve reabrir esta decisão?", + "init.title": "Registrar decisões de arquitetura", + "init.problem": "Precisamos de uma forma consistente de registrar e comunicar decisões de arquitetura importantes para que futuros colaboradores (pessoas ou agentes) encontrem seus fundamentos.", + "init.option.none": "Não manter registro formal e depender de mensagens de commit e da memória", + "init.option.wiki": "Usar uma wiki ou ferramenta externa de documentação", + "init.option.adr": "Armazenar registros de decisões de arquitetura junto ao código", + "init.decision": "Armazenar registros de decisões de arquitetura junto ao código", + "init.rationale": "eles são versionados com o código, permanecem próximos do que descrevem e podem ser lidos por pessoas e agentes de programação", + "init.good": "as decisões e seus fundamentos podem ser encontrados no próprio repositório", + "init.bad": "é preciso disciplina para manter os registros atualizados conforme as decisões evoluem", + "init.confirmation": "* [ ] `{adr_dir}` contém este arquivo, um modelo e um índice.", + "init.revisit": "A equipe adota outro sistema de documentação em todo o projeto." +} diff --git a/skills/adr-toolkit/scripts/i18n/zh.json b/skills/adr-toolkit/scripts/i18n/zh.json index 3ae60c6..3c07854 100644 --- a/skills/adr-toolkit/scripts/i18n/zh.json +++ b/skills/adr-toolkit/scripts/i18n/zh.json @@ -8,5 +8,37 @@ "status.accepted": "已接受", "status.rejected": "已拒绝", "status.deprecated": "已弃用", - "status.superseded": "已取代" + "status.superseded": "已取代", + "heading.context": "背景与问题陈述", + "heading.decision_drivers": "决策驱动因素", + "heading.considered_options": "考虑的选项", + "heading.decision_outcome": "决策结果", + "heading.consequences": "后果", + "heading.confirmation": "确认方式", + "heading.pros_cons": "各选项的优缺点", + "heading.revisit_triggers": "重新评估条件", + "label.chosen_option": "选择的方案", + "label.because": "原因是", + "label.good": "优点", + "label.bad": "缺点", + "prompt.title": "决策标题是什么?", + "prompt.problem": "什么问题或约束使这项决策成为必要?", + "prompt.options": "考虑了哪些选项?(用逗号分隔)", + "prompt.decision": "选择了哪个选项?", + "prompt.rationale": "为什么选择它?", + "prompt.good": "一项正面影响是什么?", + "prompt.bad": "一项可接受的负面影响是什么?", + "prompt.confirmation": "如何在代码中验证这项决策?", + "prompt.revisit": "什么条件应触发重新评估?", + "init.title": "记录架构决策", + "init.problem": "我们需要以一致的方式记录和传达重要的架构决策,使未来的贡献者(人或智能体)能够找到决策依据。", + "init.option.none": "不做正式记录,依赖提交信息和记忆", + "init.option.wiki": "使用 Wiki 或外部文档工具", + "init.option.adr": "将架构决策记录与代码一起保存", + "init.decision": "将架构决策记录与代码一起保存", + "init.rationale": "它们与代码共同进行版本控制,靠近所描述的内容,并且人和编码智能体都可以阅读", + "init.good": "可直接在仓库中找到决策及其依据", + "init.bad": "决策演进时需要保持记录更新的纪律", + "init.confirmation": "* [ ] `{adr_dir}`中存在此文件、模板和索引。", + "init.revisit": "团队在整个项目中采用其他文档系统。" } diff --git a/tests/unit/test_locale.py b/tests/unit/test_locale.py index ffd3745..2b5ee55 100644 --- a/tests/unit/test_locale.py +++ b/tests/unit/test_locale.py @@ -1,4 +1,6 @@ -from scripts.core.locale import DEFAULT_LOCALE, SUPPORTED_LOCALES, load_locale +import json + +from scripts.core.locale import DEFAULT_LOCALE, I18N_DIR, SUPPORTED_LOCALES, load_locale REQUIRED_KEYS = { "decision_log_title", "by_status", "by_tag", "by_affected_path", @@ -6,6 +8,19 @@ "status.rejected", "status.deprecated", "status.superseded", } +REQUIRED_GENERATION_KEYS = { + "heading.context", "heading.decision_drivers", + "heading.considered_options", "heading.decision_outcome", + "heading.consequences", "heading.confirmation", "heading.pros_cons", + "heading.revisit_triggers", "label.chosen_option", "label.because", + "label.good", "label.bad", "prompt.title", "prompt.problem", + "prompt.options", "prompt.decision", "prompt.rationale", "prompt.good", + "prompt.bad", "prompt.confirmation", "prompt.revisit", "init.title", + "init.problem", "init.option.none", "init.option.wiki", "init.option.adr", + "init.decision", "init.rationale", "init.good", "init.bad", + "init.confirmation", "init.revisit", +} + def test_default_locale_is_english(): assert DEFAULT_LOCALE == "en" @@ -29,6 +44,15 @@ def test_every_shipped_locale_has_all_required_keys(): assert not missing, f"{locale} is missing keys: {missing}" +def test_every_catalog_exactly_matches_english_generation_keys(): + english = json.loads((I18N_DIR / "en.json").read_text(encoding="utf-8")) + assert REQUIRED_GENERATION_KEYS <= set(english) + + for code in SUPPORTED_LOCALES: + catalog = json.loads((I18N_DIR / f"{code}.json").read_text(encoding="utf-8")) + assert set(catalog) == set(english), code + + def test_french_translates_a_known_string(): strings = load_locale("fr") assert strings["by_status"] == "Par statut" diff --git a/tests/unit/test_rendering.py b/tests/unit/test_rendering.py new file mode 100644 index 0000000..af6aa3b --- /dev/null +++ b/tests/unit/test_rendering.py @@ -0,0 +1,49 @@ +from scripts.core.rendering import ( + interactive_prompts, + render_initial_adr, + render_minimal, + render_template, +) + + +def test_korean_minimal_uses_korean_structure_and_preserves_answers(): + body = render_minimal( + "ko", + { + "title": "결제 시스템 분리", + "problem": "장애 격리가 필요하다.", + "options": ["유지", "분리"], + "decision": "분리", + "rationale": "격리할 수 있다", + "good": "독립 배포", + "bad": "운영 복잡성", + "confirmation": "CI에서 경계를 검사한다.", + "revisit": "운영 비용이 편익을 초과할 때", + }, + ) + + assert "## 맥락 및 문제 설명" in body + assert "장애 격리가 필요하다." in body + assert "## 재검토 조건" in body + + +def test_japanese_full_template_has_full_sections(): + template = render_template("ja", full=True) + + assert "## 決定要因" in template + assert "## 選択肢の長所と短所" in template + + +def test_korean_prompts_are_localized_in_stable_order(): + prompts = interactive_prompts("ko") + + assert prompts[0] == "결정의 제목은 무엇인가요?" + assert prompts[-1] == "어떤 조건에서 이 결정을 재검토해야 하나요?" + + +def test_initial_adr_uses_locale_boilerplate_and_path(): + title, body = render_initial_adr("fr", "docs/decisions/") + + assert title == "Consigner les décisions d’architecture" + assert "`docs/decisions/`" in body + assert "## Contexte et problème" in body From efc689aed8da243d80eb330c00765e7e59369a46 Mon Sep 17 00:00:00 2001 From: shcommit Date: Sun, 30 Aug 2026 20:13:00 +0900 Subject: [PATCH 12/76] feat: localize ADR repository initialization --- skills/adr-toolkit/scripts/adr.py | 3 + skills/adr-toolkit/scripts/commands/init.py | 87 +++++++++++---------- tests/integration/test_init_workflow.py | 4 + tests/unit/test_adr_cli.py | 10 +++ tests/unit/test_init.py | 68 +++++++++++++++- 5 files changed, 128 insertions(+), 44 deletions(-) diff --git a/skills/adr-toolkit/scripts/adr.py b/skills/adr-toolkit/scripts/adr.py index c5d0c4f..9207776 100755 --- a/skills/adr-toolkit/scripts/adr.py +++ b/skills/adr-toolkit/scripts/adr.py @@ -21,6 +21,7 @@ supersede, validate, ) +from scripts.core.locale import SUPPORTED_LOCALES def build_parser() -> argparse.ArgumentParser: @@ -37,6 +38,8 @@ def build_parser() -> argparse.ArgumentParser: p_init = sub.add_parser("init") p_init.add_argument("--dir", default="docs/decisions") + p_init.add_argument("--root", default=".") + p_init.add_argument("--locale", choices=SUPPORTED_LOCALES) p_init.add_argument("--dry-run", dest="dry_run", action="store_true") p_init.add_argument("--json", action="store_true") diff --git a/skills/adr-toolkit/scripts/commands/init.py b/skills/adr-toolkit/scripts/commands/init.py index 416749a..4cb357d 100644 --- a/skills/adr-toolkit/scripts/commands/init.py +++ b/skills/adr-toolkit/scripts/commands/init.py @@ -1,45 +1,20 @@ -"""Scaffold an ADR directory for a repository that has none yet.""" -import shutil +"""Scaffold a localized ADR directory for a repository that has none yet.""" +import json 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 - -* [ ] `{adr_dir}` exists with this file, a template, and an index. - -## Revisit Triggers - -* The team adopts a different documentation system project-wide. -""" +from scripts.core.config import ( + CONFIG_FILENAME, + CONFIG_SCHEMA_VERSION, + ConfigError, + resolve_locale, +) +from scripts.core.rendering import render_initial_adr, render_template def run(args) -> dict: adr_dir = Path(args.dir) + root = Path(getattr(args, "root", ".")) dry_run = getattr(args, "dry_run", False) if adr_dir.exists() and any(adr_dir.iterdir()): @@ -49,26 +24,58 @@ def run(args) -> dict: "errors": [{"code": "ADR_DIRECTORY_NOT_EMPTY", "path": str(adr_dir)}], } + config_path = root / CONFIG_FILENAME + config_exists = config_path.is_file() + try: + locale = resolve_locale( + cli_locale=getattr(args, "locale", None), + draft_locale=None, + root=root, + ) + except ConfigError as exc: + return { + "ok": False, + "operation": "init", + "errors": [{"code": "CONFIG_ERROR", "detail": str(exc)}], + } + would_create = [ str(adr_dir), str(adr_dir / "adr-template.md"), str(adr_dir / "0001-record-architecture-decisions.md"), ] + if not config_exists: + would_create.insert(0, str(config_path)) 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") - adr_dir_str = str(args.dir).rstrip("/") + "/" + title, body = render_initial_adr(locale, adr_dir_str) + + adr_dir.mkdir(parents=True, exist_ok=True) + (adr_dir / "adr-template.md").write_text( + render_template(locale, full=False), encoding="utf-8" + ) + if not config_exists: + config_path.parent.mkdir(parents=True, exist_ok=True) + config_path.write_text( + json.dumps( + {"schema_version": CONFIG_SCHEMA_VERSION, "locale": locale}, + indent=2, + ensure_ascii=False, + ) + + "\n", + encoding="utf-8", + ) frontmatter_block = ( "---\n" "id: ADR-0001\n" - "title: Record architecture decisions\n" + f"title: {title}\n" "status: accepted\n" f"date: {date.today().isoformat()}\n" + f"locale: {locale}\n" "decision_makers: []\n" "related: []\n" "affected_paths:\n" @@ -79,7 +86,7 @@ def run(args) -> dict: "---\n\n" ) (adr_dir / "0001-record-architecture-decisions.md").write_text( - frontmatter_block + INITIAL_ADR_BODY.format(adr_dir=adr_dir_str), encoding="utf-8" + frontmatter_block + body, encoding="utf-8" ) return {"ok": True, "operation": "init", "dry_run": False, "created": would_create} diff --git a/tests/integration/test_init_workflow.py b/tests/integration/test_init_workflow.py index 4a92df2..d7de91a 100644 --- a/tests/integration/test_init_workflow.py +++ b/tests/integration/test_init_workflow.py @@ -29,6 +29,10 @@ def test_full_init_flow_on_js_fixture(tmp_path): init_result = _run(["init", "--dir", "docs/decisions", "--json"], cwd=repo) assert init_result["ok"] is True + assert json.loads((repo / ".adr-toolkit.json").read_text(encoding="utf-8")) == { + "schema_version": 1, + "locale": "en", + } validate_result = _run(["validate", "--dir", "docs/decisions", "--json"], cwd=repo) assert validate_result["ok"] is True diff --git a/tests/unit/test_adr_cli.py b/tests/unit/test_adr_cli.py index 9642efa..633fb7a 100644 --- a/tests/unit/test_adr_cli.py +++ b/tests/unit/test_adr_cli.py @@ -49,3 +49,13 @@ def test_index_locale_rejects_an_unrecognized_code_instead_of_silently_using_eng def test_index_locale_defaults_to_english_when_omitted(): assert adr.build_parser().parse_args(["index"]).locale == "en" + + +def test_init_locale_is_optional_and_constrained(): + parser = adr.build_parser() + + assert parser.parse_args(["init"]).locale is None + assert parser.parse_args(["init", "--locale", "pt-BR"]).locale == "pt-BR" + + with pytest.raises(SystemExit): + parser.parse_args(["init", "--locale", "xx"]) diff --git a/tests/unit/test_init.py b/tests/unit/test_init.py index b5d19a9..01343ea 100644 --- a/tests/unit/test_init.py +++ b/tests/unit/test_init.py @@ -1,11 +1,14 @@ from types import SimpleNamespace +import json 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)) + result = init.run(SimpleNamespace( + dir=str(adr_dir), root=str(tmp_path), locale=None, dry_run=True, + )) assert result["ok"] is True assert result["dry_run"] is True assert not adr_dir.exists() @@ -13,7 +16,9 @@ def test_dry_run_reports_would_create_without_writing(tmp_path): 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)) + result = init.run(SimpleNamespace( + dir=str(adr_dir), root=str(tmp_path), locale=None, 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" @@ -25,7 +30,9 @@ def test_creates_directory_template_and_first_adr(tmp_path): def test_affected_paths_and_confirmation_reflect_custom_dir(tmp_path): adr_dir = tmp_path / "custom" / "adr-location" - result = init.run(SimpleNamespace(dir=str(adr_dir), dry_run=False)) + result = init.run(SimpleNamespace( + dir=str(adr_dir), root=str(tmp_path), locale=None, dry_run=False, + )) assert result["ok"] is True content = (adr_dir / "0001-record-architecture-decisions.md").read_text(encoding="utf-8") @@ -40,6 +47,59 @@ def test_refuses_to_run_on_non_empty_directory(tmp_path): 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)) + result = init.run(SimpleNamespace( + dir=str(adr_dir), root=str(tmp_path), locale=None, dry_run=False, + )) assert result["ok"] is False assert result["errors"][0]["code"] == "ADR_DIRECTORY_NOT_EMPTY" + assert not (tmp_path / ".adr-toolkit.json").exists() + + +def test_init_creates_config_and_korean_scaffold(tmp_path): + adr_dir = tmp_path / "docs" / "decisions" + + result = init.run(SimpleNamespace( + dir=str(adr_dir), root=str(tmp_path), locale="ko", dry_run=False, + )) + + assert result["ok"] is True + config = json.loads((tmp_path / ".adr-toolkit.json").read_text(encoding="utf-8")) + assert config == {"schema_version": 1, "locale": "ko"} + adr = (adr_dir / "0001-record-architecture-decisions.md").read_text(encoding="utf-8") + assert "locale: ko" in adr + assert "## 맥락 및 문제 설명" in adr + assert "# 아키텍처 결정을 기록한다" in adr + + +def test_init_dry_run_lists_config_without_writing(tmp_path): + config_path = tmp_path / ".adr-toolkit.json" + + result = init.run(SimpleNamespace( + dir=str(tmp_path / "docs/decisions"), + root=str(tmp_path), + locale="fr", + dry_run=True, + )) + + assert str(config_path) in result["would_create"] + assert not config_path.exists() + + +def test_init_never_overwrites_existing_config(tmp_path): + config = tmp_path / ".adr-toolkit.json" + config.write_text( + json.dumps({"schema_version": 1, "locale": "ja"}), encoding="utf-8" + ) + + result = init.run(SimpleNamespace( + dir=str(tmp_path / "docs/decisions"), + root=str(tmp_path), + locale=None, + dry_run=False, + )) + + assert result["ok"] is True + assert json.loads(config.read_text(encoding="utf-8"))["locale"] == "ja" + adr = (tmp_path / "docs/decisions/0001-record-architecture-decisions.md").read_text(encoding="utf-8") + assert "locale: ja" in adr + assert "## コンテキストと問題" in adr From f6c1efb3964ad01fa5045a319317e75fb9d16547 Mon Sep 17 00:00:00 2001 From: shcommit Date: Sun, 30 Aug 2026 20:15:03 +0900 Subject: [PATCH 13/76] feat: create localized ADRs with portable slugs --- skills/adr-toolkit/scripts/adr.py | 3 + skills/adr-toolkit/scripts/commands/create.py | 105 ++++++++++++------ .../adr-toolkit/scripts/core/identifiers.py | 13 +++ tests/unit/test_adr_cli.py | 16 +++ tests/unit/test_create.py | 105 +++++++++++++++--- tests/unit/test_create_interactive.py | 28 ++++- tests/unit/test_identifiers.py | 18 +++ 7 files changed, 232 insertions(+), 56 deletions(-) diff --git a/skills/adr-toolkit/scripts/adr.py b/skills/adr-toolkit/scripts/adr.py index 9207776..76c4796 100755 --- a/skills/adr-toolkit/scripts/adr.py +++ b/skills/adr-toolkit/scripts/adr.py @@ -47,6 +47,9 @@ def build_parser() -> argparse.ArgumentParser: p_create.add_argument("--input") p_create.add_argument("--interactive", action="store_true") p_create.add_argument("--dir", default="docs/decisions") + p_create.add_argument("--root", default=".") + p_create.add_argument("--locale", choices=SUPPORTED_LOCALES) + p_create.add_argument("--slug") p_create.add_argument("--dry-run", dest="dry_run", action="store_true") p_create.add_argument("--json", action="store_true") diff --git a/skills/adr-toolkit/scripts/commands/create.py b/skills/adr-toolkit/scripts/commands/create.py index c4a9ab9..d178655 100644 --- a/skills/adr-toolkit/scripts/commands/create.py +++ b/skills/adr-toolkit/scripts/commands/create.py @@ -6,6 +6,8 @@ from scripts.core import frontmatter as fm from scripts.core import identifiers +from scripts.core.config import ConfigError, resolve_locale +from scripts.core.rendering import interactive_prompts, render_minimal from scripts.core.schema import validate_frontmatter REQUIRED_DRAFT_FIELDS = {"title", "status", "body"} @@ -17,47 +19,54 @@ def _prompt(input_fn, question: str) -> str: return input_fn("").strip() -def gather_draft_interactively(input_fn=None) -> dict: +def gather_draft_interactively(locale: str, input_fn=None) -> dict: if input_fn is None: input_fn = input - 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)?") + prompts = interactive_prompts(locale) + title = _prompt(input_fn, prompts[0]) + problem = _prompt(input_fn, prompts[1]) + options_raw = _prompt(input_fn, prompts[2]) 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} + decision = _prompt(input_fn, prompts[3]) + rationale = _prompt(input_fn, prompts[4]) + good = _prompt(input_fn, prompts[5]) + bad = _prompt(input_fn, prompts[6]) + confirmation = _prompt(input_fn, prompts[7]) + revisit = _prompt(input_fn, prompts[8]) + + body = render_minimal(locale, { + "title": title, + "problem": problem, + "options": options or [decision], + "decision": decision, + "rationale": rationale, + "good": good, + "bad": bad, + "confirmation": confirmation, + "revisit": revisit, + }) + + return {"title": title, "status": "proposed", "body": body, "locale": locale} def run(args) -> dict: dry_run = getattr(args, "dry_run", False) + root = Path(getattr(args, "root", ".")) if getattr(args, "interactive", False): - draft = gather_draft_interactively() + try: + locale = resolve_locale( + cli_locale=getattr(args, "locale", None), + draft_locale=None, + root=root, + ) + except ConfigError as exc: + return { + "ok": False, + "operation": "create", + "errors": [{"code": "CONFIG_ERROR", "detail": str(exc)}], + } + draft = gather_draft_interactively(locale) else: input_path = getattr(args, "input", None) if not input_path: @@ -83,6 +92,19 @@ def run(args) -> dict: "errors": [{"code": "DRAFT_FILE_INVALID_JSON", "path": input_path, "detail": str(exc)}], } + try: + locale = resolve_locale( + cli_locale=getattr(args, "locale", None), + draft_locale=draft.get("locale"), + root=root, + ) + except ConfigError as exc: + return { + "ok": False, + "operation": "create", + "errors": [{"code": "CONFIG_ERROR", "detail": str(exc)}], + } + adr_dir = Path(args.dir) missing = REQUIRED_DRAFT_FIELDS - draft.keys() if missing: @@ -92,14 +114,24 @@ def run(args) -> dict: "errors": [{"code": "MISSING_DRAFT_FIELD", "fields": sorted(missing)}], } - next_num = identifiers.next_id(adr_dir) - slug = identifiers.slugify(draft["title"]) - if not slug: + cli_slug = getattr(args, "slug", None) + draft_slug = draft.get("slug") + if cli_slug is not None and draft_slug is not None and cli_slug != draft_slug: return { "ok": False, "operation": "create", - "errors": [{"code": "EMPTY_SLUG", "detail": "title must contain at least one ASCII letter or digit"}], + "errors": [{"code": "CONFLICTING_SLUG_INPUT"}], } + try: + slug = identifiers.slug_for_title(draft["title"], cli_slug or draft_slug) + except ValueError as exc: + return { + "ok": False, + "operation": "create", + "errors": [{"code": "INVALID_SLUG", "detail": str(exc)}], + } + + next_num = identifiers.next_id(adr_dir) filename = identifiers.format_filename(next_num, slug) target = adr_dir / filename @@ -115,6 +147,7 @@ def run(args) -> dict: "title": draft["title"], "status": draft["status"], "date": draft.get("date") or date.today().isoformat(), + "locale": locale, "decision_makers": draft.get("decision_makers", []), "related": draft.get("related", []), "affected_paths": draft.get("affected_paths", []), diff --git a/skills/adr-toolkit/scripts/core/identifiers.py b/skills/adr-toolkit/scripts/core/identifiers.py index 97b06b1..7c8b804 100644 --- a/skills/adr-toolkit/scripts/core/identifiers.py +++ b/skills/adr-toolkit/scripts/core/identifiers.py @@ -4,6 +4,7 @@ from typing import Optional ADR_FILENAME_RE = re.compile(r"^(\d{4})-([a-z0-9]+(?:-[a-z0-9]+)*)\.md$") +SLUG_RE = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$") def parse_filename(filename: str) -> Optional[tuple]: @@ -31,6 +32,18 @@ def slugify(title: str) -> str: return re.sub(r"-+", "-", slug) +def validate_slug(slug: str) -> str: + if not SLUG_RE.fullmatch(slug): + raise ValueError("slug must match [a-z0-9]+(?:-[a-z0-9]+)*") + return slug + + +def slug_for_title(title: str, explicit_slug: Optional[str] = None) -> str: + if explicit_slug is not None: + return validate_slug(explicit_slug) + return slugify(title) or "decision" + + def find_by_number(adr_dir: Path, number: int) -> Optional[Path]: for entry in adr_dir.glob(f"{number:04d}-*.md"): return entry diff --git a/tests/unit/test_adr_cli.py b/tests/unit/test_adr_cli.py index 633fb7a..920d581 100644 --- a/tests/unit/test_adr_cli.py +++ b/tests/unit/test_adr_cli.py @@ -59,3 +59,19 @@ def test_init_locale_is_optional_and_constrained(): with pytest.raises(SystemExit): parser.parse_args(["init", "--locale", "xx"]) + + +def test_create_locale_and_slug_are_optional_and_constrained(): + parser = adr.build_parser() + + args = parser.parse_args(["create", "--input", "draft.json"]) + assert args.locale is None + assert args.slug is None + assert args.root == "." + + args = parser.parse_args([ + "create", "--input", "draft.json", "--locale", "de", + "--slug", "separate-payment-system", + ]) + assert args.locale == "de" + assert args.slug == "separate-payment-system" diff --git a/tests/unit/test_create.py b/tests/unit/test_create.py index 1c72a37..8217a71 100644 --- a/tests/unit/test_create.py +++ b/tests/unit/test_create.py @@ -4,6 +4,7 @@ from types import SimpleNamespace from scripts.commands import create +from scripts.core import frontmatter as fm def _write_draft(tmp_path, **overrides): @@ -18,12 +19,24 @@ def _write_draft(tmp_path, **overrides): return draft_path +def _args(tmp_path, draft_path, adr_dir, *, dry_run=False, locale=None, slug=None): + return SimpleNamespace( + interactive=False, + input=str(draft_path), + dir=str(adr_dir), + root=str(tmp_path), + locale=locale, + slug=slug, + dry_run=dry_run, + ) + + 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)) + result = create.run(_args(tmp_path, draft_path, adr_dir)) assert result["ok"] is True assert result["id"] == "ADR-0001" @@ -38,7 +51,7 @@ def test_next_id_accounts_for_existing_adrs(tmp_path): (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)) + result = create.run(_args(tmp_path, draft_path, adr_dir)) assert result["id"] == "ADR-0002" @@ -48,7 +61,7 @@ def test_dry_run_does_not_write_file(tmp_path): 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)) + result = create.run(_args(tmp_path, draft_path, adr_dir, dry_run=True)) assert result["ok"] is True assert result["dry_run"] is True @@ -61,7 +74,7 @@ def test_missing_required_draft_field_is_an_error(tmp_path): 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)) + result = create.run(_args(tmp_path, draft_path, adr_dir)) assert result["ok"] is False assert result["errors"][0]["code"] == "MISSING_DRAFT_FIELD" @@ -72,7 +85,7 @@ def test_dry_run_against_nonexistent_dir_does_not_create_dir(tmp_path): # Note: adr_dir is NOT created beforehand draft_path = _write_draft(tmp_path) - result = create.run(SimpleNamespace(input=str(draft_path), dir=str(adr_dir), dry_run=True)) + result = create.run(_args(tmp_path, draft_path, adr_dir, dry_run=True)) assert result["ok"] is True assert result["dry_run"] is True @@ -84,7 +97,7 @@ def test_dry_run_response_includes_id(tmp_path): 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)) + result = create.run(_args(tmp_path, draft_path, adr_dir, dry_run=True)) assert result["ok"] is True assert result["dry_run"] is True @@ -92,27 +105,26 @@ def test_dry_run_response_includes_id(tmp_path): assert result["id"] == "ADR-0001" -def test_empty_slug_title_is_an_error_and_does_not_write_file(tmp_path): +def test_non_ascii_title_uses_portable_fallback_filename(tmp_path): adr_dir = tmp_path / "docs" / "decisions" adr_dir.mkdir(parents=True) draft_path = _write_draft(tmp_path, title="아키텍처 결정 기록") - result = create.run(SimpleNamespace(input=str(draft_path), dir=str(adr_dir), dry_run=False)) + result = create.run(_args(tmp_path, draft_path, adr_dir)) - assert result["ok"] is False - assert result["errors"][0]["code"] == "EMPTY_SLUG" - assert list(adr_dir.iterdir()) == [] + assert result["ok"] is True + assert Path(result["created"]).name == "0001-decision.md" -def test_empty_slug_title_dry_run_is_also_an_error(tmp_path): +def test_non_ascii_title_dry_run_reports_portable_fallback(tmp_path): adr_dir = tmp_path / "docs" / "decisions" adr_dir.mkdir(parents=True) draft_path = _write_draft(tmp_path, title="아키텍처 결정 기록") - result = create.run(SimpleNamespace(input=str(draft_path), dir=str(adr_dir), dry_run=True)) + result = create.run(_args(tmp_path, draft_path, adr_dir, dry_run=True)) - assert result["ok"] is False - assert result["errors"][0]["code"] == "EMPTY_SLUG" + assert result["ok"] is True + assert result["would_create"].endswith("0001-decision.md") assert list(adr_dir.iterdir()) == [] @@ -121,7 +133,7 @@ def test_missing_draft_file_returns_json_error(tmp_path): adr_dir.mkdir(parents=True) missing_path = tmp_path / "does-not-exist.json" - result = create.run(SimpleNamespace(input=str(missing_path), dir=str(adr_dir), dry_run=False)) + result = create.run(_args(tmp_path, missing_path, adr_dir)) assert result["ok"] is False assert result["errors"][0]["code"] == "DRAFT_FILE_NOT_FOUND" @@ -133,7 +145,66 @@ def test_malformed_json_draft_file_returns_json_error(tmp_path): bad_json_path = tmp_path / "draft.json" bad_json_path.write_text("{not valid json", encoding="utf-8") - result = create.run(SimpleNamespace(input=str(bad_json_path), dir=str(adr_dir), dry_run=False)) + result = create.run(_args(tmp_path, bad_json_path, adr_dir)) assert result["ok"] is False assert result["errors"][0]["code"] == "DRAFT_FILE_INVALID_JSON" + + +def test_input_create_uses_repo_locale_and_semantic_slug(tmp_path): + (tmp_path / ".adr-toolkit.json").write_text( + json.dumps({"schema_version": 1, "locale": "ko"}), encoding="utf-8" + ) + adr_dir = tmp_path / "docs/decisions" + draft_path = _write_draft( + tmp_path, + title="결제 시스템 분리", + slug="separate-payment-system", + body="# 결제 시스템 분리\n", + ) + + result = create.run(_args(tmp_path, draft_path, adr_dir)) + + assert result["created"].endswith("0001-separate-payment-system.md") + assert "locale: ko" in Path(result["created"]).read_text(encoding="utf-8") + + +def test_cli_locale_overrides_draft_and_repo(tmp_path): + result = _run_with_locales(tmp_path, repo="ko", draft="fr", cli="ja") + data, _ = fm.parse(Path(result["created"]).read_text(encoding="utf-8")) + assert data["locale"] == "ja" + + +def test_draft_locale_overrides_repo(tmp_path): + result = _run_with_locales(tmp_path, repo="ko", draft="fr", cli=None) + data, _ = fm.parse(Path(result["created"]).read_text(encoding="utf-8")) + assert data["locale"] == "fr" + + +def _run_with_locales(tmp_path, *, repo, draft, cli): + (tmp_path / ".adr-toolkit.json").write_text( + json.dumps({"schema_version": 1, "locale": repo}), encoding="utf-8" + ) + adr_dir = tmp_path / "docs/decisions" + draft_path = _write_draft(tmp_path, locale=draft) + return create.run(_args(tmp_path, draft_path, adr_dir, locale=cli)) + + +def test_invalid_explicit_slug_returns_structured_error(tmp_path): + adr_dir = tmp_path / "docs/decisions" + draft_path = _write_draft(tmp_path, title="한국어 제목", slug="Bad Slug") + + result = create.run(_args(tmp_path, draft_path, adr_dir)) + + assert result["ok"] is False + assert result["errors"][0]["code"] == "INVALID_SLUG" + + +def test_conflicting_cli_and_draft_slugs_return_structured_error(tmp_path): + adr_dir = tmp_path / "docs/decisions" + draft_path = _write_draft(tmp_path, slug="draft-slug") + + result = create.run(_args(tmp_path, draft_path, adr_dir, slug="cli-slug")) + + assert result["ok"] is False + assert result["errors"][0]["code"] == "CONFLICTING_SLUG_INPUT" diff --git a/tests/unit/test_create_interactive.py b/tests/unit/test_create_interactive.py index 1cb7931..23c5643 100644 --- a/tests/unit/test_create_interactive.py +++ b/tests/unit/test_create_interactive.py @@ -15,7 +15,7 @@ def test_gather_draft_interactively_builds_valid_minimal_body(): "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)) + draft = create.gather_draft_interactively("en", input_fn=lambda _prompt: next(answers)) assert draft["title"] == "Use Kafka for domain events" assert draft["status"] == "proposed" @@ -33,7 +33,10 @@ def test_create_run_supports_interactive_mode_end_to_end(tmp_path, monkeypatch): ]) monkeypatch.setattr("builtins.input", lambda _prompt="": next(answers)) - result = create.run(SimpleNamespace(interactive=True, input=None, dir=str(adr_dir), dry_run=False)) + result = create.run(SimpleNamespace( + interactive=True, input=None, dir=str(adr_dir), root=str(tmp_path), + locale=None, slug=None, dry_run=False, + )) assert result["ok"] is True assert result["id"] == "ADR-0001" @@ -43,7 +46,26 @@ 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)) + result = create.run(SimpleNamespace( + interactive=False, input=None, dir=str(adr_dir), root=str(tmp_path), + locale=None, slug=None, dry_run=False, + )) assert result["ok"] is False assert result["errors"][0]["code"] == "MISSING_INPUT_OR_INTERACTIVE" + + +def test_gather_draft_interactively_localizes_korean_structure(): + answers = iter([ + "결제 시스템 분리", "장애 격리가 필요하다", "유지, 분리", "분리", + "장애를 격리한다", "독립 배포", "운영 복잡성", "CI 경계 검사", + "운영 비용이 편익을 초과할 때", + ]) + + draft = create.gather_draft_interactively( + "ko", input_fn=lambda _prompt: next(answers) + ) + + assert "## 맥락 및 문제 설명" in draft["body"] + assert "장애 격리가 필요하다" in draft["body"] + assert draft["locale"] == "ko" diff --git a/tests/unit/test_identifiers.py b/tests/unit/test_identifiers.py index 596bc47..f105801 100644 --- a/tests/unit/test_identifiers.py +++ b/tests/unit/test_identifiers.py @@ -1,5 +1,7 @@ from pathlib import Path +import pytest + from scripts.core import identifiers @@ -30,6 +32,22 @@ def test_slugify_lowercases_and_hyphenates(): assert identifiers.slugify("Use Kafka for Domain Events!") == "use-kafka-for-domain-events" +def test_non_ascii_title_uses_deterministic_fallback(): + assert identifiers.slug_for_title("결제 시스템 분리", None) == "decision" + + +def test_agent_semantic_slug_wins_for_non_ascii_title(): + assert identifiers.slug_for_title( + "결제 시스템 분리", "separate-payment-system" + ) == "separate-payment-system" + + +@pytest.mark.parametrize("slug", ["Bad Slug", "한글", "../escape", "two--hyphens"]) +def test_invalid_explicit_slug_is_rejected(slug): + with pytest.raises(ValueError): + identifiers.validate_slug(slug) + + 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) From bf347dd76e03cf6ce8498c302b5a04c21b4be493 Mon Sep 17 00:00:00 2001 From: shcommit Date: Sun, 30 Aug 2026 20:18:26 +0900 Subject: [PATCH 14/76] feat: validate localized ADR workflows --- .adr-toolkit.json | 4 + skills/adr-toolkit/schemas/adr.schema.json | 1 + skills/adr-toolkit/scripts/adr.py | 4 +- skills/adr-toolkit/scripts/commands/create.py | 3 +- skills/adr-toolkit/scripts/commands/index.py | 14 +++- .../adr-toolkit/scripts/commands/validate.py | 11 +++ skills/adr-toolkit/scripts/core/schema.py | 7 ++ tests/integration/test_localized_workflow.py | 76 +++++++++++++++++++ .../test_skill_md_documented_invocation.py | 8 +- tests/unit/test_adr_cli.py | 6 +- tests/unit/test_create_interactive.py | 15 ++++ tests/unit/test_index.py | 17 ++++- tests/unit/test_schema.py | 10 +++ tests/unit/test_validate.py | 17 +++++ 14 files changed, 185 insertions(+), 8 deletions(-) create mode 100644 .adr-toolkit.json create mode 100644 tests/integration/test_localized_workflow.py diff --git a/.adr-toolkit.json b/.adr-toolkit.json new file mode 100644 index 0000000..4c14840 --- /dev/null +++ b/.adr-toolkit.json @@ -0,0 +1,4 @@ +{ + "schema_version": 1, + "locale": "en" +} diff --git a/skills/adr-toolkit/schemas/adr.schema.json b/skills/adr-toolkit/schemas/adr.schema.json index 1d5f7a7..bc80fbf 100644 --- a/skills/adr-toolkit/schemas/adr.schema.json +++ b/skills/adr-toolkit/schemas/adr.schema.json @@ -13,6 +13,7 @@ "affected_paths": { "type": "array", "items": { "type": "string" } }, "tags": { "type": "array", "items": { "type": "string" } }, "retrospective": { "type": "boolean" }, + "locale": { "type": "string", "enum": ["en", "ko", "ja", "zh", "fr", "es", "de", "pt-BR"] }, "supersedes": { "type": "array", "items": { "type": "string" } }, "superseded_by": { "type": "string" } } diff --git a/skills/adr-toolkit/scripts/adr.py b/skills/adr-toolkit/scripts/adr.py index 76c4796..5ff8494 100755 --- a/skills/adr-toolkit/scripts/adr.py +++ b/skills/adr-toolkit/scripts/adr.py @@ -55,9 +55,10 @@ def build_parser() -> argparse.ArgumentParser: p_index = sub.add_parser("index") p_index.add_argument("--dir", default="docs/decisions") + p_index.add_argument("--root", default=".") # Constrained so a typo from the agent's own language detection fails # visibly instead of silently producing English output. - p_index.add_argument("--locale", default="en", choices=["en", "fr", "ja", "ko", "zh"]) + p_index.add_argument("--locale", choices=SUPPORTED_LOCALES) p_index.add_argument("--json", action="store_true") p_related = sub.add_parser("related") @@ -73,6 +74,7 @@ def build_parser() -> argparse.ArgumentParser: p_validate = sub.add_parser("validate") p_validate.add_argument("--dir", default="docs/decisions") + p_validate.add_argument("--root", default=".") p_validate.add_argument("--json", action="store_true") p_status = sub.add_parser("status") diff --git a/skills/adr-toolkit/scripts/commands/create.py b/skills/adr-toolkit/scripts/commands/create.py index d178655..06d6b87 100644 --- a/skills/adr-toolkit/scripts/commands/create.py +++ b/skills/adr-toolkit/scripts/commands/create.py @@ -7,6 +7,7 @@ from scripts.core import frontmatter as fm from scripts.core import identifiers from scripts.core.config import ConfigError, resolve_locale +from scripts.core.locale import DEFAULT_LOCALE from scripts.core.rendering import interactive_prompts, render_minimal from scripts.core.schema import validate_frontmatter @@ -19,7 +20,7 @@ def _prompt(input_fn, question: str) -> str: return input_fn("").strip() -def gather_draft_interactively(locale: str, input_fn=None) -> dict: +def gather_draft_interactively(locale: str = DEFAULT_LOCALE, input_fn=None) -> dict: if input_fn is None: input_fn = input prompts = interactive_prompts(locale) diff --git a/skills/adr-toolkit/scripts/commands/index.py b/skills/adr-toolkit/scripts/commands/index.py index 3f5cf77..60da579 100644 --- a/skills/adr-toolkit/scripts/commands/index.py +++ b/skills/adr-toolkit/scripts/commands/index.py @@ -3,6 +3,7 @@ from scripts.core import frontmatter as fm from scripts.core import identifiers +from scripts.core.config import ConfigError, resolve_locale from scripts.core.locale import load_locale SKIP_FILES = {"README.md", "adr-template.md"} @@ -22,7 +23,18 @@ def run(args) -> dict: adr_dir = Path(args.dir) - locale = getattr(args, "locale", None) or "en" + try: + locale = resolve_locale( + cli_locale=getattr(args, "locale", None), + draft_locale=None, + root=Path(getattr(args, "root", ".")), + ) + except ConfigError as exc: + return { + "ok": False, + "operation": "index", + "errors": [{"code": "CONFIG_ERROR", "detail": str(exc)}], + } strings = load_locale(locale) entries = [] warnings = [] diff --git a/skills/adr-toolkit/scripts/commands/validate.py b/skills/adr-toolkit/scripts/commands/validate.py index 91699c0..c26594f 100644 --- a/skills/adr-toolkit/scripts/commands/validate.py +++ b/skills/adr-toolkit/scripts/commands/validate.py @@ -3,6 +3,7 @@ from scripts.core import frontmatter as fm from scripts.core import identifiers +from scripts.core.config import ConfigError, load_repository_config from scripts.core.schema import validate_frontmatter SKIP_FILES = {"README.md", "adr-template.md"} @@ -12,6 +13,16 @@ def run(args) -> dict: adr_dir = Path(args.dir) errors = [] + try: + load_repository_config(Path(getattr(args, "root", "."))) + except ConfigError as exc: + return { + "ok": False, + "operation": "validate", + "checked": 0, + "errors": [{"code": "CONFIG_ERROR", "detail": str(exc)}], + } + adr_files = sorted(p for p in adr_dir.glob("*.md") if p.name not in SKIP_FILES) parsed_entries = [] diff --git a/skills/adr-toolkit/scripts/core/schema.py b/skills/adr-toolkit/scripts/core/schema.py index 332dcf2..54f5da9 100644 --- a/skills/adr-toolkit/scripts/core/schema.py +++ b/skills/adr-toolkit/scripts/core/schema.py @@ -7,6 +7,7 @@ import re from scripts.core.lifecycle import STATUSES +from scripts.core.locale import SUPPORTED_LOCALES REQUIRED_FIELDS = { "id": str, @@ -21,6 +22,7 @@ } OPTIONAL_FIELDS = { + "locale": str, "supersedes": list, "superseded_by": str, } @@ -54,6 +56,11 @@ def validate_frontmatter(data: dict) -> list: if "status" in data and data["status"] not in STATUSES: errors.append(f"status {data['status']!r} is not one of {sorted(STATUSES)}") + if "locale" in data and data["locale"] not in SUPPORTED_LOCALES: + errors.append( + f"locale {data['locale']!r} is not one of {list(SUPPORTED_LOCALES)}" + ) + 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") diff --git a/tests/integration/test_localized_workflow.py b/tests/integration/test_localized_workflow.py new file mode 100644 index 0000000..0faa717 --- /dev/null +++ b/tests/integration/test_localized_workflow.py @@ -0,0 +1,76 @@ +import json +import subprocess +import sys +from pathlib import Path + +import pytest + +from scripts.core.locale import SUPPORTED_LOCALES + +ADR_PY = Path(__file__).resolve().parents[2] / "skills/adr-toolkit/scripts/adr.py" + +CASES = { + "en": ("Separate payment system", "Decision Log"), + "ko": ("결제 시스템 분리", "결정 기록"), + "ja": ("決済システムを分離する", "決定記録"), + "zh": ("拆分支付系统", "决策日志"), + "fr": ("Séparer le système de paiement", "Journal des décisions"), + "es": ("Separar el sistema de pagos", "Registro de decisiones"), + "de": ("Zahlungssystem trennen", "Entscheidungsprotokoll"), + "pt-BR": ("Separar o sistema de pagamentos", "Registro de decisões"), +} + + +def _run(repo, *args): + completed = subprocess.run( + [sys.executable, str(ADR_PY), *args], + cwd=repo, + capture_output=True, + text=True, + ) + assert completed.stderr == "" + payload = json.loads(completed.stdout) + assert completed.returncode == 0, payload + return payload + + +@pytest.mark.parametrize("locale", SUPPORTED_LOCALES) +def test_complete_localized_workflow(locale, tmp_path): + title, index_title = CASES[locale] + repo = tmp_path / locale + repo.mkdir() + + initialized = _run(repo, "init", "--locale", locale, "--json") + assert initialized["ok"] is True + + draft = { + "title": title, + "status": "proposed", + "body": f"# {title}\n\nUnicode body: {title}\n", + } + draft_path = repo / "draft.json" + draft_path.write_text( + json.dumps(draft, ensure_ascii=False), encoding="utf-8" + ) + created = _run( + repo, + "create", + "--input", + str(draft_path), + "--slug", + "separate-payment-system", + "--json", + ) + created_path = repo / created["created"] + assert created_path.name == "0002-separate-payment-system.md" + created_text = created_path.read_text(encoding="utf-8") + assert f"locale: {locale}" in created_text + assert title in created_text + + validated = _run(repo, "validate", "--json") + assert validated["checked"] == 2 + + indexed = _run(repo, "index", "--json") + assert indexed["count"] == 2 + readme = (repo / "docs/decisions/README.md").read_text(encoding="utf-8") + assert readme.startswith(f"# {index_title}") diff --git a/tests/integration/test_skill_md_documented_invocation.py b/tests/integration/test_skill_md_documented_invocation.py index 3bfb5f2..3987717 100644 --- a/tests/integration/test_skill_md_documented_invocation.py +++ b/tests/integration/test_skill_md_documented_invocation.py @@ -32,11 +32,15 @@ def test_documented_relative_invocation_resolves_from_repo_root(tmp_path): assert preflight.returncode == 0, preflight.stderr assert json.loads(preflight.stdout)["ok"] is True - init_result = _run(["init", "--dir", str(adr_dir), "--json"]) + init_result = _run([ + "init", "--dir", str(adr_dir), "--root", str(tmp_path), "--json", + ]) assert init_result.returncode == 0, init_result.stderr assert json.loads(init_result.stdout)["ok"] is True - validate_result = _run(["validate", "--dir", str(adr_dir), "--json"]) + validate_result = _run([ + "validate", "--dir", str(adr_dir), "--root", str(tmp_path), "--json", + ]) assert validate_result.returncode == 0, validate_result.stderr payload = json.loads(validate_result.stdout) assert payload["ok"] is True diff --git a/tests/unit/test_adr_cli.py b/tests/unit/test_adr_cli.py index 920d581..efd5947 100644 --- a/tests/unit/test_adr_cli.py +++ b/tests/unit/test_adr_cli.py @@ -47,8 +47,10 @@ def test_index_locale_rejects_an_unrecognized_code_instead_of_silently_using_eng adr.build_parser().parse_args(["index", "--locale", "zz"]) -def test_index_locale_defaults_to_english_when_omitted(): - assert adr.build_parser().parse_args(["index"]).locale == "en" +def test_index_locale_is_unset_when_omitted_so_repository_default_can_win(): + args = adr.build_parser().parse_args(["index"]) + assert args.locale is None + assert args.root == "." def test_init_locale_is_optional_and_constrained(): diff --git a/tests/unit/test_create_interactive.py b/tests/unit/test_create_interactive.py index 23c5643..7369551 100644 --- a/tests/unit/test_create_interactive.py +++ b/tests/unit/test_create_interactive.py @@ -23,6 +23,21 @@ def test_gather_draft_interactively_builds_valid_minimal_body(): assert "Kafka" in draft["body"] +def test_gather_draft_interactively_keeps_english_default_for_existing_callers(): + answers = iter([ + "Use Postgres", "Persistence is required", "SQLite, Postgres", + "Postgres", "it scales", "durability", "operations", "integration test", + "requirements change", + ]) + + draft = create.gather_draft_interactively( + input_fn=lambda _prompt: next(answers) + ) + + assert draft["locale"] == "en" + assert "## Context and Problem Statement" 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) diff --git a/tests/unit/test_index.py b/tests/unit/test_index.py index 252c202..9cb1626 100644 --- a/tests/unit/test_index.py +++ b/tests/unit/test_index.py @@ -97,7 +97,7 @@ def test_index_defaults_to_english_when_locale_omitted(tmp_path): tags=["architecture"], affected_paths=["src/events/"], ) - result = index.run(SimpleNamespace(dir=str(tmp_path))) + result = index.run(SimpleNamespace(dir=str(tmp_path), root=str(tmp_path), locale=None)) assert result["ok"] is True readme = (tmp_path / "README.md").read_text(encoding="utf-8") @@ -105,6 +105,21 @@ def test_index_defaults_to_english_when_locale_omitted(tmp_path): assert "## By status" in readme +def test_index_uses_repository_default_when_locale_omitted(tmp_path): + (tmp_path / ".adr-toolkit.json").write_text( + '{"schema_version": 1, "locale": "ko"}', encoding="utf-8" + ) + adr_dir = tmp_path / "docs/decisions" + adr_dir.mkdir(parents=True) + + result = index.run(SimpleNamespace( + dir=str(adr_dir), root=str(tmp_path), locale=None, + )) + + assert result["ok"] is True + assert (adr_dir / "README.md").read_text(encoding="utf-8").startswith("# 결정 기록") + + def test_index_unknown_status_falls_back_to_capitalized_label(tmp_path): _write_adr( tmp_path, "0001-use-kafka.md", diff --git a/tests/unit/test_schema.py b/tests/unit/test_schema.py index 8ee661d..2d3a3b1 100644 --- a/tests/unit/test_schema.py +++ b/tests/unit/test_schema.py @@ -2,6 +2,7 @@ from pathlib import Path from scripts.core import schema +from scripts.core.locale import SUPPORTED_LOCALES from scripts.core.schema import validate_frontmatter VALID = { @@ -72,6 +73,12 @@ def test_absence_of_optional_fields_is_not_an_error(): assert validate_frontmatter(VALID) == [] +def test_optional_locale_is_valid_and_unknown_locale_is_rejected(): + assert validate_frontmatter({**VALID, "locale": "ko"}) == [] + errors = validate_frontmatter({**VALID, "locale": "xx"}) + assert any("locale" in error for error in errors) + + def test_json_schema_matches_runtime_frontmatter_fields(): schema_path = Path(__file__).parents[2] / "skills/adr-toolkit/schemas/adr.schema.json" with schema_path.open() as schema_file: @@ -87,3 +94,6 @@ def test_json_schema_matches_runtime_frontmatter_fields(): for field, python_type in runtime_fields.items(): assert field in json_schema["properties"] assert json_schema["properties"][field]["type"] == expected_json_types[python_type] + + assert "locale" not in json_schema["required"] + assert tuple(json_schema["properties"]["locale"]["enum"]) == SUPPORTED_LOCALES diff --git a/tests/unit/test_validate.py b/tests/unit/test_validate.py index 097adcf..3c6d188 100644 --- a/tests/unit/test_validate.py +++ b/tests/unit/test_validate.py @@ -66,3 +66,20 @@ def test_filename_id_mismatch_is_reported(tmp_path): assert mismatches[0]["file"] == "0002-something.md" assert mismatches[0]["expected_id"] == "ADR-0002" assert mismatches[0]["found_id"] == "ADR-0001" + + +def test_invalid_repository_config_fails_before_reporting_adr_success(tmp_path): + (tmp_path / ".adr-toolkit.json").write_text( + '{"schema_version": 1, "locale": "xx"}', encoding="utf-8" + ) + adr_dir = tmp_path / "docs/decisions" + adr_dir.mkdir(parents=True) + (adr_dir / "0001-record-architecture-decisions.md").write_text( + VALID_ADR, encoding="utf-8" + ) + + result = validate.run(SimpleNamespace(dir=str(adr_dir), root=str(tmp_path))) + + assert result["ok"] is False + assert result["checked"] == 0 + assert result["errors"][0]["code"] == "CONFIG_ERROR" From d691a339cae7e1c7fb468b16822c04f59557267b Mon Sep 17 00:00:00 2001 From: shcommit Date: Sun, 30 Aug 2026 20:19:29 +0900 Subject: [PATCH 15/76] fix: fail CHECK on incomplete git diff evidence --- skills/adr-toolkit/scripts/commands/diff.py | 30 +++++++++++++- tests/unit/test_diff.py | 44 +++++++++++++++++++++ 2 files changed, 72 insertions(+), 2 deletions(-) diff --git a/skills/adr-toolkit/scripts/commands/diff.py b/skills/adr-toolkit/scripts/commands/diff.py index bd63714..005fda3 100644 --- a/skills/adr-toolkit/scripts/commands/diff.py +++ b/skills/adr-toolkit/scripts/commands/diff.py @@ -3,6 +3,13 @@ from pathlib import Path +class _GitCommandError(RuntimeError): + def __init__(self, code: str, detail: str): + super().__init__(detail) + self.code = code + self.detail = detail + + def run(args) -> dict: root = Path(getattr(args, "root", ".")).resolve() @@ -36,19 +43,33 @@ def run(args) -> dict: capture_output=True, text=True, ) if name_status.returncode != 0: - code = "INVALID_REF" if since else "GIT_DIFF_FAILED" + code = "INVALID_REF" if mode == "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_options, "--end-of-options", *range_args], capture_output=True, text=True, ) + if patch.returncode != 0: + code = "INVALID_REF" if mode == "since" else "GIT_DIFF_FAILED" + return { + "ok": False, + "operation": "diff", + "errors": [{"code": code, "detail": patch.stderr.strip()}], + } files = _parse_name_status(name_status.stdout) _attach_line_content(files, patch.stdout) if mode == "uncommitted": - files.extend(_untracked_files(root)) + try: + files.extend(_untracked_files(root)) + except _GitCommandError as exc: + return { + "ok": False, + "operation": "diff", + "errors": [{"code": exc.code, "detail": exc.detail}], + } return {"ok": True, "operation": "diff", "mode": mode, "ref": since, "files": files} @@ -61,6 +82,11 @@ def _untracked_files(root: Path) -> list: ["git", "-C", str(root), "ls-files", "--others", "--exclude-standard"], capture_output=True, text=True, ) + if listing.returncode != 0: + raise _GitCommandError( + "GIT_LS_FILES_FAILED", + listing.stderr.strip() or "git ls-files failed", + ) entries = [] for rel_path in listing.stdout.splitlines(): if not rel_path.strip(): diff --git a/tests/unit/test_diff.py b/tests/unit/test_diff.py index bc26d2f..bb5304e 100644 --- a/tests/unit/test_diff.py +++ b/tests/unit/test_diff.py @@ -109,3 +109,47 @@ def test_since_value_cannot_smuggle_a_git_option_that_writes_a_file(tmp_path): # git's `--output` writes to the literal argument, i.e. "..HEAD". assert not target.exists() assert not (tmp_path / f"{target.name}..HEAD").exists() + + +def test_patch_subprocess_failure_is_not_reported_as_clean(tmp_path, monkeypatch): + _init_repo(tmp_path) + real_run = diff.subprocess.run + diff_calls = 0 + + def fake_run(command, **kwargs): + nonlocal diff_calls + if "diff" in command: + diff_calls += 1 + if diff_calls == 2: + return subprocess.CompletedProcess(command, 2, "", "patch failed") + return real_run(command, **kwargs) + + monkeypatch.setattr(diff.subprocess, "run", fake_run) + + result = diff.run(SimpleNamespace( + root=str(tmp_path), staged=False, uncommitted=True, since=None, + )) + + assert result["ok"] is False + assert result["errors"][0]["code"] == "GIT_DIFF_FAILED" + assert "patch failed" in result["errors"][0]["detail"] + + +def test_untracked_listing_failure_is_not_reported_as_clean(tmp_path, monkeypatch): + _init_repo(tmp_path) + real_run = diff.subprocess.run + + def fake_run(command, **kwargs): + if "ls-files" in command: + return subprocess.CompletedProcess(command, 2, "", "listing failed") + return real_run(command, **kwargs) + + monkeypatch.setattr(diff.subprocess, "run", fake_run) + + result = diff.run(SimpleNamespace( + root=str(tmp_path), staged=False, uncommitted=True, since=None, + )) + + assert result["ok"] is False + assert result["errors"][0]["code"] == "GIT_LS_FILES_FAILED" + assert "listing failed" in result["errors"][0]["detail"] From 801fa4c3a4a095d76c6785138faf30aba775308b Mon Sep 17 00:00:00 2001 From: shcommit Date: Sun, 30 Aug 2026 20:21:37 +0900 Subject: [PATCH 16/76] fix: preserve both paths when CHECK sees renames --- skills/adr-toolkit/scripts/commands/diff.py | 35 ++++++++++++---- tests/unit/test_check.py | 24 +++++++++++ tests/unit/test_diff.py | 44 +++++++++++++++++++++ 3 files changed, 95 insertions(+), 8 deletions(-) diff --git a/skills/adr-toolkit/scripts/commands/diff.py b/skills/adr-toolkit/scripts/commands/diff.py index 005fda3..f1d8946 100644 --- a/skills/adr-toolkit/scripts/commands/diff.py +++ b/skills/adr-toolkit/scripts/commands/diff.py @@ -111,7 +111,25 @@ def _parse_name_status(output: str) -> list: if not line.strip(): continue parts = line.split("\t") - status_char, path = parts[0][0], parts[-1] + status_char = parts[0][0] + if status_char == "R" and len(parts) == 3: + old_path, new_path = parts[1], parts[2] + files.extend([ + { + "path": old_path, + "change_type": "deleted", + "added_lines": [], + "removed_lines": [], + }, + { + "path": new_path, + "change_type": "added", + "added_lines": [], + "removed_lines": [], + }, + ]) + continue + path = parts[-1] files.append({ "path": path, "change_type": status_map.get(status_char, "modified"), @@ -123,19 +141,20 @@ def _parse_name_status(output: str) -> list: def _attach_line_content(files: list, patch_output: str) -> None: by_path = {f["path"]: f for f in files} - current = None + removed_target = None + added_target = 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 + removed_target = by_path.get(src) if src else None 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: + added_target = by_path.get(dst) if dst else None continue if line.startswith("+"): - current["added_lines"].append(line[1:]) + if added_target is not None: + added_target["added_lines"].append(line[1:]) elif line.startswith("-"): - current["removed_lines"].append(line[1:]) + if removed_target is not None: + removed_target["removed_lines"].append(line[1:]) diff --git a/tests/unit/test_check.py b/tests/unit/test_check.py index 0c0c98d..7aa2fef 100644 --- a/tests/unit/test_check.py +++ b/tests/unit/test_check.py @@ -232,6 +232,30 @@ def test_review_required_when_verification_reference_is_removed(tmp_path): assert "src/events/replay.py" in finding["evidence"]["unrealized_paths"] +def test_review_required_when_confirmed_path_is_renamed(tmp_path): + _init_repo(tmp_path) + adr_dir = tmp_path / "docs/decisions" + adr_dir.mkdir(parents=True) + adr = ACCEPTED_ADR_WITH_VERIFICATION.replace( + "src/events/replay.py", "src/old.py" + ).replace("src/events/", "src/old.py") + (adr_dir / "0003-add-event-replay.md").write_text(adr, encoding="utf-8") + (tmp_path / "src").mkdir() + (tmp_path / "src/old.py").write_text("def replay(): pass\n", encoding="utf-8") + _git(["add", "-A"], tmp_path) + _git(["commit", "-q", "-m", "init"], tmp_path) + _git(["mv", "src/old.py", "src/new.py"], tmp_path) + + args = _args(tmp_path, adr_dir) + args.staged = True + args.uncommitted = False + result = check.run(args) + + finding = next(f for f in result["findings"] if f["kind"] == "review_required") + assert finding["adr_id"] == "ADR-0003" + assert finding["evidence"]["unrealized_paths"] == ["src/old.py"] + + def test_superseded_reference_fires_on_affected_path_overlap(tmp_path): _init_repo(tmp_path) adr_dir = tmp_path / "docs" / "decisions" diff --git a/tests/unit/test_diff.py b/tests/unit/test_diff.py index bb5304e..b8bf767 100644 --- a/tests/unit/test_diff.py +++ b/tests/unit/test_diff.py @@ -153,3 +153,47 @@ def fake_run(command, **kwargs): assert result["ok"] is False assert result["errors"][0]["code"] == "GIT_LS_FILES_FAILED" assert "listing failed" in result["errors"][0]["detail"] + + +def test_parse_rename_preserves_old_and_new_paths(): + files = diff._parse_name_status("R100\tsrc/old.py\tsrc/new.py\n") + + assert [(item["path"], item["change_type"]) for item in files] == [ + ("src/old.py", "deleted"), + ("src/new.py", "added"), + ] + + +def test_staged_rename_reports_deletion_and_addition(tmp_path): + _init_repo(tmp_path) + (tmp_path / "src").mkdir() + (tmp_path / "src/old.py").write_text("one\ntwo\nthree\n", encoding="utf-8") + _git(["add", "src/old.py"], tmp_path) + _git(["commit", "-q", "-m", "add old path"], tmp_path) + _git(["mv", "src/old.py", "src/new.py"], tmp_path) + + result = diff.run(SimpleNamespace( + root=str(tmp_path), staged=True, uncommitted=False, since=None, + )) + + changes = {(item["path"], item["change_type"]) for item in result["files"]} + assert ("src/old.py", "deleted") in changes + assert ("src/new.py", "added") in changes + + +def test_rename_patch_lines_are_attached_to_the_corresponding_path(): + files = diff._parse_name_status("R090\tsrc/old.py\tsrc/new.py\n") + patch = ( + "diff --git a/src/old.py b/src/new.py\n" + "--- a/src/old.py\n" + "+++ b/src/new.py\n" + "@@ -1 +1 @@\n" + "-old value\n" + "+new value\n" + ) + + diff._attach_line_content(files, patch) + + by_path = {item["path"]: item for item in files} + assert by_path["src/old.py"]["removed_lines"] == ["old value"] + assert by_path["src/new.py"]["added_lines"] == ["new value"] From dd96e125c348a325fb3d567e492f97a1e96685a3 Mon Sep 17 00:00:00 2001 From: shcommit Date: Sun, 30 Aug 2026 20:22:40 +0900 Subject: [PATCH 17/76] fix: make CHECK honor git path semantics --- skills/adr-toolkit/scripts/commands/check.py | 22 ++++----- skills/adr-toolkit/scripts/core/git_paths.py | 21 ++++++++ tests/unit/test_check.py | 51 ++++++++++++++++++++ tests/unit/test_git_paths.py | 42 ++++++++++++++++ 4 files changed, 123 insertions(+), 13 deletions(-) create mode 100644 skills/adr-toolkit/scripts/core/git_paths.py create mode 100644 tests/unit/test_git_paths.py diff --git a/skills/adr-toolkit/scripts/commands/check.py b/skills/adr-toolkit/scripts/commands/check.py index 2d90b41..c1a7234 100644 --- a/skills/adr-toolkit/scripts/commands/check.py +++ b/skills/adr-toolkit/scripts/commands/check.py @@ -7,6 +7,7 @@ from scripts.core import frontmatter as fm from scripts.core import identifiers from scripts.core.constraints import ConstraintsError, extract_constraints +from scripts.core.git_paths import GitPathsError, list_existing_paths from scripts.rules import conflict SKIP_FILES = {"README.md", "adr-template.md"} @@ -29,7 +30,14 @@ def run(args) -> dict: diff_files = diff_result["files"] root = Path(getattr(args, "root", ".")).resolve() - existing_paths = _existing_paths(root) + try: + existing_paths = list_existing_paths(root) + except GitPathsError as exc: + return { + "ok": False, + "operation": "check", + "errors": [{"code": "GIT_LS_FILES_FAILED", "detail": str(exc)}], + } # `--dir` is relative to `--root`, so `check --root /repo --dir docs/decisions` # means the same directory no matter what the process CWD happens to be. @@ -160,15 +168,3 @@ def _missing_realization(body: str, diff_files: list, existing_paths: set): ), "evidence": {"unrealized_paths": missing}, } - - -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 diff --git a/skills/adr-toolkit/scripts/core/git_paths.py b/skills/adr-toolkit/scripts/core/git_paths.py new file mode 100644 index 0000000..56d7c51 --- /dev/null +++ b/skills/adr-toolkit/scripts/core/git_paths.py @@ -0,0 +1,21 @@ +"""List repository paths using Git's tracked and ignore semantics.""" +import subprocess +from pathlib import Path + + +class GitPathsError(RuntimeError): + """Git could not provide a trustworthy repository path inventory.""" + + +def list_existing_paths(root: Path) -> set: + result = subprocess.run( + [ + "git", "-C", str(root), "ls-files", "--cached", "--others", + "--exclude-standard", + ], + capture_output=True, + text=True, + ) + if result.returncode != 0: + raise GitPathsError(result.stderr.strip() or "git ls-files failed") + return {line for line in result.stdout.splitlines() if line} diff --git a/tests/unit/test_check.py b/tests/unit/test_check.py index 7aa2fef..a2ce279 100644 --- a/tests/unit/test_check.py +++ b/tests/unit/test_check.py @@ -367,6 +367,57 @@ def test_review_required_fires_when_a_confirmation_path_was_never_created(tmp_pa assert finding["evidence"]["unrealized_paths"] == ["tests/test_replay.py"] +def test_ignored_artifact_does_not_satisfy_file_must_exist(tmp_path): + _init_repo(tmp_path) + adr_dir = tmp_path / "docs/decisions" + adr_dir.mkdir(parents=True) + adr = """--- +id: ADR-0011 +title: Require generated policy manifest +status: accepted +date: 2026-08-03 +decision_makers: [] +related: [] +affected_paths: + - src/ +tags: [] +retrospective: false +--- + +# Require generated policy manifest + +## Implementation Constraints + +```yaml +constraints: + - id: generated-policy-must-be-versioned + kind: file_must_exist + paths: ["build/generated-policy.json"] + pattern: [] + severity: major + message: "The generated policy manifest must be versioned." +``` +""" + (adr_dir / "0011-require-generated-policy-manifest.md").write_text( + adr, encoding="utf-8" + ) + (tmp_path / ".gitignore").write_text("build/\n", encoding="utf-8") + (tmp_path / "src").mkdir() + (tmp_path / "src/app.py").write_text("value = 1\n", encoding="utf-8") + _git(["add", "-A"], tmp_path) + _git(["commit", "-q", "-m", "init"], tmp_path) + + (tmp_path / "build").mkdir() + (tmp_path / "build/generated-policy.json").write_text("{}\n", encoding="utf-8") + (tmp_path / "src/app.py").write_text("value = 2\n", encoding="utf-8") + + result = check.run(_args(tmp_path, adr_dir)) + + violation = next(f for f in result["findings"] if f["kind"] == "verified_violation") + assert violation["rule_id"] == "generated-policy-must-be-versioned" + assert violation["evidence"]["missing_paths"] == ["build/generated-policy.json"] + + STRING_AFFECTED_PATHS_ADR = """--- id: ADR-0020 title: Scalar affected paths diff --git a/tests/unit/test_git_paths.py b/tests/unit/test_git_paths.py new file mode 100644 index 0000000..ff7613f --- /dev/null +++ b/tests/unit/test_git_paths.py @@ -0,0 +1,42 @@ +import subprocess + +import pytest + +from scripts.core import git_paths + + +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_existing_paths_include_tracked_and_untracked_but_not_ignored(tmp_path): + _init_repo(tmp_path) + (tmp_path / ".gitignore").write_text("build/\n", encoding="utf-8") + (tmp_path / "untracked.py").write_text("x = 1\n", encoding="utf-8") + (tmp_path / "build").mkdir() + (tmp_path / "build/generated.py").write_text("x = 1\n", encoding="utf-8") + + paths = git_paths.list_existing_paths(tmp_path) + + assert "existing.py" in paths + assert "untracked.py" in paths + assert "build/generated.py" not in paths + + +def test_git_path_listing_failure_includes_stderr(tmp_path, monkeypatch): + def fail(command, **kwargs): + return subprocess.CompletedProcess(command, 128, "", "not a repository") + + monkeypatch.setattr(git_paths.subprocess, "run", fail) + + with pytest.raises(git_paths.GitPathsError, match="not a repository"): + git_paths.list_existing_paths(tmp_path) From e6877d1bef01d6b556036a69699b585d8191e7ef Mon Sep 17 00:00:00 2001 From: shcommit Date: Sun, 30 Aug 2026 20:23:13 +0900 Subject: [PATCH 18/76] fix: reject conflicting CHECK diff modes --- skills/adr-toolkit/scripts/adr.py | 15 +++++++++------ tests/unit/test_adr_cli.py | 8 ++++++++ 2 files changed, 17 insertions(+), 6 deletions(-) diff --git a/skills/adr-toolkit/scripts/adr.py b/skills/adr-toolkit/scripts/adr.py index 5ff8494..55bef86 100755 --- a/skills/adr-toolkit/scripts/adr.py +++ b/skills/adr-toolkit/scripts/adr.py @@ -24,6 +24,13 @@ from scripts.core.locale import SUPPORTED_LOCALES +def _add_diff_mode_arguments(parser: argparse.ArgumentParser) -> None: + modes = parser.add_mutually_exclusive_group() + modes.add_argument("--staged", action="store_true") + modes.add_argument("--uncommitted", action="store_true") + modes.add_argument("--since") + + def build_parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser(prog="adr.py") sub = parser.add_subparsers(dest="operation", required=True) @@ -99,16 +106,12 @@ def build_parser() -> argparse.ArgumentParser: p_supersede.add_argument("--json", action="store_true") 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") + _add_diff_mode_arguments(p_diff) p_diff.add_argument("--root", default=".") p_diff.add_argument("--json", action="store_true") 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") + _add_diff_mode_arguments(p_check) p_check.add_argument("--root", default=".") p_check.add_argument("--dir", default="docs/decisions") p_check.add_argument("--json", action="store_true") diff --git a/tests/unit/test_adr_cli.py b/tests/unit/test_adr_cli.py index efd5947..a122cc0 100644 --- a/tests/unit/test_adr_cli.py +++ b/tests/unit/test_adr_cli.py @@ -77,3 +77,11 @@ def test_create_locale_and_slug_are_optional_and_constrained(): ]) assert args.locale == "de" assert args.slug == "separate-payment-system" + + +@pytest.mark.parametrize("operation", ["diff", "check"]) +def test_diff_modes_are_mutually_exclusive(operation): + with pytest.raises(SystemExit): + adr.build_parser().parse_args([ + operation, "--staged", "--since", "main", + ]) From 503f5a634ce4c7eea0df60a852eb79bae2cd21c4 Mon Sep 17 00:00:00 2001 From: shcommit Date: Sun, 30 Aug 2026 20:24:42 +0900 Subject: [PATCH 19/76] docs: define CHECK evidence confidence --- skills/adr-toolkit/SKILL.md | 9 ++++-- .../adr-toolkit/references/conflict-rules.md | 31 +++++++++++++++++++ tests/unit/test_conflict_rules_reference.py | 14 +++++++++ tests/unit/test_skill_manifest.py | 3 ++ 4 files changed, 54 insertions(+), 3 deletions(-) diff --git a/skills/adr-toolkit/SKILL.md b/skills/adr-toolkit/SKILL.md index 49473f1..991a305 100644 --- a/skills/adr-toolkit/SKILL.md +++ b/skills/adr-toolkit/SKILL.md @@ -238,6 +238,9 @@ since CHECK is read-only and never fixes anything itself. 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. + Interpret their confidence as `VERIFIED`, `VIOLATED`, or `UNVERIFIABLE` + using `references/conflict-rules.md`. No related finding is + `NOT_APPLICABLE`, not proof that the repository is compliant. 4. **REPORT** — group findings by classification. For every Verified violation finding, present all five resolutions from `references/conflict-rules.md` (`fix_code`, `supersede_adr`, @@ -274,6 +277,6 @@ file writes, or schema validation in prose — it only decides what to ask and what to draft. 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. +from `constraints:` blocks (see `references/conflict-rules.md`). It does not certify the entire architecture +and never attempts semantic or AST-level +analysis; see `project-roadmap.md` for what that fuller scope would look like. diff --git a/skills/adr-toolkit/references/conflict-rules.md b/skills/adr-toolkit/references/conflict-rules.md index 2ce8af2..b5f2e3a 100644 --- a/skills/adr-toolkit/references/conflict-rules.md +++ b/skills/adr-toolkit/references/conflict-rules.md @@ -23,6 +23,21 @@ Each rule needs `id`, `kind`, `paths` (glob list, `**` matches any depth), existence-check kinds. `paths`/`pattern` values must be a JSON-style array on one line — not YAML block-list syntax. +### Pattern syntax is kind-specific + +Every value in `paths` uses the toolkit's **glob** syntax. For +`forbidden_import` and `dependency_forbidden`, each `pattern` value is a +Python **regular expression** matched against added lines. For `required_path` +and `forbidden_path`, each `pattern` value is another glob matched against +repository paths. `file_must_exist` and `test_must_exist` use their `paths` +globs directly and do not require `pattern`. + +For example, the glob `src/**/*.py` matches Python files at any depth below +`src/`. Writing `src/**\\.py` as though it were a regular expression is +incorrect: glob `**` traversal and regex escaping are different languages. +Conversely, a `forbidden_import` content pattern such as `openai\\.` is a +regular expression, not a path glob. + ## The six rule kinds - `forbidden_import` — fires if an added line in a file matching `paths` @@ -61,6 +76,22 @@ enforce — that's a legitimate state, not an error. unknown `kind`, or an invalid regex in `pattern`) also lands here, with a `BAD_CONSTRAINTS` warning — it is never silently reported as Related. +## Evidence confidence + +The JSON finding names remain stable, while their governance meaning is: + +| Existing result | Confidence meaning | +|---|---| +| `related` | **VERIFIED** only for the applicable explicit structural rules in this diff. | +| `verified_violation` | **VIOLATED** by direct structural evidence. | +| `review_required` | **UNVERIFIABLE** without human review. | +| `no_applicable_constraint` | **UNVERIFIABLE** for the related ADR because no usable rule proved the policy. | +| No related ADR or finding | **NOT_APPLICABLE** to the known scoped rules, not a global pass. | + +CHECK does not certify the entire architecture. A successful command means +the requested evidence was collected and evaluated; it does not prove prose, +organizational rationale, runtime behavior, or every architectural invariant. + ## Resolving a Verified violation Present all five options; never default to "revert the code": diff --git a/tests/unit/test_conflict_rules_reference.py b/tests/unit/test_conflict_rules_reference.py index 1280c1b..ad88d7b 100644 --- a/tests/unit/test_conflict_rules_reference.py +++ b/tests/unit/test_conflict_rules_reference.py @@ -25,3 +25,17 @@ 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 + + +def test_reference_distinguishes_regex_and_glob_patterns(): + text = REFERENCE.read_text(encoding="utf-8") + assert "regular expression" in text + assert "glob" in text + assert "forbidden_import" in text and "required_path" in text + + +def test_reference_never_claims_full_architecture_verification(): + text = REFERENCE.read_text(encoding="utf-8") + for label in ["VERIFIED", "VIOLATED", "UNVERIFIABLE", "NOT_APPLICABLE"]: + assert label in text + assert "does not certify the entire architecture" in text diff --git a/tests/unit/test_skill_manifest.py b/tests/unit/test_skill_manifest.py index bb42708..4c7ef02 100644 --- a/tests/unit/test_skill_manifest.py +++ b/tests/unit/test_skill_manifest.py @@ -45,6 +45,9 @@ def test_skill_md_documents_check(): assert "adr.py check" in body assert "Verified violation" in body assert "CHECK is not yet implemented" not in body + assert "does not certify the entire architecture" in body + for label in ["VERIFIED", "VIOLATED", "UNVERIFIABLE", "NOT_APPLICABLE"]: + assert label in body def test_record_points_at_the_conflict_rules_reference_for_constraints_blocks(): From 477a738b5d0aac17896484cae07b5efc0a85f621 Mon Sep 17 00:00:00 2001 From: shcommit Date: Sun, 30 Aug 2026 20:26:59 +0900 Subject: [PATCH 20/76] docs: correct dogfooded ADR provenance --- .../0001-record-architecture-decisions.md | 8 ++- ...t-detection-to-structural-evidence-only.md | 29 ++++++++- ...nerated-strings-not-agent-composed-text.md | 32 +++++++++- ...-install-time-symlinks-verified-formats.md | 53 ++++++++++++++- ...flow-with-direct-tag-release-automation.md | 6 +- tests/integration/test_repository_adrs.py | 64 +++++++++++++++++++ 6 files changed, 183 insertions(+), 9 deletions(-) create mode 100644 tests/integration/test_repository_adrs.py diff --git a/docs/decisions/0001-record-architecture-decisions.md b/docs/decisions/0001-record-architecture-decisions.md index d4ee0ca..07fd39e 100644 --- a/docs/decisions/0001-record-architecture-decisions.md +++ b/docs/decisions/0001-record-architecture-decisions.md @@ -3,7 +3,9 @@ id: ADR-0001 title: Record architecture decisions status: accepted date: 2026-08-30 -decision_makers: [] +decision_makers: + - YangSeungHyun +locale: en related: [] affected_paths: - docs/decisions/ @@ -37,7 +39,9 @@ Chosen option: **Architecture Decision Records stored alongside the code**, beca ## Confirmation -* [ ] `docs/decisions/` exists with this file, a template, and an index. +`python3 skills/adr-toolkit/scripts/adr.py validate --dir docs/decisions --json` +validates the records, and `index` regenerates `docs/decisions/README.md` from +the same directory. ## Revisit Triggers 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 index 776491a..d600209 100644 --- 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 @@ -3,16 +3,21 @@ id: ADR-0002 title: Limit CHECK's conflict detection to structural evidence only status: accepted date: 2026-08-30 -decision_makers: [] +decision_makers: + - YangSeungHyun +locale: en related: [] affected_paths: - skills/adr-toolkit/scripts/commands/check.py + - skills/adr-toolkit/scripts/commands/diff.py + - skills/adr-toolkit/scripts/core/constraints.py - skills/adr-toolkit/scripts/rules/conflict.py + - skills/adr-toolkit/references/conflict-rules.md tags: - check - mvp-scope - architecture -retrospective: false +retrospective: true --- # Limit CHECK's conflict detection to structural evidence only @@ -39,6 +44,26 @@ Chosen option: **structural-evidence-only detection**, because false positives a `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. +## Confirmed Evidence + +The repository implements a fixed six-kind constraint vocabulary in +`core/constraints.py`, evaluates it through structural path and line matching +in `rules/conflict.py`, and collects Git diff evidence in `commands/diff.py`. +The unit and integration suites exercise those deterministic boundaries. This +ADR was written retrospectively from that implemented MVP state. + +## Inferred Rationale + +The trust argument in this record is a reconstruction from the implemented +scope and design material: limiting CHECK to rules it can prove appears to have +been preferred over heuristic semantic coverage because false confidence would +be more damaging than an explicit `UNVERIFIABLE` result. + +## Unknown + +The exact contemporaneous discussion, relative weighting of the alternatives, +and any rejected semantic-analysis prototypes were not preserved. + ## 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 index 3c428ce..a1da1c5 100644 --- 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 @@ -3,16 +3,24 @@ id: ADR-0003 title: Localize only index.py's generated strings, not agent-composed text status: accepted date: 2026-08-30 -decision_makers: [] +decision_makers: + - YangSeungHyun +locale: en related: [] affected_paths: + - .adr-toolkit.json + - skills/adr-toolkit/SKILL.md - skills/adr-toolkit/scripts/core/locale.py + - skills/adr-toolkit/scripts/core/config.py + - skills/adr-toolkit/scripts/core/rendering.py - skills/adr-toolkit/scripts/i18n/ + - skills/adr-toolkit/scripts/commands/init.py + - skills/adr-toolkit/scripts/commands/create.py - skills/adr-toolkit/scripts/commands/index.py tags: - i18n - architecture -retrospective: false +retrospective: true --- # Localize only index.py's generated strings, not agent-composed text @@ -39,6 +47,26 @@ Chosen option: **localize only index.py's generated strings**, because an LLM ag `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. +## Confirmed Evidence + +The v0.1.0 implementation localized only INDEX-owned headings and status labels +through five JSON catalogs. INIT and CREATE still emitted English structure, +and there was no repository locale configuration. This ADR was reconstructed +after that implementation existed. + +## Inferred Rationale + +The motivation recorded above is inferred from the original narrow +implementation and design notes: avoid translating open-ended agent prose while +keeping the deterministic code surface small. + +## Unknown + +The original discussion did not preserve a complete comparison of +repository-level defaults, localized deterministic templates, or multilingual +filename behavior. Those omissions later became the reason to revisit this +decision for v0.2.0. + ## 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 index 9fb3d6c..bd902d1 100644 --- 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 @@ -3,14 +3,19 @@ id: ADR-0004 title: Adapter packaging: manifest-only directories, install-time symlinks, verified formats status: accepted date: 2026-08-30 -decision_makers: [] +decision_makers: + - YangSeungHyun +locale: en related: [] affected_paths: - adapters/ + - .gitignore + - .claude-plugin/marketplace.json + - .claude-plugin/plugin.json tags: - adapters - harness-support -retrospective: false +retrospective: true --- # Adapter packaging: manifest-only directories, install-time symlinks, verified formats @@ -19,6 +24,13 @@ retrospective: false 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). +## Decision Drivers + +* Keep one canonical `skills/adr-toolkit/` package across harnesses. +* Preserve Windows-compatible tracked repository contents. +* Require manifest claims to be backed by documentation or an executable install check. +* Keep adapter-specific setup visible instead of hiding it in generated copies. + ## Considered Options * Copy the full `skills/adr-toolkit/` package into each `adapters//` directory @@ -34,10 +46,47 @@ Chosen option: **document the symlink as an install-time step, with formats veri * 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." +## Pros and Cons of the Options + +### Copy the package into every adapter + +* Good, because each adapter directory is self-contained after checkout. +* Bad, because duplicated skill code and documentation can drift between harnesses. + +### Commit adapter-local symlinks + +* Good, because every adapter references the canonical package without copying it. +* Bad, because Windows checkout behavior depends on Git and filesystem symlink support. + +### Create symlinks at install time and verify manifest formats + +* Good, because the repository retains one source of truth and each harness contract can be tested independently. +* Bad, because installation includes an explicit platform-sensitive setup step and its documentation must remain accurate. + ## 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. +## Confirmed Evidence + +The tracked adapter directories contain manifests and README files but no +committed adapter-local `skills/` symlinks. The repository ignore rules exclude +those install-time paths, and the test suite checks the supported manifest +shapes. This record was reconstructed after the adapter packaging work. + +## Inferred Rationale + +The preference for install-time links is inferred from the observed +single-source package layout, the cross-platform CI matrix, and review notes +that corrected guessed manifest fields. + +## Unknown + +The original option weighting and the exact manual verification performed for +every harness version were not captured uniformly. Adapter claims must +therefore continue to distinguish executable verification from structural +layout checks. + ## 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 index 0084b8a..6d8250d 100644 --- 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 @@ -3,11 +3,15 @@ id: ADR-0005 title: Adopt Git Flow with direct-tag release automation status: accepted date: 2026-08-30 -decision_makers: [] +decision_makers: + - YangSeungHyun +locale: en related: [] affected_paths: - AGENTS.md - .github/workflows/ + - skills/adr-toolkit/VERSION + - scripts/sync_version.py tags: - process - release diff --git a/tests/integration/test_repository_adrs.py b/tests/integration/test_repository_adrs.py new file mode 100644 index 0000000..5144ff1 --- /dev/null +++ b/tests/integration/test_repository_adrs.py @@ -0,0 +1,64 @@ +from pathlib import Path + +from scripts.core import frontmatter as fm +from scripts.core.identifiers import parse_filename + +REPO_ROOT = Path(__file__).resolve().parents[2] +ADR_DIR = REPO_ROOT / "docs/decisions" + + +def _repository_adrs(): + for path in sorted(ADR_DIR.glob("*.md")): + if parse_filename(path.name) is None: + continue + data, body = fm.parse(path.read_text(encoding="utf-8")) + yield path, data, body + + +def test_accepted_repository_adrs_name_a_decision_maker_and_locale(): + for path, data, _body in _repository_adrs(): + if data["status"] == "accepted": + assert data["decision_makers"] == ["YangSeungHyun"], path.name + assert data.get("locale") == "en", path.name + + +def test_retrospective_repository_adrs_separate_evidence_from_inference(): + entries = list(_repository_adrs()) + retrospective_ids = { + data["id"] for _path, data, _body in entries if data["retrospective"] + } + assert retrospective_ids == {"ADR-0002", "ADR-0003", "ADR-0004"} + + for path, data, body in entries: + if data["retrospective"]: + assert "## Confirmed Evidence" in body, path.name + assert "## Inferred Rationale" in body, path.name + assert "## Unknown" in body, path.name + + +def test_repository_adrs_cover_their_governing_paths(): + required_paths = { + "ADR-0002": { + "skills/adr-toolkit/scripts/core/constraints.py", + "skills/adr-toolkit/scripts/commands/diff.py", + "skills/adr-toolkit/references/conflict-rules.md", + }, + "ADR-0003": { + ".adr-toolkit.json", + "skills/adr-toolkit/SKILL.md", + "skills/adr-toolkit/scripts/core/rendering.py", + }, + "ADR-0004": {"adapters/", ".gitignore", ".claude-plugin/marketplace.json"}, + "ADR-0005": {"skills/adr-toolkit/VERSION", "scripts/sync_version.py"}, + } + for path, data, _body in _repository_adrs(): + expected = required_paths.get(data["id"], set()) + assert expected <= set(data["affected_paths"]), path.name + + +def test_repository_adr_structure_matches_its_evidence_contract(): + entries = {data["id"]: (path, body) for path, data, body in _repository_adrs()} + assert "* [ ]" not in entries["ADR-0001"][1] + adr4_body = entries["ADR-0004"][1] + assert "## Decision Drivers" in adr4_body + assert "## Pros and Cons of the Options" in adr4_body From f2f6c75643bd97a9499b4236d4469823f4ff4165 Mon Sep 17 00:00:00 2001 From: shcommit Date: Sun, 30 Aug 2026 20:36:40 +0900 Subject: [PATCH 21/76] docs: supersede the MVP localization decision --- ...nerated-strings-not-agent-composed-text.md | 3 +- .../0006-localized-adr-generation.md | 102 ++++++++++++++++++ docs/decisions/README.md | 80 +++++++++++++- 3 files changed, 183 insertions(+), 2 deletions(-) create mode 100644 docs/decisions/0006-localized-adr-generation.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 index a1da1c5..3f41f1d 100644 --- 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 @@ -1,7 +1,7 @@ --- id: ADR-0003 title: Localize only index.py's generated strings, not agent-composed text -status: accepted +status: superseded date: 2026-08-30 decision_makers: - YangSeungHyun @@ -21,6 +21,7 @@ tags: - i18n - architecture retrospective: true +superseded_by: ADR-0006 --- # Localize only index.py's generated strings, not agent-composed text diff --git a/docs/decisions/0006-localized-adr-generation.md b/docs/decisions/0006-localized-adr-generation.md new file mode 100644 index 0000000..881c5b4 --- /dev/null +++ b/docs/decisions/0006-localized-adr-generation.md @@ -0,0 +1,102 @@ +--- +id: ADR-0006 +title: Localize deterministic ADR generation through repository configuration +status: accepted +date: 2026-08-30 +locale: en +decision_makers: + - YangSeungHyun +related: + - ADR-0003 +affected_paths: + - .adr-toolkit.json + - skills/adr-toolkit/SKILL.md + - skills/adr-toolkit/schemas/adr.schema.json + - skills/adr-toolkit/scripts/adr.py + - skills/adr-toolkit/scripts/core/config.py + - skills/adr-toolkit/scripts/core/locale.py + - skills/adr-toolkit/scripts/core/rendering.py + - skills/adr-toolkit/scripts/core/identifiers.py + - skills/adr-toolkit/scripts/core/schema.py + - skills/adr-toolkit/scripts/i18n/ + - skills/adr-toolkit/scripts/commands/init.py + - skills/adr-toolkit/scripts/commands/create.py + - skills/adr-toolkit/scripts/commands/index.py + - skills/adr-toolkit/scripts/commands/validate.py +tags: + - i18n + - configuration + - architecture + - v0.2.0 +retrospective: false +supersedes: + - ADR-0003 +--- + +# Localize deterministic ADR generation through repository configuration + +## Context and Problem Statement + +ADR-0003 limited localization to INDEX-owned strings. That MVP boundary left INIT and CREATE structures in English, required repeated locale flags, and rejected titles containing no ASCII characters. Teams need a repository-owned default language while retaining deterministic IDs, filenames, schema validation, lifecycle transitions, and CHECK behavior. + +## Decision Drivers + +* Keep repository state deterministic even when an agent assists with language inference or drafting. +* Let teams choose a default ADR language without repeating a flag for every operation. +* Preserve Unicode titles and bodies while keeping filenames portable across filesystems, URLs, and Git clients. +* Keep runtime validation and the published JSON Schema consistent. +* Avoid claiming that machine translation or transliteration is deterministic. + +## Considered Options + +* Retain ADR-0003 and localize only INDEX output. +* Delegate all localization, translation, and filename generation to an agent. +* Localize deterministic generation through a repository config, canonical catalogs, and validated agent inputs. + +## Decision Outcome + +Chosen option: **localize deterministic generation through a repository config, canonical catalogs, and validated agent inputs**, because it supports team language policy without handing repository governance to probabilistic output. + +The repository root may contain `.adr-toolkit.json` with `schema_version: 1` and a default `locale`. The canonical locale set is `en`, `ko`, `ja`, `zh`, `fr`, `es`, `de`, and `pt-BR`; `zh` means Simplified Chinese. CLI locale resolution is explicit CLI, then approved input draft, then repository default, then English. Agent workflows use explicit user language, then request language, then repository default, then English. + +The deterministic core localizes code-owned prompts, MADR structure, initial content, and index labels. It never translates user prose. ADR frontmatter may store an optional canonical `locale`. Unicode titles and bodies are preserved, while filenames remain ASCII. An agent may propose a meaningful ASCII slug for human approval; the core validates it and otherwise derives an ASCII title slug or falls back to `decision`. + +### Consequences + +* Good: INIT, CREATE, VALIDATE, and INDEX behave reproducibly across eight languages using one repository default. +* Good: the agent can improve filename discoverability without gaining authority to bypass deterministic validation. +* Good: existing ADRs without locale metadata remain valid. +* Bad: eight catalogs must keep exact key parity and require terminology review as the generated structure evolves. +* Bad: a non-ASCII title without an approved semantic slug falls back to a less descriptive `decision` filename. +* Bad: repository configuration introduces another versioned contract that must fail visibly when malformed or unsupported. + +### Confirmation + +* `tests/integration/test_localized_workflow.py` runs INIT, CREATE, VALIDATE, and INDEX for all eight canonical locales. +* `tests/unit/test_config.py` verifies config validation and locale precedence. +* `tests/unit/test_identifiers.py` verifies semantic slug validation and the deterministic fallback. +* `tests/unit/test_schema.py` verifies runtime and JSON Schema locale parity. + +## Pros and Cons of the Options + +### Retain INDEX-only localization + +* Good, because it keeps the translation surface small. +* Bad, because repository defaults, localized creation, and non-ASCII title workflows remain unsupported. + +### Delegate localization and filenames entirely to an agent + +* Good, because an agent can infer language and propose natural semantic filenames. +* Bad, because repository state would depend on non-deterministic translation and transliteration unless every output crossed a validation boundary. + +### Repository config, canonical catalogs, and validated agent inputs + +* Good, because deterministic code owns configuration, catalogs, schema, IDs, and filenames while an agent assists only at the edge. +* Bad, because catalog parity and config compatibility become maintained product contracts. + +## Revisit Triggers + +* A supported locale needs regional variants whose terminology cannot share the current catalog. +* Teams require bilingual rendering of one ADR without creating parallel sources of truth. +* Repeated `decision` fallbacks show that the semantic-slug confirmation flow is insufficient. +* A future config format needs additional repository-wide options beyond locale and requires a schema migration. diff --git a/docs/decisions/README.md b/docs/decisions/README.md index 370fca0..ec87f85 100644 --- a/docs/decisions/README.md +++ b/docs/decisions/README.md @@ -5,9 +5,12 @@ ### 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) +- [ADR-0006 — Localize deterministic ADR generation through repository configuration](0006-localized-adr-generation.md) + +### Superseded +- [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) ## By tag @@ -17,10 +20,14 @@ ### 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) +- [ADR-0006 — Localize deterministic ADR generation through repository configuration](0006-localized-adr-generation.md) ### check - [ADR-0002 — Limit CHECK's conflict detection to structural evidence only](0002-limit-check-s-conflict-detection-to-structural-evidence-only.md) +### configuration +- [ADR-0006 — Localize deterministic ADR generation through repository configuration](0006-localized-adr-generation.md) + ### git-flow - [ADR-0005 — Adopt Git Flow with direct-tag release automation](0005-adopt-git-flow-with-direct-tag-release-automation.md) @@ -29,6 +36,7 @@ ### 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) +- [ADR-0006 — Localize deterministic ADR generation through repository configuration](0006-localized-adr-generation.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) @@ -40,11 +48,27 @@ ### release - [ADR-0005 — Adopt Git Flow with direct-tag release automation](0005-adopt-git-flow-with-direct-tag-release-automation.md) +### v0.2.0 +- [ADR-0006 — Localize deterministic ADR generation through repository configuration](0006-localized-adr-generation.md) + ## By affected path +### `.adr-toolkit.json` +- [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-0006 — Localize deterministic ADR generation through repository configuration](0006-localized-adr-generation.md) + +### `.claude-plugin/marketplace.json` +- [ADR-0004 — Adapter packaging: manifest-only directories, install-time symlinks, verified formats](0004-adapter-packaging-manifest-only-directories-install-time-symlinks-verified-formats.md) + +### `.claude-plugin/plugin.json` +- [ADR-0004 — Adapter packaging: manifest-only directories, install-time symlinks, verified formats](0004-adapter-packaging-manifest-only-directories-install-time-symlinks-verified-formats.md) + ### `.github/workflows/` - [ADR-0005 — Adopt Git Flow with direct-tag release automation](0005-adopt-git-flow-with-direct-tag-release-automation.md) +### `.gitignore` +- [ADR-0004 — Adapter packaging: manifest-only directories, install-time symlinks, verified formats](0004-adapter-packaging-manifest-only-directories-install-time-symlinks-verified-formats.md) + ### `AGENTS.md` - [ADR-0005 — Adopt Git Flow with direct-tag release automation](0005-adopt-git-flow-with-direct-tag-release-automation.md) @@ -54,17 +78,70 @@ ### `docs/decisions/` - [ADR-0001 — Record architecture decisions](0001-record-architecture-decisions.md) +### `scripts/sync_version.py` +- [ADR-0005 — Adopt Git Flow with direct-tag release automation](0005-adopt-git-flow-with-direct-tag-release-automation.md) + +### `skills/adr-toolkit/SKILL.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-0006 — Localize deterministic ADR generation through repository configuration](0006-localized-adr-generation.md) + +### `skills/adr-toolkit/VERSION` +- [ADR-0005 — Adopt Git Flow with direct-tag release automation](0005-adopt-git-flow-with-direct-tag-release-automation.md) + +### `skills/adr-toolkit/references/conflict-rules.md` +- [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/schemas/adr.schema.json` +- [ADR-0006 — Localize deterministic ADR generation through repository configuration](0006-localized-adr-generation.md) + +### `skills/adr-toolkit/scripts/adr.py` +- [ADR-0006 — Localize deterministic ADR generation through repository configuration](0006-localized-adr-generation.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/create.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) +- [ADR-0006 — Localize deterministic ADR generation through repository configuration](0006-localized-adr-generation.md) + +### `skills/adr-toolkit/scripts/commands/diff.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) +- [ADR-0006 — Localize deterministic ADR generation through repository configuration](0006-localized-adr-generation.md) + +### `skills/adr-toolkit/scripts/commands/init.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) +- [ADR-0006 — Localize deterministic ADR generation through repository configuration](0006-localized-adr-generation.md) + +### `skills/adr-toolkit/scripts/commands/validate.py` +- [ADR-0006 — Localize deterministic ADR generation through repository configuration](0006-localized-adr-generation.md) + +### `skills/adr-toolkit/scripts/core/config.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) +- [ADR-0006 — Localize deterministic ADR generation through repository configuration](0006-localized-adr-generation.md) + +### `skills/adr-toolkit/scripts/core/constraints.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/core/identifiers.py` +- [ADR-0006 — Localize deterministic ADR generation through repository configuration](0006-localized-adr-generation.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) +- [ADR-0006 — Localize deterministic ADR generation through repository configuration](0006-localized-adr-generation.md) + +### `skills/adr-toolkit/scripts/core/rendering.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) +- [ADR-0006 — Localize deterministic ADR generation through repository configuration](0006-localized-adr-generation.md) + +### `skills/adr-toolkit/scripts/core/schema.py` +- [ADR-0006 — Localize deterministic ADR generation through repository configuration](0006-localized-adr-generation.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) +- [ADR-0006 — Localize deterministic ADR generation through repository configuration](0006-localized-adr-generation.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) @@ -76,3 +153,4 @@ - 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) +- 2026-08-30 — [ADR-0006 — Localize deterministic ADR generation through repository configuration](0006-localized-adr-generation.md) From 26141a1ffddf192aaec72bb4e99de23b0a365b13 Mon Sep 17 00:00:00 2001 From: shcommit Date: Sun, 30 Aug 2026 20:39:27 +0900 Subject: [PATCH 22/76] docs: explain multilingual ADR workflows --- README.md | 80 +++++++++++++++++++++++++++++-- examples/quickstart.md | 66 +++++++++++++++++++++++-- handoff.md | 9 ++-- skills/adr-toolkit/SKILL.md | 42 ++++++++-------- tests/unit/test_readme.py | 28 +++++++++++ tests/unit/test_skill_manifest.py | 13 ++++- 6 files changed, 206 insertions(+), 32 deletions(-) create mode 100644 tests/unit/test_readme.py diff --git a/README.md b/README.md index c2b2255..0ba4e03 100644 --- a/README.md +++ b/README.md @@ -50,16 +50,88 @@ directly in a terminal — no agent required: python skills/adr-toolkit/scripts/adr.py create --interactive --dir docs/decisions ``` +## Language and repository config + +ADR Toolkit localizes deterministic, code-owned text in eight canonical +locales: `en`, `ko`, `ja`, `zh`, `fr`, `es`, `de`, and `pt-BR`. `zh` means +Simplified Chinese. User-authored prose is preserved as written, while JSON +keys, status values, error codes, IDs, and filenames remain machine-stable. + +Choose the repository default during INIT: + +```bash +python skills/adr-toolkit/scripts/adr.py init --locale ko --dir docs/decisions --json +``` + +This creates `.adr-toolkit.json` at the repository root: + +```json +{ + "schema_version": 1, + "locale": "ko" +} +``` + +Commands then use the repository default without repeating `--locale`: + +```bash +python skills/adr-toolkit/scripts/adr.py create --interactive --dir docs/decisions --json +python skills/adr-toolkit/scripts/adr.py index --dir docs/decisions --json +``` + +An explicit flag overrides the repository default for one operation. Input +draft locale overrides the repository only when the CLI flag is absent: + +```bash +python skills/adr-toolkit/scripts/adr.py create --locale ja --interactive --dir docs/decisions --json +python skills/adr-toolkit/scripts/adr.py index --locale fr --dir docs/decisions --json +``` + +The effective CLI order is explicit flag → approved input draft → repository +default → `en`. Unsupported locales and malformed config fail visibly. + +### Unicode titles and portable filenames + +Titles and bodies remain Unicode. Filenames remain ASCII for filesystem, URL, +and Git portability. For the title `결제 시스템 분리`, an agent can propose a +meaningful slug and show it for human approval: + +```bash +python skills/adr-toolkit/scripts/adr.py create --input draft.json \ + --slug separate-payment-system --dir docs/decisions --json +``` + +The deterministic core validates the slug and creates a filename such as +`0002-separate-payment-system.md`. It never translates or transliterates the +title itself. Without an approved slug or an ASCII fragment in the title, a +fresh repository safely falls back to `0001-decision.md`. + +## CHECK confidence + +CHECK does not certify the entire architecture. It evaluates only explicit, +structurally provable rules in the selected diff: + +| Meaning | Existing JSON result | +|---|---| +| `VERIFIED` | `related`, only for applicable explicit rules that were evaluated | +| `VIOLATED` | `verified_violation` | +| `UNVERIFIABLE` | `review_required` or `no_applicable_constraint` | +| `NOT_APPLICABLE` | No known ADR/rule applies to the selected change | + +Warnings mean some evidence could not be evaluated and must be reported. A +clean result never proves prose rationale, runtime behavior, or every +architecture invariant. + ## 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). +- Deterministic INIT, CREATE, and INDEX structure ships in eight locales; + agent-composed and user-authored prose is never machine-translated by the + core. See + [ADR-0006](docs/decisions/0006-localized-adr-generation.md). - Everything explicitly deferred out of MVP is tracked in [`project-roadmap.md`](project-roadmap.md), not silently dropped. diff --git a/examples/quickstart.md b/examples/quickstart.md index f5e3cbf..2a51d61 100644 --- a/examples/quickstart.md +++ b/examples/quickstart.md @@ -49,6 +49,7 @@ python skills/adr-toolkit/scripts/adr.py init --dir docs/decisions --json "operation": "init", "dry_run": false, "created": [ + ".adr-toolkit.json", "docs/decisions", "docs/decisions/adr-template.md", "docs/decisions/0001-record-architecture-decisions.md" @@ -120,7 +121,7 @@ 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 +python skills/adr-toolkit/scripts/adr.py index --dir docs/decisions --json ``` Both report `"ok": true`, and `docs/decisions/README.md` now reads: @@ -202,7 +203,65 @@ python skills/adr-toolkit/scripts/adr.py check --uncommitted --dir docs/decision ``` The violation is gone — `related` means the diff still touches a path this -ADR governs, but the rule that fired before no longer does. +ADR governs, but the explicit structural rule that fired before no longer +does. It is `VERIFIED` only for that rule and selected diff, not proof of the +entire architecture. + +## Multilingual variation — Korean repository, portable filename + +In a fresh repository, choose Korean once during INIT: + +```bash +python skills/adr-toolkit/scripts/adr.py init --locale ko --dir docs/decisions --json +``` + +Real output: + +```json +{ + "ok": true, + "operation": "init", + "dry_run": false, + "created": [ + ".adr-toolkit.json", + "docs/decisions", + "docs/decisions/adr-template.md", + "docs/decisions/0001-record-architecture-decisions.md" + ] +} +``` + +The config now supplies `ko` to CREATE and INDEX. Given a reviewed draft whose +title is `결제 시스템 분리`, let the agent propose `separate-payment-system` +and approve that semantic ASCII slug before mutation: + +```bash +python skills/adr-toolkit/scripts/adr.py create --input draft.json \ + --slug separate-payment-system --dir docs/decisions --json +``` + +Real output: + +```json +{ + "ok": true, + "operation": "create", + "dry_run": false, + "created": "docs/decisions/0002-separate-payment-system.md", + "id": "ADR-0002" +} +``` + +The title/body remain Korean and the ADR records `locale: ko`; the core never +translates the title to obtain the filename. Omitting an approved slug for a +title with no ASCII characters safely uses `decision` instead. + +```bash +python skills/adr-toolkit/scripts/adr.py index --dir docs/decisions --json +``` + +The real command reports `"count": 2`, and the generated index begins with +`# 결정 기록` because it reads the repository default. ## Next steps @@ -211,6 +270,7 @@ ADR governs, but the rule that fired before no longer does. `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. + finding classification plus VERIFIED / VIOLATED / UNVERIFIABLE / + NOT_APPLICABLE confidence meaning 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/handoff.md b/handoff.md index 8ae5b35..2dff313 100644 --- a/handoff.md +++ b/handoff.md @@ -27,10 +27,11 @@ Local execution plans live under ignored `docs/superpowers/plans/`. ## Next step -Implement `.adr-toolkit.json`, locale resolution, eight catalogs, localized -rendering, and semantic-slug validation with TDD. Then harden CHECK, correct the -existing ADRs, request the lifecycle checkpoint for ADR-0003 supersession, and -finish reports/documentation. +Document repository locale defaults, overrides, semantic slugs, and CHECK +confidence in README/quickstart/SKILL. Then write the Korean v0.2.0 readiness +report and the separate enterprise-adoption report. ADR-0006 now supersedes +ADR-0003 bidirectionally; validation, repository ADR quality checks, and +idempotent index generation pass. ## Open risks diff --git a/skills/adr-toolkit/SKILL.md b/skills/adr-toolkit/SKILL.md index 991a305..0691dcc 100644 --- a/skills/adr-toolkit/SKILL.md +++ b/skills/adr-toolkit/SKILL.md @@ -27,23 +27,24 @@ PREFLIGHT ## 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. +Use one of the canonical locales `en`, `ko`, `ja`, `zh`, `fr`, `es`, `de`, +or `pt-BR`; `zh` means Simplified Chinese. Compose agent-authored questions +and reports using this precedence: explicit user request → request language → +repository default in `.adr-toolkit.json` → `en`. Do not translate prose the +user already supplied. -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): +The deterministic CLI resolves locale as explicit `--locale` → approved input +draft `locale` → repository default → `en`. Normally omit repeated flags when +the repository default matches the request; pass `--locale` only for an +explicit per-operation override. For example: ```bash python skills/adr-toolkit/scripts/adr.py index --dir docs/decisions --locale fr --json ``` -Every `index` command shown elsewhere in this file writes `--locale `; -substitute the detected language's code, or `en` when it can't be determined. +INIT and CREATE localize deterministic prompts and MADR structure. INDEX +localizes its generated headings. IDs, status values, JSON keys, error codes, +and filenames remain English/ASCII machine contracts. ## INIT (scaffolding only) @@ -56,11 +57,10 @@ time later) to recover past decisions. 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 skills/adr-toolkit/scripts/adr.py init --dir docs/decisions` to - scaffold the directory, template, and ADR-0001. +3. **MUTATE** — run `python skills/adr-toolkit/scripts/adr.py init --locale --dir docs/decisions` + to create `.adr-toolkit.json`, the directory, template, and ADR-0001. 4. **VALIDATE** — run `python skills/adr-toolkit/scripts/adr.py validate --dir docs/decisions --json` - and `python skills/adr-toolkit/scripts/adr.py index --dir docs/decisions --locale --json` - (see Language for ``). + and `python skills/adr-toolkit/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. @@ -181,19 +181,20 @@ workflow does not. can enforce it — see `references/conflict-rules.md` for the six rule kinds and the exact block syntax. 6. **CONFIRM** — before any `create`, show the title, problem, considered - options, decision, primary driver, accepted downside, and affected paths. + options, decision, primary driver, accepted downside, affected paths, and + the proposed semantic ASCII slug. For a non-ASCII title, suggest a useful + slug but never translate or transliterate inside the deterministic core. Get explicit approval of the draft. If it may replace an Accepted ADR, separately confirm that supersession is intended. 7. **MUTATE** — write the approved draft JSON and run - `python skills/adr-toolkit/scripts/adr.py create --input --dir docs/decisions --json`. + `python skills/adr-toolkit/scripts/adr.py create --input --slug --dir docs/decisions --json`. Only after explicit supersession intent and approval, follow the supersede preview and mutation sequence in Lifecycle operations; never hand-edit lifecycle fields or links. 8. **VALIDATE** — run `python skills/adr-toolkit/scripts/adr.py validate --dir docs/decisions --json` and - `python skills/adr-toolkit/scripts/adr.py index --dir docs/decisions --locale --json` - (see Language for ``). + `python skills/adr-toolkit/scripts/adr.py index --dir docs/decisions --json`. 9. **REPORT** — report facts found, judgment and significance result, questions asked, files created or updated, validation result, and remaining uncertainty. @@ -215,7 +216,8 @@ pair, and ask for confirmation. Only then run the same command without | Supersede an ADR with another | `python skills/adr-toolkit/scripts/adr.py supersede --by --dir docs/decisions --dry-run --json` | `python skills/adr-toolkit/scripts/adr.py supersede --by --dir docs/decisions --json` | After a successful mutation, run the same validate and index commands as -RECORD, including `index`'s `--locale `. If a preview or mutation returns `INVALID_TRANSITION`, stop and +RECORD, using the repository locale unless the user requested an explicit +override. If a preview or mutation returns `INVALID_TRANSITION`, stop and explain the rejected transition using `references/lifecycle.md`; do not retry with a different status or bypass the script. diff --git a/tests/unit/test_readme.py b/tests/unit/test_readme.py new file mode 100644 index 0000000..0be3ee8 --- /dev/null +++ b/tests/unit/test_readme.py @@ -0,0 +1,28 @@ +from pathlib import Path + +README = Path(__file__).resolve().parents[2] / "README.md" + + +def test_readme_documents_repository_locale_configuration(): + text = README.read_text(encoding="utf-8") + assert ".adr-toolkit.json" in text + for locale in ["en", "ko", "ja", "zh", "fr", "es", "de", "pt-BR"]: + assert f"`{locale}`" in text + assert "init --locale ko" in text + assert "create --interactive" in text + assert "create --locale ja" in text + assert "index --locale fr" in text + + +def test_readme_documents_non_ascii_titles_and_semantic_slugs(): + text = README.read_text(encoding="utf-8") + assert "결제 시스템 분리" in text + assert "--slug separate-payment-system" in text + assert "0001-decision.md" in text + + +def test_readme_scopes_check_confidence(): + text = README.read_text(encoding="utf-8") + assert "CHECK does not certify the entire architecture" in text + for label in ["VERIFIED", "VIOLATED", "UNVERIFIABLE", "NOT_APPLICABLE"]: + assert label in text diff --git a/tests/unit/test_skill_manifest.py b/tests/unit/test_skill_manifest.py index 4c7ef02..4dd9f8e 100644 --- a/tests/unit/test_skill_manifest.py +++ b/tests/unit/test_skill_manifest.py @@ -59,4 +59,15 @@ def test_record_points_at_the_conflict_rules_reference_for_constraints_blocks(): 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 + for locale in ["en", "ko", "ja", "zh", "fr", "es", "de", "pt-BR"]: + assert locale in body + assert "explicit user request" in body + assert "request language" in body + assert "repository default" in body + + +def test_skill_md_requires_semantic_slug_confirmation(): + _, body = fm.parse(SKILL_MD.read_text(encoding="utf-8")) + assert "semantic ASCII slug" in body + assert "--slug" in body + assert "show" in body and "CONFIRM" in body From 4588cbfd6ceeaf6478d425e5409edad06548f3ce Mon Sep 17 00:00:00 2001 From: shcommit Date: Sun, 30 Aug 2026 21:04:08 +0900 Subject: [PATCH 23/76] feat: ship v0.2.0 multilingual ADR support and release evidence Adds repository-configured eight-locale ADR generation with strict config/schema validation, portable Unicode filenames with an approved semantic ASCII slug, closes several CHECK false-clean gaps, and makes repository-scoped commands resolve relative paths from --root instead of the caller's CWD. Includes updated docs, the dogfooded ADR history (ADR-0006 superseding ADR-0003), and the Korean v0.2.0 readiness and enterprise-adoption reports with local release evidence. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01CeDZgjQakpnGGtsWoC7ijF --- changelog.md | 19 ++ docs/adr-toolkit-v0.2.0-readiness-report.md | 193 +++++++++++++++++ docs/enterprise-adoption.md | 199 ++++++++++++++++++ handoff.md | 42 ++-- improvements.md | 79 +++---- skills/adr-toolkit/SKILL.md | 6 +- .../adr-toolkit/references/conflict-rules.md | 5 +- skills/adr-toolkit/scripts/commands/check.py | 14 +- skills/adr-toolkit/scripts/commands/create.py | 3 +- skills/adr-toolkit/scripts/commands/diff.py | 69 ++++-- skills/adr-toolkit/scripts/commands/index.py | 6 +- skills/adr-toolkit/scripts/commands/init.py | 3 +- .../adr-toolkit/scripts/commands/validate.py | 6 +- skills/adr-toolkit/scripts/core/git_paths.py | 7 +- skills/adr-toolkit/scripts/core/locale.py | 8 +- .../scripts/core/repository_paths.py | 9 + tests/unit/test_check.py | 53 +++++ tests/unit/test_create.py | 15 ++ tests/unit/test_create_interactive.py | 31 +++ tests/unit/test_diff.py | 15 ++ tests/unit/test_git_paths.py | 9 + tests/unit/test_index.py | 17 ++ tests/unit/test_init.py | 17 ++ tests/unit/test_validate.py | 17 ++ 24 files changed, 727 insertions(+), 115 deletions(-) create mode 100644 docs/adr-toolkit-v0.2.0-readiness-report.md create mode 100644 docs/enterprise-adoption.md create mode 100644 skills/adr-toolkit/scripts/core/repository_paths.py diff --git a/changelog.md b/changelog.md index 480f840..944484e 100644 --- a/changelog.md +++ b/changelog.md @@ -4,6 +4,25 @@ Lightweight human-readable summary of meaningful repository changes. ## Unreleased +- Added repository-configured deterministic ADR localization for eight locales + (`en`, `ko`, `ja`, `zh`, `fr`, `es`, `de`, `pt-BR`) across INIT, CREATE, + templates, prompts, and INDEX, with strict config/schema validation and + explicit override precedence. +- Added portable multilingual ADR creation: Unicode titles and bodies are + preserved, an approved semantic ASCII slug can be supplied, and non-ASCII + titles safely fall back to an ID-qualified `decision` filename. +- Hardened CHECK against false-clean results by failing closed on incomplete git + evidence, retaining both sides of renames and Unicode paths, detecting + working-tree deletions, exposing invalid ADRs, following Git ignore/path + semantics, and rejecting conflicting diff modes. +- Made relative ADR directories resolve consistently from `--root` for every + repository-scoped generation and validation command. +- Improved the repository's own ADR evidence and lifecycle history, including + the accepted ADR-0006 decision that supersedes the MVP index-only + localization decision ADR-0003. +- Expanded README, quickstart, and skill guidance with real multilingual + workflows and CHECK confidence boundaries, and added separate Korean v0.2.0 + readiness and enterprise-adoption reports. - Added the self-contained `skills/adr-toolkit/` package: deterministic scaffolding/discovery commands (`init`, `discover`, `preflight`, `validate`, `index`), ADR frontmatter/ID/lifecycle core logic, MADR diff --git a/docs/adr-toolkit-v0.2.0-readiness-report.md b/docs/adr-toolkit-v0.2.0-readiness-report.md new file mode 100644 index 0000000..7fbcd4c --- /dev/null +++ b/docs/adr-toolkit-v0.2.0-readiness-report.md @@ -0,0 +1,193 @@ +# ADR Toolkit v0.2.0 준비도 보고서 + +작성일: 2026-08-30 + +평가 대상: `develop-2` 브랜치 + +릴리스 성격: 하위 호환을 유지하는 다국어 기능 추가이므로 patch가 아닌 minor release + +## 1. 결론 + +v0.2.0의 제품 구현 범위는 로컬 검증 기준으로 릴리스 준비가 끝났다. 저장소 기본 +언어 설정, 8개 언어의 결정론적 문서 생성, 비 ASCII 제목과 이식 가능한 파일명, +런타임과 JSON Schema의 locale 일치, 기존 ADR 품질 보강, CHECK의 false-clean 결함 +수정이 모두 테스트로 확인됐다. + +다만 **현재 판정은 `조건부 GO`**다. `develop-2`의 최종 변경이 PR의 필수 CI를 +통과하고, 소유자가 v0.2.0 버전 변경을 승인한 뒤 release 브랜치를 통해 +`master`에 병합하기 전에는 태그를 만들면 안 된다. 이는 기능 결함 때문이 아니라 +저장소가 채택한 릴리스 절차의 남은 통제 단계다. + +## 2. 평가 방법 + +### 2.1 성숙도 기준 + +| 수준 | 재현 가능한 의미 | +| --- | --- | +| 1 | 프로토타입 또는 수동 절차만 존재한다. | +| 2 | 기능은 존재하지만 중요한 정확성·운영 공백이 남아 있다. | +| 3 | 개인 또는 소규모 팀이 테스트와 문서에 따라 반복 사용할 수 있다. | +| 4 | CI와 저장소 거버넌스로 같은 보장을 반복 검증하고 강제할 수 있다. | +| 5 | 조직 단위 정책, 권한, 감사, 예외, 측정 체계가 운영된다. | + +점수는 정성적 소수점 대신 충족한 증거로 결정했다. 로컬 테스트가 충분하더라도 +현재 작업 브랜치의 PR CI와 보호 규칙으로 강제되지 않으면 수준 4로 올리지 않았다. + +### 2.2 근거 구분 + +- **저장소 사실**: 코드, 테스트, 명령 출력, GitHub API에서 직접 확인한 상태다. +- **외부 관행**: AWS, Microsoft, GitHub의 공식 문서에서 확인한 권고나 기능이다. +- **평가·추론**: 위 근거로부터 도출한 현재 수준과 권고이며 제품 사실과 구분한다. + +## 3. 객관적 기준선 + +| 영역 | 현재 수준 | 측정 신호 | 수준을 올리기 위한 조건 | +| --- | ---: | --- | --- | +| 결정론적 코어 | 3 | ID, lifecycle, schema, 관계, index, config를 테스트하며 전체 274개 테스트 통과 | 최종 PR CI를 필수 검사로 강제 | +| ADR 내용·lifecycle | 3 | ADR 6개 유효, 소유자·근거·locale 보강, ADR-0003과 ADR-0006 양방향 supersession 검증 | 독립 리뷰어와 CODEOWNERS 승인 강제 | +| 국제화 | 3 | 8개 locale catalog key parity와 INIT → CREATE → VALIDATE → INDEX E2E 통과 | PR CI 및 원어민 용어 검수 체계 | +| CHECK 신뢰성 | 3 | git 증거 수집 실패 시 fail-closed, rename 양 경로, ignore 및 diff-mode 회귀 테스트 | 실제 저장소 fixture 확대와 필수 CI 적용 | +| GitHub 거버넌스 | 2 | CI 5개 조합은 운영되지만 `master`/`develop` 보호와 ruleset, 필수 리뷰가 없음 | 공개 전환 후 ruleset을 API로 검증 | +| 엔터프라이즈 확장성 | 1 | 방향과 경계만 문서화됐고 조직 정책·감사·측정 구현은 없음 | 별도 도입 단계와 운영 데이터 필요 | + +국제화와 CHECK가 수준 3인 것은 기능 부족을 뜻하지 않는다. 기능 계약은 P0를 +충족하지만, 현재 브랜치에서 조직적 강제까지 확인하지 않았기 때문이다. + +## 4. v0.2.0 구현 검증 + +### 4.1 결정론적 다국어 생성 + +**저장소 사실** + +- 정식 locale은 `en`, `ko`, `ja`, `zh`, `fr`, `es`, `de`, `pt-BR` 8개다. +- `.adr-toolkit.json`이 저장소 기본 locale을 보유하며, 명령의 결정 순서는 + `CLI --locale` → 입력 draft의 locale → 저장소 기본값 → `en`이다. +- Agent는 사용자 명시 요청 → 요청 언어 → 저장소 기본값 → `en` 순으로 locale을 + 정하되, 결과는 동일한 결정론적 validator를 통과해야 한다. +- INIT, interactive CREATE, template, prompt, INDEX는 같은 catalog loader를 + 사용한다. input CREATE는 승인된 사용자 본문을 번역·재렌더링하지 않고 선택된 + locale metadata를 기록한다. 모든 catalog는 영어 key 집합과 정확히 일치한다. +- config의 알 수 없는 버전·키·locale은 묵시적으로 무시되지 않고 실패한다. +- locale 없는 기존 ADR은 계속 유효하며, locale이 있으면 런타임 validator와 + `adr.schema.json`이 같은 8개 값만 허용한다. + +### 4.2 파일명 정책 + +사용자의 Unicode 제목과 본문은 보존한다. 코어는 의미 번역이나 음역을 하지 않는다. +승인된 ASCII slug가 있으면 이를 검증해 사용하고, 안전한 ASCII slug를 만들 수 +없으면 `decision`으로 되돌아간다. ID가 앞에 붙으므로 충돌 없이 +`0006-decision.md` 같은 이식 가능한 이름을 만든다. + +Agent는 `separate-payment-system` 같은 의미 있는 slug를 제안할 수 있지만, 사용자의 +확인과 코어의 ASCII 규칙 검증을 모두 거쳐야 한다. 이 경계가 +**Deterministic Core / Agentic Edge** 원칙을 실제 동작으로 만든다. + +### 4.3 Dogfooding ADR 품질 + +**수정한 사실** + +- 기존 ADR에 승인된 decision maker와 `locale: en`을 기록했다. +- 사후 작성 ADR에는 확인된 증거, 추론한 근거, 알 수 없는 내용을 분리했다. +- ADR-0004를 Full MADR 구조로 보강하고 affected paths와 confirmation을 구체화했다. +- MVP의 index-only 국제화 결정인 ADR-0003을 유지한 채, 저장소 기본 locale과 + 다국어 생성을 채택한 ADR-0006이 이를 양방향으로 supersede하도록 lifecycle + 명령으로 변경했다. +- 생성된 ADR index를 두 번 생성해 동일한 해시가 유지되는 것을 확인했다. + +AWS는 중요한 결정에 context, decision, consequences와 명확한 소유자를 두고, +승인된 ADR은 직접 고치는 대신 새 ADR로 대체할 것을 권고한다. Microsoft 역시 ADR을 +append-only 기록으로 취급하고 status와 supersession을 명시하도록 설명한다. +현재 lifecycle 보강은 이 외부 관행과 일치한다. +([AWS ADR process](https://docs.aws.amazon.com/prescriptive-guidance/latest/architectural-decision-records/adr-process.html), +[Microsoft ADR guidance](https://learn.microsoft.com/en-us/azure/well-architected/architect-role/architecture-decision-record)) + +### 4.4 CHECK의 검증 한계 + +CHECK는 검증할 수 없는 정책까지 증명했다고 주장하지 않는다. + +| 결과 의미 | 해석 | +| --- | --- | +| `VERIFIED` | 구조화된 규칙과 git 증거로 정책 준수를 증명할 수 있다. | +| `VIOLATED` | 구조화된 규칙과 증거가 위반을 확인한다. | +| `NOT_APPLICABLE` | 현재 변경에 적용되는 구조화된 규칙이 없다. | +| `UNVERIFIABLE` | 산문·조직적 근거처럼 현재 rule vocabulary로 증명할 수 없다. | + +구현은 두 diff 호출과 tracked/untracked 파일 수집 실패를 모두 오류로 처리하고, +rename의 이전·새 경로와 Unicode 경로를 보존한다. 파일 존재 판단은 Git의 +tracked/untracked/ignore 및 working-tree 삭제 의미를 따르며 서로 충돌하는 diff +mode를 거부한다. schema-invalid ADR은 묵시적으로 제외하지 않고 warning으로 +노출한다. `--root`를 쓸 때 상대 `--dir`은 caller CWD가 아니라 repository root에서 +일관되게 해석한다. 따라서 현재까지 재현된 “증거를 못 읽었는데 clean으로 보이는” +경로는 닫혔다. 이 분류는 앞으로의 결과 모델이며, 현재 CLI의 세부 finding 이름과 +일대일로 같다는 뜻은 아니다. + +## 5. Release Gates + +### P0 — Release Blocking + +| Gate | 결과 | 증거 | +| --- | --- | --- | +| 비 ASCII 제목 생성 | PASS | 한국어 제목·본문 생성과 fallback/semantic slug 회귀 테스트 | +| locale catalog/loader 결정성 | PASS | 8개 catalog key parity 및 config 오류 테스트 | +| INIT / CREATE / INDEX의 정식 locale 지원 | PASS | 8-locale input E2E와 8-locale interactive prompt/render matrix | +| 런타임 validator / JSON Schema 일치 | PASS | locale parity 테스트, 기존 locale-less ADR 호환 | +| 기존 ADR의 RECORD 품질 계약 | PASS | 저장소 ADR 품질 테스트 4개 및 6개 ADR validation | +| CHECK false-confidence 정확성 | PASS | subprocess, rename, Unicode, deletion, schema warning, ignore, path, diff-mode 회귀 테스트 | +| 전체 테스트 | PASS | `python3 -m pytest -q` → `290 passed`, exit 0 | +| 버전 파일 드리프트 | PASS | `python3 scripts/sync_version.py --check` → exit 0 | + +### P1 — Strongly Recommended + +| 항목 | 결과 | +| --- | --- | +| 한국어 준비도 보고서 | PASS — 본 문서 | +| README 다국어 사용 예시 | PASS | +| 비라틴 quickstart | PASS — 한국어 기본 locale, semantic slug, 한국어 index의 실제 실행 결과 포함 | +| Dogfooding ADR 품질 개선 | PASS | +| work-tracking 문서 정합성 | PASS — 완료 항목은 `improvements.md`의 Done으로 이동 | + +### P2 — Post Release + +공개 저장소 ruleset, CODEOWNERS, 조직 ruleset, enterprise 도입 지표, +cross-repository discovery는 v0.2.0을 막지 않는다. 구체적인 단계와 통제는 +`docs/enterprise-adoption.md`에서 분리한다. + +## 6. 실제 GitHub 상태와 릴리스 판정 + +**2026-08-30에 확인한 저장소 사실** + +- 저장소는 private이고 기본 브랜치는 `master`다. +- `master`, `develop` branch protection과 repository ruleset이 없다. +- 현재 private 플랜에서 protection/ruleset API는 업그레이드 또는 public 전환을 + 요구하며 403을 반환한다. +- 첫 PR은 Ubuntu/macOS/Windows와 Python 3.9/3.12의 5개 CI 검사를 모두 통과했지만, + 별도 review 없이 병합됐다. +- CODEOWNERS와 PR template은 아직 없다. + +**평가** + +기능 구현에 대한 P0는 통과했다. 그러나 최종 변경이 원격 PR CI를 통과하지 않았고 +manifest 버전은 승인 없이 바꾸지 않았으므로, 지금 즉시 `v0.2.0` 태그를 만드는 +판정은 `NO-GO`다. 다음 세 조건을 만족하면 별도 설계 변경 없이 `GO`로 전환할 수 +있다. + +1. `develop-2` 변경을 PR로 올려 필수 CI가 모두 통과한다. +2. 소유자가 v0.2.0 version bump를 승인하고 `scripts/sync_version.py`로 전파한다. +3. release 브랜치를 `master`와 `develop`에 정책대로 반영한 뒤 tag가 `master`의 + 해당 커밋을 가리키는지 확인한다. + +공개 전환 후에는 GitHub ruleset으로 PR, required status checks, conversation +resolution, force-push/deletion 차단을 강제하고 API로 실제 적용을 확인해야 한다. +GitHub 공식 문서상 ruleset은 이 통제들을 제공하며 공개 저장소에서도 사용할 수 +있다. +([GitHub ruleset rules](https://docs.github.com/en/repositories/configuring-branches-and-merges-in-your-repository/managing-rulesets/available-rules-for-rulesets)) + +## 7. 남은 위험 + +- 8개 언어의 구조와 결정성은 검증했지만 전문 용어의 자연스러움은 각 언어 + 사용자의 검수를 대체하지 않는다. +- CHECK는 구조화된 경로·dependency 규칙만 증명한다. 사업적 타당성, 조직 효과, + 산문의 진실성은 `UNVERIFIABLE`로 남기는 것이 올바르다. +- 현재 저장소에서는 독립 리뷰와 브랜치 통제가 강제되지 않는다. public 전환 전에는 + 운영 절차가 사람의 준수에 의존한다. +- v0.2.0 버전 변경, release branch, push, tag는 이번 구현 범위에 포함되지 않았다. diff --git a/docs/enterprise-adoption.md b/docs/enterprise-adoption.md new file mode 100644 index 0000000..3de6607 --- /dev/null +++ b/docs/enterprise-adoption.md @@ -0,0 +1,199 @@ +# ADR Toolkit 엔터프라이즈 도입 보고서 + +작성일: 2026-08-30 + +범위: v0.2.0 이후의 팀·공개 저장소·조직 단위 도입 + +## 1. 요약 + +ADR Toolkit의 강점은 AI가 초안과 언어 표현을 돕더라도 repository state의 진실은 +결정론적 코어가 소유한다는 점이다. 현재 구현은 개인·소규모 팀이 반복 사용할 수 +있는 수준이지만, 엔터프라이즈 도구라고 부르기에는 권한, 독립 승인, 감사, 예외, +조직 정책 배포, 채택 측정이 부족하다. + +가장 좋은 확장 순서는 기능을 한 번에 늘리는 것이 아니라 다음 네 단계다. + +1. private solo 저장소에서 v0.2.0의 정확성을 확정한다. +2. public 전환 시 GitHub ruleset과 기본 기여 통제를 실제로 강제한다. +3. 복수 maintainer가 생기면 CODEOWNERS와 독립 ADR review를 도입한다. +4. 여러 저장소가 같은 문제를 반복할 때 조직 ruleset, reusable workflow, audit, + metrics, cross-repository discovery를 추가한다. + +## 2. 현재 상태 + +### 확인된 사실 + +- 저장소는 private이며 `master`와 `develop` 보호 규칙 및 repository ruleset이 없다. +- 현재 플랜에서는 private 저장소 protection API가 활성화되지 않는다. +- CI는 Ubuntu, macOS, Windows와 지원 Python 조합에서 동작한다. +- 첫 PR은 모든 CI를 통과했지만 독립 review 없이 병합됐다. +- CODEOWNERS와 PR template이 없다. +- 코어는 locale, ID, lifecycle, schema, relationship, index, CHECK의 repository + state를 결정론적으로 검증한다. +- 중앙 서비스, 사용자 계정, 조직 RBAC, audit export, telemetry는 없다. + +### 객관적 판단 + +현재 상태는 “엔터프라이즈 준비 완료”가 아니라 **강한 로컬 코어를 가진 팀 도입 +전 단계**다. 중앙 서버와 telemetry가 없는 것은 개인 정보·오프라인 사용 측면에서는 +장점이지만, 조직은 누가 어떤 결정을 승인했고 정책 예외가 언제 만료되는지를 별도 +시스템 없이 집계할 수 없다. + +## 3. 유지해야 할 설계 원칙 + +### Deterministic Core, Agentic Edge + +코어가 소유해야 하는 범위는 ID, lifecycle transition, schema, relationship +invariant, index, filename, locale catalog, CHECK 규칙이다. Agent는 언어 추론, 초안, +근거 추출, 관련 ADR 탐색, semantic slug 제안을 도울 수 있다. Agent 출력은 저장소에 +반영되기 전에 항상 코어의 검증 경계를 통과해야 한다. + +### Human-localized, machine-stable + +사람이 읽는 제목·본문·prompt는 팀 언어를 따르되 ID, status, relationship, locale +code, 파일명 계약은 기계적으로 안정적이어야 한다. 코어가 제목을 번역해 semantic +slug를 만들어서는 안 된다. + +### No false governance confidence + +자동화할 수 없는 판단은 실패나 성공으로 꾸미지 않고 `UNVERIFIABLE`로 표시한다. +CI 통과는 구조화된 정책의 증거일 뿐, 결정의 사업적 타당성이나 조직 합의의 증거가 +아니다. + +## 4. 공개 저장소 전환 Gate + +사용자가 계획한 public 전환 직후 다음 repository ruleset을 적용하고 API로 실제 +상태를 재조회한다. + +| 대상 | 권장 통제 | 도입 이유 | +| --- | --- | --- | +| `master` | PR 필수, required CI, conversation resolution, force-push/deletion 차단 | 릴리스 이력과 tag 기준점 보호 | +| `develop` | PR 필수, required CI, conversation resolution, force-push/deletion 차단 | 통합 브랜치의 회귀 차단 | +| `v*` tag | 생성/수정/삭제 권한 제한 | 공개 릴리스 provenance 보호 | +| 예외 | 최소 인원 bypass, 사유 기록, 정기 검토 | 운영 복구 수단과 감사 가능성의 균형 | + +GitHub ruleset은 required pull request, status check, code-owner review, +conversation resolution, signed commit, force-push와 deletion 제한을 제공한다. +실제 조합은 maintainer 수와 자동화 계정 요구에 맞춰 최소 권한으로 정한다. +([GitHub ruleset rules](https://docs.github.com/en/repositories/configuring-branches-and-merges-in-your-repository/managing-rulesets/available-rules-for-rulesets)) + +public 전환과 동시에 추가할 최소 파일은 다음과 같다. + +- PR template: ADR 영향 여부, 관련 ADR ID, CHECK 결과, 문서 변경, 예외 사유를 묻는다. +- `CONTRIBUTING.md`: Git Flow, required checks, ADR lifecycle, release 절차를 설명한다. +- `SECURITY.md`: 취약점의 비공개 신고 경로와 지원 범위를 밝힌다. +- CODEOWNERS: 실제로 독립 승인할 수 있는 qualified maintainer가 2명 이상일 때 + 활성화한다. 한 사람만 있는 상태에서 필수 code-owner review를 켜면 운영을 막거나 + 형식적인 self-review만 만들 수 있다. + +## 5. 팀 단위 운영 모델 + +AWS는 ADR에 단일 owner를 두고 Proposed 상태에서 이해관계자 peer review를 거친 뒤 +Accepted로 전환하며, 승인된 기록은 새 ADR로 supersede할 것을 권고한다. Microsoft도 +status, considered options, tradeoffs, consequences를 포함하는 append-only 기록을 +권장한다. +([AWS ADR process](https://docs.aws.amazon.com/prescriptive-guidance/latest/architectural-decision-records/adr-process.html), +[Microsoft ADR guidance](https://learn.microsoft.com/en-us/azure/well-architected/architect-role/architecture-decision-record)) + +이를 Toolkit에 적용한 권장 역할은 다음과 같다. + +| 역할 | 책임 | +| --- | --- | +| Decision owner | 맥락·선택지·근거·consequence를 완성하고 review를 요청 | +| Required reviewer | 영역 전문성과 영향 범위를 확인하고 독립 승인 | +| Repository maintainer | lifecycle invariant, CI, merge, release 통제 유지 | +| Exception owner | 정책 예외의 사유, 범위, 만료일, 후속 조치 소유 | + +운영 절차는 Proposed → review → Accepted → implementation evidence → periodic review +순으로 둔다. Accepted ADR의 사실 오류를 몰래 고치지 않고, 결정 변경은 새 ADR과 +supersession으로 남긴다. 긴급 예외는 owner와 만료일이 없는 영구 bypass가 되지 않게 +한다. + +## 6. 조직 단위 확장 + +### 6.1 중앙 정책 배포 + +여러 저장소에서 같은 보호 규칙이 반복될 때 organization ruleset을 사용한다. +GitHub 조직 ruleset은 repository targeting, enforcement status, bypass actor를 중앙에서 +관리할 수 있다. GitHub Actions의 reusable workflow는 공통 검증 로직을 한 곳에 두고 +여러 workflow에서 호출할 수 있다. +([GitHub organization rulesets](https://docs.github.com/en/organizations/managing-organization-settings/managing-rulesets-for-repositories-in-your-organization), +[GitHub reusable workflows](https://docs.github.com/en/actions/how-tos/reuse-automations/share-with-your-organization)) + +권장 구성은 다음과 같다. + +- 조직 ruleset: protected branch/tag와 bypass 정책 +- reusable workflow: validate, index-drift, CHECK, schema/config parity +- repository-local config: 팀 locale과 ADR directory처럼 저장소가 소유할 값 +- 중앙 taxonomy: domain, risk, regulatory classification처럼 조직 간 집계할 값 +- exception registry: 규칙, owner, 승인자, scope, 생성일, 만료일, 해소 상태 + +중앙 정책은 저장소의 모든 결정을 대신하지 않는다. 조직 공통 invariant만 중앙화하고, +제품 맥락과 결정 본문은 해당 저장소가 소유해야 변경 속도와 책임 소재가 유지된다. + +### 6.2 RBAC와 감사 + +최소 권한 역할을 maintainer, decision owner, reviewer, auditor로 분리하고, bot에는 +검증에 필요한 read/check 권한만 준다. bypass는 소수 역할에 한정하고 사용 이벤트를 +감사한다. GitHub audit log는 조직의 actor, action, 대상, 시각을 조회·내보낼 수 있어 +ruleset 변경과 bypass 검토의 근거가 된다. +([GitHub audit log](https://docs.github.com/en/organizations/keeping-your-organization-secure/managing-security-settings-for-your-organization/reviewing-the-audit-log-for-your-organization)) + +Toolkit 자체에 계정 시스템을 서둘러 넣기보다 GitHub의 인증·권한·감사를 활용하고, +제품은 ADR 상태와 검증 결과를 기계 판독 가능한 산출물로 제공하는 편이 단순하고 +신뢰 경계가 명확하다. + +## 7. 측정 가능한 도입 지표 + +지표는 감시나 생산성 순위가 아니라 workflow 병목과 정책 부채를 찾는 용도로만 +사용한다. + +| 지표 | 정의 | 해석 시 주의점 | +| --- | --- | --- | +| Decision lead time | ADR 최초 Proposed 시각부터 Accepted/Rejected 시각까지의 중앙값 | 짧다고 결정 품질이 자동으로 높지 않음 | +| Review latency | review 요청부터 첫 qualified review까지의 중앙값 | 시간대·휴가·팀 규모를 함께 봐야 함 | +| Supersession rate | 기간 내 Accepted ADR 중 superseded된 비율 | 높은 값은 학습일 수도, 불안정일 수도 있음 | +| Unresolved violations | mainline에서 열린 `VIOLATED` 결과 수와 지속 일수 | rule coverage 밖의 문제는 포함하지 않음 | +| Exception age | 만료되지 않은 예외의 생성 후 경과 일수와 만료 초과 수 | 예외 개수보다 owner·만료·해소가 중요함 | +| Stale review coverage | review 주기를 넘긴 active ADR / review 대상 active ADR | 모든 ADR에 동일 주기를 강제하지 않음 | + +성숙도 수준 5는 이 지표를 수집한다는 사실만으로 달성되지 않는다. 정의가 버전 관리되고, +예외와 audit evidence를 재현할 수 있으며, 지표가 실제 운영 개선으로 연결돼야 한다. + +## 8. 서비스 개선 우선순위 + +### 다음 구현 후보 + +1. ADR directory 로딩을 공통 iterator로 통합해 validate/index/related/CHECK의 warning + 의미가 갈라지는 위험을 줄인다. +2. public 전환 시 PR template과 CONTRIBUTING/SECURITY 문서를 만들고 ruleset 적용을 + 자동 검증한다. +3. CHECK 결과를 `VERIFIED`, `VIOLATED`, `NOT_APPLICABLE`, `UNVERIFIABLE`의 안정된 + machine-readable contract로 승격한다. +4. 예외에 owner, reason, scope, expiry를 요구하는 작은 schema부터 시작한다. +5. 두 개 이상의 저장소가 같은 운영 문제를 보일 때 reusable workflow와 조직 + taxonomy를 설계한다. + +### 지금 구현하지 않을 것 + +- 중앙 포털, 벡터 검색, GitHub App은 flat-directory와 GitHub Action의 실제 한계가 + 측정되기 전에는 비용이 더 크다. +- 자동 번역과 자동 semantic slug 확정은 결정성을 약화하므로 코어에 넣지 않는다. +- 독립 reviewer가 없는 현재 저장소에서 mandatory CODEOWNERS 승인을 형식적으로 + 켜지 않는다. +- 모든 산문 ADR을 자동 검증하려 하지 않는다. 검증할 수 없는 판단은 사람의 review + 대상으로 남긴다. + +## 9. 단계별 완료 조건 + +| 단계 | 완료 조건 | +| --- | --- | +| v0.2.0 | P0 전체 통과, 최종 PR CI, 승인된 version bump와 release 절차 | +| Public | `master`/`develop`/`v*` ruleset API 검증, PR template, CONTRIBUTING, SECURITY | +| Team | 2명 이상 qualified maintainer, CODEOWNERS 독립 승인, 예외 owner/expiry | +| Enterprise | 조직 ruleset·reusable workflow, audit export, taxonomy, 정의된 adoption metrics | +| Multi-repo | 반복된 탐색 실패와 운영 요구를 근거로 registry/portal 도입 | + +이 순서라면 v0.2.0의 핵심 메시지인 “AI는 ADR 작성을 도울 수 있지만 architecture +governance의 진실은 결정론적 검증이 보장한다”를 유지하면서도, 실제 사용 증거가 +생길 때만 운영 복잡도를 추가할 수 있다. diff --git a/handoff.md b/handoff.md index 2dff313..b3844e3 100644 --- a/handoff.md +++ b/handoff.md @@ -2,10 +2,11 @@ ## Current task (2026-08-30) -Prepare ADR Toolkit `v0.2.0` as a minor feature release: repository-configured -eight-locale ADR generation, portable multilingual filenames, CHECK -false-confidence fixes, dogfooded ADR quality corrections, and separate Korean -readiness/enterprise-adoption reports. +ADR Toolkit `v0.2.0` minor-release implementation and local release evidence +are complete: repository-configured eight-locale ADR generation, portable +multilingual filenames, CHECK false-confidence fixes, dogfooded ADR quality +corrections, user documentation, and separate Korean readiness/enterprise +reports. The work runs in the linked worktree `develop-2`. Its base is the current `origin/develop` history plus the approved design commits: @@ -16,31 +17,30 @@ The work runs in the linked worktree `develop-2`. Its base is the current Baseline verification before implementation: `212 passed` on 2026-08-30. -## Touched files +Latest local release evidence on 2026-08-30: -- `docs/superpowers/specs/2026-08-30-adr-toolkit-localization-and-readiness-design.md` -- `handoff.md` -- `improvements.md` -- `project-roadmap.md` +- `python3 -m pytest -q` → `290 passed`, exit 0 +- `python3 scripts/sync_version.py --check` → exit 0 +- repository validation → 6 ADRs, no errors +- regenerated decision index → byte-identical -Local execution plans live under ignored `docs/superpowers/plans/`. +The detailed evidence and maturity assessment are in +`docs/adr-toolkit-v0.2.0-readiness-report.md`; post-release governance is kept +separate in `docs/enterprise-adoption.md`. ## Next step -Document repository locale defaults, overrides, semantic slugs, and CHECK -confidence in README/quickstart/SKILL. Then write the Korean v0.2.0 readiness -report and the separate enterprise-adoption report. ADR-0006 now supersedes -ADR-0003 bidirectionally; validation, repository ADR quality checks, and -idempotent index generation pass. +Commit the final reports and tracking reconciliation, then open the final PR. +After required CI passes, obtain explicit approval for the v0.2.0 version bump, +release branch, push, and tag. Until then the readiness decision is conditional +GO and tag release is NO-GO. ## Open risks -- Locale precedence must distinguish an omitted CLI flag from explicit English. -- Translation catalogs need exact key parity and human-readable terminology. -- ADR-0003's MVP decision is replaced by v0.2.0 and must be superseded through - the lifecycle command, not silently rewritten. -- Known CHECK rename/path/subprocess defects can produce false confidence until - fixed and regression-tested. +- Catalog structure is deterministic, but terminology still benefits from + native-speaker review in each supported language. +- CHECK deliberately cannot prove prose, business rationale, or organizational + claims; those remain human-review evidence. - GitHub protection is intentionally unavailable in the current private plan; configure and API-verify it after the repository becomes public. - Version bump, release branch, push, and tag are not authorized by this task. diff --git a/improvements.md b/improvements.md index 787b1ca..8f118cb 100644 --- a/improvements.md +++ b/improvements.md @@ -7,59 +7,14 @@ Concrete implementation backlog. Unscheduled product bets belong in ### P0 — v0.2.0 release blocking -- [ ] **Repository locale configuration and precedence.** Add the versioned - root `.adr-toolkit.json` contract and resolve locale as explicit CLI → - approved input draft → repository default → `en`. Agent workflows resolve - explicit request → request language → repository default → `en`. - **Done when:** missing config defaults to English; malformed, unknown-version, - unknown-key, and unsupported-locale configs fail visibly; precedence has - focused tests. -- [ ] **Eight-locale deterministic generation.** Support - `en/ko/ja/zh/fr/es/de/pt-BR` across INIT, interactive CREATE, input CREATE, - templates, and INDEX without translating user prose. - **Done when:** all catalogs exactly match the English key set and every locale - passes INIT → CREATE → VALIDATE → INDEX in an integration matrix. -- [ ] **Portable multilingual filenames.** Preserve Unicode titles/bodies, - accept an optional human-approved semantic ASCII slug, and fall back to - `decision` when no ASCII slug can be derived. - **Done when:** non-ASCII-only, mixed-script, explicit valid slug, and invalid - slug cases have regression coverage on the ASCII filename contract. -- [ ] **ADR locale schema parity.** Add optional `locale` metadata without - breaking existing ADRs. - **Done when:** runtime validation and `adr.schema.json` accept the same eight - values, reject unsupported present values, and keep locale-less ADRs valid. -- [ ] **CHECK must fail closed on incomplete git evidence.** Check both diff - subprocess return codes, surface untracked-listing failures, preserve old and - new rename paths, use `git ls-files --cached --others --exclude-standard` for - existing paths, reject conflicting diff modes, and document regex versus glob - patterns. - **Done when:** each prior false-clean path has a failing-then-passing - regression test and the complete CHECK suite passes. -- [ ] **Dogfooded ADR minimum quality contract.** Record the owner-approved - decision maker, add `locale: en`, represent retrospective reconstruction - truthfully, strengthen affected paths/confirmations, and supersede ADR-0003 - with the approved v0.2.0 localization/config decision. - **Done when:** repository ADR quality tests, `validate`, lifecycle links, and - a regenerated zero-diff index all pass. -- [ ] **Released behavior is documented.** Explain repository defaults, - per-command overrides, eight locales, semantic slug confirmation, and CHECK's - limited verification confidence in README/SKILL/quickstart. - **Done when:** documentation-contract tests pass and every shown CLI command - has been executed successfully in a scratch repository. -- [ ] **Fresh release verification.** Run the full suite, version-drift check, - repository ADR validation/index stability, and diff whitespace checks. - **Done when:** the readiness report contains command evidence for every P0 - gate and recommends GO; version bump/tag still require separate approval. +- [ ] **Complete the remote v0.2.0 release gate.** Open the final PR, require all + CI jobs to pass, obtain approval for the v0.2.0 version bump, merge through a + release branch, and verify the tag points at the intended `master` commit. + **Done when:** the readiness report's conditional GO becomes GO. Creating the + release branch, pushing, version bumping, and tagging require owner approval. ### P1 — strongly recommended -- [ ] **Publish the Korean readiness and enterprise-adoption reports.** Keep - release evidence separate from post-release team/enterprise governance. - **Done when:** facts, external practices, inferences, recommendations, costs, - and measurable signals are visibly separated and cited. -- [ ] **Add a non-Latin quickstart.** Demonstrate repository-default locale, - Unicode title, approved semantic slug, and localized index with real output. - **Done when:** the example commands execute byte-for-byte as documented. - [ ] **Centralize ADR directory loading.** Replace duplicated `SKIP_FILES`/filename/frontmatter loops in related/index/validate/check with a focused shared iterator. @@ -98,6 +53,30 @@ Concrete implementation backlog. Unscheduled product bets belong in ## Done +- [x] Added the versioned `.adr-toolkit.json` locale contract with strict + validation and CLI → draft → repository → English precedence. +- [x] Added deterministic INIT, CREATE, template, prompt, and INDEX rendering + for `en/ko/ja/zh/fr/es/de/pt-BR`, with exact catalog parity and an end-to-end + locale matrix. +- [x] Preserved Unicode ADR content while enforcing portable ASCII filenames, + optional approved semantic slugs, and the deterministic `decision` fallback. +- [x] Kept runtime and JSON Schema locale validation in parity without breaking + locale-less ADRs. +- [x] Made CHECK fail closed on incomplete git evidence and covered subprocess, + rename, Unicode, tracked deletion, invalid ADR visibility, ignored-path, Git + path-semantics, and conflicting-mode defects. +- [x] Made relative ADR directories resolve consistently against `--root` for + INIT, CREATE, INDEX, VALIDATE, and CHECK instead of the caller's CWD. +- [x] Raised the dogfooded ADR set to the repository quality contract and + superseded ADR-0003 with the approved ADR-0006 localization decision. +- [x] Documented repository defaults, per-command overrides, eight locales, + semantic slugs, and CHECK confidence in README, SKILL, and quickstart. +- [x] Added a real Korean quickstart path covering repository-default locale, + Unicode content, semantic slug, fallback filename, and localized index. +- [x] Published separate Korean v0.2.0 readiness and enterprise-adoption + reports with measurable maturity criteria and official external references. +- [x] Passed local release evidence: 290-test full suite, version-drift check, + six-ADR validation, stable generated index, and whitespace checks. - [x] Integrated the MVP feature work through `develop` and released `v0.1.0` from `master`. - [x] Merged PR #1 into `develop`, adding dogfooded ADRs, the root README, diff --git a/skills/adr-toolkit/SKILL.md b/skills/adr-toolkit/SKILL.md index 0691dcc..d167da0 100644 --- a/skills/adr-toolkit/SKILL.md +++ b/skills/adr-toolkit/SKILL.md @@ -253,9 +253,9 @@ since CHECK is read-only and never fixes anything itself. 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. +6. Any `check` `warnings` entries (e.g. `BAD_FRONTMATTER`, `SCHEMA_ERROR`, + `BAD_CONSTRAINTS`) mean one ADR or constraint was skipped, not that CHECK + failed — report them and never describe a warning-bearing result as clean. ## Prohibited diff --git a/skills/adr-toolkit/references/conflict-rules.md b/skills/adr-toolkit/references/conflict-rules.md index b5f2e3a..e7161da 100644 --- a/skills/adr-toolkit/references/conflict-rules.md +++ b/skills/adr-toolkit/references/conflict-rules.md @@ -100,7 +100,10 @@ Present all five options; never default to "revert the code": 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. + broad or too narrow. If the ADR is still Proposed, revise it through the + RECORD approval flow. If it is Accepted, record and approve a replacement + ADR, then supersede the old one; never weaken an accepted record in place + merely to clear CHECK. 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 diff --git a/skills/adr-toolkit/scripts/commands/check.py b/skills/adr-toolkit/scripts/commands/check.py index c1a7234..1047088 100644 --- a/skills/adr-toolkit/scripts/commands/check.py +++ b/skills/adr-toolkit/scripts/commands/check.py @@ -8,6 +8,8 @@ from scripts.core import identifiers from scripts.core.constraints import ConstraintsError, extract_constraints from scripts.core.git_paths import GitPathsError, list_existing_paths +from scripts.core.repository_paths import resolve_from_root +from scripts.core.schema import validate_frontmatter from scripts.rules import conflict SKIP_FILES = {"README.md", "adr-template.md"} @@ -41,9 +43,7 @@ def run(args) -> dict: # `--dir` is relative to `--root`, so `check --root /repo --dir docs/decisions` # means the same directory no matter what the process CWD happens to be. - adr_dir = Path(args.dir) - if not adr_dir.is_absolute(): - adr_dir = root / adr_dir + adr_dir = resolve_from_root(root, args.dir) if not adr_dir.is_dir(): # Silently proceeding here would emit a confident "no conflicts" for # what is really a configuration error. @@ -137,6 +137,14 @@ def _load_adrs(adr_dir: Path) -> tuple: except (fm.FrontmatterError, OSError, UnicodeDecodeError) as exc: warnings.append({"code": "BAD_FRONTMATTER", "file": entry.name, "detail": str(exc)}) continue + schema_errors = validate_frontmatter(data) + if schema_errors: + warnings.extend({ + "code": "SCHEMA_ERROR", + "file": entry.name, + "detail": detail, + } for detail in schema_errors) + continue entries.append({"data": data, "body": body}) return entries, warnings diff --git a/skills/adr-toolkit/scripts/commands/create.py b/skills/adr-toolkit/scripts/commands/create.py index 06d6b87..1da3143 100644 --- a/skills/adr-toolkit/scripts/commands/create.py +++ b/skills/adr-toolkit/scripts/commands/create.py @@ -9,6 +9,7 @@ from scripts.core.config import ConfigError, resolve_locale from scripts.core.locale import DEFAULT_LOCALE from scripts.core.rendering import interactive_prompts, render_minimal +from scripts.core.repository_paths import resolve_from_root from scripts.core.schema import validate_frontmatter REQUIRED_DRAFT_FIELDS = {"title", "status", "body"} @@ -106,7 +107,7 @@ def run(args) -> dict: "errors": [{"code": "CONFIG_ERROR", "detail": str(exc)}], } - adr_dir = Path(args.dir) + adr_dir = resolve_from_root(root, args.dir) missing = REQUIRED_DRAFT_FIELDS - draft.keys() if missing: return { diff --git a/skills/adr-toolkit/scripts/commands/diff.py b/skills/adr-toolkit/scripts/commands/diff.py index f1d8946..09e968e 100644 --- a/skills/adr-toolkit/scripts/commands/diff.py +++ b/skills/adr-toolkit/scripts/commands/diff.py @@ -39,7 +39,10 @@ def run(args) -> dict: mode, diff_options, range_args = "uncommitted", [], [] name_status = subprocess.run( - ["git", "-C", str(root), "diff", "--name-status", *diff_options, "--end-of-options", *range_args], + [ + "git", "-C", str(root), "-c", "core.quotePath=false", "diff", + "--name-status", "-z", *diff_options, "--end-of-options", *range_args, + ], capture_output=True, text=True, ) if name_status.returncode != 0: @@ -47,7 +50,10 @@ def run(args) -> dict: return {"ok": False, "operation": "diff", "errors": [{"code": code, "detail": name_status.stderr.strip()}]} patch = subprocess.run( - ["git", "-C", str(root), "diff", "--unified=0", *diff_options, "--end-of-options", *range_args], + [ + "git", "-C", str(root), "-c", "core.quotePath=false", "diff", + "--unified=0", *diff_options, "--end-of-options", *range_args, + ], capture_output=True, text=True, ) if patch.returncode != 0: @@ -79,7 +85,10 @@ def _untracked_files(root: Path) -> list: # 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"], + [ + "git", "-C", str(root), "ls-files", "--others", + "--exclude-standard", "-z", + ], capture_output=True, text=True, ) if listing.returncode != 0: @@ -88,7 +97,7 @@ def _untracked_files(root: Path) -> list: listing.stderr.strip() or "git ls-files failed", ) entries = [] - for rel_path in listing.stdout.splitlines(): + for rel_path in listing.stdout.split("\0"): if not rel_path.strip(): continue try: @@ -107,6 +116,28 @@ def _untracked_files(root: Path) -> list: def _parse_name_status(output: str) -> list: status_map = {"A": "added", "M": "modified", "D": "deleted"} files = [] + if "\0" in output: + parts = output.split("\0") + if parts and parts[-1] == "": + parts.pop() + index = 0 + while index < len(parts): + status = parts[index] + index += 1 + status_char = status[0] + if status_char in {"R", "C"}: + old_path, new_path = parts[index], parts[index + 1] + index += 2 + files.extend([ + _file_entry(old_path, "deleted"), + _file_entry(new_path, "added"), + ]) + else: + path = parts[index] + index += 1 + files.append(_file_entry(path, status_map.get(status_char, "modified"))) + return files + for line in output.splitlines(): if not line.strip(): continue @@ -115,30 +146,24 @@ def _parse_name_status(output: str) -> list: if status_char == "R" and len(parts) == 3: old_path, new_path = parts[1], parts[2] files.extend([ - { - "path": old_path, - "change_type": "deleted", - "added_lines": [], - "removed_lines": [], - }, - { - "path": new_path, - "change_type": "added", - "added_lines": [], - "removed_lines": [], - }, + _file_entry(old_path, "deleted"), + _file_entry(new_path, "added"), ]) continue path = parts[-1] - files.append({ - "path": path, - "change_type": status_map.get(status_char, "modified"), - "added_lines": [], - "removed_lines": [], - }) + files.append(_file_entry(path, status_map.get(status_char, "modified"))) return files +def _file_entry(path: str, change_type: str) -> dict: + return { + "path": path, + "change_type": change_type, + "added_lines": [], + "removed_lines": [], + } + + def _attach_line_content(files: list, patch_output: str) -> None: by_path = {f["path"]: f for f in files} removed_target = None diff --git a/skills/adr-toolkit/scripts/commands/index.py b/skills/adr-toolkit/scripts/commands/index.py index 60da579..4fd23ef 100644 --- a/skills/adr-toolkit/scripts/commands/index.py +++ b/skills/adr-toolkit/scripts/commands/index.py @@ -5,6 +5,7 @@ from scripts.core import identifiers from scripts.core.config import ConfigError, resolve_locale from scripts.core.locale import load_locale +from scripts.core.repository_paths import resolve_from_root SKIP_FILES = {"README.md", "adr-template.md"} @@ -22,12 +23,13 @@ def run(args) -> dict: - adr_dir = Path(args.dir) + root = Path(getattr(args, "root", ".")) + adr_dir = resolve_from_root(root, args.dir) try: locale = resolve_locale( cli_locale=getattr(args, "locale", None), draft_locale=None, - root=Path(getattr(args, "root", ".")), + root=root, ) except ConfigError as exc: return { diff --git a/skills/adr-toolkit/scripts/commands/init.py b/skills/adr-toolkit/scripts/commands/init.py index 4cb357d..644aa59 100644 --- a/skills/adr-toolkit/scripts/commands/init.py +++ b/skills/adr-toolkit/scripts/commands/init.py @@ -10,11 +10,12 @@ resolve_locale, ) from scripts.core.rendering import render_initial_adr, render_template +from scripts.core.repository_paths import resolve_from_root def run(args) -> dict: - adr_dir = Path(args.dir) root = Path(getattr(args, "root", ".")) + adr_dir = resolve_from_root(root, args.dir) dry_run = getattr(args, "dry_run", False) if adr_dir.exists() and any(adr_dir.iterdir()): diff --git a/skills/adr-toolkit/scripts/commands/validate.py b/skills/adr-toolkit/scripts/commands/validate.py index c26594f..0571579 100644 --- a/skills/adr-toolkit/scripts/commands/validate.py +++ b/skills/adr-toolkit/scripts/commands/validate.py @@ -5,16 +5,18 @@ from scripts.core import identifiers from scripts.core.config import ConfigError, load_repository_config from scripts.core.schema import validate_frontmatter +from scripts.core.repository_paths import resolve_from_root SKIP_FILES = {"README.md", "adr-template.md"} def run(args) -> dict: - adr_dir = Path(args.dir) + root = Path(getattr(args, "root", ".")) + adr_dir = resolve_from_root(root, args.dir) errors = [] try: - load_repository_config(Path(getattr(args, "root", "."))) + load_repository_config(root) except ConfigError as exc: return { "ok": False, diff --git a/skills/adr-toolkit/scripts/core/git_paths.py b/skills/adr-toolkit/scripts/core/git_paths.py index 56d7c51..8afdbda 100644 --- a/skills/adr-toolkit/scripts/core/git_paths.py +++ b/skills/adr-toolkit/scripts/core/git_paths.py @@ -11,11 +11,14 @@ def list_existing_paths(root: Path) -> set: result = subprocess.run( [ "git", "-C", str(root), "ls-files", "--cached", "--others", - "--exclude-standard", + "--exclude-standard", "-z", ], capture_output=True, text=True, ) if result.returncode != 0: raise GitPathsError(result.stderr.strip() or "git ls-files failed") - return {line for line in result.stdout.splitlines() if line} + return { + path for path in result.stdout.split("\0") + if path and (root / path).exists() + } diff --git a/skills/adr-toolkit/scripts/core/locale.py b/skills/adr-toolkit/scripts/core/locale.py index 4c2b8d9..4ea50d0 100644 --- a/skills/adr-toolkit/scripts/core/locale.py +++ b/skills/adr-toolkit/scripts/core/locale.py @@ -1,10 +1,4 @@ -"""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). -""" +"""Load deterministic strings for ADR scaffolds, prompts, and indexes.""" import json from pathlib import Path diff --git a/skills/adr-toolkit/scripts/core/repository_paths.py b/skills/adr-toolkit/scripts/core/repository_paths.py new file mode 100644 index 0000000..8e887f0 --- /dev/null +++ b/skills/adr-toolkit/scripts/core/repository_paths.py @@ -0,0 +1,9 @@ +"""Resolve repository-owned paths independently of the caller's CWD.""" +from pathlib import Path + + +def resolve_from_root(root, path) -> Path: + candidate = Path(path) + if candidate.is_absolute(): + return candidate + return Path(root) / candidate diff --git a/tests/unit/test_check.py b/tests/unit/test_check.py index a2ce279..6047beb 100644 --- a/tests/unit/test_check.py +++ b/tests/unit/test_check.py @@ -173,6 +173,59 @@ def test_unreadable_adr_file_degrades_to_warning_and_keeps_other_findings(tmp_pa assert any(w["code"] == "BAD_FRONTMATTER" and w["file"] == "0010-invalid-utf8.md" for w in result["warnings"]) +def test_schema_invalid_adr_is_visible_as_a_warning_instead_of_silent_skip(tmp_path): + _init_repo(tmp_path) + adr_dir = tmp_path / "docs" / "decisions" + adr_dir.mkdir(parents=True) + invalid = ACCEPTED_ADR_WITH_RULE.replace( + "affected_paths:\n - src/features/", "affected_paths: src/features/" + ) + (adr_dir / "0001-use-a-provider-port.md").write_text(invalid, 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" / "결제.py").write_text( + "import openai\n", encoding="utf-8" + ) + + result = check.run(_args(tmp_path, adr_dir)) + + assert result["ok"] is True + assert result["findings"] == [] + warning = next(w for w in result["warnings"] if w["code"] == "SCHEMA_ERROR") + assert warning["file"] == "0001-use-a-provider-port.md" + assert "affected_paths" in warning["detail"] + + +def test_deleted_tracked_file_does_not_satisfy_file_must_exist(tmp_path): + _init_repo(tmp_path) + adr_dir = tmp_path / "docs" / "decisions" + adr_dir.mkdir(parents=True) + adr = ACCEPTED_ADR_WITH_RULE.replace( + " - id: no-provider-sdk-in-feature\n" + " kind: forbidden_import\n" + " paths: [\"src/features/**\"]\n" + " pattern: [\"openai\"]\n", + " - id: registry-must-exist\n" + " kind: file_must_exist\n" + " paths: [\"src/features/registry.py\"]\n" + " pattern: []\n", + ) + (adr_dir / "0001-use-a-provider-port.md").write_text(adr, encoding="utf-8") + (tmp_path / "src" / "features").mkdir(parents=True) + registry = tmp_path / "src" / "features" / "registry.py" + registry.write_text("REGISTRY = {}\n", encoding="utf-8") + _git(["add", "-A"], tmp_path) + _git(["commit", "-q", "-m", "init"], tmp_path) + registry.unlink() + + result = check.run(_args(tmp_path, adr_dir)) + + violation = next(f for f in result["findings"] if f.get("rule_id") == "registry-must-exist") + assert violation["kind"] == "verified_violation" + assert violation["evidence"] == {"missing_paths": ["src/features/registry.py"]} + + ACCEPTED_ADR_WITH_VERIFICATION = """--- id: ADR-0003 title: Add event replay diff --git a/tests/unit/test_create.py b/tests/unit/test_create.py index 8217a71..0469cb0 100644 --- a/tests/unit/test_create.py +++ b/tests/unit/test_create.py @@ -169,6 +169,21 @@ def test_input_create_uses_repo_locale_and_semantic_slug(tmp_path): assert "locale: ko" in Path(result["created"]).read_text(encoding="utf-8") +def test_relative_adr_directory_is_resolved_against_root(tmp_path, monkeypatch): + caller = tmp_path / "caller" + repo = tmp_path / "repo" + caller.mkdir() + repo.mkdir() + draft_path = _write_draft(repo) + monkeypatch.chdir(caller) + + result = create.run(_args(repo, draft_path, Path("docs/decisions"))) + + assert result["ok"] is True + assert Path(result["created"]).parent == repo / "docs/decisions" + assert not (caller / "docs/decisions").exists() + + def test_cli_locale_overrides_draft_and_repo(tmp_path): result = _run_with_locales(tmp_path, repo="ko", draft="fr", cli="ja") data, _ = fm.parse(Path(result["created"]).read_text(encoding="utf-8")) diff --git a/tests/unit/test_create_interactive.py b/tests/unit/test_create_interactive.py index 7369551..6981a86 100644 --- a/tests/unit/test_create_interactive.py +++ b/tests/unit/test_create_interactive.py @@ -1,8 +1,22 @@ from types import SimpleNamespace +import pytest + from scripts.commands import create +INTERACTIVE_LOCALE_CASES = { + "en": ("Title of the decision?", "Context and Problem Statement"), + "ko": ("결정의 제목은 무엇인가요?", "맥락 및 문제 설명"), + "ja": ("決定のタイトルは何ですか?", "コンテキストと問題"), + "zh": ("决策标题是什么?", "背景与问题陈述"), + "fr": ("Quel est le titre de la décision ?", "Contexte et problème"), + "es": ("¿Cuál es el título de la decisión?", "Contexto y problema"), + "de": ("Wie lautet der Titel der Entscheidung?", "Kontext und Problemstellung"), + "pt-BR": ("Qual é o título da decisão?", "Contexto e problema"), +} + + def test_gather_draft_interactively_builds_valid_minimal_body(): answers = iter([ "Use Kafka for domain events", @@ -84,3 +98,20 @@ def test_gather_draft_interactively_localizes_korean_structure(): assert "## 맥락 및 문제 설명" in draft["body"] assert "장애 격리가 필요하다" in draft["body"] assert draft["locale"] == "ko" + + +@pytest.mark.parametrize("locale", INTERACTIVE_LOCALE_CASES) +def test_interactive_create_prompts_and_renders_every_supported_locale(locale, capsys): + expected_prompt, expected_heading = INTERACTIVE_LOCALE_CASES[locale] + answers = iter([ + "Decision title", "Problem", "Option A, Option B", "Option A", + "Rationale", "Benefit", "Cost", "CI evidence", "Trigger", + ]) + + draft = create.gather_draft_interactively( + locale, input_fn=lambda _prompt: next(answers) + ) + + assert expected_prompt in capsys.readouterr().err + assert f"## {expected_heading}" in draft["body"] + assert draft["locale"] == locale diff --git a/tests/unit/test_diff.py b/tests/unit/test_diff.py index b8bf767..a2b0825 100644 --- a/tests/unit/test_diff.py +++ b/tests/unit/test_diff.py @@ -94,6 +94,21 @@ def test_uncommitted_mode_includes_untracked_new_files(tmp_path): assert "import openai" in entry["added_lines"] +def test_uncommitted_mode_preserves_unicode_untracked_path_and_content(tmp_path): + _init_repo(tmp_path) + unicode_path = tmp_path / "src" / "결제.py" + unicode_path.parent.mkdir() + unicode_path.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"] == "src/결제.py") + assert entry["change_type"] == "added" + assert entry["added_lines"] == ["import openai"] + + def test_since_value_cannot_smuggle_a_git_option_that_writes_a_file(tmp_path): """CHECK is read-only: a `--since` starting with `-` must not reach git as an option.""" _init_repo(tmp_path) diff --git a/tests/unit/test_git_paths.py b/tests/unit/test_git_paths.py index ff7613f..36273dd 100644 --- a/tests/unit/test_git_paths.py +++ b/tests/unit/test_git_paths.py @@ -32,6 +32,15 @@ def test_existing_paths_include_tracked_and_untracked_but_not_ignored(tmp_path): assert "build/generated.py" not in paths +def test_existing_paths_excludes_a_tracked_file_deleted_from_worktree(tmp_path): + _init_repo(tmp_path) + (tmp_path / "existing.py").unlink() + + paths = git_paths.list_existing_paths(tmp_path) + + assert "existing.py" not in paths + + def test_git_path_listing_failure_includes_stderr(tmp_path, monkeypatch): def fail(command, **kwargs): return subprocess.CompletedProcess(command, 128, "", "not a repository") diff --git a/tests/unit/test_index.py b/tests/unit/test_index.py index 9cb1626..4e1348f 100644 --- a/tests/unit/test_index.py +++ b/tests/unit/test_index.py @@ -120,6 +120,23 @@ def test_index_uses_repository_default_when_locale_omitted(tmp_path): assert (adr_dir / "README.md").read_text(encoding="utf-8").startswith("# 결정 기록") +def test_relative_adr_directory_is_resolved_against_root(tmp_path, monkeypatch): + caller = tmp_path / "caller" + repo = tmp_path / "repo" + adr_dir = repo / "docs/decisions" + caller.mkdir() + adr_dir.mkdir(parents=True) + monkeypatch.chdir(caller) + + result = index.run(SimpleNamespace( + dir="docs/decisions", root=str(repo), locale="en", + )) + + assert result["ok"] is True + assert (adr_dir / "README.md").is_file() + assert not (caller / "docs/decisions/README.md").exists() + + def test_index_unknown_status_falls_back_to_capitalized_label(tmp_path): _write_adr( tmp_path, "0001-use-kafka.md", diff --git a/tests/unit/test_init.py b/tests/unit/test_init.py index 01343ea..5f40e34 100644 --- a/tests/unit/test_init.py +++ b/tests/unit/test_init.py @@ -103,3 +103,20 @@ def test_init_never_overwrites_existing_config(tmp_path): adr = (tmp_path / "docs/decisions/0001-record-architecture-decisions.md").read_text(encoding="utf-8") assert "locale: ja" in adr assert "## コンテキストと問題" in adr + + +def test_relative_adr_directory_is_resolved_against_root(tmp_path, monkeypatch): + caller = tmp_path / "caller" + repo = tmp_path / "repo" + caller.mkdir() + repo.mkdir() + monkeypatch.chdir(caller) + + result = init.run(SimpleNamespace( + dir="docs/decisions", root=str(repo), locale="ko", dry_run=False, + )) + + assert result["ok"] is True + assert (repo / ".adr-toolkit.json").is_file() + assert (repo / "docs/decisions/0001-record-architecture-decisions.md").is_file() + assert not (caller / "docs/decisions").exists() diff --git a/tests/unit/test_validate.py b/tests/unit/test_validate.py index 3c6d188..8478aef 100644 --- a/tests/unit/test_validate.py +++ b/tests/unit/test_validate.py @@ -83,3 +83,20 @@ def test_invalid_repository_config_fails_before_reporting_adr_success(tmp_path): assert result["ok"] is False assert result["checked"] == 0 assert result["errors"][0]["code"] == "CONFIG_ERROR" + + +def test_relative_adr_directory_is_resolved_against_root(tmp_path, monkeypatch): + caller = tmp_path / "caller" + repo = tmp_path / "repo" + adr_dir = repo / "docs/decisions" + caller.mkdir() + adr_dir.mkdir(parents=True) + (adr_dir / "0001-record-architecture-decisions.md").write_text( + VALID_ADR, encoding="utf-8" + ) + monkeypatch.chdir(caller) + + result = validate.run(SimpleNamespace(dir="docs/decisions", root=str(repo))) + + assert result["ok"] is True + assert result["checked"] == 1 From b9e0ec3718fda60099da22def3949150adab6af9 Mon Sep 17 00:00:00 2001 From: shcommit Date: Sun, 30 Aug 2026 21:35:11 +0900 Subject: [PATCH 24/76] fix: harden sync_version.py against silent drift and unicode loss A manifest that keeps its file but loses its "version" key was silently skipped by --check, so CI would report no drift even after the tracked key vanished. A manifest path outside the repo root crashed require_known_paths() with an unrelated ValueError instead of its intended SystemExit. JSON writes escaped non-ASCII manifest content instead of preserving it, which regresses this project's own multilingual metadata. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01CeDZgjQakpnGGtsWoC7ijF --- improvements.md | 9 ++++---- scripts/sync_version.py | 25 ++++++++++++++++++++-- tests/unit/test_sync_version.py | 37 +++++++++++++++++++++++++++++++++ 3 files changed, 65 insertions(+), 6 deletions(-) diff --git a/improvements.md b/improvements.md index 8f118cb..0bf02b8 100644 --- a/improvements.md +++ b/improvements.md @@ -24,10 +24,6 @@ Concrete implementation backlog. Unscheduled product bets belong in adapter-local symlink remains relevant to native `.codex-plugin` discovery. **Done when:** every claim maps to a verified command or is explicitly marked structural-only. -- [ ] **Harden version synchronization.** Fail when a tracked manifest loses - its version key, preserve intended `SystemExit` outside the repo root, and use - `ensure_ascii=False` for JSON writes. - **Done when:** focused regression tests cover all three paths. - [ ] **Decide manifest-description ownership.** Description text has drifted across manifests while only versions are synchronized. **Done when:** one canonical source and a check are implemented, or an ADR @@ -53,6 +49,11 @@ Concrete implementation backlog. Unscheduled product bets belong in ## Done +- [x] Hardened `sync_version.py`: a tracked manifest that keeps its file but + loses its `version` key now fails `--check` instead of reporting no drift; + a manifest path outside the repo root raises the intended `SystemExit` + instead of an unrelated `ValueError`; JSON writes use `ensure_ascii=False` + so non-ASCII manifest content survives a sync unescaped. - [x] Added the versioned `.adr-toolkit.json` locale contract with strict validation and CLI → draft → repository → English precedence. - [x] Added deterministic INIT, CREATE, template, prompt, and INDEX rendering diff --git a/scripts/sync_version.py b/scripts/sync_version.py index 7edb072..b18d0e0 100755 --- a/scripts/sync_version.py +++ b/scripts/sync_version.py @@ -69,7 +69,9 @@ def sync(version_file: Path, manifest_specs: list, check_only: bool) -> list: changed.append(path) if not check_only: target[key_path[-1]] = version - path.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8") + path.write_text( + json.dumps(data, indent=2, ensure_ascii=False) + "\n", encoding="utf-8" + ) return changed @@ -101,9 +103,28 @@ def require_known_paths() -> None: if not SKILL_MD_PATH.is_file(): missing.append(SKILL_MD_PATH) if missing: - names = ", ".join(str(p.relative_to(REPO_ROOT)) for p in missing) + names = ", ".join(_display_path(p) for p in missing) raise SystemExit(f"missing tracked file(s): {names}") + keyless = [] + for path, key_path in MANIFEST_SPECS: + target = json.loads(path.read_text(encoding="utf-8")) + for key in key_path: + if not isinstance(target, dict) or key not in target: + keyless.append(path) + break + target = target[key] + if keyless: + names = ", ".join(_display_path(p) for p in keyless) + raise SystemExit(f"tracked manifest(s) lost their version key: {names}") + + +def _display_path(path: Path) -> str: + try: + return str(path.relative_to(REPO_ROOT)) + except ValueError: + return str(path) + def main(argv=None) -> int: parser = argparse.ArgumentParser() diff --git a/tests/unit/test_sync_version.py b/tests/unit/test_sync_version.py index 57d3f15..59333de 100644 --- a/tests/unit/test_sync_version.py +++ b/tests/unit/test_sync_version.py @@ -215,3 +215,40 @@ def test_sync_still_tolerates_a_missing_fixture_path_for_testability(tmp_path): version_file.write_text("1.2.3\n", encoding="utf-8") assert sync(version_file, [(tmp_path / "absent.json", ["version"])], check_only=True) == [] + + +def test_require_known_paths_raises_when_a_tracked_manifest_loses_its_version_key( + tmp_path, monkeypatch +): + manifest = tmp_path / "plugin.json" + manifest.write_text(json.dumps({"name": "x"}), encoding="utf-8") + monkeypatch.setattr(_sync_version, "MANIFEST_SPECS", [(manifest, ["version"])]) + + with pytest.raises(SystemExit) as excinfo: + require_known_paths() + + assert "plugin.json" in str(excinfo.value) + + +def test_require_known_paths_raises_a_clean_error_for_a_manifest_outside_repo_root( + tmp_path, monkeypatch +): + outside = tmp_path / "definitely-missing.json" + monkeypatch.setattr(_sync_version, "MANIFEST_SPECS", [(outside, ["version"])]) + + with pytest.raises(SystemExit) as excinfo: + require_known_paths() + + assert "definitely-missing.json" in str(excinfo.value) + + +def test_sync_preserves_non_ascii_content_in_manifest_writes(tmp_path): + version_file = tmp_path / "VERSION" + version_file.write_text("1.2.3\n", encoding="utf-8") + manifest = _write( + tmp_path, "plugin.json", {"name": "x", "description": "한국어 설명", "version": "0.0.0"} + ) + + sync(version_file, [(manifest, ["version"])], check_only=False) + + assert "한국어 설명" in manifest.read_text(encoding="utf-8") From baa09875acfcc76377b9bec390a5a5ecb1038504 Mon Sep 17 00:00:00 2001 From: shcommit Date: Sun, 30 Aug 2026 21:35:54 +0900 Subject: [PATCH 25/76] ci: run the manifest version-drift check once, not per matrix leg sync_version.py --check reads repository content only; its result cannot vary by OS or Python interpreter. Running it identically on all 5 pytest matrix legs wasted CI minutes for no additional coverage. Split it into its own single-run job instead. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01CeDZgjQakpnGGtsWoC7ijF --- .github/workflows/test.yml | 8 ++++++++ improvements.md | 7 +++---- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 9e4bd9d..0562650 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -32,5 +32,13 @@ jobs: run: pip install pytest - name: Run tests run: python -m pytest tests/unit tests/integration -v + + version-drift: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" - name: Check manifest versions are in sync run: python scripts/sync_version.py --check diff --git a/improvements.md b/improvements.md index 0bf02b8..4785f92 100644 --- a/improvements.md +++ b/improvements.md @@ -28,10 +28,6 @@ Concrete implementation backlog. Unscheduled product bets belong in across manifests while only versions are synchronized. **Done when:** one canonical source and a check are implemented, or an ADR explicitly accepts independent descriptions. -- [ ] **Avoid redundant version-drift CI work.** Run the repository-invariant - sync check once instead of on every OS/Python matrix leg. - **Done when:** test coverage remains cross-platform and one required job owns - the drift check. - [ ] **Resolve `--json` semantics.** Every subcommand parses `--json` but always emits JSON. **Done when:** either a human-readable mode exists with compatibility tests or @@ -49,6 +45,9 @@ Concrete implementation backlog. Unscheduled product bets belong in ## Done +- [x] Moved the manifest version-drift check out of the 5-leg OS/Python test + matrix into its own single `version-drift` job, since the check is a + repository-content invariant, not a per-platform/per-interpreter one. - [x] Hardened `sync_version.py`: a tracked manifest that keeps its file but loses its `version` key now fails `--check` instead of reporting no drift; a manifest path outside the repo root raises the intended `SystemExit` From 2f2a8defd742b796b7a1a0f46eca6724c08a9cf2 Mon Sep 17 00:00:00 2001 From: shcommit Date: Sun, 30 Aug 2026 21:38:17 +0900 Subject: [PATCH 26/76] refactor: centralize ADR directory loading into a shared iterator related, index, validate, and check each redefined the same SKIP_FILES set and re-walked the ADR directory with their own glob/skip/parse loop. Extract core.adr_directory.iter_adr_files as the single source of truth for "which files in this directory are candidate ADRs," while each command keeps its own bad-filename and frontmatter-error handling, since those differ deliberately (validate reports a bad filename as an error; the others skip it silently). Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01CeDZgjQakpnGGtsWoC7ijF --- improvements.md | 8 ++-- skills/adr-toolkit/scripts/commands/check.py | 7 ++- skills/adr-toolkit/scripts/commands/index.py | 9 +--- .../adr-toolkit/scripts/commands/related.py | 8 ++-- .../adr-toolkit/scripts/commands/validate.py | 13 ++---- .../adr-toolkit/scripts/core/adr_directory.py | 20 ++++++++ tests/unit/test_adr_directory.py | 46 +++++++++++++++++++ 7 files changed, 83 insertions(+), 28 deletions(-) create mode 100644 skills/adr-toolkit/scripts/core/adr_directory.py create mode 100644 tests/unit/test_adr_directory.py diff --git a/improvements.md b/improvements.md index 4785f92..2bab2a8 100644 --- a/improvements.md +++ b/improvements.md @@ -15,10 +15,6 @@ Concrete implementation backlog. Unscheduled product bets belong in ### P1 — strongly recommended -- [ ] **Centralize ADR directory loading.** Replace duplicated - `SKIP_FILES`/filename/frontmatter loops in related/index/validate/check with a - focused shared iterator. - **Done when:** all four commands retain their warning semantics and tests pass. - [ ] **Correct Codex adapter claims.** Scope the README to the root `.claude-plugin/marketplace.json` flow actually exercised and explain why the adapter-local symlink remains relevant to native `.codex-plugin` discovery. @@ -45,6 +41,10 @@ Concrete implementation backlog. Unscheduled product bets belong in ## Done +- [x] Centralized ADR directory loading: `related`, `index`, `validate`, and + `check` now share `core.adr_directory.iter_adr_files` instead of each + redefining `SKIP_FILES` and re-walking the directory; each command keeps its + own bad-filename/frontmatter handling (silent skip vs. reportable error). - [x] Moved the manifest version-drift check out of the 5-leg OS/Python test matrix into its own single `version-drift` job, since the check is a repository-content invariant, not a per-platform/per-interpreter one. diff --git a/skills/adr-toolkit/scripts/commands/check.py b/skills/adr-toolkit/scripts/commands/check.py index 1047088..3d8ce69 100644 --- a/skills/adr-toolkit/scripts/commands/check.py +++ b/skills/adr-toolkit/scripts/commands/check.py @@ -5,14 +5,13 @@ from scripts.commands import diff as diff_command from scripts.core import frontmatter as fm -from scripts.core import identifiers +from scripts.core.adr_directory import iter_adr_files from scripts.core.constraints import ConstraintsError, extract_constraints from scripts.core.git_paths import GitPathsError, list_existing_paths from scripts.core.repository_paths import resolve_from_root from scripts.core.schema import validate_frontmatter from scripts.rules import conflict -SKIP_FILES = {"README.md", "adr-template.md"} RESOLUTIONS = ["fix_code", "supersede_adr", "adjust_scope", "register_exception", "false_positive"] # Every ADR this toolkit writes uses `## Confirmation` (templates/madr-*.md and @@ -129,8 +128,8 @@ def run(args) -> dict: 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: + for entry, parsed in iter_adr_files(adr_dir): + if parsed is None: continue try: data, body = fm.parse(entry.read_text(encoding="utf-8")) diff --git a/skills/adr-toolkit/scripts/commands/index.py b/skills/adr-toolkit/scripts/commands/index.py index 4fd23ef..d22d438 100644 --- a/skills/adr-toolkit/scripts/commands/index.py +++ b/skills/adr-toolkit/scripts/commands/index.py @@ -2,13 +2,11 @@ from pathlib import Path from scripts.core import frontmatter as fm -from scripts.core import identifiers +from scripts.core.adr_directory import iter_adr_files from scripts.core.config import ConfigError, resolve_locale from scripts.core.locale import load_locale from scripts.core.repository_paths import resolve_from_root -SKIP_FILES = {"README.md", "adr-template.md"} - # Last-resort English headers, used when even the English locale file is # unavailable — e.g. a copy-based install (permitted by # adapters/generic/README.md) that omitted scripts/i18n/. Localization is @@ -41,10 +39,7 @@ def run(args) -> dict: entries = [] warnings = [] - for entry in sorted(adr_dir.glob("*.md")): - if entry.name in SKIP_FILES: - continue - parsed = identifiers.parse_filename(entry.name) + for entry, parsed in iter_adr_files(adr_dir): if parsed is None: continue try: diff --git a/skills/adr-toolkit/scripts/commands/related.py b/skills/adr-toolkit/scripts/commands/related.py index 56b12c4..876dbec 100644 --- a/skills/adr-toolkit/scripts/commands/related.py +++ b/skills/adr-toolkit/scripts/commands/related.py @@ -2,9 +2,7 @@ from pathlib import Path from scripts.core import frontmatter as fm -from scripts.core import identifiers - -SKIP_FILES = {"README.md", "adr-template.md"} +from scripts.core.adr_directory import iter_adr_files def _as_list(value) -> list: @@ -19,8 +17,8 @@ def run(args) -> dict: matches = [] warnings = [] - for entry in sorted(adr_dir.glob("*.md")): - if entry.name in SKIP_FILES or identifiers.parse_filename(entry.name) is None: + for entry, parsed in iter_adr_files(adr_dir): + if parsed is None: continue try: diff --git a/skills/adr-toolkit/scripts/commands/validate.py b/skills/adr-toolkit/scripts/commands/validate.py index 0571579..ffd120f 100644 --- a/skills/adr-toolkit/scripts/commands/validate.py +++ b/skills/adr-toolkit/scripts/commands/validate.py @@ -2,13 +2,11 @@ from pathlib import Path from scripts.core import frontmatter as fm -from scripts.core import identifiers +from scripts.core.adr_directory import iter_adr_files from scripts.core.config import ConfigError, load_repository_config from scripts.core.schema import validate_frontmatter from scripts.core.repository_paths import resolve_from_root -SKIP_FILES = {"README.md", "adr-template.md"} - def run(args) -> dict: root = Path(getattr(args, "root", ".")) @@ -25,13 +23,12 @@ def run(args) -> dict: "errors": [{"code": "CONFIG_ERROR", "detail": str(exc)}], } - adr_files = sorted(p for p in adr_dir.glob("*.md") if p.name not in SKIP_FILES) - parsed_entries = [] seen_ids: dict = {} + checked = 0 - for path in adr_files: - parsed_filename = identifiers.parse_filename(path.name) + for path, parsed_filename in iter_adr_files(adr_dir): + checked += 1 if parsed_filename is None: errors.append({"code": "BAD_FILENAME", "file": path.name}) continue @@ -70,4 +67,4 @@ def run(args) -> dict: 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} + return {"ok": not errors, "operation": "validate", "checked": checked, "errors": errors} diff --git a/skills/adr-toolkit/scripts/core/adr_directory.py b/skills/adr-toolkit/scripts/core/adr_directory.py new file mode 100644 index 0000000..9e55d51 --- /dev/null +++ b/skills/adr-toolkit/scripts/core/adr_directory.py @@ -0,0 +1,20 @@ +"""Shared ADR-candidate file iteration for related/index/validate/check.""" +from pathlib import Path + +from scripts.core import identifiers + +SKIP_FILES = {"README.md", "adr-template.md"} + + +def iter_adr_files(adr_dir: Path): + """Yield (path, parsed) for every candidate ADR file in `adr_dir`, sorted. + + Skips known non-ADR files (README.md, adr-template.md). `parsed` is the + `identifiers.parse_filename()` result — None when the filename doesn't + match the ADR-NNNN-slug pattern. Callers decide whether that is a silent + skip or a reportable error. + """ + for path in sorted(adr_dir.glob("*.md")): + if path.name in SKIP_FILES: + continue + yield path, identifiers.parse_filename(path.name) diff --git a/tests/unit/test_adr_directory.py b/tests/unit/test_adr_directory.py new file mode 100644 index 0000000..0c997ad --- /dev/null +++ b/tests/unit/test_adr_directory.py @@ -0,0 +1,46 @@ +from scripts.core.adr_directory import SKIP_FILES, iter_adr_files + + +def _touch(path, text="content"): + path.write_text(text, encoding="utf-8") + + +def test_iter_adr_files_skips_readme_and_template(tmp_path): + _touch(tmp_path / "README.md") + _touch(tmp_path / "adr-template.md") + _touch(tmp_path / "0001-decision.md") + + results = list(iter_adr_files(tmp_path)) + + assert [path.name for path, _ in results] == ["0001-decision.md"] + + +def test_iter_adr_files_yields_parsed_id_and_slug(tmp_path): + _touch(tmp_path / "0002-use-a-cache.md") + + ((path, parsed),) = list(iter_adr_files(tmp_path)) + + assert path.name == "0002-use-a-cache.md" + assert parsed == (2, "use-a-cache") + + +def test_iter_adr_files_yields_none_for_unparseable_filename(tmp_path): + _touch(tmp_path / "not-an-adr.md") + + ((path, parsed),) = list(iter_adr_files(tmp_path)) + + assert path.name == "not-an-adr.md" + assert parsed is None + + +def test_iter_adr_files_sorted_by_filename(tmp_path): + _touch(tmp_path / "0002-second.md") + _touch(tmp_path / "0001-first.md") + + results = list(iter_adr_files(tmp_path)) + + assert [path.name for path, _ in results] == ["0001-first.md", "0002-second.md"] + + +def test_skip_files_constant_matches_known_non_adr_filenames(): + assert SKIP_FILES == {"README.md", "adr-template.md"} From 061d44e40fb93d26e6d00d545bc712e3c0b7efa4 Mon Sep 17 00:00:00 2001 From: shcommit Date: Sun, 30 Aug 2026 21:41:03 +0900 Subject: [PATCH 27/76] docs: correct the Codex adapter's unverified symlink instruction Re-verified end to end against Codex CLI 0.151.0 with no adapters/codex/skills/ symlink present at all: codex plugin marketplace add + codex plugin add + preflight --json all still succeed, because registering the repo root resolves .claude-plugin/plugin.json as the plugin manifest, whose sibling skills/ is the real package. Install step 1 (create that symlink) was therefore dead documentation from an earlier design plan. Removed it from the install flow and added a section explaining that .codex-plugin/plugin.json and its symlink are structural-only, kept for consistency with the other adapters rather than exercised by the verified path. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01CeDZgjQakpnGGtsWoC7ijF --- adapters/codex/README.md | 66 ++++++++++++++++++++++++++++++---------- improvements.md | 12 +++++--- 2 files changed, 57 insertions(+), 21 deletions(-) diff --git a/adapters/codex/README.md b/adapters/codex/README.md index 27c99c6..2abb0ca 100644 --- a/adapters/codex/README.md +++ b/adapters/codex/README.md @@ -16,15 +16,7 @@ from a bare plugin directory. This repository is already a valid marketplace root, because its `.claude-plugin/marketplace.json` is a manifest format Codex CLI reads, so no extra manifest is needed. -1. Symlink this repo's `skills/adr-toolkit/` package under this adapter's - directory, as a sibling of `.codex-plugin/` (design spec §17.2): - - ```bash - mkdir -p adapters/codex/skills - ln -s "$(pwd)/skills/adr-toolkit" adapters/codex/skills/adr-toolkit - ``` - -2. Register this repository as a Codex marketplace and install the plugin: +1. Register this repository as a Codex marketplace and install the plugin: ```bash codex plugin marketplace add "$(pwd)" @@ -35,21 +27,57 @@ Codex CLI reads, so no extra manifest is needed. `.claude-plugin/marketplace.json`; `codex plugin marketplace add` prints it on success, and `codex plugin marketplace list` shows it later. -3. Confirm Codex lists `adr-toolkit` as installed and enabled: +2. Confirm Codex lists `adr-toolkit` as installed and enabled: ```bash codex plugin list ``` -4. Optionally run +3. Optionally 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. `.gitignore` -already ignores `adapters/codex/skills/adr-toolkit` so a `git add -A` after -step 1 cannot commit it by accident. +No symlink step is required. Registering the repository **root** as the +marketplace means Codex reads `.claude-plugin/plugin.json` as the plugin +manifest, and Agent Plugins' sibling-`skills/`-directory discovery finds the +real `skills/adr-toolkit/` package next to it directly — verified below by +installing with no `adapters/codex/skills/` symlink present at all. + +## Why `.codex-plugin/plugin.json` and an adapter-local symlink exist here too + +Design spec §17.2 originally planned every harness adapter as a +**self-contained** plugin directory: `adapters/codex/.codex-plugin/plugin.json` +as that harness's own manifest, with `adapters/codex/skills/adr-toolkit` +symlinked in as its required sibling `skills/` directory +(`ln -s ../../../skills/adr-toolkit adapters/codex/skills/adr-toolkit`), so +each adapter would work if pointed at directly. + +That plan does not hold for Codex specifically: `codex plugin marketplace add` +rejects a bare single-plugin directory — + +``` +$ codex plugin marketplace add "$(pwd)/adapters/codex" +Error: invalid marketplace file `/adapters/codex`: marketplace root does not contain a supported manifest +``` + +— because only the repository root carries a marketplace manifest +(`.claude-plugin/marketplace.json`). So `adapters/codex/.codex-plugin/plugin.json` +and its sibling symlink are never read by the verified install path above; +they exist only for structural consistency with the Gemini and Antigravity +adapters' self-contained layout, and in case a future Codex version accepts a +bare plugin directory as a marketplace root. If you want that manifest to +resolve correctly on its own regardless, create the symlink manually: + +```bash +mkdir -p adapters/codex/skills +ln -s "$(pwd)/skills/adr-toolkit" adapters/codex/skills/adr-toolkit +``` + +This is not committed to the repo — committing a real symlink breaks on +Windows checkouts that don't have `core.symlinks` enabled, which this +project's CI can't assume. `.gitignore` already ignores +`adapters/codex/skills/adr-toolkit` so a `git add -A` cannot commit it by +accident. ## Verification status @@ -86,6 +114,12 @@ out of it against a scratch git repository, producing correct `"ok": true` output for `preflight`, `init --dir docs/decisions`, and `validate --dir docs/decisions`. +Re-verified with **no `adapters/codex/skills/` symlink present at all**: the +same three commands above still succeed and `preflight --json` still returns +`"ok": true` from the installed snapshot, confirming the symlink step this +README previously listed as install step 1 played no part in the working +flow. + Two details worth knowing: - **A marketplace root, not a plugin directory, is what you register.** diff --git a/improvements.md b/improvements.md index 2bab2a8..55c1d7d 100644 --- a/improvements.md +++ b/improvements.md @@ -15,11 +15,6 @@ Concrete implementation backlog. Unscheduled product bets belong in ### P1 — strongly recommended -- [ ] **Correct Codex adapter claims.** Scope the README to the root - `.claude-plugin/marketplace.json` flow actually exercised and explain why the - adapter-local symlink remains relevant to native `.codex-plugin` discovery. - **Done when:** every claim maps to a verified command or is explicitly marked - structural-only. - [ ] **Decide manifest-description ownership.** Description text has drifted across manifests while only versions are synchronized. **Done when:** one canonical source and a check are implemented, or an ADR @@ -41,6 +36,13 @@ Concrete implementation backlog. Unscheduled product bets belong in ## Done +- [x] Corrected `adapters/codex/README.md`: re-verified against Codex CLI + 0.151.0 that the documented install flow works with zero + `adapters/codex/skills/` symlink present (marketplace root resolves to the + repo root's own `.claude-plugin/plugin.json`, whose sibling `skills/` is + the real package), removed the misleading symlink as install step 1, and + added an explicit section marking `.codex-plugin/plugin.json` and its + optional symlink as structural-only, not exercised by the verified path. - [x] Centralized ADR directory loading: `related`, `index`, `validate`, and `check` now share `core.adr_directory.iter_adr_files` instead of each redefining `SKIP_FILES` and re-walking the directory; each command keeps its From 5d37bef69c3e848bbe4011acfb7310a0dec12ffd Mon Sep 17 00:00:00 2001 From: shcommit Date: Sun, 30 Aug 2026 21:43:43 +0900 Subject: [PATCH 28/76] fix: make SKILL.md the canonical source for manifest descriptions Manifest descriptions had drifted the same way versions once did: .claude-plugin/plugin.json was missing "and existing decisions" that SKILL.md and the three adapter manifests all carried. Extend sync_version.py to treat SKILL.md's frontmatter description as canonical and sync it into every duplicating manifest, with --check enforcing it the same way version drift already is. Fixes the one real drift this uncovered. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01CeDZgjQakpnGGtsWoC7ijF --- .claude-plugin/plugin.json | 2 +- .github/workflows/test.yml | 2 +- improvements.md | 12 +++++--- scripts/sync_version.py | 51 +++++++++++++++++++++++++------- tests/unit/test_sync_version.py | 52 +++++++++++++++++++++++++++++++++ 5 files changed, 103 insertions(+), 16 deletions(-) diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index e645c6f..aa12830 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,5 +1,5 @@ { "name": "adr-toolkit", "version": "0.1.0", - "description": "Initialize, record, and check Architecture Decision Records by inspecting the repository before asking questions." + "description": "Initialize, record, and check Architecture Decision Records by inspecting the repository and existing decisions before asking questions." } diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 0562650..f20f9ec 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -40,5 +40,5 @@ jobs: - uses: actions/setup-python@v5 with: python-version: "3.12" - - name: Check manifest versions are in sync + - name: Check manifest versions and descriptions are in sync run: python scripts/sync_version.py --check diff --git a/improvements.md b/improvements.md index 55c1d7d..140b94e 100644 --- a/improvements.md +++ b/improvements.md @@ -15,10 +15,6 @@ Concrete implementation backlog. Unscheduled product bets belong in ### P1 — strongly recommended -- [ ] **Decide manifest-description ownership.** Description text has drifted - across manifests while only versions are synchronized. - **Done when:** one canonical source and a check are implemented, or an ADR - explicitly accepts independent descriptions. - [ ] **Resolve `--json` semantics.** Every subcommand parses `--json` but always emits JSON. **Done when:** either a human-readable mode exists with compatibility tests or @@ -36,6 +32,14 @@ Concrete implementation backlog. Unscheduled product bets belong in ## Done +- [x] Decided manifest-description ownership: `SKILL.md`'s frontmatter + `description:` is now the single canonical source, synced by + `sync_version.py` into `.claude-plugin/plugin.json`, + `adapters/codex/.codex-plugin/plugin.json`, + `adapters/gemini-cli/gemini-extension.json`, and + `adapters/antigravity/plugin.json`, with `--check` enforcing it in CI. Fixed + the one real drift this uncovered (`.claude-plugin/plugin.json` was missing + "and existing decisions"). - [x] Corrected `adapters/codex/README.md`: re-verified against Codex CLI 0.151.0 that the documented install flow works with zero `adapters/codex/skills/` symlink present (marketplace root resolves to the diff --git a/scripts/sync_version.py b/scripts/sync_version.py index b18d0e0..0affe5d 100755 --- a/scripts/sync_version.py +++ b/scripts/sync_version.py @@ -1,5 +1,6 @@ #!/usr/bin/env python3 -"""Sync skills/adr-toolkit/VERSION into every manifest that duplicates it. +"""Sync skills/adr-toolkit/VERSION and SKILL.md's description into every +manifest that duplicates them. Repo tooling — not part of the distributable skills/adr-toolkit/ package. """ @@ -21,8 +22,19 @@ (REPO_ROOT / "adapters" / "gemini-cli" / "gemini-extension.json", ["version"]), ] +# SKILL.md's frontmatter `description:` is the single canonical source; every +# manifest below duplicates it for its own harness's format and is synced +# from it the same way MANIFEST_SPECS entries are synced from VERSION. +DESCRIPTION_MANIFEST_SPECS = [ + (REPO_ROOT / ".claude-plugin" / "plugin.json", ["description"]), + (REPO_ROOT / "adapters" / "codex" / ".codex-plugin" / "plugin.json", ["description"]), + (REPO_ROOT / "adapters" / "gemini-cli" / "gemini-extension.json", ["description"]), + (REPO_ROOT / "adapters" / "antigravity" / "plugin.json", ["description"]), +] + VERSION_LINE_RE = re.compile(r"^version:\s*\S+$", re.MULTILINE) VERSION_FORMAT_RE = re.compile(r"\d+\.\d+\.\d+(-[\w.]+)?") +DESCRIPTION_LINE_RE = re.compile(r"^description:[ \t]*(.+)$", re.MULTILINE) def read_version(version_file: Path) -> str: @@ -49,8 +61,24 @@ def replace_version_line(text: str, version: str) -> str: return VERSION_LINE_RE.sub(lambda _: f"version: {version}", text, count=1) +def read_description(skill_md_path: Path) -> str: + """Read the canonical `description:` value from SKILL.md's frontmatter.""" + text = skill_md_path.read_text(encoding="utf-8") + match = DESCRIPTION_LINE_RE.search(text) + if match is None: + raise SystemExit(f"no description: line found in {skill_md_path}") + return match.group(1).strip() + + def sync(version_file: Path, manifest_specs: list, check_only: bool) -> list: - version = read_version(version_file) + return _sync_value(read_version(version_file), manifest_specs, check_only) + + +def sync_descriptions(skill_md_path: Path, manifest_specs: list, check_only: bool) -> list: + return _sync_value(read_description(skill_md_path), manifest_specs, check_only) + + +def _sync_value(value: str, manifest_specs: list, check_only: bool) -> list: changed = [] for path, key_path in manifest_specs: if not path.is_file(): @@ -64,11 +92,11 @@ def sync(version_file: Path, manifest_specs: list, check_only: bool) -> list: target = target[key] if target is None or key_path[-1] not in target: continue - if target[key_path[-1]] == version: + if target[key_path[-1]] == value: continue changed.append(path) if not check_only: - target[key_path[-1]] = version + target[key_path[-1]] = value path.write_text( json.dumps(data, indent=2, ensure_ascii=False) + "\n", encoding="utf-8" ) @@ -97,26 +125,28 @@ def require_known_paths() -> None: MANIFEST_SPECS/SKILL_MD_PATH — a renamed or deleted manifest there must not silently drop out of the drift check and leave CI green forever. """ - missing = [p for p, _ in MANIFEST_SPECS if not p.is_file()] + all_specs = MANIFEST_SPECS + DESCRIPTION_MANIFEST_SPECS + tracked_paths = {p for p, _ in all_specs} + missing = [p for p in tracked_paths if not p.is_file()] if not VERSION_FILE.is_file(): missing.append(VERSION_FILE) if not SKILL_MD_PATH.is_file(): missing.append(SKILL_MD_PATH) if missing: - names = ", ".join(_display_path(p) for p in missing) + names = ", ".join(_display_path(p) for p in sorted(missing, key=str)) raise SystemExit(f"missing tracked file(s): {names}") keyless = [] - for path, key_path in MANIFEST_SPECS: + for path, key_path in all_specs: target = json.loads(path.read_text(encoding="utf-8")) for key in key_path: if not isinstance(target, dict) or key not in target: - keyless.append(path) + keyless.append((path, key_path[-1])) break target = target[key] if keyless: - names = ", ".join(_display_path(p) for p in keyless) - raise SystemExit(f"tracked manifest(s) lost their version key: {names}") + names = ", ".join(f"{_display_path(p)} ({key})" for p, key in keyless) + raise SystemExit(f"tracked manifest(s) lost a tracked key: {names}") def _display_path(path: Path) -> str: @@ -133,6 +163,7 @@ def main(argv=None) -> int: require_known_paths() changed = sync(VERSION_FILE, MANIFEST_SPECS, check_only=args.check) + changed += sync_descriptions(SKILL_MD_PATH, DESCRIPTION_MANIFEST_SPECS, check_only=args.check) if sync_skill_md(VERSION_FILE, SKILL_MD_PATH, check_only=args.check): changed.append(SKILL_MD_PATH) diff --git a/tests/unit/test_sync_version.py b/tests/unit/test_sync_version.py index 59333de..d037d08 100644 --- a/tests/unit/test_sync_version.py +++ b/tests/unit/test_sync_version.py @@ -252,3 +252,55 @@ def test_sync_preserves_non_ascii_content_in_manifest_writes(tmp_path): sync(version_file, [(manifest, ["version"])], check_only=False) assert "한국어 설명" in manifest.read_text(encoding="utf-8") + + +# --- Manifest description ownership ----------------------------------------- + +read_description = _sync_version.read_description +sync_descriptions = _sync_version.sync_descriptions +REAL_DESCRIPTION_MANIFEST_SPECS = _sync_version.DESCRIPTION_MANIFEST_SPECS + + +def test_read_description_extracts_frontmatter_description(tmp_path): + skill_md = tmp_path / "SKILL.md" + skill_md.write_text( + "---\nname: x\ndescription: Do the thing.\nversion: 0.0.0\n---\n\nBody\n", + encoding="utf-8", + ) + + assert read_description(skill_md) == "Do the thing." + + +def test_sync_descriptions_writes_matching_description_into_every_manifest(tmp_path): + skill_md = tmp_path / "SKILL.md" + skill_md.write_text( + "---\nname: x\ndescription: Canonical description.\n---\n", encoding="utf-8" + ) + plugin = _write(tmp_path, "plugin.json", {"name": "x", "description": "stale"}) + + changed = sync_descriptions(skill_md, [(plugin, ["description"])], check_only=False) + + assert json.loads(plugin.read_text())["description"] == "Canonical description." + assert changed == [plugin] + + +def test_sync_descriptions_check_only_reports_drift_without_writing(tmp_path): + skill_md = tmp_path / "SKILL.md" + skill_md.write_text( + "---\nname: x\ndescription: Canonical description.\n---\n", encoding="utf-8" + ) + plugin = _write(tmp_path, "plugin.json", {"name": "x", "description": "stale"}) + + changed = sync_descriptions(skill_md, [(plugin, ["description"])], check_only=True) + + assert changed == [plugin] + assert json.loads(plugin.read_text())["description"] == "stale" + + +def test_real_manifests_have_a_description_matching_skill_md(): + canonical = read_description(REAL_SKILL_MD_PATH) + for path, key_path in REAL_DESCRIPTION_MANIFEST_SPECS: + target = json.loads(path.read_text(encoding="utf-8")) + for key in key_path: + target = target[key] + assert target == canonical, f"{path} description has drifted from SKILL.md" From 63c4c8592ee117d5a4c8af10cc32931790c3d4d4 Mon Sep 17 00:00:00 2001 From: shcommit Date: Sun, 30 Aug 2026 21:47:13 +0900 Subject: [PATCH 29/76] docs: formalize --json as a no-op, always-JSON CLI contract Every subcommand parsed --json but main() always printed JSON regardless, matching its own "JSON-only-stdout contract" comment and every existing test/doc caller. Rather than inventing a human-readable mode or dropping the flag as a breaking change, made that existing behavior the deliberate, documented contract: centralized the 14 duplicated --json argument definitions into one helper with clear --help text, added a regression test proving output is JSON with or without the flag, and added --json to the three doc examples that had omitted it. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01CeDZgjQakpnGGtsWoC7ijF --- README.md | 2 +- improvements.md | 12 ++++++---- skills/adr-toolkit/SKILL.md | 4 ++-- skills/adr-toolkit/scripts/adr.py | 38 ++++++++++++++++++++----------- tests/integration/test_cli.py | 13 +++++++++++ 5 files changed, 49 insertions(+), 20 deletions(-) diff --git a/README.md b/README.md index 0ba4e03..0e7ee79 100644 --- a/README.md +++ b/README.md @@ -47,7 +47,7 @@ of these adapters at it: directly in a terminal — no agent required: ```bash -python skills/adr-toolkit/scripts/adr.py create --interactive --dir docs/decisions +python skills/adr-toolkit/scripts/adr.py create --interactive --dir docs/decisions --json ``` ## Language and repository config diff --git a/improvements.md b/improvements.md index 140b94e..8525e8a 100644 --- a/improvements.md +++ b/improvements.md @@ -15,10 +15,6 @@ Concrete implementation backlog. Unscheduled product bets belong in ### P1 — strongly recommended -- [ ] **Resolve `--json` semantics.** Every subcommand parses `--json` but always - emits JSON. - **Done when:** either a human-readable mode exists with compatibility tests or - a future breaking-release decision removes the inert flag. - [ ] **Resolve Codex metadata compatibility.** Codex `quick_validate.py` rejects `user-invocable` and `version`, while the cross-harness contract and repository tests require them. @@ -32,6 +28,14 @@ Concrete implementation backlog. Unscheduled product bets belong in ## Done +- [x] Resolved `--json` semantics: adopted "always JSON to stdout" as the + deliberate, tested contract (matching the code's own existing + "JSON-only-stdout contract" comment and every real caller), rather than + inventing a human-readable mode or removing the flag in a breaking change. + `--json` is now documented via `--help` as a no-op kept for backward + compatibility; added a regression test proving output is JSON with or + without it; added it to the three doc examples that omitted it, for + consistency. - [x] Decided manifest-description ownership: `SKILL.md`'s frontmatter `description:` is now the single canonical source, synced by `sync_version.py` into `.claude-plugin/plugin.json`, diff --git a/skills/adr-toolkit/SKILL.md b/skills/adr-toolkit/SKILL.md index d167da0..7d79c9b 100644 --- a/skills/adr-toolkit/SKILL.md +++ b/skills/adr-toolkit/SKILL.md @@ -57,7 +57,7 @@ time later) to recover past decisions. 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 skills/adr-toolkit/scripts/adr.py init --locale --dir docs/decisions` +3. **MUTATE** — run `python skills/adr-toolkit/scripts/adr.py init --locale --dir docs/decisions --json` to create `.adr-toolkit.json`, the directory, template, and ADR-0001. 4. **VALIDATE** — run `python skills/adr-toolkit/scripts/adr.py validate --dir docs/decisions --json` and `python skills/adr-toolkit/scripts/adr.py index --dir docs/decisions --json`. @@ -115,7 +115,7 @@ entirely, or re-run later to mine more of the history incrementally. (`title`, `status`, `body` — body includes the three-part structure above — plus any of `date`/`decision_makers`/`related`/ `affected_paths`/`tags`/`retrospective`) and run - `python skills/adr-toolkit/scripts/adr.py create --input --dir docs/decisions`. + `python skills/adr-toolkit/scripts/adr.py create --input --dir docs/decisions --json`. 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. diff --git a/skills/adr-toolkit/scripts/adr.py b/skills/adr-toolkit/scripts/adr.py index 55bef86..e1f500d 100755 --- a/skills/adr-toolkit/scripts/adr.py +++ b/skills/adr-toolkit/scripts/adr.py @@ -24,6 +24,18 @@ from scripts.core.locale import SUPPORTED_LOCALES +def _add_json_flag(parser: argparse.ArgumentParser) -> None: + # Every command always prints JSON to stdout (see main()) — there is no + # human-readable mode. This flag exists only for documentation clarity + # and backward compatibility with commands that already pass it; + # omitting it changes nothing. + parser.add_argument( + "--json", + action="store_true", + help="No effect: output is always JSON. Kept for backward compatibility.", + ) + + def _add_diff_mode_arguments(parser: argparse.ArgumentParser) -> None: modes = parser.add_mutually_exclusive_group() modes.add_argument("--staged", action="store_true") @@ -36,11 +48,11 @@ def build_parser() -> argparse.ArgumentParser: sub = parser.add_subparsers(dest="operation", required=True) p_preflight = sub.add_parser("preflight") - p_preflight.add_argument("--json", action="store_true") + _add_json_flag(p_preflight) p_preflight.add_argument("--root", default=".") p_discover = sub.add_parser("discover") - p_discover.add_argument("--json", action="store_true") + _add_json_flag(p_discover) p_discover.add_argument("--root", default=".") p_init = sub.add_parser("init") @@ -48,7 +60,7 @@ def build_parser() -> argparse.ArgumentParser: p_init.add_argument("--root", default=".") p_init.add_argument("--locale", choices=SUPPORTED_LOCALES) p_init.add_argument("--dry-run", dest="dry_run", action="store_true") - p_init.add_argument("--json", action="store_true") + _add_json_flag(p_init) p_create = sub.add_parser("create") p_create.add_argument("--input") @@ -58,7 +70,7 @@ def build_parser() -> argparse.ArgumentParser: p_create.add_argument("--locale", choices=SUPPORTED_LOCALES) p_create.add_argument("--slug") p_create.add_argument("--dry-run", dest="dry_run", action="store_true") - p_create.add_argument("--json", action="store_true") + _add_json_flag(p_create) p_index = sub.add_parser("index") p_index.add_argument("--dir", default="docs/decisions") @@ -66,36 +78,36 @@ def build_parser() -> argparse.ArgumentParser: # Constrained so a typo from the agent's own language detection fails # visibly instead of silently producing English output. p_index.add_argument("--locale", choices=SUPPORTED_LOCALES) - p_index.add_argument("--json", action="store_true") + _add_json_flag(p_index) 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_json_flag(p_related) p_significance = sub.add_parser("significance") p_significance.add_argument("--input", required=True) - p_significance.add_argument("--json", action="store_true") + _add_json_flag(p_significance) p_validate = sub.add_parser("validate") p_validate.add_argument("--dir", default="docs/decisions") p_validate.add_argument("--root", default=".") - p_validate.add_argument("--json", action="store_true") + _add_json_flag(p_validate) 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") + _add_json_flag(p_status) 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") + _add_json_flag(p_deprecate) p_deprecate.set_defaults(to="deprecated") p_supersede = sub.add_parser("supersede") @@ -103,18 +115,18 @@ def build_parser() -> argparse.ArgumentParser: 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_json_flag(p_supersede) p_diff = sub.add_parser("diff") _add_diff_mode_arguments(p_diff) p_diff.add_argument("--root", default=".") - p_diff.add_argument("--json", action="store_true") + _add_json_flag(p_diff) p_check = sub.add_parser("check") _add_diff_mode_arguments(p_check) p_check.add_argument("--root", default=".") p_check.add_argument("--dir", default="docs/decisions") - p_check.add_argument("--json", action="store_true") + _add_json_flag(p_check) return parser diff --git a/tests/integration/test_cli.py b/tests/integration/test_cli.py index d6c5380..13e9def 100644 --- a/tests/integration/test_cli.py +++ b/tests/integration/test_cli.py @@ -36,6 +36,19 @@ def test_unknown_subcommand_exits_nonzero(tmp_path): assert result.returncode != 0 +def test_preflight_output_is_json_even_without_the_json_flag(tmp_path): + """The CLI has one output contract — JSON to stdout — regardless of --json. + + --json is accepted for documentation clarity and backward compatibility, + but every command has always printed JSON whether or not it's passed. + """ + result = _run(["preflight"], cwd=tmp_path) + assert result.returncode == 0 + payload = json.loads(result.stdout) + assert payload["operation"] == "preflight" + assert payload["ok"] is True + + def test_significance_via_cli(tmp_path): scores_path = tmp_path / "scores.json" scores_path.write_text( From 14627a4e93f5ea19fc7a0fd8649bc8c992e2b32b Mon Sep 17 00:00:00 2001 From: shcommit Date: Sun, 30 Aug 2026 21:52:34 +0900 Subject: [PATCH 30/76] docs: mark completed and re-scope enterprise-adoption's next candidates Item 1 (shared ADR directory iterator) shipped in 2f2a8de. Annotated the remaining four with their actual current state: item 2 blocked on the repo going public, item 5 blocked on a second repository existing, and items 3-4 genuinely not started, with the concrete gap named (no VERIFIED-equivalent finding kind exists yet; register_exception is a label with no backing schema). Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01CeDZgjQakpnGGtsWoC7ijF --- docs/enterprise-adoption.md | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/docs/enterprise-adoption.md b/docs/enterprise-adoption.md index 3de6607..275f2b5 100644 --- a/docs/enterprise-adoption.md +++ b/docs/enterprise-adoption.md @@ -164,15 +164,21 @@ Toolkit 자체에 계정 시스템을 서둘러 넣기보다 GitHub의 인증· ### 다음 구현 후보 -1. ADR directory 로딩을 공통 iterator로 통합해 validate/index/related/CHECK의 warning - 의미가 갈라지는 위험을 줄인다. +1. ~~ADR directory 로딩을 공통 iterator로 통합해~~ **완료 (2026-08-30).** + `core.adr_directory.iter_adr_files`로 validate/index/related/CHECK를 통합했고 + 각 명령의 warning 의미는 그대로 유지했다. 2. public 전환 시 PR template과 CONTRIBUTING/SECURITY 문서를 만들고 ruleset 적용을 - 자동 검증한다. + 자동 검증한다. **저장소가 아직 private이라 시작 조건이 충족되지 않았다.** 3. CHECK 결과를 `VERIFIED`, `VIOLATED`, `NOT_APPLICABLE`, `UNVERIFIABLE`의 안정된 - machine-readable contract로 승격한다. + machine-readable contract로 승격한다. **아직 시작 전.** 현재 CHECK는 이 네 + 값을 쓰지 않고 `kind: verified_violation / review_required / related / + no_applicable_constraint`를 쓴다; `VERIFIED`에 해당하는 "규칙을 평가했고 + 준수를 확인했다"는 값 자체가 아직 없다 — 새 API 설계가 필요하다. 4. 예외에 owner, reason, scope, expiry를 요구하는 작은 schema부터 시작한다. + **아직 시작 전.** 현재 `register_exception`은 CHECK finding의 해결 옵션 + 문자열로만 존재하고, 실제 저장·검증하는 schema나 명령은 없다. 5. 두 개 이상의 저장소가 같은 운영 문제를 보일 때 reusable workflow와 조직 - taxonomy를 설계한다. + taxonomy를 설계한다. **시작 조건(2개 이상 저장소) 미충족.** ### 지금 구현하지 않을 것 From 2c73af629c27f8d7b8064b47f3db84be5d50617c Mon Sep 17 00:00:00 2001 From: shcommit Date: Sun, 30 Aug 2026 21:59:39 +0900 Subject: [PATCH 31/76] feat: promote CHECK's confidence classification to a stable field MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit README, SKILL.md, and conflict-rules.md already documented a mapping from each finding's kind to a VERIFIED/VIOLATED/UNVERIFIABLE confidence value, but it existed only in prose — the agent had to re-derive it from docs on every run. Compute it once in check.py and attach it to every finding directly as `confidence`, using the exact mapping those docs already specified (related->VERIFIED, verified_violation->VIOLATED, review_required and no_applicable_constraint->UNVERIFIABLE). Updated the docs to describe the field instead of a derivation the agent had to perform, and synced quickstart's example output to real command output. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01CeDZgjQakpnGGtsWoC7ijF --- README.md | 16 ++++++++------- docs/enterprise-adoption.md | 13 +++++++----- examples/quickstart.md | 5 +++-- improvements.md | 5 +++++ skills/adr-toolkit/SKILL.md | 13 ++++++------ .../adr-toolkit/references/conflict-rules.md | 20 ++++++++++--------- skills/adr-toolkit/scripts/commands/check.py | 13 ++++++++++++ tests/unit/test_check.py | 6 ++++++ 8 files changed, 62 insertions(+), 29 deletions(-) diff --git a/README.md b/README.md index 0e7ee79..9c1b5e3 100644 --- a/README.md +++ b/README.md @@ -109,14 +109,16 @@ fresh repository safely falls back to `0001-decision.md`. ## CHECK confidence CHECK does not certify the entire architecture. It evaluates only explicit, -structurally provable rules in the selected diff: +structurally provable rules in the selected diff. Every finding carries a +`confidence` field with one of these four values directly — no need to +re-derive it from `kind`: -| Meaning | Existing JSON result | -|---|---| -| `VERIFIED` | `related`, only for applicable explicit rules that were evaluated | -| `VIOLATED` | `verified_violation` | -| `UNVERIFIABLE` | `review_required` or `no_applicable_constraint` | -| `NOT_APPLICABLE` | No known ADR/rule applies to the selected change | +| `confidence` | `kind` it comes from | Meaning | +|---|---|---| +| `VERIFIED` | `related` | Applicable explicit rules were evaluated and none fired. | +| `VIOLATED` | `verified_violation` | Structural evidence confirms a violation. | +| `UNVERIFIABLE` | `review_required` or `no_applicable_constraint` | No usable rule vocabulary could prove or disprove this. | +| `NOT_APPLICABLE` | (no finding at all) | No known ADR/rule applies to the selected change — an empty `findings` list, not proof of compliance. | Warnings mean some evidence could not be evaluated and must be reported. A clean result never proves prose rationale, runtime behavior, or every diff --git a/docs/enterprise-adoption.md b/docs/enterprise-adoption.md index 275f2b5..e41fed4 100644 --- a/docs/enterprise-adoption.md +++ b/docs/enterprise-adoption.md @@ -169,11 +169,14 @@ Toolkit 자체에 계정 시스템을 서둘러 넣기보다 GitHub의 인증· 각 명령의 warning 의미는 그대로 유지했다. 2. public 전환 시 PR template과 CONTRIBUTING/SECURITY 문서를 만들고 ruleset 적용을 자동 검증한다. **저장소가 아직 private이라 시작 조건이 충족되지 않았다.** -3. CHECK 결과를 `VERIFIED`, `VIOLATED`, `NOT_APPLICABLE`, `UNVERIFIABLE`의 안정된 - machine-readable contract로 승격한다. **아직 시작 전.** 현재 CHECK는 이 네 - 값을 쓰지 않고 `kind: verified_violation / review_required / related / - no_applicable_constraint`를 쓴다; `VERIFIED`에 해당하는 "규칙을 평가했고 - 준수를 확인했다"는 값 자체가 아직 없다 — 새 API 설계가 필요하다. +3. ~~CHECK 결과를 `VERIFIED`, `VIOLATED`, `NOT_APPLICABLE`, `UNVERIFIABLE`의 + 안정된 machine-readable contract로 승격한다.~~ **완료 (2026-08-30).** 모든 + finding이 기존 `kind` 값과 별개로 `confidence` 필드를 직접 갖는다 + (`related→VERIFIED`, `verified_violation→VIOLATED`, + `review_required`/`no_applicable_constraint→UNVERIFIABLE`; 관련 ADR/finding + 자체가 없으면 `NOT_APPLICABLE`, 이는 빈 `findings` 배열로 표현되고 별도 + finding을 만들지 않는다). README, SKILL.md, `conflict-rules.md`, quickstart + 예제 출력을 모두 갱신했다. 4. 예외에 owner, reason, scope, expiry를 요구하는 작은 schema부터 시작한다. **아직 시작 전.** 현재 `register_exception`은 CHECK finding의 해결 옵션 문자열로만 존재하고, 실제 저장·검증하는 schema나 명령은 없다. diff --git a/examples/quickstart.md b/examples/quickstart.md index 2a51d61..c08d545 100644 --- a/examples/quickstart.md +++ b/examples/quickstart.md @@ -171,7 +171,8 @@ python skills/adr-toolkit/scripts/adr.py check --uncommitted --dir docs/decision "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"] + "resolutions": ["fix_code", "supersede_adr", "adjust_scope", "register_exception", "false_positive"], + "confidence": "VIOLATED" } ], "warnings": [] @@ -197,7 +198,7 @@ python skills/adr-toolkit/scripts/adr.py check --uncommitted --dir docs/decision "ok": true, "operation": "check", "diff": {"mode": "uncommitted", "ref": null, "files_changed": 1}, - "findings": [{"adr_id": "ADR-0002", "kind": "related"}], + "findings": [{"adr_id": "ADR-0002", "kind": "related", "confidence": "VERIFIED"}], "warnings": [] } ``` diff --git a/improvements.md b/improvements.md index 8525e8a..19fb74e 100644 --- a/improvements.md +++ b/improvements.md @@ -28,6 +28,11 @@ Concrete implementation backlog. Unscheduled product bets belong in ## Done +- [x] Promoted CHECK's confidence classification to a stable field: every + finding now carries `confidence` (`VERIFIED`/`VIOLATED`/`UNVERIFIABLE`) + computed directly from its `kind`, instead of leaving that mapping for the + agent to re-derive from prose docs each time. Updated README, SKILL.md, + `conflict-rules.md`, and quickstart's example output to match. - [x] Resolved `--json` semantics: adopted "always JSON to stdout" as the deliberate, tested contract (matching the code's own existing "JSON-only-stdout contract" comment and every real caller), rather than diff --git a/skills/adr-toolkit/SKILL.md b/skills/adr-toolkit/SKILL.md index 7d79c9b..920ab6b 100644 --- a/skills/adr-toolkit/SKILL.md +++ b/skills/adr-toolkit/SKILL.md @@ -237,12 +237,13 @@ since CHECK is read-only and never fixes anything itself. 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. - Interpret their confidence as `VERIFIED`, `VIOLATED`, or `UNVERIFIABLE` - using `references/conflict-rules.md`. No related finding is - `NOT_APPLICABLE`, not proof that the repository is compliant. +3. **CLASSIFY** — each finding already carries both its `kind` (`related`, + `review_required`, `verified_violation`, `no_applicable_constraint`) and a + `confidence` field (`VERIFIED`, `VIOLATED`, or `UNVERIFIABLE`) computed by + the deterministic core — do not re-judge or recompute it, report it as + returned. See `references/conflict-rules.md` for what each value means. No + related finding at all is `NOT_APPLICABLE`, not proof that the repository + is compliant. 4. **REPORT** — group findings by classification. For every Verified violation finding, present all five resolutions from `references/conflict-rules.md` (`fix_code`, `supersede_adr`, diff --git a/skills/adr-toolkit/references/conflict-rules.md b/skills/adr-toolkit/references/conflict-rules.md index e7161da..efcf21f 100644 --- a/skills/adr-toolkit/references/conflict-rules.md +++ b/skills/adr-toolkit/references/conflict-rules.md @@ -78,15 +78,17 @@ enforce — that's a legitimate state, not an error. ## Evidence confidence -The JSON finding names remain stable, while their governance meaning is: - -| Existing result | Confidence meaning | -|---|---| -| `related` | **VERIFIED** only for the applicable explicit structural rules in this diff. | -| `verified_violation` | **VIOLATED** by direct structural evidence. | -| `review_required` | **UNVERIFIABLE** without human review. | -| `no_applicable_constraint` | **UNVERIFIABLE** for the related ADR because no usable rule proved the policy. | -| No related ADR or finding | **NOT_APPLICABLE** to the known scoped rules, not a global pass. | +Every finding carries this mapping directly as a `confidence` field — reading +it off `kind` yourself is only needed to explain *why* a confidence value +applies, not to compute it: + +| Existing `kind` | `confidence` field | Meaning | +|---|---|---| +| `related` | **VERIFIED** | Only for the applicable explicit structural rules in this diff. | +| `verified_violation` | **VIOLATED** | By direct structural evidence. | +| `review_required` | **UNVERIFIABLE** | Without human review. | +| `no_applicable_constraint` | **UNVERIFIABLE** | For the related ADR, because no usable rule proved the policy. | +| No related ADR or finding | **NOT_APPLICABLE** | To the known scoped rules — an empty `findings` list, not a global pass. | CHECK does not certify the entire architecture. A successful command means the requested evidence was collected and evaluated; it does not prove prose, diff --git a/skills/adr-toolkit/scripts/commands/check.py b/skills/adr-toolkit/scripts/commands/check.py index 3d8ce69..9df71e8 100644 --- a/skills/adr-toolkit/scripts/commands/check.py +++ b/skills/adr-toolkit/scripts/commands/check.py @@ -14,6 +14,16 @@ RESOLUTIONS = ["fix_code", "supersede_adr", "adjust_scope", "register_exception", "false_positive"] +# The stable, machine-readable confidence contract documented in README.md's +# "CHECK confidence" section and references/conflict-rules.md — promotes the +# ad-hoc `kind` values below into the four values agents are told to report. +CONFIDENCE_BY_KIND = { + "related": "VERIFIED", + "verified_violation": "VIOLATED", + "review_required": "UNVERIFIABLE", + "no_applicable_constraint": "UNVERIFIABLE", +} + # Every ADR this toolkit writes uses `## Confirmation` (templates/madr-*.md and # commands/create.py); `## Verification` is the design spec's name for the same # section. Accept both, plus any trailing words in the heading. @@ -117,6 +127,9 @@ def run(args) -> dict: "resolutions": RESOLUTIONS, }) + for finding in findings: + finding["confidence"] = CONFIDENCE_BY_KIND[finding["kind"]] + return { "ok": True, "operation": "check", diff --git a/tests/unit/test_check.py b/tests/unit/test_check.py index 6047beb..54ecb99 100644 --- a/tests/unit/test_check.py +++ b/tests/unit/test_check.py @@ -86,6 +86,7 @@ def test_verified_violation_when_rule_fires(tmp_path): assert violation["adr_id"] == "ADR-0001" assert violation["rule_id"] == "no-provider-sdk-in-feature" assert violation["resolutions"] == RESOLUTIONS + assert violation["confidence"] == "VIOLATED" def test_related_when_rule_present_but_does_not_fire(tmp_path): @@ -103,6 +104,7 @@ def test_related_when_rule_present_but_does_not_fire(tmp_path): finding = next(f for f in result["findings"] if f["adr_id"] == "ADR-0001") assert finding["kind"] == "related" + assert finding["confidence"] == "VERIFIED" def test_no_applicable_constraint_when_adr_has_no_constraints_block(tmp_path): @@ -119,6 +121,7 @@ def test_no_applicable_constraint_when_adr_has_no_constraints_block(tmp_path): finding = next(f for f in result["findings"] if f["adr_id"] == "ADR-0002") assert finding["kind"] == "no_applicable_constraint" + assert finding["confidence"] == "UNVERIFIABLE" def test_adr_with_no_affected_path_overlap_produces_no_finding(tmp_path): @@ -283,6 +286,7 @@ def test_review_required_when_verification_reference_is_removed(tmp_path): 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"]["unrealized_paths"] + assert finding["confidence"] == "UNVERIFIABLE" def test_review_required_when_confirmed_path_is_renamed(tmp_path): @@ -327,6 +331,7 @@ def test_superseded_reference_fires_on_affected_path_overlap(tmp_path): assert finding["kind"] == "verified_violation" assert finding["evidence"]["superseded_by"] == "ADR-0005" assert finding["resolutions"] == RESOLUTIONS + assert finding["confidence"] == "VIOLATED" # --- Regression tests for the final whole-branch review of Plan 3 (CHECK) --- @@ -391,6 +396,7 @@ def test_review_required_fires_on_a_confirmation_section_written_by_create(tmp_p 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"]["unrealized_paths"] + assert finding["confidence"] == "UNVERIFIABLE" def test_review_required_fires_when_a_confirmation_path_was_never_created(tmp_path): From 6e68b5d70511b6e3d7d96de0f591c0dae8a3a0ec Mon Sep 17 00:00:00 2001 From: shcommit Date: Sun, 30 Aug 2026 22:05:29 +0900 Subject: [PATCH 32/76] feat: add a deterministic exception schema and wire it into CHECK MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit register_exception existed only as a resolution label with no backing schema or storage. Add scripts/core/exceptions.py (validation, expiry, scope matching against globs), a new `adr.py exception` command that validates a draft (adr_id, rule_id, owner, reason, scope, expiry) and writes docs/decisions/exceptions/NNNN.json as EXC-NNNN, and schemas/exception.schema.json documenting the same shape. CHECK loads active (non-expired, schema-valid) exceptions and annotates a matching verified_violation finding's `exception` field — it never suppresses or downgrades the finding, keeping kind/confidence exactly what the structural evidence says, consistent with this project's "no false governance confidence" principle. A malformed exception file degrades to a BAD_EXCEPTION warning rather than aborting the run, matching the existing ADR/constraints pattern. Updated README, SKILL.md, and conflict-rules.md to document the command and field; verified the documented command end to end in a scratch repository. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01CeDZgjQakpnGGtsWoC7ijF --- README.md | 20 ++++ docs/enterprise-adoption.md | 10 +- improvements.md | 8 ++ skills/adr-toolkit/SKILL.md | 13 ++- .../adr-toolkit/references/conflict-rules.md | 17 ++- .../adr-toolkit/schemas/exception.schema.json | 16 +++ skills/adr-toolkit/scripts/adr.py | 9 ++ skills/adr-toolkit/scripts/commands/check.py | 57 ++++++++++ .../adr-toolkit/scripts/commands/exception.py | 98 +++++++++++++++++ skills/adr-toolkit/scripts/core/exceptions.py | 81 ++++++++++++++ tests/unit/test_check.py | 100 ++++++++++++++++++ tests/unit/test_exception_command.py | 87 +++++++++++++++ tests/unit/test_exceptions.py | 94 ++++++++++++++++ 13 files changed, 603 insertions(+), 7 deletions(-) create mode 100644 skills/adr-toolkit/schemas/exception.schema.json create mode 100644 skills/adr-toolkit/scripts/commands/exception.py create mode 100644 skills/adr-toolkit/scripts/core/exceptions.py create mode 100644 tests/unit/test_exception_command.py create mode 100644 tests/unit/test_exceptions.py diff --git a/README.md b/README.md index 9c1b5e3..52fe6f4 100644 --- a/README.md +++ b/README.md @@ -124,6 +124,26 @@ Warnings mean some evidence could not be evaluated and must be reported. A clean result never proves prose rationale, runtime behavior, or every architecture invariant. +### Exceptions + +`register_exception` — one of a Verified violation's five resolutions — is a +real, deterministic record, not just a label: + +```bash +python skills/adr-toolkit/scripts/adr.py exception --input exception.json \ + --dir docs/decisions --json +``` + +`exception.json` requires `adr_id`, `rule_id`, `owner`, `reason`, a `scope` +(path patterns the exception is narrowed to), and an `expiry` +(`YYYY-MM-DD`). The command assigns the next `EXC-NNNN` id and writes +`docs/decisions/exceptions/NNNN.json`. CHECK annotates a matching, non-expired +exception onto its finding's `exception` field — the finding's `kind` and +`confidence` stay exactly what the structural evidence says +(`verified_violation`/`VIOLATED`); an exception is visible, reviewable +evidence, never a silent pass. Once `expiry` passes, CHECK stops applying it +automatically. + ## Scope - MADR 4.x format, no new standard. diff --git a/docs/enterprise-adoption.md b/docs/enterprise-adoption.md index e41fed4..0086ac5 100644 --- a/docs/enterprise-adoption.md +++ b/docs/enterprise-adoption.md @@ -177,9 +177,13 @@ Toolkit 자체에 계정 시스템을 서둘러 넣기보다 GitHub의 인증· 자체가 없으면 `NOT_APPLICABLE`, 이는 빈 `findings` 배열로 표현되고 별도 finding을 만들지 않는다). README, SKILL.md, `conflict-rules.md`, quickstart 예제 출력을 모두 갱신했다. -4. 예외에 owner, reason, scope, expiry를 요구하는 작은 schema부터 시작한다. - **아직 시작 전.** 현재 `register_exception`은 CHECK finding의 해결 옵션 - 문자열로만 존재하고, 실제 저장·검증하는 schema나 명령은 없다. +4. ~~예외에 owner, reason, scope, expiry를 요구하는 작은 schema부터 + 시작한다.~~ **완료 (2026-08-30).** `adr.py exception --input `이 + `owner`/`reason`/`scope`/`expiry`를 요구하는 schema로 검증한 뒤 + `docs/decisions/exceptions/NNNN.json`에 `EXC-NNNN`으로 기록한다. CHECK는 + 일치하는 만료 전 예외를 finding의 `exception` 필드로 주석만 달고, `kind`나 + `confidence`는 절대 낮추거나 숨기지 않는다 — "no false governance + confidence" 원칙 유지. 5. 두 개 이상의 저장소가 같은 운영 문제를 보일 때 reusable workflow와 조직 taxonomy를 설계한다. **시작 조건(2개 이상 저장소) 미충족.** diff --git a/improvements.md b/improvements.md index 19fb74e..2938228 100644 --- a/improvements.md +++ b/improvements.md @@ -28,6 +28,14 @@ Concrete implementation backlog. Unscheduled product bets belong in ## Done +- [x] Added a small, deterministic schema for CHECK policy exceptions: + `adr.py exception --input ` validates `owner`/`reason`/`scope`/ + `expiry`/`adr_id`/`rule_id` and writes `docs/decisions/exceptions/NNNN.json` + as `EXC-NNNN`. CHECK loads active (non-expired, schema-valid) exceptions and + annotates a matching finding's `exception` field — it never suppresses or + downgrades the finding itself, keeping `kind`/`confidence` exactly what the + structural evidence says. A malformed exception file degrades to a + `BAD_EXCEPTION` warning, matching the existing ADR/constraints pattern. - [x] Promoted CHECK's confidence classification to a stable field: every finding now carries `confidence` (`VERIFIED`/`VIOLATED`/`UNVERIFIABLE`) computed directly from its `kind`, instead of leaving that mapping for the diff --git a/skills/adr-toolkit/SKILL.md b/skills/adr-toolkit/SKILL.md index 920ab6b..58ed1f2 100644 --- a/skills/adr-toolkit/SKILL.md +++ b/skills/adr-toolkit/SKILL.md @@ -253,10 +253,17 @@ since CHECK is read-only and never fixes anything itself. 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`. + `constraints:`, `affected_paths`, or `status`. If they pick + `register_exception`, gather `adr_id`, `rule_id`, `owner`, `reason`, a + narrow `scope` (never the whole rule), and an `expiry`, then run + `python skills/adr-toolkit/scripts/adr.py exception --input --dir docs/decisions --json` + — never hand-write a file under `docs/decisions/exceptions/`. A finding + already carrying an `exception` field has a matching, non-expired record; + report it, don't re-register it. 6. Any `check` `warnings` entries (e.g. `BAD_FRONTMATTER`, `SCHEMA_ERROR`, - `BAD_CONSTRAINTS`) mean one ADR or constraint was skipped, not that CHECK - failed — report them and never describe a warning-bearing result as clean. + `BAD_CONSTRAINTS`, `BAD_EXCEPTION`) mean one ADR, constraint, or exception + was skipped, not that CHECK failed — report them and never describe a + warning-bearing result as clean. ## Prohibited diff --git a/skills/adr-toolkit/references/conflict-rules.md b/skills/adr-toolkit/references/conflict-rules.md index efcf21f..5d8735b 100644 --- a/skills/adr-toolkit/references/conflict-rules.md +++ b/skills/adr-toolkit/references/conflict-rules.md @@ -107,6 +107,21 @@ Present all five options; never default to "revert the code": ADR, then supersede the old one; never weaken an accepted record in place merely to clear CHECK. 4. `register_exception` — this specific case is a deliberate, documented - exception to an otherwise-still-valid rule. + exception to an otherwise-still-valid rule. Record it deterministically: + + ```bash + python skills/adr-toolkit/scripts/adr.py exception --input exception.json \ + --dir docs/decisions --json + ``` + + `exception.json` must provide `adr_id`, `rule_id`, `owner`, `reason`, + `scope` (path patterns the exception is narrowed to — never the whole + rule), and `expiry` (`YYYY-MM-DD`); the command assigns the next `EXC-NNNN` + id and writes `docs/decisions/exceptions/NNNN.json`. A future CHECK run on + a matching, non-expired exception annotates the finding with an + `exception` field — the finding's `kind` and `confidence` stay + `verified_violation`/`VIOLATED`; an exception is visible evidence, never a + silent pass. An expired exception stops applying automatically; register a + new one to extend it, never edit the `expiry` of an existing record. 5. `false_positive` — the rule fired but there is no real conflict; note why so the rule can be tightened. diff --git a/skills/adr-toolkit/schemas/exception.schema.json b/skills/adr-toolkit/schemas/exception.schema.json new file mode 100644 index 0000000..c8209e4 --- /dev/null +++ b/skills/adr-toolkit/schemas/exception.schema.json @@ -0,0 +1,16 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "CHECK policy exception", + "type": "object", + "required": ["id", "adr_id", "rule_id", "owner", "reason", "scope", "expiry", "created"], + "properties": { + "id": { "type": "string", "pattern": "^EXC-[0-9]{4}$" }, + "adr_id": { "type": "string", "pattern": "^ADR-[0-9]{4}$" }, + "rule_id": { "type": "string", "minLength": 1 }, + "owner": { "type": "string", "minLength": 1 }, + "reason": { "type": "string", "minLength": 1 }, + "scope": { "type": "array", "items": { "type": "string" }, "minItems": 1 }, + "expiry": { "type": "string", "format": "date" }, + "created": { "type": "string", "format": "date" } + } +} diff --git a/skills/adr-toolkit/scripts/adr.py b/skills/adr-toolkit/scripts/adr.py index e1f500d..a267978 100755 --- a/skills/adr-toolkit/scripts/adr.py +++ b/skills/adr-toolkit/scripts/adr.py @@ -12,6 +12,7 @@ create, diff, discover, + exception, index, init, preflight, @@ -128,6 +129,13 @@ def build_parser() -> argparse.ArgumentParser: p_check.add_argument("--dir", default="docs/decisions") _add_json_flag(p_check) + p_exception = sub.add_parser("exception") + p_exception.add_argument("--input", required=True) + p_exception.add_argument("--dir", default="docs/decisions") + p_exception.add_argument("--root", default=".") + p_exception.add_argument("--dry-run", dest="dry_run", action="store_true") + _add_json_flag(p_exception) + return parser @@ -145,6 +153,7 @@ def build_parser() -> argparse.ArgumentParser: "supersede": supersede.run, "diff": diff.run, "check": check.run, + "exception": exception.run, } diff --git a/skills/adr-toolkit/scripts/commands/check.py b/skills/adr-toolkit/scripts/commands/check.py index 9df71e8..3544426 100644 --- a/skills/adr-toolkit/scripts/commands/check.py +++ b/skills/adr-toolkit/scripts/commands/check.py @@ -1,12 +1,15 @@ """Match a git diff against Accepted ADRs' constraints: rules and Superseded ADRs' affected_paths, per §7/§16 of the design spec.""" +import json import re +from datetime import date from pathlib import Path from scripts.commands import diff as diff_command from scripts.core import frontmatter as fm from scripts.core.adr_directory import iter_adr_files from scripts.core.constraints import ConstraintsError, extract_constraints +from scripts.core.exceptions import applies_to, is_expired, validate_exception from scripts.core.git_paths import GitPathsError, list_existing_paths from scripts.core.repository_paths import resolve_from_root from scripts.core.schema import validate_frontmatter @@ -63,6 +66,8 @@ def run(args) -> dict: } entries, warnings = _load_adrs(adr_dir) + active_exceptions, exception_warnings = _load_exceptions(adr_dir) + warnings += exception_warnings findings = [] for entry in entries: @@ -129,6 +134,23 @@ def run(args) -> dict: for finding in findings: finding["confidence"] = CONFIDENCE_BY_KIND[finding["kind"]] + if finding["kind"] == "verified_violation": + match = _matching_exception( + active_exceptions, + adr_id=finding.get("adr_id"), + rule_id=finding.get("rule_id"), + file_path=finding.get("file"), + ) + # Annotate only — an exception never hides or downgrades a + # violation. Confidence stays VIOLATED; a human/agent still sees + # and reports it, now with the approved exception attached. + if match is not None: + finding["exception"] = { + "id": match["id"], + "owner": match["owner"], + "reason": match["reason"], + "expiry": match["expiry"], + } return { "ok": True, @@ -139,6 +161,41 @@ def run(args) -> dict: } +def _load_exceptions(adr_dir: Path) -> tuple: + """Load active (non-expired, schema-valid) exceptions from adr_dir/exceptions. + + A malformed exception file degrades to a warning, same as a malformed ADR + — it must never silently vanish, and it must never abort the whole run. + """ + exceptions_dir = adr_dir / "exceptions" + active, warnings = [], [] + if not exceptions_dir.is_dir(): + return active, warnings + for path in sorted(exceptions_dir.glob("*.json")): + try: + data = json.loads(path.read_text(encoding="utf-8")) + except (json.JSONDecodeError, OSError, UnicodeDecodeError) as exc: + warnings.append({"code": "BAD_EXCEPTION", "file": path.name, "detail": str(exc)}) + continue + schema_errors = validate_exception(data) if isinstance(data, dict) else ["not a JSON object"] + if schema_errors: + warnings.extend({ + "code": "BAD_EXCEPTION", "file": path.name, "detail": detail, + } for detail in schema_errors) + continue + if is_expired(data["expiry"], today=date.today()): + continue + active.append(data) + return active, warnings + + +def _matching_exception(exceptions: list, *, adr_id: str, rule_id: str, file_path): + for exception in exceptions: + if applies_to(exception, adr_id=adr_id, rule_id=rule_id, file_path=file_path): + return exception + return None + + def _load_adrs(adr_dir: Path) -> tuple: entries, warnings = [], [] for entry, parsed in iter_adr_files(adr_dir): diff --git a/skills/adr-toolkit/scripts/commands/exception.py b/skills/adr-toolkit/scripts/commands/exception.py new file mode 100644 index 0000000..a9f2e0c --- /dev/null +++ b/skills/adr-toolkit/scripts/commands/exception.py @@ -0,0 +1,98 @@ +"""Validate and record a CHECK policy exception (owner/reason/scope/expiry).""" +import json +from datetime import date +from pathlib import Path + +from scripts.core.exceptions import validate_exception +from scripts.core.repository_paths import resolve_from_root + +REQUIRED_DRAFT_FIELDS = {"adr_id", "rule_id", "owner", "reason", "scope", "expiry"} + + +def _next_id(exceptions_dir: Path) -> int: + existing = [] + if exceptions_dir.is_dir(): + for entry in exceptions_dir.glob("*.json"): + if entry.stem.isdigit(): + existing.append(int(entry.stem)) + return max(existing, default=0) + 1 + + +def run(args) -> dict: + dry_run = getattr(args, "dry_run", False) + root = Path(getattr(args, "root", ".")) + input_path = getattr(args, "input", None) + + if not input_path: + return {"ok": False, "operation": "exception", "errors": [{"code": "MISSING_INPUT"}]} + + try: + raw_draft = Path(input_path).read_text(encoding="utf-8") + except FileNotFoundError: + return { + "ok": False, + "operation": "exception", + "errors": [{"code": "DRAFT_FILE_NOT_FOUND", "path": input_path}], + } + try: + draft = json.loads(raw_draft) + except json.JSONDecodeError as exc: + return { + "ok": False, + "operation": "exception", + "errors": [{"code": "DRAFT_FILE_INVALID_JSON", "path": input_path, "detail": str(exc)}], + } + + missing = REQUIRED_DRAFT_FIELDS - draft.keys() + if missing: + return { + "ok": False, + "operation": "exception", + "errors": [{"code": "MISSING_DRAFT_FIELD", "fields": sorted(missing)}], + } + + adr_dir = resolve_from_root(root, args.dir) + exceptions_dir = adr_dir / "exceptions" + next_num = _next_id(exceptions_dir) + exception_id = f"EXC-{next_num:04d}" + + data = { + "id": exception_id, + "adr_id": draft["adr_id"], + "rule_id": draft["rule_id"], + "owner": draft["owner"], + "reason": draft["reason"], + "scope": draft["scope"], + "expiry": draft["expiry"], + "created": draft.get("created") or date.today().isoformat(), + } + + schema_errors = validate_exception(data) + if schema_errors: + return { + "ok": False, + "operation": "exception", + "errors": [{"code": "SCHEMA_ERROR", "detail": e} for e in schema_errors], + } + + target = exceptions_dir / f"{next_num:04d}.json" + + if dry_run: + return { + "ok": True, + "operation": "exception", + "dry_run": True, + "would_create": str(target), + "id": exception_id, + } + + exceptions_dir.mkdir(parents=True, exist_ok=True) + target.write_text(json.dumps(data, indent=2, ensure_ascii=False) + "\n", encoding="utf-8") + + return { + "ok": True, + "operation": "exception", + "dry_run": False, + "created": str(target), + "id": exception_id, + } diff --git a/skills/adr-toolkit/scripts/core/exceptions.py b/skills/adr-toolkit/scripts/core/exceptions.py new file mode 100644 index 0000000..8d455e5 --- /dev/null +++ b/skills/adr-toolkit/scripts/core/exceptions.py @@ -0,0 +1,81 @@ +"""Structural validation and matching for CHECK policy exceptions. + +schemas/exception.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 +import re + +from scripts.core import globs + +REQUIRED_FIELDS = { + "id": str, + "adr_id": str, + "rule_id": str, + "owner": str, + "reason": str, + "scope": list, + "expiry": str, + "created": str, +} + +ID_RE = re.compile(r"^EXC-\d{4}$") +ADR_ID_RE = re.compile(r"^ADR-\d{4}$") +DATE_RE = re.compile(r"^\d{4}-\d{2}-\d{2}$") + + +def _validate_date(field: str, value: str, errors: list) -> None: + if not DATE_RE.match(value): + errors.append(f"{field} {value!r} is not YYYY-MM-DD") + return + year, month, day = (int(part) for part in value.split("-")) + try: + date_cls(year, month, day) + except ValueError: + errors.append(f"{field} {value!r} is not a real calendar date") + + +def validate_exception(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 EXC-NNNN") + + if isinstance(data.get("adr_id"), str) and not ADR_ID_RE.match(data["adr_id"]): + errors.append(f"adr_id {data['adr_id']!r} does not match ADR-NNNN") + + for field in ("owner", "reason", "rule_id"): + if isinstance(data.get(field), str) and not data[field].strip(): + errors.append(f"{field} must not be empty") + + if isinstance(data.get("scope"), list) and not data["scope"]: + errors.append("scope must contain at least one path pattern") + + if isinstance(data.get("expiry"), str): + _validate_date("expiry", data["expiry"], errors) + if isinstance(data.get("created"), str): + _validate_date("created", data["created"], errors) + + return errors + + +def is_expired(expiry: str, today: date_cls) -> bool: + year, month, day = (int(part) for part in expiry.split("-")) + return today > date_cls(year, month, day) + + +def applies_to(exception: dict, *, adr_id: str, rule_id: str, file_path: str) -> bool: + if exception.get("adr_id") != adr_id or exception.get("rule_id") != rule_id: + return False + if file_path is None: + return False + return any(globs.match(pattern, file_path) for pattern in exception.get("scope", [])) diff --git a/tests/unit/test_check.py b/tests/unit/test_check.py index 54ecb99..4a1138b 100644 --- a/tests/unit/test_check.py +++ b/tests/unit/test_check.py @@ -87,6 +87,106 @@ def test_verified_violation_when_rule_fires(tmp_path): assert violation["rule_id"] == "no-provider-sdk-in-feature" assert violation["resolutions"] == RESOLUTIONS assert violation["confidence"] == "VIOLATED" + assert "exception" not in violation + + +def _write_exception(adr_dir, **overrides): + import json as _json + exceptions_dir = adr_dir / "exceptions" + exceptions_dir.mkdir(parents=True, exist_ok=True) + data = { + "id": "EXC-0001", + "adr_id": "ADR-0001", + "rule_id": "no-provider-sdk-in-feature", + "owner": "YangSeungHyun", + "reason": "Vendor migration is in progress.", + "scope": ["src/features/**"], + "expiry": "2999-12-31", + "created": "2026-08-30", + } + data.update(overrides) + (exceptions_dir / "0001.json").write_text(_json.dumps(data), encoding="utf-8") + return data + + +def test_verified_violation_is_annotated_with_a_matching_active_exception(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) + _write_exception(adr_dir) + + (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)) + + violation = next(f for f in result["findings"] if f["kind"] == "verified_violation") + # An active exception annotates the finding; it never hides or downgrades it. + assert violation["confidence"] == "VIOLATED" + assert violation["exception"]["id"] == "EXC-0001" + assert violation["exception"]["owner"] == "YangSeungHyun" + assert violation["exception"]["expiry"] == "2999-12-31" + + +def test_expired_exception_is_not_applied(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) + _write_exception(adr_dir, expiry="2020-01-01") + + (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)) + + violation = next(f for f in result["findings"] if f["kind"] == "verified_violation") + assert "exception" not in violation + + +def test_exception_outside_scope_is_not_applied(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) + _write_exception(adr_dir, scope=["src/features/legacy/**"]) + + (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)) + + violation = next(f for f in result["findings"] if f["kind"] == "verified_violation") + assert "exception" not in violation + + +def test_malformed_exception_file_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") + _git(["add", "-A"], tmp_path) + _git(["commit", "-q", "-m", "init"], tmp_path) + exceptions_dir = adr_dir / "exceptions" + exceptions_dir.mkdir(parents=True) + (exceptions_dir / "0001.json").write_text("{not valid json", encoding="utf-8") + + (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 + assert any(w["code"] == "BAD_EXCEPTION" for w in result["warnings"]) + violation = next(f for f in result["findings"] if f["kind"] == "verified_violation") + assert "exception" not in violation def test_related_when_rule_present_but_does_not_fire(tmp_path): diff --git a/tests/unit/test_exception_command.py b/tests/unit/test_exception_command.py new file mode 100644 index 0000000..6edaa82 --- /dev/null +++ b/tests/unit/test_exception_command.py @@ -0,0 +1,87 @@ +import json +from types import SimpleNamespace + +from scripts.commands import exception + +VALID_DRAFT = { + "adr_id": "ADR-0001", + "rule_id": "no-provider-sdk-in-feature", + "owner": "YangSeungHyun", + "reason": "Vendor migration is in progress; tracked in issue #42.", + "scope": ["src/features/legacy/**"], + "expiry": "2026-12-31", +} + + +def _args(tmp_path, draft, **overrides): + draft_path = tmp_path / "draft.json" + draft_path.write_text(json.dumps(draft), encoding="utf-8") + defaults = dict( + input=str(draft_path), dir="docs/decisions", root=str(tmp_path), dry_run=False, + ) + defaults.update(overrides) + return SimpleNamespace(**defaults) + + +def test_creates_first_exception_as_exc_0001(tmp_path): + result = exception.run(_args(tmp_path, VALID_DRAFT)) + + assert result["ok"] is True + assert result["id"] == "EXC-0001" + created = tmp_path / "docs" / "decisions" / "exceptions" / "0001.json" + assert created.is_file() + data = json.loads(created.read_text(encoding="utf-8")) + assert data["id"] == "EXC-0001" + assert data["adr_id"] == "ADR-0001" + assert data["owner"] == "YangSeungHyun" + assert data["created"] # auto-stamped + + +def test_second_exception_gets_next_sequential_id(tmp_path): + exception.run(_args(tmp_path, VALID_DRAFT)) + second_draft = {**VALID_DRAFT, "rule_id": "some-other-rule"} + + result = exception.run(_args(tmp_path, second_draft)) + + assert result["id"] == "EXC-0002" + assert (tmp_path / "docs" / "decisions" / "exceptions" / "0002.json").is_file() + + +def test_missing_required_field_fails_before_writing(tmp_path): + bad_draft = {k: v for k, v in VALID_DRAFT.items() if k != "owner"} + + result = exception.run(_args(tmp_path, bad_draft)) + + assert result["ok"] is False + assert result["errors"][0]["code"] == "MISSING_DRAFT_FIELD" + assert not (tmp_path / "docs" / "decisions" / "exceptions").exists() + + +def test_present_but_invalid_field_fails_with_schema_error(tmp_path): + bad_draft = {**VALID_DRAFT, "expiry": "not-a-date"} + + result = exception.run(_args(tmp_path, bad_draft)) + + assert result["ok"] is False + assert result["errors"][0]["code"] == "SCHEMA_ERROR" + assert not (tmp_path / "docs" / "decisions" / "exceptions").exists() + + +def test_dry_run_validates_without_writing(tmp_path): + result = exception.run(_args(tmp_path, VALID_DRAFT, dry_run=True)) + + assert result["ok"] is True + assert result["dry_run"] is True + assert not (tmp_path / "docs" / "decisions" / "exceptions").exists() + + +def test_missing_input_file_is_an_explicit_error(tmp_path): + args = SimpleNamespace( + input=str(tmp_path / "nope.json"), dir="docs/decisions", root=str(tmp_path), + dry_run=False, + ) + + result = exception.run(args) + + assert result["ok"] is False + assert result["errors"][0]["code"] == "DRAFT_FILE_NOT_FOUND" diff --git a/tests/unit/test_exceptions.py b/tests/unit/test_exceptions.py new file mode 100644 index 0000000..dea7acb --- /dev/null +++ b/tests/unit/test_exceptions.py @@ -0,0 +1,94 @@ +from datetime import date + +from scripts.core.exceptions import applies_to, is_expired, validate_exception + +VALID = { + "id": "EXC-0001", + "adr_id": "ADR-0001", + "rule_id": "no-provider-sdk-in-feature", + "owner": "YangSeungHyun", + "reason": "Vendor migration is in progress; tracked in issue #42.", + "scope": ["src/features/legacy/**"], + "expiry": "2026-12-31", + "created": "2026-08-30", +} + + +def test_valid_exception_has_no_errors(): + assert validate_exception(VALID) == [] + + +def test_missing_required_field_is_reported(): + data = {k: v for k, v in VALID.items() if k != "owner"} + errors = validate_exception(data) + assert any("owner" in e for e in errors) + + +def test_empty_owner_is_rejected(): + data = {**VALID, "owner": " "} + errors = validate_exception(data) + assert any("owner" in e for e in errors) + + +def test_empty_scope_list_is_rejected(): + data = {**VALID, "scope": []} + errors = validate_exception(data) + assert any("scope" in e for e in errors) + + +def test_malformed_expiry_is_rejected(): + data = {**VALID, "expiry": "12/31/2026"} + errors = validate_exception(data) + assert any("expiry" in e for e in errors) + + +def test_malformed_id_is_rejected(): + data = {**VALID, "id": "exception-1"} + errors = validate_exception(data) + assert any("id" in e for e in errors) + + +def test_malformed_adr_id_is_rejected(): + data = {**VALID, "adr_id": "1"} + errors = validate_exception(data) + assert any("adr_id" in e for e in errors) + + +def test_is_expired_true_after_expiry_date(): + assert is_expired("2026-01-01", today=date(2026, 1, 2)) is True + + +def test_is_expired_false_on_expiry_date(): + assert is_expired("2026-01-01", today=date(2026, 1, 1)) is False + + +def test_is_expired_false_before_expiry_date(): + assert is_expired("2026-01-01", today=date(2025, 12, 31)) is False + + +def test_applies_to_matches_adr_rule_and_scope(): + assert applies_to( + VALID, adr_id="ADR-0001", rule_id="no-provider-sdk-in-feature", + file_path="src/features/legacy/old.py", + ) is True + + +def test_applies_to_false_when_adr_id_differs(): + assert applies_to( + VALID, adr_id="ADR-0002", rule_id="no-provider-sdk-in-feature", + file_path="src/features/legacy/old.py", + ) is False + + +def test_applies_to_false_when_rule_id_differs(): + assert applies_to( + VALID, adr_id="ADR-0001", rule_id="some-other-rule", + file_path="src/features/legacy/old.py", + ) is False + + +def test_applies_to_false_when_file_outside_scope(): + assert applies_to( + VALID, adr_id="ADR-0001", rule_id="no-provider-sdk-in-feature", + file_path="src/features/current/new.py", + ) is False From ffc4f216e1455a2222401028daaa3b80f24dcec9 Mon Sep 17 00:00:00 2001 From: shcommit Date: Sun, 30 Aug 2026 23:13:37 +0900 Subject: [PATCH 33/76] docs: bring handoff.md up to date with the P1 backlog work The last handoff predated 8 commits (sync_version hardening, CI dedup, ADR directory centralization, Codex adapter doc fix, manifest description ownership, --json contract, CHECK confidence field, exception schema) and still cited a 290-test baseline against the current 327. Recorded what's done, what's left (two owner-gated P1 items and the P0 release gate), and confirmed project-roadmap.md has nothing actionable right now. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01CeDZgjQakpnGGtsWoC7ijF --- handoff.md | 81 ++++++++++++++++++++++++++++++++++++++++++------------ 1 file changed, 63 insertions(+), 18 deletions(-) diff --git a/handoff.md b/handoff.md index b3844e3..922cfb3 100644 --- a/handoff.md +++ b/handoff.md @@ -2,11 +2,10 @@ ## Current task (2026-08-30) -ADR Toolkit `v0.2.0` minor-release implementation and local release evidence -are complete: repository-configured eight-locale ADR generation, portable -multilingual filenames, CHECK false-confidence fixes, dogfooded ADR quality -corrections, user documentation, and separate Korean readiness/enterprise -reports. +ADR Toolkit `v0.2.0` minor-release implementation is complete, and the +`improvements.md` P1 backlog has been worked down to just the two items that +require the owner's own action. Everything else in P1 that was actionable by +an agent is done. The work runs in the linked worktree `develop-2`. Its base is the current `origin/develop` history plus the approved design commits: @@ -17,30 +16,76 @@ The work runs in the linked worktree `develop-2`. Its base is the current Baseline verification before implementation: `212 passed` on 2026-08-30. -Latest local release evidence on 2026-08-30: +## Touched since the last handoff -- `python3 -m pytest -q` → `290 passed`, exit 0 +- `b9e0ec3` — hardened `sync_version.py`: silent drift when a tracked + manifest loses its version key, a crash on a manifest path outside the + repo root, and non-ASCII manifest content being escaped on write. +- `baa0987` — split the manifest version-drift CI check into its own job + instead of running it on all 5 OS/Python matrix legs. +- `2f2a8de` — centralized ADR directory loading into + `core.adr_directory.iter_adr_files`, shared by related/index/validate/check. +- `061d44e` — corrected `adapters/codex/README.md`: the documented + install-time symlink step was unused by the actually-verified install path; + removed it and explained why `.codex-plugin/plugin.json` is structural-only. +- `5d37bef` — made `SKILL.md`'s frontmatter description the canonical source, + synced into every duplicating manifest by `sync_version.py`, fixing one + real drift in `.claude-plugin/plugin.json`. +- `63c4c85` — formalized `--json` as a documented no-op (output is always + JSON); added a regression test and fixed 3 docs that omitted the flag. +- `2c73af6` — promoted CHECK's confidence classification to a stable + `confidence` field (`VERIFIED`/`VIOLATED`/`UNVERIFIABLE`) on every finding, + computed from `kind` instead of left for the agent to re-derive from docs. +- `6e68b5d` — added a deterministic exception schema: `adr.py exception` + validates and records `owner`/`reason`/`scope`/`expiry`/`adr_id`/`rule_id` + as `docs/decisions/exceptions/EXC-NNNN.json`; CHECK annotates a matching, + non-expired exception onto a finding without ever suppressing or + downgrading it. + +Latest local verification on 2026-08-30: + +- `python3 -m pytest -q` → `327 passed`, exit 0 - `python3 scripts/sync_version.py --check` → exit 0 -- repository validation → 6 ADRs, no errors -- regenerated decision index → byte-identical +- `git status --short` → clean -The detailed evidence and maturity assessment are in -`docs/adr-toolkit-v0.2.0-readiness-report.md`; post-release governance is kept -separate in `docs/enterprise-adoption.md`. +The detailed v0.2.0 evidence and maturity assessment are in +`docs/adr-toolkit-v0.2.0-readiness-report.md` (not re-verified against the +latest commits above — re-run its evidence commands before treating its +GO/NO-GO judgment as current); post-release governance is kept separate in +`docs/enterprise-adoption.md`, whose §8 "다음 구현 후보" now reflects items 1, +3, and 4 as done. ## Next step -Commit the final reports and tracking reconciliation, then open the final PR. -After required CI passes, obtain explicit approval for the v0.2.0 version bump, -release branch, push, and tag. Until then the readiness decision is conditional -GO and tag release is NO-GO. +Two `improvements.md` P1 items remain, both requiring the owner, not an +agent: + +- **Codex metadata compatibility** — deliberately deferred by the owner + (2026-08-30); revisit only when asked. +- **Stale remote branch cleanup** (`origin/SHcommit/feat-plan-adr-toolkit`, + `origin/feat/adr-toolkit-mvp-implement`) — needs the owner's explicit + deletion approval; not attempted. + +Beyond that, the only remaining work is the P0 release gate: open the final +PR, get required CI green, get the owner's approval for the v0.2.0 version +bump, merge through a release branch, and verify the tag lands on the +intended `master` commit. `develop-2` has not been pushed to `origin` yet +(no upstream branch configured) — the owner has said they'll handle the +public-repository transition themselves later, and PR/push have not been +authorized in this session. + +`project-roadmap.md` was reviewed end to end: every item there is explicitly +gated on usage evidence or a separate design decision that doesn't exist yet +— nothing there is actionable now. ## Open risks - Catalog structure is deterministic, but terminology still benefits from native-speaker review in each supported language. - CHECK deliberately cannot prove prose, business rationale, or organizational - claims; those remain human-review evidence. + claims; those remain human-review evidence. The new exception mechanism + extends this: an exception is recorded and annotated, never a silent pass. - GitHub protection is intentionally unavailable in the current private plan; - configure and API-verify it after the repository becomes public. + configure and API-verify it after the repository becomes public (owner's + own follow-up, not scheduled here). - Version bump, release branch, push, and tag are not authorized by this task. From c995904537da8b0fc3c1d2acb3ed1f52d240cb1a Mon Sep 17 00:00:00 2001 From: shcommit Date: Sun, 30 Aug 2026 23:40:16 +0900 Subject: [PATCH 34/76] docs: close the Codex metadata compatibility item as not applicable quick_validate.py's rejection of user-invocable/version comes from Codex CLI's own local skill-creator authoring tool, never from the actual plugin install/discovery path (independently verified working). skill-creator targets simple Codex-only skills with a narrower schema; adr-toolkit is deliberately cross-harness, so there's no real gap to close. Recorded the decision and moved the item to Done rather than leaving it open pending a fix that isn't needed. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01CeDZgjQakpnGGtsWoC7ijF --- improvements.md | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/improvements.md b/improvements.md index 2938228..9ddb000 100644 --- a/improvements.md +++ b/improvements.md @@ -15,11 +15,6 @@ Concrete implementation backlog. Unscheduled product bets belong in ### P1 — strongly recommended -- [ ] **Resolve Codex metadata compatibility.** Codex `quick_validate.py` - rejects `user-invocable` and `version`, while the cross-harness contract and - repository tests require them. - **Done when:** a deliberate compatibility decision and verified adapter path - replace the standing ambiguity. - [ ] **Clean stale remote branches after reference checks.** Candidates are `origin/SHcommit/feat-plan-adr-toolkit` and `origin/feat/adr-toolkit-mvp-implement`. @@ -28,6 +23,18 @@ Concrete implementation backlog. Unscheduled product bets belong in ## Done +- [x] Decided Codex metadata compatibility: no code change needed. + `quick_validate.py`'s rejection of `user-invocable`/`version` comes from + Codex CLI's own local `skill-creator` authoring tool + (`~/.codex/skills/.system/skill-creator/`), not from the actual plugin + install/discovery path (`codex plugin marketplace add` / + `codex plugin add`), which never reads that script and was independently + verified to work. `skill-creator` targets simple, Codex-only skills built + from scratch with a narrower schema (`name`/`description`/`license`/ + `allowed-tools`/`metadata`); `adr-toolkit` is a deliberately cross-harness + skill following the Agent Plugins standard instead, so there is no real + compatibility gap to close — only an incidental collision if someone points + that unrelated tool at this repo's `SKILL.md` by hand. - [x] Added a small, deterministic schema for CHECK policy exceptions: `adr.py exception --input ` validates `owner`/`reason`/`scope`/ `expiry`/`adr_id`/`rule_id` and writes `docs/decisions/exceptions/NNNN.json` From b1a4ec5858a77df58a909af31730b0ea4fbe7ba0 Mon Sep 17 00:00:00 2001 From: shcommit Date: Sun, 30 Aug 2026 23:44:25 +0900 Subject: [PATCH 35/76] chore: delete two stale remote branches, close the backlog item Both origin/SHcommit/feat-plan-adr-toolkit and origin/feat/adr-toolkit-mvp-implement were fully merged into origin/develop with no open PR referencing either. Owner approved deletion; deleted via git push origin --delete and confirmed gone. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01CeDZgjQakpnGGtsWoC7ijF --- improvements.md | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/improvements.md b/improvements.md index 9ddb000..63248d7 100644 --- a/improvements.md +++ b/improvements.md @@ -13,16 +13,14 @@ Concrete implementation backlog. Unscheduled product bets belong in **Done when:** the readiness report's conditional GO becomes GO. Creating the release branch, pushing, version bumping, and tagging require owner approval. -### P1 — strongly recommended - -- [ ] **Clean stale remote branches after reference checks.** Candidates are - `origin/SHcommit/feat-plan-adr-toolkit` and - `origin/feat/adr-toolkit-mvp-implement`. - **Done when:** no open PR or release reference needs them and the owner - explicitly approves deletion. - ## Done +- [x] Cleaned stale remote branches: `origin/SHcommit/feat-plan-adr-toolkit` + and `origin/feat/adr-toolkit-mvp-implement` were both fully merged into + `origin/develop` with no open PR referencing either (`gh pr list --state + open` returned none repo-wide); owner approved deletion, deleted via + `git push origin --delete`, and confirmed gone via `git fetch --prune`. + - [x] Decided Codex metadata compatibility: no code change needed. `quick_validate.py`'s rejection of `user-invocable`/`version` comes from Codex CLI's own local `skill-creator` authoring tool From 2fa6d676de47ad40b91b2dfcf76974f79750cc70 Mon Sep 17 00:00:00 2001 From: shcommit Date: Sun, 30 Aug 2026 23:55:16 +0900 Subject: [PATCH 36/76] docs: record ADR-0007..0010 for this session's design decisions Dogfoods the toolkit's own RECORD workflow: scored each candidate decision with adr.py significance before writing anything. - ADR-0007 (10, recommended): promote CHECK's kind-to-confidence mapping to a stable output field. - ADR-0008 (12, recommended): deterministic CHECK policy exceptions, schema-validated and annotate-only, never suppressing a violation. - ADR-0009 (7, recommended): --json is a documented no-op; CLI output is always JSON. - ADR-0010 (4, optional): Codex skill-creator's quick_validate.py incompatibility is not this project's problem, with the verification evidence recorded so the question doesn't need re-investigating. Regenerated docs/decisions/README.md; full suite (327 tests) and validate both pass with all 10 ADRs. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01CeDZgjQakpnGGtsWoC7ijF --- docs/decisions/0007-check-confidence-field.md | 80 +++++++++++++++++ .../0008-check-exceptions-annotate-only.md | 88 +++++++++++++++++++ .../0009-json-flag-always-json-contract.md | 80 +++++++++++++++++ ...010-codex-quick-validate-not-applicable.md | 74 ++++++++++++++++ docs/decisions/README.md | 70 +++++++++++++++ 5 files changed, 392 insertions(+) create mode 100644 docs/decisions/0007-check-confidence-field.md create mode 100644 docs/decisions/0008-check-exceptions-annotate-only.md create mode 100644 docs/decisions/0009-json-flag-always-json-contract.md create mode 100644 docs/decisions/0010-codex-quick-validate-not-applicable.md diff --git a/docs/decisions/0007-check-confidence-field.md b/docs/decisions/0007-check-confidence-field.md new file mode 100644 index 0000000..0934a70 --- /dev/null +++ b/docs/decisions/0007-check-confidence-field.md @@ -0,0 +1,80 @@ +--- +id: ADR-0007 +title: Promote CHECK's kind-to-confidence mapping to a stable output field +status: accepted +date: 2026-08-30 +locale: en +decision_makers: + - YangSeungHyun +related: [] +affected_paths: + - skills/adr-toolkit/scripts/commands/check.py + - README.md + - skills/adr-toolkit/SKILL.md + - skills/adr-toolkit/references/conflict-rules.md + - examples/quickstart.md + - tests/unit/test_check.py +tags: + - check + - confidence + - v0.2.0 +retrospective: false +--- + +# Promote CHECK's kind-to-confidence mapping to a stable output field + +## Context and Problem Statement + +CHECK findings carried an ad-hoc `kind` value (`verified_violation`, `review_required`, `related`, `no_applicable_constraint`). README, SKILL.md, and `references/conflict-rules.md` already documented a mapping from each `kind` to a governance confidence meaning (`VERIFIED`, `VIOLATED`, `UNVERIFIABLE`, `NOT_APPLICABLE`), but that mapping existed only in prose. Every agent consuming CHECK output had to re-derive it from documentation on every run instead of reading it off the result. + +## Decision Drivers + +* Give CHECK a stable, machine-readable confidence contract instead of leaving classification to prose the agent must re-read every time. +* Avoid breaking existing consumers and tests that already assert on `kind`. +* Keep the change additive and low-risk given the size of the existing CHECK test suite. + +## Considered Options + +* Leave the mapping in documentation only; require agents to re-derive it each run. +* Replace `kind` entirely with the four-value `confidence` vocabulary. +* Add `confidence` as a new field computed from the existing `kind`, leaving `kind` unchanged. + +## Decision Outcome + +Chosen option: **add `confidence` as a new field computed from `kind`**, because it makes the already-documented contract machine-readable without touching `kind`'s sixteen existing test assertions or any consumer that already keys off it. + +Every finding of `kind: verified_violation` now also carries `confidence: VIOLATED`; `related` carries `VERIFIED`; `review_required` and `no_applicable_constraint` both carry `UNVERIFIABLE`. An empty `findings` list still means `NOT_APPLICABLE` — that value has no corresponding per-finding `kind`, since no finding is emitted when no ADR governs the changed paths at all. + +### Consequences + +* Good: agents (and any other consumer) read `confidence` directly instead of re-deriving it from prose on every run. +* Good: `kind` and every existing test assertion on it are untouched — zero regression risk to the CHECK test suite. +* Bad: two overlapping fields (`kind` and `confidence`) now exist on every finding; a future cleanup could consider whether `kind` should eventually be retired once all consumers move to `confidence`. + +### Confirmation + +* `tests/unit/test_check.py` asserts the exact `confidence` value for each of the four `kind` values, including the superseded-reference violation path. +* `python3 -m pytest -q` passes (327 tests) after the change. +* README's "CHECK confidence" table, SKILL.md's CLASSIFY step, and `references/conflict-rules.md`'s evidence-confidence table were updated to describe the field instead of a derivation the agent had to perform. + +## Pros and Cons of the Options + +### Leave the mapping in documentation only + +* Good, because it requires no code change. +* Bad, because every agent run re-derives the same fixed mapping from prose, which is exactly the kind of repeatable, low-judgment work a deterministic core should own. + +### Replace `kind` with `confidence` entirely + +* Good, because it removes the two-field redundancy. +* Bad, because it breaks sixteen existing test assertions and any external consumer already keyed off `kind`, for no functional gain over the additive approach. + +### Add `confidence` as a new field + +* Good, because it is purely additive: no existing behavior, test, or consumer changes. +* Bad, because the two fields overlap in meaning, which a future major version could choose to collapse. + +## Revisit Triggers + +* A future breaking release consolidates `kind` and `confidence` into a single field once external consumers no longer depend on `kind` alone. +* A fifth finding shape is added that doesn't map cleanly onto the four confidence values, requiring the mapping itself to be revisited. diff --git a/docs/decisions/0008-check-exceptions-annotate-only.md b/docs/decisions/0008-check-exceptions-annotate-only.md new file mode 100644 index 0000000..f058e1b --- /dev/null +++ b/docs/decisions/0008-check-exceptions-annotate-only.md @@ -0,0 +1,88 @@ +--- +id: ADR-0008 +title: Deterministic CHECK policy exceptions: schema-validated, annotate-only, never suppress +status: accepted +date: 2026-08-30 +locale: en +decision_makers: + - YangSeungHyun +related: [] +affected_paths: + - skills/adr-toolkit/scripts/core/exceptions.py + - skills/adr-toolkit/scripts/commands/exception.py + - skills/adr-toolkit/scripts/commands/check.py + - skills/adr-toolkit/scripts/adr.py + - skills/adr-toolkit/schemas/exception.schema.json + - README.md + - skills/adr-toolkit/SKILL.md + - skills/adr-toolkit/references/conflict-rules.md +tags: + - check + - exceptions + - governance + - v0.2.0 +retrospective: false +--- + +# Deterministic CHECK policy exceptions: schema-validated, annotate-only, never suppress + +## Context and Problem Statement + +`register_exception` has always been one of CHECK's five documented resolutions for a Verified violation, but it existed only as a string label. There was no schema for what an exception must record, nowhere to store one, and no way for a later CHECK run to recognize that a specific violation had already been reviewed and accepted. Anyone who picked `register_exception` had no deterministic next step. + +## Decision Drivers + +* Give `register_exception` a real, followable path instead of a dead-end label. +* Never let CHECK present a false "clean" result — this project's core principle is that a clean result must never be confused with proof of compliance. +* Keep exceptions scoped, owned, and time-boxed rather than a blanket, permanent rule bypass. +* Reuse the existing deterministic-core patterns (schema validation, sequential IDs, one JSON record per exception) instead of inventing a new mechanism. + +## Considered Options + +* Leave `register_exception` as a label only, with no backing schema or storage. +* Build a full exception system where a matching, active exception suppresses the violation from future CHECK runs. +* Build a schema-validated exception record (`owner`, `reason`, `scope`, `expiry`, `adr_id`, `rule_id`) that CHECK annotates onto a matching finding, but never uses to hide or downgrade it. + +## Decision Outcome + +Chosen option: **schema-validated exception records that CHECK annotates but never suppresses**, because any mechanism that makes an accepted violation silently disappear from CHECK's output would directly contradict this project's "no false governance confidence" principle — a human scanning CHECK results must still see every structurally confirmed violation, exception or not. + +`adr.py exception --input ` validates a draft against `schemas/exception.schema.json` (`owner`, `reason`, `scope`, `expiry`, `adr_id`, `rule_id` all required; `scope` must be a non-empty list of path patterns narrower than the rule itself) and writes `docs/decisions/exceptions/EXC-NNNN.json`. CHECK loads every active (schema-valid, non-expired) exception on each run and, for a `verified_violation` finding whose `adr_id`, `rule_id`, and file path all match, attaches an `exception` field carrying the exception's id, owner, reason, and expiry. The finding's `kind` and `confidence` stay exactly what the structural evidence says. A malformed exception file degrades to a `BAD_EXCEPTION` warning rather than aborting the run, matching the existing ADR/constraints degradation pattern. + +### Consequences + +* Good: `register_exception` is now a real, deterministic, auditable action instead of a dead-end label. +* Good: an exception can never cause CHECK to under-report a real violation — the worst case is a violation the reader already expects to see, now with context attached. +* Good: `scope` forces an exception to be narrower than the rule it excepts, and `expiry` forces it to be time-boxed rather than a permanent bypass. +* Bad: a violation with an active exception still appears in every CHECK run, which is by design but means CHECK output stays "noisy" for accepted, reviewed exceptions rather than going quiet. +* Bad: exceptions are matched by exact `adr_id`/`rule_id`/scoped-path equality only — no notion of exception review, renewal workflow, or expiry notification exists yet. + +### Confirmation + +* `tests/unit/test_exceptions.py` covers schema validation, expiry, and scope matching. +* `tests/unit/test_exception_command.py` covers the `exception` command's id assignment, dry-run, and error paths. +* `tests/unit/test_check.py` covers a matching active exception being annotated, an expired exception not applying, an out-of-scope exception not applying, and a malformed exception file degrading to a warning. +* The documented `adr.py exception` command was run end to end in a scratch repository and produced the exact output shown in the README. + +## Pros and Cons of the Options + +### Leave `register_exception` as a label only + +* Good, because it requires no new code, schema, or storage format. +* Bad, because it leaves one of CHECK's five documented resolutions with no actual mechanism behind it. + +### Suppress matching violations from future CHECK runs + +* Good, because accepted exceptions would stop generating repeat noise on every run. +* Bad, because it lets CHECK present a "clean" result over a structurally confirmed violation, which this project's own principles explicitly reject as false governance confidence. + +### Schema-validated, annotate-only exceptions + +* Good, because it makes `register_exception` real and auditable while keeping every violation visible. +* Bad, because CHECK output never goes fully quiet for a repository carrying accepted exceptions — a deliberate tradeoff, not an oversight. + +## Revisit Triggers + +* Real usage shows accepted-exception noise is a genuine workflow problem, motivating a filtered or grouped view rather than suppression itself. +* Multiple exceptions accumulate to the point that an `adr.py exception list` or expiry-reporting command becomes worth building. +* Organizations need exception approval workflows (multi-party sign-off) beyond a single `owner` field. diff --git a/docs/decisions/0009-json-flag-always-json-contract.md b/docs/decisions/0009-json-flag-always-json-contract.md new file mode 100644 index 0000000..31166ad --- /dev/null +++ b/docs/decisions/0009-json-flag-always-json-contract.md @@ -0,0 +1,80 @@ +--- +id: ADR-0009 +title: --json is a documented no-op; CLI output is always JSON +status: accepted +date: 2026-08-30 +locale: en +decision_makers: + - YangSeungHyun +related: [] +affected_paths: + - skills/adr-toolkit/scripts/adr.py + - README.md + - skills/adr-toolkit/SKILL.md + - tests/integration/test_cli.py +tags: + - cli + - output-contract + - v0.2.0 +retrospective: false +--- + +# --json is a documented no-op; CLI output is always JSON + +## Context and Problem Statement + +Every `adr.py` subcommand parsed a `--json` flag via argparse, but `main()` always printed `json.dumps(result, ...)` to stdout regardless of whether the flag was passed. The flag looked configurable in `--help` output but silently did nothing — a dead-flag bug that also left an unresolved question: should the tool grow a human-readable output mode, or should the flag be removed? + +## Decision Drivers + +* The tool's own code already carried a comment declaring a "JSON-only-stdout contract"; the flag contradicted that stated intent. +* Every real caller — every documented command in README/SKILL.md/quickstart, and every integration test — already passes `--json` explicitly. Only two SKILL.md steps and one README example omitted it. +* v0.2.0 is declared a non-breaking minor release; removing an accepted flag would be a breaking CLI change. +* Building a genuine human-readable mode means designing a format for fourteen different result shapes, an open-ended and unscoped effort not requested by any real usage. + +## Considered Options + +* Add a human-readable default output mode, with `--json` opting into the current JSON behavior (inverts today's actual default). +* Remove the `--json` flag entirely in a breaking CLI change. +* Keep current always-JSON behavior exactly as-is; formalize it as the deliberate, tested contract and document `--json` as an accepted no-op. + +## Decision Outcome + +Chosen option: **formalize always-JSON as the deliberate contract**, because it costs nothing — behavior does not change for a single existing caller — while resolving the standing ambiguity the dead flag created. This aligns with the tool's own "Deterministic Core" design: JSON is the one machine-readable contract agents and CI already rely on, and no request has ever surfaced a need for a second, human-readable output format. + +The fourteen duplicated `--json` argument definitions were centralized into one `_add_json_flag()` helper with a `--help` string stating plainly that the flag has no effect and is kept only for backward compatibility. A regression test proves output is identical JSON whether or not `--json` is passed. The three documented call sites that had omitted the flag were updated to include it, for consistency rather than necessity. + +### Consequences + +* Good: the standing ambiguity is resolved without any behavior change or breaking release. +* Good: `--help` now tells the truth about what the flag does, instead of implying a choice that doesn't exist. +* Bad: `--json` remains a slightly confusing vestigial flag that a new contributor may still wonder about, even with the `--help` text. +* Bad: a genuine human-readable mode is deferred indefinitely rather than designed now, if a future use case (e.g. a human running the CLI directly, without an agent) ever wants one. + +### Confirmation + +* `tests/integration/test_cli.py::test_preflight_output_is_json_even_without_the_json_flag` asserts identical JSON output with and without `--json`. +* `python3 -m pytest -q` passes (327 tests) after the change. +* `python3 skills/adr-toolkit/scripts/adr.py preflight --help` shows the updated, accurate help text. + +## Pros and Cons of the Options + +### Add a human-readable default mode + +* Good, because it would give `--json` genuine meaning again. +* Bad, because it inverts today's actual default behavior for the three call sites that currently omit the flag, and requires designing an unscoped, ad-hoc text format for fourteen different result shapes with no real usage evidence motivating it. + +### Remove the flag entirely + +* Good, because it eliminates the dead flag outright. +* Bad, because it is a breaking CLI change that would fail every existing documented and tested invocation, in a release explicitly declared non-breaking. + +### Formalize always-JSON as the contract + +* Good, because it changes zero behavior for zero existing callers while resolving the ambiguity. +* Bad, because the flag itself remains present and slightly confusing, rather than being cleanly resolved one way or the other. + +## Revisit Triggers + +* A real, evidenced need emerges for a human operator to run the CLI directly without an agent or script parsing its output. +* A future major/breaking release is already planned for unrelated reasons, making it a low-cost moment to drop the inert flag entirely. diff --git a/docs/decisions/0010-codex-quick-validate-not-applicable.md b/docs/decisions/0010-codex-quick-validate-not-applicable.md new file mode 100644 index 0000000..402d286 --- /dev/null +++ b/docs/decisions/0010-codex-quick-validate-not-applicable.md @@ -0,0 +1,74 @@ +--- +id: ADR-0010 +title: Codex skill-creator's quick_validate.py incompatibility is not this project's problem +status: accepted +date: 2026-08-30 +locale: en +decision_makers: + - YangSeungHyun +related: [] +affected_paths: + - skills/adr-toolkit/SKILL.md +tags: + - codex + - cross-harness + - documentation +retrospective: false +--- + +# Codex skill-creator's quick_validate.py incompatibility is not this project's problem + +## Context and Problem Statement + +Codex CLI ships a local, per-user "skill-creator" meta-skill (`~/.codex/skills/.system/skill-creator/`) with its own `quick_validate.py` lint script. That script only allows `name`, `description`, `license`, `allowed-tools`, and `metadata` in a `SKILL.md`'s frontmatter, and rejects `user-invocable` and `version` as unexpected keys. `skills/adr-toolkit/SKILL.md` carries both of those keys, because this project's own cross-harness contract and `tests/unit/test_skill_manifest.py` require them. Running that unrelated script against this repository's `SKILL.md` therefore fails. + +## Decision Drivers + +* Determine whether this represents a real compatibility gap this project must close, before spending effort on a fix. +* Verify the claim empirically against the actually-installed tooling rather than assuming. +* Avoid weakening this project's own cross-harness manifest contract to satisfy an unrelated tool's narrower one. + +## Considered Options + +* Drop `user-invocable` and `version` from `SKILL.md` to satisfy `quick_validate.py`. +* Maintain two parallel `SKILL.md` variants, one for Codex's skill-creator convention and one for the real cross-harness contract. +* Verify whether `quick_validate.py` sits on the actual plugin install/discovery path at all, and if not, take no action beyond documenting the finding. + +## Decision Outcome + +Chosen option: **verify, then take no action**, because `quick_validate.py` was confirmed to have no role in the actual Codex plugin install/discovery path. `codex plugin marketplace add` and `codex plugin add` (originally verified end to end for ADR-0004; re-verified in this session against Codex CLI 0.151.0, see `adapters/codex/README.md`) read only `.claude-plugin/plugin.json` and `.codex-plugin/plugin.json`; neither invokes `quick_validate.py`. That script belongs to a separate, local, per-user Codex feature for authoring brand-new, Codex-only skills from scratch with a deliberately narrower schema — it is not part of this project's distribution or install path, and this repository's `SKILL.md` was never built through that tool. + +Dropping `user-invocable`/`version` to satisfy an unrelated script would break this project's actual cross-harness contract and its own tests, in exchange for satisfying a tool this project's plugin never runs through. + +### Consequences + +* Good: no code or contract change was needed; nothing was broken to accommodate an unrelated tool. +* Good: the reasoning is now recorded, so the question doesn't have to be re-investigated from scratch if it resurfaces (it has already been asked twice in one session). +* Bad: a user who happens to run their local `quick_validate.py` against this repository's `SKILL.md` by hand will still see a failure, with no in-repo signal explaining why — the explanation lives only in this ADR and session history, not in `SKILL.md` itself. + +### Confirmation + +* `codex plugin marketplace add "$(pwd)"` and `codex plugin add adr-toolkit@adr-toolkit-marketplace` were re-run against Codex CLI 0.151.0 in an isolated `CODEX_HOME`; both succeeded and read `.claude-plugin/plugin.json`, never `quick_validate.py`. +* `python3 ~/.codex/skills/.system/skill-creator/scripts/quick_validate.py skills/adr-toolkit` was run directly and reproduced the reported failure, confirming the claim while also confirming it sits outside the install path. + +## Pros and Cons of the Options + +### Drop the two keys from SKILL.md + +* Good, because it would satisfy `quick_validate.py`. +* Bad, because it breaks this project's own cross-harness contract and `test_skill_manifest.py` for a tool that never actually reads this file during real plugin installation. + +### Maintain two SKILL.md variants + +* Good, because both conventions could theoretically be satisfied at once. +* Bad, because it reintroduces exactly the drift risk this project has repeatedly hardened against (see ADR-0004's single-canonical-package rationale), for a tool with no install-path role. + +### Verify, then take no action + +* Good, because it costs nothing and is backed by an actual reproduction against the real tooling, not assumption. +* Bad, because the explanation is not discoverable from `SKILL.md` itself if the question resurfaces outside this session. + +## Revisit Triggers + +* Codex's actual plugin install/discovery path changes to route through `quick_validate.py` or an equivalent check, making this a real compatibility gap. +* A future Agent Plugins spec revision standardizes `user-invocable`/`version`-equivalent keys in a way `quick_validate.py`'s allowed set would need updating for anyway. diff --git a/docs/decisions/README.md b/docs/decisions/README.md index ec87f85..9f2c48b 100644 --- a/docs/decisions/README.md +++ b/docs/decisions/README.md @@ -8,6 +8,10 @@ - [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) - [ADR-0006 — Localize deterministic ADR generation through repository configuration](0006-localized-adr-generation.md) +- [ADR-0007 — Promote CHECK's kind-to-confidence mapping to a stable output field](0007-check-confidence-field.md) +- [ADR-0008 — Deterministic CHECK policy exceptions: schema-validated, annotate-only, never suppress](0008-check-exceptions-annotate-only.md) +- [ADR-0009 — --json is a documented no-op; CLI output is always JSON](0009-json-flag-always-json-contract.md) +- [ADR-0010 — Codex skill-creator's quick_validate.py incompatibility is not this project's problem](0010-codex-quick-validate-not-applicable.md) ### Superseded - [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) @@ -24,13 +28,36 @@ ### check - [ADR-0002 — Limit CHECK's conflict detection to structural evidence only](0002-limit-check-s-conflict-detection-to-structural-evidence-only.md) +- [ADR-0007 — Promote CHECK's kind-to-confidence mapping to a stable output field](0007-check-confidence-field.md) +- [ADR-0008 — Deterministic CHECK policy exceptions: schema-validated, annotate-only, never suppress](0008-check-exceptions-annotate-only.md) + +### cli +- [ADR-0009 — --json is a documented no-op; CLI output is always JSON](0009-json-flag-always-json-contract.md) + +### codex +- [ADR-0010 — Codex skill-creator's quick_validate.py incompatibility is not this project's problem](0010-codex-quick-validate-not-applicable.md) + +### confidence +- [ADR-0007 — Promote CHECK's kind-to-confidence mapping to a stable output field](0007-check-confidence-field.md) ### configuration - [ADR-0006 — Localize deterministic ADR generation through repository configuration](0006-localized-adr-generation.md) +### cross-harness +- [ADR-0010 — Codex skill-creator's quick_validate.py incompatibility is not this project's problem](0010-codex-quick-validate-not-applicable.md) + +### documentation +- [ADR-0010 — Codex skill-creator's quick_validate.py incompatibility is not this project's problem](0010-codex-quick-validate-not-applicable.md) + +### exceptions +- [ADR-0008 — Deterministic CHECK policy exceptions: schema-validated, annotate-only, never suppress](0008-check-exceptions-annotate-only.md) + ### git-flow - [ADR-0005 — Adopt Git Flow with direct-tag release automation](0005-adopt-git-flow-with-direct-tag-release-automation.md) +### governance +- [ADR-0008 — Deterministic CHECK policy exceptions: schema-validated, annotate-only, never suppress](0008-check-exceptions-annotate-only.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) @@ -41,6 +68,9 @@ ### mvp-scope - [ADR-0002 — Limit CHECK's conflict detection to structural evidence only](0002-limit-check-s-conflict-detection-to-structural-evidence-only.md) +### output-contract +- [ADR-0009 — --json is a documented no-op; CLI output is always JSON](0009-json-flag-always-json-contract.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) @@ -50,6 +80,9 @@ ### v0.2.0 - [ADR-0006 — Localize deterministic ADR generation through repository configuration](0006-localized-adr-generation.md) +- [ADR-0007 — Promote CHECK's kind-to-confidence mapping to a stable output field](0007-check-confidence-field.md) +- [ADR-0008 — Deterministic CHECK policy exceptions: schema-validated, annotate-only, never suppress](0008-check-exceptions-annotate-only.md) +- [ADR-0009 — --json is a documented no-op; CLI output is always JSON](0009-json-flag-always-json-contract.md) ## By affected path @@ -72,33 +105,54 @@ ### `AGENTS.md` - [ADR-0005 — Adopt Git Flow with direct-tag release automation](0005-adopt-git-flow-with-direct-tag-release-automation.md) +### `README.md` +- [ADR-0007 — Promote CHECK's kind-to-confidence mapping to a stable output field](0007-check-confidence-field.md) +- [ADR-0008 — Deterministic CHECK policy exceptions: schema-validated, annotate-only, never suppress](0008-check-exceptions-annotate-only.md) +- [ADR-0009 — --json is a documented no-op; CLI output is always JSON](0009-json-flag-always-json-contract.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) +### `examples/quickstart.md` +- [ADR-0007 — Promote CHECK's kind-to-confidence mapping to a stable output field](0007-check-confidence-field.md) + ### `scripts/sync_version.py` - [ADR-0005 — Adopt Git Flow with direct-tag release automation](0005-adopt-git-flow-with-direct-tag-release-automation.md) ### `skills/adr-toolkit/SKILL.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-0006 — Localize deterministic ADR generation through repository configuration](0006-localized-adr-generation.md) +- [ADR-0007 — Promote CHECK's kind-to-confidence mapping to a stable output field](0007-check-confidence-field.md) +- [ADR-0008 — Deterministic CHECK policy exceptions: schema-validated, annotate-only, never suppress](0008-check-exceptions-annotate-only.md) +- [ADR-0009 — --json is a documented no-op; CLI output is always JSON](0009-json-flag-always-json-contract.md) +- [ADR-0010 — Codex skill-creator's quick_validate.py incompatibility is not this project's problem](0010-codex-quick-validate-not-applicable.md) ### `skills/adr-toolkit/VERSION` - [ADR-0005 — Adopt Git Flow with direct-tag release automation](0005-adopt-git-flow-with-direct-tag-release-automation.md) ### `skills/adr-toolkit/references/conflict-rules.md` - [ADR-0002 — Limit CHECK's conflict detection to structural evidence only](0002-limit-check-s-conflict-detection-to-structural-evidence-only.md) +- [ADR-0007 — Promote CHECK's kind-to-confidence mapping to a stable output field](0007-check-confidence-field.md) +- [ADR-0008 — Deterministic CHECK policy exceptions: schema-validated, annotate-only, never suppress](0008-check-exceptions-annotate-only.md) ### `skills/adr-toolkit/schemas/adr.schema.json` - [ADR-0006 — Localize deterministic ADR generation through repository configuration](0006-localized-adr-generation.md) +### `skills/adr-toolkit/schemas/exception.schema.json` +- [ADR-0008 — Deterministic CHECK policy exceptions: schema-validated, annotate-only, never suppress](0008-check-exceptions-annotate-only.md) + ### `skills/adr-toolkit/scripts/adr.py` - [ADR-0006 — Localize deterministic ADR generation through repository configuration](0006-localized-adr-generation.md) +- [ADR-0008 — Deterministic CHECK policy exceptions: schema-validated, annotate-only, never suppress](0008-check-exceptions-annotate-only.md) +- [ADR-0009 — --json is a documented no-op; CLI output is always JSON](0009-json-flag-always-json-contract.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) +- [ADR-0007 — Promote CHECK's kind-to-confidence mapping to a stable output field](0007-check-confidence-field.md) +- [ADR-0008 — Deterministic CHECK policy exceptions: schema-validated, annotate-only, never suppress](0008-check-exceptions-annotate-only.md) ### `skills/adr-toolkit/scripts/commands/create.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) @@ -107,6 +161,9 @@ ### `skills/adr-toolkit/scripts/commands/diff.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/exception.py` +- [ADR-0008 — Deterministic CHECK policy exceptions: schema-validated, annotate-only, never suppress](0008-check-exceptions-annotate-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) - [ADR-0006 — Localize deterministic ADR generation through repository configuration](0006-localized-adr-generation.md) @@ -125,6 +182,9 @@ ### `skills/adr-toolkit/scripts/core/constraints.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/core/exceptions.py` +- [ADR-0008 — Deterministic CHECK policy exceptions: schema-validated, annotate-only, never suppress](0008-check-exceptions-annotate-only.md) + ### `skills/adr-toolkit/scripts/core/identifiers.py` - [ADR-0006 — Localize deterministic ADR generation through repository configuration](0006-localized-adr-generation.md) @@ -146,6 +206,12 @@ ### `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) +### `tests/integration/test_cli.py` +- [ADR-0009 — --json is a documented no-op; CLI output is always JSON](0009-json-flag-always-json-contract.md) + +### `tests/unit/test_check.py` +- [ADR-0007 — Promote CHECK's kind-to-confidence mapping to a stable output field](0007-check-confidence-field.md) + ## Chronological (newest first) - 2026-08-30 — [ADR-0001 — Record architecture decisions](0001-record-architecture-decisions.md) @@ -154,3 +220,7 @@ - 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) - 2026-08-30 — [ADR-0006 — Localize deterministic ADR generation through repository configuration](0006-localized-adr-generation.md) +- 2026-08-30 — [ADR-0007 — Promote CHECK's kind-to-confidence mapping to a stable output field](0007-check-confidence-field.md) +- 2026-08-30 — [ADR-0008 — Deterministic CHECK policy exceptions: schema-validated, annotate-only, never suppress](0008-check-exceptions-annotate-only.md) +- 2026-08-30 — [ADR-0009 — --json is a documented no-op; CLI output is always JSON](0009-json-flag-always-json-contract.md) +- 2026-08-30 — [ADR-0010 — Codex skill-creator's quick_validate.py incompatibility is not this project's problem](0010-codex-quick-validate-not-applicable.md) From d860a2189c4fc6f1bcf59b7abebd0964f9a1b738 Mon Sep 17 00:00:00 2001 From: shcommit Date: Sun, 30 Aug 2026 23:56:43 +0900 Subject: [PATCH 37/76] docs: bring handoff.md up to date with the branch rename and ADRs Recorded the branch rename (develop-2 -> feature/v0.2.0-multilingual- and-check-confidence), the Codex compatibility closeout, the stale branch deletion, and ADR-0007..0010. Flagged the deferred token-usage measurement idea as an open, undecided item for the next session. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01CeDZgjQakpnGGtsWoC7ijF --- handoff.md | 110 +++++++++++++++++++++++++++++------------------------ 1 file changed, 60 insertions(+), 50 deletions(-) diff --git a/handoff.md b/handoff.md index 922cfb3..11155ea 100644 --- a/handoff.md +++ b/handoff.md @@ -2,13 +2,16 @@ ## Current task (2026-08-30) -ADR Toolkit `v0.2.0` minor-release implementation is complete, and the -`improvements.md` P1 backlog has been worked down to just the two items that -require the owner's own action. Everything else in P1 that was actionable by -an agent is done. +ADR Toolkit `v0.2.0` minor-release implementation is complete. The +`improvements.md` P1 backlog has been fully worked down — every item that +was actionable by an agent is done, and the two that weren't (Codex +metadata compatibility, stale branch cleanup) were resolved with the +owner's decision/approval. This session's own design decisions have been +dogfooded as ADR-0007..0010. -The work runs in the linked worktree `develop-2`. Its base is the current -`origin/develop` history plus the approved design commits: +The work runs in the linked worktree at `/Users/yangseunghyeon/orca/workspaces/ADR-toolkit/develop-2` +(directory name unchanged; the branch itself was renamed). Its base is the +current `origin/develop` history plus the approved design commits: - `7b9bb8c` — initial localization/readiness design - `b69a784` — v0.2.0 release gates, repository config, CHECK confidence, and @@ -16,74 +19,81 @@ The work runs in the linked worktree `develop-2`. Its base is the current Baseline verification before implementation: `212 passed` on 2026-08-30. +**Branch renamed**: `develop-2` → `feature/v0.2.0-multilingual-and-check-confidence` +(local rename only; not yet pushed, no upstream configured). + ## Touched since the last handoff -- `b9e0ec3` — hardened `sync_version.py`: silent drift when a tracked - manifest loses its version key, a crash on a manifest path outside the - repo root, and non-ASCII manifest content being escaped on write. -- `baa0987` — split the manifest version-drift CI check into its own job - instead of running it on all 5 OS/Python matrix legs. -- `2f2a8de` — centralized ADR directory loading into - `core.adr_directory.iter_adr_files`, shared by related/index/validate/check. -- `061d44e` — corrected `adapters/codex/README.md`: the documented - install-time symlink step was unused by the actually-verified install path; - removed it and explained why `.codex-plugin/plugin.json` is structural-only. -- `5d37bef` — made `SKILL.md`'s frontmatter description the canonical source, - synced into every duplicating manifest by `sync_version.py`, fixing one - real drift in `.claude-plugin/plugin.json`. -- `63c4c85` — formalized `--json` as a documented no-op (output is always - JSON); added a regression test and fixed 3 docs that omitted the flag. -- `2c73af6` — promoted CHECK's confidence classification to a stable - `confidence` field (`VERIFIED`/`VIOLATED`/`UNVERIFIABLE`) on every finding, - computed from `kind` instead of left for the agent to re-derive from docs. -- `6e68b5d` — added a deterministic exception schema: `adr.py exception` - validates and records `owner`/`reason`/`scope`/`expiry`/`adr_id`/`rule_id` - as `docs/decisions/exceptions/EXC-NNNN.json`; CHECK annotates a matching, - non-expired exception onto a finding without ever suppressing or - downgrading it. +- `c995904` — closed the Codex metadata compatibility item as not + applicable: `quick_validate.py` belongs to Codex's local skill-creator + tool, never the actual plugin install path (independently re-verified). +- `b1a4ec5` — deleted the two stale remote branches + (`origin/SHcommit/feat-plan-adr-toolkit`, + `origin/feat/adr-toolkit-mvp-implement`); both were fully merged into + `origin/develop` with no open PR referencing either. +- `2fa6d67` — recorded ADR-0007..0010 for this session's own design + decisions, scored via `adr.py significance` before writing: + - ADR-0007 (10, recommended) — CHECK's `kind`→`confidence` mapping + promoted to a stable output field. + - ADR-0008 (12, recommended) — deterministic CHECK policy exceptions, + schema-validated and annotate-only, never suppressing a violation. + - ADR-0009 (7, recommended) — `--json` is a documented no-op; CLI output + is always JSON. + - ADR-0010 (4, optional) — Codex `quick_validate.py` incompatibility is + not this project's problem, with the verification evidence recorded. + +(Earlier in this session — still current, not re-listed in detail: `b9e0ec3` +sync_version.py hardening, `baa0987` CI dedup, `2f2a8de` ADR directory +centralization, `061d44e` Codex adapter doc fix, `5d37bef` manifest +description ownership, `63c4c85` `--json` implementation, `2c73af6` CHECK +confidence field implementation, `6e68b5d` exception schema implementation +— ADR-0007..0010 above are the *rationale records* for the last three of +these.) Latest local verification on 2026-08-30: - `python3 -m pytest -q` → `327 passed`, exit 0 - `python3 scripts/sync_version.py --check` → exit 0 +- `adr.py validate --dir docs/decisions` → `checked: 10`, no errors +- `adr.py index --dir docs/decisions` regenerated cleanly - `git status --short` → clean The detailed v0.2.0 evidence and maturity assessment are in `docs/adr-toolkit-v0.2.0-readiness-report.md` (not re-verified against the -latest commits above — re-run its evidence commands before treating its -GO/NO-GO judgment as current); post-release governance is kept separate in -`docs/enterprise-adoption.md`, whose §8 "다음 구현 후보" now reflects items 1, -3, and 4 as done. +commits since it was written — re-run its evidence commands before treating +its GO/NO-GO judgment as current); post-release governance is kept separate +in `docs/enterprise-adoption.md`, whose §8 "다음 구현 후보" now reflects +items 1, 3, and 4 as done. ## Next step -Two `improvements.md` P1 items remain, both requiring the owner, not an -agent: - -- **Codex metadata compatibility** — deliberately deferred by the owner - (2026-08-30); revisit only when asked. -- **Stale remote branch cleanup** (`origin/SHcommit/feat-plan-adr-toolkit`, - `origin/feat/adr-toolkit-mvp-implement`) — needs the owner's explicit - deletion approval; not attempted. - -Beyond that, the only remaining work is the P0 release gate: open the final -PR, get required CI green, get the owner's approval for the v0.2.0 version -bump, merge through a release branch, and verify the tag lands on the -intended `master` commit. `develop-2` has not been pushed to `origin` yet -(no upstream branch configured) — the owner has said they'll handle the -public-repository transition themselves later, and PR/push have not been -authorized in this session. +`improvements.md` has exactly one open item left: the P0 release gate. Open +the final PR (from `feature/v0.2.0-multilingual-and-check-confidence` +against `develop`), get required CI green, get the owner's approval for the +v0.2.0 version bump (`skills/adr-toolkit/VERSION` is still `0.1.0`), merge +through a release branch, and verify the tag lands on the intended `master` +commit. The branch has not been pushed to `origin` yet (no upstream +configured) — the owner has said they'll handle the public-repository +transition themselves later, and push/PR have not been authorized in this +session. `project-roadmap.md` was reviewed end to end: every item there is explicitly gated on usage evidence or a separate design decision that doesn't exist yet — nothing there is actionable now. +An open, undecided idea from this session: measuring how much LLM-token +input/output the deterministic core saves an agent versus doing the same +work by reading/writing everything by hand (e.g. CHECK returning a +structured finding instead of the agent scanning a full diff). Discussed but +deliberately deferred — no scope or destination (new doc section vs. +benchmarking script) has been decided. + ## Open risks - Catalog structure is deterministic, but terminology still benefits from native-speaker review in each supported language. - CHECK deliberately cannot prove prose, business rationale, or organizational - claims; those remain human-review evidence. The new exception mechanism + claims; those remain human-review evidence. The exception mechanism extends this: an exception is recorded and annotated, never a silent pass. - GitHub protection is intentionally unavailable in the current private plan; configure and API-verify it after the repository becomes public (owner's From 300456a954cdc333d90d7f9f150731ebe40a803c Mon Sep 17 00:00:00 2001 From: shcommit Date: Mon, 31 Aug 2026 01:25:23 +0900 Subject: [PATCH 38/76] docs: write the ADR search and relationship navigation design spec MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Design for adr.py search (keyword now matches body, not just title; tag/status filters; matched_in transparency), a fix to the same title-only keyword gap in related.py, and a Relationships section in the generated index (supersession chains, related lists) localized across all 8 catalogs. Scoped after researching how comparable OSS ADR tools handle this: npryce/adr-tools has no search at all; thomvaill/log4brains (1.5k stars) treats search and relationship visibility as its two headline features, without a graph renderer, semantic search, or directory sharding — those stay deferred in project-roadmap.md. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01CeDZgjQakpnGGtsWoC7ijF --- ...-31-adr-search-and-relationships-design.md | 164 ++++++++++++++++++ 1 file changed, 164 insertions(+) create mode 100644 docs/superpowers/specs/2026-08-31-adr-search-and-relationships-design.md diff --git a/docs/superpowers/specs/2026-08-31-adr-search-and-relationships-design.md b/docs/superpowers/specs/2026-08-31-adr-search-and-relationships-design.md new file mode 100644 index 0000000..4d19163 --- /dev/null +++ b/docs/superpowers/specs/2026-08-31-adr-search-and-relationships-design.md @@ -0,0 +1,164 @@ +# ADR Search and Relationship Navigation Design + +## Context and Problem + +ADR Toolkit ships as an open-source, multi-harness skill meant for other developers and +teams to adopt, not just for this repository's own 10 ADRs. `project-roadmap.md`'s "ADR +navigation and scale" item flagged this as future work, gated on evidence. Research into +comparable real-world tools (`npryce/adr-tools`, the classic flat-file CLI with no search; +`thomvaill/log4brains`, 1.5k GitHub stars, the most-adopted actively maintained ADR tool) +shows the two features that actually matter for any team running an ADR practice over time +are **search** and **relationship visibility** — not folder restructuring, not semantic +search, not a web UI. Those two are proven table-stakes for any team, regardless of current +repository size; building them now is not premature relative to real adopters, even though +this repository itself only has 10 ADRs today. + +Two concrete gaps exist today: + +1. `related.py` — the only existing lookup command — matches a keyword against `title` + only, never `body`. A decision phrase that lives in the rationale/context, not the + title, cannot be found. +2. The generated `docs/decisions/README.md` shows *that* an ADR is `superseded` (under "By + status") but never shows *what superseded it* without opening the file. `related`/ + `supersedes` links are invisible from the index. + +## Goals + +- Let a human or an agent find an existing ADR by keyword (title **and** body), tag, or + status, without opening every file. +- Make supersession and relatedness visible directly in the generated index. +- Fix `related`'s title-only keyword gap as part of the same change, since it's the same + underlying defect. +- Keep the CLI/agent-native shape this project already has: no web server, no static site, + no new storage format. + +## Non-goals (stay in `project-roadmap.md`, unchanged) + +- Rendered graph images / DOT / Mermaid visualization of relationships. +- Full-text indexing, ranking, or semantic/vector search. +- Directory sharding or an alternate storage layout. + +These remain explicitly deferred until real usage (per the roadmap's own stated +threshold) demonstrates substring/tag/status matching and a text-based relationship list +are insufficient. + +## Components + +### 1. `scripts/core/search.py` — shared matching primitives + +New module, reused by both `related` and the new `search` command: + +```python +def matches_keyword(keyword: str, title: str, body: str) -> bool +def matches_tags(query_tags: set, entry_tags: list) -> set # returns the overlap +def matches_paths(query_paths: set, affected_paths: list) -> set # returns the overlap +``` + +`matches_keyword` is the fix: case-insensitive substring match against `title` **or** +`body`, not title alone. `matches_tags`/`matches_paths` extract `related.py`'s existing +set-intersection logic unchanged, so `related.py` becomes a thin caller of this module +instead of owning duplicate logic. + +### 2. `related.py` — fixed, not replaced + +Update its keyword check to call `search.matches_keyword(keyword, title, body)` instead of +`keyword in data.get("title", "").lower()`. No interface change — same command, same +output shape, same DISCOVER-stage purpose (finding conflicts/precedent for a *new* draft +under RECORD). A regression test proves a body-only match now succeeds. + +### 3. `scripts/commands/search.py` — new command + +``` +adr.py search [--keyword TEXT] [--tag TAG ...] [--status STATUS] --dir docs/decisions --json +``` + +Distinct from `related` in purpose and default behavior: `search` is for ad-hoc lookup +(by a human at a terminal, or an agent answering "have we decided this before?"), not tied +to drafting a new ADR. With **no filters given, it returns every non-skip ADR** (browse +mode) — the opposite of `related`'s current "no filters -> no matches" behavior, which is +correct for *its* purpose (nothing to relate to) but wrong for a general search command +(an empty query should list, not return nothing). + +Result shape: + +```json +{ + "ok": true, + "operation": "search", + "count": 2, + "results": [ + { + "id": "ADR-0007", + "filename": "0007-check-confidence-field.md", + "title": "Promote CHECK's kind-to-confidence mapping to a stable output field", + "status": "accepted", + "tags": ["check", "confidence", "v0.2.0"], + "matched_in": ["title", "tags"] + } + ], + "warnings": [] +} +``` + +`matched_in` lists which field(s) caused the match (`title`, `body`, `tags`, `status`), +kept for the same transparency reason CHECK findings carry `reasons` — a consumer should +never have to trust a match blindly. + +Uses `core.adr_directory.iter_adr_files` for the walk and `frontmatter.parse` for +frontmatter, matching every other command's pattern. A malformed ADR degrades to a +`BAD_FRONTMATTER` warning, same as `related`/`check`/`validate`. + +### 4. `index.py` — Relationships section + +New generated section, after "Chronological", localized like every other section header: + +``` +## Relationships + +### Supersession chains +- ADR-0003 → superseded by → ADR-0006 + +### Related +- ADR-0007 related to: ADR-0002 +``` + +Only ADRs that actually carry a `supersedes`/`superseded_by`/non-empty `related` are +listed — an ADR with none of those is omitted from this section entirely (no noise). +`entries` in `run()` gains `related`, `supersedes`, `superseded_by` from frontmatter +(currently not collected). + +New i18n keys (`relationships`, `supersession_chains`, `related`, `superseded_by`, +`related_to`), added to `FALLBACK_STRINGS` and all 8 locale catalogs +(`skills/adr-toolkit/scripts/i18n/{en,ko,ja,zh,fr,es,de,pt-BR}.json`), following the exact +pattern `by_status`/`by_tag` already established. + +## Testing + +- `tests/unit/test_search_module.py` — `matches_keyword`/`matches_tags`/`matches_paths` in + isolation. +- `tests/unit/test_search_command.py` — keyword-in-title, keyword-in-body, tag filter, + status filter, no-filter-returns-all, `matched_in` correctness, malformed-ADR warning. +- `tests/unit/test_related.py` — new regression test: keyword present only in body now + matches. +- `tests/unit/test_index.py` — Relationships section renders a supersession chain and a + related list; an ADR with no relationships is absent from the section. +- `tests/unit/test_locale.py` — extended to cover the five new keys in the existing + 8-catalog exact-key-set parity check. +- `tests/integration/test_localized_workflow.py` or equivalent — `search` and the new + index section produce valid output in at least one non-English locale. + +## Documentation + +- `README.md` — add `search` alongside the existing `related`/`check` usage examples. +- `skills/adr-toolkit/SKILL.md` — document when an agent should reach for `search` versus + `related` (ad-hoc lookup vs. DISCOVER-stage conflict check). +- Run every newly documented command in a scratch repository before treating the docs as + accurate, matching this project's existing practice. + +## Open question for implementation + +None outstanding — scope, interfaces, and file list above are considered final for this +spec. `writing-plans` should sequence: (1) `core/search.py` + its tests, (2) `related.py` +fix + regression test, (3) `search` command + tests + CLI wiring, (4) `index.py` +Relationships section + 8-locale catalog updates + tests, (5) docs, (6) full suite + +`sync_version.py --check` + a real scratch-repo run of every new documented command. From f8b64232a47660c1c9f42d311048a308d335b43b Mon Sep 17 00:00:00 2001 From: shcommit Date: Mon, 31 Aug 2026 01:36:40 +0900 Subject: [PATCH 39/76] docs: revise the ADR search/relationships spec per external review Incorporated a detailed external review after verifying its claims against the actual codebase: - core/search.py split into core/query.py (matching + deterministic ranking) and core/relationships.py (canonical Relationship model). - Explicit AND-across-fields/OR-within-field semantics for `search`; verified related.py's existing behavior is actually OR-across-fields (a broad discovery net) and kept it that way rather than unifying policy, since that's real, tested, current behavior. - `--path` on `search` uses directory-boundary/glob "governed by" matching (same as CHECK), not related.py's exact frontmatter-list matching -- verified these are genuinely different questions. - Found and fixed a layering problem in the review's own suggestion: reusing conflict.py's path-prefix logic from core/query.py would make core depend on rules, a new and wrong direction. Extracted it into core/globs.py instead, which both already import. - validate.py gets two new relationship-integrity error codes (consolidated from the review's three), matching its existing errors-only contract rather than introducing a new warnings concept. - Deterministic ranking tiers, not the review's literal point values, and no ranking score exposed as a public JSON field. - --limit/total/truncated, path field, --tags (not --tag) for CLI consistency with related.py's existing flag. Cycle detection, graph rendering, semantic search, sharding, and a standalone relationships/--id lookup command remain deferred to project-roadmap.md. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01CeDZgjQakpnGGtsWoC7ijF --- ...-31-adr-search-and-relationships-design.md | 254 ++++++++++++++---- 1 file changed, 195 insertions(+), 59 deletions(-) diff --git a/docs/superpowers/specs/2026-08-31-adr-search-and-relationships-design.md b/docs/superpowers/specs/2026-08-31-adr-search-and-relationships-design.md index 4d19163..455b800 100644 --- a/docs/superpowers/specs/2026-08-31-adr-search-and-relationships-design.md +++ b/docs/superpowers/specs/2026-08-31-adr-search-and-relationships-design.md @@ -13,71 +13,148 @@ search, not a web UI. Those two are proven table-stakes for any team, regardless repository size; building them now is not premature relative to real adopters, even though this repository itself only has 10 ADRs today. -Two concrete gaps exist today: +Guiding principle for this design: **keep ADR storage boring; make ADR retrieval +powerful.** Storage stays flat Markdown + frontmatter + Git — nothing here changes that. +Retrieval gets a real, shared, deterministic core that both a human at a terminal and an +agent consume identically. + +Three concrete gaps exist today: 1. `related.py` — the only existing lookup command — matches a keyword against `title` only, never `body`. A decision phrase that lives in the rationale/context, not the title, cannot be found. 2. The generated `docs/decisions/README.md` shows *that* an ADR is `superseded` (under "By - status") but never shows *what superseded it* without opening the file. `related`/ - `supersedes` links are invisible from the index. + status") but never shows *what superseded it*, and never shows `related` links at all, + without opening the file. +3. `validate.py` checks that `related` IDs point at real ADRs (`BROKEN_RELATED_LINK`), but + `supersedes`/`superseded_by` are never checked for existing or mutually consistent — + `adr.py supersede` writes both sides atomically today, but nothing catches it if a + frontmatter field is later hand-edited into an inconsistent state. ## Goals -- Let a human or an agent find an existing ADR by keyword (title **and** body), tag, or - status, without opening every file. -- Make supersession and relatedness visible directly in the generated index. +- Let a human or an agent find an existing ADR by keyword (title **and** body), tag, + status, or the file path it governs — without opening every file. +- Return results in a deterministic, best-match-first order. +- Keep result size and shape safe for an agent's context window (`--limit`, `total`, + `truncated`). +- Make supersession and relatedness visible directly in the generated index, by title not + just ID. +- Catch broken or inconsistent relationship metadata as a structural validation error, the + same way a broken `related` link already is. - Fix `related`'s title-only keyword gap as part of the same change, since it's the same - underlying defect. + underlying defect — without changing its existing matching *policy*. - Keep the CLI/agent-native shape this project already has: no web server, no static site, - no new storage format. + no new storage format, no ranking model beyond simple deterministic tiers. ## Non-goals (stay in `project-roadmap.md`, unchanged) - Rendered graph images / DOT / Mermaid visualization of relationships. -- Full-text indexing, ranking, or semantic/vector search. +- Full-text indexing, ranking algorithms (BM25/TF-IDF), or semantic/vector search. +- Relationship cycle detection (the model below doesn't block adding it later; it's simply + not built now — no cycle has ever occurred in this repository's 10 ADRs). - Directory sharding or an alternate storage layout. +- A standalone `adr.py relationships ` command or `--id` exact lookup — small, + plausible near-future additions, deliberately left out of this spec to keep it focused. These remain explicitly deferred until real usage (per the roadmap's own stated -threshold) demonstrates substring/tag/status matching and a text-based relationship list -are insufficient. +threshold) demonstrates the deterministic, substring-based approach here is insufficient. ## Components -### 1. `scripts/core/search.py` — shared matching primitives - -New module, reused by both `related` and the new `search` command: +### 1. `scripts/core/query.py` — shared matching and ranking primitives ```python def matches_keyword(keyword: str, title: str, body: str) -> bool -def matches_tags(query_tags: set, entry_tags: list) -> set # returns the overlap -def matches_paths(query_paths: set, affected_paths: list) -> set # returns the overlap +def matches_tags_any(query_tags: set, entry_tags: list) -> set # overlap, used by related's OR policy +def matches_paths_exact(query_paths: set, affected_paths: list) -> set # overlap, used by related's OR policy +def path_governed_by(path: str, affected_paths: list) -> bool # prefix/glob overlap, used by search --path +def rank_key(entry: dict, keyword: str) -> tuple # deterministic best-match-first sort key +``` + +`matches_keyword` is the bug fix: case-insensitive substring match against `title` **or** +`body`, not title alone. + +`matches_tags_any`/`matches_paths_exact` are `related.py`'s existing set-intersection logic, +extracted unchanged — this is the matching policy a broad, "cast a wide net before +drafting" discovery command wants. + +`path_governed_by` is a **different, new** matching policy: does a single real file path +fall under any of an ADR's `affected_paths`, using the same directory-boundary prefix and +glob logic `scripts/rules/conflict.py`'s `affected_paths_overlap`/`_path_under` already use +for CHECK. This is deliberately not the same function as `matches_paths_exact` — "does this +ADR's frontmatter literally list this path" (what `related` needs when comparing a new +draft's exact `affected_paths`) and "does this real file fall under an ADR's governed +scope" (what `search --path` needs when an agent asks "does anything govern +`src/payment/x.py`?") are genuinely different questions. + +Layering note: `rules/conflict.py` already imports `core/globs.py` (nothing in `core/` +currently imports from `rules/`, and this spec doesn't introduce that direction). So +`_path_under` moves *into* `core/globs.py` as a new public `globs.path_under(path, prefix)` +— `conflict.py` is refactored to call it instead of owning a private copy, and +`query.path_governed_by` calls the same function. One implementation, no new dependency +direction between `core` and `rules`. + +`rank_key` orders results deterministically, best match first, using fixed tiers (highest to +lowest): exact ID match, exact title match, title substring match, tag exact match, body +substring match. No numeric score is exposed in the JSON output — the tiering is an +internal sort key, not a stable public contract, avoiding over-committing to arbitrary +weights. + +### 2. `scripts/core/relationships.py` — canonical relationship model + +```python +class Relationship(NamedTuple): + source: str # ADR id + type: str # "related" | "supersedes" | "superseded_by" + target: str + +def resolve(entries: list) -> list[Relationship] +def missing_targets(relationships: list, known_ids: set) -> list[Relationship] +def supersession_mismatches(relationships: list) -> list[tuple] # (supersedes_edge, expected_but_missing_or_wrong) ``` -`matches_keyword` is the fix: case-insensitive substring match against `title` **or** -`body`, not title alone. `matches_tags`/`matches_paths` extract `related.py`'s existing -set-intersection logic unchanged, so `related.py` becomes a thin caller of this module -instead of owning duplicate logic. +`resolve()` turns each ADR's `related`/`supersedes`/`superseded_by` frontmatter into a flat +list of edges. `index.py`, `validate.py`, and any future consumer (a graph renderer, an MCP +tool) read this same list instead of each re-deriving relationship data from raw frontmatter +independently — this is the one piece of real structural investment in this spec, and it's +what keeps `index.py`'s section from becoming ad-hoc string-building that a future feature +would have to reverse-engineer. -### 2. `related.py` — fixed, not replaced +### 3. `related.py` — fixed, not replaced, policy unchanged -Update its keyword check to call `search.matches_keyword(keyword, title, body)` instead of -`keyword in data.get("title", "").lower()`. No interface change — same command, same -output shape, same DISCOVER-stage purpose (finding conflicts/precedent for a *new* draft -under RECORD). A regression test proves a body-only match now succeeds. +Updated to import `matches_keyword`, `matches_tags_any`, `matches_paths_exact` from +`core/query.py` instead of owning duplicate logic. Keyword now matches body too (the fix). +**Combination policy is unchanged**: a path OR a tag OR a keyword match is enough to +include an ADR ("related" wants a broad net before drafting) — this is `related`'s existing, +tested behavior and this spec does not change it. A regression test proves a body-only +keyword match now succeeds; all thirteen existing `related` tests must keep passing +unmodified. -### 3. `scripts/commands/search.py` — new command +### 4. `scripts/commands/search.py` — new command ``` -adr.py search [--keyword TEXT] [--tag TAG ...] [--status STATUS] --dir docs/decisions --json +adr.py search [--keyword TEXT] [--tags TAG [TAG ...]] [--status STATUS] [--path PATH] + [--limit N] --dir docs/decisions --json ``` -Distinct from `related` in purpose and default behavior: `search` is for ad-hoc lookup -(by a human at a terminal, or an agent answering "have we decided this before?"), not tied -to drafting a new ADR. With **no filters given, it returns every non-skip ADR** (browse -mode) — the opposite of `related`'s current "no filters -> no matches" behavior, which is -correct for *its* purpose (nothing to relate to) but wrong for a general search command -(an empty query should list, not return nothing). +`--tags` matches `related.py`'s existing flag name and `nargs="*"` shape for CLI +consistency (space-separated values under one flag, not a repeatable `--tag`). + +Distinct purpose from `related`: general lookup ("what ADRs exist?"), not tied to drafting. +Two policy differences from `related`, both deliberate: + +- **Filters across different fields combine with AND; multiple values within the same + field combine with OR.** `--keyword database --tags postgres mysql --status + accepted` means: keyword matches AND (tag is postgres OR mysql) AND status is accepted. + This must be documented explicitly in `--help` and the README — an agent must never have + to guess implicit CLI semantics. +- **No filters given returns every non-skip ADR** (browse mode), the opposite of + `related`'s "no filters -> no matches." An empty query on a lookup command should list, + not return nothing. + +`--path` uses `query.path_governed_by`, matching CHECK's real-file-vs-governed-scope +semantics, not `related`'s exact frontmatter-list semantics. Result shape: @@ -85,11 +162,15 @@ Result shape: { "ok": true, "operation": "search", + "query": {"keyword": "confidence", "tags": ["check"], "status": null, "path": null, "limit": null}, "count": 2, + "total": 2, + "truncated": false, "results": [ { "id": "ADR-0007", "filename": "0007-check-confidence-field.md", + "path": "docs/decisions/0007-check-confidence-field.md", "title": "Promote CHECK's kind-to-confidence mapping to a stable output field", "status": "accepted", "tags": ["check", "confidence", "v0.2.0"], @@ -100,48 +181,91 @@ Result shape: } ``` -`matched_in` lists which field(s) caused the match (`title`, `body`, `tags`, `status`), -kept for the same transparency reason CHECK findings carry `reasons` — a consumer should -never have to trust a match blindly. +`matched_in` lists which field(s) caused the match (`title`, `body`, `tags`, `status`, +`path`) — kept exactly as originally designed, for the same transparency reason CHECK +findings carry `reasons`: a consumer should never have to trust a match blindly. Detailed +per-field match evidence (e.g. which substring matched) is deferred — `matched_in` alone is +enough for MVP. + +`--limit N` truncates `results` to N entries (already rank-ordered, so truncation drops the +weakest matches, not arbitrary ones); `total` is always the untruncated match count; +`truncated` is `true` iff `total > count`. No default limit — unlimited unless requested, +matching every other command's current behavior; the fields exist so a harness *can* bound +its own consumption, not so the tool imposes one. Uses `core.adr_directory.iter_adr_files` for the walk and `frontmatter.parse` for frontmatter, matching every other command's pattern. A malformed ADR degrades to a `BAD_FRONTMATTER` warning, same as `related`/`check`/`validate`. -### 4. `index.py` — Relationships section +### 5. `index.py` — Relationships section -New generated section, after "Chronological", localized like every other section header: +New generated section, after "Chronological", built from `relationships.resolve()` instead +of ad-hoc string building, localized like every other section header: ``` ## Relationships ### Supersession chains -- ADR-0003 → superseded by → ADR-0006 +- ADR-0003 "Localize only index.py's generated strings..." → superseded by → ADR-0006 "Localize deterministic ADR generation..." ### Related -- ADR-0007 related to: ADR-0002 +- ADR-0007 "Promote CHECK's kind-to-confidence..." related to: ADR-0002 "Limit CHECK's conflict detection..." ``` -Only ADRs that actually carry a `supersedes`/`superseded_by`/non-empty `related` are -listed — an ADR with none of those is omitted from this section entirely (no noise). -`entries` in `run()` gains `related`, `supersedes`, `superseded_by` from frontmatter -(currently not collected). +Titles are shown alongside IDs (not IDs alone) — a developer reading the generated README +should understand a relationship without opening either file. Only ADRs that actually carry +a `supersedes`/`superseded_by`/non-empty `related` are listed; an ADR with none of those is +omitted entirely (no noise). `entries` in `run()` gains `related`, `supersedes`, +`superseded_by` from frontmatter (currently not collected). New i18n keys (`relationships`, `supersession_chains`, `related`, `superseded_by`, `related_to`), added to `FALLBACK_STRINGS` and all 8 locale catalogs (`skills/adr-toolkit/scripts/i18n/{en,ko,ja,zh,fr,es,de,pt-BR}.json`), following the exact pattern `by_status`/`by_tag` already established. +### 6. `validate.py` — relationship integrity + +Extends the existing `known_ids`/`BROKEN_RELATED_LINK` check (which today only covers +`related`) using `relationships.resolve()` and `relationships.missing_targets()`/ +`supersession_mismatches()`: + +- `BROKEN_SUPERSESSION_LINK` — a `supersedes` or `superseded_by` value references an ADR ID + that doesn't exist among `known_ids`. Same severity and shape as the existing + `BROKEN_RELATED_LINK`. +- `SUPERSESSION_MISMATCH` — ADR A's `supersedes` lists B, but B's `superseded_by` isn't A + (missing or pointing elsewhere). Catches exactly the case `adr.py supersede` normally + prevents by writing both sides atomically, but a hand-edit could still produce. + +Both are **errors**, not warnings — `validate.py` has no warnings mechanism today (unlike +`check.py`/`index.py`), and introducing one for two checks would be disproportionate new +infrastructure. This matches the existing `BROKEN_RELATED_LINK`'s severity exactly, so no +new severity concept is introduced. + +Two codes, not three: `missing_targets()` alone would suggest a third +`MISSING_RELATIONSHIP_TARGET` code distinct from `BROKEN_SUPERSESSION_LINK`, but a missing +supersession target *is* a broken supersession link — one code covers it. + ## Testing -- `tests/unit/test_search_module.py` — `matches_keyword`/`matches_tags`/`matches_paths` in - isolation. -- `tests/unit/test_search_command.py` — keyword-in-title, keyword-in-body, tag filter, - status filter, no-filter-returns-all, `matched_in` correctness, malformed-ADR warning. +- `tests/unit/test_globs.py`/`test_conflict.py` — extended for `globs.path_under()`; + existing `conflict.affected_paths_overlap` tests must pass unmodified after the refactor. +- `tests/unit/test_query.py` — `matches_keyword` (title-only, body-only, neither), + `matches_tags_any`, `matches_paths_exact`, `path_governed_by` (directory-boundary + prefix, glob, and non-match cases), `rank_key` ordering across all five tiers. +- `tests/unit/test_relationships.py` — `resolve()` produces the expected edge list; + `missing_targets()`/`supersession_mismatches()` against fixture data with a dangling + target and a one-sided supersession. +- `tests/unit/test_search_command.py` — keyword-in-title, keyword-in-body, tag filter + (OR within field), AND across different fields, status filter, `--path` using governed-by + semantics, no-filter-returns-all, `--limit`/`total`/`truncated`, `matched_in` correctness, + deterministic ordering, malformed-ADR warning. - `tests/unit/test_related.py` — new regression test: keyword present only in body now - matches. + matches; all existing tests unmodified and still passing (policy unchanged). - `tests/unit/test_index.py` — Relationships section renders a supersession chain and a - related list; an ADR with no relationships is absent from the section. + related list with titles; an ADR with no relationships is absent from the section. +- `tests/unit/test_validate.py` — `BROKEN_SUPERSESSION_LINK` for a dangling supersedes/ + superseded_by target; `SUPERSESSION_MISMATCH` for a one-sided edit; the real repository's + own 10 ADRs still validate clean (no existing supersession is broken). - `tests/unit/test_locale.py` — extended to cover the five new keys in the existing 8-catalog exact-key-set parity check. - `tests/integration/test_localized_workflow.py` or equivalent — `search` and the new @@ -149,16 +273,28 @@ pattern `by_status`/`by_tag` already established. ## Documentation -- `README.md` — add `search` alongside the existing `related`/`check` usage examples. +- `README.md` — add `search` alongside the existing `related`/`check` usage examples, + including the explicit AND/OR filter-semantics statement. - `skills/adr-toolkit/SKILL.md` — document when an agent should reach for `search` versus - `related` (ad-hoc lookup vs. DISCOVER-stage conflict check). + `related` (ad-hoc lookup vs. DISCOVER-stage conflict check), framed as: `related` is a + discovery *policy* built on the same Query Engine `search` uses generically. - Run every newly documented command in a scratch repository before treating the docs as accurate, matching this project's existing practice. -## Open question for implementation - -None outstanding — scope, interfaces, and file list above are considered final for this -spec. `writing-plans` should sequence: (1) `core/search.py` + its tests, (2) `related.py` -fix + regression test, (3) `search` command + tests + CLI wiring, (4) `index.py` -Relationships section + 8-locale catalog updates + tests, (5) docs, (6) full suite + -`sync_version.py --check` + a real scratch-repo run of every new documented command. +## Implementation sequencing + +1. Extract `_path_under` from `rules/conflict.py` into `core/globs.py` as + `globs.path_under()`; refactor `conflict.affected_paths_overlap` to call it. Full + `test_conflict.py`/`test_check.py` suites must still pass unmodified — pure refactor, + no behavior change. +2. `core/query.py` + tests (no command changes yet). +3. `core/relationships.py` + tests (no command changes yet). +4. `related.py` migrated onto `core/query.py`, body-search regression test added — verify + all existing tests still pass unmodified. +5. `search` command + CLI wiring + tests. +6. `index.py` Relationships section + 8-locale catalog updates + tests. +7. `validate.py` relationship-integrity checks + tests, run against this repository's real + 10 ADRs to confirm no existing supersession is inadvertently flagged. +8. Docs (README, SKILL.md) + a real scratch-repo run of every newly documented command. +9. Full suite + `sync_version.py --check` + `adr.py validate`/`index` against this + repository's real ADRs. From 4db7fb8c0f17bcba42d96bc0166510c6c57da5cb Mon Sep 17 00:00:00 2001 From: shcommit Date: Mon, 31 Aug 2026 01:54:16 +0900 Subject: [PATCH 40/76] docs: record the approved search/relationships spec in handoff.md Owner approved the design; recorded scope, sequencing, and that the required next step per the brainstorming skill's architectural path is writing-plans, not implementation code directly. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01CeDZgjQakpnGGtsWoC7ijF --- handoff.md | 51 +++++++++++++++++++++++++++++++++++++-------------- 1 file changed, 37 insertions(+), 14 deletions(-) diff --git a/handoff.md b/handoff.md index 11155ea..cc135d1 100644 --- a/handoff.md +++ b/handoff.md @@ -1,6 +1,6 @@ # handoff.md -## Current task (2026-08-30) +## Current task (2026-08-31) ADR Toolkit `v0.2.0` minor-release implementation is complete. The `improvements.md` P1 backlog has been fully worked down — every item that @@ -9,6 +9,11 @@ metadata compatibility, stale branch cleanup) were resolved with the owner's decision/approval. This session's own design decisions have been dogfooded as ADR-0007..0010. +Now implementing an additional, owner-approved feature: ADR search and +relationship navigation (see "Next step" below) — not part of v0.2.0's +original scope, taken on after the owner asked to improve ADR +navigability for real-world OSS adoption. + The work runs in the linked worktree at `/Users/yangseunghyeon/orca/workspaces/ADR-toolkit/develop-2` (directory name unchanged; the branch itself was renamed). Its base is the current `origin/develop` history plus the approved design commits: @@ -67,19 +72,37 @@ items 1, 3, and 4 as done. ## Next step -`improvements.md` has exactly one open item left: the P0 release gate. Open -the final PR (from `feature/v0.2.0-multilingual-and-check-confidence` -against `develop`), get required CI green, get the owner's approval for the -v0.2.0 version bump (`skills/adr-toolkit/VERSION` is still `0.1.0`), merge -through a release branch, and verify the tag lands on the intended `master` -commit. The branch has not been pushed to `origin` yet (no upstream -configured) — the owner has said they'll handle the public-repository -transition themselves later, and push/PR have not been authorized in this -session. - -`project-roadmap.md` was reviewed end to end: every item there is explicitly -gated on usage evidence or a separate design decision that doesn't exist yet -— nothing there is actionable now. +**In progress**: implementing the ADR search + relationship navigation design +from `docs/superpowers/specs/2026-08-31-adr-search-and-relationships-design.md` +(committed `300456a`, revised after external review verification `f8b6423`, +owner-approved). This came from `project-roadmap.md`'s "ADR navigation and +scale" item, promoted out of roadmap after research into comparable OSS ADR +tools (`npryce/adr-tools`, `thomvaill/log4brains`) showed search + relationship +visibility are proven table-stakes regardless of this repo's current 10-ADR +size. Scope: `core/globs.py` gains `path_under()` (extracted from +`rules/conflict.py`), new `core/query.py` + `core/relationships.py`, `related.py` +fixed (body-search) with policy unchanged, new `search` command, `index.py` +Relationships section (8-locale), `validate.py` relationship-integrity checks. +9-step sequencing is in the spec's "Implementation sequencing" section — follow +it in order, each step's own tests green before the next. Next action: invoke +`writing-plans` to turn the spec into a task-level plan (per the brainstorming +skill's architectural path, this is the required next step before writing any +implementation code). + +Once that work lands: `improvements.md` still has exactly one open item, the +P0 release gate. Open the final PR (from +`feature/v0.2.0-multilingual-and-check-confidence` against `develop`), get +required CI green, get the owner's approval for the v0.2.0 version bump +(`skills/adr-toolkit/VERSION` is still `0.1.0`), merge through a release +branch, and verify the tag lands on the intended `master` commit. The branch +has not been pushed to `origin` yet (no upstream configured) — the owner has +said they'll handle the public-repository transition themselves later, and +push/PR have not been authorized in this session. + +`project-roadmap.md`'s remaining items (everything except "ADR navigation and +scale," now superseded by the spec above) were reviewed end to end: each is +explicitly gated on usage evidence or a separate design decision that doesn't +exist yet — nothing else there is actionable now. An open, undecided idea from this session: measuring how much LLM-token input/output the deterministic core saves an agent versus doing the same From 20254cfd3b2462faab18d785c8d815be8149471f Mon Sep 17 00:00:00 2001 From: shcommit Date: Mon, 31 Aug 2026 02:03:39 +0900 Subject: [PATCH 41/76] refactor: extract path_under into core/globs.py Moves the directory-boundary prefix-matching primitive CHECK's affected_paths_overlap already used out of rules/conflict.py's private _path_under into core/globs.py's public path_under(), so the upcoming search command's --path filter can reuse it without core depending on rules (nothing in core currently imports from rules). Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01CeDZgjQakpnGGtsWoC7ijF --- skills/adr-toolkit/scripts/core/globs.py | 8 ++++++++ skills/adr-toolkit/scripts/rules/conflict.py | 10 +--------- tests/unit/test_globs.py | 20 ++++++++++++++++++++ 3 files changed, 29 insertions(+), 9 deletions(-) diff --git a/skills/adr-toolkit/scripts/core/globs.py b/skills/adr-toolkit/scripts/core/globs.py index 1b14479..4cb3ff5 100644 --- a/skills/adr-toolkit/scripts/core/globs.py +++ b/skills/adr-toolkit/scripts/core/globs.py @@ -11,6 +11,14 @@ def match(pattern: str, path: str) -> bool: return re.match(_translate(pattern), path) is not None +def path_under(path: str, prefix: str) -> bool: + """Directory-boundary prefix match: "src/db" governs "src/db/x.py" but + not "src/db2/file.py".""" + if path == prefix: + return True + return path.startswith(prefix.rstrip("/") + "/") + + def _translate(pattern: str) -> str: parts = [] i, n = 0, len(pattern) diff --git a/skills/adr-toolkit/scripts/rules/conflict.py b/skills/adr-toolkit/scripts/rules/conflict.py index 89af3c6..d6640f2 100644 --- a/skills/adr-toolkit/scripts/rules/conflict.py +++ b/skills/adr-toolkit/scripts/rules/conflict.py @@ -41,21 +41,13 @@ def _as_list(value) -> list: def affected_paths_overlap(diff_files: list, affected_paths) -> bool: touched = _touched_paths(diff_files) return any( - _path_under(diff_path, ap) or globs.match(ap, diff_path) + globs.path_under(diff_path, ap) or globs.match(ap, diff_path) for diff_path in touched for ap in _as_list(affected_paths) if isinstance(ap, str) ) -def _path_under(diff_path: str, affected_path: str) -> bool: - # Prefix matching must respect directory boundaries: "src/db" governs - # "src/db/x.py" but not "src/db2/file.py". - if diff_path == affected_path: - return True - return diff_path.startswith(affected_path.rstrip("/") + "/") - - def _touched_paths(diff_files: list) -> set: return {f["path"] for f in diff_files} diff --git a/tests/unit/test_globs.py b/tests/unit/test_globs.py index f5464b9..688bab7 100644 --- a/tests/unit/test_globs.py +++ b/tests/unit/test_globs.py @@ -26,3 +26,23 @@ def test_double_star_prefix_matches_any_depth(): 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") + + +def test_path_under_matches_exact_path(): + assert globs.path_under("src/db", "src/db") is True + + +def test_path_under_matches_nested_path(): + assert globs.path_under("src/db/x.py", "src/db") is True + + +def test_path_under_matches_with_trailing_slash_prefix(): + assert globs.path_under("src/db/x.py", "src/db/") is True + + +def test_path_under_respects_directory_boundaries(): + assert globs.path_under("src/db2/file.py", "src/db") is False + + +def test_path_under_false_for_unrelated_path(): + assert globs.path_under("src/other/x.py", "src/db") is False From 7c95692b0ac7aa94bef278de628011b7a8cf867f Mon Sep 17 00:00:00 2001 From: shcommit Date: Mon, 31 Aug 2026 02:04:04 +0900 Subject: [PATCH 42/76] feat: add core/query.py matching primitives matches_keyword fixes the title-only search gap (matches body too). matches_tags_any/matches_paths_exact extract related.py's existing set-intersection logic unchanged. path_governed_by is new: does a real file path fall under an ADR's governed scope, using the same directory-boundary prefix + glob logic CHECK uses -- a different question from matches_paths_exact's frontmatter-literal-list check. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01CeDZgjQakpnGGtsWoC7ijF --- skills/adr-toolkit/scripts/core/query.py | 28 +++++++++++++ tests/unit/test_query.py | 50 ++++++++++++++++++++++++ 2 files changed, 78 insertions(+) create mode 100644 skills/adr-toolkit/scripts/core/query.py create mode 100644 tests/unit/test_query.py diff --git a/skills/adr-toolkit/scripts/core/query.py b/skills/adr-toolkit/scripts/core/query.py new file mode 100644 index 0000000..c53eff6 --- /dev/null +++ b/skills/adr-toolkit/scripts/core/query.py @@ -0,0 +1,28 @@ +"""Shared ADR matching primitives for related.py and search.py. + +related.py wants a broad net (any single field overlap is enough); search.py +wants precise filtering (all given fields must match). Both call the same +functions here; the combination policy lives in each command, not here. +""" +from scripts.core import globs + + +def matches_keyword(keyword: str, title: str, body: str) -> bool: + keyword = keyword.lower() + return keyword in (title or "").lower() or keyword in (body or "").lower() + + +def matches_tags_any(query_tags: set, entry_tags: list) -> set: + return query_tags & set(entry_tags or []) + + +def matches_paths_exact(query_paths: set, affected_paths: list) -> set: + return query_paths & set(affected_paths or []) + + +def path_governed_by(path: str, affected_paths: list) -> bool: + return any( + globs.path_under(path, ap) or globs.match(ap, path) + for ap in (affected_paths or []) + if isinstance(ap, str) + ) diff --git a/tests/unit/test_query.py b/tests/unit/test_query.py new file mode 100644 index 0000000..6971a65 --- /dev/null +++ b/tests/unit/test_query.py @@ -0,0 +1,50 @@ +from scripts.core.query import ( + matches_keyword, + matches_paths_exact, + matches_tags_any, + path_governed_by, +) + + +def test_matches_keyword_in_title(): + assert matches_keyword("cache", "Cache policy for reads", "irrelevant body") is True + + +def test_matches_keyword_in_body_only(): + assert matches_keyword("redis", "Cache policy", "We chose Redis for shared caching.") is True + + +def test_matches_keyword_case_insensitive(): + assert matches_keyword("REDIS", "Cache policy", "we chose redis") is True + + +def test_matches_keyword_absent_returns_false(): + assert matches_keyword("postgres", "Cache policy", "We chose Redis.") is False + + +def test_matches_tags_any_returns_overlap(): + assert matches_tags_any({"check", "cli"}, ["cli", "v0.2.0"]) == {"cli"} + + +def test_matches_tags_any_empty_when_no_overlap(): + assert matches_tags_any({"check"}, ["cli"]) == set() + + +def test_matches_paths_exact_returns_overlap(): + assert matches_paths_exact({"src/db/"}, ["src/db/", "src/api/"]) == {"src/db/"} + + +def test_matches_paths_exact_does_not_prefix_match(): + assert matches_paths_exact({"src/db/x.py"}, ["src/db/"]) == set() + + +def test_path_governed_by_true_for_nested_file(): + assert path_governed_by("src/db/x.py", ["src/db/"]) is True + + +def test_path_governed_by_false_when_no_affected_path_covers_it(): + assert path_governed_by("src/other/x.py", ["src/db/"]) is False + + +def test_path_governed_by_ignores_non_string_affected_paths(): + assert path_governed_by("src/db/x.py", ["src/db/", 42, None]) is True From ff25b688ea8229612f8b1a366deb5bcc3ce9a5d9 Mon Sep 17 00:00:00 2001 From: shcommit Date: Mon, 31 Aug 2026 02:04:32 +0900 Subject: [PATCH 43/76] feat: add deterministic rank_key to core/query.py Fixed tiers (exact id > exact title > title substring > tag-only > body substring), id as tie-break. No numeric score is exposed as a public field -- this is purely an internal sort key. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01CeDZgjQakpnGGtsWoC7ijF --- skills/adr-toolkit/scripts/core/query.py | 20 ++++++++++++++ tests/unit/test_query.py | 33 ++++++++++++++++++++++++ 2 files changed, 53 insertions(+) diff --git a/skills/adr-toolkit/scripts/core/query.py b/skills/adr-toolkit/scripts/core/query.py index c53eff6..d5f6f7b 100644 --- a/skills/adr-toolkit/scripts/core/query.py +++ b/skills/adr-toolkit/scripts/core/query.py @@ -26,3 +26,23 @@ def path_governed_by(path: str, affected_paths: list) -> bool: for ap in (affected_paths or []) if isinstance(ap, str) ) + + +def rank_key(entry: dict, keyword: str) -> tuple: + """Lower tuples sort first -- tier 0 (best) through tier 4 (worst), then + id as a stable, deterministic tie-break.""" + keyword_lower = (keyword or "").lower() + title_lower = (entry.get("title") or "").lower() + body_lower = (entry.get("body") or "").lower() + + if keyword_lower and entry.get("id", "").lower() == keyword_lower: + tier = 0 + elif keyword_lower and title_lower == keyword_lower: + tier = 1 + elif keyword_lower and keyword_lower in title_lower: + tier = 2 + elif keyword_lower and keyword_lower in body_lower: + tier = 4 + else: + tier = 3 # tag-only or no-keyword matches land between title and body + return (tier, entry.get("id", "")) diff --git a/tests/unit/test_query.py b/tests/unit/test_query.py index 6971a65..5c7bbf1 100644 --- a/tests/unit/test_query.py +++ b/tests/unit/test_query.py @@ -3,6 +3,7 @@ matches_paths_exact, matches_tags_any, path_governed_by, + rank_key, ) @@ -48,3 +49,35 @@ def test_path_governed_by_false_when_no_affected_path_covers_it(): def test_path_governed_by_ignores_non_string_affected_paths(): assert path_governed_by("src/db/x.py", ["src/db/", 42, None]) is True + + +def _entry(id_="ADR-0001", title="Cache policy", body="body text"): + return {"id": id_, "title": title, "body": body} + + +def test_rank_key_exact_id_match_ranks_first(): + exact_id = _entry(id_="ADR-0007") + title_match = _entry(id_="ADR-0001", title="ADR-0007 discussion") + ranked = sorted([title_match, exact_id], key=lambda e: rank_key(e, "ADR-0007")) + assert ranked[0]["id"] == "ADR-0007" + + +def test_rank_key_exact_title_ranks_above_title_substring(): + exact_title = _entry(id_="ADR-0001", title="cache") + substring_title = _entry(id_="ADR-0002", title="cache policy for reads") + ranked = sorted([substring_title, exact_title], key=lambda e: rank_key(e, "cache")) + assert ranked[0]["id"] == "ADR-0001" + + +def test_rank_key_title_substring_ranks_above_body_only_match(): + title_match = _entry(id_="ADR-0001", title="redis caching", body="unrelated") + body_match = _entry(id_="ADR-0002", title="unrelated title", body="uses redis internally") + ranked = sorted([body_match, title_match], key=lambda e: rank_key(e, "redis")) + assert ranked[0]["id"] == "ADR-0001" + + +def test_rank_key_ties_break_by_filename_for_determinism(): + a = _entry(id_="ADR-0002", title="cache", body="") + b = _entry(id_="ADR-0001", title="cache", body="") + ranked = sorted([a, b], key=lambda e: rank_key(e, "cache")) + assert [e["id"] for e in ranked] == ["ADR-0001", "ADR-0002"] From 92d95cc3b320cc3e4a200f15d018598c9804d0b0 Mon Sep 17 00:00:00 2001 From: shcommit Date: Mon, 31 Aug 2026 02:05:17 +0900 Subject: [PATCH 44/76] docs: checkpoint handoff.md after tasks 1-3 of the search/relationships plan Recorded completed commits, exact remaining task list, and pointed at the plan file's location (gitignored, filesystem-only) plus a fallback (the committed spec's own sequencing) in case a fresh session needs to resume without it. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01CeDZgjQakpnGGtsWoC7ijF --- handoff.md | 68 +++++++++++++++++++++++++++++++++++++++++------------- 1 file changed, 52 insertions(+), 16 deletions(-) diff --git a/handoff.md b/handoff.md index cc135d1..9aab9cd 100644 --- a/handoff.md +++ b/handoff.md @@ -72,22 +72,58 @@ items 1, 3, and 4 as done. ## Next step -**In progress**: implementing the ADR search + relationship navigation design -from `docs/superpowers/specs/2026-08-31-adr-search-and-relationships-design.md` -(committed `300456a`, revised after external review verification `f8b6423`, -owner-approved). This came from `project-roadmap.md`'s "ADR navigation and -scale" item, promoted out of roadmap after research into comparable OSS ADR -tools (`npryce/adr-tools`, `thomvaill/log4brains`) showed search + relationship -visibility are proven table-stakes regardless of this repo's current 10-ADR -size. Scope: `core/globs.py` gains `path_under()` (extracted from -`rules/conflict.py`), new `core/query.py` + `core/relationships.py`, `related.py` -fixed (body-search) with policy unchanged, new `search` command, `index.py` -Relationships section (8-locale), `validate.py` relationship-integrity checks. -9-step sequencing is in the spec's "Implementation sequencing" section — follow -it in order, each step's own tests green before the next. Next action: invoke -`writing-plans` to turn the spec into a task-level plan (per the brainstorming -skill's architectural path, this is the required next step before writing any -implementation code). +**In progress, executing inline (not subagent-driven) per owner's request for +tight commit/handoff cadence in case the session runs out and Codex picks up +next.** Implementing the ADR search + relationship navigation feature: + +- Design: `docs/superpowers/specs/2026-08-31-adr-search-and-relationships-design.md` + (`300456a`, revised after external review verification `f8b6423`, owner-approved). +- Plan: `docs/superpowers/plans/2026-08-31-adr-search-and-relationships.md` + — **gitignored, not committed to git; it only exists in this worktree's + filesystem.** If a fresh session (this one or Codex) needs to resume and that + file is somehow gone, re-derive the remaining tasks from the spec above (which + *is* committed) plus the "Tasks completed so far" list below — the spec's own + "Implementation sequencing" section has the same 9-step order the plan's 14 + tasks were broken out from. +- The plan has 14 bite-sized TDD tasks. **Follow strict TDD**: write the failing + test, watch it fail, write minimal code, watch it pass, commit — one task at a + time, in order, exactly as the plan file specifies (real code and test bodies + are already written out in the plan; don't improvise different ones). + +**Tasks completed so far (commits, in order):** +1. `20254cf` — extracted `path_under` into `core/globs.py`, refactored + `rules/conflict.py` to use it (pure refactor, `test_conflict.py`/ + `test_check.py` still pass unmodified). +2. `7c95692` — `core/query.py`: `matches_keyword` (title+body fix), + `matches_tags_any`, `matches_paths_exact`, `path_governed_by`. +3. `ff25b68` — `core/query.py`: `rank_key` deterministic ranking (5 tiers, + no numeric score exposed publicly). + +**Tasks remaining (plan file has full code for each):** +4. `core/relationships.py` — `Relationship` NamedTuple + `resolve()`. +5. `core/relationships.py` — `missing_targets()` + `supersession_mismatches()`. +6. Migrate `related.py` onto `core/query.py`, fix body-search bug, policy + unchanged (all pre-existing `test_related.py` tests must stay green). +7. `search` command — core filtering (browse mode, AND-across-fields, + OR-within-field, `--path` via `path_governed_by`). +8. `search` command — ranking, `--limit`/`total`/`truncated`, `query` echo. +9. Wire `search` into `adr.py`'s CLI (needs `STATUSES` import from + `scripts.core.lifecycle`). +10. `index.py` — Relationships section (English/fallback strings first). +11. Localize the 5 new keys across all 8 `scripts/i18n/*.json` catalogs + + `test_locale.py`'s `REQUIRED_KEYS`. +12. `validate.py` — `BROKEN_SUPERSESSION_LINK` + `SUPERSESSION_MISMATCH` + (errors, not warnings — `validate.py` has no warnings mechanism). +13. Docs: README (search usage + AND/OR semantics statement) + SKILL.md + (`related` vs `search` framing) — run every shown command in a scratch + repo first, don't hand-write example output. +14. Final verification: full suite, `sync_version.py --check`, + `validate`/`index` against this repo's real 10 ADRs, `git diff --check`, + commit the regenerated `docs/decisions/README.md`, then update + `improvements.md`/`handoff.md` to mark this feature done. + +After Task 14: `improvements.md` will have exactly one open item left, the P0 +release gate (unchanged from before this feature started). Once that work lands: `improvements.md` still has exactly one open item, the P0 release gate. Open the final PR (from From 7ade27d6c07b2f48c9137fb678c2cce87b9054c4 Mon Sep 17 00:00:00 2001 From: shcommit Date: Mon, 31 Aug 2026 02:05:39 +0900 Subject: [PATCH 45/76] feat: add core/relationships.py canonical Relationship model resolve() turns each ADR's related/supersedes/superseded_by frontmatter into a flat edge list, so index.py and validate.py both read the same structured data instead of each re-deriving it from raw frontmatter. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01CeDZgjQakpnGGtsWoC7ijF --- .../adr-toolkit/scripts/core/relationships.py | 22 +++++++++++++ tests/unit/test_relationships.py | 33 +++++++++++++++++++ 2 files changed, 55 insertions(+) create mode 100644 skills/adr-toolkit/scripts/core/relationships.py create mode 100644 tests/unit/test_relationships.py diff --git a/skills/adr-toolkit/scripts/core/relationships.py b/skills/adr-toolkit/scripts/core/relationships.py new file mode 100644 index 0000000..82481fc --- /dev/null +++ b/skills/adr-toolkit/scripts/core/relationships.py @@ -0,0 +1,22 @@ +"""Canonical ADR relationship model, shared by index.py and validate.py.""" +from typing import NamedTuple + + +class Relationship(NamedTuple): + source: str + type: str # "related" | "supersedes" | "superseded_by" + target: str + + +def resolve(entries: list) -> list: + edges = [] + for entry in entries: + source = entry.get("id") + for target in entry.get("related") or []: + edges.append(Relationship(source=source, type="related", target=target)) + for target in entry.get("supersedes") or []: + edges.append(Relationship(source=source, type="supersedes", target=target)) + superseded_by = entry.get("superseded_by") + if superseded_by: + edges.append(Relationship(source=source, type="superseded_by", target=superseded_by)) + return edges diff --git a/tests/unit/test_relationships.py b/tests/unit/test_relationships.py new file mode 100644 index 0000000..36c1236 --- /dev/null +++ b/tests/unit/test_relationships.py @@ -0,0 +1,33 @@ +from scripts.core.relationships import Relationship, resolve + +ENTRIES = [ + {"id": "ADR-0003", "related": [], "supersedes": [], "superseded_by": "ADR-0006"}, + {"id": "ADR-0006", "related": [], "supersedes": ["ADR-0003"], "superseded_by": None}, + {"id": "ADR-0007", "related": ["ADR-0002"], "supersedes": [], "superseded_by": None}, + {"id": "ADR-0002", "related": [], "supersedes": [], "superseded_by": None}, +] + + +def test_resolve_produces_supersedes_edge(): + edges = resolve(ENTRIES) + assert Relationship(source="ADR-0006", type="supersedes", target="ADR-0003") in edges + + +def test_resolve_produces_superseded_by_edge(): + edges = resolve(ENTRIES) + assert Relationship(source="ADR-0003", type="superseded_by", target="ADR-0006") in edges + + +def test_resolve_produces_related_edge(): + edges = resolve(ENTRIES) + assert Relationship(source="ADR-0007", type="related", target="ADR-0002") in edges + + +def test_resolve_produces_no_edges_for_entry_with_no_relationships(): + edges = resolve(ENTRIES) + assert not any(e.source == "ADR-0002" for e in edges) + + +def test_resolve_handles_missing_fields_gracefully(): + edges = resolve([{"id": "ADR-0001"}]) + assert edges == [] From c597a308b805aa6f83893c991750b7ade5d76098 Mon Sep 17 00:00:00 2001 From: shcommit Date: Mon, 31 Aug 2026 02:06:05 +0900 Subject: [PATCH 46/76] feat: add relationship integrity checks to core/relationships.py missing_targets finds a related/supersedes/superseded_by reference to an ADR id that doesn't exist. supersession_mismatches finds a one-sided supersession edit -- exactly what adr.py supersede's atomic write normally prevents, but a hand-edit could still produce. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01CeDZgjQakpnGGtsWoC7ijF --- .../adr-toolkit/scripts/core/relationships.py | 10 +++++++ tests/unit/test_relationships.py | 29 ++++++++++++++++++- 2 files changed, 38 insertions(+), 1 deletion(-) diff --git a/skills/adr-toolkit/scripts/core/relationships.py b/skills/adr-toolkit/scripts/core/relationships.py index 82481fc..49f0e8f 100644 --- a/skills/adr-toolkit/scripts/core/relationships.py +++ b/skills/adr-toolkit/scripts/core/relationships.py @@ -20,3 +20,13 @@ def resolve(entries: list) -> list: if superseded_by: edges.append(Relationship(source=source, type="superseded_by", target=superseded_by)) return edges + + +def missing_targets(relationships: list, known_ids: set) -> list: + return [r for r in relationships if r.target not in known_ids] + + +def supersession_mismatches(relationships: list) -> list: + supersedes_edges = {(r.source, r.target) for r in relationships if r.type == "supersedes"} + superseded_by_edges = {(r.target, r.source) for r in relationships if r.type == "superseded_by"} + return sorted(supersedes_edges - superseded_by_edges) diff --git a/tests/unit/test_relationships.py b/tests/unit/test_relationships.py index 36c1236..27ffaf9 100644 --- a/tests/unit/test_relationships.py +++ b/tests/unit/test_relationships.py @@ -1,4 +1,4 @@ -from scripts.core.relationships import Relationship, resolve +from scripts.core.relationships import Relationship, missing_targets, resolve, supersession_mismatches ENTRIES = [ {"id": "ADR-0003", "related": [], "supersedes": [], "superseded_by": "ADR-0006"}, @@ -31,3 +31,30 @@ def test_resolve_produces_no_edges_for_entry_with_no_relationships(): def test_resolve_handles_missing_fields_gracefully(): edges = resolve([{"id": "ADR-0001"}]) assert edges == [] + + +def test_missing_targets_finds_dangling_reference(): + edges = resolve([{"id": "ADR-0001", "related": ["ADR-9999"]}]) + missing = missing_targets(edges, known_ids={"ADR-0001"}) + assert Relationship(source="ADR-0001", type="related", target="ADR-9999") in missing + + +def test_missing_targets_empty_when_all_targets_known(): + edges = resolve(ENTRIES) + known_ids = {e["id"] for e in ENTRIES} + assert missing_targets(edges, known_ids) == [] + + +def test_supersession_mismatches_empty_when_bidirectional(): + edges = resolve(ENTRIES) + assert supersession_mismatches(edges) == [] + + +def test_supersession_mismatches_finds_one_sided_edge(): + one_sided = [ + {"id": "ADR-0010", "related": [], "supersedes": ["ADR-0009"], "superseded_by": None}, + {"id": "ADR-0009", "related": [], "supersedes": [], "superseded_by": None}, + ] + edges = resolve(one_sided) + mismatches = supersession_mismatches(edges) + assert ("ADR-0010", "ADR-0009") in mismatches From c8c2b06165c4f4ec7e46e021985c640875ec4471 Mon Sep 17 00:00:00 2001 From: shcommit Date: Mon, 31 Aug 2026 02:07:33 +0900 Subject: [PATCH 47/76] fix: related.py keyword search now matches body, not just title Migrated onto core/query.py's shared matching primitives. Combination policy is unchanged (path OR tag OR keyword -- related wants a broad net before drafting); all pre-existing tests pass unmodified. Also fixes a regression the migration would otherwise have introduced and one already latent in path_governed_by: extracting related.py's matching logic dropped its _as_list() guard against a malformed ADR carrying a non-list value for affected_paths/tags (e.g. a plain string instead of a YAML list), which would silently decompose into individual characters and produce nonsense matches. Restored the guard in core/query.py, applied consistently to all three matchers. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01CeDZgjQakpnGGtsWoC7ijF --- .../adr-toolkit/scripts/commands/related.py | 15 +++++------ skills/adr-toolkit/scripts/core/query.py | 15 ++++++++--- tests/unit/test_query.py | 15 +++++++++++ tests/unit/test_related.py | 25 +++++++++++++++++++ 4 files changed, 58 insertions(+), 12 deletions(-) diff --git a/skills/adr-toolkit/scripts/commands/related.py b/skills/adr-toolkit/scripts/commands/related.py index 876dbec..d7144d1 100644 --- a/skills/adr-toolkit/scripts/commands/related.py +++ b/skills/adr-toolkit/scripts/commands/related.py @@ -3,10 +3,7 @@ from scripts.core import frontmatter as fm from scripts.core.adr_directory import iter_adr_files - - -def _as_list(value) -> list: - return value if isinstance(value, list) else [] +from scripts.core.query import matches_keyword, matches_paths_exact, matches_tags_any def run(args) -> dict: @@ -22,23 +19,23 @@ def run(args) -> dict: continue try: - data, _ = fm.parse(entry.read_text(encoding="utf-8")) + 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 reasons = [] - path_overlap = query_paths & set(_as_list(data.get("affected_paths"))) + path_overlap = matches_paths_exact(query_paths, data.get("affected_paths")) if path_overlap: reasons.append(f"affected_paths overlap: {sorted(path_overlap)}") - tag_overlap = query_tags & set(_as_list(data.get("tags"))) + tag_overlap = matches_tags_any(query_tags, 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 keyword and matches_keyword(keyword, data.get("title", ""), body): + reasons.append("title/body keyword match") if reasons: matches.append({ diff --git a/skills/adr-toolkit/scripts/core/query.py b/skills/adr-toolkit/scripts/core/query.py index d5f6f7b..538e9d5 100644 --- a/skills/adr-toolkit/scripts/core/query.py +++ b/skills/adr-toolkit/scripts/core/query.py @@ -7,23 +7,32 @@ from scripts.core import globs +def _as_list(value) -> list: + # A malformed ADR can carry a non-list value for a field this project's + # schema declares as a list (e.g. "affected_paths: src/events/" instead + # of a YAML list) -- treating it as iterable would silently decompose a + # string into individual characters and produce nonsense single-char + # matches. Anything that isn't already a list is treated as empty. + return value if isinstance(value, list) else [] + + def matches_keyword(keyword: str, title: str, body: str) -> bool: keyword = keyword.lower() return keyword in (title or "").lower() or keyword in (body or "").lower() def matches_tags_any(query_tags: set, entry_tags: list) -> set: - return query_tags & set(entry_tags or []) + return query_tags & set(_as_list(entry_tags)) def matches_paths_exact(query_paths: set, affected_paths: list) -> set: - return query_paths & set(affected_paths or []) + return query_paths & set(_as_list(affected_paths)) def path_governed_by(path: str, affected_paths: list) -> bool: return any( globs.path_under(path, ap) or globs.match(ap, path) - for ap in (affected_paths or []) + for ap in _as_list(affected_paths) if isinstance(ap, str) ) diff --git a/tests/unit/test_query.py b/tests/unit/test_query.py index 5c7bbf1..0aa92ea 100644 --- a/tests/unit/test_query.py +++ b/tests/unit/test_query.py @@ -51,6 +51,21 @@ def test_path_governed_by_ignores_non_string_affected_paths(): assert path_governed_by("src/db/x.py", ["src/db/", 42, None]) is True +def test_path_governed_by_does_not_iterate_a_non_list_affected_paths_by_character(): + # A malformed ADR could carry "affected_paths: src/db/" (a plain string, + # not a YAML list). Iterating it would decompose into characters and + # produce nonsense single-character path_under/glob matches. + assert path_governed_by("s", "src/db/") is False + + +def test_matches_tags_any_ignores_non_list_entry_tags(): + assert matches_tags_any({"c"}, "check") == set() + + +def test_matches_paths_exact_ignores_non_list_affected_paths(): + assert matches_paths_exact({"s"}, "src/events/") == set() + + def _entry(id_="ADR-0001", title="Cache policy", body="body text"): return {"id": id_, "title": title, "body": body} diff --git a/tests/unit/test_related.py b/tests/unit/test_related.py index 4dd8479..0f8c29c 100644 --- a/tests/unit/test_related.py +++ b/tests/unit/test_related.py @@ -80,3 +80,28 @@ def test_non_list_affected_paths_does_not_match_by_character(tmp_path): assert result["count"] == 0 assert result["matches"] == [] + + +def test_keyword_matches_when_present_only_in_body(tmp_path): + (tmp_path / "0001-cache-policy.md").write_text( + "---\n" + "id: ADR-0001\n" + "title: Cache policy\n" + "status: accepted\n" + "date: 2026-08-31\n" + "decision_makers: []\n" + "related: []\n" + "affected_paths: []\n" + "tags: []\n" + "retrospective: false\n" + "---\n\n" + "# Cache policy\n\nWe chose Redis for the shared cache layer.\n", + encoding="utf-8", + ) + + result = related.run(SimpleNamespace( + dir=str(tmp_path), paths=None, tags=None, keyword="redis", + )) + + assert result["count"] == 1 + assert result["matches"][0]["id"] == "ADR-0001" From 5db24711f44e673e21b5df49e1b198baa69ca7c0 Mon Sep 17 00:00:00 2001 From: shcommit Date: Mon, 31 Aug 2026 02:08:47 +0900 Subject: [PATCH 48/76] feat: add search command core filtering (keyword/tags/status/path) No filters given returns every ADR (browse mode). Different filter fields combine with AND; multiple values in --tags combine with OR. --path uses path_governed_by (directory-boundary + glob), not related.py's exact frontmatter-list matching. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01CeDZgjQakpnGGtsWoC7ijF --- skills/adr-toolkit/scripts/commands/search.py | 77 ++++++++++ tests/unit/test_search_command.py | 132 ++++++++++++++++++ 2 files changed, 209 insertions(+) create mode 100644 skills/adr-toolkit/scripts/commands/search.py create mode 100644 tests/unit/test_search_command.py diff --git a/skills/adr-toolkit/scripts/commands/search.py b/skills/adr-toolkit/scripts/commands/search.py new file mode 100644 index 0000000..89b182e --- /dev/null +++ b/skills/adr-toolkit/scripts/commands/search.py @@ -0,0 +1,77 @@ +"""General ADR lookup: keyword (title+body), tags, status, or governed path. + +Distinct from related.py's discovery policy (broad OR, no-query-means-no- +match): search.py combines different filter fields with AND, multiple values +within one field with OR, and an empty query browses every ADR. +""" +from pathlib import Path + +from scripts.core import frontmatter as fm +from scripts.core.adr_directory import iter_adr_files +from scripts.core.query import matches_tags_any, path_governed_by + + +def run(args) -> dict: + adr_dir = Path(args.dir) + keyword = (getattr(args, "keyword", None) or "").lower() + query_tags = set(getattr(args, "tags", None) or []) + status = getattr(args, "status", None) + path = getattr(args, "path", None) + + results = [] + warnings = [] + for entry, parsed in iter_adr_files(adr_dir): + if parsed 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 + + matched_in = [] + + if keyword: + title_hit = keyword in (data.get("title") or "").lower() + body_hit = keyword in (body or "").lower() + if not (title_hit or body_hit): + continue + if title_hit: + matched_in.append("title") + if body_hit: + matched_in.append("body") + + if query_tags: + tag_overlap = matches_tags_any(query_tags, data.get("tags")) + if not tag_overlap: + continue + matched_in.append("tags") + + if status: + if data.get("status") != status: + continue + matched_in.append("status") + + if path: + if not path_governed_by(path, data.get("affected_paths")): + continue + matched_in.append("path") + + results.append({ + "id": data.get("id"), + "filename": entry.name, + "path": str(Path(args.dir) / entry.name), + "title": data.get("title"), + "status": data.get("status"), + "tags": data.get("tags", []), + "matched_in": matched_in, + }) + + return { + "ok": True, + "operation": "search", + "count": len(results), + "results": results, + "warnings": warnings, + } diff --git a/tests/unit/test_search_command.py b/tests/unit/test_search_command.py new file mode 100644 index 0000000..bec25ea --- /dev/null +++ b/tests/unit/test_search_command.py @@ -0,0 +1,132 @@ +from types import SimpleNamespace + +from scripts.commands import search + + +def _yaml_list_field(key, values): + if not values: + return f"{key}: []" + return f"{key}:\n" + "\n".join(f" - {v}" for v in values) + + +def _write_adr(adr_dir, filename, id, title, status="accepted", affected_paths=None, tags=None, body="Body text."): + adr_dir.mkdir(parents=True, exist_ok=True) + text = ( + "---\n" + f"id: {id}\n" + f"title: {title}\n" + f"status: {status}\n" + "date: 2026-08-31\n" + "decision_makers: []\n" + "related: []\n" + f"{_yaml_list_field('affected_paths', affected_paths)}\n" + f"{_yaml_list_field('tags', tags)}\n" + "retrospective: false\n" + "---\n\n" + f"# {title}\n\n" + f"{body}\n" + ) + (adr_dir / filename).write_text(text, encoding="utf-8") + + +def _args(adr_dir, **overrides): + defaults = dict(dir=str(adr_dir), keyword=None, tags=None, status=None, path=None, limit=None) + defaults.update(overrides) + return SimpleNamespace(**defaults) + + +def test_search_with_no_filters_returns_every_adr(tmp_path): + adr_dir = tmp_path / "docs" / "decisions" + _write_adr(adr_dir, "0001-a.md", id="ADR-0001", title="First") + _write_adr(adr_dir, "0002-b.md", id="ADR-0002", title="Second") + + result = search.run(_args(adr_dir)) + + assert result["count"] == 2 + + +def test_search_keyword_matches_title(tmp_path): + adr_dir = tmp_path / "docs" / "decisions" + _write_adr(adr_dir, "0001-a.md", id="ADR-0001", title="Cache policy") + _write_adr(adr_dir, "0002-b.md", id="ADR-0002", title="Unrelated") + + result = search.run(_args(adr_dir, keyword="cache")) + + assert result["count"] == 1 + assert result["results"][0]["id"] == "ADR-0001" + + +def test_search_keyword_matches_body(tmp_path): + adr_dir = tmp_path / "docs" / "decisions" + _write_adr(adr_dir, "0001-a.md", id="ADR-0001", title="Cache policy", body="We chose Redis.") + + result = search.run(_args(adr_dir, keyword="redis")) + + assert result["count"] == 1 + + +def test_search_tag_filter(tmp_path): + adr_dir = tmp_path / "docs" / "decisions" + _write_adr(adr_dir, "0001-a.md", id="ADR-0001", title="A", tags=["database"]) + _write_adr(adr_dir, "0002-b.md", id="ADR-0002", title="B", tags=["cli"]) + + result = search.run(_args(adr_dir, tags=["database"])) + + assert result["count"] == 1 + assert result["results"][0]["id"] == "ADR-0001" + + +def test_search_status_filter(tmp_path): + adr_dir = tmp_path / "docs" / "decisions" + _write_adr(adr_dir, "0001-a.md", id="ADR-0001", title="A", status="accepted") + _write_adr(adr_dir, "0002-b.md", id="ADR-0002", title="B", status="proposed") + + result = search.run(_args(adr_dir, status="proposed")) + + assert result["count"] == 1 + assert result["results"][0]["id"] == "ADR-0002" + + +def test_search_path_filter_uses_governed_by_semantics(tmp_path): + adr_dir = tmp_path / "docs" / "decisions" + _write_adr(adr_dir, "0001-a.md", id="ADR-0001", title="A", affected_paths=["src/db/"]) + _write_adr(adr_dir, "0002-b.md", id="ADR-0002", title="B", affected_paths=["src/api/"]) + + result = search.run(_args(adr_dir, path="src/db/connection.py")) + + assert result["count"] == 1 + assert result["results"][0]["id"] == "ADR-0001" + + +def test_search_combines_different_fields_with_and(tmp_path): + adr_dir = tmp_path / "docs" / "decisions" + _write_adr(adr_dir, "0001-a.md", id="ADR-0001", title="Cache policy", tags=["database"]) + _write_adr(adr_dir, "0002-b.md", id="ADR-0002", title="Cache policy", tags=["cli"]) + + result = search.run(_args(adr_dir, keyword="cache", tags=["database"])) + + assert result["count"] == 1 + assert result["results"][0]["id"] == "ADR-0001" + + +def test_search_combines_same_field_values_with_or(tmp_path): + adr_dir = tmp_path / "docs" / "decisions" + _write_adr(adr_dir, "0001-a.md", id="ADR-0001", title="A", tags=["postgres"]) + _write_adr(adr_dir, "0002-b.md", id="ADR-0002", title="B", tags=["mysql"]) + _write_adr(adr_dir, "0003-c.md", id="ADR-0003", title="C", tags=["cli"]) + + result = search.run(_args(adr_dir, tags=["postgres", "mysql"])) + + assert result["count"] == 2 + + +def test_search_malformed_adr_degrades_to_warning(tmp_path): + adr_dir = tmp_path / "docs" / "decisions" + adr_dir.mkdir(parents=True) + (adr_dir / "0001-bad.md").write_text("not valid frontmatter", encoding="utf-8") + + result = search.run(_args(adr_dir)) + + assert result["ok"] is True + assert result["count"] == 0 + assert any(w["code"] == "BAD_FRONTMATTER" for w in result["warnings"]) From 9a86666fd2feaeb7a3ac4f73b42f5c4976a52203 Mon Sep 17 00:00:00 2001 From: shcommit Date: Mon, 31 Aug 2026 02:09:39 +0900 Subject: [PATCH 49/76] feat: rank search results deterministically, add --limit/total/truncated Results are best-match-first via core/query.py's rank_key. --limit truncates the already-ranked list (weakest matches dropped first); total is always the untruncated count; truncated is total > count. query echoes the effective filters back for harness introspection. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01CeDZgjQakpnGGtsWoC7ijF --- skills/adr-toolkit/scripts/commands/search.py | 24 +++++++++- tests/unit/test_search_command.py | 45 +++++++++++++++++++ 2 files changed, 68 insertions(+), 1 deletion(-) diff --git a/skills/adr-toolkit/scripts/commands/search.py b/skills/adr-toolkit/scripts/commands/search.py index 89b182e..a235f00 100644 --- a/skills/adr-toolkit/scripts/commands/search.py +++ b/skills/adr-toolkit/scripts/commands/search.py @@ -8,7 +8,7 @@ from scripts.core import frontmatter as fm from scripts.core.adr_directory import iter_adr_files -from scripts.core.query import matches_tags_any, path_governed_by +from scripts.core.query import matches_tags_any, path_governed_by, rank_key def run(args) -> dict: @@ -17,6 +17,7 @@ def run(args) -> dict: query_tags = set(getattr(args, "tags", None) or []) status = getattr(args, "status", None) path = getattr(args, "path", None) + limit = getattr(args, "limit", None) results = [] warnings = [] @@ -66,12 +67,33 @@ def run(args) -> dict: "status": data.get("status"), "tags": data.get("tags", []), "matched_in": matched_in, + "_body": body, # used only for ranking below, stripped before returning }) + results.sort(key=lambda r: rank_key({"id": r["id"], "title": r["title"], "body": r["_body"]}, keyword)) + for r in results: + del r["_body"] + + total = len(results) + if limit is not None: + truncated = total > limit + results = results[:limit] + else: + truncated = False + return { "ok": True, "operation": "search", + "query": { + "keyword": getattr(args, "keyword", None), + "tags": getattr(args, "tags", None), + "status": status, + "path": path, + "limit": limit, + }, "count": len(results), + "total": total, + "truncated": truncated, "results": results, "warnings": warnings, } diff --git a/tests/unit/test_search_command.py b/tests/unit/test_search_command.py index bec25ea..86ae958 100644 --- a/tests/unit/test_search_command.py +++ b/tests/unit/test_search_command.py @@ -120,6 +120,51 @@ def test_search_combines_same_field_values_with_or(tmp_path): assert result["count"] == 2 +def test_search_orders_exact_title_match_before_substring_match(tmp_path): + adr_dir = tmp_path / "docs" / "decisions" + _write_adr(adr_dir, "0001-a.md", id="ADR-0001", title="cache policy extended") + _write_adr(adr_dir, "0002-b.md", id="ADR-0002", title="cache") + + result = search.run(_args(adr_dir, keyword="cache")) + + assert [r["id"] for r in result["results"]] == ["ADR-0002", "ADR-0001"] + + +def test_search_limit_truncates_and_reports_total(tmp_path): + adr_dir = tmp_path / "docs" / "decisions" + _write_adr(adr_dir, "0001-a.md", id="ADR-0001", title="A") + _write_adr(adr_dir, "0002-b.md", id="ADR-0002", title="B") + _write_adr(adr_dir, "0003-c.md", id="ADR-0003", title="C") + + result = search.run(_args(adr_dir, limit=2)) + + assert result["count"] == 2 + assert result["total"] == 3 + assert result["truncated"] is True + assert len(result["results"]) == 2 + + +def test_search_no_limit_is_not_truncated(tmp_path): + adr_dir = tmp_path / "docs" / "decisions" + _write_adr(adr_dir, "0001-a.md", id="ADR-0001", title="A") + + result = search.run(_args(adr_dir)) + + assert result["total"] == 1 + assert result["truncated"] is False + + +def test_search_echoes_the_query(tmp_path): + adr_dir = tmp_path / "docs" / "decisions" + _write_adr(adr_dir, "0001-a.md", id="ADR-0001", title="A", tags=["cli"]) + + result = search.run(_args(adr_dir, keyword="a", tags=["cli"], status="accepted")) + + assert result["query"] == { + "keyword": "a", "tags": ["cli"], "status": "accepted", "path": None, "limit": None, + } + + def test_search_malformed_adr_degrades_to_warning(tmp_path): adr_dir = tmp_path / "docs" / "decisions" adr_dir.mkdir(parents=True) From 36d70480e726af55a97d32c52db5241128c3567e Mon Sep 17 00:00:00 2001 From: shcommit Date: Mon, 31 Aug 2026 02:10:32 +0900 Subject: [PATCH 50/76] feat: wire search into the adr.py CLI Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01CeDZgjQakpnGGtsWoC7ijF --- skills/adr-toolkit/scripts/adr.py | 12 ++++++++++++ tests/integration/test_cli.py | 12 ++++++++++++ 2 files changed, 24 insertions(+) diff --git a/skills/adr-toolkit/scripts/adr.py b/skills/adr-toolkit/scripts/adr.py index a267978..82d2100 100755 --- a/skills/adr-toolkit/scripts/adr.py +++ b/skills/adr-toolkit/scripts/adr.py @@ -17,11 +17,13 @@ init, preflight, related, + search, significance, status, supersede, validate, ) +from scripts.core.lifecycle import STATUSES from scripts.core.locale import SUPPORTED_LOCALES @@ -136,6 +138,15 @@ def build_parser() -> argparse.ArgumentParser: p_exception.add_argument("--dry-run", dest="dry_run", action="store_true") _add_json_flag(p_exception) + p_search = sub.add_parser("search") + p_search.add_argument("--keyword") + p_search.add_argument("--tags", nargs="*") + p_search.add_argument("--status", choices=sorted(STATUSES)) + p_search.add_argument("--path") + p_search.add_argument("--limit", type=int) + p_search.add_argument("--dir", default="docs/decisions") + _add_json_flag(p_search) + return parser @@ -154,6 +165,7 @@ def build_parser() -> argparse.ArgumentParser: "diff": diff.run, "check": check.run, "exception": exception.run, + "search": search.run, } diff --git a/tests/integration/test_cli.py b/tests/integration/test_cli.py index 13e9def..f2ed635 100644 --- a/tests/integration/test_cli.py +++ b/tests/integration/test_cli.py @@ -49,6 +49,18 @@ def test_preflight_output_is_json_even_without_the_json_flag(tmp_path): assert payload["ok"] is True +def test_search_via_cli(tmp_path): + init_result = _run(["init", "--dir", "docs/decisions", "--json"], cwd=tmp_path) + assert init_result.returncode == 0 + + result = _run(["search", "--keyword", "architecture", "--dir", "docs/decisions", "--json"], cwd=tmp_path) + + payload = json.loads(result.stdout) + assert result.returncode == 0 + assert payload["ok"] is True + assert payload["count"] >= 1 + + def test_significance_via_cli(tmp_path): scores_path = tmp_path / "scores.json" scores_path.write_text( From 954496ad1780f73b8fb26f27a5ee0c4feb1a0fe2 Mon Sep 17 00:00:00 2001 From: shcommit Date: Mon, 31 Aug 2026 02:10:54 +0900 Subject: [PATCH 51/76] docs: checkpoint handoff.md after tasks 1-9 (search command complete) Recorded two real bugs found and fixed mid-task (a dropped non-list guard, and this repo's minimal frontmatter parser not supporting YAML flow-style lists) so the next session doesn't rediscover them. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01CeDZgjQakpnGGtsWoC7ijF --- handoff.md | 34 +++++++++++++++++++++++++--------- 1 file changed, 25 insertions(+), 9 deletions(-) diff --git a/handoff.md b/handoff.md index 9aab9cd..19db394 100644 --- a/handoff.md +++ b/handoff.md @@ -98,17 +98,33 @@ next.** Implementing the ADR search + relationship navigation feature: `matches_tags_any`, `matches_paths_exact`, `path_governed_by`. 3. `ff25b68` — `core/query.py`: `rank_key` deterministic ranking (5 tiers, no numeric score exposed publicly). +4. `7ade27d` — `core/relationships.py`: `Relationship` NamedTuple + `resolve()`. +5. `c597a30` — `core/relationships.py`: `missing_targets()` + + `supersession_mismatches()`. +6. `c8c2b06` — migrated `related.py` onto `core/query.py`, fixed body-search + bug; **also found and fixed a real regression while doing this**: the + migration would have dropped `related.py`'s original `_as_list()` guard + against a malformed ADR carrying a non-list value for `affected_paths`/ + `tags` (e.g. a plain YAML string instead of a list), which would silently + decompose into individual characters and produce nonsense matches. Fixed + by restoring the guard as `_as_list()` inside `core/query.py` itself, + applied to all three matchers including `path_governed_by` (which had the + same latent bug with no prior test catching it — added one). +7. `5db2471` — `search` command core filtering (browse mode, + AND-across-fields, OR-within-field, `--path` via `path_governed_by`). + **Note for whoever writes ADR test fixtures next**: this repo's + `frontmatter.py` is a minimal custom parser, not real YAML — it only + understands `key: []` or block-style `key:\n - item`. A flow-style + `tags: ['x']` silently parses as the literal string `"['x']"`, not a + list. Hit this exact bug writing `test_search_command.py`'s fixture + helper; fixed by rendering block-list YAML instead. +8. `9a86666` — ranking, `--limit`/`total`/`truncated`, `query` echo. +9. `36d7048` — wired `search` into `adr.py`'s CLI (added `STATUSES` import + from `scripts.core.lifecycle` for `--status` choices). + +Full suite at this point: `374 passed`. **Tasks remaining (plan file has full code for each):** -4. `core/relationships.py` — `Relationship` NamedTuple + `resolve()`. -5. `core/relationships.py` — `missing_targets()` + `supersession_mismatches()`. -6. Migrate `related.py` onto `core/query.py`, fix body-search bug, policy - unchanged (all pre-existing `test_related.py` tests must stay green). -7. `search` command — core filtering (browse mode, AND-across-fields, - OR-within-field, `--path` via `path_governed_by`). -8. `search` command — ranking, `--limit`/`total`/`truncated`, `query` echo. -9. Wire `search` into `adr.py`'s CLI (needs `STATUSES` import from - `scripts.core.lifecycle`). 10. `index.py` — Relationships section (English/fallback strings first). 11. Localize the 5 new keys across all 8 `scripts/i18n/*.json` catalogs + `test_locale.py`'s `REQUIRED_KEYS`. From 2ed781e7dfd42d27740649b40e7f75d4a9ed873f Mon Sep 17 00:00:00 2001 From: shcommit Date: Mon, 31 Aug 2026 02:12:02 +0900 Subject: [PATCH 52/76] feat: add Relationships section to the generated index Supersession chains and related lists now render with titles, not just IDs, built from core/relationships.py's resolved edge list. An ADR with no relationships is omitted from the section entirely. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01CeDZgjQakpnGGtsWoC7ijF --- skills/adr-toolkit/scripts/commands/index.py | 39 +++++++++++++++++++ tests/unit/test_index.py | 40 ++++++++++++++++++++ 2 files changed, 79 insertions(+) diff --git a/skills/adr-toolkit/scripts/commands/index.py b/skills/adr-toolkit/scripts/commands/index.py index d22d438..526f020 100644 --- a/skills/adr-toolkit/scripts/commands/index.py +++ b/skills/adr-toolkit/scripts/commands/index.py @@ -5,6 +5,7 @@ from scripts.core.adr_directory import iter_adr_files from scripts.core.config import ConfigError, resolve_locale from scripts.core.locale import load_locale +from scripts.core.relationships import resolve from scripts.core.repository_paths import resolve_from_root # Last-resort English headers, used when even the English locale file is @@ -17,6 +18,11 @@ "by_tag": "By tag", "by_affected_path": "By affected path", "chronological": "Chronological (newest first)", + "relationships": "Relationships", + "supersession_chains": "Supersession chains", + "related": "Related", + "superseded_by": "superseded by", + "related_to": "related to", } @@ -55,6 +61,9 @@ def run(args) -> dict: "date": data.get("date", ""), "tags": data.get("tags", []), "affected_paths": data.get("affected_paths", []), + "related": data.get("related", []), + "supersedes": data.get("supersedes", []), + "superseded_by": data.get("superseded_by"), }) (adr_dir / "README.md").write_text(_render(entries, strings), encoding="utf-8") @@ -117,4 +126,34 @@ def _render(entries: list, strings: dict) -> str: for entry in sorted(entries, key=lambda e: e["date"], reverse=True): lines.append(f"- {entry['date']} — [{entry['id']} — {entry['title']}]({entry['filename']})") + by_id = {entry["id"]: entry for entry in entries} + edges = resolve(entries) + + lines.append("") + lines.append(f"## {_s(strings, 'relationships')}") + lines.append("") + + lines.append(f"### {_s(strings, 'supersession_chains')}") + lines.append("") + for edge in sorted(e for e in edges if e.type == "supersedes"): + source = by_id.get(edge.source) + target = by_id.get(edge.target) + if source and target: + lines.append( + f"- {target['id']} \"{target['title']}\" → {_s(strings, 'superseded_by')} → " + f"{source['id']} \"{source['title']}\"" + ) + lines.append("") + + lines.append(f"### {_s(strings, 'related')}") + lines.append("") + for edge in sorted(e for e in edges if e.type == "related"): + source = by_id.get(edge.source) + target = by_id.get(edge.target) + if source and target: + lines.append( + f"- {source['id']} \"{source['title']}\" {_s(strings, 'related_to')}: " + f"{target['id']} \"{target['title']}\"" + ) + return "\n".join(lines) + "\n" diff --git a/tests/unit/test_index.py b/tests/unit/test_index.py index 4e1348f..f917870 100644 --- a/tests/unit/test_index.py +++ b/tests/unit/test_index.py @@ -205,3 +205,43 @@ def test_index_survives_a_completely_absent_i18n_directory(tmp_path, monkeypatch assert "## Chronological (newest first)" in readme assert "### Accepted" in readme assert "ADR-0001" in readme + + +def test_index_relationships_section_shows_supersession_with_titles(tmp_path): + adr_dir = tmp_path / "docs" / "decisions" + adr_dir.mkdir(parents=True) + (adr_dir / "0003-old.md").write_text( + "---\nid: ADR-0003\ntitle: Old decision\nstatus: superseded\n" + "date: 2026-08-31\ndecision_makers: []\nrelated: []\naffected_paths: []\n" + "tags: []\nretrospective: false\nsuperseded_by: ADR-0006\n---\n\nBody.\n", + encoding="utf-8", + ) + (adr_dir / "0006-new.md").write_text( + "---\nid: ADR-0006\ntitle: New decision\nstatus: accepted\n" + "date: 2026-08-31\ndecision_makers: []\nrelated: []\naffected_paths: []\n" + "tags: []\nretrospective: false\nsupersedes:\n - ADR-0003\n---\n\nBody.\n", + encoding="utf-8", + ) + + index.run(SimpleNamespace(dir=str(adr_dir), root=str(tmp_path), locale=None)) + + readme = (adr_dir / "README.md").read_text(encoding="utf-8") + assert "## Relationships" in readme + assert "ADR-0003" in readme and "Old decision" in readme + assert "ADR-0006" in readme and "New decision" in readme + + +def test_index_relationships_section_omits_adr_with_no_relationships(tmp_path): + adr_dir = tmp_path / "docs" / "decisions" + adr_dir.mkdir(parents=True) + (adr_dir / "0001-lonely.md").write_text( + "---\nid: ADR-0001\ntitle: Lonely decision\nstatus: accepted\n" + "date: 2026-08-31\ndecision_makers: []\nrelated: []\naffected_paths: []\n" + "tags: []\nretrospective: false\n---\n\nBody.\n", + encoding="utf-8", + ) + + index.run(SimpleNamespace(dir=str(adr_dir), root=str(tmp_path), locale=None)) + + readme = (adr_dir / "README.md").read_text(encoding="utf-8") + assert "Lonely decision" not in readme.split("## Relationships")[1] From 38e0d278211670c72f2f93a55df1401ad4ed1651 Mon Sep 17 00:00:00 2001 From: shcommit Date: Mon, 31 Aug 2026 02:13:16 +0900 Subject: [PATCH 53/76] feat: localize the Relationships section across all 8 catalogs Same key-set-equality contract test_locale.py already enforces for every other generated-index string. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01CeDZgjQakpnGGtsWoC7ijF --- skills/adr-toolkit/scripts/i18n/de.json | 5 +++++ skills/adr-toolkit/scripts/i18n/en.json | 5 +++++ skills/adr-toolkit/scripts/i18n/es.json | 5 +++++ skills/adr-toolkit/scripts/i18n/fr.json | 5 +++++ skills/adr-toolkit/scripts/i18n/ja.json | 5 +++++ skills/adr-toolkit/scripts/i18n/ko.json | 5 +++++ skills/adr-toolkit/scripts/i18n/pt-BR.json | 5 +++++ skills/adr-toolkit/scripts/i18n/zh.json | 5 +++++ tests/unit/test_locale.py | 2 ++ 9 files changed, 42 insertions(+) diff --git a/skills/adr-toolkit/scripts/i18n/de.json b/skills/adr-toolkit/scripts/i18n/de.json index 82ef082..baf5d8f 100644 --- a/skills/adr-toolkit/scripts/i18n/de.json +++ b/skills/adr-toolkit/scripts/i18n/de.json @@ -4,6 +4,11 @@ "by_tag": "Nach Tag", "by_affected_path": "Nach betroffenem Pfad", "chronological": "Chronologisch (neueste zuerst)", + "relationships": "Beziehungen", + "supersession_chains": "Ablöseketten", + "related": "Verwandt", + "superseded_by": "abgelöst durch", + "related_to": "verwandt mit", "status.proposed": "Vorgeschlagen", "status.accepted": "Akzeptiert", "status.rejected": "Abgelehnt", diff --git a/skills/adr-toolkit/scripts/i18n/en.json b/skills/adr-toolkit/scripts/i18n/en.json index 0e9dd49..141f830 100644 --- a/skills/adr-toolkit/scripts/i18n/en.json +++ b/skills/adr-toolkit/scripts/i18n/en.json @@ -4,6 +4,11 @@ "by_tag": "By tag", "by_affected_path": "By affected path", "chronological": "Chronological (newest first)", + "relationships": "Relationships", + "supersession_chains": "Supersession chains", + "related": "Related", + "superseded_by": "superseded by", + "related_to": "related to", "status.proposed": "Proposed", "status.accepted": "Accepted", "status.rejected": "Rejected", diff --git a/skills/adr-toolkit/scripts/i18n/es.json b/skills/adr-toolkit/scripts/i18n/es.json index aa34be2..a80f7cb 100644 --- a/skills/adr-toolkit/scripts/i18n/es.json +++ b/skills/adr-toolkit/scripts/i18n/es.json @@ -4,6 +4,11 @@ "by_tag": "Por etiqueta", "by_affected_path": "Por ruta afectada", "chronological": "Cronológico (más reciente primero)", + "relationships": "Relaciones", + "supersession_chains": "Cadenas de sustitución", + "related": "Relacionadas", + "superseded_by": "reemplazado por", + "related_to": "relacionada con", "status.proposed": "Propuesta", "status.accepted": "Aceptada", "status.rejected": "Rechazada", diff --git a/skills/adr-toolkit/scripts/i18n/fr.json b/skills/adr-toolkit/scripts/i18n/fr.json index 05db63e..20b1303 100644 --- a/skills/adr-toolkit/scripts/i18n/fr.json +++ b/skills/adr-toolkit/scripts/i18n/fr.json @@ -4,6 +4,11 @@ "by_tag": "Par étiquette", "by_affected_path": "Par chemin concerné", "chronological": "Chronologique (plus récent d'abord)", + "relationships": "Relations", + "supersession_chains": "Chaînes de remplacement", + "related": "Liées", + "superseded_by": "remplacé par", + "related_to": "liée à", "status.proposed": "Proposé", "status.accepted": "Accepté", "status.rejected": "Rejeté", diff --git a/skills/adr-toolkit/scripts/i18n/ja.json b/skills/adr-toolkit/scripts/i18n/ja.json index 7562278..dca7dac 100644 --- a/skills/adr-toolkit/scripts/i18n/ja.json +++ b/skills/adr-toolkit/scripts/i18n/ja.json @@ -4,6 +4,11 @@ "by_tag": "タグ別", "by_affected_path": "影響パス別", "chronological": "時系列(新しい順)", + "relationships": "関係", + "supersession_chains": "置き換えの履歴", + "related": "関連", + "superseded_by": "置き換え先", + "related_to": "関連", "status.proposed": "提案中", "status.accepted": "承認済み", "status.rejected": "却下", diff --git a/skills/adr-toolkit/scripts/i18n/ko.json b/skills/adr-toolkit/scripts/i18n/ko.json index 3114563..54ad7fa 100644 --- a/skills/adr-toolkit/scripts/i18n/ko.json +++ b/skills/adr-toolkit/scripts/i18n/ko.json @@ -4,6 +4,11 @@ "by_tag": "태그별", "by_affected_path": "영향 경로별", "chronological": "시간순 (최신순)", + "relationships": "관계", + "supersession_chains": "대체 이력", + "related": "관련", + "superseded_by": "대체됨", + "related_to": "관련", "status.proposed": "제안됨", "status.accepted": "승인됨", "status.rejected": "거부됨", diff --git a/skills/adr-toolkit/scripts/i18n/pt-BR.json b/skills/adr-toolkit/scripts/i18n/pt-BR.json index 9d23e92..46fd0af 100644 --- a/skills/adr-toolkit/scripts/i18n/pt-BR.json +++ b/skills/adr-toolkit/scripts/i18n/pt-BR.json @@ -4,6 +4,11 @@ "by_tag": "Por etiqueta", "by_affected_path": "Por caminho afetado", "chronological": "Cronológico (mais recente primeiro)", + "relationships": "Relacionamentos", + "supersession_chains": "Cadeias de substituição", + "related": "Relacionadas", + "superseded_by": "substituído por", + "related_to": "relacionada a", "status.proposed": "Proposta", "status.accepted": "Aceita", "status.rejected": "Rejeitada", diff --git a/skills/adr-toolkit/scripts/i18n/zh.json b/skills/adr-toolkit/scripts/i18n/zh.json index 3c07854..5edf659 100644 --- a/skills/adr-toolkit/scripts/i18n/zh.json +++ b/skills/adr-toolkit/scripts/i18n/zh.json @@ -4,6 +4,11 @@ "by_tag": "按标签", "by_affected_path": "按受影响路径", "chronological": "按时间顺序(最新优先)", + "relationships": "关系", + "supersession_chains": "替代链", + "related": "相关", + "superseded_by": "被替代为", + "related_to": "相关", "status.proposed": "已提议", "status.accepted": "已接受", "status.rejected": "已拒绝", diff --git a/tests/unit/test_locale.py b/tests/unit/test_locale.py index 2b5ee55..17f5446 100644 --- a/tests/unit/test_locale.py +++ b/tests/unit/test_locale.py @@ -6,6 +6,8 @@ "decision_log_title", "by_status", "by_tag", "by_affected_path", "chronological", "status.proposed", "status.accepted", "status.rejected", "status.deprecated", "status.superseded", + "relationships", "supersession_chains", "related", "superseded_by", + "related_to", } REQUIRED_GENERATION_KEYS = { From b6d96580ef3002d75c39974cffc9013429fdb570 Mon Sep 17 00:00:00 2001 From: shcommit Date: Mon, 31 Aug 2026 02:14:08 +0900 Subject: [PATCH 54/76] feat: validate relationship integrity (supersedes/superseded_by) BROKEN_SUPERSESSION_LINK for a dangling target, SUPERSESSION_MISMATCH for a one-sided edit -- exactly what adr.py supersede's atomic write normally prevents. Both are errors, matching BROKEN_RELATED_LINK's existing severity; validate.py has no warnings mechanism and this doesn't add one for two checks. Verified against this repository's real 10 ADRs: ok, no errors. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01CeDZgjQakpnGGtsWoC7ijF --- .../adr-toolkit/scripts/commands/validate.py | 18 +++++++ tests/unit/test_validate.py | 54 +++++++++++++++++++ 2 files changed, 72 insertions(+) diff --git a/skills/adr-toolkit/scripts/commands/validate.py b/skills/adr-toolkit/scripts/commands/validate.py index ffd120f..4fd4fc6 100644 --- a/skills/adr-toolkit/scripts/commands/validate.py +++ b/skills/adr-toolkit/scripts/commands/validate.py @@ -4,6 +4,7 @@ from scripts.core import frontmatter as fm from scripts.core.adr_directory import iter_adr_files from scripts.core.config import ConfigError, load_repository_config +from scripts.core.relationships import missing_targets, resolve, supersession_mismatches from scripts.core.schema import validate_frontmatter from scripts.core.repository_paths import resolve_from_root @@ -67,4 +68,21 @@ def run(args) -> dict: if related_id not in known_ids: errors.append({"code": "BROKEN_RELATED_LINK", "file": filename, "related_id": related_id}) + edges = resolve([data for _, data in parsed_entries]) + + for edge in missing_targets(edges, known_ids): + if edge.type in ("supersedes", "superseded_by"): + errors.append({ + "code": "BROKEN_SUPERSESSION_LINK", + "adr_id": edge.source, + "target": edge.target, + }) + + for source, target in supersession_mismatches(edges): + errors.append({ + "code": "SUPERSESSION_MISMATCH", + "adr_id": source, + "expected_superseded_by_on": target, + }) + return {"ok": not errors, "operation": "validate", "checked": checked, "errors": errors} diff --git a/tests/unit/test_validate.py b/tests/unit/test_validate.py index 8478aef..b394911 100644 --- a/tests/unit/test_validate.py +++ b/tests/unit/test_validate.py @@ -100,3 +100,57 @@ def test_relative_adr_directory_is_resolved_against_root(tmp_path, monkeypatch): assert result["ok"] is True assert result["checked"] == 1 + + +def test_broken_supersession_link_is_an_error(tmp_path): + (tmp_path / "0001-a.md").write_text( + "---\nid: ADR-0001\ntitle: A\nstatus: accepted\ndate: 2026-08-31\n" + "decision_makers: []\nrelated: []\naffected_paths: []\ntags: []\n" + "retrospective: false\nsupersedes:\n - ADR-9999\n---\n\nBody.\n", + encoding="utf-8", + ) + + result = validate.run(SimpleNamespace(dir=str(tmp_path))) + + assert result["ok"] is False + assert any(e["code"] == "BROKEN_SUPERSESSION_LINK" for e in result["errors"]) + + +def test_supersession_mismatch_is_an_error(tmp_path): + (tmp_path / "0001-old.md").write_text( + "---\nid: ADR-0001\ntitle: Old\nstatus: superseded\ndate: 2026-08-31\n" + "decision_makers: []\nrelated: []\naffected_paths: []\ntags: []\n" + "retrospective: false\n---\n\nBody.\n", + encoding="utf-8", + ) + (tmp_path / "0002-new.md").write_text( + "---\nid: ADR-0002\ntitle: New\nstatus: accepted\ndate: 2026-08-31\n" + "decision_makers: []\nrelated: []\naffected_paths: []\ntags: []\n" + "retrospective: false\nsupersedes:\n - ADR-0001\n---\n\nBody.\n", + encoding="utf-8", + ) + # ADR-0001 has no superseded_by: ADR-0002 -- one-sided edit. + + result = validate.run(SimpleNamespace(dir=str(tmp_path))) + + assert result["ok"] is False + assert any(e["code"] == "SUPERSESSION_MISMATCH" for e in result["errors"]) + + +def test_bidirectional_supersession_has_no_relationship_errors(tmp_path): + (tmp_path / "0001-old.md").write_text( + "---\nid: ADR-0001\ntitle: Old\nstatus: superseded\ndate: 2026-08-31\n" + "decision_makers: []\nrelated: []\naffected_paths: []\ntags: []\n" + "retrospective: false\nsuperseded_by: ADR-0002\n---\n\nBody.\n", + encoding="utf-8", + ) + (tmp_path / "0002-new.md").write_text( + "---\nid: ADR-0002\ntitle: New\nstatus: accepted\ndate: 2026-08-31\n" + "decision_makers: []\nrelated: []\naffected_paths: []\ntags: []\n" + "retrospective: false\nsupersedes:\n - ADR-0001\n---\n\nBody.\n", + encoding="utf-8", + ) + + result = validate.run(SimpleNamespace(dir=str(tmp_path))) + + assert result["ok"] is True From 5e19e0adaf4ff7ef1b64581c58be8536241602b3 Mon Sep 17 00:00:00 2001 From: shcommit Date: Mon, 31 Aug 2026 02:14:23 +0900 Subject: [PATCH 55/76] docs: checkpoint handoff.md after tasks 10-12 (all code done, docs left) Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01CeDZgjQakpnGGtsWoC7ijF --- handoff.md | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/handoff.md b/handoff.md index 19db394..e48b050 100644 --- a/handoff.md +++ b/handoff.md @@ -122,14 +122,19 @@ next.** Implementing the ADR search + relationship navigation feature: 9. `36d7048` — wired `search` into `adr.py`'s CLI (added `STATUSES` import from `scripts.core.lifecycle` for `--status` choices). -Full suite at this point: `374 passed`. +10. `2ed781e` — `index.py` Relationships section (supersession chains + + related lists, titles shown alongside IDs, entries with no + relationships omitted). +11. `38e0d27` — localized the 5 new keys across all 8 + `scripts/i18n/*.json` catalogs + `test_locale.py`'s `REQUIRED_KEYS`. +12. `b6d9658` — `validate.py`: `BROKEN_SUPERSESSION_LINK` + + `SUPERSESSION_MISMATCH` (errors, matching `BROKEN_RELATED_LINK`'s + existing severity — `validate.py` has no warnings mechanism). Verified + against this repo's real 10 ADRs: `ok: true`, no errors. + +Full suite at this point: `379 passed`. **Tasks remaining (plan file has full code for each):** -10. `index.py` — Relationships section (English/fallback strings first). -11. Localize the 5 new keys across all 8 `scripts/i18n/*.json` catalogs + - `test_locale.py`'s `REQUIRED_KEYS`. -12. `validate.py` — `BROKEN_SUPERSESSION_LINK` + `SUPERSESSION_MISMATCH` - (errors, not warnings — `validate.py` has no warnings mechanism). 13. Docs: README (search usage + AND/OR semantics statement) + SKILL.md (`related` vs `search` framing) — run every shown command in a scratch repo first, don't hand-write example output. From 9694f873f6feae1a9b049d2864b41bb95a593653 Mon Sep 17 00:00:00 2001 From: shcommit Date: Mon, 31 Aug 2026 02:15:32 +0900 Subject: [PATCH 56/76] docs: document the search command and related vs search framing Every shown command was run in a scratch repository first; example output is real, not hand-written. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01CeDZgjQakpnGGtsWoC7ijF --- README.md | 46 +++++++++++++++++++++++++++++++++++++ skills/adr-toolkit/SKILL.md | 8 +++++++ 2 files changed, 54 insertions(+) diff --git a/README.md b/README.md index 52fe6f4..465be4a 100644 --- a/README.md +++ b/README.md @@ -144,6 +144,52 @@ exception onto its finding's `exception` field — the finding's `kind` and evidence, never a silent pass. Once `expiry` passes, CHECK stops applying it automatically. +## Search + +Find an existing ADR by keyword (title **and** body), tags, status, or the +file path it governs — without opening every file: + +```bash +python skills/adr-toolkit/scripts/adr.py search --keyword architecture --dir docs/decisions --json +``` + +```json +{ + "ok": true, + "operation": "search", + "query": {"keyword": "architecture", "tags": null, "status": null, "path": null, "limit": null}, + "count": 1, + "total": 1, + "truncated": false, + "results": [ + { + "id": "ADR-0001", + "filename": "0001-record-architecture-decisions.md", + "path": "docs/decisions/0001-record-architecture-decisions.md", + "title": "Record architecture decisions", + "status": "accepted", + "tags": ["process"], + "matched_in": ["title", "body"] + } + ], + "warnings": [] +} +``` + +**Filter semantics:** filters across different fields (`--keyword`, `--tags`, +`--status`, `--path`) are combined with AND. Multiple values within +`--tags` are combined with OR — `--tags postgres mysql` means "postgres or +mysql". No filters at all browses every ADR. `--path` matches a real file +against an ADR's governed scope (the same directory-boundary + glob logic +CHECK uses), not an exact match against the ADR's literal `affected_paths` +list. `--limit N` truncates the already-ranked (best-match-first) result +list; `total` is always the untruncated count and `truncated` is +`total > count`. + +`search` is a general lookup command ("has this been decided before?"), distinct +from `related` (used during RECORD's DISCOVER stage to find precedent for a +*new* draft, with a broader OR-across-fields match). + ## Scope - MADR 4.x format, no new standard. diff --git a/skills/adr-toolkit/SKILL.md b/skills/adr-toolkit/SKILL.md index 58ed1f2..d662e3d 100644 --- a/skills/adr-toolkit/SKILL.md +++ b/skills/adr-toolkit/SKILL.md @@ -156,6 +156,14 @@ workflow does not. Omit `--paths`, `--tags`, or `--keyword` when that evidence is unknown; never invent a path, tag, or keyword. Use the results to find ADRs that already cover, conflict with, or may be replaced by the candidate. + `related`'s filters are OR'd together (any single overlap is enough) — it + deliberately casts a broad net before drafting. For a general lookup + unrelated to drafting (a user asks "has this been decided before?" or + "what ADRs govern `src/payment/`?"), use `search` instead — its filters + combine with AND for precise results, it supports `--status` and + `--path` (real-file-vs-governed-scope, not an exact list match), it + ranks results best-match-first, and an empty query browses every ADR. + See README's "Search" section for the full contract. 3. **CLASSIFY** — apply the table above, then read `references/significance-rules.md`. Score only evidence-supported 0/1/2 values; never guess or inflate a score to force a band. Write all seven From 68e20073409ddd6d8ce5f7cdf6d6ea8543c486db Mon Sep 17 00:00:00 2001 From: shcommit Date: Mon, 31 Aug 2026 02:16:01 +0900 Subject: [PATCH 57/76] chore: regenerate docs/decisions/README.md with the Relationships section Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01CeDZgjQakpnGGtsWoC7ijF --- docs/decisions/README.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/docs/decisions/README.md b/docs/decisions/README.md index 9f2c48b..955800a 100644 --- a/docs/decisions/README.md +++ b/docs/decisions/README.md @@ -224,3 +224,13 @@ - 2026-08-30 — [ADR-0008 — Deterministic CHECK policy exceptions: schema-validated, annotate-only, never suppress](0008-check-exceptions-annotate-only.md) - 2026-08-30 — [ADR-0009 — --json is a documented no-op; CLI output is always JSON](0009-json-flag-always-json-contract.md) - 2026-08-30 — [ADR-0010 — Codex skill-creator's quick_validate.py incompatibility is not this project's problem](0010-codex-quick-validate-not-applicable.md) + +## Relationships + +### Supersession chains + +- ADR-0003 "Localize only index.py's generated strings, not agent-composed text" → superseded by → ADR-0006 "Localize deterministic ADR generation through repository configuration" + +### Related + +- ADR-0006 "Localize deterministic ADR generation through repository configuration" related to: ADR-0003 "Localize only index.py's generated strings, not agent-composed text" From 13accfa32e845236e585d55eecb52e7aa977a86e Mon Sep 17 00:00:00 2001 From: shcommit Date: Mon, 31 Aug 2026 02:17:13 +0900 Subject: [PATCH 58/76] docs: record ADR search/relationships feature as done improvements.md gets the full Done entry, project-roadmap.md's "ADR navigation and scale" item is split into a done bullet (search + relationship visibility) and three still-deferred ones (rendered graph, 500+-scale sharding/real search index, semantic retrieval), and handoff.md reflects the feature as complete with the P0 release gate as the only remaining open item. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01CeDZgjQakpnGGtsWoC7ijF --- handoff.md | 207 +++++++++++++++++++-------------------------- improvements.md | 19 +++++ project-roadmap.md | 17 ++-- 3 files changed, 116 insertions(+), 127 deletions(-) diff --git a/handoff.md b/handoff.md index e48b050..2af37c4 100644 --- a/handoff.md +++ b/handoff.md @@ -9,10 +9,14 @@ metadata compatibility, stale branch cleanup) were resolved with the owner's decision/approval. This session's own design decisions have been dogfooded as ADR-0007..0010. -Now implementing an additional, owner-approved feature: ADR search and -relationship navigation (see "Next step" below) — not part of v0.2.0's -original scope, taken on after the owner asked to improve ADR -navigability for real-world OSS adoption. +An additional, owner-approved feature — ADR search and relationship +navigation — has also been fully implemented and merged into this branch +(see "Touched since the last handoff"). Not part of v0.2.0's original scope; +taken on after the owner asked to improve ADR navigability for real-world +OSS adoption, and promoted out of `project-roadmap.md`'s "ADR navigation and +scale" item after research into comparable OSS tools showed search + +relationship visibility are proven table-stakes regardless of this repo's +own ADR count. The work runs in the linked worktree at `/Users/yangseunghyeon/orca/workspaces/ADR-toolkit/develop-2` (directory name unchanged; the branch itself was renamed). Its base is the @@ -29,137 +33,90 @@ Baseline verification before implementation: `212 passed` on 2026-08-30. ## Touched since the last handoff -- `c995904` — closed the Codex metadata compatibility item as not - applicable: `quick_validate.py` belongs to Codex's local skill-creator - tool, never the actual plugin install path (independently re-verified). -- `b1a4ec5` — deleted the two stale remote branches - (`origin/SHcommit/feat-plan-adr-toolkit`, - `origin/feat/adr-toolkit-mvp-implement`); both were fully merged into - `origin/develop` with no open PR referencing either. -- `2fa6d67` — recorded ADR-0007..0010 for this session's own design - decisions, scored via `adr.py significance` before writing: - - ADR-0007 (10, recommended) — CHECK's `kind`→`confidence` mapping - promoted to a stable output field. - - ADR-0008 (12, recommended) — deterministic CHECK policy exceptions, - schema-validated and annotate-only, never suppressing a violation. - - ADR-0009 (7, recommended) — `--json` is a documented no-op; CLI output - is always JSON. - - ADR-0010 (4, optional) — Codex `quick_validate.py` incompatibility is - not this project's problem, with the verification evidence recorded. - -(Earlier in this session — still current, not re-listed in detail: `b9e0ec3` -sync_version.py hardening, `baa0987` CI dedup, `2f2a8de` ADR directory -centralization, `061d44e` Codex adapter doc fix, `5d37bef` manifest -description ownership, `63c4c85` `--json` implementation, `2c73af6` CHECK -confidence field implementation, `6e68b5d` exception schema implementation -— ADR-0007..0010 above are the *rationale records* for the last three of -these.) - -Latest local verification on 2026-08-30: - -- `python3 -m pytest -q` → `327 passed`, exit 0 -- `python3 scripts/sync_version.py --check` → exit 0 -- `adr.py validate --dir docs/decisions` → `checked: 10`, no errors -- `adr.py index --dir docs/decisions` regenerated cleanly -- `git status --short` → clean - -The detailed v0.2.0 evidence and maturity assessment are in -`docs/adr-toolkit-v0.2.0-readiness-report.md` (not re-verified against the -commits since it was written — re-run its evidence commands before treating -its GO/NO-GO judgment as current); post-release governance is kept separate -in `docs/enterprise-adoption.md`, whose §8 "다음 구현 후보" now reflects -items 1, 3, and 4 as done. +**Session cleanup** (see prior handoff versions in git history for full +detail): `c995904` closed Codex metadata compatibility as not applicable, +`b1a4ec5` deleted two stale remote branches, `2fa6d67` recorded ADR-0007..0010. -## Next step +**ADR search and relationship navigation** — design: +`docs/superpowers/specs/2026-08-31-adr-search-and-relationships-design.md` +(`300456a`, revised after external review verification `f8b6423`). +Implementation, 14 TDD tasks, in order: -**In progress, executing inline (not subagent-driven) per owner's request for -tight commit/handoff cadence in case the session runs out and Codex picks up -next.** Implementing the ADR search + relationship navigation feature: - -- Design: `docs/superpowers/specs/2026-08-31-adr-search-and-relationships-design.md` - (`300456a`, revised after external review verification `f8b6423`, owner-approved). -- Plan: `docs/superpowers/plans/2026-08-31-adr-search-and-relationships.md` - — **gitignored, not committed to git; it only exists in this worktree's - filesystem.** If a fresh session (this one or Codex) needs to resume and that - file is somehow gone, re-derive the remaining tasks from the spec above (which - *is* committed) plus the "Tasks completed so far" list below — the spec's own - "Implementation sequencing" section has the same 9-step order the plan's 14 - tasks were broken out from. -- The plan has 14 bite-sized TDD tasks. **Follow strict TDD**: write the failing - test, watch it fail, write minimal code, watch it pass, commit — one task at a - time, in order, exactly as the plan file specifies (real code and test bodies - are already written out in the plan; don't improvise different ones). - -**Tasks completed so far (commits, in order):** -1. `20254cf` — extracted `path_under` into `core/globs.py`, refactored - `rules/conflict.py` to use it (pure refactor, `test_conflict.py`/ - `test_check.py` still pass unmodified). -2. `7c95692` — `core/query.py`: `matches_keyword` (title+body fix), - `matches_tags_any`, `matches_paths_exact`, `path_governed_by`. +1. `20254cf` — extracted `path_under` into `core/globs.py` (pure refactor + of `rules/conflict.py`'s prefix matching). +2. `7c95692` — `core/query.py`: `matches_keyword` (title+body fix, was + title-only before), `matches_tags_any`, `matches_paths_exact`, + `path_governed_by`. 3. `ff25b68` — `core/query.py`: `rank_key` deterministic ranking (5 tiers, no numeric score exposed publicly). 4. `7ade27d` — `core/relationships.py`: `Relationship` NamedTuple + `resolve()`. 5. `c597a30` — `core/relationships.py`: `missing_targets()` + `supersession_mismatches()`. -6. `c8c2b06` — migrated `related.py` onto `core/query.py`, fixed body-search - bug; **also found and fixed a real regression while doing this**: the - migration would have dropped `related.py`'s original `_as_list()` guard - against a malformed ADR carrying a non-list value for `affected_paths`/ - `tags` (e.g. a plain YAML string instead of a list), which would silently - decompose into individual characters and produce nonsense matches. Fixed - by restoring the guard as `_as_list()` inside `core/query.py` itself, - applied to all three matchers including `path_governed_by` (which had the - same latent bug with no prior test catching it — added one). +6. `c8c2b06` — migrated `related.py` onto `core/query.py`, fixed the + body-search bug (policy unchanged: still OR-across-fields). Also found + and fixed a real regression the migration would otherwise have + introduced, plus one latent bug: both `matches_tags_any`/ + `matches_paths_exact` and `path_governed_by` needed a restored + `_as_list()` guard against a malformed ADR carrying a non-list value for + `affected_paths`/`tags` (would otherwise silently iterate a string + character-by-character). 7. `5db2471` — `search` command core filtering (browse mode, AND-across-fields, OR-within-field, `--path` via `path_governed_by`). - **Note for whoever writes ADR test fixtures next**: this repo's - `frontmatter.py` is a minimal custom parser, not real YAML — it only - understands `key: []` or block-style `key:\n - item`. A flow-style - `tags: ['x']` silently parses as the literal string `"['x']"`, not a - list. Hit this exact bug writing `test_search_command.py`'s fixture - helper; fixed by rendering block-list YAML instead. 8. `9a86666` — ranking, `--limit`/`total`/`truncated`, `query` echo. -9. `36d7048` — wired `search` into `adr.py`'s CLI (added `STATUSES` import - from `scripts.core.lifecycle` for `--status` choices). - +9. `36d7048` — wired `search` into `adr.py`'s CLI. 10. `2ed781e` — `index.py` Relationships section (supersession chains + - related lists, titles shown alongside IDs, entries with no - relationships omitted). + related lists, titles alongside IDs, entries with no relationships + omitted). 11. `38e0d27` — localized the 5 new keys across all 8 - `scripts/i18n/*.json` catalogs + `test_locale.py`'s `REQUIRED_KEYS`. + `scripts/i18n/*.json` catalogs. 12. `b6d9658` — `validate.py`: `BROKEN_SUPERSESSION_LINK` + `SUPERSESSION_MISMATCH` (errors, matching `BROKEN_RELATED_LINK`'s - existing severity — `validate.py` has no warnings mechanism). Verified - against this repo's real 10 ADRs: `ok: true`, no errors. - -Full suite at this point: `379 passed`. - -**Tasks remaining (plan file has full code for each):** -13. Docs: README (search usage + AND/OR semantics statement) + SKILL.md - (`related` vs `search` framing) — run every shown command in a scratch - repo first, don't hand-write example output. -14. Final verification: full suite, `sync_version.py --check`, - `validate`/`index` against this repo's real 10 ADRs, `git diff --check`, - commit the regenerated `docs/decisions/README.md`, then update - `improvements.md`/`handoff.md` to mark this feature done. - -After Task 14: `improvements.md` will have exactly one open item left, the P0 -release gate (unchanged from before this feature started). - -Once that work lands: `improvements.md` still has exactly one open item, the -P0 release gate. Open the final PR (from -`feature/v0.2.0-multilingual-and-check-confidence` against `develop`), get -required CI green, get the owner's approval for the v0.2.0 version bump -(`skills/adr-toolkit/VERSION` is still `0.1.0`), merge through a release -branch, and verify the tag lands on the intended `master` commit. The branch -has not been pushed to `origin` yet (no upstream configured) — the owner has -said they'll handle the public-repository transition themselves later, and -push/PR have not been authorized in this session. - -`project-roadmap.md`'s remaining items (everything except "ADR navigation and -scale," now superseded by the spec above) were reviewed end to end: each is -explicitly gated on usage evidence or a separate design decision that doesn't -exist yet — nothing else there is actionable now. + existing severity). +13. `9694f87` — documented `search` in README (with real, scratch-repo- + verified example output) and the `related` vs `search` framing in + SKILL.md. +14. `68e2007` — regenerated `docs/decisions/README.md` with the new + Relationships section. + +**Note for whoever writes ADR test fixtures next**: this repo's +`frontmatter.py` is a minimal custom parser, not real YAML — it only +understands `key: []` or block-style `key:\n - item`. A flow-style +`tags: ['x']` silently parses as the literal string `"['x']"`, not a list. +Hit this exact bug while writing `test_search_command.py`'s fixtures. + +Latest local verification on 2026-08-31: + +- `python3 -m pytest -q` → `379 passed`, exit 0 +- `python3 scripts/sync_version.py --check` → exit 0 +- `adr.py validate --dir docs/decisions` → `checked: 10`, no errors (the new + relationship-integrity checks don't flag ADR-0003/ADR-0006's real + supersession) +- `adr.py index --dir docs/decisions` regenerated cleanly, Relationships + section correct +- `git status --short` → clean + +The detailed v0.2.0 evidence and maturity assessment (predating the search +feature) are in `docs/adr-toolkit-v0.2.0-readiness-report.md` — not +re-verified against commits since it was written; re-run its evidence +commands before treating its GO/NO-GO judgment as current. Post-release +governance is kept separate in `docs/enterprise-adoption.md`. + +## Next step + +`improvements.md` has exactly one open item left: the P0 release gate. Open +the final PR (from `feature/v0.2.0-multilingual-and-check-confidence` +against `develop`), get required CI green, get the owner's approval for the +v0.2.0 version bump (`skills/adr-toolkit/VERSION` is still `0.1.0`), merge +through a release branch, and verify the tag lands on the intended `master` +commit. The branch has not been pushed to `origin` yet (no upstream +configured) — the owner has said they'll handle the public-repository +transition themselves later, and push/PR have not been authorized in this +session. + +`project-roadmap.md` was updated to mark "ADR navigation and scale"'s first +bullet done, with the remaining three (graph *rendering* specifically, +500+-scale sharding/real search index, semantic retrieval) still explicitly +gated on usage evidence that doesn't exist yet. An open, undecided idea from this session: measuring how much LLM-token input/output the deterministic core saves an agent versus doing the same @@ -171,10 +128,16 @@ benchmarking script) has been decided. ## Open risks - Catalog structure is deterministic, but terminology still benefits from - native-speaker review in each supported language. + native-speaker review in each supported language — now including the 5 + new Relationships-section keys. - CHECK deliberately cannot prove prose, business rationale, or organizational claims; those remain human-review evidence. The exception mechanism extends this: an exception is recorded and annotated, never a silent pass. +- Search/relationship matching is deterministic substring/exact/prefix + matching, untested at real scale (this repo has 10 ADRs); if this project + grows well past that, revisit `project-roadmap.md`'s remaining + navigation-and-scale items rather than assuming the current approach + still holds. - GitHub protection is intentionally unavailable in the current private plan; configure and API-verify it after the repository becomes public (owner's own follow-up, not scheduled here). diff --git a/improvements.md b/improvements.md index 63248d7..81d221d 100644 --- a/improvements.md +++ b/improvements.md @@ -15,6 +15,25 @@ Concrete implementation backlog. Unscheduled product bets belong in ## Done +- [x] Added ADR search and relationship navigation, promoted out of + `project-roadmap.md`'s "ADR navigation and scale" item after research into + comparable OSS ADR tools (`npryce/adr-tools` has no search; + `thomvaill/log4brains`, 1.5k stars, treats search + relationship + visibility as headline features) showed both are proven table-stakes + regardless of this repo's own ADR count. Design: + `docs/superpowers/specs/2026-08-31-adr-search-and-relationships-design.md`. + Delivered: `core/globs.path_under()` (extracted from `rules/conflict.py`), + new `core/query.py` (keyword/tag/path matching + deterministic ranking) + and `core/relationships.py` (canonical `Relationship` model), `related.py` + fixed to search body text too (policy unchanged — still an OR-across- + fields broad net), new `adr.py search` command (AND-across-fields, + OR-within-field, `--path` via governed-scope matching, `--limit`/`total`/ + `truncated`, deterministic best-match-first ordering), a Relationships + section in the generated index (supersession chains + related lists with + titles, localized across all 8 catalogs), and `validate.py` relationship- + integrity checks (`BROKEN_SUPERSESSION_LINK`, `SUPERSESSION_MISMATCH`). + Graph rendering, semantic search, and directory sharding remain + deliberately deferred in `project-roadmap.md`. - [x] Cleaned stale remote branches: `origin/SHcommit/feat-plan-adr-toolkit` and `origin/feat/adr-toolkit-mvp-implement` were both fully merged into `origin/develop` with no open PR referencing either (`gh pr list --state diff --git a/project-roadmap.md b/project-roadmap.md index 4389a66..25e1a1b 100644 --- a/project-roadmap.md +++ b/project-roadmap.md @@ -22,11 +22,18 @@ before implementation. Concrete selected work belongs in `improvements.md`. ## ADR navigation and scale -- Render `related`/`supersedes` relationships as a graph, not only index lists. -- Test whether 500+ decisions require sharding, alternate indexes, or search - beyond the flat-directory model. -- Improve related-decision discovery beyond path/tag/keyword only after real - misses demonstrate the need for semantic retrieval. +- ~~Add a general search command and relationship visibility.~~ **Done + (2026-08-31).** `adr.py search` (keyword now matches body, not just + title; tag/status/governed-path filters; deterministic ranking) and a + Relationships section in the generated index. See `improvements.md`'s + Done entry and `docs/superpowers/specs/2026-08-31-adr-search-and-relationships-design.md`. +- Render `related`/`supersedes` relationships as a rendered graph image (DOT/ + Mermaid), not just the text-based index lists search/index now provide. +- Test whether 500+ decisions require sharding, alternate indexes, or a + real search index (this repo has 10 ADRs; substring/tag/path matching is + untested at that scale). +- Improve related-decision discovery beyond path/tag/keyword/body-substring + only after real misses demonstrate the need for semantic retrieval. ## Internationalization From 8efde929950ae8e2aaacd8a3ecee656a9d7f19f2 Mon Sep 17 00:00:00 2001 From: shcommit Date: Mon, 31 Aug 2026 02:33:59 +0900 Subject: [PATCH 59/76] feat: detect supersession cycles as a validate error find_cycles() walks only "supersedes" edges (not "related", which is symmetric and has no logical cycle concept) via DFS, returning each cycle's node path. validate.py reports SUPERSESSION_CYCLE, matching the existing error-only contract. Verified against this repository's real 10 ADRs: ok, no errors. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01CeDZgjQakpnGGtsWoC7ijF --- .../adr-toolkit/scripts/commands/validate.py | 5 +- .../adr-toolkit/scripts/core/relationships.py | 39 +++++++++++++++ tests/unit/test_relationships.py | 47 ++++++++++++++++++- tests/unit/test_validate.py | 20 ++++++++ 4 files changed, 109 insertions(+), 2 deletions(-) diff --git a/skills/adr-toolkit/scripts/commands/validate.py b/skills/adr-toolkit/scripts/commands/validate.py index 4fd4fc6..afb614c 100644 --- a/skills/adr-toolkit/scripts/commands/validate.py +++ b/skills/adr-toolkit/scripts/commands/validate.py @@ -4,7 +4,7 @@ from scripts.core import frontmatter as fm from scripts.core.adr_directory import iter_adr_files from scripts.core.config import ConfigError, load_repository_config -from scripts.core.relationships import missing_targets, resolve, supersession_mismatches +from scripts.core.relationships import find_cycles, missing_targets, resolve, supersession_mismatches from scripts.core.schema import validate_frontmatter from scripts.core.repository_paths import resolve_from_root @@ -85,4 +85,7 @@ def run(args) -> dict: "expected_superseded_by_on": target, }) + for cycle in find_cycles(edges): + errors.append({"code": "SUPERSESSION_CYCLE", "cycle": list(cycle)}) + return {"ok": not errors, "operation": "validate", "checked": checked, "errors": errors} diff --git a/skills/adr-toolkit/scripts/core/relationships.py b/skills/adr-toolkit/scripts/core/relationships.py index 49f0e8f..865ccbd 100644 --- a/skills/adr-toolkit/scripts/core/relationships.py +++ b/skills/adr-toolkit/scripts/core/relationships.py @@ -30,3 +30,42 @@ def supersession_mismatches(relationships: list) -> list: supersedes_edges = {(r.source, r.target) for r in relationships if r.type == "supersedes"} superseded_by_edges = {(r.target, r.source) for r in relationships if r.type == "superseded_by"} return sorted(supersedes_edges - superseded_by_edges) + + +def find_cycles(relationships: list) -> list: + """Detect cycles among "supersedes" edges only -- "related" is symmetric + in meaning, so an A<->B related pair is not a logical error the way a + supersession cycle (A supersedes B supersedes A) is. + + Returns a list of cycle paths, each a tuple of ADR ids in the order the + depth-first walk encountered them. + """ + graph: dict = {} + for r in relationships: + if r.type == "supersedes": + graph.setdefault(r.source, []).append(r.target) + + cycles = [] + visited = set() + + def dfs(node, path, path_set): + if node in path_set: + cycle_start = path.index(node) + cycle = tuple(path[cycle_start:]) + if cycle not in cycles: + cycles.append(cycle) + return + if node in visited: + return + visited.add(node) + path.append(node) + path_set.add(node) + for neighbor in graph.get(node, []): + dfs(neighbor, path, path_set) + path.pop() + path_set.discard(node) + + for node in sorted(graph): + dfs(node, [], set()) + + return cycles diff --git a/tests/unit/test_relationships.py b/tests/unit/test_relationships.py index 27ffaf9..97b9dde 100644 --- a/tests/unit/test_relationships.py +++ b/tests/unit/test_relationships.py @@ -1,4 +1,10 @@ -from scripts.core.relationships import Relationship, missing_targets, resolve, supersession_mismatches +from scripts.core.relationships import ( + Relationship, + find_cycles, + missing_targets, + resolve, + supersession_mismatches, +) ENTRIES = [ {"id": "ADR-0003", "related": [], "supersedes": [], "superseded_by": "ADR-0006"}, @@ -58,3 +64,42 @@ def test_supersession_mismatches_finds_one_sided_edge(): edges = resolve(one_sided) mismatches = supersession_mismatches(edges) assert ("ADR-0010", "ADR-0009") in mismatches + + +def test_find_cycles_empty_for_a_linear_chain(): + linear = [ + {"id": "ADR-0001", "supersedes": [], "superseded_by": "ADR-0002"}, + {"id": "ADR-0002", "supersedes": ["ADR-0001"], "superseded_by": None}, + ] + assert find_cycles(resolve(linear)) == [] + + +def test_find_cycles_detects_a_direct_two_node_cycle(): + mutual = [ + {"id": "ADR-0001", "supersedes": ["ADR-0002"], "superseded_by": None}, + {"id": "ADR-0002", "supersedes": ["ADR-0001"], "superseded_by": None}, + ] + cycles = find_cycles(resolve(mutual)) + assert len(cycles) == 1 + assert set(cycles[0]) == {"ADR-0001", "ADR-0002"} + + +def test_find_cycles_detects_a_longer_cycle(): + triangle = [ + {"id": "ADR-0001", "supersedes": ["ADR-0002"], "superseded_by": None}, + {"id": "ADR-0002", "supersedes": ["ADR-0003"], "superseded_by": None}, + {"id": "ADR-0003", "supersedes": ["ADR-0001"], "superseded_by": None}, + ] + cycles = find_cycles(resolve(triangle)) + assert len(cycles) == 1 + assert set(cycles[0]) == {"ADR-0001", "ADR-0002", "ADR-0003"} + + +def test_find_cycles_ignores_related_edges(): + # related is symmetric/non-directional in meaning; A<->B related is not + # a logical error the way a supersession cycle is. + mutually_related = [ + {"id": "ADR-0001", "related": ["ADR-0002"]}, + {"id": "ADR-0002", "related": ["ADR-0001"]}, + ] + assert find_cycles(resolve(mutually_related)) == [] diff --git a/tests/unit/test_validate.py b/tests/unit/test_validate.py index b394911..b9ead3a 100644 --- a/tests/unit/test_validate.py +++ b/tests/unit/test_validate.py @@ -154,3 +154,23 @@ def test_bidirectional_supersession_has_no_relationship_errors(tmp_path): result = validate.run(SimpleNamespace(dir=str(tmp_path))) assert result["ok"] is True + + +def test_supersession_cycle_is_an_error(tmp_path): + (tmp_path / "0001-a.md").write_text( + "---\nid: ADR-0001\ntitle: A\nstatus: accepted\ndate: 2026-08-31\n" + "decision_makers: []\nrelated: []\naffected_paths: []\ntags: []\n" + "retrospective: false\nsupersedes:\n - ADR-0002\n---\n\nBody.\n", + encoding="utf-8", + ) + (tmp_path / "0002-b.md").write_text( + "---\nid: ADR-0002\ntitle: B\nstatus: accepted\ndate: 2026-08-31\n" + "decision_makers: []\nrelated: []\naffected_paths: []\ntags: []\n" + "retrospective: false\nsupersedes:\n - ADR-0001\n---\n\nBody.\n", + encoding="utf-8", + ) + + result = validate.run(SimpleNamespace(dir=str(tmp_path))) + + assert result["ok"] is False + assert any(e["code"] == "SUPERSESSION_CYCLE" for e in result["errors"]) From b8d93e4158ccc87f524a37d1984163ec6685a141 Mon Sep 17 00:00:00 2001 From: shcommit Date: Mon, 31 Aug 2026 02:35:12 +0900 Subject: [PATCH 60/76] feat: add --id exact lookup to the search command --id combines with other filters via the same AND policy as keyword/tags/status/path, rather than a separate short-circuit path, keeping search's combination semantics uniform across every filter. Verified against a real ADR in this repository. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01CeDZgjQakpnGGtsWoC7ijF --- skills/adr-toolkit/scripts/adr.py | 1 + skills/adr-toolkit/scripts/commands/search.py | 7 ++++++ tests/unit/test_search_command.py | 25 +++++++++++++++++-- 3 files changed, 31 insertions(+), 2 deletions(-) diff --git a/skills/adr-toolkit/scripts/adr.py b/skills/adr-toolkit/scripts/adr.py index 82d2100..cb93296 100755 --- a/skills/adr-toolkit/scripts/adr.py +++ b/skills/adr-toolkit/scripts/adr.py @@ -139,6 +139,7 @@ def build_parser() -> argparse.ArgumentParser: _add_json_flag(p_exception) p_search = sub.add_parser("search") + p_search.add_argument("--id") p_search.add_argument("--keyword") p_search.add_argument("--tags", nargs="*") p_search.add_argument("--status", choices=sorted(STATUSES)) diff --git a/skills/adr-toolkit/scripts/commands/search.py b/skills/adr-toolkit/scripts/commands/search.py index a235f00..642ca6a 100644 --- a/skills/adr-toolkit/scripts/commands/search.py +++ b/skills/adr-toolkit/scripts/commands/search.py @@ -13,6 +13,7 @@ def run(args) -> dict: adr_dir = Path(args.dir) + adr_id = getattr(args, "id", None) keyword = (getattr(args, "keyword", None) or "").lower() query_tags = set(getattr(args, "tags", None) or []) status = getattr(args, "status", None) @@ -33,6 +34,11 @@ def run(args) -> dict: matched_in = [] + if adr_id: + if data.get("id") != adr_id: + continue + matched_in.append("id") + if keyword: title_hit = keyword in (data.get("title") or "").lower() body_hit = keyword in (body or "").lower() @@ -85,6 +91,7 @@ def run(args) -> dict: "ok": True, "operation": "search", "query": { + "id": adr_id, "keyword": getattr(args, "keyword", None), "tags": getattr(args, "tags", None), "status": status, diff --git a/tests/unit/test_search_command.py b/tests/unit/test_search_command.py index 86ae958..3bddbf1 100644 --- a/tests/unit/test_search_command.py +++ b/tests/unit/test_search_command.py @@ -30,7 +30,7 @@ def _write_adr(adr_dir, filename, id, title, status="accepted", affected_paths=N def _args(adr_dir, **overrides): - defaults = dict(dir=str(adr_dir), keyword=None, tags=None, status=None, path=None, limit=None) + defaults = dict(dir=str(adr_dir), keyword=None, tags=None, status=None, path=None, limit=None, id=None) defaults.update(overrides) return SimpleNamespace(**defaults) @@ -161,10 +161,31 @@ def test_search_echoes_the_query(tmp_path): result = search.run(_args(adr_dir, keyword="a", tags=["cli"], status="accepted")) assert result["query"] == { - "keyword": "a", "tags": ["cli"], "status": "accepted", "path": None, "limit": None, + "id": None, "keyword": "a", "tags": ["cli"], "status": "accepted", "path": None, "limit": None, } +def test_search_id_filter_returns_exact_match_only(tmp_path): + adr_dir = tmp_path / "docs" / "decisions" + _write_adr(adr_dir, "0001-a.md", id="ADR-0001", title="A") + _write_adr(adr_dir, "0002-b.md", id="ADR-0002", title="B") + + result = search.run(_args(adr_dir, id="ADR-0002")) + + assert result["count"] == 1 + assert result["results"][0]["id"] == "ADR-0002" + assert result["results"][0]["matched_in"] == ["id"] + + +def test_search_id_filter_no_match_returns_empty(tmp_path): + adr_dir = tmp_path / "docs" / "decisions" + _write_adr(adr_dir, "0001-a.md", id="ADR-0001", title="A") + + result = search.run(_args(adr_dir, id="ADR-9999")) + + assert result["count"] == 0 + + def test_search_malformed_adr_degrades_to_warning(tmp_path): adr_dir = tmp_path / "docs" / "decisions" adr_dir.mkdir(parents=True) From 72427e1cbe5aa1436015dc969d0f7d39e3dba390 Mon Sep 17 00:00:00 2001 From: shcommit Date: Mon, 31 Aug 2026 02:36:09 +0900 Subject: [PATCH 61/76] docs: document cycle detection and --id lookup, checkpoint handoff.md Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01CeDZgjQakpnGGtsWoC7ijF --- README.md | 16 ++++++++-------- handoff.md | 21 +++++++++++++++++---- improvements.md | 7 ++++--- 3 files changed, 29 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index 465be4a..d3064a6 100644 --- a/README.md +++ b/README.md @@ -176,15 +176,15 @@ python skills/adr-toolkit/scripts/adr.py search --keyword architecture --dir doc } ``` -**Filter semantics:** filters across different fields (`--keyword`, `--tags`, -`--status`, `--path`) are combined with AND. Multiple values within +**Filter semantics:** filters across different fields (`--id`, `--keyword`, +`--tags`, `--status`, `--path`) are combined with AND. Multiple values within `--tags` are combined with OR — `--tags postgres mysql` means "postgres or -mysql". No filters at all browses every ADR. `--path` matches a real file -against an ADR's governed scope (the same directory-boundary + glob logic -CHECK uses), not an exact match against the ADR's literal `affected_paths` -list. `--limit N` truncates the already-ranked (best-match-first) result -list; `total` is always the untruncated count and `truncated` is -`total > count`. +mysql". No filters at all browses every ADR. `--id` looks up one ADR by its +exact id. `--path` matches a real file against an ADR's governed scope (the +same directory-boundary + glob logic CHECK uses), not an exact match against +the ADR's literal `affected_paths` list. `--limit N` truncates the +already-ranked (best-match-first) result list; `total` is always the +untruncated count and `truncated` is `total > count`. `search` is a general lookup command ("has this been decided before?"), distinct from `related` (used during RECORD's DISCOVER stage to find precedent for a diff --git a/handoff.md b/handoff.md index 2af37c4..0b3f277 100644 --- a/handoff.md +++ b/handoff.md @@ -78,6 +78,17 @@ Implementation, 14 TDD tasks, in order: 14. `68e2007` — regenerated `docs/decisions/README.md` with the new Relationships section. +**Post-spec additions** (owner asked to add 2 of the external review's +smaller deferred items after the 14-task plan landed; graph rendering, +semantic search, and `adr serve` remain out of scope, per the review's own +§22 and this project's evidence-before-building principle): + +15. `8efde92` — `core/relationships.find_cycles()` (DFS over `supersedes` + edges only — `related` is symmetric, no cycle concept applies) + + `validate.py`'s `SUPERSESSION_CYCLE` error. +16. `b8d93e4` — `search --id` for exact ADR lookup, combined via the same + AND policy as every other filter (not a separate short-circuit path). + **Note for whoever writes ADR test fixtures next**: this repo's `frontmatter.py` is a minimal custom parser, not real YAML — it only understands `key: []` or block-style `key:\n - item`. A flow-style @@ -86,13 +97,15 @@ Hit this exact bug while writing `test_search_command.py`'s fixtures. Latest local verification on 2026-08-31: -- `python3 -m pytest -q` → `379 passed`, exit 0 +- `python3 -m pytest -q` → `386 passed`, exit 0 - `python3 scripts/sync_version.py --check` → exit 0 -- `adr.py validate --dir docs/decisions` → `checked: 10`, no errors (the new - relationship-integrity checks don't flag ADR-0003/ADR-0006's real - supersession) +- `adr.py validate --dir docs/decisions` → `checked: 10`, no errors (neither + the relationship-integrity checks nor the new cycle check flag ADR-0003/ + ADR-0006's real supersession) - `adr.py index --dir docs/decisions` regenerated cleanly, Relationships section correct +- `adr.py search --id ADR-0007 --dir docs/decisions --json` → real result + verified against this repository's actual ADR-0007 - `git status --short` → clean The detailed v0.2.0 evidence and maturity assessment (predating the search diff --git a/improvements.md b/improvements.md index 81d221d..1d18c72 100644 --- a/improvements.md +++ b/improvements.md @@ -31,9 +31,10 @@ Concrete implementation backlog. Unscheduled product bets belong in `truncated`, deterministic best-match-first ordering), a Relationships section in the generated index (supersession chains + related lists with titles, localized across all 8 catalogs), and `validate.py` relationship- - integrity checks (`BROKEN_SUPERSESSION_LINK`, `SUPERSESSION_MISMATCH`). - Graph rendering, semantic search, and directory sharding remain - deliberately deferred in `project-roadmap.md`. + integrity checks (`BROKEN_SUPERSESSION_LINK`, `SUPERSESSION_MISMATCH`, + `SUPERSESSION_CYCLE`). `search --id` added for exact lookup. Graph + rendering, semantic search, and directory sharding remain deliberately + deferred in `project-roadmap.md`. - [x] Cleaned stale remote branches: `origin/SHcommit/feat-plan-adr-toolkit` and `origin/feat/adr-toolkit-mvp-implement` were both fully merged into `origin/develop` with no open PR referencing either (`gh pr list --state From 22f76f5dd90f9d8d9bfc631a31845bbb2c7ea541 Mon Sep 17 00:00:00 2001 From: shcommit Date: Mon, 31 Aug 2026 02:39:28 +0900 Subject: [PATCH 62/76] docs: explain why ADR storage stays flat instead of sharded folders README's Search section documented the command but not the design rationale. Added it: checked how npryce/adr-tools (no search, expects grep) and log4brains (1.5k+ stars, most-adopted actively maintained ADR tool, still flat storage + search/graph layered on top) handle this before deciding. The corollary this repo follows is that retrieval, not storage layout, is where a growing ADR set gets harder to use -- folder sharding, a rendered relationship graph, and a real search index stay deliberately deferred in project-roadmap.md until ADR count actually demonstrates the need. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01CeDZgjQakpnGGtsWoC7ijF --- README.md | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/README.md b/README.md index d3064a6..3840378 100644 --- a/README.md +++ b/README.md @@ -190,6 +190,33 @@ untruncated count and `truncated` is `total > count`. from `related` (used during RECORD's DISCOVER stage to find precedent for a *new* draft, with a broader OR-across-fields match). +### Why storage stays flat + +ADRs live as `NNNN-slug.md` in one flat `docs/decisions/` directory — no +per-year, per-team, or per-status subfolders — no matter how many +accumulate. Two comparable real-world tools were checked before deciding +this: [`npryce/adr-tools`](https://github.com/npryce/adr-tools), the +original Nygard-style CLI, ships no search at all and expects `grep`; the +most-adopted actively maintained ADR tool, +[`log4brains`](https://github.com/thomvaill/log4brains) (1.5k+ GitHub +stars), also keeps ADRs as flat Markdown and instead layers a full-text +search and a relationship graph on top — it does not shard the source +files by count. A folder hierarchy also creates a real problem flat storage +avoids: a decision that spans two teams or domains has no unambiguous +folder to live in. + +The corollary is **retrieval, not storage, is where a growing ADR set gets +harder to use** — so that's where the effort went: `search`'s +title-and-body keyword/tag/status/path matching plus the generated index's +"By status" / "By tag" / "By affected path" / "Relationships" views. Both +already work identically for 10 ADRs or 500. Folder sharding, a rendered +relationship graph, and a real search index are deliberately not built yet +— they're tracked in [`project-roadmap.md`](project-roadmap.md), gated on +this repository (or an adopting team's) ADR count actually reaching a scale +where flat-directory substring search stops being fast enough. Building +that ahead of evidence would add real maintenance cost for a problem no one +has hit yet. + ## Scope - MADR 4.x format, no new standard. From 3ccd1e3964b51a12be40715a2c8e0b13fe0f6da0 Mon Sep 17 00:00:00 2001 From: shcommit Date: Mon, 31 Aug 2026 03:05:06 +0900 Subject: [PATCH 63/76] docs: record PR #2 opened, CI pending Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01CeDZgjQakpnGGtsWoC7ijF --- handoff.md | 25 ++++++++++++++----------- 1 file changed, 14 insertions(+), 11 deletions(-) diff --git a/handoff.md b/handoff.md index 0b3f277..f681c02 100644 --- a/handoff.md +++ b/handoff.md @@ -28,8 +28,12 @@ current `origin/develop` history plus the approved design commits: Baseline verification before implementation: `212 passed` on 2026-08-30. -**Branch renamed**: `develop-2` → `feature/v0.2.0-multilingual-and-check-confidence` -(local rename only; not yet pushed, no upstream configured). +**Branch renamed**: `develop-2` → `feature/v0.2.0-multilingual-and-check-confidence`. +**Pushed to `origin` and PR opened**: owner approved push + PR (not yet the +version bump/tag). PR #2 against `develop`: +https://github.com/SHcommit/ADR-toolkit/pull/2 — CI (5-leg pytest matrix + +`version-drift` job) triggered on open; check its status before assuming +green. ## Touched since the last handoff @@ -116,15 +120,14 @@ governance is kept separate in `docs/enterprise-adoption.md`. ## Next step -`improvements.md` has exactly one open item left: the P0 release gate. Open -the final PR (from `feature/v0.2.0-multilingual-and-check-confidence` -against `develop`), get required CI green, get the owner's approval for the -v0.2.0 version bump (`skills/adr-toolkit/VERSION` is still `0.1.0`), merge -through a release branch, and verify the tag lands on the intended `master` -commit. The branch has not been pushed to `origin` yet (no upstream -configured) — the owner has said they'll handle the public-repository -transition themselves later, and push/PR have not been authorized in this -session. +`improvements.md` has exactly one open item left: the P0 release gate. +**PR #2 is open** (https://github.com/SHcommit/ADR-toolkit/pull/2), CI +running. Next: confirm all 6 checks (5-leg pytest matrix + version-drift) +are green — `gh pr checks 2` — before treating this as mergeable. Still +required after that, each needing separate owner approval not yet given: +the v0.2.0 version bump (`skills/adr-toolkit/VERSION` is still `0.1.0`), +merging through a release branch, and verifying the tag lands on the +intended `master` commit. `project-roadmap.md` was updated to mark "ADR navigation and scale"'s first bullet done, with the remaining three (graph *rendering* specifically, From e58d8b15c6fa51e6bc172394e2e61d2591cdf33c Mon Sep 17 00:00:00 2001 From: shcommit Date: Mon, 31 Aug 2026 03:07:21 +0900 Subject: [PATCH 64/76] fix: decode git subprocess output as UTF-8 explicitly, not locale default Windows CI failed test_uncommitted_mode_preserves_unicode_untracked_path_and_content: a Unicode filename round-tripped through subprocess.run(..., text=True) without capture as garbage on windows-latest (both 3.9 and 3.12), while macOS/Ubuntu passed. Root cause: without an explicit encoding=, Python decodes subprocess output using the platform's preferred locale encoding, which is UTF-8 on macOS/Linux but not guaranteed on Windows -- git itself always writes UTF-8. Added encoding="utf-8" to every git subprocess.run() call in diff.py and git_paths.py. Pre-existing bug from earlier CHECK-correctness work in this branch; only surfaced now because this branch's CI had never run before this PR. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01CeDZgjQakpnGGtsWoC7ijF --- skills/adr-toolkit/scripts/commands/diff.py | 8 ++++---- skills/adr-toolkit/scripts/core/git_paths.py | 1 + 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/skills/adr-toolkit/scripts/commands/diff.py b/skills/adr-toolkit/scripts/commands/diff.py index 09e968e..6a5780e 100644 --- a/skills/adr-toolkit/scripts/commands/diff.py +++ b/skills/adr-toolkit/scripts/commands/diff.py @@ -15,7 +15,7 @@ def run(args) -> dict: repo_check = subprocess.run( ["git", "-C", str(root), "rev-parse", "--is-inside-work-tree"], - capture_output=True, text=True, + capture_output=True, text=True, encoding="utf-8", ) if repo_check.returncode != 0: return { @@ -43,7 +43,7 @@ def run(args) -> dict: "git", "-C", str(root), "-c", "core.quotePath=false", "diff", "--name-status", "-z", *diff_options, "--end-of-options", *range_args, ], - capture_output=True, text=True, + capture_output=True, text=True, encoding="utf-8", ) if name_status.returncode != 0: code = "INVALID_REF" if mode == "since" else "GIT_DIFF_FAILED" @@ -54,7 +54,7 @@ def run(args) -> dict: "git", "-C", str(root), "-c", "core.quotePath=false", "diff", "--unified=0", *diff_options, "--end-of-options", *range_args, ], - capture_output=True, text=True, + capture_output=True, text=True, encoding="utf-8", ) if patch.returncode != 0: code = "INVALID_REF" if mode == "since" else "GIT_DIFF_FAILED" @@ -89,7 +89,7 @@ def _untracked_files(root: Path) -> list: "git", "-C", str(root), "ls-files", "--others", "--exclude-standard", "-z", ], - capture_output=True, text=True, + capture_output=True, text=True, encoding="utf-8", ) if listing.returncode != 0: raise _GitCommandError( diff --git a/skills/adr-toolkit/scripts/core/git_paths.py b/skills/adr-toolkit/scripts/core/git_paths.py index 8afdbda..9b8b028 100644 --- a/skills/adr-toolkit/scripts/core/git_paths.py +++ b/skills/adr-toolkit/scripts/core/git_paths.py @@ -15,6 +15,7 @@ def list_existing_paths(root: Path) -> set: ], capture_output=True, text=True, + encoding="utf-8", ) if result.returncode != 0: raise GitPathsError(result.stderr.strip() or "git ls-files failed") From 105b776e0be7f457be6e031a6d01c161e5475dd8 Mon Sep 17 00:00:00 2001 From: shcommit Date: Mon, 31 Aug 2026 03:09:55 +0900 Subject: [PATCH 65/76] docs: record PR #2 CI green and mergeable, spell out remaining approval gates Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01CeDZgjQakpnGGtsWoC7ijF --- handoff.md | 46 ++++++++++++++++++++++++++++++++++------------ 1 file changed, 34 insertions(+), 12 deletions(-) diff --git a/handoff.md b/handoff.md index f681c02..9d274fc 100644 --- a/handoff.md +++ b/handoff.md @@ -29,11 +29,24 @@ current `origin/develop` history plus the approved design commits: Baseline verification before implementation: `212 passed` on 2026-08-30. **Branch renamed**: `develop-2` → `feature/v0.2.0-multilingual-and-check-confidence`. -**Pushed to `origin` and PR opened**: owner approved push + PR (not yet the -version bump/tag). PR #2 against `develop`: -https://github.com/SHcommit/ADR-toolkit/pull/2 — CI (5-leg pytest matrix + -`version-drift` job) triggered on open; check its status before assuming -green. +**Pushed to `origin`, PR #2 open against `develop`, CI green**: +https://github.com/SHcommit/ADR-toolkit/pull/2 — all 6 checks (5-leg pytest +matrix + `version-drift`) passing, `mergeable`. Owner approved push + PR +opening; **not** the version bump/release branch/tag — those still need +separate explicit approval. + +CI caught a real pre-existing Windows-only bug on the first run (both +`windows-latest` legs failed, macOS/Ubuntu/version-drift passed): +`test_uncommitted_mode_preserves_unicode_untracked_path_and_content` in +`test_diff.py`. Root cause: none of `diff.py`'s or `git_paths.py`'s +`subprocess.run(..., text=True)` calls passed `encoding="utf-8"`, so Python +decoded git's UTF-8 output using the platform's default locale encoding — +correct on macOS/Linux, not guaranteed on Windows. Fixed in `e58d8b1` by +adding `encoding="utf-8"` explicitly to every git subprocess call in both +files; re-run confirmed all 6 checks green. This was a latent bug from +earlier CHECK-correctness work in this branch (commits `801fa4c`/`d691a33`, +predating this session's search/relationships work) — it only surfaced now +because this branch had never run CI before this PR. ## Touched since the last handoff @@ -121,13 +134,22 @@ governance is kept separate in `docs/enterprise-adoption.md`. ## Next step `improvements.md` has exactly one open item left: the P0 release gate. -**PR #2 is open** (https://github.com/SHcommit/ADR-toolkit/pull/2), CI -running. Next: confirm all 6 checks (5-leg pytest matrix + version-drift) -are green — `gh pr checks 2` — before treating this as mergeable. Still -required after that, each needing separate owner approval not yet given: -the v0.2.0 version bump (`skills/adr-toolkit/VERSION` is still `0.1.0`), -merging through a release branch, and verifying the tag lands on the -intended `master` commit. +**PR #2 is open, CI green, `mergeable`** +(https://github.com/SHcommit/ADR-toolkit/pull/2). Everything left needs +separate, explicit owner approval not yet given, in order: + +1. The v0.2.0 version bump (`skills/adr-toolkit/VERSION` is still `0.1.0`; + run `scripts/sync_version.py` after bumping to propagate). +2. Merging PR #2 (into `develop`) — normal merge or squash, per repo + convention; no merge has been authorized in this session. +3. Cutting a `release/*` branch into `master` and back into `develop`, per + `AGENTS.md`'s Git Flow policy. +4. Pushing a `v0.2.0` tag on `master` — `.github/workflows/release.yml` + then runs the full suite, verifies manifest versions against the tag, + and publishes the GitHub Release automatically. + +Do not perform 1-4 without asking first — each is a separate explicit +approval point, not implied by "CI is green." `project-roadmap.md` was updated to mark "ADR navigation and scale"'s first bullet done, with the remaining three (graph *rendering* specifically, From 29b2e228b44198ff364916ea93a32b46efbcdb7e Mon Sep 17 00:00:00 2001 From: shcommit Date: Mon, 31 Aug 2026 03:24:02 +0900 Subject: [PATCH 66/76] chore: bump version to 0.2.0 Owner-approved minor release version bump. skills/adr-toolkit/VERSION is the source of truth; scripts/sync_version.py propagated it to every manifest that duplicates it (.claude-plugin/plugin.json, adapters/gemini-cli/gemini-extension.json, SKILL.md's frontmatter). Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01CeDZgjQakpnGGtsWoC7ijF --- .claude-plugin/plugin.json | 2 +- adapters/gemini-cli/gemini-extension.json | 2 +- skills/adr-toolkit/SKILL.md | 2 +- skills/adr-toolkit/VERSION | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index aa12830..7597106 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,5 +1,5 @@ { "name": "adr-toolkit", - "version": "0.1.0", + "version": "0.2.0", "description": "Initialize, record, and check Architecture Decision Records by inspecting the repository and existing decisions before asking questions." } diff --git a/adapters/gemini-cli/gemini-extension.json b/adapters/gemini-cli/gemini-extension.json index aa12830..7597106 100644 --- a/adapters/gemini-cli/gemini-extension.json +++ b/adapters/gemini-cli/gemini-extension.json @@ -1,5 +1,5 @@ { "name": "adr-toolkit", - "version": "0.1.0", + "version": "0.2.0", "description": "Initialize, record, and check Architecture Decision Records by inspecting the repository and existing decisions before asking questions." } diff --git a/skills/adr-toolkit/SKILL.md b/skills/adr-toolkit/SKILL.md index d662e3d..e9ff071 100644 --- a/skills/adr-toolkit/SKILL.md +++ b/skills/adr-toolkit/SKILL.md @@ -2,7 +2,7 @@ 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 +version: 0.2.0 --- # ADR Toolkit diff --git a/skills/adr-toolkit/VERSION b/skills/adr-toolkit/VERSION index 6e8bf73..0ea3a94 100644 --- a/skills/adr-toolkit/VERSION +++ b/skills/adr-toolkit/VERSION @@ -1 +1 @@ -0.1.0 +0.2.0 From 9eccf6127d813646afd2cc9bdca1fc0d19d38332 Mon Sep 17 00:00:00 2001 From: shcommit Date: Mon, 31 Aug 2026 03:26:00 +0900 Subject: [PATCH 67/76] docs: record version bump complete, CI re-verified green Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01CeDZgjQakpnGGtsWoC7ijF --- handoff.md | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/handoff.md b/handoff.md index 9d274fc..37a2ea1 100644 --- a/handoff.md +++ b/handoff.md @@ -135,11 +135,13 @@ governance is kept separate in `docs/enterprise-adoption.md`. `improvements.md` has exactly one open item left: the P0 release gate. **PR #2 is open, CI green, `mergeable`** -(https://github.com/SHcommit/ADR-toolkit/pull/2). Everything left needs -separate, explicit owner approval not yet given, in order: +(https://github.com/SHcommit/ADR-toolkit/pull/2). Remaining steps, each +needing separate, explicit owner approval: -1. The v0.2.0 version bump (`skills/adr-toolkit/VERSION` is still `0.1.0`; - run `scripts/sync_version.py` after bumping to propagate). +1. ✅ **Done** (`29b2e22`) — v0.2.0 version bump. `skills/adr-toolkit/VERSION` + is `0.2.0`; `scripts/sync_version.py` propagated it to + `.claude-plugin/plugin.json`, `adapters/gemini-cli/gemini-extension.json`, + `SKILL.md`. Pushed; CI re-ran green (6/6) on this commit too. 2. Merging PR #2 (into `develop`) — normal merge or squash, per repo convention; no merge has been authorized in this session. 3. Cutting a `release/*` branch into `master` and back into `develop`, per @@ -148,8 +150,9 @@ separate, explicit owner approval not yet given, in order: then runs the full suite, verifies manifest versions against the tag, and publishes the GitHub Release automatically. -Do not perform 1-4 without asking first — each is a separate explicit -approval point, not implied by "CI is green." +Do not perform 2-4 without asking first — each is a separate explicit +approval point, not implied by "CI is green" or by an earlier step's +approval. `project-roadmap.md` was updated to mark "ADR navigation and scale"'s first bullet done, with the remaining three (graph *rendering* specifically, From b841357e9f6f527c80408047312d2d3dd713af01 Mon Sep 17 00:00:00 2001 From: shcommit Date: Mon, 31 Aug 2026 13:15:41 +0900 Subject: [PATCH 68/76] docs: consolidate completed work into changelog.md, trim tracking docs Recorded everything completed this session (ADR search/relationships, CHECK confidence/exceptions, --json contract, sync_version hardening, Windows encoding fix, v0.2.0 bump) in changelog.md, which already serves as this project's durable human-readable history per AGENTS.md. improvements.md's Done section and handoff.md's long commit-by-commit walkthrough are redundant with that plus git log, so both are trimmed to current/active state only -- nothing is lost, it's consolidated into the file whose stated purpose is exactly this record. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01CeDZgjQakpnGGtsWoC7ijF --- changelog.md | 35 +++++++++ handoff.md | 205 +++++++++--------------------------------------- improvements.md | 117 +-------------------------- 3 files changed, 76 insertions(+), 281 deletions(-) diff --git a/changelog.md b/changelog.md index 944484e..dab90e5 100644 --- a/changelog.md +++ b/changelog.md @@ -4,6 +4,41 @@ Lightweight human-readable summary of meaningful repository changes. ## Unreleased +- Added `adr.py search`: finds an existing ADR by keyword (title **and** + body — `related`'s keyword match was title-only before), tags, status, + `--id`, or the real file path it governs, with deterministic best-match + ordering and `--limit`/`total`/`truncated` for bounding results. Added a + Relationships section (supersession chains + related lists, titles + alongside IDs) to the generated `docs/decisions/README.md`, localized + across all 8 catalogs, and `validate` checks for broken supersession + links, one-sided supersession edits, and supersession cycles. +- Added CHECK policy exceptions: `adr.py exception` validates and records a + schema-checked, owned, scoped, time-boxed exception; CHECK annotates a + matching finding with it but never suppresses or downgrades the finding — + an exception is visible evidence, not a silent pass. +- Promoted CHECK's violation classification to a stable `confidence` field + (`VERIFIED`/`VIOLATED`/`UNVERIFIABLE`) on every finding, instead of an + agent re-deriving it from documentation each run. +- Fixed a Windows-only bug where CHECK's git evidence gathering + (`diff.py`/`git_paths.py`) decoded subprocess output using the platform's + default locale encoding instead of UTF-8, garbling Unicode paths/content + on Windows CI while working correctly on macOS/Linux. +- Hardened `scripts/sync_version.py` against three silent-drift bugs + (a tracked manifest losing its version key, a manifest path outside the + repo root crashing instead of failing cleanly, non-ASCII manifest content + being escaped on write) and extended it to keep manifest `description` + fields in sync with `SKILL.md`'s frontmatter, the same way `version` + already was. +- Formalized `--json` as a documented no-op: every command has always + printed JSON regardless of the flag; `--help` now says so explicitly + instead of implying a choice that doesn't exist. +- Corrected repository ADR provenance and lifecycle: existing ADRs now name + an approved decision maker and locale, and ADR-0006 (repository-default + locale generation) supersedes ADR-0003 (index-only localization) + bidirectionally. This session's own design decisions were recorded as + ADR-0007..0010 (CHECK confidence field, exception mechanism, `--json` + contract, and a closed-as-not-applicable Codex tooling question). +- Bumped to v0.2.0. - Added repository-configured deterministic ADR localization for eight locales (`en`, `ko`, `ja`, `zh`, `fr`, `es`, `de`, `pt-BR`) across INIT, CREATE, templates, prompts, and INDEX, with strict config/schema validation and diff --git a/handoff.md b/handoff.md index 37a2ea1..a942d74 100644 --- a/handoff.md +++ b/handoff.md @@ -2,184 +2,55 @@ ## Current task (2026-08-31) -ADR Toolkit `v0.2.0` minor-release implementation is complete. The -`improvements.md` P1 backlog has been fully worked down — every item that -was actionable by an agent is done, and the two that weren't (Codex -metadata compatibility, stale branch cleanup) were resolved with the -owner's decision/approval. This session's own design decisions have been -dogfooded as ADR-0007..0010. +`v0.2.0` release gate is in progress — the only remaining work +(`improvements.md`'s single open item). Everything else (multilingual ADR +generation, CHECK hardening, exception mechanism, ADR search and +relationship navigation) is implemented and merged into this branch; see +`changelog.md` for what shipped and `git log` for exactly how. -An additional, owner-approved feature — ADR search and relationship -navigation — has also been fully implemented and merged into this branch -(see "Touched since the last handoff"). Not part of v0.2.0's original scope; -taken on after the owner asked to improve ADR navigability for real-world -OSS adoption, and promoted out of `project-roadmap.md`'s "ADR navigation and -scale" item after research into comparable OSS tools showed search + -relationship visibility are proven table-stakes regardless of this repo's -own ADR count. - -The work runs in the linked worktree at `/Users/yangseunghyeon/orca/workspaces/ADR-toolkit/develop-2` -(directory name unchanged; the branch itself was renamed). Its base is the -current `origin/develop` history plus the approved design commits: - -- `7b9bb8c` — initial localization/readiness design -- `b69a784` — v0.2.0 release gates, repository config, CHECK confidence, and - report separation - -Baseline verification before implementation: `212 passed` on 2026-08-30. - -**Branch renamed**: `develop-2` → `feature/v0.2.0-multilingual-and-check-confidence`. -**Pushed to `origin`, PR #2 open against `develop`, CI green**: -https://github.com/SHcommit/ADR-toolkit/pull/2 — all 6 checks (5-leg pytest -matrix + `version-drift`) passing, `mergeable`. Owner approved push + PR -opening; **not** the version bump/release branch/tag — those still need -separate explicit approval. - -CI caught a real pre-existing Windows-only bug on the first run (both -`windows-latest` legs failed, macOS/Ubuntu/version-drift passed): -`test_uncommitted_mode_preserves_unicode_untracked_path_and_content` in -`test_diff.py`. Root cause: none of `diff.py`'s or `git_paths.py`'s -`subprocess.run(..., text=True)` calls passed `encoding="utf-8"`, so Python -decoded git's UTF-8 output using the platform's default locale encoding — -correct on macOS/Linux, not guaranteed on Windows. Fixed in `e58d8b1` by -adding `encoding="utf-8"` explicitly to every git subprocess call in both -files; re-run confirmed all 6 checks green. This was a latent bug from -earlier CHECK-correctness work in this branch (commits `801fa4c`/`d691a33`, -predating this session's search/relationships work) — it only surfaced now -because this branch had never run CI before this PR. - -## Touched since the last handoff - -**Session cleanup** (see prior handoff versions in git history for full -detail): `c995904` closed Codex metadata compatibility as not applicable, -`b1a4ec5` deleted two stale remote branches, `2fa6d67` recorded ADR-0007..0010. - -**ADR search and relationship navigation** — design: -`docs/superpowers/specs/2026-08-31-adr-search-and-relationships-design.md` -(`300456a`, revised after external review verification `f8b6423`). -Implementation, 14 TDD tasks, in order: - -1. `20254cf` — extracted `path_under` into `core/globs.py` (pure refactor - of `rules/conflict.py`'s prefix matching). -2. `7c95692` — `core/query.py`: `matches_keyword` (title+body fix, was - title-only before), `matches_tags_any`, `matches_paths_exact`, - `path_governed_by`. -3. `ff25b68` — `core/query.py`: `rank_key` deterministic ranking (5 tiers, - no numeric score exposed publicly). -4. `7ade27d` — `core/relationships.py`: `Relationship` NamedTuple + `resolve()`. -5. `c597a30` — `core/relationships.py`: `missing_targets()` + - `supersession_mismatches()`. -6. `c8c2b06` — migrated `related.py` onto `core/query.py`, fixed the - body-search bug (policy unchanged: still OR-across-fields). Also found - and fixed a real regression the migration would otherwise have - introduced, plus one latent bug: both `matches_tags_any`/ - `matches_paths_exact` and `path_governed_by` needed a restored - `_as_list()` guard against a malformed ADR carrying a non-list value for - `affected_paths`/`tags` (would otherwise silently iterate a string - character-by-character). -7. `5db2471` — `search` command core filtering (browse mode, - AND-across-fields, OR-within-field, `--path` via `path_governed_by`). -8. `9a86666` — ranking, `--limit`/`total`/`truncated`, `query` echo. -9. `36d7048` — wired `search` into `adr.py`'s CLI. -10. `2ed781e` — `index.py` Relationships section (supersession chains + - related lists, titles alongside IDs, entries with no relationships - omitted). -11. `38e0d27` — localized the 5 new keys across all 8 - `scripts/i18n/*.json` catalogs. -12. `b6d9658` — `validate.py`: `BROKEN_SUPERSESSION_LINK` + - `SUPERSESSION_MISMATCH` (errors, matching `BROKEN_RELATED_LINK`'s - existing severity). -13. `9694f87` — documented `search` in README (with real, scratch-repo- - verified example output) and the `related` vs `search` framing in - SKILL.md. -14. `68e2007` — regenerated `docs/decisions/README.md` with the new - Relationships section. - -**Post-spec additions** (owner asked to add 2 of the external review's -smaller deferred items after the 14-task plan landed; graph rendering, -semantic search, and `adr serve` remain out of scope, per the review's own -§22 and this project's evidence-before-building principle): - -15. `8efde92` — `core/relationships.find_cycles()` (DFS over `supersedes` - edges only — `related` is symmetric, no cycle concept applies) + - `validate.py`'s `SUPERSESSION_CYCLE` error. -16. `b8d93e4` — `search --id` for exact ADR lookup, combined via the same - AND policy as every other filter (not a separate short-circuit path). - -**Note for whoever writes ADR test fixtures next**: this repo's -`frontmatter.py` is a minimal custom parser, not real YAML — it only -understands `key: []` or block-style `key:\n - item`. A flow-style -`tags: ['x']` silently parses as the literal string `"['x']"`, not a list. -Hit this exact bug while writing `test_search_command.py`'s fixtures. - -Latest local verification on 2026-08-31: - -- `python3 -m pytest -q` → `386 passed`, exit 0 -- `python3 scripts/sync_version.py --check` → exit 0 -- `adr.py validate --dir docs/decisions` → `checked: 10`, no errors (neither - the relationship-integrity checks nor the new cycle check flag ADR-0003/ - ADR-0006's real supersession) -- `adr.py index --dir docs/decisions` regenerated cleanly, Relationships - section correct -- `adr.py search --id ADR-0007 --dir docs/decisions --json` → real result - verified against this repository's actual ADR-0007 -- `git status --short` → clean - -The detailed v0.2.0 evidence and maturity assessment (predating the search -feature) are in `docs/adr-toolkit-v0.2.0-readiness-report.md` — not -re-verified against commits since it was written; re-run its evidence -commands before treating its GO/NO-GO judgment as current. Post-release -governance is kept separate in `docs/enterprise-adoption.md`. +Work runs in the linked worktree at +`/Users/yangseunghyeon/orca/workspaces/ADR-toolkit/develop-2`, branch +`feature/v0.2.0-multilingual-and-check-confidence`, pushed to `origin`. ## Next step -`improvements.md` has exactly one open item left: the P0 release gate. -**PR #2 is open, CI green, `mergeable`** -(https://github.com/SHcommit/ADR-toolkit/pull/2). Remaining steps, each -needing separate, explicit owner approval: +**PR #2 open against `develop`, CI green (6/6), `mergeable`**: +https://github.com/SHcommit/ADR-toolkit/pull/2 -1. ✅ **Done** (`29b2e22`) — v0.2.0 version bump. `skills/adr-toolkit/VERSION` - is `0.2.0`; `scripts/sync_version.py` propagated it to - `.claude-plugin/plugin.json`, `adapters/gemini-cli/gemini-extension.json`, - `SKILL.md`. Pushed; CI re-ran green (6/6) on this commit too. -2. Merging PR #2 (into `develop`) — normal merge or squash, per repo - convention; no merge has been authorized in this session. -3. Cutting a `release/*` branch into `master` and back into `develop`, per - `AGENTS.md`'s Git Flow policy. -4. Pushing a `v0.2.0` tag on `master` — `.github/workflows/release.yml` - then runs the full suite, verifies manifest versions against the tag, - and publishes the GitHub Release automatically. +Release gate steps, each needing separate explicit owner approval — do not +perform any of the remaining ones without asking first, "CI is green" does +not imply approval for the next step: -Do not perform 2-4 without asking first — each is a separate explicit -approval point, not implied by "CI is green" or by an earlier step's -approval. +1. ✅ **Done** — v0.2.0 version bump (`skills/adr-toolkit/VERSION` → + `0.2.0`, propagated via `scripts/sync_version.py`); CI re-ran green. +2. Merge PR #2 into `develop` (normal merge or squash — ask which). +3. Cut a `release/*` branch into `master` and back into `develop`, per + `AGENTS.md`'s Git Flow policy. +4. Push a `v0.2.0` tag on `master` — + `.github/workflows/release.yml` then runs the full suite, verifies + manifest versions against the tag, and publishes the GitHub Release. -`project-roadmap.md` was updated to mark "ADR navigation and scale"'s first -bullet done, with the remaining three (graph *rendering* specifically, -500+-scale sharding/real search index, semantic retrieval) still explicitly -gated on usage evidence that doesn't exist yet. +Latest local verification (2026-08-31): `python3 -m pytest -q` → `386 +passed`; `sync_version.py --check` → exit 0; `adr.py validate` → 10 ADRs, +no errors; `git status --short` → clean. An open, undecided idea from this session: measuring how much LLM-token input/output the deterministic core saves an agent versus doing the same -work by reading/writing everything by hand (e.g. CHECK returning a -structured finding instead of the agent scanning a full diff). Discussed but -deliberately deferred — no scope or destination (new doc section vs. -benchmarking script) has been decided. +work by hand (e.g. CHECK returning a structured finding instead of the +agent scanning a full diff). Deliberately deferred — no scope or +destination decided. ## Open risks -- Catalog structure is deterministic, but terminology still benefits from - native-speaker review in each supported language — now including the 5 - new Relationships-section keys. -- CHECK deliberately cannot prove prose, business rationale, or organizational - claims; those remain human-review evidence. The exception mechanism - extends this: an exception is recorded and annotated, never a silent pass. +- CHECK deliberately cannot prove prose, business rationale, or + organizational claims; those remain human-review evidence. The exception + mechanism extends this — an exception is recorded and annotated, never a + silent pass. - Search/relationship matching is deterministic substring/exact/prefix - matching, untested at real scale (this repo has 10 ADRs); if this project - grows well past that, revisit `project-roadmap.md`'s remaining - navigation-and-scale items rather than assuming the current approach - still holds. -- GitHub protection is intentionally unavailable in the current private plan; - configure and API-verify it after the repository becomes public (owner's - own follow-up, not scheduled here). -- Version bump, release branch, push, and tag are not authorized by this task. + matching, untested at real scale (this repo has 10 ADRs); revisit + `project-roadmap.md`'s navigation-and-scale items if that changes. +- GitHub branch/tag protection is intentionally unavailable on the current + private plan; configure and API-verify after the repository goes public + (owner's own follow-up, not scheduled here). +- Version bump is done; release branch, push, and tag remain unauthorized + until the owner approves each step above. diff --git a/improvements.md b/improvements.md index 1d18c72..00eada5 100644 --- a/improvements.md +++ b/improvements.md @@ -15,117 +15,6 @@ Concrete implementation backlog. Unscheduled product bets belong in ## Done -- [x] Added ADR search and relationship navigation, promoted out of - `project-roadmap.md`'s "ADR navigation and scale" item after research into - comparable OSS ADR tools (`npryce/adr-tools` has no search; - `thomvaill/log4brains`, 1.5k stars, treats search + relationship - visibility as headline features) showed both are proven table-stakes - regardless of this repo's own ADR count. Design: - `docs/superpowers/specs/2026-08-31-adr-search-and-relationships-design.md`. - Delivered: `core/globs.path_under()` (extracted from `rules/conflict.py`), - new `core/query.py` (keyword/tag/path matching + deterministic ranking) - and `core/relationships.py` (canonical `Relationship` model), `related.py` - fixed to search body text too (policy unchanged — still an OR-across- - fields broad net), new `adr.py search` command (AND-across-fields, - OR-within-field, `--path` via governed-scope matching, `--limit`/`total`/ - `truncated`, deterministic best-match-first ordering), a Relationships - section in the generated index (supersession chains + related lists with - titles, localized across all 8 catalogs), and `validate.py` relationship- - integrity checks (`BROKEN_SUPERSESSION_LINK`, `SUPERSESSION_MISMATCH`, - `SUPERSESSION_CYCLE`). `search --id` added for exact lookup. Graph - rendering, semantic search, and directory sharding remain deliberately - deferred in `project-roadmap.md`. -- [x] Cleaned stale remote branches: `origin/SHcommit/feat-plan-adr-toolkit` - and `origin/feat/adr-toolkit-mvp-implement` were both fully merged into - `origin/develop` with no open PR referencing either (`gh pr list --state - open` returned none repo-wide); owner approved deletion, deleted via - `git push origin --delete`, and confirmed gone via `git fetch --prune`. - -- [x] Decided Codex metadata compatibility: no code change needed. - `quick_validate.py`'s rejection of `user-invocable`/`version` comes from - Codex CLI's own local `skill-creator` authoring tool - (`~/.codex/skills/.system/skill-creator/`), not from the actual plugin - install/discovery path (`codex plugin marketplace add` / - `codex plugin add`), which never reads that script and was independently - verified to work. `skill-creator` targets simple, Codex-only skills built - from scratch with a narrower schema (`name`/`description`/`license`/ - `allowed-tools`/`metadata`); `adr-toolkit` is a deliberately cross-harness - skill following the Agent Plugins standard instead, so there is no real - compatibility gap to close — only an incidental collision if someone points - that unrelated tool at this repo's `SKILL.md` by hand. -- [x] Added a small, deterministic schema for CHECK policy exceptions: - `adr.py exception --input ` validates `owner`/`reason`/`scope`/ - `expiry`/`adr_id`/`rule_id` and writes `docs/decisions/exceptions/NNNN.json` - as `EXC-NNNN`. CHECK loads active (non-expired, schema-valid) exceptions and - annotates a matching finding's `exception` field — it never suppresses or - downgrades the finding itself, keeping `kind`/`confidence` exactly what the - structural evidence says. A malformed exception file degrades to a - `BAD_EXCEPTION` warning, matching the existing ADR/constraints pattern. -- [x] Promoted CHECK's confidence classification to a stable field: every - finding now carries `confidence` (`VERIFIED`/`VIOLATED`/`UNVERIFIABLE`) - computed directly from its `kind`, instead of leaving that mapping for the - agent to re-derive from prose docs each time. Updated README, SKILL.md, - `conflict-rules.md`, and quickstart's example output to match. -- [x] Resolved `--json` semantics: adopted "always JSON to stdout" as the - deliberate, tested contract (matching the code's own existing - "JSON-only-stdout contract" comment and every real caller), rather than - inventing a human-readable mode or removing the flag in a breaking change. - `--json` is now documented via `--help` as a no-op kept for backward - compatibility; added a regression test proving output is JSON with or - without it; added it to the three doc examples that omitted it, for - consistency. -- [x] Decided manifest-description ownership: `SKILL.md`'s frontmatter - `description:` is now the single canonical source, synced by - `sync_version.py` into `.claude-plugin/plugin.json`, - `adapters/codex/.codex-plugin/plugin.json`, - `adapters/gemini-cli/gemini-extension.json`, and - `adapters/antigravity/plugin.json`, with `--check` enforcing it in CI. Fixed - the one real drift this uncovered (`.claude-plugin/plugin.json` was missing - "and existing decisions"). -- [x] Corrected `adapters/codex/README.md`: re-verified against Codex CLI - 0.151.0 that the documented install flow works with zero - `adapters/codex/skills/` symlink present (marketplace root resolves to the - repo root's own `.claude-plugin/plugin.json`, whose sibling `skills/` is - the real package), removed the misleading symlink as install step 1, and - added an explicit section marking `.codex-plugin/plugin.json` and its - optional symlink as structural-only, not exercised by the verified path. -- [x] Centralized ADR directory loading: `related`, `index`, `validate`, and - `check` now share `core.adr_directory.iter_adr_files` instead of each - redefining `SKIP_FILES` and re-walking the directory; each command keeps its - own bad-filename/frontmatter handling (silent skip vs. reportable error). -- [x] Moved the manifest version-drift check out of the 5-leg OS/Python test - matrix into its own single `version-drift` job, since the check is a - repository-content invariant, not a per-platform/per-interpreter one. -- [x] Hardened `sync_version.py`: a tracked manifest that keeps its file but - loses its `version` key now fails `--check` instead of reporting no drift; - a manifest path outside the repo root raises the intended `SystemExit` - instead of an unrelated `ValueError`; JSON writes use `ensure_ascii=False` - so non-ASCII manifest content survives a sync unescaped. -- [x] Added the versioned `.adr-toolkit.json` locale contract with strict - validation and CLI → draft → repository → English precedence. -- [x] Added deterministic INIT, CREATE, template, prompt, and INDEX rendering - for `en/ko/ja/zh/fr/es/de/pt-BR`, with exact catalog parity and an end-to-end - locale matrix. -- [x] Preserved Unicode ADR content while enforcing portable ASCII filenames, - optional approved semantic slugs, and the deterministic `decision` fallback. -- [x] Kept runtime and JSON Schema locale validation in parity without breaking - locale-less ADRs. -- [x] Made CHECK fail closed on incomplete git evidence and covered subprocess, - rename, Unicode, tracked deletion, invalid ADR visibility, ignored-path, Git - path-semantics, and conflicting-mode defects. -- [x] Made relative ADR directories resolve consistently against `--root` for - INIT, CREATE, INDEX, VALIDATE, and CHECK instead of the caller's CWD. -- [x] Raised the dogfooded ADR set to the repository quality contract and - superseded ADR-0003 with the approved ADR-0006 localization decision. -- [x] Documented repository defaults, per-command overrides, eight locales, - semantic slugs, and CHECK confidence in README, SKILL, and quickstart. -- [x] Added a real Korean quickstart path covering repository-default locale, - Unicode content, semantic slug, fallback filename, and localized index. -- [x] Published separate Korean v0.2.0 readiness and enterprise-adoption - reports with measurable maturity criteria and official external references. -- [x] Passed local release evidence: 290-test full suite, version-drift check, - six-ADR validation, stable generated index, and whitespace checks. -- [x] Integrated the MVP feature work through `develop` and released `v0.1.0` - from `master`. -- [x] Merged PR #1 into `develop`, adding dogfooded ADRs, the root README, - executable quickstart, and local-artifact ignore rules. +Resolved items are recorded in `changelog.md` (what shipped) and git history +(exactly how) rather than kept here — this section stays empty between +sessions. From 8f0343ef56210848aa3167f872ee0e0ea950b81b Mon Sep 17 00:00:00 2001 From: shcommit Date: Mon, 31 Aug 2026 14:21:33 +0900 Subject: [PATCH 69/76] feat: add ADR relationship graph exports --- .github/PULL_REQUEST_TEMPLATE.md | 22 ++++ CONTRIBUTING.md | 48 +++++++ README.md | 20 +++ SECURITY.md | 34 +++++ adapters/codex/README.md | 17 ++- changelog.md | 7 + docs/decisions/README.md | 16 +++ docs/decisions/relationships.mmd | 13 ++ docs/decisions/relationships.svg | 41 ++++++ ...1-public-readiness-and-adr-graph-design.md | 27 ++++ project-roadmap.md | 7 - skills/adr-toolkit/SKILL.md | 14 ++ skills/adr-toolkit/scripts/adr.py | 9 ++ skills/adr-toolkit/scripts/commands/graph.py | 65 ++++++++++ skills/adr-toolkit/scripts/commands/index.py | 9 +- .../adr-toolkit/scripts/core/relationships.py | 114 +++++++++++++++++ tests/unit/test_adr_cli.py | 14 ++ tests/unit/test_graph_command.py | 120 ++++++++++++++++++ tests/unit/test_index.py | 3 + tests/unit/test_relationships.py | 46 ++++++- 20 files changed, 625 insertions(+), 21 deletions(-) create mode 100644 .github/PULL_REQUEST_TEMPLATE.md create mode 100644 CONTRIBUTING.md create mode 100644 SECURITY.md create mode 100644 docs/decisions/relationships.mmd create mode 100644 docs/decisions/relationships.svg create mode 100644 docs/superpowers/specs/2026-08-31-public-readiness-and-adr-graph-design.md create mode 100644 skills/adr-toolkit/scripts/commands/graph.py create mode 100644 tests/unit/test_graph_command.py diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..304542a --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,22 @@ +## Summary + +- + +## ADR Impact + +- [ ] I checked whether this changes an accepted architectural decision. +- [ ] I linked related ADRs, or this PR has no ADR impact. +- [ ] I ran `adr.py check` when the change touches governed paths. + +Related ADRs: + +## Verification + +- [ ] `python3 -m pytest -q` +- [ ] `python3 scripts/sync_version.py --check` +- [ ] `python3 skills/adr-toolkit/scripts/adr.py validate --dir docs/decisions --json` +- [ ] `python3 skills/adr-toolkit/scripts/adr.py index --dir docs/decisions --json` + +## Notes + +- diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..4445b47 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,48 @@ +# Contributing + +Thanks for helping improve ADR Toolkit. This project is intentionally small: +the agent can reason and draft, but deterministic scripts own file writes, +IDs, lifecycle transitions, validation, indexes, and machine-readable output. + +## Branches + +Follow the repository policy in `AGENTS.md`. + +- Branch from `develop` for normal work. +- Use `feature/*`, `fix/*`, or `docs/*` for short-lived work branches. +- Merge normal work back to `develop` through a pull request. +- Release branches merge to `master` and back to `develop`. +- Tags named `v*` are created only from `master`. + +## Local Checks + +Run the relevant checks before opening a pull request: + +```bash +python3 -m pytest -q +python3 scripts/sync_version.py --check +python3 skills/adr-toolkit/scripts/adr.py validate --dir docs/decisions --json +python3 skills/adr-toolkit/scripts/adr.py index --dir docs/decisions --json +``` + +For changes that may affect accepted architectural decisions, also run: + +```bash +python3 skills/adr-toolkit/scripts/adr.py check --uncommitted --dir docs/decisions --json +``` + +## ADR Changes + +- Keep ADR source files flat under `docs/decisions/`. +- Do not hand-edit generated indexes or lifecycle links to repair validation + errors. Change the source ADR or use the lifecycle command. +- Use `adr.py create`, `status`, `deprecate`, `supersede`, and `exception` for + deterministic mutations. +- Accepted ADRs are append-only in spirit: use supersession for changed + decisions instead of rewriting history. + +## Public Repository Hygiene + +Public repository branch/tag protection is expected to enforce PRs, required +checks, conversation resolution, and force-push/deletion restrictions on +`develop`, `master`, and `v*` tags once the repository is public. diff --git a/README.md b/README.md index 3840378..5db17d3 100644 --- a/README.md +++ b/README.md @@ -190,6 +190,26 @@ untruncated count and `truncated` is `total > count`. from `related` (used during RECORD's DISCOVER stage to find precedent for a *new* draft, with a broader OR-across-fields match). +## Relationship graph + +`index` embeds a Mermaid relationship graph in `docs/decisions/README.md` +whenever ADRs have `related` or `supersedes` links, so GitHub can render the +navigation view directly. + +For a standalone graph artifact, export Mermaid and SVG files: + +```bash +python skills/adr-toolkit/scripts/adr.py graph --dir docs/decisions --format both --json +``` + +This writes `docs/decisions/relationships.mmd` and +`docs/decisions/relationships.svg`. SVG is the default image artifact because it +stays sharp when zoomed and does not require Mermaid CLI, Node, or a browser. +With `--format both`, `--output build/adr-relationships` is treated as a file +prefix and writes `build/adr-relationships.mmd` plus +`build/adr-relationships.svg`; relative output paths are resolved from +`--root`, matching the other repository-scoped commands. + ### Why storage stays flat ADRs live as `NNNN-slug.md` in one flat `docs/decisions/` directory — no diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..84f604e --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,34 @@ +# Security Policy + +## Supported Versions + +Security fixes target the latest released version of ADR Toolkit. + +## Reporting a Vulnerability + +Please report security issues privately to the repository owner before opening a +public issue. Include: + +- affected version or commit +- reproduction steps +- expected impact +- whether the issue affects generated ADR content, command execution, path + handling, release automation, or plugin installation + +Do not include exploit details in a public issue until a fix or mitigation is +available. + +## Scope + +In scope: + +- command injection or unsafe path handling in deterministic scripts +- release workflow or version provenance problems +- plugin manifest behavior that can install or expose unintended files +- validation bugs that silently report clean results for invalid ADR state + +Out of scope: + +- disagreement with an ADR's architectural recommendation +- AI-generated prose quality +- unsupported third-party harness behavior outside this repository's adapters diff --git a/adapters/codex/README.md b/adapters/codex/README.md index 2abb0ca..80d8ac4 100644 --- a/adapters/codex/README.md +++ b/adapters/codex/README.md @@ -95,8 +95,8 @@ $ codex plugin add adr-toolkit@adr-toolkit-marketplace --json "pluginId": "adr-toolkit@adr-toolkit-marketplace", "name": "adr-toolkit", "marketplaceName": "adr-toolkit-marketplace", - "version": "0.1.0", - "installedPath": "/plugins/cache/adr-toolkit-marketplace/adr-toolkit/0.1.0", + "version": "0.2.0", + "installedPath": "/plugins/cache/adr-toolkit-marketplace/adr-toolkit/0.2.0", "authPolicy": "ON_INSTALL" } @@ -105,7 +105,7 @@ Marketplace `adr-toolkit-marketplace` /.claude-plugin/marketplace.json PLUGIN STATUS VERSION PATH -adr-toolkit@adr-toolkit-marketplace installed, enabled 0.1.0 +adr-toolkit@adr-toolkit-marketplace installed, enabled 0.2.0 ``` The installed snapshot under `/plugins/cache/` carries the @@ -129,9 +129,8 @@ Two details worth knowing: documented marketplace model, not a defect; the repo root is the correct thing to register, and it works. - **The version Codex reports comes from the plugin manifest it reads.** - Installing from this repo's root reports `0.1.0`, taken from - `.claude-plugin/plugin.json`. Installing a marketplace root whose plugin - manifest is only this adapter's `.codex-plugin/plugin.json` reports - `1.0.0` instead, because that manifest carries no `version` field and - Codex substitutes a default. Adding an explicit `version` to - `.codex-plugin/plugin.json` is tracked as a follow-up. + Installing from this repo's root reports the version in + `.claude-plugin/plugin.json`, which is kept in sync with + `skills/adr-toolkit/VERSION` by `scripts/sync_version.py --check`. The + adapter-local `.codex-plugin/plugin.json` intentionally has no `version` + field because the verified install path does not read it. diff --git a/changelog.md b/changelog.md index dab90e5..05ae2be 100644 --- a/changelog.md +++ b/changelog.md @@ -4,6 +4,13 @@ Lightweight human-readable summary of meaningful repository changes. ## Unreleased +- Added ADR relationship graph navigation: `adr.py index` now embeds a Mermaid + graph in the generated decision log when relationships exist, and + `adr.py graph` exports standalone `relationships.mmd` plus a crisp SVG + navigation artifact without requiring Node, Mermaid CLI, or browser + rendering. +- Added public repository readiness docs: `CONTRIBUTING.md`, `SECURITY.md`, + and a pull request template covering ADR impact and local verification. - Added `adr.py search`: finds an existing ADR by keyword (title **and** body — `related`'s keyword match was title-only before), tags, status, `--id`, or the real file path it governs, with deterministic best-match diff --git a/docs/decisions/README.md b/docs/decisions/README.md index 955800a..a6550ab 100644 --- a/docs/decisions/README.md +++ b/docs/decisions/README.md @@ -234,3 +234,19 @@ ### Related - ADR-0006 "Localize deterministic ADR generation through repository configuration" related to: ADR-0003 "Localize only index.py's generated strings, not agent-composed text" + +```mermaid +flowchart LR + ADR_0001["ADR-0001
Record architecture decisions"] + ADR_0002["ADR-0002
Limit CHECK's conflict detection to structural evidence only"] + ADR_0003["ADR-0003
Localize only index.py's generated strings, not agent-composed text"] + ADR_0004["ADR-0004
Adapter packaging: manifest-only directories, install-time symlinks, verified formats"] + ADR_0005["ADR-0005
Adopt Git Flow with direct-tag release automation"] + ADR_0006["ADR-0006
Localize deterministic ADR generation through repository configuration"] + ADR_0007["ADR-0007
Promote CHECK's kind-to-confidence mapping to a stable output field"] + ADR_0008["ADR-0008
Deterministic CHECK policy exceptions: schema-validated, annotate-only, never suppress"] + ADR_0009["ADR-0009
--json is a documented no-op; CLI output is always JSON"] + ADR_0010["ADR-0010
Codex skill-creator's quick_validate.py incompatibility is not this project's problem"] + ADR_0006 -.->|related| ADR_0003 + ADR_0006 -->|supersedes| ADR_0003 +``` diff --git a/docs/decisions/relationships.mmd b/docs/decisions/relationships.mmd new file mode 100644 index 0000000..e07a247 --- /dev/null +++ b/docs/decisions/relationships.mmd @@ -0,0 +1,13 @@ +flowchart LR + ADR_0001["ADR-0001
Record architecture decisions"] + ADR_0002["ADR-0002
Limit CHECK's conflict detection to structural evidence only"] + ADR_0003["ADR-0003
Localize only index.py's generated strings, not agent-composed text"] + ADR_0004["ADR-0004
Adapter packaging: manifest-only directories, install-time symlinks, verified formats"] + ADR_0005["ADR-0005
Adopt Git Flow with direct-tag release automation"] + ADR_0006["ADR-0006
Localize deterministic ADR generation through repository configuration"] + ADR_0007["ADR-0007
Promote CHECK's kind-to-confidence mapping to a stable output field"] + ADR_0008["ADR-0008
Deterministic CHECK policy exceptions: schema-validated, annotate-only, never suppress"] + ADR_0009["ADR-0009
--json is a documented no-op; CLI output is always JSON"] + ADR_0010["ADR-0010
Codex skill-creator's quick_validate.py incompatibility is not this project's problem"] + ADR_0006 -.->|related| ADR_0003 + ADR_0006 -->|supersedes| ADR_0003 diff --git a/docs/decisions/relationships.svg b/docs/decisions/relationships.svg new file mode 100644 index 0000000..18dc6ae --- /dev/null +++ b/docs/decisions/relationships.svg @@ -0,0 +1,41 @@ + +ADR relationship graph +Vector export of ADR supersession and related-decision links. + + +ADR relationship graph + +ADR-0001 +Record architecture decisions + +ADR-0002 +Limit CHECK's conflict detection ... + +ADR-0003 +Localize only index.py's generate... + +ADR-0004 +Adapter packaging: manifest-only ... + +ADR-0005 +Adopt Git Flow with direct-tag re... + +ADR-0006 +Localize deterministic ADR genera... + +ADR-0007 +Promote CHECK's kind-to-confidenc... + +ADR-0008 +Deterministic CHECK policy except... + +ADR-0009 +--json is a documented no-op; CLI... + +ADR-0010 +Codex skill-creator's quick_valid... + +ADR-0006 related ADR-0003 + +ADR-0006 supersedes ADR-0003 + diff --git a/docs/superpowers/specs/2026-08-31-public-readiness-and-adr-graph-design.md b/docs/superpowers/specs/2026-08-31-public-readiness-and-adr-graph-design.md new file mode 100644 index 0000000..14b0d33 --- /dev/null +++ b/docs/superpowers/specs/2026-08-31-public-readiness-and-adr-graph-design.md @@ -0,0 +1,27 @@ +# Public Readiness and ADR Graph Design + +## Scope + +Prepare ADR Toolkit for public open-source use and add a sharper ADR navigation +view without changing the canonical flat `docs/decisions/` storage model. + +## Decisions + +- Keep ADR Markdown files flat in `docs/decisions/`. +- Add a Mermaid relationship graph to the generated decision index so GitHub can + render it directly. +- Add a deterministic `adr.py graph` export command that writes + `relationships.mmd` and, when requested, a vector `relationships.svg`. +- Generate SVG directly from the same relationship model in Python. The SVG is a + navigation artifact, not a pixel screenshot, so it remains crisp when zoomed. +- Keep PNG out of the default workflow. Raster export can be revisited later if + users explicitly need it. +- Add public repository hygiene docs: contributing guidance, security reporting, + and a pull request template. + +## Validation + +- Unit tests must cover Mermaid block rendering, Mermaid file export, SVG file + export, and graph behavior when no relationships exist. +- Existing ADR validation, index generation, version drift, and full pytest must + pass before completion. diff --git a/project-roadmap.md b/project-roadmap.md index 25e1a1b..6a16644 100644 --- a/project-roadmap.md +++ b/project-roadmap.md @@ -22,13 +22,6 @@ before implementation. Concrete selected work belongs in `improvements.md`. ## ADR navigation and scale -- ~~Add a general search command and relationship visibility.~~ **Done - (2026-08-31).** `adr.py search` (keyword now matches body, not just - title; tag/status/governed-path filters; deterministic ranking) and a - Relationships section in the generated index. See `improvements.md`'s - Done entry and `docs/superpowers/specs/2026-08-31-adr-search-and-relationships-design.md`. -- Render `related`/`supersedes` relationships as a rendered graph image (DOT/ - Mermaid), not just the text-based index lists search/index now provide. - Test whether 500+ decisions require sharding, alternate indexes, or a real search index (this repo has 10 ADRs; substring/tag/path matching is untested at that scale). diff --git a/skills/adr-toolkit/SKILL.md b/skills/adr-toolkit/SKILL.md index e9ff071..c2c3ac0 100644 --- a/skills/adr-toolkit/SKILL.md +++ b/skills/adr-toolkit/SKILL.md @@ -298,3 +298,17 @@ CHECK's conflict detection is deliberately limited to structural evidence from `constraints:` blocks (see `references/conflict-rules.md`). It does not certify the entire architecture and never attempts semantic or AST-level analysis; see `project-roadmap.md` for what that fuller scope would look like. + +`graph` is a read-only navigation export: + +```bash +python skills/adr-toolkit/scripts/adr.py graph --dir docs/decisions --format both --json +``` + +It writes Mermaid (`relationships.mmd`) and SVG (`relationships.svg`) graph +artifacts from the same relationship model used by `index`. SVG is generated +directly by Python so the image stays sharp without requiring Mermaid CLI, +Node, or browser rendering. +When `--format both` and `--output` are used together, `--output` is a file +prefix; for example `--output build/adr-relationships` writes +`build/adr-relationships.mmd` and `build/adr-relationships.svg`. diff --git a/skills/adr-toolkit/scripts/adr.py b/skills/adr-toolkit/scripts/adr.py index cb93296..9d139dc 100755 --- a/skills/adr-toolkit/scripts/adr.py +++ b/skills/adr-toolkit/scripts/adr.py @@ -13,6 +13,7 @@ diff, discover, exception, + graph, index, init, preflight, @@ -138,6 +139,13 @@ def build_parser() -> argparse.ArgumentParser: p_exception.add_argument("--dry-run", dest="dry_run", action="store_true") _add_json_flag(p_exception) + p_graph = sub.add_parser("graph") + p_graph.add_argument("--dir", default="docs/decisions") + p_graph.add_argument("--root", default=".") + p_graph.add_argument("--format", choices=("mermaid", "svg", "both"), default="both") + p_graph.add_argument("--output") + _add_json_flag(p_graph) + p_search = sub.add_parser("search") p_search.add_argument("--id") p_search.add_argument("--keyword") @@ -166,6 +174,7 @@ def build_parser() -> argparse.ArgumentParser: "diff": diff.run, "check": check.run, "exception": exception.run, + "graph": graph.run, "search": search.run, } diff --git a/skills/adr-toolkit/scripts/commands/graph.py b/skills/adr-toolkit/scripts/commands/graph.py new file mode 100644 index 0000000..19c9ffb --- /dev/null +++ b/skills/adr-toolkit/scripts/commands/graph.py @@ -0,0 +1,65 @@ +"""Export ADR relationships as Mermaid and SVG graph artifacts.""" +from pathlib import Path + +from scripts.core import frontmatter as fm +from scripts.core.adr_directory import iter_adr_files +from scripts.core.relationships import render_mermaid, render_svg, resolve +from scripts.core.repository_paths import resolve_from_root + + +def run(args) -> dict: + root = Path(getattr(args, "root", ".")) + adr_dir = resolve_from_root(root, args.dir) + entries = [] + warnings = [] + + for entry, parsed in iter_adr_files(adr_dir): + 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]), + "related": data.get("related", []), + "supersedes": data.get("supersedes", []), + "superseded_by": data.get("superseded_by"), + }) + + output = getattr(args, "output", None) + format_ = getattr(args, "format", "both") + outputs = [] + if format_ in {"mermaid", "both"}: + path = _output_path(root, adr_dir, output, format_, "mermaid") + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(render_mermaid(entries), encoding="utf-8") + outputs.append(str(path)) + if format_ in {"svg", "both"}: + path = _output_path(root, adr_dir, output, format_, "svg") + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(render_svg(entries), encoding="utf-8") + outputs.append(str(path)) + + rendered_edges = [edge for edge in resolve(entries) if edge.type in {"related", "supersedes"}] + return { + "ok": True, + "operation": "graph", + "count": len(rendered_edges), + "outputs": outputs, + "warnings": warnings, + } + + +def _output_path(root: Path, adr_dir: Path, output: str, requested_format: str, actual_format: str) -> Path: + suffix = f".{'mmd' if actual_format == 'mermaid' else 'svg'}" + if not output: + return adr_dir / f"relationships{suffix}" + + base = resolve_from_root(root, output) + if requested_format == "both": + return base.with_suffix(suffix) + return base diff --git a/skills/adr-toolkit/scripts/commands/index.py b/skills/adr-toolkit/scripts/commands/index.py index 526f020..b9148df 100644 --- a/skills/adr-toolkit/scripts/commands/index.py +++ b/skills/adr-toolkit/scripts/commands/index.py @@ -5,7 +5,7 @@ from scripts.core.adr_directory import iter_adr_files from scripts.core.config import ConfigError, resolve_locale from scripts.core.locale import load_locale -from scripts.core.relationships import resolve +from scripts.core.relationships import render_mermaid, resolve from scripts.core.repository_paths import resolve_from_root # Last-resort English headers, used when even the English locale file is @@ -156,4 +156,11 @@ def _render(entries: list, strings: dict) -> str: f"{target['id']} \"{target['title']}\"" ) + visual_edges = [edge for edge in edges if edge.type in {"related", "supersedes"}] + if visual_edges: + lines.append("") + lines.append("```mermaid") + lines.extend(render_mermaid(entries).rstrip().splitlines()) + lines.append("```") + return "\n".join(lines) + "\n" diff --git a/skills/adr-toolkit/scripts/core/relationships.py b/skills/adr-toolkit/scripts/core/relationships.py index 865ccbd..d9f8bd0 100644 --- a/skills/adr-toolkit/scripts/core/relationships.py +++ b/skills/adr-toolkit/scripts/core/relationships.py @@ -1,4 +1,5 @@ """Canonical ADR relationship model, shared by index.py and validate.py.""" +from html import escape from typing import NamedTuple @@ -69,3 +70,116 @@ def dfs(node, path, path_set): dfs(node, [], set()) return cycles + + +def render_mermaid(entries: list) -> str: + """Render ADR relationships as GitHub-compatible Mermaid flowchart text.""" + by_id = _entries_by_id(entries) + relationships = resolve(entries) + visual_edges = _visual_edges(relationships) + lines = ["flowchart LR"] + + for adr_id in sorted(by_id): + entry = by_id[adr_id] + label = _mermaid_label(adr_id, entry.get("title", adr_id)) + lines.append(f" {_node_id(adr_id)}[\"{label}\"]") + + if not visual_edges: + lines.append(' EMPTY["No relationships recorded"]') + + for relationship in sorted(visual_edges): + source = _node_id(relationship.source) + target = _node_id(relationship.target) + if relationship.type == "related": + lines.append(f" {source} -.->|related| {target}") + else: + lines.append(f" {source} -->|supersedes| {target}") + + return "\n".join(lines) + "\n" + + +def render_svg(entries: list) -> str: + """Render a small deterministic SVG navigation artifact. + + This is intentionally not a full Mermaid renderer. It uses the same graph + model and writes vector text/lines directly so the exported artifact stays + sharp without requiring Node or browser automation. + """ + by_id = _entries_by_id(entries) + relationships = _visual_edges(resolve(entries)) + ordered_ids = sorted(by_id) + width = 960 + row_height = 82 + edge_height = 28 + node_height = max(1, len(ordered_ids)) * row_height + edge_start = 72 + node_height + height = edge_start + max(1, len(relationships)) * edge_height + 48 + y_by_id = {adr_id: 72 + index * row_height for index, adr_id in enumerate(ordered_ids)} + + lines = [ + f'', + "ADR relationship graph", + "Vector export of ADR supersession and related-decision links.", + '', + '', + 'ADR relationship graph', + ] + + for adr_id in ordered_ids: + y = y_by_id[adr_id] + title = str(by_id[adr_id].get("title", adr_id)) + lines.extend([ + f'', + f'{escape(adr_id)}', + f'{escape(_truncate(title, 36))}', + ]) + + if relationships: + pair_totals = {} + for relationship in relationships: + pair = (relationship.source, relationship.target) + pair_totals[pair] = pair_totals.get(pair, 0) + 1 + pair_seen = {} + for index, relationship in enumerate(sorted(relationships)): + pair = (relationship.source, relationship.target) + pair_seen[pair] = pair_seen.get(pair, 0) + 1 + offset = (pair_seen[pair] - (pair_totals[pair] + 1) / 2) * 14 + source_y = y_by_id.get(relationship.source, 72) + 26 + offset + target_y = y_by_id.get(relationship.target, 72) + 26 + offset + edge_y = edge_start + index * edge_height + label = f"{relationship.source} {relationship.type} {relationship.target}" + dash = ' stroke-dasharray="5 5"' if relationship.type == "related" else "" + lines.extend([ + f'', + f'{escape(label)}', + ]) + else: + lines.append('No relationships recorded') + + lines.append("") + return "\n".join(lines) + "\n" + + +def _entries_by_id(entries: list) -> dict: + return {entry["id"]: entry for entry in entries if entry.get("id")} + + +def _visual_edges(relationships: list) -> list: + return [r for r in relationships if r.type in {"related", "supersedes"}] + + +def _node_id(adr_id: str) -> str: + return adr_id.replace("-", "_") + + +def _mermaid_label(adr_id: str, title: str) -> str: + safe_title = " ".join(str(title).split()) + safe_title = escape(safe_title, quote=False) + safe_title = safe_title.replace('"', "'").replace("[", "(").replace("]", ")") + return f"{adr_id}
{safe_title}" + + +def _truncate(value: str, limit: int) -> str: + if len(value) <= limit: + return value + return value[: limit - 3] + "..." diff --git a/tests/unit/test_adr_cli.py b/tests/unit/test_adr_cli.py index a122cc0..b8378cc 100644 --- a/tests/unit/test_adr_cli.py +++ b/tests/unit/test_adr_cli.py @@ -53,6 +53,20 @@ def test_index_locale_is_unset_when_omitted_so_repository_default_can_win(): assert args.root == "." +def test_graph_command_accepts_format_and_output_options(): + args = adr.build_parser().parse_args([ + "graph", + "--dir", "docs/decisions", + "--format", "svg", + "--output", "docs/decisions/relationships.svg", + ]) + + assert args.operation == "graph" + assert args.format == "svg" + assert args.output == "docs/decisions/relationships.svg" + assert adr.HANDLERS["graph"].__module__ == "scripts.commands.graph" + + def test_init_locale_is_optional_and_constrained(): parser = adr.build_parser() diff --git a/tests/unit/test_graph_command.py b/tests/unit/test_graph_command.py new file mode 100644 index 0000000..c03c378 --- /dev/null +++ b/tests/unit/test_graph_command.py @@ -0,0 +1,120 @@ +from types import SimpleNamespace + +from scripts.commands import graph + + +def _write_adr(adr_dir, filename, *, id_, title, related=None, supersedes=None, superseded_by=None): + lines = [ + "---", + f"id: {id_}", + f"title: {title}", + "status: accepted", + "date: 2026-08-31", + "decision_makers: []", + ] + if related: + lines.append("related:") + lines.extend(f" - {value}" for value in related) + else: + lines.append("related: []") + lines.append("affected_paths: []") + lines.append("tags: []") + lines.append("retrospective: false") + if supersedes: + lines.append("supersedes:") + lines.extend(f" - {value}" for value in supersedes) + if superseded_by: + lines.append(f"superseded_by: {superseded_by}") + lines.extend(["---", "", f"# {title}", ""]) + (adr_dir / filename).write_text("\n".join(lines), encoding="utf-8") + + +def test_graph_command_writes_mermaid_and_svg_exports(tmp_path): + adr_dir = tmp_path / "docs" / "decisions" + adr_dir.mkdir(parents=True) + _write_adr(adr_dir, "0001-old.md", id_="ADR-0001", title="Old", superseded_by="ADR-0002") + _write_adr(adr_dir, "0002-new.md", id_="ADR-0002", title="New", supersedes=["ADR-0001"]) + + result = graph.run(SimpleNamespace( + dir="docs/decisions", + root=str(tmp_path), + output=None, + format="both", + )) + + assert result["ok"] is True + assert result["count"] == 1 + assert result["outputs"] == [ + str(adr_dir / "relationships.mmd"), + str(adr_dir / "relationships.svg"), + ] + assert "ADR_0002 -->|supersedes| ADR_0001" in ( + adr_dir / "relationships.mmd" + ).read_text(encoding="utf-8") + assert "ADR-0002 supersedes ADR-0001" in ( + adr_dir / "relationships.svg" + ).read_text(encoding="utf-8") + + +def test_graph_command_can_write_only_mermaid_to_custom_output(tmp_path): + adr_dir = tmp_path / "docs" / "decisions" + adr_dir.mkdir(parents=True) + _write_adr(adr_dir, "0001-a.md", id_="ADR-0001", title="A", related=["ADR-0002"]) + _write_adr(adr_dir, "0002-b.md", id_="ADR-0002", title="B") + output = tmp_path / "graph.mmd" + + result = graph.run(SimpleNamespace( + dir=str(adr_dir), + root=str(tmp_path), + output=str(output), + format="mermaid", + )) + + assert result["ok"] is True + assert result["outputs"] == [str(output)] + assert "ADR_0001 -.->|related| ADR_0002" in output.read_text(encoding="utf-8") + + +def test_graph_command_uses_custom_output_as_prefix_for_both_formats(tmp_path): + adr_dir = tmp_path / "docs" / "decisions" + adr_dir.mkdir(parents=True) + _write_adr(adr_dir, "0001-a.md", id_="ADR-0001", title="A", related=["ADR-0002"]) + _write_adr(adr_dir, "0002-b.md", id_="ADR-0002", title="B") + + result = graph.run(SimpleNamespace( + dir="docs/decisions", + root=str(tmp_path), + output="build/adr-relationships", + format="both", + )) + + assert result["ok"] is True + assert result["outputs"] == [ + str(tmp_path / "build" / "adr-relationships.mmd"), + str(tmp_path / "build" / "adr-relationships.svg"), + ] + assert (tmp_path / "build" / "adr-relationships.mmd").is_file() + assert (tmp_path / "build" / "adr-relationships.svg").is_file() + + +def test_graph_command_resolves_relative_single_output_against_root(tmp_path, monkeypatch): + caller = tmp_path / "caller" + repo = tmp_path / "repo" + adr_dir = repo / "docs" / "decisions" + caller.mkdir() + adr_dir.mkdir(parents=True) + _write_adr(adr_dir, "0001-a.md", id_="ADR-0001", title="A", related=["ADR-0002"]) + _write_adr(adr_dir, "0002-b.md", id_="ADR-0002", title="B") + monkeypatch.chdir(caller) + + result = graph.run(SimpleNamespace( + dir="docs/decisions", + root=str(repo), + output="docs/decisions/custom.svg", + format="svg", + )) + + assert result["ok"] is True + assert result["outputs"] == [str(repo / "docs" / "decisions" / "custom.svg")] + assert (repo / "docs" / "decisions" / "custom.svg").is_file() + assert not (caller / "docs" / "decisions" / "custom.svg").exists() diff --git a/tests/unit/test_index.py b/tests/unit/test_index.py index f917870..a62b60e 100644 --- a/tests/unit/test_index.py +++ b/tests/unit/test_index.py @@ -229,6 +229,8 @@ def test_index_relationships_section_shows_supersession_with_titles(tmp_path): assert "## Relationships" in readme assert "ADR-0003" in readme and "Old decision" in readme assert "ADR-0006" in readme and "New decision" in readme + assert "```mermaid" in readme + assert "ADR_0006 -->|supersedes| ADR_0003" in readme def test_index_relationships_section_omits_adr_with_no_relationships(tmp_path): @@ -245,3 +247,4 @@ def test_index_relationships_section_omits_adr_with_no_relationships(tmp_path): readme = (adr_dir / "README.md").read_text(encoding="utf-8") assert "Lonely decision" not in readme.split("## Relationships")[1] + assert "```mermaid" not in readme diff --git a/tests/unit/test_relationships.py b/tests/unit/test_relationships.py index 97b9dde..0717478 100644 --- a/tests/unit/test_relationships.py +++ b/tests/unit/test_relationships.py @@ -2,15 +2,17 @@ Relationship, find_cycles, missing_targets, + render_mermaid, + render_svg, resolve, supersession_mismatches, ) ENTRIES = [ - {"id": "ADR-0003", "related": [], "supersedes": [], "superseded_by": "ADR-0006"}, - {"id": "ADR-0006", "related": [], "supersedes": ["ADR-0003"], "superseded_by": None}, - {"id": "ADR-0007", "related": ["ADR-0002"], "supersedes": [], "superseded_by": None}, - {"id": "ADR-0002", "related": [], "supersedes": [], "superseded_by": None}, + {"id": "ADR-0003", "title": "Old decision", "related": [], "supersedes": [], "superseded_by": "ADR-0006"}, + {"id": "ADR-0006", "title": "New decision", "related": [], "supersedes": ["ADR-0003"], "superseded_by": None}, + {"id": "ADR-0007", "title": "Confidence field", "related": ["ADR-0002"], "supersedes": [], "superseded_by": None}, + {"id": "ADR-0002", "title": "Structural checks", "related": [], "supersedes": [], "superseded_by": None}, ] @@ -103,3 +105,39 @@ def test_find_cycles_ignores_related_edges(): {"id": "ADR-0002", "related": ["ADR-0001"]}, ] assert find_cycles(resolve(mutually_related)) == [] + + +def test_render_mermaid_includes_supersession_and_related_edges_with_labels(): + mermaid = render_mermaid(ENTRIES) + + assert mermaid.startswith("flowchart LR\n") + assert "ADR_0006[\"ADR-0006
New decision\"]" in mermaid + assert "ADR_0006 -->|supersedes| ADR_0003" in mermaid + assert "ADR_0007 -.->|related| ADR_0002" in mermaid + assert "ADR_0003 -->|superseded by| ADR_0006" not in mermaid + + +def test_render_mermaid_reports_no_relationships_when_graph_has_no_edges(): + mermaid = render_mermaid([{"id": "ADR-0001", "title": "Lonely decision"}]) + + assert "ADR_0001[\"ADR-0001
Lonely decision\"]" in mermaid + assert "No relationships recorded" in mermaid + + +def test_render_mermaid_escapes_title_text_for_label_safety(): + mermaid = render_mermaid([ + {"id": "ADR-0001", "title": "Use \"ports\" [core]\nnow"}, + ]) + + assert "Use <A&B> 'ports' (core) now" in mermaid + assert "\nnow" not in mermaid + + +def test_render_svg_returns_crisp_vector_navigation_artifact(): + svg = render_svg(ENTRIES) + + assert svg.startswith("ADR relationship graph" in svg + assert "ADR-0006" in svg and "New decision" in svg + assert "ADR-0006 supersedes ADR-0003" in svg + assert "ADR-0007 related ADR-0002" in svg From 35c81aab8223b5b77b83465961f24271470ce425 Mon Sep 17 00:00:00 2001 From: shcommit Date: Mon, 31 Aug 2026 14:32:02 +0900 Subject: [PATCH 70/76] docs: record ADR-0011 for relationship graph and public readiness work Regenerates docs/decisions/README.md and relationships.mmd/svg to include ADR-0011, and updates handoff.md with verification results (395 tests, 11 ADRs validated, 6 graph edges) ahead of PR follow-up. --- ...adr-relationship-graph-public-readiness.md | 125 ++++++++++++++++++ docs/decisions/README.md | 63 +++++++++ docs/decisions/relationships.mmd | 5 + docs/decisions/relationships.svg | 17 ++- handoff.md | 96 ++++++++------ 5 files changed, 265 insertions(+), 41 deletions(-) create mode 100644 docs/decisions/0011-adr-relationship-graph-public-readiness.md diff --git a/docs/decisions/0011-adr-relationship-graph-public-readiness.md b/docs/decisions/0011-adr-relationship-graph-public-readiness.md new file mode 100644 index 0000000..f849353 --- /dev/null +++ b/docs/decisions/0011-adr-relationship-graph-public-readiness.md @@ -0,0 +1,125 @@ +--- +id: ADR-0011 +title: Expose ADR relationships as Mermaid and SVG navigation artifacts +status: accepted +date: 2026-08-31 +locale: en +decision_makers: + - YangSeungHyun +related: + - ADR-0001 + - ADR-0006 + - ADR-0008 + - ADR-0009 +affected_paths: + - README.md + - CONTRIBUTING.md + - SECURITY.md + - .github/PULL_REQUEST_TEMPLATE.md + - project-roadmap.md + - docs/decisions/README.md + - docs/decisions/relationships.mmd + - docs/decisions/relationships.svg + - skills/adr-toolkit/SKILL.md + - skills/adr-toolkit/scripts/adr.py + - skills/adr-toolkit/scripts/commands/graph.py + - skills/adr-toolkit/scripts/commands/index.py + - skills/adr-toolkit/scripts/core/relationships.py + - tests/unit/test_graph_command.py + - tests/unit/test_relationships.py + - tests/unit/test_index.py + - tests/unit/test_adr_cli.py +tags: + - navigation + - graph + - documentation + - governance + - v0.2.0 +retrospective: false +--- + +# Expose ADR relationships as Mermaid and SVG navigation artifacts + +## Context and Problem Statement + +ADR Toolkit intentionally keeps ADR source files flat under `docs/decisions/`, but a growing decision log still needs navigable relationship context. The generated index already listed supersession and related links as text, but users also wanted a graph view and a crisp image artifact. Raster screenshots were not acceptable because relationship graphs rely on small text and lines that become blurry when exported as PNG. + +The same session also prepared the repository for public open-source use with contributor, security, and pull request guidance. That public surface should point contributors at the same deterministic ADR navigation and verification workflow rather than adding a separate documentation system. + +## Decision Drivers + +* Preserve the flat `docs/decisions/` storage model. +* Make relationship navigation visible in GitHub without requiring local tooling. +* Provide a sharp standalone image artifact for documents and reviews. +* Avoid making Node, Mermaid CLI, browser automation, or screenshot rendering required dependencies. +* Keep command output deterministic JSON and path handling consistent with the existing repository-scoped commands. +* Make public contribution guidance ask reviewers to consider ADR impact and verification. + +## Considered Options + +* Keep only the text relationship lists in the generated decision index. +* Embed Mermaid in the generated index and require Mermaid CLI or browser rendering for image export. +* Embed Mermaid in the generated index and generate standalone Mermaid plus Python-rendered SVG artifacts from the same relationship model. + +## Decision Outcome + +Chosen option: **embed Mermaid in the generated index and generate standalone Mermaid plus Python-rendered SVG artifacts**, because GitHub can render Mermaid directly while SVG gives a crisp vector artifact without adding external rendering dependencies. + +`adr.py index` now appends a Mermaid `flowchart LR` block when `related` or `supersedes` edges exist. `adr.py graph --format mermaid|svg|both` exports `relationships.mmd` and/or `relationships.svg`; with `--format both`, a custom `--output` is treated as a prefix so one path can produce both extensions. Relative output paths resolve from `--root`, matching the other repository-scoped commands. + +The public readiness docs added in the same decision are `CONTRIBUTING.md`, `SECURITY.md`, and `.github/PULL_REQUEST_TEMPLATE.md`. They make ADR impact, local verification, and security reporting explicit for future open-source contributors. + +### Consequences + +* Good: GitHub readers get an inline relationship graph without installing anything. +* Good: users who need an image get SVG, which remains sharp when zoomed or embedded in documents. +* Good: graph artifacts reuse the same deterministic relationship model as validation and index generation. +* Good: public contributors now see the expected branch, ADR, security, and verification workflow before opening a PR. +* Bad: the Python SVG renderer is a deliberately small navigation artifact, not a full Mermaid renderer or layout engine. +* Bad: generated `relationships.mmd` and `relationships.svg` are additional files that must be regenerated when ADR relationships change. + +### Confirmation + +* `tests/unit/test_relationships.py` covers Mermaid rendering, label escaping, no-edge behavior, and SVG output. +* `tests/unit/test_graph_command.py` covers default export, custom output prefix behavior for `--format both`, and `--root`-relative output paths. +* `tests/unit/test_index.py` covers Mermaid graph insertion in the generated decision index. +* `tests/unit/test_adr_cli.py` covers parser registration for `adr.py graph`. +* `python3 -m pytest -q` passes with 395 tests. +* `python3 scripts/sync_version.py --check`, `adr.py validate`, `adr.py index`, and `adr.py graph --format both` all pass. + +## Implementation Constraints + +```yaml +constraints: + - id: graph-export-no-external-renderer + kind: forbidden_import + paths: ["skills/adr-toolkit/scripts/commands/graph.py", "skills/adr-toolkit/scripts/core/relationships.py"] + pattern: ["^\\s*import\\s+subprocess\\b", "^\\s*from\\s+subprocess\\b", "^\\s*import\\s+playwright\\b", "^\\s*from\\s+playwright\\b", "^\\s*import\\s+selenium\\b", "^\\s*from\\s+selenium\\b"] + severity: major + message: "ADR graph export must stay deterministic and must not require external renderers or browser automation." +``` + +## Pros and Cons of the Options + +### Keep only text relationship lists + +* Good, because it requires no new command or generated artifact. +* Bad, because relationship navigation remains harder to scan as ADR count grows, and users still lack a sharp visual artifact. + +### Mermaid plus external renderer + +* Good, because Mermaid CLI can produce familiar graph output. +* Bad, because requiring Node, browser automation, or screenshot rendering expands the dependency surface and reintroduces blurry PNG output unless users tune scale settings. + +### Mermaid plus Python-rendered SVG + +* Good, because the README stays GitHub-native and the standalone image remains vector-sharp. +* Good, because it keeps graph export inside the existing deterministic Python package. +* Bad, because the SVG layout is intentionally simple and may need a richer layout strategy if real ADR graphs become dense. + +## Revisit Triggers + +* Real repositories produce dense relationship graphs that the simple SVG layout cannot make readable. +* Users need a PNG export despite SVG availability, and can define acceptable scale/DPI behavior. +* Mermaid syntax compatibility changes on GitHub and breaks the embedded graph. +* Multiple repositories need a combined cross-repository graph rather than one graph per ADR directory. diff --git a/docs/decisions/README.md b/docs/decisions/README.md index a6550ab..864d41a 100644 --- a/docs/decisions/README.md +++ b/docs/decisions/README.md @@ -12,6 +12,7 @@ - [ADR-0008 — Deterministic CHECK policy exceptions: schema-validated, annotate-only, never suppress](0008-check-exceptions-annotate-only.md) - [ADR-0009 — --json is a documented no-op; CLI output is always JSON](0009-json-flag-always-json-contract.md) - [ADR-0010 — Codex skill-creator's quick_validate.py incompatibility is not this project's problem](0010-codex-quick-validate-not-applicable.md) +- [ADR-0011 — Expose ADR relationships as Mermaid and SVG navigation artifacts](0011-adr-relationship-graph-public-readiness.md) ### Superseded - [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) @@ -48,6 +49,7 @@ ### documentation - [ADR-0010 — Codex skill-creator's quick_validate.py incompatibility is not this project's problem](0010-codex-quick-validate-not-applicable.md) +- [ADR-0011 — Expose ADR relationships as Mermaid and SVG navigation artifacts](0011-adr-relationship-graph-public-readiness.md) ### exceptions - [ADR-0008 — Deterministic CHECK policy exceptions: schema-validated, annotate-only, never suppress](0008-check-exceptions-annotate-only.md) @@ -57,6 +59,10 @@ ### governance - [ADR-0008 — Deterministic CHECK policy exceptions: schema-validated, annotate-only, never suppress](0008-check-exceptions-annotate-only.md) +- [ADR-0011 — Expose ADR relationships as Mermaid and SVG navigation artifacts](0011-adr-relationship-graph-public-readiness.md) + +### graph +- [ADR-0011 — Expose ADR relationships as Mermaid and SVG navigation artifacts](0011-adr-relationship-graph-public-readiness.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) @@ -68,6 +74,9 @@ ### mvp-scope - [ADR-0002 — Limit CHECK's conflict detection to structural evidence only](0002-limit-check-s-conflict-detection-to-structural-evidence-only.md) +### navigation +- [ADR-0011 — Expose ADR relationships as Mermaid and SVG navigation artifacts](0011-adr-relationship-graph-public-readiness.md) + ### output-contract - [ADR-0009 — --json is a documented no-op; CLI output is always JSON](0009-json-flag-always-json-contract.md) @@ -83,6 +92,7 @@ - [ADR-0007 — Promote CHECK's kind-to-confidence mapping to a stable output field](0007-check-confidence-field.md) - [ADR-0008 — Deterministic CHECK policy exceptions: schema-validated, annotate-only, never suppress](0008-check-exceptions-annotate-only.md) - [ADR-0009 — --json is a documented no-op; CLI output is always JSON](0009-json-flag-always-json-contract.md) +- [ADR-0011 — Expose ADR relationships as Mermaid and SVG navigation artifacts](0011-adr-relationship-graph-public-readiness.md) ## By affected path @@ -96,6 +106,9 @@ ### `.claude-plugin/plugin.json` - [ADR-0004 — Adapter packaging: manifest-only directories, install-time symlinks, verified formats](0004-adapter-packaging-manifest-only-directories-install-time-symlinks-verified-formats.md) +### `.github/PULL_REQUEST_TEMPLATE.md` +- [ADR-0011 — Expose ADR relationships as Mermaid and SVG navigation artifacts](0011-adr-relationship-graph-public-readiness.md) + ### `.github/workflows/` - [ADR-0005 — Adopt Git Flow with direct-tag release automation](0005-adopt-git-flow-with-direct-tag-release-automation.md) @@ -105,10 +118,17 @@ ### `AGENTS.md` - [ADR-0005 — Adopt Git Flow with direct-tag release automation](0005-adopt-git-flow-with-direct-tag-release-automation.md) +### `CONTRIBUTING.md` +- [ADR-0011 — Expose ADR relationships as Mermaid and SVG navigation artifacts](0011-adr-relationship-graph-public-readiness.md) + ### `README.md` - [ADR-0007 — Promote CHECK's kind-to-confidence mapping to a stable output field](0007-check-confidence-field.md) - [ADR-0008 — Deterministic CHECK policy exceptions: schema-validated, annotate-only, never suppress](0008-check-exceptions-annotate-only.md) - [ADR-0009 — --json is a documented no-op; CLI output is always JSON](0009-json-flag-always-json-contract.md) +- [ADR-0011 — Expose ADR relationships as Mermaid and SVG navigation artifacts](0011-adr-relationship-graph-public-readiness.md) + +### `SECURITY.md` +- [ADR-0011 — Expose ADR relationships as Mermaid and SVG navigation artifacts](0011-adr-relationship-graph-public-readiness.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) @@ -116,9 +136,21 @@ ### `docs/decisions/` - [ADR-0001 — Record architecture decisions](0001-record-architecture-decisions.md) +### `docs/decisions/README.md` +- [ADR-0011 — Expose ADR relationships as Mermaid and SVG navigation artifacts](0011-adr-relationship-graph-public-readiness.md) + +### `docs/decisions/relationships.mmd` +- [ADR-0011 — Expose ADR relationships as Mermaid and SVG navigation artifacts](0011-adr-relationship-graph-public-readiness.md) + +### `docs/decisions/relationships.svg` +- [ADR-0011 — Expose ADR relationships as Mermaid and SVG navigation artifacts](0011-adr-relationship-graph-public-readiness.md) + ### `examples/quickstart.md` - [ADR-0007 — Promote CHECK's kind-to-confidence mapping to a stable output field](0007-check-confidence-field.md) +### `project-roadmap.md` +- [ADR-0011 — Expose ADR relationships as Mermaid and SVG navigation artifacts](0011-adr-relationship-graph-public-readiness.md) + ### `scripts/sync_version.py` - [ADR-0005 — Adopt Git Flow with direct-tag release automation](0005-adopt-git-flow-with-direct-tag-release-automation.md) @@ -129,6 +161,7 @@ - [ADR-0008 — Deterministic CHECK policy exceptions: schema-validated, annotate-only, never suppress](0008-check-exceptions-annotate-only.md) - [ADR-0009 — --json is a documented no-op; CLI output is always JSON](0009-json-flag-always-json-contract.md) - [ADR-0010 — Codex skill-creator's quick_validate.py incompatibility is not this project's problem](0010-codex-quick-validate-not-applicable.md) +- [ADR-0011 — Expose ADR relationships as Mermaid and SVG navigation artifacts](0011-adr-relationship-graph-public-readiness.md) ### `skills/adr-toolkit/VERSION` - [ADR-0005 — Adopt Git Flow with direct-tag release automation](0005-adopt-git-flow-with-direct-tag-release-automation.md) @@ -148,6 +181,7 @@ - [ADR-0006 — Localize deterministic ADR generation through repository configuration](0006-localized-adr-generation.md) - [ADR-0008 — Deterministic CHECK policy exceptions: schema-validated, annotate-only, never suppress](0008-check-exceptions-annotate-only.md) - [ADR-0009 — --json is a documented no-op; CLI output is always JSON](0009-json-flag-always-json-contract.md) +- [ADR-0011 — Expose ADR relationships as Mermaid and SVG navigation artifacts](0011-adr-relationship-graph-public-readiness.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) @@ -164,9 +198,13 @@ ### `skills/adr-toolkit/scripts/commands/exception.py` - [ADR-0008 — Deterministic CHECK policy exceptions: schema-validated, annotate-only, never suppress](0008-check-exceptions-annotate-only.md) +### `skills/adr-toolkit/scripts/commands/graph.py` +- [ADR-0011 — Expose ADR relationships as Mermaid and SVG navigation artifacts](0011-adr-relationship-graph-public-readiness.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) - [ADR-0006 — Localize deterministic ADR generation through repository configuration](0006-localized-adr-generation.md) +- [ADR-0011 — Expose ADR relationships as Mermaid and SVG navigation artifacts](0011-adr-relationship-graph-public-readiness.md) ### `skills/adr-toolkit/scripts/commands/init.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) @@ -192,6 +230,9 @@ - [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-0006 — Localize deterministic ADR generation through repository configuration](0006-localized-adr-generation.md) +### `skills/adr-toolkit/scripts/core/relationships.py` +- [ADR-0011 — Expose ADR relationships as Mermaid and SVG navigation artifacts](0011-adr-relationship-graph-public-readiness.md) + ### `skills/adr-toolkit/scripts/core/rendering.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) - [ADR-0006 — Localize deterministic ADR generation through repository configuration](0006-localized-adr-generation.md) @@ -209,11 +250,24 @@ ### `tests/integration/test_cli.py` - [ADR-0009 — --json is a documented no-op; CLI output is always JSON](0009-json-flag-always-json-contract.md) +### `tests/unit/test_adr_cli.py` +- [ADR-0011 — Expose ADR relationships as Mermaid and SVG navigation artifacts](0011-adr-relationship-graph-public-readiness.md) + ### `tests/unit/test_check.py` - [ADR-0007 — Promote CHECK's kind-to-confidence mapping to a stable output field](0007-check-confidence-field.md) +### `tests/unit/test_graph_command.py` +- [ADR-0011 — Expose ADR relationships as Mermaid and SVG navigation artifacts](0011-adr-relationship-graph-public-readiness.md) + +### `tests/unit/test_index.py` +- [ADR-0011 — Expose ADR relationships as Mermaid and SVG navigation artifacts](0011-adr-relationship-graph-public-readiness.md) + +### `tests/unit/test_relationships.py` +- [ADR-0011 — Expose ADR relationships as Mermaid and SVG navigation artifacts](0011-adr-relationship-graph-public-readiness.md) + ## Chronological (newest first) +- 2026-08-31 — [ADR-0011 — Expose ADR relationships as Mermaid and SVG navigation artifacts](0011-adr-relationship-graph-public-readiness.md) - 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) @@ -234,6 +288,10 @@ ### Related - ADR-0006 "Localize deterministic ADR generation through repository configuration" related to: ADR-0003 "Localize only index.py's generated strings, not agent-composed text" +- ADR-0011 "Expose ADR relationships as Mermaid and SVG navigation artifacts" related to: ADR-0001 "Record architecture decisions" +- ADR-0011 "Expose ADR relationships as Mermaid and SVG navigation artifacts" related to: ADR-0006 "Localize deterministic ADR generation through repository configuration" +- ADR-0011 "Expose ADR relationships as Mermaid and SVG navigation artifacts" related to: ADR-0008 "Deterministic CHECK policy exceptions: schema-validated, annotate-only, never suppress" +- ADR-0011 "Expose ADR relationships as Mermaid and SVG navigation artifacts" related to: ADR-0009 "--json is a documented no-op; CLI output is always JSON" ```mermaid flowchart LR @@ -247,6 +305,11 @@ flowchart LR ADR_0008["ADR-0008
Deterministic CHECK policy exceptions: schema-validated, annotate-only, never suppress"] ADR_0009["ADR-0009
--json is a documented no-op; CLI output is always JSON"] ADR_0010["ADR-0010
Codex skill-creator's quick_validate.py incompatibility is not this project's problem"] + ADR_0011["ADR-0011
Expose ADR relationships as Mermaid and SVG navigation artifacts"] ADR_0006 -.->|related| ADR_0003 ADR_0006 -->|supersedes| ADR_0003 + ADR_0011 -.->|related| ADR_0001 + ADR_0011 -.->|related| ADR_0006 + ADR_0011 -.->|related| ADR_0008 + ADR_0011 -.->|related| ADR_0009 ``` diff --git a/docs/decisions/relationships.mmd b/docs/decisions/relationships.mmd index e07a247..f36a1e0 100644 --- a/docs/decisions/relationships.mmd +++ b/docs/decisions/relationships.mmd @@ -9,5 +9,10 @@ flowchart LR ADR_0008["ADR-0008
Deterministic CHECK policy exceptions: schema-validated, annotate-only, never suppress"] ADR_0009["ADR-0009
--json is a documented no-op; CLI output is always JSON"] ADR_0010["ADR-0010
Codex skill-creator's quick_validate.py incompatibility is not this project's problem"] + ADR_0011["ADR-0011
Expose ADR relationships as Mermaid and SVG navigation artifacts"] ADR_0006 -.->|related| ADR_0003 ADR_0006 -->|supersedes| ADR_0003 + ADR_0011 -.->|related| ADR_0001 + ADR_0011 -.->|related| ADR_0006 + ADR_0011 -.->|related| ADR_0008 + ADR_0011 -.->|related| ADR_0009 diff --git a/docs/decisions/relationships.svg b/docs/decisions/relationships.svg index 18dc6ae..7afc55f 100644 --- a/docs/decisions/relationships.svg +++ b/docs/decisions/relationships.svg @@ -1,4 +1,4 @@ - + ADR relationship graph Vector export of ADR supersession and related-decision links. @@ -34,8 +34,19 @@ ADR-0010 Codex skill-creator's quick_valid... + +ADR-0011 +Expose ADR relationships as Merma... -ADR-0006 related ADR-0003 +ADR-0006 related ADR-0003 -ADR-0006 supersedes ADR-0003 +ADR-0006 supersedes ADR-0003 + +ADR-0011 related ADR-0001 + +ADR-0011 related ADR-0006 + +ADR-0011 related ADR-0008 + +ADR-0011 related ADR-0009 diff --git a/handoff.md b/handoff.md index a942d74..d206060 100644 --- a/handoff.md +++ b/handoff.md @@ -2,55 +2,75 @@ ## Current task (2026-08-31) -`v0.2.0` release gate is in progress — the only remaining work -(`improvements.md`'s single open item). Everything else (multilingual ADR -generation, CHECK hardening, exception mechanism, ADR search and -relationship navigation) is implemented and merged into this branch; see -`changelog.md` for what shipped and `git log` for exactly how. +Public-readiness and ADR navigation graph work is implemented on branch +`feature/project-roadmap-implements` in: -Work runs in the linked worktree at -`/Users/yangseunghyeon/orca/workspaces/ADR-toolkit/develop-2`, branch -`feature/v0.2.0-multilingual-and-check-confidence`, pushed to `origin`. +`/Users/yangseunghyeon/orca/workspaces/ADR-toolkit/develop` + +Latest commits: + +- `8f0343e feat: add ADR relationship graph exports` +- ADR-0011 work is in progress in the working tree and should be committed + after final verification. + +Implemented in this session: + +- `adr.py graph --format mermaid|svg|both` exports deterministic Mermaid and + Python-rendered SVG artifacts. +- `adr.py index` embeds a GitHub-renderable Mermaid relationship graph in the + generated decision log. +- Public repository hygiene docs were added: + `CONTRIBUTING.md`, `SECURITY.md`, and `.github/PULL_REQUEST_TEMPLATE.md`. +- `project-roadmap.md` now lists only remaining unscheduled work; completed + graph/search/public-doc items were removed. +- ADR-0011 records the Mermaid/SVG graph and public readiness decision. ## Next step -**PR #2 open against `develop`, CI green (6/6), `mergeable`**: -https://github.com/SHcommit/ADR-toolkit/pull/2 +Commit the ADR-0011 follow-up after verification. Then open or update a PR for +`feature/project-roadmap-implements` into `develop`. -Release gate steps, each needing separate explicit owner approval — do not -perform any of the remaining ones without asking first, "CI is green" does -not imply approval for the next step: +The broader `v0.2.0` release gate still needs separate explicit owner approval +for each step: -1. ✅ **Done** — v0.2.0 version bump (`skills/adr-toolkit/VERSION` → - `0.2.0`, propagated via `scripts/sync_version.py`); CI re-ran green. -2. Merge PR #2 into `develop` (normal merge or squash — ask which). -3. Cut a `release/*` branch into `master` and back into `develop`, per +1. Merge the PR into `develop`. +2. Cut a `release/*` branch into `master` and back into `develop`, per `AGENTS.md`'s Git Flow policy. -4. Push a `v0.2.0` tag on `master` — - `.github/workflows/release.yml` then runs the full suite, verifies - manifest versions against the tag, and publishes the GitHub Release. +3. Push a `v0.2.0` tag on `master`; `.github/workflows/release.yml` then runs + the full suite, verifies manifest versions against the tag, and publishes + the GitHub Release. + +Do not perform merge, release branch, push, or tag operations without explicit +owner approval. + +## Latest local verification + +Before `8f0343e`: + +- `python3 -m pytest -q` -> `395 passed` +- `python3 scripts/sync_version.py --check` -> exit 0 +- `python3 skills/adr-toolkit/scripts/adr.py validate --dir docs/decisions --json` + -> 10 ADRs, no errors + +After ADR-0011 creation: -Latest local verification (2026-08-31): `python3 -m pytest -q` → `386 -passed`; `sync_version.py --check` → exit 0; `adr.py validate` → 10 ADRs, -no errors; `git status --short` → clean. +- `adr.py validate --dir docs/decisions --json` -> 11 ADRs, no errors +- `adr.py index --dir docs/decisions --json` -> 11 ADRs, no warnings +- `adr.py graph --dir docs/decisions --format both --json` -> 6 rendered graph + edges, no warnings -An open, undecided idea from this session: measuring how much LLM-token -input/output the deterministic core saves an agent versus doing the same -work by hand (e.g. CHECK returning a structured finding instead of the -agent scanning a full diff). Deliberately deferred — no scope or -destination decided. +Run the full verification suite again before the ADR-0011 commit. ## Open risks -- CHECK deliberately cannot prove prose, business rationale, or - organizational claims; those remain human-review evidence. The exception - mechanism extends this — an exception is recorded and annotated, never a - silent pass. +- `adr.py check --uncommitted` reports a `VIOLATED` finding for superseded + ADR-0003 when changes touch `index.py`; this branch intentionally follows + ADR-0006, which supersedes ADR-0003. Note that in PR review rather than + treating it as a code defect. +- CHECK deliberately cannot prove prose, business rationale, or organizational + claims; those remain human-review evidence. - Search/relationship matching is deterministic substring/exact/prefix - matching, untested at real scale (this repo has 10 ADRs); revisit - `project-roadmap.md`'s navigation-and-scale items if that changes. + matching, untested at real scale. Revisit roadmap scale items if an adopting + repository reaches hundreds of ADRs. - GitHub branch/tag protection is intentionally unavailable on the current - private plan; configure and API-verify after the repository goes public - (owner's own follow-up, not scheduled here). -- Version bump is done; release branch, push, and tag remain unauthorized - until the owner approves each step above. + private plan; configure and API-verify after the repository goes public. From b8db21157c499174ea1c108ab597e77c6ef0a316 Mon Sep 17 00:00:00 2001 From: shcommit Date: Mon, 31 Aug 2026 14:40:45 +0900 Subject: [PATCH 71/76] docs: correct stale ADR count in project-roadmap.md ADR-0011 brought the count to 11; the navigation-and-scale note still referenced 10. --- project-roadmap.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/project-roadmap.md b/project-roadmap.md index 6a16644..70a06d3 100644 --- a/project-roadmap.md +++ b/project-roadmap.md @@ -23,7 +23,7 @@ before implementation. Concrete selected work belongs in `improvements.md`. ## ADR navigation and scale - Test whether 500+ decisions require sharding, alternate indexes, or a - real search index (this repo has 10 ADRs; substring/tag/path matching is + real search index (this repo has 11 ADRs; substring/tag/path matching is untested at that scale). - Improve related-decision discovery beyond path/tag/keyword/body-substring only after real misses demonstrate the need for semantic retrieval. From b1bcb9370f353e733010de6d6cd04363d8975b0c Mon Sep 17 00:00:00 2001 From: shcommit Date: Mon, 31 Aug 2026 14:57:41 +0900 Subject: [PATCH 72/76] docs: add code of conduct and GitHub issue templates Baseline open-source hygiene ahead of the repository going public: a Code of Conduct with report/enforcement contact matching SECURITY.md's convention, and bug report / feature request issue templates scoped to this project's commands and existing roadmap/improvements tracking. --- .github/ISSUE_TEMPLATE/bug_report.md | 39 ++++++++++++++++++++ .github/ISSUE_TEMPLATE/feature_request.md | 19 ++++++++++ CODE_OF_CONDUCT.md | 44 +++++++++++++++++++++++ 3 files changed, 102 insertions(+) create mode 100644 .github/ISSUE_TEMPLATE/bug_report.md create mode 100644 .github/ISSUE_TEMPLATE/feature_request.md create mode 100644 CODE_OF_CONDUCT.md diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000..2c4e92b --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,39 @@ +--- +name: Bug report +about: Report a defect in ADR Toolkit +title: "" +labels: bug +--- + +## Summary + +## Steps to Reproduce + +1. +2. +3. + +## Expected Behavior + +## Actual Behavior + +## Environment + +- Harness: Claude Code / Codex CLI / Gemini CLI / Antigravity CLI / generic / other +- ADR Toolkit version (`skills/adr-toolkit/VERSION`): +- OS: + +## Relevant Output + +Paste the command and its output, including `--json` output where +applicable. + +``` +``` + +## Scope + +- [ ] This affects `adr.py validate`, `create`, `index`, `check`, `graph`, + or `search`. +- [ ] This affects a specific harness adapter (Codex, Gemini CLI, + Antigravity, or generic). diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 0000000..4f7f064 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,19 @@ +--- +name: Feature request +about: Propose new functionality for ADR Toolkit +title: "" +labels: enhancement +--- + +## Problem + +What are you trying to do that ADR Toolkit does not support today? + +## Proposed Solution + +## Alternatives Considered + +## Additional Context + +- [ ] I checked `project-roadmap.md` and `improvements.md` for existing + plans that already cover this. diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..f8fb524 --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,44 @@ +# Code of Conduct + +## Our Standard + +Participation in this project — issues, pull requests, discussions, and +reviews — is expected to stay respectful and focused on the technical +problem. Disagreement about a decision or an approach is normal; personal +attacks, harassment, discriminatory language, or deliberately derailing a +discussion are not. + +## Expected Behavior + +- Assume good faith and give reviewers/contributors the benefit of the + doubt. +- Keep feedback specific to the code, design, or decision under + discussion. +- Accept that maintainers may decline a change and explain why, without + that being an invitation to escalate. + +## Unacceptable Behavior + +- Harassment, insults, or discriminatory remarks directed at a person + rather than their contribution. +- Publishing others' private information without consent. +- Sustained disruption of issues, pull requests, or reviews. + +## Reporting + +Report a violation privately to the repository owner rather than escalating +in a public issue or pull request thread. Include what happened, where +(link to the issue/PR/comment), and when. + +## Enforcement + +The repository owner reviews reports and may edit or remove content, warn a +participant, or restrict further participation in the project's spaces, +proportional to the violation. Enforcement decisions are not owed a public +explanation when doing so would expose reporter identity or private +details. + +## Scope + +This applies to all project spaces (issues, pull requests, discussions, +commits, and comments) and to representing the project in public spaces. From e28eb5dbcfc85302d47bd10ee4e508b85ee36c3e Mon Sep 17 00:00:00 2001 From: shcommit Date: Mon, 31 Aug 2026 15:12:33 +0900 Subject: [PATCH 73/76] docs: record Antigravity CLI adapter verification against agy 1.1.13 Re-ran the same validate/install/list/script-layer checks documented for the Codex and Gemini CLI adapters, this time against the agy CLI, which is in fact available in this environment (contrary to the prior README note). All three script commands (preflight, init, validate) returned "ok": true from the agy-installed snapshot in an isolated HOME, closing the one real Harness parity gap: Antigravity was the only adapter with no end-to-end verification on record. --- adapters/antigravity/README.md | 65 +++++++++++++++++++++++++++++----- 1 file changed, 56 insertions(+), 9 deletions(-) diff --git a/adapters/antigravity/README.md b/adapters/antigravity/README.md index fc9331b..205d586 100644 --- a/adapters/antigravity/README.md +++ b/adapters/antigravity/README.md @@ -3,6 +3,9 @@ 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`. +**Manually verified against Antigravity's `agy` CLI 1.1.13** (`agy +--version`): validate, install, and discovery all work — see "Verification +status" below. ## Install @@ -14,18 +17,62 @@ directories (`skills/`, `agents/`, `rules/`), per 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 +2. Install with Antigravity's own plugin CLI: + + ```bash + agy plugin install "$(pwd)/adapters/antigravity" + ``` + +3. Confirm Antigravity lists `adr-toolkit`: + + ```bash + agy plugin list + ``` + +4. Either way, 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. + +## Verification status + +Manually verified against Antigravity's `agy` CLI 1.1.13 (`agy --version`) +in an isolated `HOME=$(mktemp -d)` so no state was written to the real +`~/.gemini` (Antigravity's plugin CLI stores state under `.gemini/config/` +in the active home directory). + +``` +$ agy plugin validate "$(pwd)/adapters/antigravity" + [ok] /adapters/antigravity + [check] skills : 1 processed + - agents : skipped (not found) + - commands : skipped (not found) + - mcpServers : skipped (not found) + - hooks : skipped (not found) + +$ agy plugin install "$(pwd)/adapters/antigravity" + [ok] adr-toolkit + [check] skills : 1 processed + +$ agy plugin list +{ + "imports": [ + { + "name": "adr-toolkit", + "source": "antigravity", + "importedAt": "2026-08-31T06:11:34Z", + "components": ["skills"] + } + ] +} +``` + +The installed snapshot lands under `/.gemini/config/plugins/adr-toolkit/` +and carries the whole `skills/adr-toolkit/` package. The script layer was +run directly out of it against a scratch git repository, producing correct +`"ok": true` output for `preflight`, `init --dir docs/decisions`, and +`validate --dir docs/decisions` — matching the Codex and Gemini CLI +adapters' verified depth. From 2f2f604d6c58b0f8a2af2081ec8721b6f771a5a4 Mon Sep 17 00:00:00 2001 From: shcommit Date: Mon, 31 Aug 2026 15:26:56 +0900 Subject: [PATCH 74/76] ci: add harness-parity job for Codex and Gemini CLI end-to-end install Existing tests only check that adapter manifests are schema-valid JSON; nothing catches an upstream CLI change or a manifest edit that breaks real installation. This job installs the real Codex CLI (0.151.0) and Gemini CLI (0.46.0) -- the exact versions manually verified in adapters/codex and adapters/gemini-cli's READMEs -- and drives their own plugin/extension commands (marketplace add, install, list) against this repo, then runs preflight/init/validate from the installed snapshot and asserts "ok": true on each. Every step was dry-run locally against those exact CLI versions before landing this. Antigravity CLI (agy) has no npm/package-registry distribution, so it stays a manually verified adapter only per adapters/antigravity/README.md. --- .github/workflows/test.yml | 53 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 53 insertions(+) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index f20f9ec..5fb11b4 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -42,3 +42,56 @@ jobs: python-version: "3.12" - name: Check manifest versions and descriptions are in sync run: python scripts/sync_version.py --check + + harness-parity: + # Installs the real Codex CLI and Gemini CLI and drives their own plugin + # commands against this repo, the same way a contributor would. Manifest + # JSON shape is already covered by tests/unit/test_*_adapter.py; this job + # instead proves discovery -> install -> list -> the installed skill + # package's script layer still runs, so an upstream CLI change or a + # manifest edit that breaks real installation fails CI instead of + # surfacing later as a user-reported install failure. Antigravity CLI + # (agy) has no public package registry distribution, so it stays a + # manually verified adapter only -- see adapters/antigravity/README.md. + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: "20" + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + - name: Install Codex CLI + run: npm install -g @openai/codex@0.151.0 + - name: Install Gemini CLI + run: npm install -g @google/gemini-cli@0.46.0 + - name: Verify Codex CLI adapter end to end + run: | + set -euo pipefail + export CODEX_HOME="$(mktemp -d)" + REPO_ROOT="$(pwd)" + codex plugin marketplace add "$REPO_ROOT" + INSTALLED_PATH="$(codex plugin add adr-toolkit@adr-toolkit-marketplace --json | jq -r '.installedPath')" + codex plugin list + SCRATCH="$(mktemp -d)" + cd "$SCRATCH" && git init -q + python3 "$INSTALLED_PATH/skills/adr-toolkit/scripts/adr.py" preflight --json | jq -e '.ok == true' + python3 "$INSTALLED_PATH/skills/adr-toolkit/scripts/adr.py" init --dir docs/decisions --json | jq -e '.ok == true' + python3 "$INSTALLED_PATH/skills/adr-toolkit/scripts/adr.py" validate --dir docs/decisions --json | jq -e '.ok == true' + - name: Verify Gemini CLI adapter end to end + run: | + set -euo pipefail + REPO_ROOT="$(pwd)" + mkdir -p adapters/gemini-cli/skills + ln -s "$REPO_ROOT/skills/adr-toolkit" adapters/gemini-cli/skills/adr-toolkit + export HOME="$(mktemp -d)" + gemini extensions validate "$REPO_ROOT/adapters/gemini-cli" + printf 'y\ny\n' | gemini extensions install "$REPO_ROOT/adapters/gemini-cli" --consent + gemini extensions list + INSTALLED_PATH="$HOME/.gemini/extensions/adr-toolkit" + SCRATCH="$(mktemp -d)" + cd "$SCRATCH" && git init -q + python3 "$INSTALLED_PATH/skills/adr-toolkit/scripts/adr.py" preflight --json | jq -e '.ok == true' + python3 "$INSTALLED_PATH/skills/adr-toolkit/scripts/adr.py" init --dir docs/decisions --json | jq -e '.ok == true' + python3 "$INSTALLED_PATH/skills/adr-toolkit/scripts/adr.py" validate --dir docs/decisions --json | jq -e '.ok == true' From 3058325c7cd69574395d59f51f3cdb9ca8480d78 Mon Sep 17 00:00:00 2001 From: shcommit Date: Mon, 31 Aug 2026 15:27:57 +0900 Subject: [PATCH 75/76] docs: close out harness-parity session in handoff and roadmap project-roadmap.md's Harness parity section now reflects that Codex/Gemini CLI install verification is automated in CI; the remaining gaps are extending coverage past preflight/init/validate and automating Antigravity once it has an installable distribution. handoff.md rewritten to describe what this session actually did instead of the stale ADR-0011-in-progress state, and flags that harness-parity has only run locally, never on a real GitHub Actions runner yet. --- handoff.md | 82 ++++++++++++++++++++++++++++++++-------------- project-roadmap.md | 14 ++++++-- 2 files changed, 70 insertions(+), 26 deletions(-) diff --git a/handoff.md b/handoff.md index d206060..4bdfde1 100644 --- a/handoff.md +++ b/handoff.md @@ -2,33 +2,59 @@ ## Current task (2026-08-31) -Public-readiness and ADR navigation graph work is implemented on branch +Public-readiness, ADR navigation graph, and harness-parity work is on branch `feature/project-roadmap-implements` in: `/Users/yangseunghyeon/orca/workspaces/ADR-toolkit/develop` -Latest commits: +Latest commits (newest first): +- `2f2f604 ci: add harness-parity job for Codex and Gemini CLI end-to-end install` +- `e28eb5d docs: record Antigravity CLI adapter verification against agy 1.1.13` +- `b1bcb93 docs: add code of conduct and GitHub issue templates` +- `b8db211 docs: correct stale ADR count in project-roadmap.md` +- `35c81aa docs: record ADR-0011 for relationship graph and public readiness work` - `8f0343e feat: add ADR relationship graph exports` -- ADR-0011 work is in progress in the working tree and should be committed - after final verification. -Implemented in this session: +Working tree is clean; every item below is already committed. + +Implemented this session: - `adr.py graph --format mermaid|svg|both` exports deterministic Mermaid and - Python-rendered SVG artifacts. -- `adr.py index` embeds a GitHub-renderable Mermaid relationship graph in the - generated decision log. -- Public repository hygiene docs were added: - `CONTRIBUTING.md`, `SECURITY.md`, and `.github/PULL_REQUEST_TEMPLATE.md`. -- `project-roadmap.md` now lists only remaining unscheduled work; completed - graph/search/public-doc items were removed. -- ADR-0011 records the Mermaid/SVG graph and public readiness decision. + Python-rendered SVG artifacts; `adr.py index` embeds the Mermaid graph in + the generated decision log. ADR-0011 records this decision. +- Public repository hygiene: `CONTRIBUTING.md`, `SECURITY.md`, + `.github/PULL_REQUEST_TEMPLATE.md`, `CODE_OF_CONDUCT.md`, and + `.github/ISSUE_TEMPLATE/{bug_report,feature_request}.md`. +- Harness parity, manually re-verified end-to-end against the real CLIs + installed on this machine (not just manifest-schema checks): + - Codex CLI 0.151.0 -- `codex plugin marketplace add/add/list`, then + `preflight`/`init`/`validate` from the installed snapshot, all + `"ok": true`. + - Gemini CLI 0.46.0 -- `gemini extensions validate/install/list`, then the + same three commands from the installed snapshot, all `"ok": true`. + - Antigravity CLI (`agy` 1.1.13) -- previously undocumented as available; + `agy plugin validate/install/list` plus the same three commands all + `"ok": true`. `adapters/antigravity/README.md` updated from "unverified" + to a recorded transcript, matching the Codex/Gemini READMEs' format. + - New `harness-parity` CI job (`.github/workflows/test.yml`) automates the + Codex and Gemini checks above on every push/PR, pinned to the exact + verified versions. Every step was dry-run locally against those same + CLI versions before landing. Antigravity stays manual-only: `agy` has no + npm/package-registry distribution, so a CI runner can't install it. + - `project-roadmap.md`'s "Harness parity" section narrowed to what's + actually still open: extending `harness-parity` past + preflight/init/validate to check/search/graph/create, and automating + Antigravity once it has an installable distribution. +- `project-roadmap.md` audited end-to-end against the codebase; confirmed no + other section describes already-shipped work. ## Next step -Commit the ADR-0011 follow-up after verification. Then open or update a PR for -`feature/project-roadmap-implements` into `develop`. +Open or update a PR for `feature/project-roadmap-implements` into `develop` +so the new `harness-parity` CI job actually runs on GitHub's runners at +least once before being trusted (everything above was dry-run locally with +the same pinned CLI versions, but never through the real Actions runner). The broader `v0.2.0` release gate still needs separate explicit owner approval for each step: @@ -41,28 +67,35 @@ for each step: the GitHub Release. Do not perform merge, release branch, push, or tag operations without explicit -owner approval. +owner approval. The repository owner has said the switch to public is coming +"soon" but deliberately not yet -- don't start the "Public and enterprise +governance" roadmap items (they're gated on the repo actually being public). ## Latest local verification -Before `8f0343e`: +After `2f2f604` (current HEAD): - `python3 -m pytest -q` -> `395 passed` - `python3 scripts/sync_version.py --check` -> exit 0 -- `python3 skills/adr-toolkit/scripts/adr.py validate --dir docs/decisions --json` - -> 10 ADRs, no errors - -After ADR-0011 creation: - - `adr.py validate --dir docs/decisions --json` -> 11 ADRs, no errors - `adr.py index --dir docs/decisions --json` -> 11 ADRs, no warnings - `adr.py graph --dir docs/decisions --format both --json` -> 6 rendered graph edges, no warnings +- `harness-parity` job's Codex and Gemini steps dry-run locally against + Codex CLI 0.151.0 and Gemini CLI 0.46.0 -- both passed, matching what the + workflow file runs. -Run the full verification suite again before the ADR-0011 commit. +Run the full verification suite again before any further commit, and after +the PR's first real CI run, confirm `harness-parity` actually went green on +GitHub's runners (not just locally) before relying on it as a gate. ## Open risks +- `harness-parity`'s Codex/Gemini steps have only ever run locally on this + macOS dev machine, never on GitHub's `ubuntu-latest` runners. Watch the + first real CI run for environment differences (npm registry access, + network egress rules, `jq` availability -- present by default on + `ubuntu-latest` but unconfirmed here). - `adr.py check --uncommitted` reports a `VIOLATED` finding for superseded ADR-0003 when changes touch `index.py`; this branch intentionally follows ADR-0006, which supersedes ADR-0003. Note that in PR review rather than @@ -73,4 +106,5 @@ Run the full verification suite again before the ADR-0011 commit. matching, untested at real scale. Revisit roadmap scale items if an adopting repository reaches hundreds of ADRs. - GitHub branch/tag protection is intentionally unavailable on the current - private plan; configure and API-verify after the repository goes public. + private plan; configure and API-verify after the repository goes public + (owner has deferred this deliberately, not blocked on it). diff --git a/project-roadmap.md b/project-roadmap.md index 70a06d3..3054e4e 100644 --- a/project-roadmap.md +++ b/project-roadmap.md @@ -15,8 +15,18 @@ before implementation. Concrete selected work belongs in `improvements.md`. ## Harness parity -- Full cross-harness fixture/golden matrix for Codex, Gemini CLI, and - Antigravity CLI at the depth currently available for Claude Code. +- ~~Automate the Codex CLI and Gemini CLI adapters' install-and-run + verification.~~ **Done (2026-08-31).** `.github/workflows/test.yml`'s + `harness-parity` job installs the real Codex CLI and Gemini CLI and runs + `preflight`/`init`/`validate` from each one's installed snapshot on every + push and pull request. +- Extend `harness-parity` coverage beyond `preflight`/`init`/`validate` to + `check`, `search`, `graph`, and `create` once a real regression in one of + those commands under a specific harness demonstrates the gap matters. +- Automate the Antigravity CLI (`agy`) adapter the same way once it has a + package-registry distribution a CI runner can install non-interactively; + today it has none, so `adapters/antigravity/README.md`'s manual + verification is the only signal. - Harness-specific hook support beyond Claude Code SessionStart when equivalent stable extension points exist. From 178394e8a2c8f6e813d0b50621e21c4a1ae2fb25 Mon Sep 17 00:00:00 2001 From: shcommit Date: Mon, 31 Aug 2026 16:03:02 +0900 Subject: [PATCH 76/76] docs: record SessionStart hook evaluation as not pursued Codex CLI and Gemini CLI now both have stable hook extension points (hooks.json SessionStart/UserPromptSubmit; gemini hooks migrate), so the roadmap item's precondition is technically satisfied. Decided not to pursue it anyway: ADR Toolkit doesn't use a hook even on Claude Code today (pure skill auto-discovery), an always-on session hook cuts against the project's own minimal-interruption principle, and the plausible use cases are already covered by deliberately invoking discover/check. Recorded so the same discussion doesn't have to happen again from scratch. --- project-roadmap.md | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/project-roadmap.md b/project-roadmap.md index 3054e4e..d64bf98 100644 --- a/project-roadmap.md +++ b/project-roadmap.md @@ -27,8 +27,21 @@ before implementation. Concrete selected work belongs in `improvements.md`. package-registry distribution a CI runner can install non-interactively; today it has none, so `adapters/antigravity/README.md`'s manual verification is the only signal. -- Harness-specific hook support beyond Claude Code SessionStart when equivalent - stable extension points exist. +- ~~Harness-specific hook support beyond Claude Code SessionStart when + equivalent stable extension points exist.~~ **Evaluated, not pursued + (2026-08-31).** The precondition is now true: Codex CLI has a config-driven + `SessionStart`/`UserPromptSubmit` hook system (`~/.codex/hooks.json`), and + Gemini CLI ships `gemini hooks migrate` specifically to port Claude Code + hooks over. But ADR Toolkit doesn't use a hook even on Claude Code today + (it relies entirely on skill auto-discovery), and a hook that fires on + every session regardless of relevance cuts against this project's own + restraint principle (max 3 questions, judge what's significant, minimize + interruption). The plausible use cases (nudge about an unfinished draft + ADR, warn about a governed path) are already covered by deliberately + invoking `discover` and `check` rather than an always-on hook. Revisit + only if real usage shows people miss something that `discover`/`check` + can't catch without a session-start nudge -- not just because the + extension points now exist. ## ADR navigation and scale