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

242 lines
9.8 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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