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
SessionStarthook only reminds — no scan, no classification, no mutation except writinglast_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+.mdpair. - 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→systemMessagebanner 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
.gitignoreedits. - Each seam unit-testable in isolation with injected clock and filesystem.
Non-Goals:
- The AI classify pass /
checkskill,entries,op/op_type/safety_tier. token_estimate/raw_tokenspopulation (owned bycheck; see proposal).- The
.gitignoreignore-entry offer UX (owned bycheck). This change only guarantees the store never silently edits.gitignore. - The
cleanskill, patch-applier, mtime-guard application, andsweep. - 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
systemMessagebanner 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-ignoreandhygiene: frozengive 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; whethercheckpersists cleared-file signals is its call. - [Reminder reads state on every session — a corrupt
state.jsoncould 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).