2026-07-03 15:12:31 +00:00
|
|
|
|
#!/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
|
2026-07-03 15:23:24 +00:00
|
|
|
|
# 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
|
2026-07-03 15:12:31 +00:00
|
|
|
|
# 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())
|