diff --git a/.gitignore b/.gitignore index 8e0bb41..2a40dfe 100644 --- a/.gitignore +++ b/.gitignore @@ -11,3 +11,5 @@ graphify-out/ # doc-hygiene plugin state .dochygiene/ +# os-adr plugin state +.os-adr/ diff --git a/CLAUDE.md b/CLAUDE.md index 2dca195..8482fee 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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:`, 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: `/graphify-out/` — same pattern; gitignore it in each project repo diff --git a/docs/adr-system/00-README.md b/docs/adr-system/00-README.md index 9ceb5fb..9072922 100644 --- a/docs/adr-system/00-README.md +++ b/docs/adr-system/00-README.md @@ -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 W1–W3, retrieval + R1–R4) written before the plugin skeleton froze; the evaluation itself remains a deferred, + separate stage per `04-plugin-requirements.md`. Provenance note: `02-external-research.md` cites web sources gathered by research agents in a 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. diff --git a/docs/adr-system/04-plugin-requirements.md b/docs/adr-system/04-plugin-requirements.md new file mode 100644 index 0000000..f5d5224 --- /dev/null +++ b/docs/adr-system/04-plugin-requirements.md @@ -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. diff --git a/docs/adr-system/05-plugin-prd.md b/docs/adr-system/05-plugin-prd.md new file mode 100644 index 0000000..ac7bc6c --- /dev/null +++ b/docs/adr-system/05-plugin-prd.md @@ -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. diff --git a/docs/adr-system/06-eval-scenarios.md b/docs/adr-system/06-eval-scenarios.md new file mode 100644 index 0000000..b6914ff --- /dev/null +++ b/docs/adr-system/06-eval-scenarios.md @@ -0,0 +1,74 @@ +# Eval Scenario Sketches (held-out, deferred stage) + +_Last updated: 2026-07-03 — sketches only; the evaluation itself is a separate later stage +(locked sequencing in `04-plugin-requirements.md`)._ + +These sketch the *shape* of the held-out scenarios that will eventually measure requirements 4 +and 5 (unprompted write-trigger recognition, unprompted correct retrieval). They exist now, before +the plugin skeleton freezes, for one purpose: to catch any plugin surface the eval will need that +Phases 1–4 don't provide. They are **not** to be run, automated, or informally "tried out" during +the build — that would contaminate the held-out methodology. + +## Ground rules for all scenarios + +- **Held-out**: scenario prompts never name the ADR system, the plugin, an ADR ID/title, or the + exact constraint text. Relevance must be discovered, not pointed at. +- **Scored on two axes**: (a) did the agent consult/propose the ADR system *at all* unprompted; + (b) did it act on the *specific correct* ADR (retrieval) or propose a *well-scoped* new one + (write-trigger) — not merely a plausible-looking neighbor. +- **Environment**: a real onboarded project (post-pilot) with a populated `docs/adr/`, the + SessionStart hook active, and — for graph-layer variants — a built `graphify-out/`. +- The eval stage will iterate with the `autoresearch` skill (modify → verify → keep/discard) + against these scenarios once written out in full. + +## Write-trigger scenario shapes + +**W1 — Irreversible choice mid-task.** Task: "Add persistence to X" in a project where the +storage engine choice is genuinely open and consequential (hard to reverse once data exists). +Never mention ADRs. Pass: the agent proposes recording the engine decision via `/os-adr:create` +(or asks whether to) before or immediately after implementing. Fail: silently picks one and moves on. + +**W2 — Cross-cutting convention change.** Task: a refactor request whose natural solution +changes a convention used across many files (e.g. error-handling shape, module layout). Pass: +the agent recognizes the decision outlives the task and proposes an ADR. Fail: does the refactor +with no record. + +**W3 — Contested reversal.** Task: user asks for something that quietly reverses an earlier +(non-ADR'd, but discoverable-in-code) approach. Pass: agent flags that this is a +decision-of-record moment and proposes capturing it. This shape overlaps retrieval when the +earlier approach *is* ADR'd — kept separate deliberately. + +## Retrieval scenario shapes + +**R1 — Direct conflict with an Accepted ADR.** Task: a change request whose obvious +implementation violates an existing Accepted ADR whose `affected-paths` cover the files being +edited. The prompt names neither the ADR nor the constraint. Pass: agent surfaces the specific +ADR (via `/os-adr:find` or the SessionStart note) and adjusts or pushes back. Fail: implements +the violation, or cites a different/wrong ADR. + +**R2 — Distractor discrimination.** Same as R1, but `docs/adr/` also contains 2–3 plausible +near-miss ADRs (same component family, different decision; or Superseded status). Pass requires +retrieving the *correct, Accepted* one — this is the "correct, not merely plausible" axis and +the reason the status filter and path-match layers exist. + +**R3 — Mid-task question.** Partway through an ongoing task, the user asks a "how should we…" +question already answered by an Accepted ADR, phrased in different vocabulary. Pass: agent +queries the ADR system mid-task (not only at SessionStart) and answers from the ADR. This is the +scenario that requires a **mid-task-callable query surface**. + +**R4 — Graph-layer reach.** Conflict exists but the edited files are *not* listed in any ADR's +`affected-paths`; they are one graph hop away (import/reference) from files that are. Pass only +reachable via the Graphify traversal layer. Also serves as the degradation check: without +`graphify-out/`, R4 is expected to fail while R1–R3 still pass. + +## Plugin-surface requirements derived from these sketches + +All are already covered by the planned Phases 1–4 — **no plan change needed**: + +| Scenario need | Covering surface | +| --- | --- | +| Mid-task query, not SessionStart-only (R3) | `/os-adr:find` skill + `bin/adr-find` CLI (Phase 4) | +| Discoverability without being told (all) | SessionStart note names both `create` and `find` (Phase 2) | +| Correct-vs-plausible discrimination (R2) | path/component match + Accepted-by-default status filter (Phase 4, layers 1–2) | +| One-hop relevance (R4) | Graphify traversal layer with graceful degradation (Phase 4, layer 3) | +| Low-friction unprompted writing (W1–W3) | one-invocation `/os-adr:create` (Phase 1) | diff --git a/docs/memory-system/03-architecture-decisions.md b/docs/memory-system/03-architecture-decisions.md index 20892d8..46b171f 100644 --- a/docs/memory-system/03-architecture-decisions.md +++ b/docs/memory-system/03-architecture-decisions.md @@ -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 | diff --git a/openspec/changes/archive/2026-07-03-add-os-adr-plugin/.openspec.yaml b/openspec/changes/archive/2026-07-03-add-os-adr-plugin/.openspec.yaml new file mode 100644 index 0000000..43e65ca --- /dev/null +++ b/openspec/changes/archive/2026-07-03-add-os-adr-plugin/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-07-03 diff --git a/openspec/changes/archive/2026-07-03-add-os-adr-plugin/design.md b/openspec/changes/archive/2026-07-03-add-os-adr-plugin/design.md new file mode 100644 index 0000000..6ddcb44 --- /dev/null +++ b/openspec/changes/archive/2026-07-03-add-os-adr-plugin/design.md @@ -0,0 +1,197 @@ +# Design: add-os-adr-plugin + +## Context + +The requirements are locked in `docs/adr-system/04-plugin-requirements.md` and phased in +`docs/adr-system/05-plugin-prd.md` — this design decides *how* to build them, not *what* they +are. cc-os already has three global plugins establishing the conventions this one must follow: + +- `os-vault` — Python hooks (thin entry points + shared `config.py`/`hook_io.py` modules), hooks + wired by absolute path in `settings.json`, verb-first skills, no `commands/` dir. +- `os-doc-hygiene` — plugin-relative `hooks/hooks.json` using `${CLAUDE_PLUGIN_ROOT}`, + deterministic zero-token SessionStart reminder with per-project gitignored state dir + (`.dochygiene/`) and snooze, deterministic-scanner + generative-only-where-needed split, + invariants.md + golden-example fixtures. +- `os-orchestration` — minimal SessionStart context injection. + +Constraints: single template everywhere (no variants), `docs/adr/` location, mechanical-first +with LLM reserved for judgment, non-destructive migration, OO per Sandi Metz, eval deferred. + +## Goals / Non-Goals + +**Goals:** + +- A working `os-adr` plugin at `cc-os/plugins/os-adr/`, installed via the `local-plugins` + marketplace, covering PRD Phases 1–4 (authoring, session hook, migration, retrieval). +- Every deterministic behavior testable without a model; every LLM-assisted behavior isolated + behind a skill so the mechanical core stays pure. +- Eval scenario *sketches* written down so the plugin surface is eval-ready. + +**Non-Goals:** + +- The unprompted-behavior evaluation itself (separate later stage, `autoresearch` loop). +- Retrofitting cc-os's own ADR file, bulk rollout beyond the pilot projects. +- Per-project template variants, severity/enforcement fields, multi-editor governance. + +## Decisions + +### D1 — Plugin layout and packaging + +`cc-os/plugins/os-adr/` with: + +``` +os-adr/ + .claude-plugin/plugin.json # name: os-adr + hooks/hooks.json # SessionStart wiring via ${CLAUDE_PLUGIN_ROOT} + hooks/session_start.py # thin Python entry point (deterministic check) + lib/ # Ruby OO core (adr/ classes) + bin/ # Ruby CLI entry points the skills invoke + templates/adr.md # the customized-Nygard template + skills/create/SKILL.md + skills/init/SKILL.md + skills/migrate/SKILL.md + skills/find/SKILL.md + tests/ # unit tests + golden-example fixtures + invariants.md +``` + +Install follows the CLAUDE.md three-place procedure: symlink +`~/.claude/plugins/os-adr → cc-os/plugins/os-adr/`, add `"os-adr"`/`"./os-adr"` to the +`local-plugins` marketplace manifest, enable `os-adr@local-plugins` in `settings.json`, then +`claude plugin marketplace update` + `claude plugin install`, and verify with +`claude plugin details` (skills registered is the check that catches manifest mistakes). + +**Why:** matches all three existing plugins; the manifest step is the known failure mode +(ADR-018) so it's explicit in tasks. + +### D2 — Hook wiring: plugin `hooks.json`, not `settings.json` absolute paths + +Use the `os-doc-hygiene` pattern (`hooks/hooks.json` with `${CLAUDE_PLUGIN_ROOT}`, matcher +`startup|resume`, short timeout). **Why over the os-vault absolute-path pattern:** it keeps the +plugin self-contained, survives repo moves, and hook registration then fails *with* plugin +registration rather than silently diverging from it (the masking problem ADR-018 documented). + +### D3 — Language split: Ruby OO core, Python only for the hook entry point + +- `lib/` is Ruby, Sandi Metz style: small single-responsibility classes + (`Adr::Template`, `Adr::Record`, `Adr::Index`, `Adr::Repository`, `Adr::Detector` (migration + shape detection), `Adr::Migrator`, `Adr::Finder` (retrieval pipeline)), dependency injection + (paths and IO handed in, no globals), tell-don't-ask. +- `bin/` exposes thin Ruby CLIs (`adr-new`, `adr-init`, `adr-detect`, `adr-migrate`, + `adr-find`) that skills call via Bash. (No standalone `adr-index` — index regeneration is + embedded in every writing CLI, per D4.) +- The SessionStart hook is a thin Python script that does the existence check inline (stat two + paths, read one suppression file, emit JSON) — it does **not** shell out to Ruby. + +**Why:** the user's standing Ruby preference for the core; Python for the hook matches the +existing hook pattern and keeps session-start latency free of Ruby interpreter spin-up. The hook +logic is ~20 lines and purely mechanical, so duplicating "does `docs/adr/` exist" between Python +and Ruby is acceptable coupling (alternative — Python wrapping the Ruby core — was rejected for +latency and dependency-surface reasons at session start). + +### D4 — Template and ID/index mechanics + +- Template (`templates/adr.md`): frontmatter `id`, `date`, `status` + (Proposed/Accepted/Superseded/Deprecated), `supersedes`, `superseded-by`, `affected-paths`, + `affected-components`, `migration_confidence` (migration-written files only); body sections + `## Context`, `## Decision`, `## Consequences`, `## Alternatives rejected`. +- Files: `docs/adr/NNNN-kebab-title.md`, 4-digit zero-padded sequential ID; next ID = max + existing + 1, derived by scanning the directory (never trusting the index). +- Index: `docs/adr/README.md`, a generated table (ID, title, status, date) between markers; + regenerated in full from the directory on every write by `Adr::Index` — never hand-edited, + never appended-to incrementally. Setting `superseded-by` on an old ADR when a new one declares + `supersedes` is also mechanical (`adr-new` does both edits). + +**Why full regeneration over append:** idempotent, self-healing after manual file edits, trivial +to test against golden fixtures; append-only indexes drift. + +### D5 — SessionStart behavior and suppression + +- `docs/adr/` + index present → inject one short additionalContext line: the system exists, how + to write (`/os-adr:create`) and query (`/os-adr:find`) it. Near-zero tokens, no LLM. +- Absent → one-line suggestion to run `/os-adr:init` or `/os-adr:migrate`. +- Suppression: per-project gitignored state dir `.os-adr/` containing `suppress` (permanent + opt-out for deliberately ADR-less projects) — same shape as `.dochygiene/`. `/os-adr:init` + clears it; hook exits silently when present. A once-per-day snooze stamp (`last_reminded`) + additionally rate-limits the absent-case nag, mirroring os-doc-hygiene. + +### D6 — Migration pipeline (mechanical/LLM boundary) + +`adr-detect` (mechanical) classifies existing content into the surveyed shapes — numbered +per-file dirs, dated single files, monolithic multi-decision files, prose-embedded — plus +`unrecognized` as the fallback (reported, never auto-converted). `adr-migrate` then, per source +decision: + +- **Heuristic fill (mechanical):** fields structurally unambiguous in the source — Status/Date + from frontmatter or clear headings, title, ID assignment, source-file provenance link. +- **LLM fill (skill-mediated, flagged):** interpretive fields only — Consequences, + Alternatives-rejected, splitting a monolithic file into per-decision units. The Ruby core + emits a work manifest of exactly which fields need LLM fill; the `migrate` skill has the model + fill them and hands results back to the core for writing. Never invent Decision or Context + from nothing — if absent in the source, the field carries an explicit `_not stated in + source_` marker and the file is flagged. +- **Flagging (one mechanism):** `migration_confidence: low|medium|high` frontmatter per file + + one `docs/adr/migration-report.md` summarizing per-file confidence, source mapping, and the + overall flag rate. No scattered inline comments. +- **Non-destructive:** new files only; source files untouched. Old-system deletion is a + separate, explicit user-approved step the skill offers only after the report is reviewed. +- **Pilot gate:** run on 2–3 surveyed projects (viking-warrior-training-log, delta-refinery, + llf-schema); if the low-confidence flag rate exceeds the threshold set at pilot time + (recorded in the report), tighten heuristics before wider rollout. Build-phase gate, not a + shipped feature. + +### D7 — Retrieval stack (`adr-find` / `/os-adr:find`) + +Deterministic layers first, each narrowing the candidate set: + +1. Path/component match: query paths (e.g. files being edited) against `affected-paths` / + `affected-components` frontmatter across `docs/adr/`. +2. Status filter: `Accepted` only by default (`--all-statuses` to override). +3. Graphify traversal: when `graphify-out/` exists, expand the *query* paths one hop via + graph-node `source_file` attributes and re-match the expanded set against `affected-paths` — + ADRs themselves are never graph nodes (resolved during Phase 4; see ADR-020). Reuses + os-vault's project-graph infrastructure; degrades gracefully to layers 1–2 when absent. +4. AI judgment (skill-mediated) only over the narrowed set — the `find` skill presents the + candidates and the model picks/ranks; it never sweeps the full corpus. + +This is a mid-task-callable surface (skill + CLI), satisfying the PRD's eval-readiness note — +retrieval is not locked to SessionStart. + +### D8 — Eval-readiness sketches + +A `docs/adr-system/06-eval-scenarios.md` sketch (write-trigger + retrieval scenario shapes per +the PRD) is written during this change, and the SessionStart context line explicitly names both +surfaces (`create`, `find`) so an agent can discover them unprompted. The eval itself is out of +scope. + +## Risks / Trade-offs + +- [Duplicated existence-check logic between Python hook and Ruby core] → it's two path checks; + covered by a shared fixture test asserting both agree on the same tree. +- [Ruby runtime required at skill time] → acceptable: this is the user's own machine and + standing preference; hook path (the always-runs surface) has no Ruby dependency. +- [Migration LLM fills may be wrong] → confidence flags + report + pilot gate before rollout; + non-destructiveness means a bad migration is discardable (delete `docs/adr/`, rerun). +- [Graphify layer depends on os-vault onboarding state] → retrieval degrades to layers 1–2; + layer 3 is an enhancement, not a dependency. +- [Monolithic-file splitting is the hardest detection case] → treated as LLM-boundary work with + per-unit flags; cc-os's own 19-ADR file is deliberately excluded from the pilot. +- [Index regeneration overwrites manual index edits] → by design (index is declared + never-hand-edited in its own header comment). + +## Migration Plan + +Build order matches PRD phases: Phase 1 (template+create+index) → Phase 2 (hook) → Phase 3 +(migration) → Phase 4 (retrieval), each verifiable independently. Install/registration happens +after Phase 1 so `create` is dogfoodable early. Rollback: uninstall plugin + remove symlink and +manifest entry; projects keep their `docs/adr/` files (plain markdown, tool-independent). + +## Open Questions + +- Exact low-confidence flag-rate threshold for the pilot gate — set empirically at pilot time + and recorded in the migration report (deliberately not guessed now). +- ~~Whether ADR frontmatter needs a dedicated Graphify tag/edge convention for layer-3 + traversal~~ — **Resolved during Phase 4 (2026-07-03):** no convention needed. Layer 3 expands + the *query* paths one hop via graph-node `source_file`s; ADRs never need to be graph nodes and + match purely on `affected-paths` frontmatter (verified against the real llf-schema project + graph; recorded in ADR-020). diff --git a/openspec/changes/archive/2026-07-03-add-os-adr-plugin/proposal.md b/openspec/changes/archive/2026-07-03-add-os-adr-plugin/proposal.md new file mode 100644 index 0000000..2de14d0 --- /dev/null +++ b/openspec/changes/archive/2026-07-03-add-os-adr-plugin/proposal.md @@ -0,0 +1,76 @@ +# Proposal: add-os-adr-plugin + +## Why + +~43% of the user's projects carry ADR-like content in 5+ mutually-incompatible shapes; the rest +have none, and no project uses ADR tooling. Because AI agents (not just the user) work across +every project, decision records must be discoverable and writable *consistently* by an agent +that has never seen a given repo — and cc-os's own 19-ADR/601-line monolithic file is already +near the size where research says monolithic logs stop working. The requirements are locked +(`docs/adr-system/04-plugin-requirements.md`) and the PRD is written +(`docs/adr-system/05-plugin-prd.md`); this change builds the plugin. + +## What Changes + +- New global Claude Code plugin **`os-adr`** at `cc-os/plugins/os-adr/`, following the + established cc-os plugin conventions: `os-` prefix, verb-first skills, no `commands/` + dispatcher, symlink into `~/.claude/plugins/`, registered in the `local-plugins` marketplace + manifest (the three-place rename/registration procedure in CLAUDE.md). +- **Phase 1 — authoring**: customized-Nygard template (Status/Context/Decision/Consequences + + Alternatives rejected; frontmatter with `supersedes`/`superseded-by`, + `affected-paths`/`affected-components`, `date`, sequential ID), `docs/adr/NNNN-title.md` + one-file-per-decision convention, mechanically-maintained index file, and a `new` skill that + writes a correctly templated + indexed ADR in one invocation. No LLM in the mechanical path. +- **Phase 2 — SessionStart hook**: deterministic existence check for `docs/adr/` + index. Present + → near-zero-token context note; absent → suggest setup, with a per-project suppression flag so + a deliberately ADR-less project doesn't re-nag (same shape as `os-doc-hygiene` snooze state). +- **Phase 3 — non-destructive migration**: a `migrate` skill converting existing ADR content of + any surveyed shape into the new template alongside (never replacing) the old system; heuristic + fill for structurally unambiguous fields, LLM fill only for interpretive fields, one consistent + uncertainty-flagging mechanism (`migration_confidence` frontmatter + a migration report file); + deletion of the old system only as an explicit, separate user-approved step. Piloted on 2–3 + real projects with an uncertain-flag-rate gate before wider rollout. +- **Phase 4 — retrieval**: deterministic-first layering — path/component frontmatter match → + status filter (Accepted by default) → Graphify graph traversal (reusing `os-vault` + infrastructure) → AI judgment only over the already-narrowed candidate set. Exposed as a query + surface callable mid-task, not just at SessionStart. +- Implementation style: OO per Sandi Metz principles; Ruby-preferred core with Python acceptable + for hook entry points, decided per-component (thin hook scripts wrapping an OO core). +- Held-out eval scenarios (write-trigger, retrieval) are **sketched** as part of this change so + the plugin surface isn't missing what the eval needs — but the evaluation itself stays a + separate later stage (locked sequencing in `04-plugin-requirements.md`). + +## Capabilities + +### New Capabilities + +- `adr-authoring`: the ADR template, `docs/adr/NNNN-title.md` + index location convention, and + the one-invocation new-ADR generator skill with mechanical index maintenance. +- `adr-session-awareness`: the deterministic SessionStart existence-check hook — minimal context + injection when the ADR system exists, setup suggestion with per-project suppression when it + doesn't. +- `adr-migration`: non-destructive conversion of existing ADR content (all surveyed shapes plus + a fallback) into the new template, with the mechanical/LLM field-mapping boundary, single + uncertainty-flagging mechanism, and pilot gate. +- `adr-retrieval`: the deterministic-first retrieval stack (frontmatter path match → status + filter → Graphify traversal → narrowed AI judgment) exposed as a mid-task query surface. +- `adr-plugin-packaging`: plugin structure, naming, marketplace registration, and install + procedure per cc-os conventions (`os-` prefix, verb-first skills, symlink + `local-plugins` + manifest + `settings.json` enablement). + +### Modified Capabilities + + + +## 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. diff --git a/openspec/changes/archive/2026-07-03-add-os-adr-plugin/specs/adr-authoring/spec.md b/openspec/changes/archive/2026-07-03-add-os-adr-plugin/specs/adr-authoring/spec.md new file mode 100644 index 0000000..bc4b278 --- /dev/null +++ b/openspec/changes/archive/2026-07-03-add-os-adr-plugin/specs/adr-authoring/spec.md @@ -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 diff --git a/openspec/changes/archive/2026-07-03-add-os-adr-plugin/specs/adr-migration/spec.md b/openspec/changes/archive/2026-07-03-add-os-adr-plugin/specs/adr-migration/spec.md new file mode 100644 index 0000000..0bddcb2 --- /dev/null +++ b/openspec/changes/archive/2026-07-03-add-os-adr-plugin/specs/adr-migration/spec.md @@ -0,0 +1,71 @@ +# adr-migration + +## ADDED Requirements + +### Requirement: Shape detection with a fallback +The migration tooling SHALL mechanically detect existing ADR-like content and classify it into +the surveyed shapes — numbered per-file directories, dated single files, monolithic +multi-decision files, prose embedded in other docs — plus an `unrecognized` fallback +classification. Unrecognized content SHALL be reported for manual handling, never auto-converted. + +#### Scenario: Known shape detected +- **WHEN** detection runs on a project with a monolithic multi-decision file +- **THEN** the file is classified as monolithic and its individual decisions are enumerated as + migration units + +#### Scenario: Unknown shape falls back safely +- **WHEN** detection encounters ADR-like content matching no known shape +- **THEN** it is classified `unrecognized` and listed in the report without any conversion + attempt + +### Requirement: Mechanical/LLM field-mapping boundary +During conversion, fields that are structurally unambiguous in the source (Status and Date from +frontmatter or clear headings; title; ID assignment; source provenance) SHALL be filled +mechanically. Only interpretive fields (Consequences, Alternatives rejected, monolithic-file +decision splitting) MAY be LLM-filled, and every LLM-filled field SHALL be flagged. The +migration SHALL never invent a Decision or Context field absent from the source — such fields +SHALL carry an explicit not-stated-in-source marker and lower the file's confidence. + +#### Scenario: Unambiguous field filled mechanically +- **WHEN** a source ADR has a `Status: Accepted` heading and a date +- **THEN** the converted file's `status` and `date` are set without any LLM call + +#### Scenario: Missing core field is not invented +- **WHEN** a source decision has no discernible Context +- **THEN** the converted Context section contains a not-stated-in-source marker and the file is + flagged low-confidence + +### Requirement: Single uncertainty-flagging mechanism +Migration uncertainty SHALL be surfaced through exactly one mechanism: a +`migration_confidence: low|medium|high` frontmatter field per converted file, plus one +migration report file (`docs/adr/migration-report.md`) summarizing per-file confidence, the +source-to-new mapping, and the overall low-confidence flag rate. Scattered inline uncertainty +comments SHALL NOT be used. + +#### Scenario: Report aggregates confidence +- **WHEN** a migration completes +- **THEN** the report lists every converted file with its confidence, its source location, and + the overall flag rate + +### Requirement: Non-destructive, dual-existence conversion +Migration SHALL only write new files under `docs/adr/`; existing ADR content SHALL never be +deleted, moved, or edited by the migration. Removal of the old system SHALL happen only as an +explicit, separate, user-approved step offered after the migration report exists. + +#### Scenario: Old system untouched +- **WHEN** a migration run completes +- **THEN** every pre-existing ADR source file is byte-identical to its pre-migration state + +#### Scenario: Deletion requires explicit approval +- **WHEN** the user has not explicitly approved old-system removal +- **THEN** no tooling path deletes the old content, and the removal offer appears only after the + report is available for review + +### Requirement: Pilot gate before broad rollout +The migration SHALL be piloted on 2–3 real surveyed projects, and the low-confidence flag rate +SHALL be measured against an explicit threshold recorded at pilot time before the heuristics are +treated as good enough for wider rollout. + +#### Scenario: High flag rate blocks rollout +- **WHEN** a pilot run's low-confidence rate exceeds the recorded threshold +- **THEN** the heuristics are tightened and the pilot re-run before migrating further projects diff --git a/openspec/changes/archive/2026-07-03-add-os-adr-plugin/specs/adr-plugin-packaging/spec.md b/openspec/changes/archive/2026-07-03-add-os-adr-plugin/specs/adr-plugin-packaging/spec.md new file mode 100644 index 0000000..7a03dab --- /dev/null +++ b/openspec/changes/archive/2026-07-03-add-os-adr-plugin/specs/adr-plugin-packaging/spec.md @@ -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:` (`create`, `init`, `migrate`, `find`), +with no `commands/` dispatcher directory — matching the cc-os plugin/skill naming convention. + +#### Scenario: Skills register under verb-first names +- **WHEN** the plugin is installed and `claude plugin details os-adr@local-plugins` is run +- **THEN** the listed skills are `create`, `init`, `migrate`, and `find`, and no `commands/` + directory exists in the plugin + +### Requirement: Source of truth in cc-os with symlinked install +The plugin source SHALL live git-tracked at `cc-os/plugins/os-adr/` and be installed via a +symlink `~/.claude/plugins/os-adr` pointing at it, matching the `os-vault`/`os-doc-hygiene`/ +`os-orchestration` pattern. + +#### Scenario: Symlinked install +- **WHEN** installation is complete +- **THEN** `~/.claude/plugins/os-adr` is a symlink resolving to the git-tracked plugin directory + +### Requirement: Full three-place marketplace registration +Installation SHALL update all three registration surfaces — the plugin directory/symlink, the +`local-plugins` marketplace manifest (`~/.claude/plugins/.claude-plugin/marketplace.json`), and +`settings.json` `enabledPlugins` — then refresh plugin-manager state via +`claude plugin marketplace update` and `claude plugin install`, and verify skill registration +with `claude plugin list` and `claude plugin details`. + +#### Scenario: Skills resolve after install +- **WHEN** the registration procedure completes +- **THEN** `claude plugin list` shows `os-adr@local-plugins` enabled and its skills are + invocable as slash commands in a fresh session + +### Requirement: Hooks wired through plugin hooks.json +Plugin hooks SHALL be declared in `hooks/hooks.json` using `${CLAUDE_PLUGIN_ROOT}`-relative +commands (the `os-doc-hygiene` pattern), not by absolute paths in `settings.json`, so hook +registration cannot silently outlive or diverge from plugin registration. + +#### Scenario: Hook path is plugin-relative +- **WHEN** the plugin's hook configuration is inspected +- **THEN** the SessionStart command references `${CLAUDE_PLUGIN_ROOT}` and no os-adr hook entry + exists in `settings.json` + +### Requirement: OO core with tested deterministic behavior +The plugin's core logic SHALL be implemented as an object-oriented Ruby library (small +single-responsibility classes, dependency-injected paths/IO, no globals) exposed through thin +CLI entry points, with a thin Python script only for the SessionStart hook. All deterministic +behaviors (ID assignment, index regeneration, shape detection, existence check, retrieval layers +1–2) SHALL have automated tests runnable without any LLM, including golden-example fixtures. + +#### Scenario: Deterministic tests run model-free +- **WHEN** the plugin's test suite runs on a machine with no model access +- **THEN** all tests for ID assignment, index regeneration, detection, existence check, and + frontmatter retrieval pass + +#### Scenario: Skills delegate to CLI entry points +- **WHEN** a skill performs a mechanical operation (numbering, indexing, detection, filtering) +- **THEN** it does so by invoking a `bin/` CLI rather than re-implementing the logic in prompt + instructions diff --git a/openspec/changes/archive/2026-07-03-add-os-adr-plugin/specs/adr-retrieval/spec.md b/openspec/changes/archive/2026-07-03-add-os-adr-plugin/specs/adr-retrieval/spec.md new file mode 100644 index 0000000..9b0a8d3 --- /dev/null +++ b/openspec/changes/archive/2026-07-03-add-os-adr-plugin/specs/adr-retrieval/spec.md @@ -0,0 +1,49 @@ +# adr-retrieval + +## ADDED Requirements + +### Requirement: Deterministic-first retrieval layering +ADR retrieval SHALL narrow candidates through ordered layers: (1) path/component match of query +inputs against each ADR's `affected-paths`/`affected-components` frontmatter; (2) status filter +admitting only `Accepted` ADRs by default, with an explicit override to include other statuses; +(3) Graphify graph traversal when a project graph exists; (4) AI judgment applied only to the +already-narrowed candidate set. AI judgment SHALL never be applied to the full ADR corpus. + +#### Scenario: Path match narrows first +- **WHEN** retrieval is queried with file paths being edited +- **THEN** ADRs whose `affected-paths` match are selected before any model involvement + +#### Scenario: Default status filter +- **WHEN** retrieval runs without an override +- **THEN** only `Accepted` ADRs appear in the candidate set + +#### Scenario: AI sees only the narrowed set +- **WHEN** the AI-judgment layer runs +- **THEN** it receives only the candidates surviving layers 1–3, not the full corpus + +### Requirement: Graph layer degrades gracefully +WHEN no `graphify-out/` project graph exists (or ADRs are not yet indexed as graph nodes), +retrieval SHALL still function using layers 1, 2, and 4, without error. + +#### Scenario: No project graph present +- **WHEN** retrieval runs in a project that has never been onboarded to Graphify +- **THEN** results are produced from frontmatter matching and status filtering alone + +### Requirement: Mid-task-callable query surface +Retrieval SHALL be exposed as a `find` skill (`/os-adr:find`) backed by a deterministic CLI, +callable at any point during a task — not only at SessionStart — so an agent can check +in-progress work against existing decisions. + +#### Scenario: Query during a task +- **WHEN** an agent mid-task invokes `/os-adr:find` with the files or topic it is working on +- **THEN** it receives the relevant Accepted ADR candidates for those inputs + +### Requirement: Eval-readiness sketches accompany the build +Held-out evaluation scenario shapes (write-trigger and retrieval) SHALL be sketched in a design +doc before the plugin skeleton is finalized, and any plugin surface those sketches require SHALL +be part of the initial build. The evaluation itself SHALL NOT be part of this change. + +#### Scenario: Sketches exist before skeleton freeze +- **WHEN** the plugin skeleton is finalized +- **THEN** a scenario-sketch document exists covering at least one write-trigger and one + retrieval scenario shape diff --git a/openspec/changes/archive/2026-07-03-add-os-adr-plugin/specs/adr-session-awareness/spec.md b/openspec/changes/archive/2026-07-03-add-os-adr-plugin/specs/adr-session-awareness/spec.md new file mode 100644 index 0000000..6a6fffb --- /dev/null +++ b/openspec/changes/archive/2026-07-03-add-os-adr-plugin/specs/adr-session-awareness/spec.md @@ -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 diff --git a/openspec/changes/archive/2026-07-03-add-os-adr-plugin/tasks.md b/openspec/changes/archive/2026-07-03-add-os-adr-plugin/tasks.md new file mode 100644 index 0000000..a8547a2 --- /dev/null +++ b/openspec/changes/archive/2026-07-03-add-os-adr-plugin/tasks.md @@ -0,0 +1,51 @@ +# Tasks: add-os-adr-plugin + +## 1. Plugin skeleton and eval-readiness sketches + +- [x] 1.1 Sketch held-out eval scenario shapes (write-trigger + retrieval) in `docs/adr-system/06-eval-scenarios.md`; note any plugin surface they require beyond Phases 1–4 and fold it into this plan before proceeding +- [x] 1.2 Create `cc-os/plugins/os-adr/` skeleton: `.claude-plugin/plugin.json` (name `os-adr`), `hooks/`, `lib/`, `bin/`, `templates/`, `skills/`, `tests/`, `invariants.md` stub +- [x] 1.3 Write `templates/adr.md` — customized-Nygard template with full frontmatter (`id`, `date`, `status`, `supersedes`, `superseded-by`, `affected-paths`, `affected-components`) and body sections (Context, Decision, Consequences, Alternatives rejected) + +## 2. Phase 1 — authoring core (Ruby, mechanical) + +- [x] 2.1 Implement `Adr::Record` (parse/serialize one ADR file: frontmatter + sections) with unit tests +- [x] 2.2 Implement `Adr::Repository` (scan `docs/adr/`, derive next 4-digit ID from directory contents, injected root path) with unit tests including stale/missing-index cases +- [x] 2.3 Implement `Adr::Index` (full regeneration of `docs/adr/README.md` table between markers, never-hand-edited header) with golden-fixture tests including the self-heal-after-manual-edit case +- [x] 2.4 Implement `Adr::Template` + `bin/adr-new` (create templated ADR, regenerate index, mechanical supersession: set `superseded-by` + status on the superseded ADR) with tests +- [x] 2.5 Implement `bin/adr-init` (create `docs/adr/` + empty index, clear `.os-adr/suppress`) with tests +- [x] 2.6 Write `skills/create/SKILL.md` and `skills/init/SKILL.md` — skills gather decision content, then delegate all mechanical work to `bin/adr-new`/`bin/adr-init` + +## 3. Phase 1 — install and register the plugin + +- [x] 3.1 Symlink `~/.claude/plugins/os-adr → cc-os/plugins/os-adr/` +- [x] 3.2 Add `os-adr`/`./os-adr` to `~/.claude/plugins/.claude-plugin/marketplace.json` and enable `os-adr@local-plugins` in `settings.json` +- [x] 3.3 Run `claude plugin marketplace update local-plugins` + `claude plugin install os-adr@local-plugins`; verify with `claude plugin list` and `claude plugin details` that all skills resolve (ADR-018 failure mode check) +- [x] 3.4 Dogfood: in a scratch project, run `/os-adr:init` then `/os-adr:create` and confirm one-invocation creation with correct template, ID, and index (CLI path the skills delegate to, verified in scratchpad; skills confirmed registered via `claude plugin details` — interactive slash invocation re-checked in 4.4's fresh-session pass) + +## 4. Phase 2 — SessionStart hook + +- [x] 4.1 Implement `hooks/session_start.py` — deterministic existence check (`docs/adr/` + index), present→minimal usage note naming `/os-adr:create` and `/os-adr:find`, absent→init/migrate suggestion gated by `.os-adr/suppress` flag and once-per-day snooze stamp +- [x] 4.2 Write `hooks/hooks.json` (SessionStart, matcher `startup|resume`, `${CLAUDE_PLUGIN_ROOT}` command, 5s timeout); confirm no os-adr entries needed in `settings.json` +- [x] 4.3 Add hook tests: shared fixture tree asserting the Python check and Ruby `Adr::Repository` agree on existence; suppression, snooze, and present/absent output cases +- [x] 4.4 Verify in fresh sessions: present project injects the note, absent project nags once then snoozes, suppressed project stays silent (verified by invoking the hook exactly as the harness does, in fixture git projects; hook registration confirmed via `claude plugin details` — Hooks (1) SessionStart) + +## 5. Phase 3 — migration + +- [x] 5.1 Implement `Adr::Detector` + `bin/adr-detect` — classify existing content into the four surveyed shapes + `unrecognized` fallback (report-only), enumerate migration units, with fixture tests per shape +- [x] 5.2 Implement `Adr::Migrator` + `bin/adr-migrate` mechanical pass — heuristic-fill unambiguous fields (status/date/title/ID/provenance), emit a work manifest of fields needing LLM fill, write nothing outside `docs/adr/`, with byte-identical-source tests +- [x] 5.3 Implement flagging + report: `migration_confidence` frontmatter, not-stated-in-source markers for absent Decision/Context, and `docs/adr/migration-report.md` generation (per-file confidence, source mapping, flag rate) with tests +- [x] 5.4 Write `skills/migrate/SKILL.md` — runs detect, has the model fill only manifest-listed interpretive fields, hands results back to the core for writing, presents the report, and offers old-system deletion only as an explicit separate user-approved step +- [x] 5.5 Pilot the migration on 2–3 surveyed projects (viking-warrior-training-log, delta-refinery, llf-schema); record the low-confidence flag rate and threshold in each report; tighten heuristics and re-run if the rate exceeds the threshold (gate — do not proceed to broad use until passed). **Pilot 2026-07-03 (sandboxed copies, real repos untouched): threshold 25%; two tightening rounds (bulleted labels, bold-label paragraphs `**Context.**`, Why/Rationale→Context fallback at medium confidence, SETTLED/OPEN status mapping, multi-Status-no-headings→unrecognized guard). Final rates: viking 0% (6/6 high), delta-refinery 0% (12 medium), llf-schema 8.3% (1 low: the superseded 2026-06-04 index doc, legitimately lacking Decision/Context). Gate PASSED. Real-repo migration happens interactively via `/os-adr:migrate` per project.** + +## 6. Phase 4 — retrieval + +- [x] 6.1 Implement `Adr::Finder` + `bin/adr-find` layers 1–2: path/component frontmatter matching and Accepted-by-default status filter (with override flag), with tests +- [x] 6.2 Add layer 3: Graphify traversal over `graphify-out/` when present, expanding the candidate set via graph edges; graceful degradation to layers 1–2 when absent, with tests for both paths; resolve whether ADR frontmatter needs a tag/edge convention against the llf-schema graph. **Resolved: no tag/edge convention needed — layer 3 expands the *query* paths one hop via graph-node `source_file` attributes; ADRs never need to be graph nodes and match purely on `affected-paths` frontmatter.** +- [x] 6.3 Write `skills/find/SKILL.md` — mid-task-callable: run the deterministic CLI, apply AI judgment only over the returned candidate set, never the full corpus +- [x] 6.4 Verify retrieval end-to-end in a piloted project: query with real edited-file paths and confirm the correct Accepted ADR surfaces (llf-schema pilot copy + its real project graph: `tests/IdMinterTest.php` query surfaced the correct ADR via `graph` match; `src/IdMinter.php` via `path`; unrelated path → 0 candidates) + +## 7. Wrap-up + +- [x] 7.1 Fill in `invariants.md` (non-destructiveness, index-regeneration, no-LLM-in-hook, single-flag-mechanism invariants) and ensure the test suite covers each (9 invariants, each mapped to its covering tests) +- [x] 7.2 Update cc-os `CLAUDE.md` Implemented Components with the os-adr pointer and note the locked rollout order (pilot → cc-os retrofit → wider) as remaining work +- [x] 7.3 Record the build in `docs/memory-system`-style ADR log (`03-architecture-decisions.md`) if any design decision changed during implementation; keep `docs/adr-system/` docs' status lines current (ADR-020 added: 25% pilot threshold, no-graph-convention resolution, heuristic-boundary refinements; `00-README.md` status updated, `06-eval-scenarios.md` added to its doc list) diff --git a/openspec/specs/adr-authoring/spec.md b/openspec/specs/adr-authoring/spec.md new file mode 100644 index 0000000..9e73886 --- /dev/null +++ b/openspec/specs/adr-authoring/spec.md @@ -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 diff --git a/openspec/specs/adr-migration/spec.md b/openspec/specs/adr-migration/spec.md new file mode 100644 index 0000000..82b91ae --- /dev/null +++ b/openspec/specs/adr-migration/spec.md @@ -0,0 +1,75 @@ +# Spec: ADR Migration + +## Purpose + +Defines how existing ADR-like content in a project is migrated into the standard `os-adr` format: mechanical shape detection with a safe fallback, a strict mechanical/LLM field-mapping boundary, a single uncertainty-flagging mechanism, non-destructive dual-existence conversion, and a pilot gate before broad rollout. + +## Requirements + +### Requirement: Shape detection with a fallback +The migration tooling SHALL mechanically detect existing ADR-like content and classify it into +the surveyed shapes — numbered per-file directories, dated single files, monolithic +multi-decision files, prose embedded in other docs — plus an `unrecognized` fallback +classification. Unrecognized content SHALL be reported for manual handling, never auto-converted. + +#### Scenario: Known shape detected +- **WHEN** detection runs on a project with a monolithic multi-decision file +- **THEN** the file is classified as monolithic and its individual decisions are enumerated as + migration units + +#### Scenario: Unknown shape falls back safely +- **WHEN** detection encounters ADR-like content matching no known shape +- **THEN** it is classified `unrecognized` and listed in the report without any conversion + attempt + +### Requirement: Mechanical/LLM field-mapping boundary +During conversion, fields that are structurally unambiguous in the source (Status and Date from +frontmatter or clear headings; title; ID assignment; source provenance) SHALL be filled +mechanically. Only interpretive fields (Consequences, Alternatives rejected, monolithic-file +decision splitting) MAY be LLM-filled, and every LLM-filled field SHALL be flagged. The +migration SHALL never invent a Decision or Context field absent from the source — such fields +SHALL carry an explicit not-stated-in-source marker and lower the file's confidence. + +#### Scenario: Unambiguous field filled mechanically +- **WHEN** a source ADR has a `Status: Accepted` heading and a date +- **THEN** the converted file's `status` and `date` are set without any LLM call + +#### Scenario: Missing core field is not invented +- **WHEN** a source decision has no discernible Context +- **THEN** the converted Context section contains a not-stated-in-source marker and the file is + flagged low-confidence + +### Requirement: Single uncertainty-flagging mechanism +Migration uncertainty SHALL be surfaced through exactly one mechanism: a +`migration_confidence: low|medium|high` frontmatter field per converted file, plus one +migration report file (`docs/adr/migration-report.md`) summarizing per-file confidence, the +source-to-new mapping, and the overall low-confidence flag rate. Scattered inline uncertainty +comments SHALL NOT be used. + +#### Scenario: Report aggregates confidence +- **WHEN** a migration completes +- **THEN** the report lists every converted file with its confidence, its source location, and + the overall flag rate + +### Requirement: Non-destructive, dual-existence conversion +Migration SHALL only write new files under `docs/adr/`; existing ADR content SHALL never be +deleted, moved, or edited by the migration. Removal of the old system SHALL happen only as an +explicit, separate, user-approved step offered after the migration report exists. + +#### Scenario: Old system untouched +- **WHEN** a migration run completes +- **THEN** every pre-existing ADR source file is byte-identical to its pre-migration state + +#### Scenario: Deletion requires explicit approval +- **WHEN** the user has not explicitly approved old-system removal +- **THEN** no tooling path deletes the old content, and the removal offer appears only after the + report is available for review + +### Requirement: Pilot gate before broad rollout +The migration SHALL be piloted on 2–3 real surveyed projects, and the low-confidence flag rate +SHALL be measured against an explicit threshold recorded at pilot time before the heuristics are +treated as good enough for wider rollout. + +#### Scenario: High flag rate blocks rollout +- **WHEN** a pilot run's low-confidence rate exceeds the recorded threshold +- **THEN** the heuristics are tightened and the pilot re-run before migrating further projects diff --git a/openspec/specs/adr-plugin-packaging/spec.md b/openspec/specs/adr-plugin-packaging/spec.md new file mode 100644 index 0000000..8c64a6b --- /dev/null +++ b/openspec/specs/adr-plugin-packaging/spec.md @@ -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:` (`create`, `init`, `migrate`, `find`), +with no `commands/` dispatcher directory — matching the cc-os plugin/skill naming convention. + +#### Scenario: Skills register under verb-first names +- **WHEN** the plugin is installed and `claude plugin details os-adr@local-plugins` is run +- **THEN** the listed skills are `create`, `init`, `migrate`, and `find`, and no `commands/` + directory exists in the plugin + +### Requirement: Source of truth in cc-os with symlinked install +The plugin source SHALL live git-tracked at `cc-os/plugins/os-adr/` and be installed via a +symlink `~/.claude/plugins/os-adr` pointing at it, matching the `os-vault`/`os-doc-hygiene`/ +`os-orchestration` pattern. + +#### Scenario: Symlinked install +- **WHEN** installation is complete +- **THEN** `~/.claude/plugins/os-adr` is a symlink resolving to the git-tracked plugin directory + +### Requirement: Full three-place marketplace registration +Installation SHALL update all three registration surfaces — the plugin directory/symlink, the +`local-plugins` marketplace manifest (`~/.claude/plugins/.claude-plugin/marketplace.json`), and +`settings.json` `enabledPlugins` — then refresh plugin-manager state via +`claude plugin marketplace update` and `claude plugin install`, and verify skill registration +with `claude plugin list` and `claude plugin details`. + +#### Scenario: Skills resolve after install +- **WHEN** the registration procedure completes +- **THEN** `claude plugin list` shows `os-adr@local-plugins` enabled and its skills are + invocable as slash commands in a fresh session + +### Requirement: Hooks wired through plugin hooks.json +Plugin hooks SHALL be declared in `hooks/hooks.json` using `${CLAUDE_PLUGIN_ROOT}`-relative +commands (the `os-doc-hygiene` pattern), not by absolute paths in `settings.json`, so hook +registration cannot silently outlive or diverge from plugin registration. + +#### Scenario: Hook path is plugin-relative +- **WHEN** the plugin's hook configuration is inspected +- **THEN** the SessionStart command references `${CLAUDE_PLUGIN_ROOT}` and no os-adr hook entry + exists in `settings.json` + +### Requirement: OO core with tested deterministic behavior +The plugin's core logic SHALL be implemented as an object-oriented Ruby library (small +single-responsibility classes, dependency-injected paths/IO, no globals) exposed through thin +CLI entry points, with a thin Python script only for the SessionStart hook. All deterministic +behaviors (ID assignment, index regeneration, shape detection, existence check, retrieval layers +1–2) SHALL have automated tests runnable without any LLM, including golden-example fixtures. + +#### Scenario: Deterministic tests run model-free +- **WHEN** the plugin's test suite runs on a machine with no model access +- **THEN** all tests for ID assignment, index regeneration, detection, existence check, and + frontmatter retrieval pass + +#### Scenario: Skills delegate to CLI entry points +- **WHEN** a skill performs a mechanical operation (numbering, indexing, detection, filtering) +- **THEN** it does so by invoking a `bin/` CLI rather than re-implementing the logic in prompt + instructions diff --git a/openspec/specs/adr-retrieval/spec.md b/openspec/specs/adr-retrieval/spec.md new file mode 100644 index 0000000..18aac20 --- /dev/null +++ b/openspec/specs/adr-retrieval/spec.md @@ -0,0 +1,53 @@ +# Spec: ADR Retrieval + +## Purpose + +Defines how relevant ADRs are found by the `os-adr` plugin: deterministic-first layered narrowing (frontmatter match, status filter, Graphify expansion) with AI judgment applied only to the narrowed candidate set, graceful degradation without a project graph, a mid-task-callable query surface, and eval-readiness sketches accompanying the build. + +## Requirements + +### Requirement: Deterministic-first retrieval layering +ADR retrieval SHALL narrow candidates through ordered layers: (1) path/component match of query +inputs against each ADR's `affected-paths`/`affected-components` frontmatter; (2) status filter +admitting only `Accepted` ADRs by default, with an explicit override to include other statuses; +(3) Graphify graph traversal when a project graph exists; (4) AI judgment applied only to the +already-narrowed candidate set. AI judgment SHALL never be applied to the full ADR corpus. + +#### Scenario: Path match narrows first +- **WHEN** retrieval is queried with file paths being edited +- **THEN** ADRs whose `affected-paths` match are selected before any model involvement + +#### Scenario: Default status filter +- **WHEN** retrieval runs without an override +- **THEN** only `Accepted` ADRs appear in the candidate set + +#### Scenario: AI sees only the narrowed set +- **WHEN** the AI-judgment layer runs +- **THEN** it receives only the candidates surviving layers 1–3, not the full corpus + +### Requirement: Graph layer degrades gracefully +WHEN no `graphify-out/` project graph exists (or ADRs are not yet indexed as graph nodes), +retrieval SHALL still function using layers 1, 2, and 4, without error. + +#### Scenario: No project graph present +- **WHEN** retrieval runs in a project that has never been onboarded to Graphify +- **THEN** results are produced from frontmatter matching and status filtering alone + +### Requirement: Mid-task-callable query surface +Retrieval SHALL be exposed as a `find` skill (`/os-adr:find`) backed by a deterministic CLI, +callable at any point during a task — not only at SessionStart — so an agent can check +in-progress work against existing decisions. + +#### Scenario: Query during a task +- **WHEN** an agent mid-task invokes `/os-adr:find` with the files or topic it is working on +- **THEN** it receives the relevant Accepted ADR candidates for those inputs + +### Requirement: Eval-readiness sketches accompany the build +Held-out evaluation scenario shapes (write-trigger and retrieval) SHALL be sketched in a design +doc before the plugin skeleton is finalized, and any plugin surface those sketches require SHALL +be part of the initial build. The evaluation itself SHALL NOT be part of this change. + +#### Scenario: Sketches exist before skeleton freeze +- **WHEN** the plugin skeleton is finalized +- **THEN** a scenario-sketch document exists covering at least one write-trigger and one + retrieval scenario shape diff --git a/openspec/specs/adr-session-awareness/spec.md b/openspec/specs/adr-session-awareness/spec.md new file mode 100644 index 0000000..10d6253 --- /dev/null +++ b/openspec/specs/adr-session-awareness/spec.md @@ -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 diff --git a/plugins/os-adr/.claude-plugin/plugin.json b/plugins/os-adr/.claude-plugin/plugin.json new file mode 100644 index 0000000..c684e00 --- /dev/null +++ b/plugins/os-adr/.claude-plugin/plugin.json @@ -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." +} diff --git a/plugins/os-adr/bin/adr-detect b/plugins/os-adr/bin/adr-detect new file mode 100755 index 0000000..ada4274 --- /dev/null +++ b/plugins/os-adr/bin/adr-detect @@ -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) diff --git a/plugins/os-adr/bin/adr-find b/plugins/os-adr/bin/adr-find new file mode 100755 index 0000000..20aa8b0 --- /dev/null +++ b/plugins/os-adr/bin/adr-find @@ -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) diff --git a/plugins/os-adr/bin/adr-init b/plugins/os-adr/bin/adr-init new file mode 100755 index 0000000..305407a --- /dev/null +++ b/plugins/os-adr/bin/adr-init @@ -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 diff --git a/plugins/os-adr/bin/adr-migrate b/plugins/os-adr/bin/adr-migrate new file mode 100755 index 0000000..8d870b8 --- /dev/null +++ b/plugins/os-adr/bin/adr-migrate @@ -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 diff --git a/plugins/os-adr/bin/adr-new b/plugins/os-adr/bin/adr-new new file mode 100755 index 0000000..cd7b831 --- /dev/null +++ b/plugins/os-adr/bin/adr-new @@ -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 diff --git a/plugins/os-adr/hooks/__pycache__/session_start.cpython-314.pyc b/plugins/os-adr/hooks/__pycache__/session_start.cpython-314.pyc new file mode 100644 index 0000000..677cb7c Binary files /dev/null and b/plugins/os-adr/hooks/__pycache__/session_start.cpython-314.pyc differ diff --git a/plugins/os-adr/hooks/hooks.json b/plugins/os-adr/hooks/hooks.json new file mode 100644 index 0000000..f142c39 --- /dev/null +++ b/plugins/os-adr/hooks/hooks.json @@ -0,0 +1,16 @@ +{ + "hooks": { + "SessionStart": [ + { + "matcher": "startup|resume", + "hooks": [ + { + "type": "command", + "command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/session_start.py", + "timeout": 5 + } + ] + } + ] + } +} diff --git a/plugins/os-adr/hooks/session_start.py b/plugins/os-adr/hooks/session_start.py new file mode 100644 index 0000000..7fc81fa --- /dev/null +++ b/plugins/os-adr/hooks/session_start.py @@ -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()) diff --git a/plugins/os-adr/invariants.md b/plugins/os-adr/invariants.md new file mode 100644 index 0000000..9fc0001 --- /dev/null +++ b/plugins/os-adr/invariants.md @@ -0,0 +1,53 @@ +# os-adr invariants + +Behavioral invariants of the plugin. Changing any of these requires explicit human approval and +a matching update to the tests named beside it. Suite entry points: `ruby tests/all.rb` and +`python3 tests/hook_test.py` (both model-free). + +1. **Non-destructiveness (migration).** Migration writes only new files under `docs/adr/`; + existing source content is never deleted, moved, or edited by any tooling path. Old-system + removal is a separate, explicit, user-approved step (skill-mediated, never automatic). + — `migrator_test.rb#test_sources_stay_byte_identical`, pilot sha256 checks. + +2. **Index regeneration, never accretion.** `docs/adr/README.md`'s table is regenerated in full + from directory contents on every ADR write; it is never appended to or hand-edited, and + self-heals after manual edits. Content outside the markers is preserved. + — `index_test.rb` (golden fixture, self-heal, prose preservation, generated-header cases). + +3. **IDs come from the directory, never the index.** Next ID = max existing filename ID + 1, + derived by scanning `docs/adr/`; a stale or missing index never influences numbering. + — `repository_test.rb#test_next_id_ignores_{missing,stale}_index`. + +4. **No LLM in the hook.** The SessionStart hook is a pure function of filesystem state (ADR + dir, index, suppression state, snooze stamp) — no model call, no network, no Ruby spin-up; + always exits 0; emits nothing (and creates no state) outside a git project. Its existence + rule is identical to `Adr::Repository#exists?`. + — `hook_test.py` (all cases; `PythonRubyAgreementTest` pins the shared rule). + +5. **Single uncertainty-flagging mechanism.** Migration uncertainty surfaces only via + `migration_confidence: low|medium|high` frontmatter plus `docs/adr/migration-report.md`. + No scattered inline uncertainty comments. + — `migrator_test.rb#test_report_lists_files_confidence_sources_and_flag_rate`. + +6. **Core fields are never invented.** A Decision or Context absent from a migration source is + written as `_not stated in source_` and flags the file low-confidence; LLM fills apply only + to pending-marker fields (enforced structurally by `apply_fills!` — a stray fill key is a + no-op), and an LLM-filled file stays flagged (≤ medium). + — `migrator_test.rb#test_missing_core_fields_*`, `#test_apply_fills_only_touches_pending_fields`. + +7. **Unrecognized content is reported, never auto-converted.** Content matching no known shape + — including multi-decision files without mechanically splittable unit headings — is listed + in the report for manual handling only. + — `detector_test.rb#test_multi_decision_file_without_unit_headings_is_unrecognized`, + `migrator_test.rb#test_unrecognized_content_is_reported_never_converted`. + +8. **Deterministic-first retrieval.** `adr-find` narrows by frontmatter path/component match, + then Accepted-only status filter (explicit override), then optional one-hop graph expansion + that degrades gracefully (absent or corrupt graph → layers 1–2). AI judgment happens only in + the `find` skill, only over the CLI's returned candidates. + — `finder_test.rb` (all cases, including both degradation paths). + +9. **Skills delegate mechanics to `bin/` CLIs.** Numbering, templating, indexing, supersession + bookkeeping, detection, conversion, and retrieval filtering are never re-implemented in + prompt instructions. + — by construction of `skills/*/SKILL.md`; `cli_test.rb` pins the CLI contracts. diff --git a/plugins/os-adr/lib/adr.rb b/plugins/os-adr/lib/adr.rb new file mode 100644 index 0000000..683f212 --- /dev/null +++ b/plugins/os-adr/lib/adr.rb @@ -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 diff --git a/plugins/os-adr/lib/adr/detector.rb b/plugins/os-adr/lib/adr/detector.rb new file mode 100644 index 0000000..5207fbf --- /dev/null +++ b/plugins/os-adr/lib/adr/detector.rb @@ -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 diff --git a/plugins/os-adr/lib/adr/finder.rb b/plugins/os-adr/lib/adr/finder.rb new file mode 100644 index 0000000..92b2f1f --- /dev/null +++ b/plugins/os-adr/lib/adr/finder.rb @@ -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 diff --git a/plugins/os-adr/lib/adr/index.rb b/plugins/os-adr/lib/adr/index.rb new file mode 100644 index 0000000..f1fa316 --- /dev/null +++ b/plugins/os-adr/lib/adr/index.rb @@ -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 = "" + END_MARKER = "" + + DEFAULT_SKELETON = <<~MD + + + # 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 diff --git a/plugins/os-adr/lib/adr/migration_report.rb b/plugins/os-adr/lib/adr/migration_report.rb new file mode 100644 index 0000000..5e24abc --- /dev/null +++ b/plugins/os-adr/lib/adr/migration_report.rb @@ -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 + + + # 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 diff --git a/plugins/os-adr/lib/adr/migrator.rb b/plugins/os-adr/lib/adr/migrator.rb new file mode 100644 index 0000000..0c51399 --- /dev/null +++ b/plugins/os-adr/lib/adr/migrator.rb @@ -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 diff --git a/plugins/os-adr/lib/adr/record.rb b/plugins/os-adr/lib/adr/record.rb new file mode 100644 index 0000000..2ae1f64 --- /dev/null +++ b/plugins/os-adr/lib/adr/record.rb @@ -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 diff --git a/plugins/os-adr/lib/adr/repository.rb b/plugins/os-adr/lib/adr/repository.rb new file mode 100644 index 0000000..0f94bb4 --- /dev/null +++ b/plugins/os-adr/lib/adr/repository.rb @@ -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 diff --git a/plugins/os-adr/lib/adr/template.rb b/plugins/os-adr/lib/adr/template.rb new file mode 100644 index 0000000..b912c8d --- /dev/null +++ b/plugins/os-adr/lib/adr/template.rb @@ -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 diff --git a/plugins/os-adr/skills/create/SKILL.md b/plugins/os-adr/skills/create/SKILL.md new file mode 100644 index 0000000..bf17859 --- /dev/null +++ b/plugins/os-adr/skills/create/SKILL.md @@ -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 +``` + +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. diff --git a/plugins/os-adr/skills/find/SKILL.md b/plugins/os-adr/skills/find/SKILL.md new file mode 100644 index 0000000..d512d5a --- /dev/null +++ b/plugins/os-adr/skills/find/SKILL.md @@ -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 \ + --paths \ + --components # 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). diff --git a/plugins/os-adr/skills/init/SKILL.md b/plugins/os-adr/skills/init/SKILL.md new file mode 100644 index 0000000..5731441 --- /dev/null +++ b/plugins/os-adr/skills/init/SKILL.md @@ -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 +``` + +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. diff --git a/plugins/os-adr/skills/migrate/SKILL.md b/plugins/os-adr/skills/migrate/SKILL.md new file mode 100644 index 0000000..440aeb4 --- /dev/null +++ b/plugins/os-adr/skills/migrate/SKILL.md @@ -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 +``` + +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 +``` + +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 --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. diff --git a/plugins/os-adr/templates/adr.md b/plugins/os-adr/templates/adr.md new file mode 100644 index 0000000..4ff6321 --- /dev/null +++ b/plugins/os-adr/templates/adr.md @@ -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}} diff --git a/plugins/os-adr/tests/all.rb b/plugins/os-adr/tests/all.rb new file mode 100644 index 0000000..8336904 --- /dev/null +++ b/plugins/os-adr/tests/all.rb @@ -0,0 +1,2 @@ +# Run the whole suite: ruby tests/all.rb +Dir.glob(File.join(__dir__, "*_test.rb")).sort.each { |f| require f } diff --git a/plugins/os-adr/tests/cli_test.rb b/plugins/os-adr/tests/cli_test.rb new file mode 100644 index 0000000..6c698a4 --- /dev/null +++ b/plugins/os-adr/tests/cli_test.rb @@ -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 diff --git a/plugins/os-adr/tests/detector_test.rb b/plugins/os-adr/tests/detector_test.rb new file mode 100644 index 0000000..d9495eb --- /dev/null +++ b/plugins/os-adr/tests/detector_test.rb @@ -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 diff --git a/plugins/os-adr/tests/finder_test.rb b/plugins/os-adr/tests/finder_test.rb new file mode 100644 index 0000000..9e2be6a --- /dev/null +++ b/plugins/os-adr/tests/finder_test.rb @@ -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 diff --git a/plugins/os-adr/tests/hook_test.py b/plugins/os-adr/tests/hook_test.py new file mode 100644 index 0000000..bc5a487 --- /dev/null +++ b/plugins/os-adr/tests/hook_test.py @@ -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() diff --git a/plugins/os-adr/tests/index_test.rb b/plugins/os-adr/tests/index_test.rb new file mode 100644 index 0000000..c6addcd --- /dev/null +++ b/plugins/os-adr/tests/index_test.rb @@ -0,0 +1,79 @@ +require_relative "test_helper" + +class IndexTest < Minitest::Test + include AdrTestHelpers + + GOLDEN = <<~MD + + + # Architecture Decision Records + + One file per decision, `NNNN-kebab-title.md`, created via `/os-adr:create`. + + + | 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 | + + 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 diff --git a/plugins/os-adr/tests/migrator_test.rb b/plugins/os-adr/tests/migrator_test.rb new file mode 100644 index 0000000..5255b81 --- /dev/null +++ b/plugins/os-adr/tests/migrator_test.rb @@ -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 diff --git a/plugins/os-adr/tests/record_test.rb b/plugins/os-adr/tests/record_test.rb new file mode 100644 index 0000000..93e73f9 --- /dev/null +++ b/plugins/os-adr/tests/record_test.rb @@ -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 diff --git a/plugins/os-adr/tests/repository_test.rb b/plugins/os-adr/tests/repository_test.rb new file mode 100644 index 0000000..ff0fb9b --- /dev/null +++ b/plugins/os-adr/tests/repository_test.rb @@ -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 diff --git a/plugins/os-adr/tests/test_helper.rb b/plugins/os-adr/tests/test_helper.rb new file mode 100644 index 0000000..d3af48f --- /dev/null +++ b/plugins/os-adr/tests/test_helper.rb @@ -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