Build os-adr plugin: ADR authoring, session hook, migration, retrieval
Implements OpenSpec change add-os-adr-plugin (archived as
2026-07-03-add-os-adr-plugin; delta specs synced to openspec/specs/).
- plugins/os-adr/: Ruby OO core (Record, Repository, Index, Template,
Detector, Migrator, MigrationReport, Finder) behind thin bin/ CLIs;
Python SessionStart hook via plugin-relative hooks.json; four skills
(create, init, migrate, find); invariants.md; 47 Ruby + 15 Python
model-free tests, all passing.
- Migration pilot passed (threshold 25%): viking 0%, delta-refinery 0%,
llf-schema 8.3%. Retrieval verified against the real llf-schema graph;
resolved that ADRs need no Graphify tag/edge convention (layer 3
expands query paths via node source_files).
- docs/adr-system/: requirements (04), PRD (05), eval sketches (06).
- ADR-020 recorded; CLAUDE.md Implemented Components updated with the
locked rollout order (pilot projects -> cc-os retrofit -> wider).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 18:24:19 +00:00
|
|
|
#!/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/ "
|
2026-07-04 18:49:15 +00:00
|
|
|
"(index: docs/adr/README.md). WHEN your task involves making an "
|
|
|
|
|
"architecture-level choice, or changing/replacing an approach that "
|
|
|
|
|
"already exists in this codebase (persistence, protocols, libraries, "
|
|
|
|
|
"conventions, error handling) -> FIRST run /os-adr:find to check whether "
|
|
|
|
|
"a decision already covers it. Mechanical rule: BEFORE your first edit "
|
|
|
|
|
"to any existing source or config file -> run /os-adr:find on the paths "
|
|
|
|
|
"you are about to touch (one cheap deterministic CLI call; additions "
|
|
|
|
|
"count — a new method can bypass a decided constraint). WHEN "
|
|
|
|
|
"you make such a choice -> record it "
|
|
|
|
|
"with /os-adr:create. If find shows an Accepted ADR covering the approach "
|
|
|
|
|
"you are changing, you are reversing a recorded decision: the task is "
|
|
|
|
|
"NOT COMPLETE until the superseding ADR is created with /os-adr:create "
|
|
|
|
|
"(supersedes: NNNN) — code changed but ADR unrecorded means the decision "
|
|
|
|
|
"history now asserts the opposite of what the code does."
|
Build os-adr plugin: ADR authoring, session hook, migration, retrieval
Implements OpenSpec change add-os-adr-plugin (archived as
2026-07-03-add-os-adr-plugin; delta specs synced to openspec/specs/).
- plugins/os-adr/: Ruby OO core (Record, Repository, Index, Template,
Detector, Migrator, MigrationReport, Finder) behind thin bin/ CLIs;
Python SessionStart hook via plugin-relative hooks.json; four skills
(create, init, migrate, find); invariants.md; 47 Ruby + 15 Python
model-free tests, all passing.
- Migration pilot passed (threshold 25%): viking 0%, delta-refinery 0%,
llf-schema 8.3%. Retrieval verified against the real llf-schema graph;
resolved that ADRs need no Graphify tag/edge convention (layer 3
expands query paths via node source_files).
- docs/adr-system/: requirements (04), PRD (05), eval sketches (06).
- ADR-020 recorded; CLAUDE.md Implemented Components updated with the
locked rollout order (pilot projects -> cc-os retrofit -> wider).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 18:24:19 +00:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
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())
|