cc-os/plugins/os-doc-hygiene/openspec/changes/add-deterministic-core/design.md

247 lines
13 KiB
Markdown
Raw Normal View History

# 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 `<project_root>/.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).