247 lines
13 KiB
Markdown
247 lines
13 KiB
Markdown
|
|
# 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).
|