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:
jared 2026-07-03 14:24:19 -04:00
parent 5d5145f347
commit 36abd43107
55 changed files with 3581 additions and 6 deletions

2
.gitignore vendored
View File

@ -11,3 +11,5 @@
graphify-out/
# doc-hygiene plugin state
.dochygiene/
# os-adr plugin state
.os-adr/

View File

@ -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.
- 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`
- 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

View File

@ -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)
are organized and used across the user's projects, as a precursor to building a Claude Code
plugin that automates ADR creation and relevance-scoped retrieval. **Nothing here is built
yet** — this is the survey/research phase only, requested to answer "what do we have, and what
could we have with a little engineering elbow grease."
are organized and used across the user's projects, and the requirements/PRD the **now-built**
`os-adr` plugin was implemented from (`cc-os/plugins/os-adr/`, installed as
`os-adr@local-plugins`; see CLAUDE.md Implemented Components). The docs below remain the design
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
`~/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
(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.
- **`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 W1W3, retrieval
R1R4) 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
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
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.

View File

@ -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.

View File

@ -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.

View File

@ -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 14 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 23 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 R1R3 still pass.
## Plugin-surface requirements derived from these sketches
All are already covered by the planned Phases 14 — **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 12) |
| One-hop relevance (R4) | Graphify traversal layer with graceful degradation (Phase 4, layer 3) |
| Low-friction unprompted writing (W1W3) | one-invocation `/os-adr:create` (Phase 1) |

View File

@ -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).
- **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)
| Tool | Why rejected for our use |

View File

@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-07-03

View File

@ -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 14 (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 23 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 12 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 12;
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).

View File

@ -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 23
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.

View 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

View File

@ -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 23 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

View File

@ -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
12) 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

View File

@ -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 13, 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

View File

@ -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

View File

@ -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 14 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 23 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 12: 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 12 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)

View File

@ -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

View File

@ -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 23 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

View File

@ -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
12) 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

View File

@ -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 13, 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

View File

@ -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

View File

@ -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."
}

16
plugins/os-adr/bin/adr-detect Executable file
View File

@ -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)

30
plugins/os-adr/bin/adr-find Executable file
View File

@ -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)

20
plugins/os-adr/bin/adr-init Executable file
View File

@ -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

35
plugins/os-adr/bin/adr-migrate Executable file
View File

@ -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

58
plugins/os-adr/bin/adr-new Executable file
View File

@ -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

View File

@ -0,0 +1,16 @@
{
"hooks": {
"SessionStart": [
{
"matcher": "startup|resume",
"hooks": [
{
"type": "command",
"command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/session_start.py",
"timeout": 5
}
]
}
]
}
}

View File

@ -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())

View File

@ -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 12). 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.

14
plugins/os-adr/lib/adr.rb Normal file
View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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.

View File

@ -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).

View File

@ -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.

View File

@ -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.

View File

@ -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}}

View File

@ -0,0 +1,2 @@
# Run the whole suite: ruby tests/all.rb
Dir.glob(File.join(__dir__, "*_test.rb")).sort.each { |f| require f }

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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()

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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