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

13 KiB

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 SessionStartsystemMessage 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 SessionStartsystemMessage 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).