#!/usr/bin/env python3 """ reminder.py — deterministic SessionStart reminder for doc-hygiene. This is the only thing the `SessionStart` hook invokes. It: 0. No-ops silently unless cwd is inside a real git project (invariant #3 guard — see `_is_git_project`; flagged for human review). 1. Resolves the project root from cwd (git root). 2. Reads lifecycle timestamps from the in-project state store. 3. Decides — purely, with an injected clock — whether docs are stale and whether today's snooze permits a banner. 4. On a banner decision: emits a `systemMessage` JSON object on stdout (a user-facing, zero-token banner) and writes `last_reminded = now`. 5. On a silent decision: writes nothing and prints nothing. 6. ALWAYS exits 0 so the session is never blocked. Design invariants honoured: #1 Hook only reminds — spends zero AI tokens, runs no scan / no model, mutates nothing except `last_reminded`, never blocks the session. #2 Snooze ≤ once per calendar day while stale, keyed on `last_reminded`. #3 State read/written only via the in-project StateStore. #6 Deterministic-first — no model is imported or invoked here. The decision is a PURE function (`Reminder.decide`) over `(last_check, last_reminded, now, threshold_days)` so the snooze and staleness behaviour are unit-testable with a frozen clock and no real session. """ from __future__ import annotations import json import sys from datetime import datetime, timedelta, timezone from enum import Enum from pathlib import Path from typing import Optional # --------------------------------------------------------------------------- # Path bootstrap so `python reminder.py` works regardless of cwd. # --------------------------------------------------------------------------- _SCRIPTS_DIR = Path(__file__).resolve().parent if str(_SCRIPTS_DIR) not in sys.path: sys.path.insert(0, str(_SCRIPTS_DIR)) from state_store import ( # noqa: E402 RealClock, RealRootFS, StateStore, resolve_project_root, ) # --------------------------------------------------------------------------- # Configuration constant # --------------------------------------------------------------------------- # Staleness threshold: days since `last_check` before the reminder fires. # This is an implementation detail (see design Open Questions), NOT a spec'd # contract — a per-project override is a later concern. Chosen at the # motivational, not correctness-critical, end of the 14–30 day range. DEFAULT_THRESHOLD_DAYS = 21 # --------------------------------------------------------------------------- # Pure decision (task 4.1 / design D5) # --------------------------------------------------------------------------- class Decision(Enum): """Outcome of the reminder decision.""" SILENT = "silent" BANNER = "banner" class Reminder: """ Pure staleness + snooze decision for the SessionStart reminder. The decision is a total function of its four inputs and performs no I/O, so the snooze boundary (invariant #2) and staleness rule (invariant #1's "only reminds") are deterministically testable with an injected clock. Semantics (design D5): stale = last_check is None OR (now - last_check) > threshold if not stale: SILENT # fresh docs if last_reminded is None: BANNER # stale, never reminded if same_calendar_day(last_reminded, now): SILENT # snoozed today BANNER # stale, new day Snooze keys on **calendar day** (UTC `.date()`), not a 24h sliding window, so two sessions 20 minutes apart that straddle midnight still re-banner the second day — matching invariant #2's "at most once per calendar day". """ def __init__(self, threshold_days: int = DEFAULT_THRESHOLD_DAYS) -> None: self._threshold = timedelta(days=threshold_days) @staticmethod def _as_utc(ts: datetime) -> datetime: """Normalise a datetime to UTC (treat naive as UTC) for safe compares.""" if ts.tzinfo is None: return ts.replace(tzinfo=timezone.utc) return ts.astimezone(timezone.utc) def is_stale(self, last_check: Optional[datetime], now: datetime) -> bool: """True when docs have never been checked or the check is older than the threshold.""" if last_check is None: return True return (self._as_utc(now) - self._as_utc(last_check)) > self._threshold def decide( self, last_check: Optional[datetime], last_reminded: Optional[datetime], now: datetime, ) -> Decision: """Return BANNER or SILENT given the state timestamps and the current time.""" if not self.is_stale(last_check, now): return Decision.SILENT if last_reminded is None: return Decision.BANNER # Snooze on calendar day (UTC), not a sliding 24h window. if self._as_utc(last_reminded).date() == self._as_utc(now).date(): return Decision.SILENT return Decision.BANNER def days_stale(self, last_check: Optional[datetime], now: datetime) -> Optional[int]: """Whole days since `last_check`, or None if never checked.""" if last_check is None: return None delta = self._as_utc(now) - self._as_utc(last_check) return max(delta.days, 0) # --------------------------------------------------------------------------- # Banner copy (task 4.2) # --------------------------------------------------------------------------- # # DEVIATION NOTE: the `session-reminder` spec requires the banner to "name the # slash command the user runs". That command (`/hygiene check`) is not built in # this change, so per the task we advertise that monitoring is active without # promising an unbuilt command. The staleness phrase IS retained (the spec also # says the banner states "how stale the docs are"), keyed off `last_check`. def build_banner(days_stale: Optional[int]) -> str: """Compose the deterministic, zero-token banner text.""" if days_stale is None: staleness = "no documentation check has been run yet" else: staleness = f"docs were last checked {days_stale} day{'s' if days_stale != 1 else ''} ago" return f"[doc-hygiene] Documentation health monitoring active — {staleness}." # --------------------------------------------------------------------------- # Script runner (task 4.2) — owns all I/O and the try/except envelope. # --------------------------------------------------------------------------- class ReminderRunner: """ Wires the pure `Reminder` decision to the in-project state store and stdout. All I/O lives here; the `Reminder` decision stays pure. Any failure reading state is treated as "never checked" (stale) so the reminder still fires and the session is never blocked (invariant #1, spec "Unreadable state never blocks the session"). """ def __init__( self, store: StateStore, reminder: Optional[Reminder] = None, clock: Optional[RealClock] = None, out=None, ) -> None: self._store = store self._reminder = reminder or Reminder() self._clock = clock or RealClock() self._out = out if out is not None else sys.stdout def _safe_get(self, key: str) -> Optional[datetime]: """Read a timestamp, swallowing any corruption as 'unset'.""" try: return self._store.get_timestamp(key) except Exception: return None def run(self) -> Decision: """Execute one reminder pass. Emits a banner if due. Never raises.""" now = self._clock.now() last_check = self._safe_get("last_check") last_reminded = self._safe_get("last_reminded") decision = self._reminder.decide(last_check, last_reminded, now) if decision is Decision.BANNER: days = self._reminder.days_stale(last_check, now) banner = build_banner(days) self._out.write(json.dumps({"systemMessage": banner})) # The ONLY mutation (invariant #1): record the snooze stamp. self._store.set_timestamp("last_reminded", now) return decision # --------------------------------------------------------------------------- # Entry point (task 4.2) — always exits 0. # --------------------------------------------------------------------------- def _is_git_project(root: Path) -> bool: """True only when *root* is a real git project (has a `.git` directory). Guard rationale (invariant #3): the SessionStart hook is user-scoped and fires in ANY directory. `resolve_project_root` falls back to cwd when no `.git` is found, so without this guard, starting a session in `~` or any non-project folder would create `/.dochygiene/` (e.g. `~/.dochygiene`) — a signature invariant #3 names as a violation — and, being un-checkable, would banner every calendar day there (the nag-ware the snooze prevents only WITHIN a day). We therefore restrict the reminder to git projects. Brand-new git projects still banner (never-checked = stale), per spec. NOTE FOR REVIEW: this guard is a design/invariant interaction not spelled out in the `session-reminder` spec (which assumed a project context). It is the conservative reading of invariant #3 + D4; flagged for the human gate. """ return RealRootFS().is_dir(root / ".git") def main(argv: Optional[list] = None) -> int: try: root = resolve_project_root(Path.cwd()) if not _is_git_project(root): # Not a git project — stay silent and write nothing (invariant #3). return 0 store = StateStore(root) ReminderRunner(store).run() except Exception: # Belt-and-braces: a reminder must NEVER block or fail a session. pass return 0 if __name__ == "__main__": sys.exit(main())