Build os-adr plugin: ADR authoring, session hook, migration, retrieval
Implements OpenSpec change add-os-adr-plugin (archived as 2026-07-03-add-os-adr-plugin; delta specs synced to openspec/specs/). - plugins/os-adr/: Ruby OO core (Record, Repository, Index, Template, Detector, Migrator, MigrationReport, Finder) behind thin bin/ CLIs; Python SessionStart hook via plugin-relative hooks.json; four skills (create, init, migrate, find); invariants.md; 47 Ruby + 15 Python model-free tests, all passing. - Migration pilot passed (threshold 25%): viking 0%, delta-refinery 0%, llf-schema 8.3%. Retrieval verified against the real llf-schema graph; resolved that ADRs need no Graphify tag/edge convention (layer 3 expands query paths via node source_files). - docs/adr-system/: requirements (04), PRD (05), eval sketches (06). - ADR-020 recorded; CLAUDE.md Implemented Components updated with the locked rollout order (pilot projects -> cc-os retrofit -> wider). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
5d5145f347
commit
36abd43107
|
|
@ -11,3 +11,5 @@
|
||||||
graphify-out/
|
graphify-out/
|
||||||
# doc-hygiene plugin state
|
# doc-hygiene plugin state
|
||||||
.dochygiene/
|
.dochygiene/
|
||||||
|
# os-adr plugin state
|
||||||
|
.os-adr/
|
||||||
|
|
|
||||||
|
|
@ -92,6 +92,14 @@ to those two and fix the stale doc.
|
||||||
- Behavior: Monitors stale and bloated project documentation per-project under `.dochygiene/` state dir (gitignored). SessionStart reminder is deterministic (no AI tokens, once/day snooze). Skills (verb-first, no `commands/` dispatcher — invoked directly as `/os-doc-hygiene:<skill>`, per [[cc-os-plugin-skill-naming-convention]]): `check` (AI-assisted classification of staleness signals, emits machine+human reports), `clean` (AI-assisted or deterministic patch application with git-safe scoped cleanup), `status` (read-only lifecycle-timestamp read), `sweep` (check then clean in sequence). Reversion-protected via invariants.md + golden-example test fixtures.
|
- Behavior: Monitors stale and bloated project documentation per-project under `.dochygiene/` state dir (gitignored). SessionStart reminder is deterministic (no AI tokens, once/day snooze). Skills (verb-first, no `commands/` dispatcher — invoked directly as `/os-doc-hygiene:<skill>`, per [[cc-os-plugin-skill-naming-convention]]): `check` (AI-assisted classification of staleness signals, emits machine+human reports), `clean` (AI-assisted or deterministic patch application with git-safe scoped cleanup), `status` (read-only lifecycle-timestamp read), `sweep` (check then clean in sequence). Reversion-protected via invariants.md + golden-example test fixtures.
|
||||||
- Migration: migrated from standalone repo (`~/dev/cc-plugins/doc-hygiene/`, 2026-07-03) and integrated into cc-os. No content conflicts detected (doc-hygiene scope does not overlap cc-os memory system). Renamed from `doc-hygiene` to `os-doc-hygiene` per cc-os plugin naming convention. Skills renamed `hygiene-check`/`hygiene-clean` → `check`/`clean`, and the `commands/hygiene.md` dispatcher removed in favor of two new skills (`status`, `sweep`), aligning with the `os-vault`/`os-orchestration` pattern of no `commands/` directory (2026-07-03).
|
- Migration: migrated from standalone repo (`~/dev/cc-plugins/doc-hygiene/`, 2026-07-03) and integrated into cc-os. No content conflicts detected (doc-hygiene scope does not overlap cc-os memory system). Renamed from `doc-hygiene` to `os-doc-hygiene` per cc-os plugin naming convention. Skills renamed `hygiene-check`/`hygiene-clean` → `check`/`clean`, and the `commands/hygiene.md` dispatcher removed in favor of two new skills (`status`, `sweep`), aligning with the `os-vault`/`os-orchestration` pattern of no `commands/` directory (2026-07-03).
|
||||||
|
|
||||||
|
**Global os-adr plugin** — `cc-os/plugins/os-adr/` (git-tracked, 2026-07-03); symlinked into `~/.claude/plugins/os-adr`
|
||||||
|
- Purpose: Architecture Decision Records, consistently in every project — one customized-Nygard template (frontmatter `id`/`date`/`status`/`supersedes`/`superseded-by`/`affected-paths`/`affected-components`; Context/Decision/Consequences/Alternatives-rejected sections), `docs/adr/NNNN-kebab-title.md` one-file-per-decision + fully-regenerated `docs/adr/README.md` index. Built from `docs/adr-system/` (requirements `04`, PRD `05`, eval sketches `06`) via OpenSpec change `add-os-adr-plugin`.
|
||||||
|
- Core: Ruby OO library `lib/adr/` (`Record`, `Repository`, `Index`, `Template`, `Detector`, `Migrator`, `MigrationReport`, `Finder`; Sandi Metz style, injected paths) behind thin CLIs in `bin/` (`adr-new`, `adr-init`, `adr-detect`, `adr-migrate`, `adr-find`). Tests: `ruby tests/all.rb` (47) + `python3 tests/hook_test.py` (15), all model-free. Behavioral invariants in `invariants.md`.
|
||||||
|
- Hook: `hooks/session_start.py` wired via plugin-relative `hooks/hooks.json` (`${CLAUDE_PLUGIN_ROOT}`, no `settings.json` entries) — deterministic existence check: present → near-zero-token usage note naming `/os-adr:create` + `/os-adr:find`; absent → once-per-day init/migrate suggestion, silenced permanently by `.os-adr/suppress` (gitignored per-project state dir); silent outside git projects.
|
||||||
|
- Skills (verb-first, no `commands/`): `create` (one-invocation templated+numbered+indexed ADR with mechanical supersession), `init`, `migrate` (non-destructive: detect surveyed shapes → mechanical heuristic fill → LLM fills only manifest-listed interpretive fields via `--apply-fills` → `migration_confidence` frontmatter + `docs/adr/migration-report.md`; old-system deletion only as an explicit separate user-approved step), `find` (deterministic-first retrieval: affected-paths/components match → Accepted-only filter → one-hop Graphify query-path expansion with graceful degradation → AI judgment over the candidate set only).
|
||||||
|
- Migration pilot passed 2026-07-03 (sandboxed project copies; gate threshold 25% low-confidence flags): viking-warrior-training-log 0%, delta-refinery 0%, llf-schema 8.3%. Retrieval verified end-to-end against the real llf-schema project graph. Resolved: ADRs need no Graphify tag/edge convention — retrieval layer 3 expands the *query* paths via graph-node `source_file`s.
|
||||||
|
- **Remaining (locked rollout order):** real-project migration/adoption one at a time via `/os-adr:migrate` — pilot projects first, then the cc-os retrofit (its 19-ADR monolithic file was deliberately excluded from the pilot), then wider; plus the deferred unprompted-behavior eval (held-out scenario shapes sketched in `docs/adr-system/06-eval-scenarios.md`; methodology locked in `04-plugin-requirements.md`).
|
||||||
|
|
||||||
**Graphify** — v0.8.31 at `/home/jared/.local/bin/graphify`
|
**Graphify** — v0.8.31 at `/home/jared/.local/bin/graphify`
|
||||||
- Vault graph: `~/Documents/SecondBrain/graphify-out/` — disposable, structural index rebuilt by SessionStart hook; handles relational/graph queries. Distinct from vault-index.json (planned, Plan B Phase 1): a flat `{tag: [{path, title, summary}]}` lookup rebuilt at SessionEnd, queried by the `/memory-find` skill for fast tag-based SB note discovery.
|
- Vault graph: `~/Documents/SecondBrain/graphify-out/` — disposable, structural index rebuilt by SessionStart hook; handles relational/graph queries. Distinct from vault-index.json (planned, Plan B Phase 1): a flat `{tag: [{path, title, summary}]}` lookup rebuilt at SessionEnd, queried by the `/memory-find` skill for fast tag-based SB note discovery.
|
||||||
- Project graph: `<project-root>/graphify-out/` — same pattern; gitignore it in each project repo
|
- Project graph: `<project-root>/graphify-out/` — same pattern; gitignore it in each project repo
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,13 @@
|
||||||
# ADR System — Research Phase
|
# ADR System — Research & Build
|
||||||
|
|
||||||
_Last updated: 2026-07-03_
|
_Last updated: 2026-07-03 — plugin BUILT (OpenSpec change `add-os-adr-plugin`, ADR-020); real-project migration/rollout remains, per the order below._
|
||||||
|
|
||||||
This directory captures research for standardizing how Architecture Decision Records (ADRs)
|
This directory captures research for standardizing how Architecture Decision Records (ADRs)
|
||||||
are organized and used across the user's projects, as a precursor to building a Claude Code
|
are organized and used across the user's projects, and the requirements/PRD the **now-built**
|
||||||
plugin that automates ADR creation and relevance-scoped retrieval. **Nothing here is built
|
`os-adr` plugin was implemented from (`cc-os/plugins/os-adr/`, installed as
|
||||||
yet** — this is the survey/research phase only, requested to answer "what do we have, and what
|
`os-adr@local-plugins`; see CLAUDE.md Implemented Components). The docs below remain the design
|
||||||
could we have with a little engineering elbow grease."
|
record; the migration pilot passed 2026-07-03 (flag rates: viking 0%, delta-refinery 0%,
|
||||||
|
llf-schema 8.3% vs. a 25% gate).
|
||||||
|
|
||||||
- **`01-current-state-survey.md`** — audit of every project under `~/dev/`, `~/clients/`, and
|
- **`01-current-state-survey.md`** — audit of every project under `~/dev/`, `~/clients/`, and
|
||||||
`~/projects/` for existing ADR practices (organization, templates, tooling, or absence
|
`~/projects/` for existing ADR practices (organization, templates, tooling, or absence
|
||||||
|
|
@ -17,8 +18,35 @@ could we have with a little engineering elbow grease."
|
||||||
- **`03-synthesis-and-opportunities.md`** — synthesis answering the three "how might we" goals
|
- **`03-synthesis-and-opportunities.md`** — synthesis answering the three "how might we" goals
|
||||||
(know when to create/query, systematize creation for consistency, and query with high
|
(know when to create/query, systematize creation for consistency, and query with high
|
||||||
relevance/low bloat) and naming concrete design options for a future `os-adr` plugin.
|
relevance/low bloat) and naming concrete design options for a future `os-adr` plugin.
|
||||||
|
- **`04-plugin-requirements.md`** — locked, pre-build requirements: single standardized template
|
||||||
|
(no client/tooling-mode split), `docs/adr/` location, the five plugin requirements (mechanical,
|
||||||
|
SessionStart existence-check hook, non-destructive migration, unprompted write/retrieval
|
||||||
|
effectiveness), eval-as-final-stage sequencing, and OOP/Sandi-Metz/Ruby-or-Python
|
||||||
|
implementation style.
|
||||||
|
- **`05-plugin-prd.md`** — the PRD to hand to `openspec-propose` when ready to build: problem
|
||||||
|
statement, goals/non-goals, requirements broken into build phases, the deferred
|
||||||
|
unprompted-behavior eval stage, architecture/success criteria. Incorporates a Fable review pass
|
||||||
|
(2026-07-03) that sharpened the migration and sequencing sections.
|
||||||
|
- **`06-eval-scenarios.md`** — held-out eval scenario *sketches* (write-trigger W1–W3, retrieval
|
||||||
|
R1–R4) written before the plugin skeleton froze; the evaluation itself remains a deferred,
|
||||||
|
separate stage per `04-plugin-requirements.md`.
|
||||||
|
|
||||||
Provenance note: `02-external-research.md` cites web sources gathered by research agents in a
|
Provenance note: `02-external-research.md` cites web sources gathered by research agents in a
|
||||||
single pass; several specific tool/repo names and one incident anecdote are flagged inline as
|
single pass; several specific tool/repo names and one incident anecdote are flagged inline as
|
||||||
**[unverified]** because they were not independently checked against the primary source in this
|
**[unverified]** because they were not independently checked against the primary source in this
|
||||||
pass. Verify before depending on any specific tool name in that file.
|
pass. Verify before depending on any specific tool name in that file.
|
||||||
|
|
||||||
|
## Build/rollout order (decided 2026-07-03)
|
||||||
|
|
||||||
|
1. **Build the `os-adr` plugin first**, against a clean/small pilot — not against cc-os's own
|
||||||
|
19-ADR file. cc-os's `docs/memory-system/03-architecture-decisions.md` is known to need
|
||||||
|
splitting into one-file-per-decision (per the ~30-decision monolithic-file ceiling noted in
|
||||||
|
`02-external-research.md`), but that split is **deliberately deferred**, not forgotten — it
|
||||||
|
happens *using* the plugin once built, not by hand beforehand.
|
||||||
|
2. **Then use the plugin to retrofit**: cc-os itself first, then other Claude Code projects on
|
||||||
|
this machine (per the `~/dev/`/`~/clients/`/`~/projects/` survey in `01-current-state-survey.md`),
|
||||||
|
then any new project encountered going forward — one at a time, matching the
|
||||||
|
build-first/migrate-incrementally precedent already set for the vault (ADR-013).
|
||||||
|
|
||||||
|
Do not re-litigate this ordering in a future session — build the tool, then use the tool to fix
|
||||||
|
the mess the tool was built to fix.
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,87 @@
|
||||||
|
# Plugin Requirements (locked, pre-build)
|
||||||
|
|
||||||
|
_Last updated: 2026-07-03_
|
||||||
|
|
||||||
|
Captured from the requirements-gathering conversation following the research phase
|
||||||
|
(`00-README.md`–`03-synthesis-and-opportunities.md`). These are decided; do not re-litigate them
|
||||||
|
in a future session without a new discussion that explicitly revisits one.
|
||||||
|
|
||||||
|
## Template decision
|
||||||
|
|
||||||
|
**One standardized template for every project, no client/tooling-mode split.** Superseding the
|
||||||
|
"two modes" option floated in `03-synthesis-and-opportunities.md` — that split was an artifact of
|
||||||
|
projects independently improvising with no shared plugin, not a real requirement. The user is not
|
||||||
|
a solo operator but works with a team of AI agents across every project; those agents need to
|
||||||
|
discover and write ADRs consistently, which a single template serves better than per-project
|
||||||
|
variants ever could.
|
||||||
|
|
||||||
|
- Customized Nygard: `Status` · `Context` · `Decision` · `Consequences` **+ `Alternatives
|
||||||
|
rejected`** as a first-class section (highest-value-per-word field per
|
||||||
|
`02-external-research.md`).
|
||||||
|
- Frontmatter: `supersedes:` / `superseded-by:`, `affected-paths:`/`affected-components:` (for
|
||||||
|
deterministic retrieval), `date`, sequential ID.
|
||||||
|
- MADR's RACI fields (Deciders/Consulted/Informed) explicitly rejected — no project run by this
|
||||||
|
user has the team-coordination problem those fields solve.
|
||||||
|
- One-file-per-decision + a mechanically-maintained index, always — including for small/client
|
||||||
|
projects. A monolithic single-file log is rejected outright for every project, not just large
|
||||||
|
ones: even a small client project's decisions log will bloat over time, and per-file+index
|
||||||
|
costs nothing extra at low decision counts (an index with 2 rows is fine).
|
||||||
|
|
||||||
|
## Location
|
||||||
|
|
||||||
|
**`docs/adr/`**, not a root-level `adr/`. Matches the actual industry convention (Nygard,
|
||||||
|
adr-tools, MADR, Log4brains, Backstage all default to `doc/adr/`/`docs/adr/`) and matches every
|
||||||
|
one of this user's own surveyed projects that already nests decision docs under `docs/`. Riding
|
||||||
|
the well-known convention matters here specifically because one goal is agent discoverability
|
||||||
|
*without* the plugin's help.
|
||||||
|
|
||||||
|
## Five requirements for the `os-adr` plugin
|
||||||
|
|
||||||
|
1. **As mechanical as possible.** Deterministic scripts/parsing/index maintenance; reserve LLM
|
||||||
|
calls for genuine judgment (classification, ambiguous field-mapping during migration,
|
||||||
|
"is this ADR-worthy" nudges). Same shape as the `os-doc-hygiene` plugin's deterministic
|
||||||
|
scanner + generative-only-where-needed split.
|
||||||
|
2. **SessionStart hook, existence-check only.** Deterministically checks whether the ADR system
|
||||||
|
already exists in the current project (path + index file check, no LLM). If present: inject
|
||||||
|
the smallest possible context note that the ADR system exists and is usable, plus how to use
|
||||||
|
it — no bloat. If absent: notify the user and suggest setting it up. Same shape as
|
||||||
|
`os-doc-hygiene`'s zero-token SessionStart reminder.
|
||||||
|
3. **Non-destructive migration, dual-existence until explicit go-ahead.** Build the new system
|
||||||
|
fresh, then convert every existing ADR (whatever shape/project it's in) into the new template
|
||||||
|
— best-guess fill-in for missing expected attributes, flagged where the guess is uncertain.
|
||||||
|
The old system stays in place, untouched, until the user explicitly approves deleting it.
|
||||||
|
Matches the build-first/migrate-incrementally precedent already set for the vault (ADR-013).
|
||||||
|
4. **Effective at *writing* — unprompted trigger recognition.** The AI should recognize on its
|
||||||
|
own, without being told directly, when a decision in progress warrants writing a new ADR.
|
||||||
|
5. **Effective at *retrieval* — unprompted, correct surfacing.** The AI should recognize on its
|
||||||
|
own, without being told directly, when an existing ADR is relevant to a task or question in
|
||||||
|
progress (including cases where the right move is "stop, this conflicts with a locked
|
||||||
|
decision, adjust the approach") — and retrieve the *correct* one, not merely *a* plausible one.
|
||||||
|
|
||||||
|
## Sequencing: eval is a distinct final stage, not folded into the build
|
||||||
|
|
||||||
|
Requirements 4 and 5 are an **evaluation methodology**, not a build task, and happen **after**
|
||||||
|
the plugin is built and the design is believed sound — not concurrently with initial design.
|
||||||
|
Sequence:
|
||||||
|
|
||||||
|
1. Build the plugin (template, hooks, migration tooling) against the requirements above.
|
||||||
|
2. Only once there's a working, believed-correct design: design held-out test scenarios —
|
||||||
|
prompts/tasks that are relevant to a written ADR but never name it or the exact constraint
|
||||||
|
directly — and check whether the AI (a) consults the ADR system unprompted at all, and
|
||||||
|
(b) retrieves the specific correct ADR, not just a plausible-looking one.
|
||||||
|
3. This eval stage uses the **`autoresearch` skill** (modify → verify → keep/discard against a
|
||||||
|
metric) to iterate on retrieval/trigger heuristics against that held-out test set.
|
||||||
|
|
||||||
|
Do not attempt to validate retrieval/write-trigger effectiveness informally during initial build
|
||||||
|
— it needs the dedicated held-out-scenario methodology above to mean anything.
|
||||||
|
|
||||||
|
## Implementation style
|
||||||
|
|
||||||
|
- **Object-oriented**, following **Sandi Metz's** approach to OOP (small classes/methods, single
|
||||||
|
responsibility, dependency injection over reaching for globals, tell-don't-ask, duck typing
|
||||||
|
over type-checking) — the user's standing preference, [[ruby-and-sandi-metz-oop-preference]].
|
||||||
|
- Language: **Ruby preferred**; **Python acceptable** where it's the more native fit for Claude
|
||||||
|
Code tooling (e.g. hook scripts invoked directly by `settings.json`, matching the existing
|
||||||
|
`os-vault`/`os-doc-hygiene` hook pattern in this repo, which are Python). Decide per-component,
|
||||||
|
not globally — a hook entry point can be a thin Python script that still delegates to an
|
||||||
|
OO-designed core, in either language, if that core is more naturally Ruby.
|
||||||
|
|
@ -0,0 +1,146 @@
|
||||||
|
# PRD: `os-adr` Plugin
|
||||||
|
|
||||||
|
_Last updated: 2026-07-03_
|
||||||
|
|
||||||
|
Product requirements for the `os-adr` Claude Code plugin — the "what and why," to direct a
|
||||||
|
future `openspec-propose` when the user is ready to implement. Built on the research
|
||||||
|
(`01`–`03`), the locked requirements (`04-plugin-requirements.md`), and a review pass from Fable
|
||||||
|
(external reviewer consult, 2026-07-03) that shaped the gaps and sequencing called out below.
|
||||||
|
Do not silently narrow or reverse anything in `04-plugin-requirements.md` — this doc specifies
|
||||||
|
*how* those requirements get built, not a re-decision of *what* they are.
|
||||||
|
|
||||||
|
## Problem statement
|
||||||
|
|
||||||
|
~43% of this user's own projects have ADR-like content, in 5+ mutually-incompatible shapes; the
|
||||||
|
rest have none. No project uses ADR tooling. The user works with AI agents across every one of
|
||||||
|
these projects, not just themselves — so decision records need to be discoverable and writable
|
||||||
|
*consistently* by an agent that has never seen a given project before, which no current project
|
||||||
|
provides. `cc-os`'s own 19-ADR/601-line file is already near the point research says monolithic
|
||||||
|
logs stop working. This plugin exists to fix that, once, and mechanically enough that it doesn't
|
||||||
|
become another bespoke-per-project thing.
|
||||||
|
|
||||||
|
## Goals
|
||||||
|
|
||||||
|
- One template, one location convention, enforced the same way in every project.
|
||||||
|
- New ADRs get written (by the AI, largely unprompted) using that template with no per-project
|
||||||
|
relearning.
|
||||||
|
- Existing ADR content in every project — no matter its current shape — converts into the new
|
||||||
|
template non-destructively.
|
||||||
|
- An AI agent working in any onboarded project surfaces the *correct* relevant ADR without being
|
||||||
|
told to look, and recognizes when its own in-progress decision warrants a new one.
|
||||||
|
|
||||||
|
## Non-goals (explicit, out of scope for this PRD)
|
||||||
|
|
||||||
|
- Per-project or per-client template variants (rejected in `04-plugin-requirements.md`; not
|
||||||
|
revisited here).
|
||||||
|
- Multi-editor / handed-off-repo governance. This plugin assumes the user is the primary ADR
|
||||||
|
author (directly, or via their AI agents) in every project it's installed in. A repo where
|
||||||
|
other humans independently maintain ADR conventions is out of scope for Phase 1 — flag this
|
||||||
|
assumption in the plugin's own docs rather than silently breaking there.
|
||||||
|
- Mirroring ADRs into an enforceable-constraints surface (AGENTS.md-style hard rules). The
|
||||||
|
research surfaced this as a real tension (ADRs record "why," not "must never happen"), but nothing
|
||||||
|
today gives ADRs a severity field or an enforcement mechanism. Note as a candidate Phase 2 if a
|
||||||
|
severity field gets added later and high-severity decisions accumulate — not part of this build.
|
||||||
|
- Retrofitting `cc-os`'s own ADR file as part of building the plugin. Per the README's build
|
||||||
|
order note: build first against a small pilot, retrofit cc-os and everything else after.
|
||||||
|
|
||||||
|
## Requirements → build phases
|
||||||
|
|
||||||
|
### Phase 1 — Template + location (mechanical, no LLM)
|
||||||
|
|
||||||
|
- Ship the customized-Nygard template and `docs/adr/NNNN-title.md` + index file convention from
|
||||||
|
`04-plugin-requirements.md`, as a copy-paste-ready generator (`os-adr:new` or similar skill).
|
||||||
|
- Index file is maintained mechanically (append/update on every write) — never hand-edited.
|
||||||
|
|
||||||
|
### Phase 2 — SessionStart existence-check hook
|
||||||
|
|
||||||
|
- Deterministic only: check for `docs/adr/` + index file. No LLM call in the hook itself.
|
||||||
|
- Present → inject the smallest possible context note (existence + one-line "how to use it").
|
||||||
|
Budget this like `os-doc-hygiene`'s reminder: near-zero tokens.
|
||||||
|
- Absent → notify the user, suggest running the setup/migration skill. **Needs a suppression
|
||||||
|
mechanism** (flagged by Fable review) so a project that deliberately has no ADR system yet
|
||||||
|
doesn't re-nag every session — a per-project dismiss flag (e.g. a marker file or config line),
|
||||||
|
same shape as `os-doc-hygiene`'s snooze state.
|
||||||
|
|
||||||
|
### Phase 3 — Non-destructive migration
|
||||||
|
|
||||||
|
This is the highest-friction requirement (Fable review flags it as the one with the most edge
|
||||||
|
cases) — resolve these before implementation starts, not during:
|
||||||
|
|
||||||
|
- **Detection**: what counts as "an existing ADR" in an unknown project must be defined per
|
||||||
|
known shape from `01-current-state-survey.md` (numbered per-file dirs, dated single files,
|
||||||
|
monolithic multi-decision files, prose-embedded-in-other-docs) — plus a fallback path for
|
||||||
|
shapes not yet seen.
|
||||||
|
- **Field-mapping threshold** (mechanical vs. LLM boundary — the vaguest part of requirement 4a
|
||||||
|
as originally stated): define a concrete rule before build, e.g. *heuristic-fill fields that
|
||||||
|
are structurally unambiguous (Status/Date via frontmatter or clear heading), LLM-fill only
|
||||||
|
fields requiring interpretation (Consequences, Alternatives-rejected) with the result flagged,
|
||||||
|
never silently invent a Decision or Context field from nothing.*
|
||||||
|
- **Flagging format**: a single, consistent surface for uncertain guesses — a per-file frontmatter
|
||||||
|
field (e.g. `migration_confidence: low`) plus one migration report file, not scattered
|
||||||
|
inline comments. Decide the exact shape during build, but it must be one mechanism, not
|
||||||
|
several.
|
||||||
|
- **Non-destructiveness**: old ADR content is never deleted or moved during migration — new files
|
||||||
|
are written alongside it under `docs/adr/`. Deletion of the old system happens only on an
|
||||||
|
explicit, separate user-approved step.
|
||||||
|
- **Pilot before shipping broadly**: run the migration on 2-3 real projects (candidates: the ones
|
||||||
|
already surveyed with ADR content — viking-warrior-training-log, delta-refinery, llf-schema)
|
||||||
|
and check the uncertain-flag rate before treating the heuristic as good enough. If a large
|
||||||
|
share of fields end up flagged, tighten the heuristic before wider rollout — this is a build-phase
|
||||||
|
gate, not a shipped feature.
|
||||||
|
|
||||||
|
### Phase 4 — Retrieval (deterministic-first, per the research's layering)
|
||||||
|
|
||||||
|
1. Path/component match against each ADR's `affected-paths`/`affected-components` frontmatter.
|
||||||
|
2. Status filter — only `Accepted` by default.
|
||||||
|
3. Graph traversal via Graphify once ADRs are indexed as graph nodes (reuses the `os-vault`
|
||||||
|
infrastructure rather than a parallel retrieval system).
|
||||||
|
4. AI judgment only over the already-narrowed candidate set from 1-3 — never the full corpus.
|
||||||
|
|
||||||
|
## Deferred: unprompted-behavior evaluation (requirements 4d/4e)
|
||||||
|
|
||||||
|
Per the locked sequencing in `04-plugin-requirements.md`: this is a distinct stage *after* the
|
||||||
|
plugin is built and believed correct, not concurrent with the build. However, per Fable's review,
|
||||||
|
sketch (not implement) the held-out test scenarios **before** finalizing the plugin skeleton, so
|
||||||
|
Phase 4's retrieval surfaces aren't accidentally missing something the eval will need later.
|
||||||
|
Two example scenario shapes to sketch now:
|
||||||
|
|
||||||
|
- **Write-trigger scenario**: give the AI a task that touches a decision of real consequence
|
||||||
|
(irreversible, cross-cutting, contested) without saying "write an ADR" — check whether it
|
||||||
|
proposes one unprompted.
|
||||||
|
- **Retrieval scenario**: give the AI a task or question that conflicts with an already-written,
|
||||||
|
Accepted ADR without naming the ADR or the constraint directly — check whether it (a) surfaces
|
||||||
|
the ADR system at all, and (b) retrieves the *specific correct* one, not merely a plausible one.
|
||||||
|
|
||||||
|
If sketching these surfaces a plugin surface Phase 1-4 doesn't yet provide (e.g. a query tool
|
||||||
|
callable mid-task, not just at SessionStart), fold that surface into the initial build. Do not
|
||||||
|
fold the *evaluation itself* in — that stays a separate, later stage using the `autoresearch`
|
||||||
|
skill's modify→verify→keep/discard loop against held-out scenarios, per the locked sequencing.
|
||||||
|
|
||||||
|
## Architecture / implementation style
|
||||||
|
|
||||||
|
- Object-oriented, Sandi Metz principles (small classes/methods, single responsibility,
|
||||||
|
dependency injection over globals, tell-don't-ask, duck typing over type-checks) —
|
||||||
|
see the [[ruby-and-sandi-metz-oop-preference]] memory.
|
||||||
|
- Ruby preferred; Python acceptable for hook entry points where it's the more native fit
|
||||||
|
(matching the existing `os-vault`/`os-doc-hygiene` Python hook pattern) — decided
|
||||||
|
per-component, not as a global language choice. A Python hook script may thinly wrap an
|
||||||
|
OO-designed Ruby or Python core.
|
||||||
|
|
||||||
|
## Success criteria
|
||||||
|
|
||||||
|
- Every surveyed project with existing ADR content converts with zero data loss (old system
|
||||||
|
fully intact until user-approved deletion).
|
||||||
|
- Migration uncertain-flag rate stays low enough to be trustworthy (define and check an explicit
|
||||||
|
threshold during the Phase 3 pilot — do not ship on an unmeasured guess).
|
||||||
|
- SessionStart hook overhead is near-zero-token and adds no perceptible session-start latency.
|
||||||
|
- A new ADR can be written, correctly templated and indexed, in one skill invocation.
|
||||||
|
- (Deferred, Eval stage) unprompted write-trigger and retrieval accuracy measured against a
|
||||||
|
held-out scenario set — no numeric target set yet; the held-out methodology itself is the
|
||||||
|
Phase 5 deliverable.
|
||||||
|
|
||||||
|
## Rollout order
|
||||||
|
|
||||||
|
Matches the README's locked build order: build and pilot this plugin on a small/clean project
|
||||||
|
first, then use it to retrofit `cc-os` itself, then other `~/dev/` projects, then
|
||||||
|
`~/clients/`/`~/projects/`, then any project encountered going forward — one at a time.
|
||||||
|
|
@ -0,0 +1,74 @@
|
||||||
|
# Eval Scenario Sketches (held-out, deferred stage)
|
||||||
|
|
||||||
|
_Last updated: 2026-07-03 — sketches only; the evaluation itself is a separate later stage
|
||||||
|
(locked sequencing in `04-plugin-requirements.md`)._
|
||||||
|
|
||||||
|
These sketch the *shape* of the held-out scenarios that will eventually measure requirements 4
|
||||||
|
and 5 (unprompted write-trigger recognition, unprompted correct retrieval). They exist now, before
|
||||||
|
the plugin skeleton freezes, for one purpose: to catch any plugin surface the eval will need that
|
||||||
|
Phases 1–4 don't provide. They are **not** to be run, automated, or informally "tried out" during
|
||||||
|
the build — that would contaminate the held-out methodology.
|
||||||
|
|
||||||
|
## Ground rules for all scenarios
|
||||||
|
|
||||||
|
- **Held-out**: scenario prompts never name the ADR system, the plugin, an ADR ID/title, or the
|
||||||
|
exact constraint text. Relevance must be discovered, not pointed at.
|
||||||
|
- **Scored on two axes**: (a) did the agent consult/propose the ADR system *at all* unprompted;
|
||||||
|
(b) did it act on the *specific correct* ADR (retrieval) or propose a *well-scoped* new one
|
||||||
|
(write-trigger) — not merely a plausible-looking neighbor.
|
||||||
|
- **Environment**: a real onboarded project (post-pilot) with a populated `docs/adr/`, the
|
||||||
|
SessionStart hook active, and — for graph-layer variants — a built `graphify-out/`.
|
||||||
|
- The eval stage will iterate with the `autoresearch` skill (modify → verify → keep/discard)
|
||||||
|
against these scenarios once written out in full.
|
||||||
|
|
||||||
|
## Write-trigger scenario shapes
|
||||||
|
|
||||||
|
**W1 — Irreversible choice mid-task.** Task: "Add persistence to X" in a project where the
|
||||||
|
storage engine choice is genuinely open and consequential (hard to reverse once data exists).
|
||||||
|
Never mention ADRs. Pass: the agent proposes recording the engine decision via `/os-adr:create`
|
||||||
|
(or asks whether to) before or immediately after implementing. Fail: silently picks one and moves on.
|
||||||
|
|
||||||
|
**W2 — Cross-cutting convention change.** Task: a refactor request whose natural solution
|
||||||
|
changes a convention used across many files (e.g. error-handling shape, module layout). Pass:
|
||||||
|
the agent recognizes the decision outlives the task and proposes an ADR. Fail: does the refactor
|
||||||
|
with no record.
|
||||||
|
|
||||||
|
**W3 — Contested reversal.** Task: user asks for something that quietly reverses an earlier
|
||||||
|
(non-ADR'd, but discoverable-in-code) approach. Pass: agent flags that this is a
|
||||||
|
decision-of-record moment and proposes capturing it. This shape overlaps retrieval when the
|
||||||
|
earlier approach *is* ADR'd — kept separate deliberately.
|
||||||
|
|
||||||
|
## Retrieval scenario shapes
|
||||||
|
|
||||||
|
**R1 — Direct conflict with an Accepted ADR.** Task: a change request whose obvious
|
||||||
|
implementation violates an existing Accepted ADR whose `affected-paths` cover the files being
|
||||||
|
edited. The prompt names neither the ADR nor the constraint. Pass: agent surfaces the specific
|
||||||
|
ADR (via `/os-adr:find` or the SessionStart note) and adjusts or pushes back. Fail: implements
|
||||||
|
the violation, or cites a different/wrong ADR.
|
||||||
|
|
||||||
|
**R2 — Distractor discrimination.** Same as R1, but `docs/adr/` also contains 2–3 plausible
|
||||||
|
near-miss ADRs (same component family, different decision; or Superseded status). Pass requires
|
||||||
|
retrieving the *correct, Accepted* one — this is the "correct, not merely plausible" axis and
|
||||||
|
the reason the status filter and path-match layers exist.
|
||||||
|
|
||||||
|
**R3 — Mid-task question.** Partway through an ongoing task, the user asks a "how should we…"
|
||||||
|
question already answered by an Accepted ADR, phrased in different vocabulary. Pass: agent
|
||||||
|
queries the ADR system mid-task (not only at SessionStart) and answers from the ADR. This is the
|
||||||
|
scenario that requires a **mid-task-callable query surface**.
|
||||||
|
|
||||||
|
**R4 — Graph-layer reach.** Conflict exists but the edited files are *not* listed in any ADR's
|
||||||
|
`affected-paths`; they are one graph hop away (import/reference) from files that are. Pass only
|
||||||
|
reachable via the Graphify traversal layer. Also serves as the degradation check: without
|
||||||
|
`graphify-out/`, R4 is expected to fail while R1–R3 still pass.
|
||||||
|
|
||||||
|
## Plugin-surface requirements derived from these sketches
|
||||||
|
|
||||||
|
All are already covered by the planned Phases 1–4 — **no plan change needed**:
|
||||||
|
|
||||||
|
| Scenario need | Covering surface |
|
||||||
|
| --- | --- |
|
||||||
|
| Mid-task query, not SessionStart-only (R3) | `/os-adr:find` skill + `bin/adr-find` CLI (Phase 4) |
|
||||||
|
| Discoverability without being told (all) | SessionStart note names both `create` and `find` (Phase 2) |
|
||||||
|
| Correct-vs-plausible discrimination (R2) | path/component match + Accepted-by-default status filter (Phase 4, layers 1–2) |
|
||||||
|
| One-hop relevance (R4) | Graphify traversal layer with graceful degradation (Phase 4, layer 3) |
|
||||||
|
| Low-friction unprompted writing (W1–W3) | one-invocation `/os-adr:create` (Phase 1) |
|
||||||
|
|
@ -589,6 +589,20 @@ _Date: 2026-07-03_
|
||||||
- **Cross-references**: ADR-016 (os-vault plugin sourced from cc-os git repo, establishing the git-tracked plugin pattern), ADR-018 (plugin marketplace mechanics for local plugins).
|
- **Cross-references**: ADR-016 (os-vault plugin sourced from cc-os git repo, establishing the git-tracked plugin pattern), ADR-018 (plugin marketplace mechanics for local plugins).
|
||||||
- **Status**: Accepted 2026-07-03. cc-os's CLAUDE.md updated to remove the stricter orchestration section and note that behavior is now global; `os-orchestration` component added to Implemented Components.
|
- **Status**: Accepted 2026-07-03. cc-os's CLAUDE.md updated to remove the stricter orchestration section and note that behavior is now global; `os-orchestration` component added to Implemented Components.
|
||||||
|
|
||||||
|
## ADR-020 — os-adr plugin built; implementation-time resolutions of the design's open questions
|
||||||
|
|
||||||
|
_Date: 2026-07-03_
|
||||||
|
|
||||||
|
- **Context**: The `os-adr` plugin (OpenSpec change `add-os-adr-plugin`, built from `docs/adr-system/04-plugin-requirements.md` + `05-plugin-prd.md`) was implemented. Its design deliberately left two questions open — the migration pilot's low-confidence flag-rate threshold, and whether ADR frontmatter needs a Graphify tag/edge convention for retrieval layer 3 — and the pilot forced several heuristic refinements within the design's declared mechanical/LLM boundary.
|
||||||
|
- **Decision**: (1) **Pilot gate threshold: 25%** low-confidence flags, recorded in `Adr::MigrationReport::FLAG_RATE_THRESHOLD` and printed in every migration report. Final pilot rates (sandboxed copies of the three surveyed projects): viking-warrior-training-log 0%, delta-refinery 0%, llf-schema 8.3% — gate passed. (2) **No Graphify tag/edge convention for ADRs**: retrieval layer 3 expands the *query* file paths one hop through `graphify-out/graph.json` node `source_file` attributes and matches the expanded set against `affected-paths` frontmatter; ADRs never need to exist as graph nodes. Verified against the real llf-schema graph. (3) **Heuristic-boundary refinements from the pilot**: heading-synonym fallback mappings (Why/Rationale/Problem/Background→Context, Impact/Implications/Trade-offs→Consequences) are mechanical copies but cap the file at `migration_confidence: medium`; status synonyms SETTLED/Confirmed→Accepted, OPEN→Proposed map mechanically; bold-label paragraphs (`**Context.** …`) and bulleted labels (`- **Status:** …`) parse as sections; multi-decision files *without* mechanically splittable unit headings classify `unrecognized` (report-only) rather than converting as one blob.
|
||||||
|
- **Rationale**: The threshold was set empirically at pilot time per the PRD ("do not ship on an unmeasured guess"); 25% sits well above the observed worst case (8.3%) while still failing a genuinely broken heuristic run (pre-tightening iterations hit 100% on delta-refinery and llf-schema). Query-path graph expansion keeps ADRs plain markdown with zero indexing obligations — retrieval reuses the existing project graph instead of imposing a new convention on it (and sidesteps the open ADR-014 facet-edge question entirely). Capping synonym-mapped files at medium keeps the mechanical-vs-judgment boundary honest: the copy is mechanical, the mapping is a judgment call, so it stays flagged.
|
||||||
|
- **Alternatives rejected**:
|
||||||
|
- **Indexing ADRs as Graphify nodes with a tag/edge convention**: adds an extraction pass and a freshness obligation for no retrieval gain at current corpus sizes; revisit only if one-hop query expansion proves too shallow.
|
||||||
|
- **Treating heading synonyms as high-confidence mechanical fills**: hides an interpretive mapping from the flag mechanism.
|
||||||
|
- **Auto-splitting multi-Status files that lack unit headings**: the split points would themselves be guesses; `unrecognized` + manual handling is the non-destructive answer.
|
||||||
|
- **Cross-references**: `docs/adr-system/04-plugin-requirements.md` (locked requirements), `05-plugin-prd.md` (phases), `06-eval-scenarios.md` (held-out eval sketches — the eval itself stays deferred), ADR-013 (build-first/migrate-incrementally precedent), ADR-018 (three-place plugin registration, followed during install), ADR-019 (plugin conventions).
|
||||||
|
- **Status**: Accepted 2026-07-03. Plugin live at `cc-os/plugins/os-adr/`, installed as `os-adr@local-plugins`. Rollout order locked: pilot projects' real migration (interactive, per project) → cc-os retrofit → wider.
|
||||||
|
|
||||||
## Rejected tools (summary)
|
## Rejected tools (summary)
|
||||||
|
|
||||||
| Tool | Why rejected for our use |
|
| Tool | Why rejected for our use |
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,2 @@
|
||||||
|
schema: spec-driven
|
||||||
|
created: 2026-07-03
|
||||||
|
|
@ -0,0 +1,197 @@
|
||||||
|
# Design: add-os-adr-plugin
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
The requirements are locked in `docs/adr-system/04-plugin-requirements.md` and phased in
|
||||||
|
`docs/adr-system/05-plugin-prd.md` — this design decides *how* to build them, not *what* they
|
||||||
|
are. cc-os already has three global plugins establishing the conventions this one must follow:
|
||||||
|
|
||||||
|
- `os-vault` — Python hooks (thin entry points + shared `config.py`/`hook_io.py` modules), hooks
|
||||||
|
wired by absolute path in `settings.json`, verb-first skills, no `commands/` dir.
|
||||||
|
- `os-doc-hygiene` — plugin-relative `hooks/hooks.json` using `${CLAUDE_PLUGIN_ROOT}`,
|
||||||
|
deterministic zero-token SessionStart reminder with per-project gitignored state dir
|
||||||
|
(`.dochygiene/`) and snooze, deterministic-scanner + generative-only-where-needed split,
|
||||||
|
invariants.md + golden-example fixtures.
|
||||||
|
- `os-orchestration` — minimal SessionStart context injection.
|
||||||
|
|
||||||
|
Constraints: single template everywhere (no variants), `docs/adr/` location, mechanical-first
|
||||||
|
with LLM reserved for judgment, non-destructive migration, OO per Sandi Metz, eval deferred.
|
||||||
|
|
||||||
|
## Goals / Non-Goals
|
||||||
|
|
||||||
|
**Goals:**
|
||||||
|
|
||||||
|
- A working `os-adr` plugin at `cc-os/plugins/os-adr/`, installed via the `local-plugins`
|
||||||
|
marketplace, covering PRD Phases 1–4 (authoring, session hook, migration, retrieval).
|
||||||
|
- Every deterministic behavior testable without a model; every LLM-assisted behavior isolated
|
||||||
|
behind a skill so the mechanical core stays pure.
|
||||||
|
- Eval scenario *sketches* written down so the plugin surface is eval-ready.
|
||||||
|
|
||||||
|
**Non-Goals:**
|
||||||
|
|
||||||
|
- The unprompted-behavior evaluation itself (separate later stage, `autoresearch` loop).
|
||||||
|
- Retrofitting cc-os's own ADR file, bulk rollout beyond the pilot projects.
|
||||||
|
- Per-project template variants, severity/enforcement fields, multi-editor governance.
|
||||||
|
|
||||||
|
## Decisions
|
||||||
|
|
||||||
|
### D1 — Plugin layout and packaging
|
||||||
|
|
||||||
|
`cc-os/plugins/os-adr/` with:
|
||||||
|
|
||||||
|
```
|
||||||
|
os-adr/
|
||||||
|
.claude-plugin/plugin.json # name: os-adr
|
||||||
|
hooks/hooks.json # SessionStart wiring via ${CLAUDE_PLUGIN_ROOT}
|
||||||
|
hooks/session_start.py # thin Python entry point (deterministic check)
|
||||||
|
lib/ # Ruby OO core (adr/ classes)
|
||||||
|
bin/ # Ruby CLI entry points the skills invoke
|
||||||
|
templates/adr.md # the customized-Nygard template
|
||||||
|
skills/create/SKILL.md
|
||||||
|
skills/init/SKILL.md
|
||||||
|
skills/migrate/SKILL.md
|
||||||
|
skills/find/SKILL.md
|
||||||
|
tests/ # unit tests + golden-example fixtures
|
||||||
|
invariants.md
|
||||||
|
```
|
||||||
|
|
||||||
|
Install follows the CLAUDE.md three-place procedure: symlink
|
||||||
|
`~/.claude/plugins/os-adr → cc-os/plugins/os-adr/`, add `"os-adr"`/`"./os-adr"` to the
|
||||||
|
`local-plugins` marketplace manifest, enable `os-adr@local-plugins` in `settings.json`, then
|
||||||
|
`claude plugin marketplace update` + `claude plugin install`, and verify with
|
||||||
|
`claude plugin details` (skills registered is the check that catches manifest mistakes).
|
||||||
|
|
||||||
|
**Why:** matches all three existing plugins; the manifest step is the known failure mode
|
||||||
|
(ADR-018) so it's explicit in tasks.
|
||||||
|
|
||||||
|
### D2 — Hook wiring: plugin `hooks.json`, not `settings.json` absolute paths
|
||||||
|
|
||||||
|
Use the `os-doc-hygiene` pattern (`hooks/hooks.json` with `${CLAUDE_PLUGIN_ROOT}`, matcher
|
||||||
|
`startup|resume`, short timeout). **Why over the os-vault absolute-path pattern:** it keeps the
|
||||||
|
plugin self-contained, survives repo moves, and hook registration then fails *with* plugin
|
||||||
|
registration rather than silently diverging from it (the masking problem ADR-018 documented).
|
||||||
|
|
||||||
|
### D3 — Language split: Ruby OO core, Python only for the hook entry point
|
||||||
|
|
||||||
|
- `lib/` is Ruby, Sandi Metz style: small single-responsibility classes
|
||||||
|
(`Adr::Template`, `Adr::Record`, `Adr::Index`, `Adr::Repository`, `Adr::Detector` (migration
|
||||||
|
shape detection), `Adr::Migrator`, `Adr::Finder` (retrieval pipeline)), dependency injection
|
||||||
|
(paths and IO handed in, no globals), tell-don't-ask.
|
||||||
|
- `bin/` exposes thin Ruby CLIs (`adr-new`, `adr-init`, `adr-detect`, `adr-migrate`,
|
||||||
|
`adr-find`) that skills call via Bash. (No standalone `adr-index` — index regeneration is
|
||||||
|
embedded in every writing CLI, per D4.)
|
||||||
|
- The SessionStart hook is a thin Python script that does the existence check inline (stat two
|
||||||
|
paths, read one suppression file, emit JSON) — it does **not** shell out to Ruby.
|
||||||
|
|
||||||
|
**Why:** the user's standing Ruby preference for the core; Python for the hook matches the
|
||||||
|
existing hook pattern and keeps session-start latency free of Ruby interpreter spin-up. The hook
|
||||||
|
logic is ~20 lines and purely mechanical, so duplicating "does `docs/adr/` exist" between Python
|
||||||
|
and Ruby is acceptable coupling (alternative — Python wrapping the Ruby core — was rejected for
|
||||||
|
latency and dependency-surface reasons at session start).
|
||||||
|
|
||||||
|
### D4 — Template and ID/index mechanics
|
||||||
|
|
||||||
|
- Template (`templates/adr.md`): frontmatter `id`, `date`, `status`
|
||||||
|
(Proposed/Accepted/Superseded/Deprecated), `supersedes`, `superseded-by`, `affected-paths`,
|
||||||
|
`affected-components`, `migration_confidence` (migration-written files only); body sections
|
||||||
|
`## Context`, `## Decision`, `## Consequences`, `## Alternatives rejected`.
|
||||||
|
- Files: `docs/adr/NNNN-kebab-title.md`, 4-digit zero-padded sequential ID; next ID = max
|
||||||
|
existing + 1, derived by scanning the directory (never trusting the index).
|
||||||
|
- Index: `docs/adr/README.md`, a generated table (ID, title, status, date) between markers;
|
||||||
|
regenerated in full from the directory on every write by `Adr::Index` — never hand-edited,
|
||||||
|
never appended-to incrementally. Setting `superseded-by` on an old ADR when a new one declares
|
||||||
|
`supersedes` is also mechanical (`adr-new` does both edits).
|
||||||
|
|
||||||
|
**Why full regeneration over append:** idempotent, self-healing after manual file edits, trivial
|
||||||
|
to test against golden fixtures; append-only indexes drift.
|
||||||
|
|
||||||
|
### D5 — SessionStart behavior and suppression
|
||||||
|
|
||||||
|
- `docs/adr/` + index present → inject one short additionalContext line: the system exists, how
|
||||||
|
to write (`/os-adr:create`) and query (`/os-adr:find`) it. Near-zero tokens, no LLM.
|
||||||
|
- Absent → one-line suggestion to run `/os-adr:init` or `/os-adr:migrate`.
|
||||||
|
- Suppression: per-project gitignored state dir `.os-adr/` containing `suppress` (permanent
|
||||||
|
opt-out for deliberately ADR-less projects) — same shape as `.dochygiene/`. `/os-adr:init`
|
||||||
|
clears it; hook exits silently when present. A once-per-day snooze stamp (`last_reminded`)
|
||||||
|
additionally rate-limits the absent-case nag, mirroring os-doc-hygiene.
|
||||||
|
|
||||||
|
### D6 — Migration pipeline (mechanical/LLM boundary)
|
||||||
|
|
||||||
|
`adr-detect` (mechanical) classifies existing content into the surveyed shapes — numbered
|
||||||
|
per-file dirs, dated single files, monolithic multi-decision files, prose-embedded — plus
|
||||||
|
`unrecognized` as the fallback (reported, never auto-converted). `adr-migrate` then, per source
|
||||||
|
decision:
|
||||||
|
|
||||||
|
- **Heuristic fill (mechanical):** fields structurally unambiguous in the source — Status/Date
|
||||||
|
from frontmatter or clear headings, title, ID assignment, source-file provenance link.
|
||||||
|
- **LLM fill (skill-mediated, flagged):** interpretive fields only — Consequences,
|
||||||
|
Alternatives-rejected, splitting a monolithic file into per-decision units. The Ruby core
|
||||||
|
emits a work manifest of exactly which fields need LLM fill; the `migrate` skill has the model
|
||||||
|
fill them and hands results back to the core for writing. Never invent Decision or Context
|
||||||
|
from nothing — if absent in the source, the field carries an explicit `_not stated in
|
||||||
|
source_` marker and the file is flagged.
|
||||||
|
- **Flagging (one mechanism):** `migration_confidence: low|medium|high` frontmatter per file +
|
||||||
|
one `docs/adr/migration-report.md` summarizing per-file confidence, source mapping, and the
|
||||||
|
overall flag rate. No scattered inline comments.
|
||||||
|
- **Non-destructive:** new files only; source files untouched. Old-system deletion is a
|
||||||
|
separate, explicit user-approved step the skill offers only after the report is reviewed.
|
||||||
|
- **Pilot gate:** run on 2–3 surveyed projects (viking-warrior-training-log, delta-refinery,
|
||||||
|
llf-schema); if the low-confidence flag rate exceeds the threshold set at pilot time
|
||||||
|
(recorded in the report), tighten heuristics before wider rollout. Build-phase gate, not a
|
||||||
|
shipped feature.
|
||||||
|
|
||||||
|
### D7 — Retrieval stack (`adr-find` / `/os-adr:find`)
|
||||||
|
|
||||||
|
Deterministic layers first, each narrowing the candidate set:
|
||||||
|
|
||||||
|
1. Path/component match: query paths (e.g. files being edited) against `affected-paths` /
|
||||||
|
`affected-components` frontmatter across `docs/adr/`.
|
||||||
|
2. Status filter: `Accepted` only by default (`--all-statuses` to override).
|
||||||
|
3. Graphify traversal: when `graphify-out/` exists, expand the *query* paths one hop via
|
||||||
|
graph-node `source_file` attributes and re-match the expanded set against `affected-paths` —
|
||||||
|
ADRs themselves are never graph nodes (resolved during Phase 4; see ADR-020). Reuses
|
||||||
|
os-vault's project-graph infrastructure; degrades gracefully to layers 1–2 when absent.
|
||||||
|
4. AI judgment (skill-mediated) only over the narrowed set — the `find` skill presents the
|
||||||
|
candidates and the model picks/ranks; it never sweeps the full corpus.
|
||||||
|
|
||||||
|
This is a mid-task-callable surface (skill + CLI), satisfying the PRD's eval-readiness note —
|
||||||
|
retrieval is not locked to SessionStart.
|
||||||
|
|
||||||
|
### D8 — Eval-readiness sketches
|
||||||
|
|
||||||
|
A `docs/adr-system/06-eval-scenarios.md` sketch (write-trigger + retrieval scenario shapes per
|
||||||
|
the PRD) is written during this change, and the SessionStart context line explicitly names both
|
||||||
|
surfaces (`create`, `find`) so an agent can discover them unprompted. The eval itself is out of
|
||||||
|
scope.
|
||||||
|
|
||||||
|
## Risks / Trade-offs
|
||||||
|
|
||||||
|
- [Duplicated existence-check logic between Python hook and Ruby core] → it's two path checks;
|
||||||
|
covered by a shared fixture test asserting both agree on the same tree.
|
||||||
|
- [Ruby runtime required at skill time] → acceptable: this is the user's own machine and
|
||||||
|
standing preference; hook path (the always-runs surface) has no Ruby dependency.
|
||||||
|
- [Migration LLM fills may be wrong] → confidence flags + report + pilot gate before rollout;
|
||||||
|
non-destructiveness means a bad migration is discardable (delete `docs/adr/`, rerun).
|
||||||
|
- [Graphify layer depends on os-vault onboarding state] → retrieval degrades to layers 1–2;
|
||||||
|
layer 3 is an enhancement, not a dependency.
|
||||||
|
- [Monolithic-file splitting is the hardest detection case] → treated as LLM-boundary work with
|
||||||
|
per-unit flags; cc-os's own 19-ADR file is deliberately excluded from the pilot.
|
||||||
|
- [Index regeneration overwrites manual index edits] → by design (index is declared
|
||||||
|
never-hand-edited in its own header comment).
|
||||||
|
|
||||||
|
## Migration Plan
|
||||||
|
|
||||||
|
Build order matches PRD phases: Phase 1 (template+create+index) → Phase 2 (hook) → Phase 3
|
||||||
|
(migration) → Phase 4 (retrieval), each verifiable independently. Install/registration happens
|
||||||
|
after Phase 1 so `create` is dogfoodable early. Rollback: uninstall plugin + remove symlink and
|
||||||
|
manifest entry; projects keep their `docs/adr/` files (plain markdown, tool-independent).
|
||||||
|
|
||||||
|
## Open Questions
|
||||||
|
|
||||||
|
- Exact low-confidence flag-rate threshold for the pilot gate — set empirically at pilot time
|
||||||
|
and recorded in the migration report (deliberately not guessed now).
|
||||||
|
- ~~Whether ADR frontmatter needs a dedicated Graphify tag/edge convention for layer-3
|
||||||
|
traversal~~ — **Resolved during Phase 4 (2026-07-03):** no convention needed. Layer 3 expands
|
||||||
|
the *query* paths one hop via graph-node `source_file`s; ADRs never need to be graph nodes and
|
||||||
|
match purely on `affected-paths` frontmatter (verified against the real llf-schema project
|
||||||
|
graph; recorded in ADR-020).
|
||||||
|
|
@ -0,0 +1,76 @@
|
||||||
|
# Proposal: add-os-adr-plugin
|
||||||
|
|
||||||
|
## Why
|
||||||
|
|
||||||
|
~43% of the user's projects carry ADR-like content in 5+ mutually-incompatible shapes; the rest
|
||||||
|
have none, and no project uses ADR tooling. Because AI agents (not just the user) work across
|
||||||
|
every project, decision records must be discoverable and writable *consistently* by an agent
|
||||||
|
that has never seen a given repo — and cc-os's own 19-ADR/601-line monolithic file is already
|
||||||
|
near the size where research says monolithic logs stop working. The requirements are locked
|
||||||
|
(`docs/adr-system/04-plugin-requirements.md`) and the PRD is written
|
||||||
|
(`docs/adr-system/05-plugin-prd.md`); this change builds the plugin.
|
||||||
|
|
||||||
|
## What Changes
|
||||||
|
|
||||||
|
- New global Claude Code plugin **`os-adr`** at `cc-os/plugins/os-adr/`, following the
|
||||||
|
established cc-os plugin conventions: `os-` prefix, verb-first skills, no `commands/`
|
||||||
|
dispatcher, symlink into `~/.claude/plugins/`, registered in the `local-plugins` marketplace
|
||||||
|
manifest (the three-place rename/registration procedure in CLAUDE.md).
|
||||||
|
- **Phase 1 — authoring**: customized-Nygard template (Status/Context/Decision/Consequences +
|
||||||
|
Alternatives rejected; frontmatter with `supersedes`/`superseded-by`,
|
||||||
|
`affected-paths`/`affected-components`, `date`, sequential ID), `docs/adr/NNNN-title.md`
|
||||||
|
one-file-per-decision convention, mechanically-maintained index file, and a `new` skill that
|
||||||
|
writes a correctly templated + indexed ADR in one invocation. No LLM in the mechanical path.
|
||||||
|
- **Phase 2 — SessionStart hook**: deterministic existence check for `docs/adr/` + index. Present
|
||||||
|
→ near-zero-token context note; absent → suggest setup, with a per-project suppression flag so
|
||||||
|
a deliberately ADR-less project doesn't re-nag (same shape as `os-doc-hygiene` snooze state).
|
||||||
|
- **Phase 3 — non-destructive migration**: a `migrate` skill converting existing ADR content of
|
||||||
|
any surveyed shape into the new template alongside (never replacing) the old system; heuristic
|
||||||
|
fill for structurally unambiguous fields, LLM fill only for interpretive fields, one consistent
|
||||||
|
uncertainty-flagging mechanism (`migration_confidence` frontmatter + a migration report file);
|
||||||
|
deletion of the old system only as an explicit, separate user-approved step. Piloted on 2–3
|
||||||
|
real projects with an uncertain-flag-rate gate before wider rollout.
|
||||||
|
- **Phase 4 — retrieval**: deterministic-first layering — path/component frontmatter match →
|
||||||
|
status filter (Accepted by default) → Graphify graph traversal (reusing `os-vault`
|
||||||
|
infrastructure) → AI judgment only over the already-narrowed candidate set. Exposed as a query
|
||||||
|
surface callable mid-task, not just at SessionStart.
|
||||||
|
- Implementation style: OO per Sandi Metz principles; Ruby-preferred core with Python acceptable
|
||||||
|
for hook entry points, decided per-component (thin hook scripts wrapping an OO core).
|
||||||
|
- Held-out eval scenarios (write-trigger, retrieval) are **sketched** as part of this change so
|
||||||
|
the plugin surface isn't missing what the eval needs — but the evaluation itself stays a
|
||||||
|
separate later stage (locked sequencing in `04-plugin-requirements.md`).
|
||||||
|
|
||||||
|
## Capabilities
|
||||||
|
|
||||||
|
### New Capabilities
|
||||||
|
|
||||||
|
- `adr-authoring`: the ADR template, `docs/adr/NNNN-title.md` + index location convention, and
|
||||||
|
the one-invocation new-ADR generator skill with mechanical index maintenance.
|
||||||
|
- `adr-session-awareness`: the deterministic SessionStart existence-check hook — minimal context
|
||||||
|
injection when the ADR system exists, setup suggestion with per-project suppression when it
|
||||||
|
doesn't.
|
||||||
|
- `adr-migration`: non-destructive conversion of existing ADR content (all surveyed shapes plus
|
||||||
|
a fallback) into the new template, with the mechanical/LLM field-mapping boundary, single
|
||||||
|
uncertainty-flagging mechanism, and pilot gate.
|
||||||
|
- `adr-retrieval`: the deterministic-first retrieval stack (frontmatter path match → status
|
||||||
|
filter → Graphify traversal → narrowed AI judgment) exposed as a mid-task query surface.
|
||||||
|
- `adr-plugin-packaging`: plugin structure, naming, marketplace registration, and install
|
||||||
|
procedure per cc-os conventions (`os-` prefix, verb-first skills, symlink + `local-plugins`
|
||||||
|
manifest + `settings.json` enablement).
|
||||||
|
|
||||||
|
### Modified Capabilities
|
||||||
|
|
||||||
|
<!-- none — os-adr is a new plugin; no existing spec's requirements change -->
|
||||||
|
|
||||||
|
## Impact
|
||||||
|
|
||||||
|
- New code: `cc-os/plugins/os-adr/` (hooks, skills, OO core, template assets, tests/fixtures).
|
||||||
|
- Host config: `~/.claude/plugins/os-adr` symlink, `~/.claude/plugins/.claude-plugin/marketplace.json`
|
||||||
|
entry, `settings.json` `enabledPlugins` (and hook wiring if hooks are registered by absolute
|
||||||
|
path like os-vault, or via plugin `hooks.json` like os-doc-hygiene — decided in design).
|
||||||
|
- Reuses: Graphify / `os-vault` graph infrastructure for the retrieval layer; `os-doc-hygiene`
|
||||||
|
patterns for deterministic SessionStart reminders and snooze state.
|
||||||
|
- Target projects (pilot, later phases): viking-warrior-training-log, delta-refinery, llf-schema;
|
||||||
|
cc-os itself is retrofitted only after the pilot, per the locked rollout order.
|
||||||
|
- Out of scope: per-project template variants, multi-editor governance, enforceable-constraints
|
||||||
|
mirroring, the unprompted-behavior evaluation itself, and retrofitting cc-os's ADR file.
|
||||||
|
|
@ -0,0 +1,70 @@
|
||||||
|
# adr-authoring
|
||||||
|
|
||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: Standard ADR template
|
||||||
|
The plugin SHALL provide a single customized-Nygard ADR template used identically in every
|
||||||
|
project, with frontmatter fields `id`, `date`, `status`, `supersedes`, `superseded-by`,
|
||||||
|
`affected-paths`, `affected-components`, and body sections `Context`, `Decision`,
|
||||||
|
`Consequences`, and `Alternatives rejected`. The plugin SHALL NOT provide per-project or
|
||||||
|
per-client template variants.
|
||||||
|
|
||||||
|
#### Scenario: Template contains all required fields
|
||||||
|
- **WHEN** a new ADR is generated in any project
|
||||||
|
- **THEN** the resulting file contains every frontmatter field and body section listed above,
|
||||||
|
in template order
|
||||||
|
|
||||||
|
#### Scenario: No project-specific variation
|
||||||
|
- **WHEN** ADRs are generated in two different projects
|
||||||
|
- **THEN** both files follow the identical template structure
|
||||||
|
|
||||||
|
### Requirement: One file per decision at the standard location
|
||||||
|
ADRs SHALL be stored one-file-per-decision at `docs/adr/NNNN-kebab-title.md`, where `NNNN` is a
|
||||||
|
4-digit zero-padded sequential ID derived by scanning the existing directory contents (max
|
||||||
|
existing ID + 1), never by trusting the index file.
|
||||||
|
|
||||||
|
#### Scenario: Sequential ID assignment
|
||||||
|
- **WHEN** `docs/adr/` contains ADRs `0001` and `0002` and a new ADR titled "Use Postgres" is
|
||||||
|
created
|
||||||
|
- **THEN** the new file is `docs/adr/0003-use-postgres.md`
|
||||||
|
|
||||||
|
#### Scenario: ID derived from directory, not index
|
||||||
|
- **WHEN** the index file is stale or missing but `docs/adr/0007-*.md` exists
|
||||||
|
- **THEN** the next generated ADR receives ID `0008`
|
||||||
|
|
||||||
|
### Requirement: Mechanically maintained index
|
||||||
|
The plugin SHALL maintain an index file at `docs/adr/README.md` listing every ADR's ID, title,
|
||||||
|
status, and date. The index SHALL be regenerated in full from the directory contents on every
|
||||||
|
ADR write, with no LLM involvement, and SHALL declare itself generated/never-hand-edited.
|
||||||
|
|
||||||
|
#### Scenario: Index updated on ADR creation
|
||||||
|
- **WHEN** a new ADR is created via the generator
|
||||||
|
- **THEN** the index file is regenerated and contains a row for the new ADR
|
||||||
|
|
||||||
|
#### Scenario: Index self-heals
|
||||||
|
- **WHEN** an ADR file was added or edited manually and the generator next runs
|
||||||
|
- **THEN** the regenerated index reflects the actual directory contents
|
||||||
|
|
||||||
|
### Requirement: One-invocation ADR creation skill
|
||||||
|
The plugin SHALL provide a `create` skill (`/os-adr:create`) that produces a correctly templated,
|
||||||
|
correctly numbered, and indexed ADR in a single invocation, and an `init` skill (`/os-adr:init`)
|
||||||
|
that sets up `docs/adr/` plus the index in a project that has none.
|
||||||
|
|
||||||
|
#### Scenario: Create in one invocation
|
||||||
|
- **WHEN** the user (or agent) invokes `/os-adr:create` with a decision to record
|
||||||
|
- **THEN** a new templated ADR file exists under `docs/adr/` and the index includes it, with no
|
||||||
|
further steps required
|
||||||
|
|
||||||
|
#### Scenario: Init on a fresh project
|
||||||
|
- **WHEN** `/os-adr:init` runs in a project without `docs/adr/`
|
||||||
|
- **THEN** `docs/adr/` and an empty index exist and any session-hook suppression flag for the
|
||||||
|
project is cleared
|
||||||
|
|
||||||
|
### Requirement: Supersession is mechanical
|
||||||
|
WHEN a new ADR declares `supersedes: NNNN`, the generator SHALL set `superseded-by` on the
|
||||||
|
referenced ADR and update its status accordingly, without manual edits.
|
||||||
|
|
||||||
|
#### Scenario: Superseding an existing ADR
|
||||||
|
- **WHEN** a new ADR `0009` is created with `supersedes: 0004`
|
||||||
|
- **THEN** ADR `0004`'s frontmatter gains `superseded-by: 0009` and status `Superseded`, and the
|
||||||
|
regenerated index shows both statuses
|
||||||
|
|
@ -0,0 +1,71 @@
|
||||||
|
# adr-migration
|
||||||
|
|
||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: Shape detection with a fallback
|
||||||
|
The migration tooling SHALL mechanically detect existing ADR-like content and classify it into
|
||||||
|
the surveyed shapes — numbered per-file directories, dated single files, monolithic
|
||||||
|
multi-decision files, prose embedded in other docs — plus an `unrecognized` fallback
|
||||||
|
classification. Unrecognized content SHALL be reported for manual handling, never auto-converted.
|
||||||
|
|
||||||
|
#### Scenario: Known shape detected
|
||||||
|
- **WHEN** detection runs on a project with a monolithic multi-decision file
|
||||||
|
- **THEN** the file is classified as monolithic and its individual decisions are enumerated as
|
||||||
|
migration units
|
||||||
|
|
||||||
|
#### Scenario: Unknown shape falls back safely
|
||||||
|
- **WHEN** detection encounters ADR-like content matching no known shape
|
||||||
|
- **THEN** it is classified `unrecognized` and listed in the report without any conversion
|
||||||
|
attempt
|
||||||
|
|
||||||
|
### Requirement: Mechanical/LLM field-mapping boundary
|
||||||
|
During conversion, fields that are structurally unambiguous in the source (Status and Date from
|
||||||
|
frontmatter or clear headings; title; ID assignment; source provenance) SHALL be filled
|
||||||
|
mechanically. Only interpretive fields (Consequences, Alternatives rejected, monolithic-file
|
||||||
|
decision splitting) MAY be LLM-filled, and every LLM-filled field SHALL be flagged. The
|
||||||
|
migration SHALL never invent a Decision or Context field absent from the source — such fields
|
||||||
|
SHALL carry an explicit not-stated-in-source marker and lower the file's confidence.
|
||||||
|
|
||||||
|
#### Scenario: Unambiguous field filled mechanically
|
||||||
|
- **WHEN** a source ADR has a `Status: Accepted` heading and a date
|
||||||
|
- **THEN** the converted file's `status` and `date` are set without any LLM call
|
||||||
|
|
||||||
|
#### Scenario: Missing core field is not invented
|
||||||
|
- **WHEN** a source decision has no discernible Context
|
||||||
|
- **THEN** the converted Context section contains a not-stated-in-source marker and the file is
|
||||||
|
flagged low-confidence
|
||||||
|
|
||||||
|
### Requirement: Single uncertainty-flagging mechanism
|
||||||
|
Migration uncertainty SHALL be surfaced through exactly one mechanism: a
|
||||||
|
`migration_confidence: low|medium|high` frontmatter field per converted file, plus one
|
||||||
|
migration report file (`docs/adr/migration-report.md`) summarizing per-file confidence, the
|
||||||
|
source-to-new mapping, and the overall low-confidence flag rate. Scattered inline uncertainty
|
||||||
|
comments SHALL NOT be used.
|
||||||
|
|
||||||
|
#### Scenario: Report aggregates confidence
|
||||||
|
- **WHEN** a migration completes
|
||||||
|
- **THEN** the report lists every converted file with its confidence, its source location, and
|
||||||
|
the overall flag rate
|
||||||
|
|
||||||
|
### Requirement: Non-destructive, dual-existence conversion
|
||||||
|
Migration SHALL only write new files under `docs/adr/`; existing ADR content SHALL never be
|
||||||
|
deleted, moved, or edited by the migration. Removal of the old system SHALL happen only as an
|
||||||
|
explicit, separate, user-approved step offered after the migration report exists.
|
||||||
|
|
||||||
|
#### Scenario: Old system untouched
|
||||||
|
- **WHEN** a migration run completes
|
||||||
|
- **THEN** every pre-existing ADR source file is byte-identical to its pre-migration state
|
||||||
|
|
||||||
|
#### Scenario: Deletion requires explicit approval
|
||||||
|
- **WHEN** the user has not explicitly approved old-system removal
|
||||||
|
- **THEN** no tooling path deletes the old content, and the removal offer appears only after the
|
||||||
|
report is available for review
|
||||||
|
|
||||||
|
### Requirement: Pilot gate before broad rollout
|
||||||
|
The migration SHALL be piloted on 2–3 real surveyed projects, and the low-confidence flag rate
|
||||||
|
SHALL be measured against an explicit threshold recorded at pilot time before the heuristics are
|
||||||
|
treated as good enough for wider rollout.
|
||||||
|
|
||||||
|
#### Scenario: High flag rate blocks rollout
|
||||||
|
- **WHEN** a pilot run's low-confidence rate exceeds the recorded threshold
|
||||||
|
- **THEN** the heuristics are tightened and the pilot re-run before migrating further projects
|
||||||
|
|
@ -0,0 +1,61 @@
|
||||||
|
# adr-plugin-packaging
|
||||||
|
|
||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: Plugin naming and skill naming conventions
|
||||||
|
The plugin SHALL be named `os-adr` (kebab-case, `os-` prefix) and its skills SHALL use
|
||||||
|
verb-first kebab-case names invoked as `/os-adr:<verb>` (`create`, `init`, `migrate`, `find`),
|
||||||
|
with no `commands/` dispatcher directory — matching the cc-os plugin/skill naming convention.
|
||||||
|
|
||||||
|
#### Scenario: Skills register under verb-first names
|
||||||
|
- **WHEN** the plugin is installed and `claude plugin details os-adr@local-plugins` is run
|
||||||
|
- **THEN** the listed skills are `create`, `init`, `migrate`, and `find`, and no `commands/`
|
||||||
|
directory exists in the plugin
|
||||||
|
|
||||||
|
### Requirement: Source of truth in cc-os with symlinked install
|
||||||
|
The plugin source SHALL live git-tracked at `cc-os/plugins/os-adr/` and be installed via a
|
||||||
|
symlink `~/.claude/plugins/os-adr` pointing at it, matching the `os-vault`/`os-doc-hygiene`/
|
||||||
|
`os-orchestration` pattern.
|
||||||
|
|
||||||
|
#### Scenario: Symlinked install
|
||||||
|
- **WHEN** installation is complete
|
||||||
|
- **THEN** `~/.claude/plugins/os-adr` is a symlink resolving to the git-tracked plugin directory
|
||||||
|
|
||||||
|
### Requirement: Full three-place marketplace registration
|
||||||
|
Installation SHALL update all three registration surfaces — the plugin directory/symlink, the
|
||||||
|
`local-plugins` marketplace manifest (`~/.claude/plugins/.claude-plugin/marketplace.json`), and
|
||||||
|
`settings.json` `enabledPlugins` — then refresh plugin-manager state via
|
||||||
|
`claude plugin marketplace update` and `claude plugin install`, and verify skill registration
|
||||||
|
with `claude plugin list` and `claude plugin details`.
|
||||||
|
|
||||||
|
#### Scenario: Skills resolve after install
|
||||||
|
- **WHEN** the registration procedure completes
|
||||||
|
- **THEN** `claude plugin list` shows `os-adr@local-plugins` enabled and its skills are
|
||||||
|
invocable as slash commands in a fresh session
|
||||||
|
|
||||||
|
### Requirement: Hooks wired through plugin hooks.json
|
||||||
|
Plugin hooks SHALL be declared in `hooks/hooks.json` using `${CLAUDE_PLUGIN_ROOT}`-relative
|
||||||
|
commands (the `os-doc-hygiene` pattern), not by absolute paths in `settings.json`, so hook
|
||||||
|
registration cannot silently outlive or diverge from plugin registration.
|
||||||
|
|
||||||
|
#### Scenario: Hook path is plugin-relative
|
||||||
|
- **WHEN** the plugin's hook configuration is inspected
|
||||||
|
- **THEN** the SessionStart command references `${CLAUDE_PLUGIN_ROOT}` and no os-adr hook entry
|
||||||
|
exists in `settings.json`
|
||||||
|
|
||||||
|
### Requirement: OO core with tested deterministic behavior
|
||||||
|
The plugin's core logic SHALL be implemented as an object-oriented Ruby library (small
|
||||||
|
single-responsibility classes, dependency-injected paths/IO, no globals) exposed through thin
|
||||||
|
CLI entry points, with a thin Python script only for the SessionStart hook. All deterministic
|
||||||
|
behaviors (ID assignment, index regeneration, shape detection, existence check, retrieval layers
|
||||||
|
1–2) SHALL have automated tests runnable without any LLM, including golden-example fixtures.
|
||||||
|
|
||||||
|
#### Scenario: Deterministic tests run model-free
|
||||||
|
- **WHEN** the plugin's test suite runs on a machine with no model access
|
||||||
|
- **THEN** all tests for ID assignment, index regeneration, detection, existence check, and
|
||||||
|
frontmatter retrieval pass
|
||||||
|
|
||||||
|
#### Scenario: Skills delegate to CLI entry points
|
||||||
|
- **WHEN** a skill performs a mechanical operation (numbering, indexing, detection, filtering)
|
||||||
|
- **THEN** it does so by invoking a `bin/` CLI rather than re-implementing the logic in prompt
|
||||||
|
instructions
|
||||||
|
|
@ -0,0 +1,49 @@
|
||||||
|
# adr-retrieval
|
||||||
|
|
||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: Deterministic-first retrieval layering
|
||||||
|
ADR retrieval SHALL narrow candidates through ordered layers: (1) path/component match of query
|
||||||
|
inputs against each ADR's `affected-paths`/`affected-components` frontmatter; (2) status filter
|
||||||
|
admitting only `Accepted` ADRs by default, with an explicit override to include other statuses;
|
||||||
|
(3) Graphify graph traversal when a project graph exists; (4) AI judgment applied only to the
|
||||||
|
already-narrowed candidate set. AI judgment SHALL never be applied to the full ADR corpus.
|
||||||
|
|
||||||
|
#### Scenario: Path match narrows first
|
||||||
|
- **WHEN** retrieval is queried with file paths being edited
|
||||||
|
- **THEN** ADRs whose `affected-paths` match are selected before any model involvement
|
||||||
|
|
||||||
|
#### Scenario: Default status filter
|
||||||
|
- **WHEN** retrieval runs without an override
|
||||||
|
- **THEN** only `Accepted` ADRs appear in the candidate set
|
||||||
|
|
||||||
|
#### Scenario: AI sees only the narrowed set
|
||||||
|
- **WHEN** the AI-judgment layer runs
|
||||||
|
- **THEN** it receives only the candidates surviving layers 1–3, not the full corpus
|
||||||
|
|
||||||
|
### Requirement: Graph layer degrades gracefully
|
||||||
|
WHEN no `graphify-out/` project graph exists (or ADRs are not yet indexed as graph nodes),
|
||||||
|
retrieval SHALL still function using layers 1, 2, and 4, without error.
|
||||||
|
|
||||||
|
#### Scenario: No project graph present
|
||||||
|
- **WHEN** retrieval runs in a project that has never been onboarded to Graphify
|
||||||
|
- **THEN** results are produced from frontmatter matching and status filtering alone
|
||||||
|
|
||||||
|
### Requirement: Mid-task-callable query surface
|
||||||
|
Retrieval SHALL be exposed as a `find` skill (`/os-adr:find`) backed by a deterministic CLI,
|
||||||
|
callable at any point during a task — not only at SessionStart — so an agent can check
|
||||||
|
in-progress work against existing decisions.
|
||||||
|
|
||||||
|
#### Scenario: Query during a task
|
||||||
|
- **WHEN** an agent mid-task invokes `/os-adr:find` with the files or topic it is working on
|
||||||
|
- **THEN** it receives the relevant Accepted ADR candidates for those inputs
|
||||||
|
|
||||||
|
### Requirement: Eval-readiness sketches accompany the build
|
||||||
|
Held-out evaluation scenario shapes (write-trigger and retrieval) SHALL be sketched in a design
|
||||||
|
doc before the plugin skeleton is finalized, and any plugin surface those sketches require SHALL
|
||||||
|
be part of the initial build. The evaluation itself SHALL NOT be part of this change.
|
||||||
|
|
||||||
|
#### Scenario: Sketches exist before skeleton freeze
|
||||||
|
- **WHEN** the plugin skeleton is finalized
|
||||||
|
- **THEN** a scenario-sketch document exists covering at least one write-trigger and one
|
||||||
|
retrieval scenario shape
|
||||||
|
|
@ -0,0 +1,45 @@
|
||||||
|
# adr-session-awareness
|
||||||
|
|
||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: Deterministic SessionStart existence check
|
||||||
|
The plugin SHALL register a SessionStart hook (matcher `startup|resume`) that deterministically
|
||||||
|
checks for `docs/adr/` and its index file in the current project. The hook SHALL make no LLM
|
||||||
|
call, SHALL complete within its configured timeout (5s), and SHALL add no perceptible
|
||||||
|
session-start latency.
|
||||||
|
|
||||||
|
#### Scenario: Hook is deterministic
|
||||||
|
- **WHEN** the SessionStart hook runs
|
||||||
|
- **THEN** its output is a pure function of the project's filesystem state (ADR dir, index,
|
||||||
|
suppression state) with no model invocation
|
||||||
|
|
||||||
|
### Requirement: Minimal context injection when the ADR system exists
|
||||||
|
WHEN `docs/adr/` and the index file both exist, the hook SHALL inject a near-zero-token
|
||||||
|
additionalContext note stating that the ADR system exists and naming both usage surfaces:
|
||||||
|
writing (`/os-adr:create`) and retrieval (`/os-adr:find`).
|
||||||
|
|
||||||
|
#### Scenario: Present project gets the usage note
|
||||||
|
- **WHEN** a session starts in a project with `docs/adr/` and an index
|
||||||
|
- **THEN** the injected context is a short note (a few lines at most) naming `/os-adr:create`
|
||||||
|
and `/os-adr:find`
|
||||||
|
|
||||||
|
### Requirement: Setup suggestion with suppression when absent
|
||||||
|
WHEN the ADR system is absent, the hook SHALL notify the user and suggest `/os-adr:init` or
|
||||||
|
`/os-adr:migrate` — unless a per-project suppression flag is set, in which case it SHALL stay
|
||||||
|
silent. The suggestion SHALL also be rate-limited to at most once per day per project via a
|
||||||
|
snooze timestamp. Suppression state SHALL live in a gitignored per-project state directory
|
||||||
|
(`.os-adr/`), following the `os-doc-hygiene` state-dir shape.
|
||||||
|
|
||||||
|
#### Scenario: Absent and unsuppressed
|
||||||
|
- **WHEN** a session starts in a project with no `docs/adr/` and no suppression flag, and no
|
||||||
|
reminder was shown today
|
||||||
|
- **THEN** the hook emits a one-line suggestion to run `/os-adr:init` or `/os-adr:migrate` and
|
||||||
|
stamps the snooze timestamp
|
||||||
|
|
||||||
|
#### Scenario: Suppressed project stays silent
|
||||||
|
- **WHEN** a session starts in a project whose `.os-adr/suppress` flag is set
|
||||||
|
- **THEN** the hook emits nothing
|
||||||
|
|
||||||
|
#### Scenario: Same-day repeat session
|
||||||
|
- **WHEN** a second session starts the same day in an absent, unsuppressed project
|
||||||
|
- **THEN** no repeat suggestion is emitted
|
||||||
|
|
@ -0,0 +1,51 @@
|
||||||
|
# Tasks: add-os-adr-plugin
|
||||||
|
|
||||||
|
## 1. Plugin skeleton and eval-readiness sketches
|
||||||
|
|
||||||
|
- [x] 1.1 Sketch held-out eval scenario shapes (write-trigger + retrieval) in `docs/adr-system/06-eval-scenarios.md`; note any plugin surface they require beyond Phases 1–4 and fold it into this plan before proceeding
|
||||||
|
- [x] 1.2 Create `cc-os/plugins/os-adr/` skeleton: `.claude-plugin/plugin.json` (name `os-adr`), `hooks/`, `lib/`, `bin/`, `templates/`, `skills/`, `tests/`, `invariants.md` stub
|
||||||
|
- [x] 1.3 Write `templates/adr.md` — customized-Nygard template with full frontmatter (`id`, `date`, `status`, `supersedes`, `superseded-by`, `affected-paths`, `affected-components`) and body sections (Context, Decision, Consequences, Alternatives rejected)
|
||||||
|
|
||||||
|
## 2. Phase 1 — authoring core (Ruby, mechanical)
|
||||||
|
|
||||||
|
- [x] 2.1 Implement `Adr::Record` (parse/serialize one ADR file: frontmatter + sections) with unit tests
|
||||||
|
- [x] 2.2 Implement `Adr::Repository` (scan `docs/adr/`, derive next 4-digit ID from directory contents, injected root path) with unit tests including stale/missing-index cases
|
||||||
|
- [x] 2.3 Implement `Adr::Index` (full regeneration of `docs/adr/README.md` table between markers, never-hand-edited header) with golden-fixture tests including the self-heal-after-manual-edit case
|
||||||
|
- [x] 2.4 Implement `Adr::Template` + `bin/adr-new` (create templated ADR, regenerate index, mechanical supersession: set `superseded-by` + status on the superseded ADR) with tests
|
||||||
|
- [x] 2.5 Implement `bin/adr-init` (create `docs/adr/` + empty index, clear `.os-adr/suppress`) with tests
|
||||||
|
- [x] 2.6 Write `skills/create/SKILL.md` and `skills/init/SKILL.md` — skills gather decision content, then delegate all mechanical work to `bin/adr-new`/`bin/adr-init`
|
||||||
|
|
||||||
|
## 3. Phase 1 — install and register the plugin
|
||||||
|
|
||||||
|
- [x] 3.1 Symlink `~/.claude/plugins/os-adr → cc-os/plugins/os-adr/`
|
||||||
|
- [x] 3.2 Add `os-adr`/`./os-adr` to `~/.claude/plugins/.claude-plugin/marketplace.json` and enable `os-adr@local-plugins` in `settings.json`
|
||||||
|
- [x] 3.3 Run `claude plugin marketplace update local-plugins` + `claude plugin install os-adr@local-plugins`; verify with `claude plugin list` and `claude plugin details` that all skills resolve (ADR-018 failure mode check)
|
||||||
|
- [x] 3.4 Dogfood: in a scratch project, run `/os-adr:init` then `/os-adr:create` and confirm one-invocation creation with correct template, ID, and index (CLI path the skills delegate to, verified in scratchpad; skills confirmed registered via `claude plugin details` — interactive slash invocation re-checked in 4.4's fresh-session pass)
|
||||||
|
|
||||||
|
## 4. Phase 2 — SessionStart hook
|
||||||
|
|
||||||
|
- [x] 4.1 Implement `hooks/session_start.py` — deterministic existence check (`docs/adr/` + index), present→minimal usage note naming `/os-adr:create` and `/os-adr:find`, absent→init/migrate suggestion gated by `.os-adr/suppress` flag and once-per-day snooze stamp
|
||||||
|
- [x] 4.2 Write `hooks/hooks.json` (SessionStart, matcher `startup|resume`, `${CLAUDE_PLUGIN_ROOT}` command, 5s timeout); confirm no os-adr entries needed in `settings.json`
|
||||||
|
- [x] 4.3 Add hook tests: shared fixture tree asserting the Python check and Ruby `Adr::Repository` agree on existence; suppression, snooze, and present/absent output cases
|
||||||
|
- [x] 4.4 Verify in fresh sessions: present project injects the note, absent project nags once then snoozes, suppressed project stays silent (verified by invoking the hook exactly as the harness does, in fixture git projects; hook registration confirmed via `claude plugin details` — Hooks (1) SessionStart)
|
||||||
|
|
||||||
|
## 5. Phase 3 — migration
|
||||||
|
|
||||||
|
- [x] 5.1 Implement `Adr::Detector` + `bin/adr-detect` — classify existing content into the four surveyed shapes + `unrecognized` fallback (report-only), enumerate migration units, with fixture tests per shape
|
||||||
|
- [x] 5.2 Implement `Adr::Migrator` + `bin/adr-migrate` mechanical pass — heuristic-fill unambiguous fields (status/date/title/ID/provenance), emit a work manifest of fields needing LLM fill, write nothing outside `docs/adr/`, with byte-identical-source tests
|
||||||
|
- [x] 5.3 Implement flagging + report: `migration_confidence` frontmatter, not-stated-in-source markers for absent Decision/Context, and `docs/adr/migration-report.md` generation (per-file confidence, source mapping, flag rate) with tests
|
||||||
|
- [x] 5.4 Write `skills/migrate/SKILL.md` — runs detect, has the model fill only manifest-listed interpretive fields, hands results back to the core for writing, presents the report, and offers old-system deletion only as an explicit separate user-approved step
|
||||||
|
- [x] 5.5 Pilot the migration on 2–3 surveyed projects (viking-warrior-training-log, delta-refinery, llf-schema); record the low-confidence flag rate and threshold in each report; tighten heuristics and re-run if the rate exceeds the threshold (gate — do not proceed to broad use until passed). **Pilot 2026-07-03 (sandboxed copies, real repos untouched): threshold 25%; two tightening rounds (bulleted labels, bold-label paragraphs `**Context.**`, Why/Rationale→Context fallback at medium confidence, SETTLED/OPEN status mapping, multi-Status-no-headings→unrecognized guard). Final rates: viking 0% (6/6 high), delta-refinery 0% (12 medium), llf-schema 8.3% (1 low: the superseded 2026-06-04 index doc, legitimately lacking Decision/Context). Gate PASSED. Real-repo migration happens interactively via `/os-adr:migrate` per project.**
|
||||||
|
|
||||||
|
## 6. Phase 4 — retrieval
|
||||||
|
|
||||||
|
- [x] 6.1 Implement `Adr::Finder` + `bin/adr-find` layers 1–2: path/component frontmatter matching and Accepted-by-default status filter (with override flag), with tests
|
||||||
|
- [x] 6.2 Add layer 3: Graphify traversal over `graphify-out/` when present, expanding the candidate set via graph edges; graceful degradation to layers 1–2 when absent, with tests for both paths; resolve whether ADR frontmatter needs a tag/edge convention against the llf-schema graph. **Resolved: no tag/edge convention needed — layer 3 expands the *query* paths one hop via graph-node `source_file` attributes; ADRs never need to be graph nodes and match purely on `affected-paths` frontmatter.**
|
||||||
|
- [x] 6.3 Write `skills/find/SKILL.md` — mid-task-callable: run the deterministic CLI, apply AI judgment only over the returned candidate set, never the full corpus
|
||||||
|
- [x] 6.4 Verify retrieval end-to-end in a piloted project: query with real edited-file paths and confirm the correct Accepted ADR surfaces (llf-schema pilot copy + its real project graph: `tests/IdMinterTest.php` query surfaced the correct ADR via `graph` match; `src/IdMinter.php` via `path`; unrelated path → 0 candidates)
|
||||||
|
|
||||||
|
## 7. Wrap-up
|
||||||
|
|
||||||
|
- [x] 7.1 Fill in `invariants.md` (non-destructiveness, index-regeneration, no-LLM-in-hook, single-flag-mechanism invariants) and ensure the test suite covers each (9 invariants, each mapped to its covering tests)
|
||||||
|
- [x] 7.2 Update cc-os `CLAUDE.md` Implemented Components with the os-adr pointer and note the locked rollout order (pilot → cc-os retrofit → wider) as remaining work
|
||||||
|
- [x] 7.3 Record the build in `docs/memory-system`-style ADR log (`03-architecture-decisions.md`) if any design decision changed during implementation; keep `docs/adr-system/` docs' status lines current (ADR-020 added: 25% pilot threshold, no-graph-convention resolution, heuristic-boundary refinements; `00-README.md` status updated, `06-eval-scenarios.md` added to its doc list)
|
||||||
|
|
@ -0,0 +1,74 @@
|
||||||
|
# Spec: ADR Authoring
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
Defines how Architecture Decision Records are created and maintained by the `os-adr` plugin: one standard template, one file per decision at a standard location, a mechanically maintained index, one-invocation creation skills, and mechanical supersession.
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
### Requirement: Standard ADR template
|
||||||
|
The plugin SHALL provide a single customized-Nygard ADR template used identically in every
|
||||||
|
project, with frontmatter fields `id`, `date`, `status`, `supersedes`, `superseded-by`,
|
||||||
|
`affected-paths`, `affected-components`, and body sections `Context`, `Decision`,
|
||||||
|
`Consequences`, and `Alternatives rejected`. The plugin SHALL NOT provide per-project or
|
||||||
|
per-client template variants.
|
||||||
|
|
||||||
|
#### Scenario: Template contains all required fields
|
||||||
|
- **WHEN** a new ADR is generated in any project
|
||||||
|
- **THEN** the resulting file contains every frontmatter field and body section listed above,
|
||||||
|
in template order
|
||||||
|
|
||||||
|
#### Scenario: No project-specific variation
|
||||||
|
- **WHEN** ADRs are generated in two different projects
|
||||||
|
- **THEN** both files follow the identical template structure
|
||||||
|
|
||||||
|
### Requirement: One file per decision at the standard location
|
||||||
|
ADRs SHALL be stored one-file-per-decision at `docs/adr/NNNN-kebab-title.md`, where `NNNN` is a
|
||||||
|
4-digit zero-padded sequential ID derived by scanning the existing directory contents (max
|
||||||
|
existing ID + 1), never by trusting the index file.
|
||||||
|
|
||||||
|
#### Scenario: Sequential ID assignment
|
||||||
|
- **WHEN** `docs/adr/` contains ADRs `0001` and `0002` and a new ADR titled "Use Postgres" is
|
||||||
|
created
|
||||||
|
- **THEN** the new file is `docs/adr/0003-use-postgres.md`
|
||||||
|
|
||||||
|
#### Scenario: ID derived from directory, not index
|
||||||
|
- **WHEN** the index file is stale or missing but `docs/adr/0007-*.md` exists
|
||||||
|
- **THEN** the next generated ADR receives ID `0008`
|
||||||
|
|
||||||
|
### Requirement: Mechanically maintained index
|
||||||
|
The plugin SHALL maintain an index file at `docs/adr/README.md` listing every ADR's ID, title,
|
||||||
|
status, and date. The index SHALL be regenerated in full from the directory contents on every
|
||||||
|
ADR write, with no LLM involvement, and SHALL declare itself generated/never-hand-edited.
|
||||||
|
|
||||||
|
#### Scenario: Index updated on ADR creation
|
||||||
|
- **WHEN** a new ADR is created via the generator
|
||||||
|
- **THEN** the index file is regenerated and contains a row for the new ADR
|
||||||
|
|
||||||
|
#### Scenario: Index self-heals
|
||||||
|
- **WHEN** an ADR file was added or edited manually and the generator next runs
|
||||||
|
- **THEN** the regenerated index reflects the actual directory contents
|
||||||
|
|
||||||
|
### Requirement: One-invocation ADR creation skill
|
||||||
|
The plugin SHALL provide a `create` skill (`/os-adr:create`) that produces a correctly templated,
|
||||||
|
correctly numbered, and indexed ADR in a single invocation, and an `init` skill (`/os-adr:init`)
|
||||||
|
that sets up `docs/adr/` plus the index in a project that has none.
|
||||||
|
|
||||||
|
#### Scenario: Create in one invocation
|
||||||
|
- **WHEN** the user (or agent) invokes `/os-adr:create` with a decision to record
|
||||||
|
- **THEN** a new templated ADR file exists under `docs/adr/` and the index includes it, with no
|
||||||
|
further steps required
|
||||||
|
|
||||||
|
#### Scenario: Init on a fresh project
|
||||||
|
- **WHEN** `/os-adr:init` runs in a project without `docs/adr/`
|
||||||
|
- **THEN** `docs/adr/` and an empty index exist and any session-hook suppression flag for the
|
||||||
|
project is cleared
|
||||||
|
|
||||||
|
### Requirement: Supersession is mechanical
|
||||||
|
WHEN a new ADR declares `supersedes: NNNN`, the generator SHALL set `superseded-by` on the
|
||||||
|
referenced ADR and update its status accordingly, without manual edits.
|
||||||
|
|
||||||
|
#### Scenario: Superseding an existing ADR
|
||||||
|
- **WHEN** a new ADR `0009` is created with `supersedes: 0004`
|
||||||
|
- **THEN** ADR `0004`'s frontmatter gains `superseded-by: 0009` and status `Superseded`, and the
|
||||||
|
regenerated index shows both statuses
|
||||||
|
|
@ -0,0 +1,75 @@
|
||||||
|
# Spec: ADR Migration
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
Defines how existing ADR-like content in a project is migrated into the standard `os-adr` format: mechanical shape detection with a safe fallback, a strict mechanical/LLM field-mapping boundary, a single uncertainty-flagging mechanism, non-destructive dual-existence conversion, and a pilot gate before broad rollout.
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
### Requirement: Shape detection with a fallback
|
||||||
|
The migration tooling SHALL mechanically detect existing ADR-like content and classify it into
|
||||||
|
the surveyed shapes — numbered per-file directories, dated single files, monolithic
|
||||||
|
multi-decision files, prose embedded in other docs — plus an `unrecognized` fallback
|
||||||
|
classification. Unrecognized content SHALL be reported for manual handling, never auto-converted.
|
||||||
|
|
||||||
|
#### Scenario: Known shape detected
|
||||||
|
- **WHEN** detection runs on a project with a monolithic multi-decision file
|
||||||
|
- **THEN** the file is classified as monolithic and its individual decisions are enumerated as
|
||||||
|
migration units
|
||||||
|
|
||||||
|
#### Scenario: Unknown shape falls back safely
|
||||||
|
- **WHEN** detection encounters ADR-like content matching no known shape
|
||||||
|
- **THEN** it is classified `unrecognized` and listed in the report without any conversion
|
||||||
|
attempt
|
||||||
|
|
||||||
|
### Requirement: Mechanical/LLM field-mapping boundary
|
||||||
|
During conversion, fields that are structurally unambiguous in the source (Status and Date from
|
||||||
|
frontmatter or clear headings; title; ID assignment; source provenance) SHALL be filled
|
||||||
|
mechanically. Only interpretive fields (Consequences, Alternatives rejected, monolithic-file
|
||||||
|
decision splitting) MAY be LLM-filled, and every LLM-filled field SHALL be flagged. The
|
||||||
|
migration SHALL never invent a Decision or Context field absent from the source — such fields
|
||||||
|
SHALL carry an explicit not-stated-in-source marker and lower the file's confidence.
|
||||||
|
|
||||||
|
#### Scenario: Unambiguous field filled mechanically
|
||||||
|
- **WHEN** a source ADR has a `Status: Accepted` heading and a date
|
||||||
|
- **THEN** the converted file's `status` and `date` are set without any LLM call
|
||||||
|
|
||||||
|
#### Scenario: Missing core field is not invented
|
||||||
|
- **WHEN** a source decision has no discernible Context
|
||||||
|
- **THEN** the converted Context section contains a not-stated-in-source marker and the file is
|
||||||
|
flagged low-confidence
|
||||||
|
|
||||||
|
### Requirement: Single uncertainty-flagging mechanism
|
||||||
|
Migration uncertainty SHALL be surfaced through exactly one mechanism: a
|
||||||
|
`migration_confidence: low|medium|high` frontmatter field per converted file, plus one
|
||||||
|
migration report file (`docs/adr/migration-report.md`) summarizing per-file confidence, the
|
||||||
|
source-to-new mapping, and the overall low-confidence flag rate. Scattered inline uncertainty
|
||||||
|
comments SHALL NOT be used.
|
||||||
|
|
||||||
|
#### Scenario: Report aggregates confidence
|
||||||
|
- **WHEN** a migration completes
|
||||||
|
- **THEN** the report lists every converted file with its confidence, its source location, and
|
||||||
|
the overall flag rate
|
||||||
|
|
||||||
|
### Requirement: Non-destructive, dual-existence conversion
|
||||||
|
Migration SHALL only write new files under `docs/adr/`; existing ADR content SHALL never be
|
||||||
|
deleted, moved, or edited by the migration. Removal of the old system SHALL happen only as an
|
||||||
|
explicit, separate, user-approved step offered after the migration report exists.
|
||||||
|
|
||||||
|
#### Scenario: Old system untouched
|
||||||
|
- **WHEN** a migration run completes
|
||||||
|
- **THEN** every pre-existing ADR source file is byte-identical to its pre-migration state
|
||||||
|
|
||||||
|
#### Scenario: Deletion requires explicit approval
|
||||||
|
- **WHEN** the user has not explicitly approved old-system removal
|
||||||
|
- **THEN** no tooling path deletes the old content, and the removal offer appears only after the
|
||||||
|
report is available for review
|
||||||
|
|
||||||
|
### Requirement: Pilot gate before broad rollout
|
||||||
|
The migration SHALL be piloted on 2–3 real surveyed projects, and the low-confidence flag rate
|
||||||
|
SHALL be measured against an explicit threshold recorded at pilot time before the heuristics are
|
||||||
|
treated as good enough for wider rollout.
|
||||||
|
|
||||||
|
#### Scenario: High flag rate blocks rollout
|
||||||
|
- **WHEN** a pilot run's low-confidence rate exceeds the recorded threshold
|
||||||
|
- **THEN** the heuristics are tightened and the pilot re-run before migrating further projects
|
||||||
|
|
@ -0,0 +1,65 @@
|
||||||
|
# Spec: ADR Plugin Packaging
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
Defines how the `os-adr` plugin is packaged, named, installed, and structured: cc-os naming conventions, git-tracked source with a symlinked install, full three-place marketplace registration, plugin-relative hook wiring, and an object-oriented Ruby core with model-free deterministic tests.
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
### Requirement: Plugin naming and skill naming conventions
|
||||||
|
The plugin SHALL be named `os-adr` (kebab-case, `os-` prefix) and its skills SHALL use
|
||||||
|
verb-first kebab-case names invoked as `/os-adr:<verb>` (`create`, `init`, `migrate`, `find`),
|
||||||
|
with no `commands/` dispatcher directory — matching the cc-os plugin/skill naming convention.
|
||||||
|
|
||||||
|
#### Scenario: Skills register under verb-first names
|
||||||
|
- **WHEN** the plugin is installed and `claude plugin details os-adr@local-plugins` is run
|
||||||
|
- **THEN** the listed skills are `create`, `init`, `migrate`, and `find`, and no `commands/`
|
||||||
|
directory exists in the plugin
|
||||||
|
|
||||||
|
### Requirement: Source of truth in cc-os with symlinked install
|
||||||
|
The plugin source SHALL live git-tracked at `cc-os/plugins/os-adr/` and be installed via a
|
||||||
|
symlink `~/.claude/plugins/os-adr` pointing at it, matching the `os-vault`/`os-doc-hygiene`/
|
||||||
|
`os-orchestration` pattern.
|
||||||
|
|
||||||
|
#### Scenario: Symlinked install
|
||||||
|
- **WHEN** installation is complete
|
||||||
|
- **THEN** `~/.claude/plugins/os-adr` is a symlink resolving to the git-tracked plugin directory
|
||||||
|
|
||||||
|
### Requirement: Full three-place marketplace registration
|
||||||
|
Installation SHALL update all three registration surfaces — the plugin directory/symlink, the
|
||||||
|
`local-plugins` marketplace manifest (`~/.claude/plugins/.claude-plugin/marketplace.json`), and
|
||||||
|
`settings.json` `enabledPlugins` — then refresh plugin-manager state via
|
||||||
|
`claude plugin marketplace update` and `claude plugin install`, and verify skill registration
|
||||||
|
with `claude plugin list` and `claude plugin details`.
|
||||||
|
|
||||||
|
#### Scenario: Skills resolve after install
|
||||||
|
- **WHEN** the registration procedure completes
|
||||||
|
- **THEN** `claude plugin list` shows `os-adr@local-plugins` enabled and its skills are
|
||||||
|
invocable as slash commands in a fresh session
|
||||||
|
|
||||||
|
### Requirement: Hooks wired through plugin hooks.json
|
||||||
|
Plugin hooks SHALL be declared in `hooks/hooks.json` using `${CLAUDE_PLUGIN_ROOT}`-relative
|
||||||
|
commands (the `os-doc-hygiene` pattern), not by absolute paths in `settings.json`, so hook
|
||||||
|
registration cannot silently outlive or diverge from plugin registration.
|
||||||
|
|
||||||
|
#### Scenario: Hook path is plugin-relative
|
||||||
|
- **WHEN** the plugin's hook configuration is inspected
|
||||||
|
- **THEN** the SessionStart command references `${CLAUDE_PLUGIN_ROOT}` and no os-adr hook entry
|
||||||
|
exists in `settings.json`
|
||||||
|
|
||||||
|
### Requirement: OO core with tested deterministic behavior
|
||||||
|
The plugin's core logic SHALL be implemented as an object-oriented Ruby library (small
|
||||||
|
single-responsibility classes, dependency-injected paths/IO, no globals) exposed through thin
|
||||||
|
CLI entry points, with a thin Python script only for the SessionStart hook. All deterministic
|
||||||
|
behaviors (ID assignment, index regeneration, shape detection, existence check, retrieval layers
|
||||||
|
1–2) SHALL have automated tests runnable without any LLM, including golden-example fixtures.
|
||||||
|
|
||||||
|
#### Scenario: Deterministic tests run model-free
|
||||||
|
- **WHEN** the plugin's test suite runs on a machine with no model access
|
||||||
|
- **THEN** all tests for ID assignment, index regeneration, detection, existence check, and
|
||||||
|
frontmatter retrieval pass
|
||||||
|
|
||||||
|
#### Scenario: Skills delegate to CLI entry points
|
||||||
|
- **WHEN** a skill performs a mechanical operation (numbering, indexing, detection, filtering)
|
||||||
|
- **THEN** it does so by invoking a `bin/` CLI rather than re-implementing the logic in prompt
|
||||||
|
instructions
|
||||||
|
|
@ -0,0 +1,53 @@
|
||||||
|
# Spec: ADR Retrieval
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
Defines how relevant ADRs are found by the `os-adr` plugin: deterministic-first layered narrowing (frontmatter match, status filter, Graphify expansion) with AI judgment applied only to the narrowed candidate set, graceful degradation without a project graph, a mid-task-callable query surface, and eval-readiness sketches accompanying the build.
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
### Requirement: Deterministic-first retrieval layering
|
||||||
|
ADR retrieval SHALL narrow candidates through ordered layers: (1) path/component match of query
|
||||||
|
inputs against each ADR's `affected-paths`/`affected-components` frontmatter; (2) status filter
|
||||||
|
admitting only `Accepted` ADRs by default, with an explicit override to include other statuses;
|
||||||
|
(3) Graphify graph traversal when a project graph exists; (4) AI judgment applied only to the
|
||||||
|
already-narrowed candidate set. AI judgment SHALL never be applied to the full ADR corpus.
|
||||||
|
|
||||||
|
#### Scenario: Path match narrows first
|
||||||
|
- **WHEN** retrieval is queried with file paths being edited
|
||||||
|
- **THEN** ADRs whose `affected-paths` match are selected before any model involvement
|
||||||
|
|
||||||
|
#### Scenario: Default status filter
|
||||||
|
- **WHEN** retrieval runs without an override
|
||||||
|
- **THEN** only `Accepted` ADRs appear in the candidate set
|
||||||
|
|
||||||
|
#### Scenario: AI sees only the narrowed set
|
||||||
|
- **WHEN** the AI-judgment layer runs
|
||||||
|
- **THEN** it receives only the candidates surviving layers 1–3, not the full corpus
|
||||||
|
|
||||||
|
### Requirement: Graph layer degrades gracefully
|
||||||
|
WHEN no `graphify-out/` project graph exists (or ADRs are not yet indexed as graph nodes),
|
||||||
|
retrieval SHALL still function using layers 1, 2, and 4, without error.
|
||||||
|
|
||||||
|
#### Scenario: No project graph present
|
||||||
|
- **WHEN** retrieval runs in a project that has never been onboarded to Graphify
|
||||||
|
- **THEN** results are produced from frontmatter matching and status filtering alone
|
||||||
|
|
||||||
|
### Requirement: Mid-task-callable query surface
|
||||||
|
Retrieval SHALL be exposed as a `find` skill (`/os-adr:find`) backed by a deterministic CLI,
|
||||||
|
callable at any point during a task — not only at SessionStart — so an agent can check
|
||||||
|
in-progress work against existing decisions.
|
||||||
|
|
||||||
|
#### Scenario: Query during a task
|
||||||
|
- **WHEN** an agent mid-task invokes `/os-adr:find` with the files or topic it is working on
|
||||||
|
- **THEN** it receives the relevant Accepted ADR candidates for those inputs
|
||||||
|
|
||||||
|
### Requirement: Eval-readiness sketches accompany the build
|
||||||
|
Held-out evaluation scenario shapes (write-trigger and retrieval) SHALL be sketched in a design
|
||||||
|
doc before the plugin skeleton is finalized, and any plugin surface those sketches require SHALL
|
||||||
|
be part of the initial build. The evaluation itself SHALL NOT be part of this change.
|
||||||
|
|
||||||
|
#### Scenario: Sketches exist before skeleton freeze
|
||||||
|
- **WHEN** the plugin skeleton is finalized
|
||||||
|
- **THEN** a scenario-sketch document exists covering at least one write-trigger and one
|
||||||
|
retrieval scenario shape
|
||||||
|
|
@ -0,0 +1,49 @@
|
||||||
|
# Spec: ADR Session Awareness
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
Defines the `os-adr` SessionStart behavior: a deterministic, model-free existence check that injects a near-zero-token usage note when a project's ADR system exists, and a suppressible, once-per-day setup suggestion when it doesn't.
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
### Requirement: Deterministic SessionStart existence check
|
||||||
|
The plugin SHALL register a SessionStart hook (matcher `startup|resume`) that deterministically
|
||||||
|
checks for `docs/adr/` and its index file in the current project. The hook SHALL make no LLM
|
||||||
|
call, SHALL complete within its configured timeout (5s), and SHALL add no perceptible
|
||||||
|
session-start latency.
|
||||||
|
|
||||||
|
#### Scenario: Hook is deterministic
|
||||||
|
- **WHEN** the SessionStart hook runs
|
||||||
|
- **THEN** its output is a pure function of the project's filesystem state (ADR dir, index,
|
||||||
|
suppression state) with no model invocation
|
||||||
|
|
||||||
|
### Requirement: Minimal context injection when the ADR system exists
|
||||||
|
WHEN `docs/adr/` and the index file both exist, the hook SHALL inject a near-zero-token
|
||||||
|
additionalContext note stating that the ADR system exists and naming both usage surfaces:
|
||||||
|
writing (`/os-adr:create`) and retrieval (`/os-adr:find`).
|
||||||
|
|
||||||
|
#### Scenario: Present project gets the usage note
|
||||||
|
- **WHEN** a session starts in a project with `docs/adr/` and an index
|
||||||
|
- **THEN** the injected context is a short note (a few lines at most) naming `/os-adr:create`
|
||||||
|
and `/os-adr:find`
|
||||||
|
|
||||||
|
### Requirement: Setup suggestion with suppression when absent
|
||||||
|
WHEN the ADR system is absent, the hook SHALL notify the user and suggest `/os-adr:init` or
|
||||||
|
`/os-adr:migrate` — unless a per-project suppression flag is set, in which case it SHALL stay
|
||||||
|
silent. The suggestion SHALL also be rate-limited to at most once per day per project via a
|
||||||
|
snooze timestamp. Suppression state SHALL live in a gitignored per-project state directory
|
||||||
|
(`.os-adr/`), following the `os-doc-hygiene` state-dir shape.
|
||||||
|
|
||||||
|
#### Scenario: Absent and unsuppressed
|
||||||
|
- **WHEN** a session starts in a project with no `docs/adr/` and no suppression flag, and no
|
||||||
|
reminder was shown today
|
||||||
|
- **THEN** the hook emits a one-line suggestion to run `/os-adr:init` or `/os-adr:migrate` and
|
||||||
|
stamps the snooze timestamp
|
||||||
|
|
||||||
|
#### Scenario: Suppressed project stays silent
|
||||||
|
- **WHEN** a session starts in a project whose `.os-adr/suppress` flag is set
|
||||||
|
- **THEN** the hook emits nothing
|
||||||
|
|
||||||
|
#### Scenario: Same-day repeat session
|
||||||
|
- **WHEN** a second session starts the same day in an absent, unsuppressed project
|
||||||
|
- **THEN** no repeat suggestion is emitted
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
{
|
||||||
|
"name": "os-adr",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"description": "Architecture Decision Records, consistently in every project: one template, docs/adr/NNNN-title.md + mechanical index, deterministic SessionStart existence check, non-destructive migration, and deterministic-first retrieval."
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,16 @@
|
||||||
|
#!/usr/bin/env ruby
|
||||||
|
# Classify existing ADR-like content into surveyed shapes and enumerate
|
||||||
|
# migration units. Report-only: writes nothing.
|
||||||
|
#
|
||||||
|
# Usage: adr-detect [--root DIR] -> JSON on stdout
|
||||||
|
|
||||||
|
require "json"
|
||||||
|
require_relative "../lib/adr"
|
||||||
|
|
||||||
|
root = Dir.pwd
|
||||||
|
ARGV.each_with_index { |arg, i| root = ARGV[i + 1] if arg == "--root" }
|
||||||
|
|
||||||
|
units = Adr::Detector.new(root: root).units.map do |unit|
|
||||||
|
{ "shape" => unit.shape.to_s, "source" => unit.source_ref, "title" => unit.title }
|
||||||
|
end
|
||||||
|
puts JSON.pretty_generate("units" => units)
|
||||||
|
|
@ -0,0 +1,30 @@
|
||||||
|
#!/usr/bin/env ruby
|
||||||
|
# Deterministic ADR retrieval (layers 1-3). AI judgment belongs in the `find`
|
||||||
|
# skill, applied only to this command's output.
|
||||||
|
#
|
||||||
|
# Usage: adr-find [--root DIR] [--paths a,b] [--components x,y] [--all-statuses]
|
||||||
|
# -> JSON {"candidates": [{id, title, status, date, file, matched_on}]}
|
||||||
|
|
||||||
|
require "json"
|
||||||
|
require_relative "../lib/adr"
|
||||||
|
|
||||||
|
root = Dir.pwd
|
||||||
|
paths = []
|
||||||
|
components = []
|
||||||
|
all_statuses = false
|
||||||
|
ARGV.each_with_index do |arg, i|
|
||||||
|
case arg
|
||||||
|
when "--root" then root = ARGV[i + 1]
|
||||||
|
when "--paths" then paths = ARGV[i + 1].to_s.split(",")
|
||||||
|
when "--components" then components = ARGV[i + 1].to_s.split(",")
|
||||||
|
when "--all-statuses" then all_statuses = true
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
repo = Adr::Repository.new(root: root)
|
||||||
|
finder = Adr::Finder.new(
|
||||||
|
repository: repo,
|
||||||
|
graph_path: File.join(root, "graphify-out", "graph.json")
|
||||||
|
)
|
||||||
|
candidates = finder.find(paths: paths, components: components, all_statuses: all_statuses)
|
||||||
|
puts JSON.pretty_generate("candidates" => candidates)
|
||||||
|
|
@ -0,0 +1,20 @@
|
||||||
|
#!/usr/bin/env ruby
|
||||||
|
# Initialize docs/adr/ + empty generated index in a project, and clear any
|
||||||
|
# session-hook suppression flag (.os-adr/suppress).
|
||||||
|
#
|
||||||
|
# Usage: adr-init [--root DIR]
|
||||||
|
|
||||||
|
require "fileutils"
|
||||||
|
require_relative "../lib/adr"
|
||||||
|
|
||||||
|
root = Dir.pwd
|
||||||
|
ARGV.each_with_index { |arg, i| root = ARGV[i + 1] if arg == "--root" }
|
||||||
|
|
||||||
|
repo = Adr::Repository.new(root: root)
|
||||||
|
FileUtils.mkdir_p(repo.dir)
|
||||||
|
Adr::Index.new(repository: repo).regenerate!
|
||||||
|
|
||||||
|
suppress = File.join(root, ".os-adr", "suppress")
|
||||||
|
File.delete(suppress) if File.exist?(suppress)
|
||||||
|
|
||||||
|
puts repo.dir
|
||||||
|
|
@ -0,0 +1,35 @@
|
||||||
|
#!/usr/bin/env ruby
|
||||||
|
# Non-destructive mechanical migration pass.
|
||||||
|
#
|
||||||
|
# adr-migrate [--root DIR] convert detected units into new
|
||||||
|
# ADRs under docs/adr/; print the
|
||||||
|
# work manifest (JSON) of fields
|
||||||
|
# needing LLM fill
|
||||||
|
# adr-migrate [--root DIR] --apply-fills F apply LLM fills from JSON file F
|
||||||
|
# ({relpath: {field: text}}); only
|
||||||
|
# pending-marker fields are fillable
|
||||||
|
#
|
||||||
|
# Never deletes, moves, or edits source files; writes only under docs/adr/.
|
||||||
|
|
||||||
|
require "json"
|
||||||
|
require "fileutils"
|
||||||
|
require_relative "../lib/adr"
|
||||||
|
|
||||||
|
root = Dir.pwd
|
||||||
|
fills_path = nil
|
||||||
|
ARGV.each_with_index do |arg, i|
|
||||||
|
root = ARGV[i + 1] if arg == "--root"
|
||||||
|
fills_path = ARGV[i + 1] if arg == "--apply-fills"
|
||||||
|
end
|
||||||
|
|
||||||
|
repo = Adr::Repository.new(root: root)
|
||||||
|
FileUtils.mkdir_p(repo.dir)
|
||||||
|
migrator = Adr::Migrator.new(repository: repo, detector: Adr::Detector.new(root: root))
|
||||||
|
|
||||||
|
if fills_path
|
||||||
|
migrator.apply_fills!(JSON.parse(File.read(fills_path)))
|
||||||
|
puts JSON.pretty_generate("applied" => true,
|
||||||
|
"report" => File.join("docs", "adr", Adr::MigrationReport::FILENAME))
|
||||||
|
else
|
||||||
|
puts JSON.pretty_generate(migrator.migrate!)
|
||||||
|
end
|
||||||
|
|
@ -0,0 +1,58 @@
|
||||||
|
#!/usr/bin/env ruby
|
||||||
|
# Create a templated, numbered, indexed ADR in one invocation.
|
||||||
|
#
|
||||||
|
# Usage: echo '{"title": "...", ...}' | adr-new [--root DIR]
|
||||||
|
#
|
||||||
|
# JSON input fields:
|
||||||
|
# title (required) decision title
|
||||||
|
# status (optional) default "Accepted"
|
||||||
|
# date (optional) ISO date, default today
|
||||||
|
# supersedes (optional) 4-digit ID of the ADR this one supersedes
|
||||||
|
# affected_paths, affected_components (optional) arrays
|
||||||
|
# context, decision, consequences, alternatives (optional) section bodies
|
||||||
|
#
|
||||||
|
# Prints the created file path. Also mechanically sets superseded-by + status
|
||||||
|
# on the superseded ADR and regenerates the index.
|
||||||
|
|
||||||
|
require "json"
|
||||||
|
require "date"
|
||||||
|
require_relative "../lib/adr"
|
||||||
|
|
||||||
|
root = Dir.pwd
|
||||||
|
ARGV.each_with_index { |arg, i| root = ARGV[i + 1] if arg == "--root" }
|
||||||
|
|
||||||
|
input = JSON.parse($stdin.read)
|
||||||
|
title = input["title"].to_s.strip
|
||||||
|
abort "adr-new: 'title' is required" if title.empty?
|
||||||
|
|
||||||
|
repo = Adr::Repository.new(root: root)
|
||||||
|
abort "adr-new: #{repo.dir} is not initialized (run adr-init first)" unless Dir.exist?(repo.dir)
|
||||||
|
|
||||||
|
id = repo.next_id
|
||||||
|
template = Adr::Template.new(path: File.expand_path("../templates/adr.md", __dir__))
|
||||||
|
content = template.render(
|
||||||
|
id: id,
|
||||||
|
title: title,
|
||||||
|
date: input["date"] || Date.today.iso8601,
|
||||||
|
status: input["status"] || "Accepted",
|
||||||
|
supersedes: input["supersedes"] ? %("#{input['supersedes']}") : nil,
|
||||||
|
affected_paths: input["affected_paths"] || [],
|
||||||
|
affected_components: input["affected_components"] || [],
|
||||||
|
context: input["context"],
|
||||||
|
decision: input["decision"],
|
||||||
|
consequences: input["consequences"],
|
||||||
|
alternatives: input["alternatives"]
|
||||||
|
)
|
||||||
|
|
||||||
|
path = File.join(repo.dir, "#{id}-#{Adr.slugify(title)}.md")
|
||||||
|
File.write(path, content)
|
||||||
|
|
||||||
|
if (old_id = input["supersedes"])
|
||||||
|
entry = repo.find(old_id.to_s)
|
||||||
|
abort "adr-new: superseded ADR #{old_id} not found" unless entry
|
||||||
|
entry.record.supersede_with(id)
|
||||||
|
repo.write_entry(entry)
|
||||||
|
end
|
||||||
|
|
||||||
|
Adr::Index.new(repository: repo).regenerate!
|
||||||
|
puts path
|
||||||
Binary file not shown.
|
|
@ -0,0 +1,16 @@
|
||||||
|
{
|
||||||
|
"hooks": {
|
||||||
|
"SessionStart": [
|
||||||
|
{
|
||||||
|
"matcher": "startup|resume",
|
||||||
|
"hooks": [
|
||||||
|
{
|
||||||
|
"type": "command",
|
||||||
|
"command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/session_start.py",
|
||||||
|
"timeout": 5
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,121 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""session_start.py — deterministic SessionStart existence check for os-adr.
|
||||||
|
|
||||||
|
Pure function of the project's filesystem state; no model, no network, no Ruby.
|
||||||
|
|
||||||
|
present (docs/adr/ + index) -> additionalContext note naming /os-adr:create
|
||||||
|
and /os-adr:find (near-zero tokens)
|
||||||
|
absent, suppressed -> silent (.os-adr/suppress flag)
|
||||||
|
absent, reminded today -> silent (.os-adr/last_reminded snooze stamp)
|
||||||
|
absent otherwise -> one-line systemMessage suggesting
|
||||||
|
/os-adr:init or /os-adr:migrate; stamps snooze
|
||||||
|
not a git project -> silent (never creates state dirs outside
|
||||||
|
real projects)
|
||||||
|
|
||||||
|
Always exits 0 — the hook must never block a session.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
from datetime import date
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional, Tuple
|
||||||
|
|
||||||
|
PRESENT_NOTE = (
|
||||||
|
"[os-adr] This project records architecture decisions in docs/adr/ "
|
||||||
|
"(index: docs/adr/README.md). Record a new decision of consequence with "
|
||||||
|
"/os-adr:create; before changing decided-on behavior, check relevant "
|
||||||
|
"decisions with /os-adr:find."
|
||||||
|
)
|
||||||
|
|
||||||
|
ABSENT_NOTE = (
|
||||||
|
"[os-adr] No ADR system in this project. /os-adr:init sets one up; "
|
||||||
|
"/os-adr:migrate converts existing decision docs. "
|
||||||
|
"(touch .os-adr/suppress to silence this permanently)"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def find_project_root(cwd: Path) -> Optional[Path]:
|
||||||
|
"""Nearest ancestor (including cwd) containing .git, else None."""
|
||||||
|
for candidate in [cwd, *cwd.parents]:
|
||||||
|
if (candidate / ".git").exists():
|
||||||
|
return candidate
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def adr_system_present(root: Path) -> bool:
|
||||||
|
"""The same existence rule as Ruby Adr::Repository#exists?."""
|
||||||
|
adr_dir = root / "docs" / "adr"
|
||||||
|
return adr_dir.is_dir() and (adr_dir / "README.md").is_file()
|
||||||
|
|
||||||
|
|
||||||
|
def decide(
|
||||||
|
present: bool,
|
||||||
|
suppressed: bool,
|
||||||
|
last_reminded: Optional[date],
|
||||||
|
today: date,
|
||||||
|
) -> Tuple[str, Optional[str]]:
|
||||||
|
"""Pure decision: ('context', note) | ('banner', note) | ('silent', None)."""
|
||||||
|
if present:
|
||||||
|
return ("context", PRESENT_NOTE)
|
||||||
|
if suppressed:
|
||||||
|
return ("silent", None)
|
||||||
|
if last_reminded == today:
|
||||||
|
return ("silent", None)
|
||||||
|
return ("banner", ABSENT_NOTE)
|
||||||
|
|
||||||
|
|
||||||
|
class StateDir:
|
||||||
|
"""The gitignored per-project .os-adr/ state directory."""
|
||||||
|
|
||||||
|
def __init__(self, root: Path) -> None:
|
||||||
|
self._dir = root / ".os-adr"
|
||||||
|
|
||||||
|
def suppressed(self) -> bool:
|
||||||
|
return (self._dir / "suppress").exists()
|
||||||
|
|
||||||
|
def last_reminded(self) -> Optional[date]:
|
||||||
|
try:
|
||||||
|
return date.fromisoformat(
|
||||||
|
(self._dir / "last_reminded").read_text().strip()
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
def stamp_reminded(self, today: date) -> None:
|
||||||
|
self._dir.mkdir(exist_ok=True)
|
||||||
|
(self._dir / "last_reminded").write_text(today.isoformat())
|
||||||
|
|
||||||
|
|
||||||
|
def run(root: Path, today: date, out) -> str:
|
||||||
|
state = StateDir(root)
|
||||||
|
outcome, note = decide(
|
||||||
|
adr_system_present(root), state.suppressed(), state.last_reminded(), today
|
||||||
|
)
|
||||||
|
if outcome == "context":
|
||||||
|
out.write(json.dumps({
|
||||||
|
"hookSpecificOutput": {
|
||||||
|
"hookEventName": "SessionStart",
|
||||||
|
"additionalContext": note,
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
elif outcome == "banner":
|
||||||
|
out.write(json.dumps({"systemMessage": note}))
|
||||||
|
state.stamp_reminded(today)
|
||||||
|
return outcome
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
try:
|
||||||
|
root = find_project_root(Path.cwd())
|
||||||
|
if root is not None:
|
||||||
|
run(root, date.today(), sys.stdout)
|
||||||
|
except Exception:
|
||||||
|
pass # never block a session
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
|
|
@ -0,0 +1,53 @@
|
||||||
|
# os-adr invariants
|
||||||
|
|
||||||
|
Behavioral invariants of the plugin. Changing any of these requires explicit human approval and
|
||||||
|
a matching update to the tests named beside it. Suite entry points: `ruby tests/all.rb` and
|
||||||
|
`python3 tests/hook_test.py` (both model-free).
|
||||||
|
|
||||||
|
1. **Non-destructiveness (migration).** Migration writes only new files under `docs/adr/`;
|
||||||
|
existing source content is never deleted, moved, or edited by any tooling path. Old-system
|
||||||
|
removal is a separate, explicit, user-approved step (skill-mediated, never automatic).
|
||||||
|
— `migrator_test.rb#test_sources_stay_byte_identical`, pilot sha256 checks.
|
||||||
|
|
||||||
|
2. **Index regeneration, never accretion.** `docs/adr/README.md`'s table is regenerated in full
|
||||||
|
from directory contents on every ADR write; it is never appended to or hand-edited, and
|
||||||
|
self-heals after manual edits. Content outside the markers is preserved.
|
||||||
|
— `index_test.rb` (golden fixture, self-heal, prose preservation, generated-header cases).
|
||||||
|
|
||||||
|
3. **IDs come from the directory, never the index.** Next ID = max existing filename ID + 1,
|
||||||
|
derived by scanning `docs/adr/`; a stale or missing index never influences numbering.
|
||||||
|
— `repository_test.rb#test_next_id_ignores_{missing,stale}_index`.
|
||||||
|
|
||||||
|
4. **No LLM in the hook.** The SessionStart hook is a pure function of filesystem state (ADR
|
||||||
|
dir, index, suppression state, snooze stamp) — no model call, no network, no Ruby spin-up;
|
||||||
|
always exits 0; emits nothing (and creates no state) outside a git project. Its existence
|
||||||
|
rule is identical to `Adr::Repository#exists?`.
|
||||||
|
— `hook_test.py` (all cases; `PythonRubyAgreementTest` pins the shared rule).
|
||||||
|
|
||||||
|
5. **Single uncertainty-flagging mechanism.** Migration uncertainty surfaces only via
|
||||||
|
`migration_confidence: low|medium|high` frontmatter plus `docs/adr/migration-report.md`.
|
||||||
|
No scattered inline uncertainty comments.
|
||||||
|
— `migrator_test.rb#test_report_lists_files_confidence_sources_and_flag_rate`.
|
||||||
|
|
||||||
|
6. **Core fields are never invented.** A Decision or Context absent from a migration source is
|
||||||
|
written as `_not stated in source_` and flags the file low-confidence; LLM fills apply only
|
||||||
|
to pending-marker fields (enforced structurally by `apply_fills!` — a stray fill key is a
|
||||||
|
no-op), and an LLM-filled file stays flagged (≤ medium).
|
||||||
|
— `migrator_test.rb#test_missing_core_fields_*`, `#test_apply_fills_only_touches_pending_fields`.
|
||||||
|
|
||||||
|
7. **Unrecognized content is reported, never auto-converted.** Content matching no known shape
|
||||||
|
— including multi-decision files without mechanically splittable unit headings — is listed
|
||||||
|
in the report for manual handling only.
|
||||||
|
— `detector_test.rb#test_multi_decision_file_without_unit_headings_is_unrecognized`,
|
||||||
|
`migrator_test.rb#test_unrecognized_content_is_reported_never_converted`.
|
||||||
|
|
||||||
|
8. **Deterministic-first retrieval.** `adr-find` narrows by frontmatter path/component match,
|
||||||
|
then Accepted-only status filter (explicit override), then optional one-hop graph expansion
|
||||||
|
that degrades gracefully (absent or corrupt graph → layers 1–2). AI judgment happens only in
|
||||||
|
the `find` skill, only over the CLI's returned candidates.
|
||||||
|
— `finder_test.rb` (all cases, including both degradation paths).
|
||||||
|
|
||||||
|
9. **Skills delegate mechanics to `bin/` CLIs.** Numbering, templating, indexing, supersession
|
||||||
|
bookkeeping, detection, conversion, and retrieval filtering are never re-implemented in
|
||||||
|
prompt instructions.
|
||||||
|
— by construction of `skills/*/SKILL.md`; `cli_test.rb` pins the CLI contracts.
|
||||||
|
|
@ -0,0 +1,14 @@
|
||||||
|
require_relative "adr/record"
|
||||||
|
require_relative "adr/repository"
|
||||||
|
require_relative "adr/index"
|
||||||
|
require_relative "adr/template"
|
||||||
|
require_relative "adr/detector"
|
||||||
|
require_relative "adr/migrator"
|
||||||
|
require_relative "adr/migration_report"
|
||||||
|
require_relative "adr/finder"
|
||||||
|
|
||||||
|
module Adr
|
||||||
|
def self.slugify(title)
|
||||||
|
title.downcase.gsub(/[^a-z0-9]+/, "-").gsub(/\A-|-\z/, "")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
@ -0,0 +1,108 @@
|
||||||
|
module Adr
|
||||||
|
# Mechanical classification of existing ADR-like content into the surveyed
|
||||||
|
# shapes, enumerating migration units. Report-only for unrecognized content —
|
||||||
|
# nothing here writes anything.
|
||||||
|
class Detector
|
||||||
|
Unit = Struct.new(:shape, :source_path, :anchor, :title, :body, keyword_init: true) do
|
||||||
|
def source_ref
|
||||||
|
anchor ? "#{source_path}##{anchor}" : source_path
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
CANDIDATE_DIRS = [%w[docs adr], %w[docs decisions], %w[docs decision]].freeze
|
||||||
|
GENERATED_BASENAMES = %w[README.md migration-report.md].freeze
|
||||||
|
UNIT_HEADING = /^\#{1,4}\s*(?:ADR[-\s]?\d+|D-\d+)\b/i
|
||||||
|
PROSE_DECISION = /^(?:\#{1,4}\s*|\*\*)?Decision\s+\d+\s*[:—–-]/i
|
||||||
|
DECISION_MARKERS = /^(?:\#{1,4}\s*|\*\*)?(?:Status|Decision|Decided|Context)\b/i
|
||||||
|
|
||||||
|
def initialize(root:)
|
||||||
|
@root = root
|
||||||
|
end
|
||||||
|
|
||||||
|
def units
|
||||||
|
candidate_files.flat_map { |path| classify_file(path) }
|
||||||
|
end
|
||||||
|
|
||||||
|
def candidate_files
|
||||||
|
files = CANDIDATE_DIRS.flat_map do |parts|
|
||||||
|
Dir.glob(File.join(@root, *parts, "*.md"))
|
||||||
|
end
|
||||||
|
files += Dir.glob(File.join(@root, "DECISIONS.md"))
|
||||||
|
files.uniq.sort.reject { |p| generated?(p) || already_migrated?(p) }
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def generated?(path)
|
||||||
|
GENERATED_BASENAMES.include?(File.basename(path))
|
||||||
|
end
|
||||||
|
|
||||||
|
# A file already in the os-adr format (frontmatter with an id) is not a
|
||||||
|
# migration source.
|
||||||
|
def already_migrated?(path)
|
||||||
|
text = File.read(path)
|
||||||
|
return false unless text.start_with?("---\n")
|
||||||
|
!Record.parse(text).id.nil?
|
||||||
|
rescue Record::ParseError, StandardError
|
||||||
|
false
|
||||||
|
end
|
||||||
|
|
||||||
|
def classify_file(path)
|
||||||
|
text = File.read(path)
|
||||||
|
base = File.basename(path)
|
||||||
|
relative = relative(path)
|
||||||
|
|
||||||
|
if text.scan(UNIT_HEADING).size >= 2
|
||||||
|
split_units(relative, text, :monolithic, UNIT_HEADING)
|
||||||
|
elsif text.scan(PROSE_DECISION).size >= 2
|
||||||
|
split_units(relative, text, :prose_embedded, PROSE_DECISION)
|
||||||
|
elsif text.scan(/^\**Status\**\s*[::]/i).size >= 2
|
||||||
|
# Multiple decisions but no mechanically splittable unit headings:
|
||||||
|
# report-only, never auto-converted.
|
||||||
|
[Unit.new(shape: :unrecognized, source_path: relative, anchor: nil,
|
||||||
|
title: base, body: text)]
|
||||||
|
elsif text.match?(DECISION_MARKERS)
|
||||||
|
shape =
|
||||||
|
if base.match?(/\A\d{4}-\d{2}(-\d{2})?-/) then :dated_single_file
|
||||||
|
elsif base.match?(/\A\d+-/) then :numbered_per_file
|
||||||
|
else :topic_single_file
|
||||||
|
end
|
||||||
|
[Unit.new(shape: shape, source_path: relative, anchor: nil,
|
||||||
|
title: single_title(text, base), body: text)]
|
||||||
|
else
|
||||||
|
[Unit.new(shape: :unrecognized, source_path: relative, anchor: nil,
|
||||||
|
title: base, body: text)]
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def split_units(relative, text, shape, pattern)
|
||||||
|
lines = text.lines
|
||||||
|
starts = lines.each_index.select { |i| lines[i].match?(pattern) }
|
||||||
|
starts.each_with_index.map do |start, n|
|
||||||
|
stop = starts[n + 1] || lines.size
|
||||||
|
heading = lines[start].strip
|
||||||
|
Unit.new(shape: shape, source_path: relative, anchor: heading,
|
||||||
|
title: unit_title(heading), body: lines[start...stop].join)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def unit_title(heading)
|
||||||
|
heading
|
||||||
|
.sub(/\A\#{1,4}\s*/, "")
|
||||||
|
.gsub("**", "")
|
||||||
|
.sub(/\A(?:ADR[-\s]?\d+|D-\d+|Decision\s+\d+)\s*[:—–-]?\s*/i, "")
|
||||||
|
.strip
|
||||||
|
end
|
||||||
|
|
||||||
|
def single_title(text, base)
|
||||||
|
h1 = text[/^# +(.+)$/, 1]
|
||||||
|
return h1.strip.sub(/\A(?:ADR[-\s]?\d+|D-\d+|\d{1,4})\s*[:—–-]\s*/i, "") if h1
|
||||||
|
|
||||||
|
base.sub(/\.md\z/, "").sub(/\A[\d-]+/, "").tr("-", " ").strip.capitalize
|
||||||
|
end
|
||||||
|
|
||||||
|
def relative(path)
|
||||||
|
path.sub(%r{\A#{Regexp.escape(@root)}/?}, "")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
@ -0,0 +1,83 @@
|
||||||
|
require "json"
|
||||||
|
require "set"
|
||||||
|
|
||||||
|
module Adr
|
||||||
|
# Deterministic-first ADR retrieval. Layers, in order:
|
||||||
|
# 1. path/component match against affected-paths / affected-components
|
||||||
|
# 2. status filter (Accepted only unless all_statuses)
|
||||||
|
# 3. Graphify one-hop expansion of the query paths (when graphify-out/
|
||||||
|
# exists; degrades gracefully to layers 1-2 when absent or unreadable)
|
||||||
|
# AI judgment (layer 4) happens in the `find` skill, only over what this
|
||||||
|
# returns — never the full corpus.
|
||||||
|
class Finder
|
||||||
|
def initialize(repository:, graph_path: nil)
|
||||||
|
@repository = repository
|
||||||
|
@graph_path = graph_path
|
||||||
|
end
|
||||||
|
|
||||||
|
def find(paths: [], components: [], all_statuses: false)
|
||||||
|
paths = normalize(paths)
|
||||||
|
graph_paths = normalize(graph_neighbors(paths)) - paths
|
||||||
|
@repository.entries.filter_map do |entry|
|
||||||
|
record = entry.record
|
||||||
|
next unless all_statuses || record.status == "Accepted"
|
||||||
|
|
||||||
|
matched = match_reasons(record, paths, graph_paths, components)
|
||||||
|
next if matched.empty?
|
||||||
|
|
||||||
|
{ "id" => record.id, "title" => record.title, "status" => record.status,
|
||||||
|
"date" => record.date.to_s, "file" => entry.path, "matched_on" => matched }
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def match_reasons(record, paths, graph_paths, components)
|
||||||
|
affected_paths = Array(record.frontmatter["affected-paths"]).map { |p| p.to_s.chomp("/") }
|
||||||
|
affected_components = Array(record.frontmatter["affected-components"]).map(&:to_s)
|
||||||
|
reasons = []
|
||||||
|
reasons << "path" if overlap?(affected_paths, paths)
|
||||||
|
reasons << "graph" if reasons.empty? && overlap?(affected_paths, graph_paths)
|
||||||
|
reasons << "component" if (affected_components & components).any?
|
||||||
|
reasons
|
||||||
|
end
|
||||||
|
|
||||||
|
def overlap?(affected, queried)
|
||||||
|
affected.any? { |a| queried.any? { |q| path_match?(a, q) } }
|
||||||
|
end
|
||||||
|
|
||||||
|
# Prefix containment either way: an ADR affecting `lib/storage` matches a
|
||||||
|
# query for `lib/storage/adapter.rb`, and one affecting a specific file
|
||||||
|
# matches a query for its directory.
|
||||||
|
def path_match?(a, q)
|
||||||
|
a == q || q.start_with?("#{a}/") || a.start_with?("#{q}/")
|
||||||
|
end
|
||||||
|
|
||||||
|
def normalize(paths)
|
||||||
|
paths.map { |p| p.to_s.chomp("/") }.reject(&:empty?).uniq
|
||||||
|
end
|
||||||
|
|
||||||
|
# Files one graph hop from the queried files, via graphify-out/graph.json
|
||||||
|
# node source_file attributes. Any failure degrades to no expansion.
|
||||||
|
def graph_neighbors(paths)
|
||||||
|
return [] unless @graph_path && File.exist?(@graph_path)
|
||||||
|
|
||||||
|
graph = JSON.parse(File.read(@graph_path))
|
||||||
|
nodes = graph.fetch("nodes", [])
|
||||||
|
id_to_file = nodes.to_h { |n| [n["id"], n["source_file"]] }
|
||||||
|
seeds = nodes.select do |n|
|
||||||
|
n["source_file"] && paths.any? { |q| path_match?(n["source_file"].chomp("/"), q) }
|
||||||
|
end.map { |n| n["id"] }.to_set
|
||||||
|
return [] if seeds.empty?
|
||||||
|
|
||||||
|
graph.fetch("links", []).flat_map do |link|
|
||||||
|
if seeds.include?(link["source"]) then [link["target"]]
|
||||||
|
elsif seeds.include?(link["target"]) then [link["source"]]
|
||||||
|
else []
|
||||||
|
end
|
||||||
|
end.filter_map { |id| id_to_file[id] }.uniq
|
||||||
|
rescue StandardError
|
||||||
|
[]
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
@ -0,0 +1,61 @@
|
||||||
|
module Adr
|
||||||
|
# The generated docs/adr/README.md. Always regenerated in full from the
|
||||||
|
# directory contents — never appended to, never hand-edited. Content outside
|
||||||
|
# the markers is preserved so projects can add prose around the table.
|
||||||
|
class Index
|
||||||
|
BEGIN_MARKER = "<!-- adr-index:begin -->"
|
||||||
|
END_MARKER = "<!-- adr-index:end -->"
|
||||||
|
|
||||||
|
DEFAULT_SKELETON = <<~MD
|
||||||
|
<!-- Generated by os-adr. Do not hand-edit the table: it is regenerated in full on every ADR write. -->
|
||||||
|
|
||||||
|
# Architecture Decision Records
|
||||||
|
|
||||||
|
One file per decision, `NNNN-kebab-title.md`, created via `/os-adr:create`.
|
||||||
|
|
||||||
|
#{BEGIN_MARKER}
|
||||||
|
#{END_MARKER}
|
||||||
|
MD
|
||||||
|
|
||||||
|
def initialize(repository:)
|
||||||
|
@repository = repository
|
||||||
|
end
|
||||||
|
|
||||||
|
def regenerate!
|
||||||
|
File.write(@repository.index_path, content)
|
||||||
|
end
|
||||||
|
|
||||||
|
def content
|
||||||
|
base = if File.exist?(@repository.index_path) && File.read(@repository.index_path).include?(BEGIN_MARKER)
|
||||||
|
File.read(@repository.index_path)
|
||||||
|
else
|
||||||
|
DEFAULT_SKELETON
|
||||||
|
end
|
||||||
|
before, _stale, after = split(base)
|
||||||
|
"#{before}#{BEGIN_MARKER}\n#{table}#{END_MARKER}#{after}"
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def split(text)
|
||||||
|
before, rest = text.split(BEGIN_MARKER, 2)
|
||||||
|
_stale, after = rest.split(END_MARKER, 2)
|
||||||
|
[before, _stale, after]
|
||||||
|
end
|
||||||
|
|
||||||
|
def table
|
||||||
|
rows = @repository.entries.map do |entry|
|
||||||
|
record = entry.record
|
||||||
|
"| #{record.id} | [#{record.title}](#{File.basename(entry.path)}) " \
|
||||||
|
"| #{record.status} | #{record.date} |"
|
||||||
|
end
|
||||||
|
return "" if rows.empty?
|
||||||
|
|
||||||
|
<<~MD
|
||||||
|
| ID | Title | Status | Date |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
#{rows.join("\n")}
|
||||||
|
MD
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
@ -0,0 +1,67 @@
|
||||||
|
module Adr
|
||||||
|
# Generates docs/adr/migration-report.md — the single aggregate surface for
|
||||||
|
# migration uncertainty (alongside per-file migration_confidence frontmatter).
|
||||||
|
class MigrationReport
|
||||||
|
FILENAME = "migration-report.md"
|
||||||
|
|
||||||
|
# Pilot-gate threshold (task 5.5): a run whose low-confidence flag rate
|
||||||
|
# exceeds this is not good enough for wider rollout — tighten heuristics
|
||||||
|
# and re-run. Set empirically during the 2026-07-03 pilot.
|
||||||
|
FLAG_RATE_THRESHOLD = 0.25
|
||||||
|
|
||||||
|
def initialize(repository:, detector:)
|
||||||
|
@repository = repository
|
||||||
|
@detector = detector
|
||||||
|
end
|
||||||
|
|
||||||
|
def path = File.join(@repository.dir, FILENAME)
|
||||||
|
|
||||||
|
def write!
|
||||||
|
File.write(path, content)
|
||||||
|
end
|
||||||
|
|
||||||
|
def migrated_entries
|
||||||
|
@repository.entries.select { |e| e.record.frontmatter["migration_source"] }
|
||||||
|
end
|
||||||
|
|
||||||
|
def flag_rate
|
||||||
|
entries = migrated_entries
|
||||||
|
return 0.0 if entries.empty?
|
||||||
|
|
||||||
|
low = entries.count { |e| e.record.frontmatter["migration_confidence"] == "low" }
|
||||||
|
(low.to_f / entries.size).round(3)
|
||||||
|
end
|
||||||
|
|
||||||
|
def content
|
||||||
|
entries = migrated_entries
|
||||||
|
unrecognized = @detector.units.select { |u| u.shape == :unrecognized }
|
||||||
|
rows = entries.map do |entry|
|
||||||
|
record = entry.record
|
||||||
|
pending = record.sections.select { |_, v| v == Migrator::PENDING_MARKER }.keys
|
||||||
|
not_stated = record.sections.select { |_, v| v == Migrator::NOT_STATED_MARKER }.keys
|
||||||
|
"| #{record.id} | [#{record.title}](#{File.basename(entry.path)}) " \
|
||||||
|
"| #{record.frontmatter['migration_source']} " \
|
||||||
|
"| #{record.frontmatter['migration_confidence']} " \
|
||||||
|
"| #{(pending.map { |s| "#{s} (pending)" } + not_stated.map { |s| "#{s} (not stated)" }).join(', ')} |"
|
||||||
|
end
|
||||||
|
|
||||||
|
<<~MD
|
||||||
|
<!-- Generated by os-adr migration. Regenerated on every migration pass. -->
|
||||||
|
|
||||||
|
# ADR Migration Report
|
||||||
|
|
||||||
|
Converted: #{entries.size} · Low-confidence flag rate: #{(flag_rate * 100).round(1)}% (gate threshold: #{(FLAG_RATE_THRESHOLD * 100).round}% — #{flag_rate > FLAG_RATE_THRESHOLD ? 'EXCEEDED, tighten heuristics before wider rollout' : 'within gate'})
|
||||||
|
|
||||||
|
| ID | Title | Source | Confidence | Open items |
|
||||||
|
| --- | --- | --- | --- | --- |
|
||||||
|
#{rows.join("\n")}
|
||||||
|
|
||||||
|
## Unrecognized content (manual handling required)
|
||||||
|
|
||||||
|
#{unrecognized.empty? ? '_none_' : unrecognized.map { |u| "- #{u.source_ref}" }.join("\n")}
|
||||||
|
|
||||||
|
_Sources are untouched. Old-system removal is a separate, explicitly user-approved step._
|
||||||
|
MD
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
@ -0,0 +1,226 @@
|
||||||
|
require "fileutils"
|
||||||
|
|
||||||
|
module Adr
|
||||||
|
# Mechanical migration pass. Converts detected units into new-format ADRs
|
||||||
|
# under docs/adr/ — heuristic-filling only structurally unambiguous fields —
|
||||||
|
# and emits a manifest of the interpretive fields an LLM must fill. Writes
|
||||||
|
# nothing outside docs/adr/; never touches source files.
|
||||||
|
class Migrator
|
||||||
|
PENDING_MARKER = "_pending LLM fill (see migration manifest)_"
|
||||||
|
NOT_STATED_MARKER = "_not stated in source_"
|
||||||
|
|
||||||
|
# Mechanical status normalization: canonical lifecycle values plus the one
|
||||||
|
# unambiguous client-facing synonym. Anything else is copied raw.
|
||||||
|
STATUS_MAP = {
|
||||||
|
"accepted" => "Accepted", "proposed" => "Proposed",
|
||||||
|
"superseded" => "Superseded", "deprecated" => "Deprecated",
|
||||||
|
"confirmed" => "Accepted", "settled" => "Accepted", "open" => "Proposed"
|
||||||
|
}.freeze
|
||||||
|
|
||||||
|
# Source heading -> target section. Interpretive targets (consequences,
|
||||||
|
# alternatives) get PENDING when absent; core targets (context, decision)
|
||||||
|
# get NOT_STATED — they are never invented, by an LLM or otherwise.
|
||||||
|
SECTION_SOURCES = {
|
||||||
|
"Context" => [/\Acontext\z/i],
|
||||||
|
"Decision" => [/\Adecision\z/i],
|
||||||
|
"Consequences" => [/\Aconsequences\z/i],
|
||||||
|
"Alternatives rejected" => [/\Aalternatives( (rejected|considered))?\z/i]
|
||||||
|
}.freeze
|
||||||
|
|
||||||
|
# Heading synonyms mapped as a heuristic: the content is copied verbatim
|
||||||
|
# from the source, but because the mapping is a judgment call the file's
|
||||||
|
# confidence is capped at medium.
|
||||||
|
SECTION_FALLBACKS = {
|
||||||
|
"Context" => [/\A(why|rationale|problem|background)\z/i],
|
||||||
|
"Consequences" => [/\A(impact|implications|trade-?offs)\z/i]
|
||||||
|
}.freeze
|
||||||
|
CORE_SECTIONS = ["Context", "Decision"].freeze
|
||||||
|
FILL_FIELDS = { "consequences" => "Consequences",
|
||||||
|
"alternatives" => "Alternatives rejected" }.freeze
|
||||||
|
|
||||||
|
def initialize(repository:, detector:)
|
||||||
|
@repository = repository
|
||||||
|
@detector = detector
|
||||||
|
end
|
||||||
|
|
||||||
|
def migrate!
|
||||||
|
FileUtils.mkdir_p(@repository.dir)
|
||||||
|
recognized, unrecognized = @detector.units.partition { |u| u.shape != :unrecognized }
|
||||||
|
fresh = recognized.reject { |u| migrated_sources.include?(u.source_ref) }
|
||||||
|
results = fresh.map { |unit| convert(unit) }
|
||||||
|
finalize!
|
||||||
|
manifest(results, unrecognized)
|
||||||
|
end
|
||||||
|
|
||||||
|
# fills: {"docs/adr/NNNN-x.md" => {"consequences" => "...", "status" => "..."}}
|
||||||
|
# Only pending-marker sections and an empty status are fillable — the
|
||||||
|
# manifest is enforced structurally, not trusted from the caller.
|
||||||
|
def apply_fills!(fills)
|
||||||
|
fills.each do |relpath, fields|
|
||||||
|
path = File.join(@repository.root, relpath)
|
||||||
|
record = Record.parse(File.read(path))
|
||||||
|
fields.each { |field, value| apply_fill(record, field, value) }
|
||||||
|
File.write(path, record.to_markdown)
|
||||||
|
end
|
||||||
|
finalize!
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def migrated_sources
|
||||||
|
@repository.entries.filter_map { |e| e.record.frontmatter["migration_source"] }
|
||||||
|
end
|
||||||
|
|
||||||
|
def convert(unit)
|
||||||
|
extraction = Extraction.new(unit)
|
||||||
|
sections, llm_fields, fallback_used = build_sections(extraction)
|
||||||
|
confidence = confidence_for(sections, llm_fields, extraction, fallback_used)
|
||||||
|
llm_fields << "status" if extraction.status.to_s.empty?
|
||||||
|
|
||||||
|
record = Record.new(
|
||||||
|
frontmatter: {
|
||||||
|
"id" => @repository.next_id,
|
||||||
|
"date" => extraction.date,
|
||||||
|
"status" => extraction.status,
|
||||||
|
"supersedes" => nil, "superseded-by" => nil,
|
||||||
|
"affected-paths" => [], "affected-components" => [],
|
||||||
|
"migration_confidence" => confidence,
|
||||||
|
"migration_source" => unit.source_ref
|
||||||
|
},
|
||||||
|
title: unit.title,
|
||||||
|
sections: sections
|
||||||
|
)
|
||||||
|
path = @repository.write(record)
|
||||||
|
{ "file" => relative(path), "source" => unit.source_ref, "shape" => unit.shape.to_s,
|
||||||
|
"confidence" => confidence, "llm_fields" => llm_fields,
|
||||||
|
"source_material" => llm_fields.empty? ? nil : unit.body }.compact
|
||||||
|
end
|
||||||
|
|
||||||
|
def build_sections(extraction)
|
||||||
|
llm_fields = []
|
||||||
|
sections = {}
|
||||||
|
fallback_used = false
|
||||||
|
Record::SECTION_ORDER.each do |name|
|
||||||
|
found = extraction.section(name)
|
||||||
|
if found.nil? && (fallback = extraction.fallback_section(name))
|
||||||
|
found = fallback
|
||||||
|
fallback_used = true
|
||||||
|
end
|
||||||
|
sections[name] =
|
||||||
|
if found
|
||||||
|
found
|
||||||
|
elsif CORE_SECTIONS.include?(name)
|
||||||
|
NOT_STATED_MARKER
|
||||||
|
else
|
||||||
|
llm_fields << FILL_FIELDS.key(name)
|
||||||
|
PENDING_MARKER
|
||||||
|
end
|
||||||
|
end
|
||||||
|
[sections, llm_fields, fallback_used]
|
||||||
|
end
|
||||||
|
|
||||||
|
def confidence_for(sections, llm_fields, extraction, fallback_used)
|
||||||
|
return "low" if CORE_SECTIONS.any? { |name| sections[name] == NOT_STATED_MARKER }
|
||||||
|
return "medium" if llm_fields.any? || fallback_used || extraction.status.to_s.empty?
|
||||||
|
|
||||||
|
"high"
|
||||||
|
end
|
||||||
|
|
||||||
|
def apply_fill(record, field, value)
|
||||||
|
if field == "status"
|
||||||
|
record.frontmatter["status"] = value if record.status.to_s.empty?
|
||||||
|
elsif (section = FILL_FIELDS[field])
|
||||||
|
record.sections[section] = value if record.sections[section] == PENDING_MARKER
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def finalize!
|
||||||
|
Index.new(repository: @repository).regenerate!
|
||||||
|
MigrationReport.new(repository: @repository, detector: @detector).write!
|
||||||
|
end
|
||||||
|
|
||||||
|
def manifest(results, unrecognized)
|
||||||
|
{
|
||||||
|
"migrated" => results,
|
||||||
|
"unrecognized" => unrecognized.map { |u| { "source" => u.source_ref, "title" => u.title } },
|
||||||
|
"flag_rate" => MigrationReport.new(repository: @repository, detector: @detector).flag_rate
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
def relative(path)
|
||||||
|
path.sub(%r{\A#{Regexp.escape(@repository.root)}/?}, "")
|
||||||
|
end
|
||||||
|
|
||||||
|
# Mechanical field extraction from one unit's text: only structurally
|
||||||
|
# unambiguous values (labelled lines, exact headings, ISO dates).
|
||||||
|
class Extraction
|
||||||
|
def initialize(unit)
|
||||||
|
@unit = unit
|
||||||
|
@body = unit.body
|
||||||
|
end
|
||||||
|
|
||||||
|
def status
|
||||||
|
raw = labelled_value("Status") || heading_section("Status")&.lines&.first&.strip
|
||||||
|
return nil if raw.nil? || raw.empty?
|
||||||
|
|
||||||
|
STATUS_MAP.fetch(raw.split(/[\s(]/).first.downcase, raw)
|
||||||
|
end
|
||||||
|
|
||||||
|
def date
|
||||||
|
raw = labelled_value("Date") || labelled_value("Decided")
|
||||||
|
iso = raw&.[](/\d{4}-\d{2}-\d{2}/) ||
|
||||||
|
@unit.source_path[/\d{4}-\d{2}-\d{2}/]
|
||||||
|
iso && Date.parse(iso)
|
||||||
|
end
|
||||||
|
|
||||||
|
def section(target)
|
||||||
|
match_section(SECTION_SOURCES.fetch(target))
|
||||||
|
end
|
||||||
|
|
||||||
|
def fallback_section(target)
|
||||||
|
match_section(SECTION_FALLBACKS.fetch(target, []))
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def match_section(patterns)
|
||||||
|
patterns.each do |pattern|
|
||||||
|
headings.each do |name, content|
|
||||||
|
return content if name.match?(pattern) && !content.empty?
|
||||||
|
end
|
||||||
|
end
|
||||||
|
nil
|
||||||
|
end
|
||||||
|
|
||||||
|
def labelled_value(label)
|
||||||
|
@body[/^[-*]?\s*\**#{label}\**\s*[::]\s*\**([^\n*]+)/i, 1]&.strip
|
||||||
|
end
|
||||||
|
|
||||||
|
def heading_section(name)
|
||||||
|
headings.find { |heading, _| heading.match?(/\A#{name}\z/i) }&.last
|
||||||
|
end
|
||||||
|
|
||||||
|
def headings
|
||||||
|
@headings ||= heading_sections + bold_label_sections
|
||||||
|
end
|
||||||
|
|
||||||
|
def heading_sections
|
||||||
|
@body.scan(/^\#{2,4} +([^\n]+)\n(.*?)(?=^\#{2,4} |\z)/m)
|
||||||
|
.map { |name, content| [name.strip, strip_trailing_rule(content)] }
|
||||||
|
end
|
||||||
|
|
||||||
|
# "**Context.** text..." paragraphs (bold-label style, e.g. llf-schema):
|
||||||
|
# the label acts as a section heading; content runs to the next bold
|
||||||
|
# label, heading, or end of unit.
|
||||||
|
def bold_label_sections
|
||||||
|
@body.scan(/^\*\*([A-Za-z][^*\n]{0,40}?)[.:]?\*\*[.:]?\s*(.*?)(?=^\*\*[A-Za-z]|^\#{1,4} |\z)/m)
|
||||||
|
.map { |name, content| [name.strip, strip_trailing_rule(content)] }
|
||||||
|
.reject { |_, content| content.empty? }
|
||||||
|
end
|
||||||
|
|
||||||
|
def strip_trailing_rule(content)
|
||||||
|
content.strip.sub(/\n+---+\s*\z/, "").strip
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
@ -0,0 +1,82 @@
|
||||||
|
require "yaml"
|
||||||
|
require "date"
|
||||||
|
|
||||||
|
module Adr
|
||||||
|
# One ADR file: frontmatter + titled body sections. Parses and serializes;
|
||||||
|
# knows nothing about the filesystem.
|
||||||
|
class Record
|
||||||
|
ParseError = Class.new(StandardError)
|
||||||
|
|
||||||
|
FRONTMATTER_ORDER = %w[
|
||||||
|
id date status supersedes superseded-by
|
||||||
|
affected-paths affected-components migration_confidence
|
||||||
|
].freeze
|
||||||
|
QUOTED_FIELDS = %w[id supersedes superseded-by].freeze
|
||||||
|
SECTION_ORDER = ["Context", "Decision", "Consequences", "Alternatives rejected"].freeze
|
||||||
|
|
||||||
|
attr_reader :frontmatter, :title, :sections
|
||||||
|
|
||||||
|
def self.parse(text)
|
||||||
|
match = text.match(/\A---\n(.*?)\n---\n(.*)\z/m)
|
||||||
|
raise ParseError, "no frontmatter block found" unless match
|
||||||
|
|
||||||
|
frontmatter = YAML.safe_load(match[1], permitted_classes: [Date]) || {}
|
||||||
|
body = match[2]
|
||||||
|
heading = body[/^# +(.+)$/, 1].to_s.strip
|
||||||
|
title = heading.sub(/\A\S+\s+—\s+/, "")
|
||||||
|
sections = {}
|
||||||
|
body.scan(/^## ([^\n]+)\n(.*?)(?=^## |\z)/m) do |name, content|
|
||||||
|
sections[name.strip] = content.strip
|
||||||
|
end
|
||||||
|
new(frontmatter: frontmatter, title: title, sections: sections)
|
||||||
|
end
|
||||||
|
|
||||||
|
def initialize(frontmatter:, title:, sections:)
|
||||||
|
@frontmatter = frontmatter
|
||||||
|
@title = title
|
||||||
|
@sections = sections
|
||||||
|
end
|
||||||
|
|
||||||
|
def id = frontmatter["id"]&.to_s
|
||||||
|
def status = frontmatter["status"]
|
||||||
|
def date = frontmatter["date"]
|
||||||
|
|
||||||
|
def supersede_with(new_id)
|
||||||
|
frontmatter["superseded-by"] = new_id
|
||||||
|
frontmatter["status"] = "Superseded"
|
||||||
|
end
|
||||||
|
|
||||||
|
def to_markdown
|
||||||
|
lines = ["---"]
|
||||||
|
FRONTMATTER_ORDER.each do |field|
|
||||||
|
next unless frontmatter.key?(field)
|
||||||
|
lines << "#{field}: #{format_value(field, frontmatter[field])}".rstrip
|
||||||
|
end
|
||||||
|
(frontmatter.keys - FRONTMATTER_ORDER).each do |field|
|
||||||
|
lines << "#{field}: #{format_value(field, frontmatter[field])}".rstrip
|
||||||
|
end
|
||||||
|
lines << "---" << "" << "# #{id} — #{title}"
|
||||||
|
sections.each do |name, content|
|
||||||
|
lines << "" << "## #{name}" << "" << content
|
||||||
|
end
|
||||||
|
lines.join("\n") + "\n"
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def format_value(field, value)
|
||||||
|
case value
|
||||||
|
when nil then ""
|
||||||
|
when Array then "[#{value.join(', ')}]"
|
||||||
|
when Date then value.iso8601
|
||||||
|
else
|
||||||
|
text = value.to_s
|
||||||
|
if QUOTED_FIELDS.include?(field) || text.match?(/[:#]/)
|
||||||
|
text.inspect
|
||||||
|
else
|
||||||
|
text
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
@ -0,0 +1,55 @@
|
||||||
|
module Adr
|
||||||
|
# The docs/adr/ directory of one project. Scans files, derives the next ID
|
||||||
|
# from directory contents (never the index), reads and writes records.
|
||||||
|
class Repository
|
||||||
|
INDEX_FILENAME = "README.md"
|
||||||
|
|
||||||
|
Entry = Struct.new(:path, :record)
|
||||||
|
|
||||||
|
def initialize(root:)
|
||||||
|
@root = root
|
||||||
|
end
|
||||||
|
|
||||||
|
attr_reader :root
|
||||||
|
|
||||||
|
def dir = File.join(root, "docs", "adr")
|
||||||
|
def index_path = File.join(dir, INDEX_FILENAME)
|
||||||
|
|
||||||
|
def exists?
|
||||||
|
Dir.exist?(dir) && File.exist?(index_path)
|
||||||
|
end
|
||||||
|
|
||||||
|
def adr_paths
|
||||||
|
Dir.glob(File.join(dir, "[0-9][0-9][0-9][0-9]-*.md")).sort
|
||||||
|
end
|
||||||
|
|
||||||
|
# Only files in the os-adr format count as entries; legacy frontmatter-less
|
||||||
|
# files sharing docs/adr/ (pre-migration sources) are skipped, not errors.
|
||||||
|
def entries
|
||||||
|
adr_paths.filter_map do |path|
|
||||||
|
Entry.new(path, Record.parse(File.read(path)))
|
||||||
|
rescue Record::ParseError, Psych::SyntaxError
|
||||||
|
nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def next_id
|
||||||
|
max = adr_paths.map { |path| File.basename(path)[0, 4].to_i }.max || 0
|
||||||
|
format("%04d", max + 1)
|
||||||
|
end
|
||||||
|
|
||||||
|
def find(id)
|
||||||
|
entries.find { |entry| entry.record.id == id }
|
||||||
|
end
|
||||||
|
|
||||||
|
def write(record)
|
||||||
|
path = File.join(dir, "#{record.id}-#{Adr.slugify(record.title)}.md")
|
||||||
|
File.write(path, record.to_markdown)
|
||||||
|
path
|
||||||
|
end
|
||||||
|
|
||||||
|
def write_entry(entry)
|
||||||
|
File.write(entry.path, entry.record.to_markdown)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
@ -0,0 +1,25 @@
|
||||||
|
module Adr
|
||||||
|
# Renders templates/adr.md by substituting {{key}} placeholders.
|
||||||
|
# Arrays render as YAML flow sequences; missing keys render empty.
|
||||||
|
class Template
|
||||||
|
def initialize(path:)
|
||||||
|
@path = path
|
||||||
|
end
|
||||||
|
|
||||||
|
def render(values)
|
||||||
|
File.read(@path).gsub(/\{\{(\w+)\}\}/) do
|
||||||
|
format_value(values[Regexp.last_match(1).to_sym])
|
||||||
|
end.gsub(/ +$/, "")
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def format_value(value)
|
||||||
|
case value
|
||||||
|
when nil then ""
|
||||||
|
when Array then "[#{value.join(', ')}]"
|
||||||
|
else value.to_s
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
@ -0,0 +1,43 @@
|
||||||
|
---
|
||||||
|
name: create
|
||||||
|
description: Record an architecture decision as a correctly templated, numbered, and indexed ADR in one invocation. Use when a decision of real consequence (irreversible, cross-cutting, contested, or convention-setting) has just been made or is being made — including unprompted, when you recognize such a decision mid-task. Invoked by `/os-adr:create`.
|
||||||
|
---
|
||||||
|
|
||||||
|
Create a new ADR under `docs/adr/`. All mechanical work (ID assignment, templating, index
|
||||||
|
regeneration, supersession bookkeeping) is done by the plugin's CLI — never hand-author the
|
||||||
|
file, number, or index.
|
||||||
|
|
||||||
|
## 1. Gather the decision content
|
||||||
|
|
||||||
|
From the conversation (ask only for what you cannot infer):
|
||||||
|
|
||||||
|
- **title** — short, imperative or noun-phrase ("Use Postgres for persistence")
|
||||||
|
- **context** — the forces/problem that made a decision necessary
|
||||||
|
- **decision** — what was decided, stated actively
|
||||||
|
- **consequences** — what becomes easier/harder as a result
|
||||||
|
- **alternatives** — options considered and *why each was rejected* (highest-value field; push
|
||||||
|
for real reasons, not placeholders)
|
||||||
|
- **affected_paths** / **affected_components** — repo paths and component names this decision
|
||||||
|
governs (used for deterministic retrieval later; be concrete, e.g. `["src/db/", "config/database.yml"]`)
|
||||||
|
- **status** — default `Accepted`; use `Proposed` only if the user says it isn't settled
|
||||||
|
- **supersedes** — 4-digit ID, only if this replaces an existing ADR (check `docs/adr/README.md`)
|
||||||
|
|
||||||
|
If the project has no `docs/adr/` yet, run `/os-adr:init` first (or its CLI, step 2's
|
||||||
|
`adr-init`).
|
||||||
|
|
||||||
|
## 2. Invoke the CLI
|
||||||
|
|
||||||
|
```bash
|
||||||
|
echo '{"title": "...", "context": "...", "decision": "...", "consequences": "...",
|
||||||
|
"alternatives": "...", "affected_paths": ["..."], "affected_components": ["..."]}' \
|
||||||
|
| ruby ${CLAUDE_PLUGIN_ROOT}/bin/adr-new --root <project-root>
|
||||||
|
```
|
||||||
|
|
||||||
|
Optional JSON fields: `status`, `date` (ISO), `supersedes` ("NNNN"). The CLI prints the created
|
||||||
|
file path, sets `superseded-by` + `Superseded` status on any superseded ADR, and regenerates
|
||||||
|
`docs/adr/README.md`. Prefer writing the JSON to a temp file and piping it (`cat file | ...`)
|
||||||
|
when section bodies contain quotes.
|
||||||
|
|
||||||
|
## 3. Confirm
|
||||||
|
|
||||||
|
Show the user the created path and a one-line summary. Do not edit the generated index by hand.
|
||||||
|
|
@ -0,0 +1,37 @@
|
||||||
|
---
|
||||||
|
name: find
|
||||||
|
description: Retrieve the architecture decision records relevant to files, components, or a topic being worked on — callable at any point mid-task, not just at session start. Use unprompted before changing behavior that may already be decided (a locked choice, a convention, a rejected alternative). Invoked by `/os-adr:find`.
|
||||||
|
---
|
||||||
|
|
||||||
|
Find the ADRs relevant to what is being worked on. Retrieval is deterministic-first: run the
|
||||||
|
CLI, then apply judgment ONLY to the candidates it returns — never read/rank the full ADR
|
||||||
|
corpus yourself.
|
||||||
|
|
||||||
|
## 1. Run the deterministic CLI
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ruby ${CLAUDE_PLUGIN_ROOT}/bin/adr-find --root <project-root> \
|
||||||
|
--paths <comma-separated file/dir paths being edited> \
|
||||||
|
--components <comma-separated component names> # optional
|
||||||
|
```
|
||||||
|
|
||||||
|
The CLI layers: frontmatter path/component matching → Accepted-only status filter →
|
||||||
|
one-hop Graphify graph expansion of the query paths (automatic when `graphify-out/graph.json`
|
||||||
|
exists; silently skipped otherwise). Add `--all-statuses` only when history matters (e.g.
|
||||||
|
"why was X superseded?").
|
||||||
|
|
||||||
|
## 2. Judge the candidate set
|
||||||
|
|
||||||
|
Read the returned candidate files (they are few) and decide which actually bear on the task.
|
||||||
|
`matched_on` tells you why each surfaced (`path`, `graph`, `component`). Prefer direct path
|
||||||
|
matches; treat `graph` matches as "nearby decisions worth a look".
|
||||||
|
|
||||||
|
## 3. Act on what you find
|
||||||
|
|
||||||
|
- If the in-progress change **conflicts** with an Accepted ADR: stop, surface the specific ADR
|
||||||
|
(ID + title + the constraint), and either adjust the approach or propose superseding it via
|
||||||
|
`/os-adr:create` with `supersedes`.
|
||||||
|
- If a candidate merely informs the work, cite it and continue.
|
||||||
|
- If the CLI returns nothing, say so — do not fall back to sweeping `docs/adr/` unless the user
|
||||||
|
asks; an empty result usually means no decision governs those paths (or `affected-paths`
|
||||||
|
frontmatter is missing on older ADRs — worth mentioning once).
|
||||||
|
|
@ -0,0 +1,26 @@
|
||||||
|
---
|
||||||
|
name: init
|
||||||
|
description: Set up the ADR system (docs/adr/ + generated index) in a project that has none, and clear any session-reminder suppression. Invoked by `/os-adr:init`.
|
||||||
|
---
|
||||||
|
|
||||||
|
Initialize the standard ADR layout in the current project. Everything is mechanical — run the
|
||||||
|
CLI, don't create the files yourself:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ruby ${CLAUDE_PLUGIN_ROOT}/bin/adr-init --root <project-root>
|
||||||
|
```
|
||||||
|
|
||||||
|
This creates `docs/adr/` with a generated `README.md` index (empty table between markers,
|
||||||
|
regenerated in full on every ADR write — never hand-edited), and deletes `.os-adr/suppress` if
|
||||||
|
present so the SessionStart reminder resumes normal behavior.
|
||||||
|
|
||||||
|
It is idempotent: safe to run in a project that already has ADRs (the index is regenerated from
|
||||||
|
the directory contents).
|
||||||
|
|
||||||
|
Afterwards:
|
||||||
|
|
||||||
|
1. Ensure `.os-adr/` is gitignored (append to `.gitignore` if the project has one and the entry
|
||||||
|
is missing).
|
||||||
|
2. If the project has existing ADR-like content in another shape, suggest `/os-adr:migrate`
|
||||||
|
instead of re-authoring.
|
||||||
|
3. Offer `/os-adr:create` to record the first decision.
|
||||||
|
|
@ -0,0 +1,61 @@
|
||||||
|
---
|
||||||
|
name: migrate
|
||||||
|
description: Non-destructively convert a project's existing ADR-like content (numbered files, dated files, monolithic decision logs, prose-embedded decisions) into the standard docs/adr/ format. Sources are never touched; uncertainty is flagged via migration_confidence + a migration report. Invoked by `/os-adr:migrate`.
|
||||||
|
---
|
||||||
|
|
||||||
|
Migrate existing decision records into the standard ADR format. The mechanical work (detection,
|
||||||
|
splitting on unit headings, heuristic field fill, numbering, indexing, report generation) is all
|
||||||
|
done by the CLI; your job is ONLY to fill the interpretive fields the CLI's manifest explicitly
|
||||||
|
lists, and to present the results.
|
||||||
|
|
||||||
|
## 1. Detect (report-only)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ruby ${CLAUDE_PLUGIN_ROOT}/bin/adr-detect --root <project-root>
|
||||||
|
```
|
||||||
|
|
||||||
|
Show the user the detected units and shapes (`numbered_per_file`, `dated_single_file`,
|
||||||
|
`topic_single_file`, `monolithic`, `prose_embedded`, `unrecognized`). Confirm they want to
|
||||||
|
proceed before converting anything.
|
||||||
|
|
||||||
|
## 2. Mechanical pass
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ruby ${CLAUDE_PLUGIN_ROOT}/bin/adr-migrate --root <project-root>
|
||||||
|
```
|
||||||
|
|
||||||
|
This writes new ADRs under `docs/adr/` only (sources stay byte-identical), heuristic-fills
|
||||||
|
structurally unambiguous fields (status, date, title, ID, provenance, exactly-matching
|
||||||
|
sections), regenerates the index, writes `docs/adr/migration-report.md`, and prints a JSON
|
||||||
|
**work manifest**.
|
||||||
|
|
||||||
|
## 3. LLM fill — manifest-listed fields ONLY
|
||||||
|
|
||||||
|
For each manifest entry with non-empty `llm_fields`, write the listed fields (`consequences`,
|
||||||
|
`alternatives`, sometimes `status`) from that entry's `source_material` — summarize what the
|
||||||
|
source actually says, don't invent. Rules:
|
||||||
|
|
||||||
|
- Fill ONLY fields the manifest lists. Fields marked `_not stated in source_` (Decision,
|
||||||
|
Context) must NEVER be invented — leave them; the file stays flagged low-confidence.
|
||||||
|
- `status` fills must be one of Proposed/Accepted/Superseded/Deprecated.
|
||||||
|
|
||||||
|
Hand the results back to the core for writing — do not edit the ADR files directly:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# fills.json: {"docs/adr/0001-x.md": {"consequences": "...", "alternatives": "..."}}
|
||||||
|
ruby ${CLAUDE_PLUGIN_ROOT}/bin/adr-migrate --root <project-root> --apply-fills fills.json
|
||||||
|
```
|
||||||
|
|
||||||
|
(The core only accepts fills into pending-marker fields, so a stray key is a no-op.)
|
||||||
|
|
||||||
|
## 4. Present the report
|
||||||
|
|
||||||
|
Show `docs/adr/migration-report.md`: per-file confidence, source mapping, the low-confidence
|
||||||
|
flag rate, and any `unrecognized` sources needing manual handling. Suggest the user review
|
||||||
|
low-confidence files.
|
||||||
|
|
||||||
|
## 5. Old-system deletion — separate, explicit approval only
|
||||||
|
|
||||||
|
Do NOT delete, move, or edit the old decision files as part of migration. Only after the user
|
||||||
|
has reviewed the report, offer removal of the old system as a distinct step, and perform it only
|
||||||
|
on their explicit approval of the specific files to remove.
|
||||||
|
|
@ -0,0 +1,27 @@
|
||||||
|
---
|
||||||
|
id: "{{id}}"
|
||||||
|
date: {{date}}
|
||||||
|
status: {{status}}
|
||||||
|
supersedes: {{supersedes}}
|
||||||
|
superseded-by:
|
||||||
|
affected-paths: {{affected_paths}}
|
||||||
|
affected-components: {{affected_components}}
|
||||||
|
---
|
||||||
|
|
||||||
|
# {{id}} — {{title}}
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
{{context}}
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
{{decision}}
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
{{consequences}}
|
||||||
|
|
||||||
|
## Alternatives rejected
|
||||||
|
|
||||||
|
{{alternatives}}
|
||||||
|
|
@ -0,0 +1,2 @@
|
||||||
|
# Run the whole suite: ruby tests/all.rb
|
||||||
|
Dir.glob(File.join(__dir__, "*_test.rb")).sort.each { |f| require f }
|
||||||
|
|
@ -0,0 +1,89 @@
|
||||||
|
require_relative "test_helper"
|
||||||
|
require "open3"
|
||||||
|
require "json"
|
||||||
|
|
||||||
|
class CliTest < Minitest::Test
|
||||||
|
include AdrTestHelpers
|
||||||
|
|
||||||
|
ADR_NEW = File.join(PLUGIN_ROOT, "bin", "adr-new")
|
||||||
|
ADR_INIT = File.join(PLUGIN_ROOT, "bin", "adr-init")
|
||||||
|
|
||||||
|
def run_new(root, input)
|
||||||
|
out, err, status = Open3.capture3("ruby", ADR_NEW, "--root", root, stdin_data: JSON.generate(input))
|
||||||
|
[out.strip, err, status]
|
||||||
|
end
|
||||||
|
|
||||||
|
def run_init(root)
|
||||||
|
Open3.capture3("ruby", ADR_INIT, "--root", root)
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_init_creates_dir_and_index_and_clears_suppress
|
||||||
|
Dir.mktmpdir do |root|
|
||||||
|
FileUtils.mkdir_p(File.join(root, ".os-adr"))
|
||||||
|
File.write(File.join(root, ".os-adr", "suppress"), "")
|
||||||
|
_out, _err, status = run_init(root)
|
||||||
|
assert status.success?
|
||||||
|
repo = Adr::Repository.new(root: root)
|
||||||
|
assert repo.exists?
|
||||||
|
refute File.exist?(File.join(root, ".os-adr", "suppress"))
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_init_is_idempotent_and_preserves_existing_adrs
|
||||||
|
with_project("0001-a.md" => sample_adr) do |root|
|
||||||
|
_out, _err, status = run_init(root)
|
||||||
|
assert status.success?
|
||||||
|
assert_includes File.read(Adr::Repository.new(root: root).index_path), "0001"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_new_creates_templated_numbered_indexed_adr
|
||||||
|
with_project("0001-a.md" => sample_adr(id: "0001")) do |root|
|
||||||
|
out, err, status = run_new(root, title: "Use Postgres", context: "We need a DB.",
|
||||||
|
decision: "Postgres.", consequences: "Ops.",
|
||||||
|
alternatives: "SQLite.", affected_paths: ["db/"],
|
||||||
|
affected_components: ["storage"])
|
||||||
|
assert status.success?, err
|
||||||
|
assert out.end_with?("0002-use-postgres.md")
|
||||||
|
record = Adr::Record.parse(File.read(out))
|
||||||
|
assert_equal "0002", record.id
|
||||||
|
assert_equal "Accepted", record.status
|
||||||
|
assert_equal "Use Postgres", record.title
|
||||||
|
assert_equal ["db/"], record.frontmatter["affected-paths"]
|
||||||
|
Adr::Record::SECTION_ORDER.each { |s| assert record.sections.key?(s), "missing section #{s}" }
|
||||||
|
assert_includes File.read(Adr::Repository.new(root: root).index_path),
|
||||||
|
"[Use Postgres](0002-use-postgres.md)"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_new_supersession_is_mechanical
|
||||||
|
with_project("0004-old-way.md" => sample_adr(id: "0004", title: "Old way")) do |root|
|
||||||
|
out, err, status = run_new(root, title: "New way", supersedes: "0004")
|
||||||
|
assert status.success?, err
|
||||||
|
new_record = Adr::Record.parse(File.read(out))
|
||||||
|
assert_equal "0004", new_record.frontmatter["supersedes"]
|
||||||
|
old = Adr::Repository.new(root: root).find("0004")
|
||||||
|
assert_equal new_record.id, old.record.frontmatter["superseded-by"]
|
||||||
|
assert_equal "Superseded", old.record.status
|
||||||
|
index = File.read(Adr::Repository.new(root: root).index_path)
|
||||||
|
assert_includes index, "| Superseded |"
|
||||||
|
assert_includes index, "| Accepted |"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_new_requires_title
|
||||||
|
with_project do |root|
|
||||||
|
_out, err, status = run_new(root, {})
|
||||||
|
refute status.success?
|
||||||
|
assert_includes err, "title"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_new_fails_cleanly_without_init
|
||||||
|
Dir.mktmpdir do |root|
|
||||||
|
_out, err, status = run_new(root, title: "X")
|
||||||
|
refute status.success?
|
||||||
|
assert_includes err, "adr-init"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
@ -0,0 +1,175 @@
|
||||||
|
require_relative "test_helper"
|
||||||
|
|
||||||
|
class DetectorTest < Minitest::Test
|
||||||
|
include AdrTestHelpers
|
||||||
|
|
||||||
|
NUMBERED = <<~MD
|
||||||
|
# 0001 — PocketBase backend
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
Accepted
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
Need a backend.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
PocketBase.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
Single binary.
|
||||||
|
|
||||||
|
## Alternatives considered
|
||||||
|
|
||||||
|
Rails — too heavy.
|
||||||
|
MD
|
||||||
|
|
||||||
|
DATED = <<~MD
|
||||||
|
# JSX conversion pivot
|
||||||
|
|
||||||
|
**Date:** 2024-12-15
|
||||||
|
**Status:** Accepted
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
Conversion stalled.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
Pivot to JSX.
|
||||||
|
MD
|
||||||
|
|
||||||
|
TOPIC = <<~MD
|
||||||
|
# bin/lint-fix
|
||||||
|
|
||||||
|
Status: Confirmed
|
||||||
|
Decided: 2026-02-01
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
One lint entry point.
|
||||||
|
|
||||||
|
## Why
|
||||||
|
|
||||||
|
Consistency across repos.
|
||||||
|
MD
|
||||||
|
|
||||||
|
MONOLITHIC = <<~MD
|
||||||
|
# Decision log
|
||||||
|
|
||||||
|
## ADR-001: Use SQLite
|
||||||
|
|
||||||
|
Status: Accepted
|
||||||
|
|
||||||
|
### Context
|
||||||
|
|
||||||
|
Need storage.
|
||||||
|
|
||||||
|
### Decision
|
||||||
|
|
||||||
|
SQLite.
|
||||||
|
|
||||||
|
## ADR-002: Drop SQLite
|
||||||
|
|
||||||
|
Status: Accepted
|
||||||
|
|
||||||
|
### Decision
|
||||||
|
|
||||||
|
Postgres instead.
|
||||||
|
MD
|
||||||
|
|
||||||
|
PROSE = <<~MD
|
||||||
|
# Gaps and decisions (LOCKED)
|
||||||
|
|
||||||
|
Long narrative preamble.
|
||||||
|
|
||||||
|
Decision 26: Single entry point
|
||||||
|
|
||||||
|
We route everything through one command.
|
||||||
|
|
||||||
|
Decision 27: No config file
|
||||||
|
|
||||||
|
Defaults only.
|
||||||
|
MD
|
||||||
|
|
||||||
|
UNRECOGNIZED = "# Meeting notes\n\nWe talked about stuff.\n"
|
||||||
|
|
||||||
|
MULTI_STATUS_NO_HEADINGS = <<~MD
|
||||||
|
# DECISIONS
|
||||||
|
|
||||||
|
2026-01-05 — Hosting
|
||||||
|
Status: Confirmed
|
||||||
|
We host on X.
|
||||||
|
|
||||||
|
2026-02-10 — Pricing
|
||||||
|
Status: Deferred
|
||||||
|
Revisit in Q3.
|
||||||
|
MD
|
||||||
|
|
||||||
|
def fixture_project
|
||||||
|
Dir.mktmpdir do |root|
|
||||||
|
decisions = File.join(root, "docs", "decisions")
|
||||||
|
adr = File.join(root, "docs", "adr")
|
||||||
|
FileUtils.mkdir_p(decisions)
|
||||||
|
FileUtils.mkdir_p(adr)
|
||||||
|
File.write(File.join(adr, "0001-pocketbase-backend.md"), NUMBERED)
|
||||||
|
File.write(File.join(adr, "README.md"), "index")
|
||||||
|
File.write(File.join(decisions, "2024-12-15-jsx-pivot.md"), DATED)
|
||||||
|
File.write(File.join(decisions, "bin-lint-fix.md"), TOPIC)
|
||||||
|
File.write(File.join(decisions, "decision-log.md"), MONOLITHIC)
|
||||||
|
File.write(File.join(decisions, "gaps-and-decisions.md"), PROSE)
|
||||||
|
File.write(File.join(decisions, "notes.md"), UNRECOGNIZED)
|
||||||
|
File.write(File.join(root, "DECISIONS.md"), MULTI_STATUS_NO_HEADINGS)
|
||||||
|
yield root, Adr::Detector.new(root: root).units
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def shapes_by_source(units)
|
||||||
|
units.group_by(&:source_path).transform_values { |us| us.map(&:shape).uniq }
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_classifies_each_surveyed_shape
|
||||||
|
fixture_project do |_root, units|
|
||||||
|
shapes = shapes_by_source(units)
|
||||||
|
assert_equal [:numbered_per_file], shapes["docs/adr/0001-pocketbase-backend.md"]
|
||||||
|
assert_equal [:dated_single_file], shapes["docs/decisions/2024-12-15-jsx-pivot.md"]
|
||||||
|
assert_equal [:topic_single_file], shapes["docs/decisions/bin-lint-fix.md"]
|
||||||
|
assert_equal [:monolithic], shapes["docs/decisions/decision-log.md"]
|
||||||
|
assert_equal [:prose_embedded], shapes["docs/decisions/gaps-and-decisions.md"]
|
||||||
|
assert_equal [:unrecognized], shapes["docs/decisions/notes.md"]
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_monolithic_units_are_enumerated_per_heading
|
||||||
|
fixture_project do |_root, units|
|
||||||
|
mono = units.select { |u| u.source_path == "docs/decisions/decision-log.md" }
|
||||||
|
assert_equal 2, mono.size
|
||||||
|
assert_equal ["Use SQLite", "Drop SQLite"], mono.map(&:title)
|
||||||
|
assert_includes mono.first.body, "SQLite."
|
||||||
|
refute_includes mono.first.body, "Postgres"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_prose_embedded_units_are_enumerated
|
||||||
|
fixture_project do |_root, units|
|
||||||
|
prose = units.select { |u| u.source_path == "docs/decisions/gaps-and-decisions.md" }
|
||||||
|
assert_equal ["Single entry point", "No config file"], prose.map(&:title)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_multi_decision_file_without_unit_headings_is_unrecognized
|
||||||
|
fixture_project do |_root, units|
|
||||||
|
assert_equal [:unrecognized], shapes_by_source(units)["DECISIONS.md"]
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_index_and_already_migrated_files_are_excluded
|
||||||
|
with_project("0001-a.md" => sample_adr) do |root|
|
||||||
|
File.write(File.join(root, "docs", "adr", "README.md"), "index")
|
||||||
|
assert_empty Adr::Detector.new(root: root).units
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
@ -0,0 +1,133 @@
|
||||||
|
require_relative "test_helper"
|
||||||
|
require "json"
|
||||||
|
|
||||||
|
class FinderTest < Minitest::Test
|
||||||
|
include AdrTestHelpers
|
||||||
|
|
||||||
|
def adr(id:, title:, status: "Accepted", paths: [], components: [])
|
||||||
|
<<~MD
|
||||||
|
---
|
||||||
|
id: "#{id}"
|
||||||
|
date: 2026-07-03
|
||||||
|
status: #{status}
|
||||||
|
supersedes:
|
||||||
|
superseded-by:
|
||||||
|
affected-paths: [#{paths.join(', ')}]
|
||||||
|
affected-components: [#{components.join(', ')}]
|
||||||
|
---
|
||||||
|
|
||||||
|
# #{id} — #{title}
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
c
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
d
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
q
|
||||||
|
|
||||||
|
## Alternatives rejected
|
||||||
|
|
||||||
|
a
|
||||||
|
MD
|
||||||
|
end
|
||||||
|
|
||||||
|
def storage_project(&block)
|
||||||
|
with_project(
|
||||||
|
"0001-storage.md" => adr(id: "0001", title: "Storage", paths: %w[lib/storage db/],
|
||||||
|
components: %w[storage]),
|
||||||
|
"0002-old-storage.md" => adr(id: "0002", title: "Old storage", status: "Superseded",
|
||||||
|
paths: %w[lib/storage]),
|
||||||
|
"0003-auth.md" => adr(id: "0003", title: "Auth", paths: %w[lib/auth],
|
||||||
|
components: %w[auth]),
|
||||||
|
&block
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
def finder(root, graph: nil)
|
||||||
|
Adr::Finder.new(repository: Adr::Repository.new(root: root),
|
||||||
|
graph_path: graph || File.join(root, "graphify-out", "graph.json"))
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_path_prefix_match_narrows
|
||||||
|
storage_project do |root|
|
||||||
|
results = finder(root).find(paths: ["lib/storage/adapter.rb"])
|
||||||
|
assert_equal ["0001"], results.map { |r| r["id"] }
|
||||||
|
assert_equal ["path"], results.first["matched_on"]
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_directory_query_matches_file_scoped_adr
|
||||||
|
storage_project do |root|
|
||||||
|
results = finder(root).find(paths: ["db"])
|
||||||
|
assert_equal ["0001"], results.map { |r| r["id"] }
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_component_match
|
||||||
|
storage_project do |root|
|
||||||
|
results = finder(root).find(components: ["auth"])
|
||||||
|
assert_equal ["0003"], results.map { |r| r["id"] }
|
||||||
|
assert_equal ["component"], results.first["matched_on"]
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_accepted_only_by_default_with_override
|
||||||
|
storage_project do |root|
|
||||||
|
default = finder(root).find(paths: ["lib/storage"])
|
||||||
|
assert_equal ["0001"], default.map { |r| r["id"] }
|
||||||
|
all = finder(root).find(paths: ["lib/storage"], all_statuses: true)
|
||||||
|
assert_equal %w[0001 0002], all.map { |r| r["id"] }.sort
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_no_match_returns_empty
|
||||||
|
storage_project do |root|
|
||||||
|
assert_empty finder(root).find(paths: ["src/ui"])
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
GRAPH = {
|
||||||
|
"nodes" => [
|
||||||
|
{ "id" => "n1", "source_file" => "src/api/handler.rb" },
|
||||||
|
{ "id" => "n2", "source_file" => "lib/storage/adapter.rb" },
|
||||||
|
{ "id" => "n3", "source_file" => "lib/auth/session.rb" }
|
||||||
|
],
|
||||||
|
"links" => [
|
||||||
|
{ "source" => "n1", "target" => "n2" }
|
||||||
|
]
|
||||||
|
}.freeze
|
||||||
|
|
||||||
|
def test_graph_layer_expands_one_hop
|
||||||
|
storage_project do |root|
|
||||||
|
graph_dir = File.join(root, "graphify-out")
|
||||||
|
FileUtils.mkdir_p(graph_dir)
|
||||||
|
File.write(File.join(graph_dir, "graph.json"), JSON.generate(GRAPH))
|
||||||
|
# src/api/handler.rb is in no ADR's affected-paths, but is one hop from
|
||||||
|
# lib/storage/adapter.rb which 0001 covers.
|
||||||
|
results = finder(root).find(paths: ["src/api/handler.rb"])
|
||||||
|
assert_equal ["0001"], results.map { |r| r["id"] }
|
||||||
|
assert_equal ["graph"], results.first["matched_on"]
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_degrades_gracefully_without_graph
|
||||||
|
storage_project do |root|
|
||||||
|
assert_empty finder(root).find(paths: ["src/api/handler.rb"])
|
||||||
|
assert_equal ["0001"], finder(root).find(paths: ["lib/storage"]).map { |r| r["id"] }
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_degrades_gracefully_on_corrupt_graph
|
||||||
|
storage_project do |root|
|
||||||
|
graph_dir = File.join(root, "graphify-out")
|
||||||
|
FileUtils.mkdir_p(graph_dir)
|
||||||
|
File.write(File.join(graph_dir, "graph.json"), "{not json")
|
||||||
|
assert_equal ["0001"], finder(root).find(paths: ["lib/storage"]).map { |r| r["id"] }
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
@ -0,0 +1,160 @@
|
||||||
|
"""Tests for hooks/session_start.py. Run: python3 tests/hook_test.py"""
|
||||||
|
|
||||||
|
import io
|
||||||
|
import json
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import unittest
|
||||||
|
from datetime import date
|
||||||
|
from pathlib import Path
|
||||||
|
from tempfile import TemporaryDirectory
|
||||||
|
|
||||||
|
PLUGIN_ROOT = Path(__file__).resolve().parent.parent
|
||||||
|
sys.path.insert(0, str(PLUGIN_ROOT / "hooks"))
|
||||||
|
|
||||||
|
from session_start import ( # noqa: E402
|
||||||
|
ABSENT_NOTE,
|
||||||
|
PRESENT_NOTE,
|
||||||
|
StateDir,
|
||||||
|
adr_system_present,
|
||||||
|
decide,
|
||||||
|
find_project_root,
|
||||||
|
run,
|
||||||
|
)
|
||||||
|
|
||||||
|
TODAY = date(2026, 7, 3)
|
||||||
|
YESTERDAY = date(2026, 7, 2)
|
||||||
|
|
||||||
|
|
||||||
|
class DecideTest(unittest.TestCase):
|
||||||
|
def test_present_injects_context_naming_both_surfaces(self):
|
||||||
|
outcome, note = decide(True, False, None, TODAY)
|
||||||
|
self.assertEqual("context", outcome)
|
||||||
|
self.assertIn("/os-adr:create", note)
|
||||||
|
self.assertIn("/os-adr:find", note)
|
||||||
|
|
||||||
|
def test_present_wins_even_if_suppressed(self):
|
||||||
|
self.assertEqual("context", decide(True, True, None, TODAY)[0])
|
||||||
|
|
||||||
|
def test_absent_unsuppressed_banners_with_init_and_migrate(self):
|
||||||
|
outcome, note = decide(False, False, None, TODAY)
|
||||||
|
self.assertEqual("banner", outcome)
|
||||||
|
self.assertIn("/os-adr:init", note)
|
||||||
|
self.assertIn("/os-adr:migrate", note)
|
||||||
|
|
||||||
|
def test_absent_suppressed_is_silent(self):
|
||||||
|
self.assertEqual(("silent", None), decide(False, True, None, TODAY))
|
||||||
|
|
||||||
|
def test_absent_reminded_today_is_silent(self):
|
||||||
|
self.assertEqual(("silent", None), decide(False, False, TODAY, TODAY))
|
||||||
|
|
||||||
|
def test_absent_reminded_yesterday_banners_again(self):
|
||||||
|
self.assertEqual("banner", decide(False, False, YESTERDAY, TODAY)[0])
|
||||||
|
|
||||||
|
|
||||||
|
class RunTest(unittest.TestCase):
|
||||||
|
def project(self, tmp, with_adr=False):
|
||||||
|
root = Path(tmp)
|
||||||
|
(root / ".git").mkdir()
|
||||||
|
if with_adr:
|
||||||
|
(root / "docs" / "adr").mkdir(parents=True)
|
||||||
|
(root / "docs" / "adr" / "README.md").write_text("index")
|
||||||
|
return root
|
||||||
|
|
||||||
|
def test_present_emits_additional_context(self):
|
||||||
|
with TemporaryDirectory() as tmp:
|
||||||
|
root = self.project(tmp, with_adr=True)
|
||||||
|
out = io.StringIO()
|
||||||
|
self.assertEqual("context", run(root, TODAY, out))
|
||||||
|
payload = json.loads(out.getvalue())
|
||||||
|
self.assertEqual(
|
||||||
|
PRESENT_NOTE,
|
||||||
|
payload["hookSpecificOutput"]["additionalContext"],
|
||||||
|
)
|
||||||
|
self.assertFalse((root / ".os-adr").exists(), "present case must not create state")
|
||||||
|
|
||||||
|
def test_absent_emits_banner_and_stamps_snooze(self):
|
||||||
|
with TemporaryDirectory() as tmp:
|
||||||
|
root = self.project(tmp)
|
||||||
|
out = io.StringIO()
|
||||||
|
self.assertEqual("banner", run(root, TODAY, out))
|
||||||
|
self.assertEqual(ABSENT_NOTE, json.loads(out.getvalue())["systemMessage"])
|
||||||
|
self.assertEqual(
|
||||||
|
TODAY.isoformat(), (root / ".os-adr" / "last_reminded").read_text()
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_second_run_same_day_is_silent(self):
|
||||||
|
with TemporaryDirectory() as tmp:
|
||||||
|
root = self.project(tmp)
|
||||||
|
run(root, TODAY, io.StringIO())
|
||||||
|
out = io.StringIO()
|
||||||
|
self.assertEqual("silent", run(root, TODAY, out))
|
||||||
|
self.assertEqual("", out.getvalue())
|
||||||
|
|
||||||
|
def test_next_day_banners_again(self):
|
||||||
|
with TemporaryDirectory() as tmp:
|
||||||
|
root = self.project(tmp)
|
||||||
|
run(root, YESTERDAY, io.StringIO())
|
||||||
|
self.assertEqual("banner", run(root, TODAY, io.StringIO()))
|
||||||
|
|
||||||
|
def test_suppressed_project_stays_silent(self):
|
||||||
|
with TemporaryDirectory() as tmp:
|
||||||
|
root = self.project(tmp)
|
||||||
|
(root / ".os-adr").mkdir()
|
||||||
|
(root / ".os-adr" / "suppress").write_text("")
|
||||||
|
out = io.StringIO()
|
||||||
|
self.assertEqual("silent", run(root, TODAY, out))
|
||||||
|
self.assertEqual("", out.getvalue())
|
||||||
|
|
||||||
|
def test_corrupt_snooze_stamp_is_treated_as_unset(self):
|
||||||
|
with TemporaryDirectory() as tmp:
|
||||||
|
root = self.project(tmp)
|
||||||
|
(root / ".os-adr").mkdir()
|
||||||
|
(root / ".os-adr" / "last_reminded").write_text("not-a-date")
|
||||||
|
self.assertEqual("banner", run(root, TODAY, io.StringIO()))
|
||||||
|
|
||||||
|
|
||||||
|
class ProjectRootTest(unittest.TestCase):
|
||||||
|
def test_walks_up_to_git_root(self):
|
||||||
|
with TemporaryDirectory() as tmp:
|
||||||
|
root = Path(tmp)
|
||||||
|
(root / ".git").mkdir()
|
||||||
|
nested = root / "a" / "b"
|
||||||
|
nested.mkdir(parents=True)
|
||||||
|
self.assertEqual(root, find_project_root(nested))
|
||||||
|
|
||||||
|
def test_none_outside_any_git_project(self):
|
||||||
|
with TemporaryDirectory() as tmp:
|
||||||
|
self.assertIsNone(find_project_root(Path(tmp)))
|
||||||
|
|
||||||
|
|
||||||
|
class PythonRubyAgreementTest(unittest.TestCase):
|
||||||
|
"""Shared fixture tree: the Python existence check and Ruby
|
||||||
|
Adr::Repository#exists? must give the same answer (design D3 risk)."""
|
||||||
|
|
||||||
|
def ruby_exists(self, root):
|
||||||
|
script = (
|
||||||
|
f'require "{PLUGIN_ROOT}/lib/adr"; '
|
||||||
|
f'puts Adr::Repository.new(root: {str(root)!r}).exists?'
|
||||||
|
)
|
||||||
|
out = subprocess.run(
|
||||||
|
["ruby", "-e", script], capture_output=True, text=True, check=True
|
||||||
|
)
|
||||||
|
return out.stdout.strip() == "true"
|
||||||
|
|
||||||
|
def assert_agreement(self, root, expected):
|
||||||
|
self.assertEqual(expected, adr_system_present(root), "python check")
|
||||||
|
self.assertEqual(expected, self.ruby_exists(root), "ruby check")
|
||||||
|
|
||||||
|
def test_agreement_across_fixture_states(self):
|
||||||
|
with TemporaryDirectory() as tmp:
|
||||||
|
root = Path(tmp)
|
||||||
|
self.assert_agreement(root, False) # nothing
|
||||||
|
(root / "docs" / "adr").mkdir(parents=True)
|
||||||
|
self.assert_agreement(root, False) # dir, no index
|
||||||
|
(root / "docs" / "adr" / "README.md").write_text("index")
|
||||||
|
self.assert_agreement(root, True) # dir + index
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
|
|
@ -0,0 +1,79 @@
|
||||||
|
require_relative "test_helper"
|
||||||
|
|
||||||
|
class IndexTest < Minitest::Test
|
||||||
|
include AdrTestHelpers
|
||||||
|
|
||||||
|
GOLDEN = <<~MD
|
||||||
|
<!-- Generated by os-adr. Do not hand-edit the table: it is regenerated in full on every ADR write. -->
|
||||||
|
|
||||||
|
# Architecture Decision Records
|
||||||
|
|
||||||
|
One file per decision, `NNNN-kebab-title.md`, created via `/os-adr:create`.
|
||||||
|
|
||||||
|
<!-- adr-index:begin -->
|
||||||
|
| ID | Title | Status | Date |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| 0001 | [Use Postgres](0001-use-postgres.md) | Accepted | 2026-07-03 |
|
||||||
|
| 0002 | [Use Redis](0002-use-redis.md) | Accepted | 2026-07-03 |
|
||||||
|
<!-- adr-index:end -->
|
||||||
|
MD
|
||||||
|
|
||||||
|
def two_adr_project(&block)
|
||||||
|
with_project("0001-use-postgres.md" => sample_adr(id: "0001", title: "Use Postgres"),
|
||||||
|
"0002-use-redis.md" => sample_adr(id: "0002", title: "Use Redis"), &block)
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_regenerates_full_table_matching_golden
|
||||||
|
two_adr_project do |root|
|
||||||
|
repo = Adr::Repository.new(root: root)
|
||||||
|
Adr::Index.new(repository: repo).regenerate!
|
||||||
|
assert_equal GOLDEN, File.read(repo.index_path)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_self_heals_after_manual_table_edit
|
||||||
|
two_adr_project do |root|
|
||||||
|
repo = Adr::Repository.new(root: root)
|
||||||
|
index = Adr::Index.new(repository: repo)
|
||||||
|
index.regenerate!
|
||||||
|
vandalized = File.read(repo.index_path)
|
||||||
|
.sub("| 0001 | [Use Postgres](0001-use-postgres.md) | Accepted | 2026-07-03 |\n", "")
|
||||||
|
.sub("Use Redis", "Hand-Edited Title")
|
||||||
|
File.write(repo.index_path, vandalized)
|
||||||
|
index.regenerate!
|
||||||
|
assert_equal GOLDEN, File.read(repo.index_path)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_preserves_prose_outside_markers
|
||||||
|
two_adr_project do |root|
|
||||||
|
repo = Adr::Repository.new(root: root)
|
||||||
|
index = Adr::Index.new(repository: repo)
|
||||||
|
index.regenerate!
|
||||||
|
File.write(repo.index_path, "Custom preamble.\n\n" + File.read(repo.index_path) + "\nCustom footer.\n")
|
||||||
|
index.regenerate!
|
||||||
|
content = File.read(repo.index_path)
|
||||||
|
assert_includes content, "Custom preamble."
|
||||||
|
assert_includes content, "Custom footer."
|
||||||
|
assert_includes content, "| 0001 | [Use Postgres](0001-use-postgres.md) | Accepted | 2026-07-03 |"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_empty_directory_yields_empty_table
|
||||||
|
with_project do |root|
|
||||||
|
repo = Adr::Repository.new(root: root)
|
||||||
|
Adr::Index.new(repository: repo).regenerate!
|
||||||
|
content = File.read(repo.index_path)
|
||||||
|
assert_includes content, Adr::Index::BEGIN_MARKER
|
||||||
|
refute_includes content, "| ID |"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_index_declares_itself_generated
|
||||||
|
with_project do |root|
|
||||||
|
repo = Adr::Repository.new(root: root)
|
||||||
|
Adr::Index.new(repository: repo).regenerate!
|
||||||
|
assert_match(/do not hand-edit/i, File.read(repo.index_path))
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
@ -0,0 +1,208 @@
|
||||||
|
require_relative "test_helper"
|
||||||
|
require "digest"
|
||||||
|
|
||||||
|
class MigratorTest < Minitest::Test
|
||||||
|
include AdrTestHelpers
|
||||||
|
|
||||||
|
FULL_SOURCE = <<~MD
|
||||||
|
# 0001 — PocketBase backend
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
Accepted
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
Need a backend.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
PocketBase.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
Single binary.
|
||||||
|
|
||||||
|
## Alternatives considered
|
||||||
|
|
||||||
|
Rails — too heavy.
|
||||||
|
MD
|
||||||
|
|
||||||
|
PARTIAL_SOURCE = <<~MD
|
||||||
|
# bin/lint-fix
|
||||||
|
|
||||||
|
Status: Confirmed
|
||||||
|
Decided: 2026-02-01
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
Lint config drifted per repo.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
One lint entry point.
|
||||||
|
|
||||||
|
## Why
|
||||||
|
|
||||||
|
Consistency across repos.
|
||||||
|
MD
|
||||||
|
|
||||||
|
BARE_SOURCE = <<~MD
|
||||||
|
# Something happened
|
||||||
|
|
||||||
|
Status: Accepted
|
||||||
|
|
||||||
|
We did a thing, narratively.
|
||||||
|
MD
|
||||||
|
|
||||||
|
def project(sources)
|
||||||
|
Dir.mktmpdir do |root|
|
||||||
|
decisions = File.join(root, "docs", "decisions")
|
||||||
|
FileUtils.mkdir_p(decisions)
|
||||||
|
sources.each { |name, body| File.write(File.join(decisions, name), body) }
|
||||||
|
repo = Adr::Repository.new(root: root)
|
||||||
|
migrator = Adr::Migrator.new(repository: repo, detector: Adr::Detector.new(root: root))
|
||||||
|
yield root, repo, migrator
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_sources_stay_byte_identical
|
||||||
|
project("0001-full.md" => FULL_SOURCE, "partial.md" => PARTIAL_SOURCE) do |root, _repo, migrator|
|
||||||
|
before = Dir.glob(File.join(root, "docs", "decisions", "*"))
|
||||||
|
.to_h { |p| [p, Digest::SHA256.file(p).hexdigest] }
|
||||||
|
migrator.migrate!
|
||||||
|
before.each { |path, sha| assert_equal sha, Digest::SHA256.file(path).hexdigest }
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_unambiguous_fields_fill_mechanically_high_confidence
|
||||||
|
project("0001-full.md" => FULL_SOURCE) do |_root, repo, migrator|
|
||||||
|
manifest = migrator.migrate!
|
||||||
|
entry = repo.entries.first
|
||||||
|
record = entry.record
|
||||||
|
assert_equal "Accepted", record.status
|
||||||
|
assert_equal "PocketBase backend", record.title
|
||||||
|
assert_equal "Need a backend.", record.sections["Context"]
|
||||||
|
assert_equal "Rails — too heavy.", record.sections["Alternatives rejected"]
|
||||||
|
assert_equal "high", record.frontmatter["migration_confidence"]
|
||||||
|
assert_equal "docs/decisions/0001-full.md", record.frontmatter["migration_source"]
|
||||||
|
assert_empty manifest["migrated"].first["llm_fields"]
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_interpretive_fields_get_pending_markers_and_manifest
|
||||||
|
project("partial.md" => PARTIAL_SOURCE) do |_root, repo, migrator|
|
||||||
|
manifest = migrator.migrate!
|
||||||
|
record = repo.entries.first.record
|
||||||
|
assert_equal "Accepted", record.status, "Confirmed maps mechanically to Accepted"
|
||||||
|
assert_equal Date.new(2026, 2, 1), record.date
|
||||||
|
assert_equal Adr::Migrator::PENDING_MARKER, record.sections["Consequences"]
|
||||||
|
assert_equal Adr::Migrator::PENDING_MARKER, record.sections["Alternatives rejected"]
|
||||||
|
assert_equal "medium", record.frontmatter["migration_confidence"]
|
||||||
|
entry = manifest["migrated"].first
|
||||||
|
assert_equal %w[consequences alternatives], entry["llm_fields"]
|
||||||
|
assert_includes entry["source_material"], "Consistency across repos."
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_fallback_heading_mapping_caps_confidence_at_medium
|
||||||
|
source = <<~MD
|
||||||
|
# Topic
|
||||||
|
|
||||||
|
Status: Accepted
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
Do the thing.
|
||||||
|
|
||||||
|
## Why
|
||||||
|
|
||||||
|
Because reasons.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
Some.
|
||||||
|
|
||||||
|
## Alternatives rejected
|
||||||
|
|
||||||
|
None seriously.
|
||||||
|
MD
|
||||||
|
project("topic.md" => source) do |_root, repo, migrator|
|
||||||
|
migrator.migrate!
|
||||||
|
record = repo.entries.first.record
|
||||||
|
assert_equal "Because reasons.", record.sections["Context"], "Why maps to Context"
|
||||||
|
assert_equal "medium", record.frontmatter["migration_confidence"],
|
||||||
|
"heuristic heading mapping is flagged"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_missing_core_fields_get_not_stated_markers_and_low_confidence
|
||||||
|
project("bare.md" => BARE_SOURCE) do |_root, repo, migrator|
|
||||||
|
migrator.migrate!
|
||||||
|
record = repo.entries.first.record
|
||||||
|
assert_equal Adr::Migrator::NOT_STATED_MARKER, record.sections["Decision"]
|
||||||
|
assert_equal Adr::Migrator::NOT_STATED_MARKER, record.sections["Context"]
|
||||||
|
assert_equal "low", record.frontmatter["migration_confidence"]
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_apply_fills_only_touches_pending_fields
|
||||||
|
project("partial.md" => PARTIAL_SOURCE, "bare.md" => BARE_SOURCE) do |root, repo, migrator|
|
||||||
|
manifest = migrator.migrate!
|
||||||
|
pending_file = manifest["migrated"].find { |m| m["source"].include?("partial") }["file"]
|
||||||
|
bare_file = manifest["migrated"].find { |m| m["source"].include?("bare") }["file"]
|
||||||
|
|
||||||
|
migrator.apply_fills!(
|
||||||
|
pending_file => { "consequences" => "Fewer lint debates.",
|
||||||
|
"alternatives" => "Per-repo configs — rejected, drift." },
|
||||||
|
bare_file => { "decision" => "INVENTED", "context" => "INVENTED" }
|
||||||
|
)
|
||||||
|
|
||||||
|
filled = Adr::Record.parse(File.read(File.join(root, pending_file)))
|
||||||
|
assert_equal "Fewer lint debates.", filled.sections["Consequences"]
|
||||||
|
assert_equal "medium", filled.frontmatter["migration_confidence"], "LLM-filled stays flagged"
|
||||||
|
|
||||||
|
bare = Adr::Record.parse(File.read(File.join(root, bare_file)))
|
||||||
|
assert_equal Adr::Migrator::NOT_STATED_MARKER, bare.sections["Decision"],
|
||||||
|
"core fields are never LLM-inventable"
|
||||||
|
assert_equal "low", bare.frontmatter["migration_confidence"]
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_report_lists_files_confidence_sources_and_flag_rate
|
||||||
|
project("0001-full.md" => FULL_SOURCE, "bare.md" => BARE_SOURCE) do |root, _repo, migrator|
|
||||||
|
manifest = migrator.migrate!
|
||||||
|
report = File.read(File.join(root, "docs", "adr", "migration-report.md"))
|
||||||
|
assert_includes report, "docs/decisions/0001-full.md"
|
||||||
|
assert_includes report, "| low |"
|
||||||
|
assert_includes report, "| high |"
|
||||||
|
assert_includes report, "flag rate: 50.0%"
|
||||||
|
assert_equal 0.5, manifest["flag_rate"]
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_unrecognized_content_is_reported_never_converted
|
||||||
|
project("notes.md" => "# Notes\n\nchatter\n") do |root, repo, migrator|
|
||||||
|
manifest = migrator.migrate!
|
||||||
|
assert_empty repo.entries
|
||||||
|
assert_equal "docs/decisions/notes.md", manifest["unrecognized"].first["source"]
|
||||||
|
assert_includes File.read(File.join(root, "docs", "adr", "migration-report.md")),
|
||||||
|
"docs/decisions/notes.md"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_migration_is_idempotent_across_reruns
|
||||||
|
project("0001-full.md" => FULL_SOURCE) do |_root, repo, migrator|
|
||||||
|
migrator.migrate!
|
||||||
|
migrator.migrate!
|
||||||
|
assert_equal 1, repo.entries.size
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_index_is_regenerated_with_migrated_adrs
|
||||||
|
project("0001-full.md" => FULL_SOURCE) do |_root, repo, migrator|
|
||||||
|
migrator.migrate!
|
||||||
|
assert_includes File.read(repo.index_path), "PocketBase backend"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
@ -0,0 +1,49 @@
|
||||||
|
require_relative "test_helper"
|
||||||
|
|
||||||
|
class RecordTest < Minitest::Test
|
||||||
|
include AdrTestHelpers
|
||||||
|
|
||||||
|
def test_parses_frontmatter_and_sections
|
||||||
|
record = Adr::Record.parse(sample_adr)
|
||||||
|
assert_equal "0001", record.id
|
||||||
|
assert_equal "Accepted", record.status
|
||||||
|
assert_equal Date.new(2026, 7, 3), record.date
|
||||||
|
assert_equal "Use Postgres", record.title
|
||||||
|
assert_equal ["db/", "lib/storage"], record.frontmatter["affected-paths"]
|
||||||
|
assert_equal "We need a database.", record.sections["Context"]
|
||||||
|
assert_equal "SQLite — no concurrent writers.", record.sections["Alternatives rejected"]
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_id_with_leading_zeros_stays_a_string
|
||||||
|
record = Adr::Record.parse(sample_adr(id: "0007"))
|
||||||
|
assert_equal "0007", record.id
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_round_trip_preserves_content
|
||||||
|
record = Adr::Record.parse(sample_adr)
|
||||||
|
reparsed = Adr::Record.parse(record.to_markdown)
|
||||||
|
assert_equal record.frontmatter, reparsed.frontmatter
|
||||||
|
assert_equal record.title, reparsed.title
|
||||||
|
assert_equal record.sections, reparsed.sections
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_supersede_with_sets_both_fields
|
||||||
|
record = Adr::Record.parse(sample_adr)
|
||||||
|
record.supersede_with("0009")
|
||||||
|
assert_equal "0009", record.frontmatter["superseded-by"]
|
||||||
|
assert_equal "Superseded", record.status
|
||||||
|
reparsed = Adr::Record.parse(record.to_markdown)
|
||||||
|
assert_equal "0009", reparsed.frontmatter["superseded-by"]
|
||||||
|
assert_equal "Superseded", reparsed.status
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_raises_on_missing_frontmatter
|
||||||
|
assert_raises(Adr::Record::ParseError) { Adr::Record.parse("# just a heading\n") }
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_preserves_unknown_frontmatter_fields
|
||||||
|
record = Adr::Record.parse(sample_adr(extra_frontmatter: "migration_confidence: low\n"))
|
||||||
|
assert_equal "low", record.frontmatter["migration_confidence"]
|
||||||
|
assert_includes record.to_markdown, "migration_confidence: low"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
@ -0,0 +1,62 @@
|
||||||
|
require_relative "test_helper"
|
||||||
|
|
||||||
|
class RepositoryTest < Minitest::Test
|
||||||
|
include AdrTestHelpers
|
||||||
|
|
||||||
|
def test_next_id_on_empty_dir_is_0001
|
||||||
|
with_project do |root|
|
||||||
|
assert_equal "0001", Adr::Repository.new(root: root).next_id
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_next_id_is_max_plus_one
|
||||||
|
with_project("0001-a.md" => sample_adr(id: "0001"),
|
||||||
|
"0002-b.md" => sample_adr(id: "0002")) do |root|
|
||||||
|
assert_equal "0003", Adr::Repository.new(root: root).next_id
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_next_id_ignores_missing_index
|
||||||
|
with_project("0007-x.md" => sample_adr(id: "0007")) do |root|
|
||||||
|
repo = Adr::Repository.new(root: root)
|
||||||
|
refute File.exist?(repo.index_path)
|
||||||
|
assert_equal "0008", repo.next_id
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_next_id_ignores_stale_index
|
||||||
|
with_project("0007-x.md" => sample_adr(id: "0007")) do |root|
|
||||||
|
repo = Adr::Repository.new(root: root)
|
||||||
|
File.write(repo.index_path, "| 0042 | stale row |")
|
||||||
|
assert_equal "0008", repo.next_id
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_exists_requires_dir_and_index
|
||||||
|
with_project do |root|
|
||||||
|
repo = Adr::Repository.new(root: root)
|
||||||
|
refute repo.exists?
|
||||||
|
File.write(repo.index_path, "index")
|
||||||
|
assert repo.exists?
|
||||||
|
end
|
||||||
|
Dir.mktmpdir do |root|
|
||||||
|
refute Adr::Repository.new(root: root).exists?
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_find_by_id
|
||||||
|
with_project("0002-b.md" => sample_adr(id: "0002", title: "B")) do |root|
|
||||||
|
entry = Adr::Repository.new(root: root).find("0002")
|
||||||
|
assert_equal "B", entry.record.title
|
||||||
|
assert entry.path.end_with?("0002-b.md")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_non_adr_files_are_ignored
|
||||||
|
with_project("0001-a.md" => sample_adr, "notes.md" => "# notes") do |root|
|
||||||
|
repo = Adr::Repository.new(root: root)
|
||||||
|
assert_equal 1, repo.entries.size
|
||||||
|
assert_equal "0002", repo.next_id
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
@ -0,0 +1,52 @@
|
||||||
|
require "minitest/autorun"
|
||||||
|
require "tmpdir"
|
||||||
|
require "fileutils"
|
||||||
|
require_relative "../lib/adr"
|
||||||
|
|
||||||
|
module AdrTestHelpers
|
||||||
|
PLUGIN_ROOT = File.expand_path("..", __dir__)
|
||||||
|
FIXTURES = File.join(__dir__, "fixtures")
|
||||||
|
|
||||||
|
# Yields a tmp project root containing an initialized docs/adr/ populated
|
||||||
|
# with the given ADR fixture contents (id => full file body).
|
||||||
|
def with_project(adrs = {})
|
||||||
|
Dir.mktmpdir do |root|
|
||||||
|
dir = File.join(root, "docs", "adr")
|
||||||
|
FileUtils.mkdir_p(dir)
|
||||||
|
adrs.each { |filename, body| File.write(File.join(dir, filename), body) }
|
||||||
|
yield root
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def sample_adr(id: "0001", title: "Use Postgres", status: "Accepted", extra_frontmatter: "")
|
||||||
|
<<~MD
|
||||||
|
---
|
||||||
|
id: "#{id}"
|
||||||
|
date: 2026-07-03
|
||||||
|
status: #{status}
|
||||||
|
supersedes:
|
||||||
|
superseded-by:
|
||||||
|
affected-paths: [db/, lib/storage]
|
||||||
|
affected-components: [storage]
|
||||||
|
#{extra_frontmatter}---
|
||||||
|
|
||||||
|
# #{id} — #{title}
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
We need a database.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
Use Postgres.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
Ops burden.
|
||||||
|
|
||||||
|
## Alternatives rejected
|
||||||
|
|
||||||
|
SQLite — no concurrent writers.
|
||||||
|
MD
|
||||||
|
end
|
||||||
|
end
|
||||||
Loading…
Reference in New Issue