cc-os/plugins/os-doc-hygiene/scripts/reminder.py

242 lines
9.8 KiB
Python
Raw Normal View History

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