242 lines
9.8 KiB
Python
242 lines
9.8 KiB
Python
#!/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 (`/os-doc-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 `<cwd>/.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())
|