cc-os/openspec/changes/archive/2026-07-03-add-os-adr-plugin/design.md

11 KiB
Raw Blame History

Design: add-os-adr-plugin

Context

The requirements are locked in docs/adr-system/04-plugin-requirements.md and phased in docs/adr-system/05-plugin-prd.md — this design decides how to build them, not what they are. cc-os already has three global plugins establishing the conventions this one must follow:

  • os-vault — Python hooks (thin entry points + shared config.py/hook_io.py modules), hooks wired by absolute path in settings.json, verb-first skills, no commands/ dir.
  • os-doc-hygiene — plugin-relative hooks/hooks.json using ${CLAUDE_PLUGIN_ROOT}, deterministic zero-token SessionStart reminder with per-project gitignored state dir (.dochygiene/) and snooze, deterministic-scanner + generative-only-where-needed split, invariants.md + golden-example fixtures.
  • os-orchestration — minimal SessionStart context injection.

Constraints: single template everywhere (no variants), docs/adr/ location, mechanical-first with LLM reserved for judgment, non-destructive migration, OO per Sandi Metz, eval deferred.

Goals / Non-Goals

Goals:

  • A working os-adr plugin at cc-os/plugins/os-adr/, installed via the local-plugins marketplace, covering PRD Phases 14 (authoring, session hook, migration, retrieval).
  • Every deterministic behavior testable without a model; every LLM-assisted behavior isolated behind a skill so the mechanical core stays pure.
  • Eval scenario sketches written down so the plugin surface is eval-ready.

Non-Goals:

  • The unprompted-behavior evaluation itself (separate later stage, autoresearch loop).
  • Retrofitting cc-os's own ADR file, bulk rollout beyond the pilot projects.
  • Per-project template variants, severity/enforcement fields, multi-editor governance.

Decisions

D1 — Plugin layout and packaging

cc-os/plugins/os-adr/ with:

os-adr/
  .claude-plugin/plugin.json     # name: os-adr
  hooks/hooks.json               # SessionStart wiring via ${CLAUDE_PLUGIN_ROOT}
  hooks/session_start.py         # thin Python entry point (deterministic check)
  lib/                           # Ruby OO core (adr/ classes)
  bin/                           # Ruby CLI entry points the skills invoke
  templates/adr.md               # the customized-Nygard template
  skills/create/SKILL.md
  skills/init/SKILL.md
  skills/migrate/SKILL.md
  skills/find/SKILL.md
  tests/                         # unit tests + golden-example fixtures
  invariants.md

Install follows the CLAUDE.md three-place procedure: symlink ~/.claude/plugins/os-adr → cc-os/plugins/os-adr/, add "os-adr"/"./os-adr" to the local-plugins marketplace manifest, enable os-adr@local-plugins in settings.json, then claude plugin marketplace update + claude plugin install, and verify with claude plugin details (skills registered is the check that catches manifest mistakes).

Why: matches all three existing plugins; the manifest step is the known failure mode (ADR-018) so it's explicit in tasks.

D2 — Hook wiring: plugin hooks.json, not settings.json absolute paths

Use the os-doc-hygiene pattern (hooks/hooks.json with ${CLAUDE_PLUGIN_ROOT}, matcher startup|resume, short timeout). Why over the os-vault absolute-path pattern: it keeps the plugin self-contained, survives repo moves, and hook registration then fails with plugin registration rather than silently diverging from it (the masking problem ADR-018 documented).

D3 — Language split: Ruby OO core, Python only for the hook entry point

  • lib/ is Ruby, Sandi Metz style: small single-responsibility classes (Adr::Template, Adr::Record, Adr::Index, Adr::Repository, Adr::Detector (migration shape detection), Adr::Migrator, Adr::Finder (retrieval pipeline)), dependency injection (paths and IO handed in, no globals), tell-don't-ask.
  • bin/ exposes thin Ruby CLIs (adr-new, adr-init, adr-detect, adr-migrate, adr-find) that skills call via Bash. (No standalone adr-index — index regeneration is embedded in every writing CLI, per D4.)
  • The SessionStart hook is a thin Python script that does the existence check inline (stat two paths, read one suppression file, emit JSON) — it does not shell out to Ruby.

Why: the user's standing Ruby preference for the core; Python for the hook matches the existing hook pattern and keeps session-start latency free of Ruby interpreter spin-up. The hook logic is ~20 lines and purely mechanical, so duplicating "does docs/adr/ exist" between Python and Ruby is acceptable coupling (alternative — Python wrapping the Ruby core — was rejected for latency and dependency-surface reasons at session start).

D4 — Template and ID/index mechanics

  • Template (templates/adr.md): frontmatter id, date, status (Proposed/Accepted/Superseded/Deprecated), supersedes, superseded-by, affected-paths, affected-components, migration_confidence (migration-written files only); body sections ## Context, ## Decision, ## Consequences, ## Alternatives rejected.
  • Files: docs/adr/NNNN-kebab-title.md, 4-digit zero-padded sequential ID; next ID = max existing + 1, derived by scanning the directory (never trusting the index).
  • Index: docs/adr/README.md, a generated table (ID, title, status, date) between markers; regenerated in full from the directory on every write by Adr::Index — never hand-edited, never appended-to incrementally. Setting superseded-by on an old ADR when a new one declares supersedes is also mechanical (adr-new does both edits).

