# Design: Deterministic Core ## Context The report schema (build step #1) is frozen in `openspec/specs/report-schema/`. This change builds the deterministic substrate beneath it: the three zero-model runtime components that must exist before the AI `check` pass can run. Per CLAUDE.md build order item 2, those are the reminder hook, the scanner, and the state store. Constraints that shape this design: - **Invariant #6 (deterministic-first):** none of these seams may invoke a model. Everything here is scripts with structured JSON output, correct exit codes, and injected clock/filesystem for testability. - **Invariant #1:** the `SessionStart` hook only reminds — no scan, no classification, no mutation except writing `last_reminded`. - **Invariant #2:** the reminder snoozes to at most once per calendar day while stale. - **Invariant #3:** state lives in-project under gitignored `.dochygiene/`; no global index; never silently edit `.gitignore`. - **Invariant #4:** report rollover keeps only the latest `.json` + `.md` pair. - **Invariant #9:** frozen/ignored/append-only files are never shortlisted. - **No CC-hook precedent:** no plugin in this collection has wired a Claude Code hook. The `systemMessage`-banner mechanism was confirmed against the hook docs when the PRD was finalized, but it has never been exercised here — hence the build-spike gate. The scanner's output is **not** the machine report. The frozen schema places `signals` on `entries`, and `entries` are AI-produced. The scanner emits an **intermediate** artifact (shortlist + per-path signals); the `check` change folds that into `report.shortlist` and `entries[].signals`. ## Goals / Non-Goals **Goals:** - Wire and prove the `SessionStart` → `systemMessage` banner path (build-spike). - A deterministic reminder script: staleness comparison, once/day snooze, zero tokens, mutate only `last_reminded`, injected-clock testable. - A deterministic scanner: shortlist + per-path signals, full exclusion pipeline, no model, intermediate artifact (not a report). - A deterministic state store: project-root resolution, lifecycle timestamps, atomic writes, report rollover, write confinement, no global index, no silent `.gitignore` edits. - Each seam unit-testable in isolation with injected clock and filesystem. **Non-Goals:** - The AI classify pass / `check` skill, `entries`, `op`/`op_type`/`safety_tier`. - `token_estimate` / `raw_tokens` population (owned by `check`; see proposal). - The `.gitignore` ignore-entry *offer* UX (owned by `check`). This change only guarantees the store never silently edits `.gitignore`. - The `clean` skill, patch-applier, mtime-guard application, and `sweep`. - Per-project scope-config overrides beyond the documented defaults (the schema records `scope_globs`/`excluded_dirs`; richer override UX is later). ## Decisions ### D1. Three capabilities, one per deterministic seam `session-reminder`, `doc-scanner`, `state-store` map one-to-one onto the three runtime components, each with its own independently-testable seam (injected-clock snooze; exclusion pipeline; root-resolution + rollover + atomic-write). A single monolithic capability would couple three unrelated concerns and blur which requirement each test pins. **Alternative considered:** one `deterministic-core` capability — rejected because the test seams and invariants are disjoint and would read as an unfocused requirement pile. ### D2. Build-spike is a sequential gate, not a capability The spike proves the `SessionStart` → `systemMessage` mechanism with a hardcoded banner before any logic depends on it. The hook-wiring it proves belongs under `session-reminder` (the capability that owns the hook). It is a **task-level gate**: nothing else in this change starts until the spike renders visibly in a real session. The "confirmed to render visibly" criterion is an **implementation-time acceptance criterion** discharged when the executor runs it — not at artifact-authoring time. **Alternative considered:** fold the spike into the reminder hook task — rejected because building snooze/staleness logic atop an unproven mechanism would conflate a mechanism failure with a logic bug. ### D3. Injected clock and filesystem on every seam Each component takes a `Clock` (returns "now") and a filesystem/root abstraction by constructor injection. Tests pass a frozen clock and a temp dir; production passes the real clock and resolved root. This makes the snooze, staleness, and rollover behaviors deterministic under test with no real time and no real session. **Alternative considered:** read `datetime.now()` and `os.getcwd()` inline — rejected: untestable snooze/staleness, the exact behaviors invariants #1/#2 demand proof of. ### D4. Project-root resolution: git root, fallback cwd ``` resolve_project_root(start_dir): walk upward from start_dir looking for a `.git` directory if found: return that directory # git root else: return start_dir # fallback: cwd ``` Resolution is deterministic and pure given `start_dir` and the filesystem. All state and reports live under `/.dochygiene/`. Writes are confined to that directory (invariant #3); no path outside the resolved root is ever written. **Alternative considered:** an env var or marker file for the root — rejected as per-project setup, against US-27 (works without setup beyond enablement). ### D5. Snooze and staleness logic (reminder) The reminder is a pure decision over `(last_check, last_reminded, now, threshold)`: ``` remind?(state, now, threshold_days): stale = last_check is None OR (now - last_check) > threshold_days if not stale: return SILENT # fresh: no banner if last_reminded is None: return BANNER # never reminded if same_calendar_day(last_reminded, now): return SILENT # snoozed today return BANNER # stale, new day ``` On `BANNER` the script writes `last_reminded = now` (the only mutation) and emits the `systemMessage`. On `SILENT` it writes nothing. **Snooze keys on calendar day, not a 24h sliding window** — invariant #2 says "once per calendar day," and a sliding window would let two banners straddle midnight-adjacent sessions. The snooze is load-bearing because the hook fires on every matched `startup`/`resume` event; without it, reopening or resuming a project several times in one working day re-fires the banner each time. (The matcher is `startup|resume`, so `clear`/ `compact` do not trigger the hook at all — the matcher, not the snooze, handles those; the snooze handles repeated startup/resume within a calendar day.) The script exits 0 always (never blocks the session) with a low `timeout` (≤5s) in `hooks.json`. **Alternative considered:** snooze on a rolling 24h delta — rejected per the calendar-day wording and the midnight edge case. ### D6. Reminder never scans (invariant #1) The reminder script reads only `state.json`. It does not import or invoke the scanner, reads no doc content, and computes the banner purely from timestamps. The banner text is fixed prose plus the day-count and the slash command — it reflects *no* freshly-computed analysis. This is what keeps the hook zero-token and side-effect-free beyond `last_reminded`. ### D7. Scanner exclusion pipeline (ordered, short-circuiting) The scanner walks files matching the scope globs, then applies exclusions so a file that matches *any* exclusion never reaches the shortlist (invariant #9): ``` for each candidate path under project_root: 1. dir-prune: skip if any path component is in excluded_dirs (build, vendor, archive, graphify-out, .dochygiene) # self-exclusion here 2. ignore-match: skip if matched by a .dochygiene-ignore pattern 3. frozen-frontmatter: skip if YAML frontmatter has `hygiene: frozen` 4. append-only: skip if detected as an append-only log → otherwise: compute signals, add (path, signals) to the intermediate artifact ``` Dir-pruning happens first and at the directory level so excluded trees are never descended (cheap, and self-excludes `.dochygiene/` before any state file is read as a doc). Exclusion precedes signal computation so excluded files cost nothing. **Append-only detection** is heuristic and deterministic (e.g. monotonic dated/ sectioned growth with no in-place edits across git history, or a documented append-only marker) — the exact heuristic is an implementation detail, but it must be deterministic and unit-tested with a positive and a negative fixture. **Alternative considered:** compute signals first then filter — rejected: wastes work and risks an excluded file leaking into output if a later filter is missed. ### D8. Scanner emits an intermediate artifact, not a report The scanner's output is `{ shortlist: [path...], signals: { path: [signal...] } }` (or an equivalent intermediate shape) — paths plus their objective signals. It does **not** write a machine report, `entries`, `category`, `op`, or `token_estimate`. The `check` change reads this artifact and produces the report. Keeping the scanner output intermediate honors the frozen schema's split (scanner → shortlist; AI → entries with signals) and keeps this change from re-deriving the report shape. **Signals are objective facts only** (the schema's "objective scanner facts that support the classification"); the scanner attaches no judgment. ### D9. Atomic writes (state store) Every write to `state.json` and to report files is **write-temp-then-rename**: write to a temp file in the same directory, `fsync`, then `os.replace` onto the target. `os.replace` is atomic on POSIX, so a concurrent reader (e.g. a second session's reminder) sees either the old or the new file, never a torn write. **Alternative considered:** in-place truncate-and-write — rejected: a crash mid-write corrupts state, and the reminder reads state on every session. ### D10. Report rollover (state store) ``` write_report(json_blob, md_blob): delete any existing *.json and *.md report in .dochygiene/ # keep latest only atomically write the new .json and .md pair ``` Exactly one report pair survives each write (invariant #4) — the tool must not become the bloat it polices. Rollover deletes the prior pair *before* writing the new one. (Report *content* generation is owned by `check`; the store only owns the rollover-and-atomic-write mechanics, exposed for `check` to call.) **Alternative considered:** timestamped report history — rejected by invariant #4. ### D11. State store never silently edits `.gitignore` The store creates `.dochygiene/` (allowed — an untracked, not-yet-ignored dir is fine; invariant #3 forbids *editing* `.gitignore`, not creating the dir) and confines all writes to it. It never opens or appends to `.gitignore`. Detecting whether `.dochygiene/` is ignored and *offering* to add the entry is a `check`-change concern (US-4 / PRD "State & artifacts"), explicitly out of scope here. ## Risks / Trade-offs - **[The `systemMessage` banner doesn't render or the hook entry format is wrong for this collection]** → The build-spike gate exists precisely for this: a hardcoded-banner spike is run and visually confirmed before any logic is built on it. If the spike fails, only the spike is reworked, not the reminder logic. - **[Append-only detection is heuristic — false negatives re-flag a log, false positives hide a real doc]** → Deterministic heuristic pinned by positive and negative unit fixtures; `.dochygiene-ignore` and `hygiene: frozen` give the user explicit overrides for any heuristic miss (invariant #9 has three independent exclusion paths, so no single heuristic is the only guard). - **[Calendar-day snooze across timezones]** → Snooze compares calendar days in a single, documented timezone (the injected clock's). Tests pin the boundary (same-day → silent, next-day-while-stale → banner). Acceptable for a motivational reminder; not a correctness-critical clock. - **[Cleared-file audit gap]** → A shortlisted file the AI later clears loses its scanner signals (they live on `entries`, which a cleared file lacks). This is a frozen-schema decision (see report-schema design "Why scanner signals live only on entries"), inherited here, not introduced. The scanner's intermediate artifact *does* hold per-path signals; whether `check` persists cleared-file signals is its call. - **[Reminder reads state on every session — a corrupt `state.json` could block]** → Atomic writes (D9) prevent torn state; the reminder treats a missing/unreadable state as "never checked" (stale) and exits 0 regardless, never blocking the session (invariant #1). ## Migration Plan Additive only — no existing behavior changes (no code exists yet). Deploy order is the task order: spike gate first, then scanner ∥ state-store in parallel, then the reminder hook (which depends on the state store for `last_reminded`). Rollback is removing `hooks/hooks.json` and the new scripts; nothing else consumes them yet. ## Open Questions - **Staleness threshold default** (days before the reminder fires) — a constant for this change; the value is an implementation detail, not a spec'd contract. Per-project override is a later concern. - **Exact append-only heuristic** — left to implementation, constrained only to be deterministic and fixture-tested (positive + negative).