198 lines
11 KiB
Markdown
198 lines
11 KiB
Markdown
# 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).
|