From 7b9bb8c46a7caf549143bad4e12e7b6cf8c11a37 Mon Sep 17 00:00:00 2001 From: shcommit Date: Sun, 30 Aug 2026 19:09:27 +0900 Subject: [PATCH 01/62] 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 02/62] 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 03/62] 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 04/62] 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 05/62] 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 06/62] 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 07/62] 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 08/62] 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 09/62] 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 10/62] 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 11/62] 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 12/62] 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 13/62] 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 14/62] 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 15/62] 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 16/62] 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 17/62] 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 18/62] 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 19/62] 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 20/62] 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 21/62] 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 22/62] 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 23/62] 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 24/62] 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 25/62] 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 26/62] 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 27/62] 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 28/62] 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 29/62] 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 30/62] 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 31/62] 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 32/62] 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 33/62] 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 34/62] 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 35/62] 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 36/62] 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 37/62] 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 38/62] 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 39/62] 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 40/62] 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 41/62] 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 42/62] 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 43/62] 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 44/62] 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 45/62] 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 46/62] 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 47/62] 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 48/62] 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 49/62] 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 50/62] 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 51/62] 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 52/62] 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 53/62] 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 54/62] 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 55/62] 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 56/62] 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 57/62] 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 58/62] 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 59/62] 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 60/62] 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 61/62] 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 62/62] 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.