122 lines
3.7 KiB
Python
122 lines
3.7 KiB
Python
|
|
#!/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). Record a new decision of consequence with "
|
||
|
|
"/os-adr:create; before changing decided-on behavior, check relevant "
|
||
|
|
"decisions with /os-adr:find."
|
||
|
|
)
|
||
|
|
|
||
|
|
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())
|