Why full regeneration over append: idempotent, self-healing after manual file edits, trivial to test against golden fixtures; append-only indexes drift.

D5 — SessionStart behavior and suppression

  • docs/adr/ + index present → inject one short additionalContext line: the system exists, how to write (/os-adr:create) and query (/os-adr:find) it. Near-zero tokens, no LLM.
  • Absent → one-line suggestion to run /os-adr:init or /os-adr:migrate.
  • Suppression: per-project gitignored state dir .os-adr/ containing suppress (permanent opt-out for deliberately ADR-less projects) — same shape as .dochygiene/. /os-adr:init clears it; hook exits silently when present. A once-per-day snooze stamp (last_reminded) additionally rate-limits the absent-case nag, mirroring os-doc-hygiene.

D6 — Migration pipeline (mechanical/LLM boundary)

adr-detect (mechanical) classifies existing content into the surveyed shapes — numbered per-file dirs, dated single files, monolithic multi-decision files, prose-embedded — plus unrecognized as the fallback (reported, never auto-converted). adr-migrate then, per source decision:

  • Heuristic fill (mechanical): fields structurally unambiguous in the source — Status/Date from frontmatter or clear headings, title, ID assignment, source-file provenance link.
  • LLM fill (skill-mediated, flagged): interpretive fields only — Consequences, Alternatives-rejected, splitting a monolithic file into per-decision units. The Ruby core emits a work manifest of exactly which fields need LLM fill; the migrate skill has the model fill them and hands results back to the core for writing. Never invent Decision or Context from nothing — if absent in the source, the field carries an explicit _not stated in source_ marker and the file is flagged.
  • Flagging (one mechanism): migration_confidence: low|medium|high frontmatter per file + one docs/adr/migration-report.md summarizing per-file confidence, source mapping, and the overall flag rate. No scattered inline comments.
  • Non-destructive: new files only; source files untouched. Old-system deletion is a separate, explicit user-approved step the skill offers only after the report is reviewed.
  • Pilot gate: run on 23 surveyed projects (viking-warrior-training-log, delta-refinery, llf-schema); if the low-confidence flag rate exceeds the threshold set at pilot time (recorded in the report), tighten heuristics before wider rollout. Build-phase gate, not a shipped feature.

D7 — Retrieval stack (adr-find / /os-adr:find)

Deterministic layers first, each narrowing the candidate set:

  1. Path/component match: query paths (e.g. files being edited) against affected-paths / affected-components frontmatter across docs/adr/.
  2. Status filter: Accepted only by default (--all-statuses to override).
  3. Graphify traversal: when graphify-out/ exists, expand the query paths one hop via graph-node source_file attributes and re-match the expanded set against affected-paths — ADRs themselves are never graph nodes (resolved during Phase 4; see ADR-020). Reuses os-vault's project-graph infrastructure; degrades gracefully to layers 12 when absent.
  4. AI judgment (skill-mediated) only over the narrowed set — the find skill presents the candidates and the model picks/ranks; it never sweeps the full corpus.

This is a mid-task-callable surface (skill + CLI), satisfying the PRD's eval-readiness note — retrieval is not locked to SessionStart.

D8 — Eval-readiness sketches

A docs/adr-system/06-eval-scenarios.md sketch (write-trigger + retrieval scenario shapes per the PRD) is written during this change, and the SessionStart context line explicitly names both surfaces (create, find) so an agent can discover them unprompted. The eval itself is out of scope.

Risks / Trade-offs

  • [Duplicated existence-check logic between Python hook and Ruby core] → it's two path checks; covered by a shared fixture test asserting both agree on the same tree.
  • [Ruby runtime required at skill time] → acceptable: this is the user's own machine and standing preference; hook path (the always-runs surface) has no Ruby dependency.
  • [Migration LLM fills may be wrong] → confidence flags + report + pilot gate before rollout; non-destructiveness means a bad migration is discardable (delete docs/adr/, rerun).
  • [Graphify layer depends on os-vault onboarding state] → retrieval degrades to layers 12; layer 3 is an enhancement, not a dependency.
  • [Monolithic-file splitting is the hardest detection case] → treated as LLM-boundary work with per-unit flags; cc-os's own 19-ADR file is deliberately excluded from the pilot.
  • [Index regeneration overwrites manual index edits] → by design (index is declared never-hand-edited in its own header comment).

Migration Plan

Build order matches PRD phases: Phase 1 (template+create+index) → Phase 2 (hook) → Phase 3 (migration) → Phase 4 (retrieval), each verifiable independently. Install/registration happens after Phase 1 so create is dogfoodable early. Rollback: uninstall plugin + remove symlink and manifest entry; projects keep their docs/adr/ files (plain markdown, tool-independent).

Open Questions

  • Exact low-confidence flag-rate threshold for the pilot gate — set empirically at pilot time and recorded in the migration report (deliberately not guessed now).
  • Whether ADR frontmatter needs a dedicated Graphify tag/edge convention for layer-3 traversalResolved during Phase 4 (2026-07-03): no convention needed. Layer 3 expands the query paths one hop via graph-node source_files; 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).