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