cc-os/plugins/os-adr/hooks/session_start.py

133 lines
4.5 KiB
Python
Raw Normal View History

#!/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). 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."
)
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())