# Design: Check ## Context The deterministic core (build step #2) and the token estimator are in place: `scanner.py` emits the intermediate `{ project_root, scope_globs, excluded_dirs, files_scanned, shortlist, signals }` artifact; `state_store.py` owns lifecycle timestamps, atomic writes, and report rollover; `validate_report.py` owns the frozen schema validation, including the single-source `derive_safety_tier(...)` function and `KIND_TABLE`; `token_estimator.py` exposes `default_estimator().estimate_for_report(text)`. This change builds the first AI pass on top of them — the `check` skill — plus the `/hygiene` command that the `SessionStart` reminder already advertises. Constraints that shape this design: - **Invariant #6 (deterministic-first):** scan, state, patch-apply, and token-estimate are scripts. The model does only classification and prose distillation. `raw_tokens` comes from the local estimator, never a model. - **Invariant #10 (`safety_tier` is derived):** `safety_tier` is computed only by `derive_safety_tier(op_type, is_destructive, is_reversible)` — never model-assigned. - **Invariant #11 (`op_type` is a property of the chosen op):** `op_type` is `deterministic` iff the op carries an `exact_edit`; a single subtype may map to either op-type depending on the chosen op. - **Invariant #4 (report rollover):** exactly one `report.json` + `report.md` pair survives each write; `write_report` deletes the prior pair before writing the new one. - **Invariant #8 (mtime/content guard):** the cleaner verifies a file's current hash matches the entry's `expected_sha256` before applying a cached edit. ## Goals / Non-Goals **Goals:** - A `/hygiene` command surface: `check` (scan + classify + report) and `status` (read-only timestamps), with `clean` / `sweep` reserved. - A `hygiene-check` skill orchestrating scan → Sonnet classify → deterministic finalize → validate → write + stamp `last_check`. - A standalone, model-free `report_builder.py` finalize pass that owns the four fields the model must not author (`expected_sha256`, `safety_tier`, `is_destructive`/`is_reversible` for deterministic ops, `raw_tokens`). - Hermetic classifier golden examples + a unit harness that never calls a live model. **Non-Goals:** - The `clean` skill, patch-applier, mtime-guard application, and `sweep` (Phase 4). - `token_estimate` weighting (`injection_frequency`, `weighted_tokens`, rollup) — v2 bonus; this change populates only `raw_tokens`. - The live model-classification regression harness (separate, manually invoked). ## Decisions ### D1. A deterministic finalize pass between classify and write — `report_builder.py` This is the decision that shapes everything. Four per-entry fields cannot be model-authored without violating a frozen invariant or being physically impossible: - `exact_edit.expected_sha256` — the sha256 of real file bytes; the model never sees the bytes, and inventing a hash is meaningless. - `safety_tier` — invariant #10: derived only by `derive_safety_tier(...)`, never model-assigned. - `is_destructive` / `is_reversible` for deterministic ops — fixed by `exact_edit.kind` via `KIND_TABLE` in `validate_report.py`. - `token_estimate.raw_tokens` — invariant #6: from the local token estimator, never a model. Therefore check MUST include a deterministic step **between** model classification and `StateStore.write_report`. A new standalone script `scripts/report_builder.py` is that step: a model-free assembler. **Input:** the scanner artifact plus the model's slim per-file classification proposals. **Output:** a full schema-valid machine report plus a deterministic human-report skeleton. For each proposal it reads the anchored span, computes `expected_sha256`, looks up `(is_destructive, is_reversible)` from `kind`, calls `derive_safety_tier(...)` **imported** from `validate_report.py` (single source of truth — no re-implementation), calls `token_estimator.default_estimator().estimate_for_report(span_text)`, stamps per-entry `generated_at` at each file's hash instant, and assembles `scan` / `shortlist` / `entries`. **Alternative considered:** have the skill assemble the report inline in the SKILL.md workflow — rejected: the four guardrail fields then have no enforced, unit-testable home, and the model could leak into a field it must not author. ### D2. Command surface — a single `commands/hygiene.md` dispatching on `$ARGUMENTS` One command file. Frontmatter is minimal (`name: hygiene`; a description naming `/hygiene check` to scan and `/hygiene status` for timestamps). Routes: - `check [--scope ] [--category ]` → invoke the `hygiene-check` skill. - `status` → read-only: `StateStore.get_last_check` / `get_last_clean` / `get_last_reminded` plus whether a report exists. No scan, no model. `status` lives **inline** in the command (a few `python3` calls), not in a separate skill. - `clean` / `sweep` → reserved: "not yet implemented (Phase 4)". - no/unknown args → usage + current status. **Alternative considered:** a separate `status` skill — rejected: `status` is a few read-only timestamp reads, not judgment; a skill would be ceremony. ### D3. Check skill flow — scan, classify, finalize, validate, write, stamp `skills/hygiene-check/SKILL.md` frontmatter mirrors the `commit` skill (`name: hygiene-check`; a description). Like the `commit` skill, the SKILL.md workflow runs deterministic scripts via Bash, dispatches a Sonnet subagent for judgment-only classification, then runs deterministic scripts to assemble/validate/write — the sanctioned mechanism inside a skill workflow (D9). Flow (**D** = deterministic script, **M** = model): 1. **(D) Scan** — `python3 scripts/scanner.py` (auto-resolves root, default `**/*.md`, default excludes incl. `.dochygiene/`). `--scope` applies a `--globs`/path filter. Capture the artifact `{ project_root, scope_globs, excluded_dirs, files_scanned, shortlist, signals }`. 2. **(D) Select candidates** — real candidates = signal-bearing paths (the keys of `signals`). Zero-signal shortlisted files are **presumptively cleared**: they stay in `shortlist`, produce no entries, and are **not read by the model**. `--category` filters only at the entry stage. 3. **(M) Classify** each signal-bearing candidate (Sonnet subagent). The subagent reads each candidate file plus its scanner signals and returns a **slim proposal** per file (judgment only, no computed fields): - `category` `{ class, subtype }` from the closed enum (`STALE_SUBTYPES`/`BLOAT_SUBTYPES`) justified by cited signals (PRD taxonomy). - `signals` passed through **verbatim** (scanner names: `broken_reference`, `version_skew`, `edit_recency_vs_churn`, `stale_name_location`, `archive_to_live_ratio`, `frontmatter_marker`) with an optional one-line gloss in `detail`. - `op` (a human sentence); `op_type` `deterministic` | `generative` (a property of the chosen op, invariant #11). - If `deterministic`: an `exact_edit` **skeleton** — `kind` (closed enum) + `anchor.{start_line,end_line}` where required + kind-specific fields (`dest_path` / `key,value` / `match,replacement` / `canonical_ref`). The model does **not** supply `expected_sha256`, `is_destructive`, `is_reversible`, or `safety_tier`. - If `generative`: no `exact_edit`; instead a **non-persisted** `reducible_range` `{start_line,end_line}` so the assembler counts `raw_tokens` on the real text. - Plus `confidence`. **Decision rules** (subtype → kind → tier): destructive deletion of unique content → `delete-range` (→`confirm`); content-preserving relocation → `move-to-archive` (→`auto`); freeze a completed doc → `insert-frontmatter` (→`auto`); exact duplicate preserved elsewhere → `dedupe` with `canonical_ref` (→`auto`); known-target link fix → `replace-text` (→`auto`); prose condensation/splitting → `generative` (→`confirm`). 4. **(D) Finalize** via `report_builder.py` — per proposal: read the file; compute `expected_sha256` over current bytes (anchor-bearing kinds only); set `(is_destructive, is_reversible)` from `KIND_TABLE[kind]`; `safety_tier = derive_safety_tier(op_type, is_destructive, is_reversible)` imported from `validate_report.py`; extract the span (anchor range / `reducible_range` / whole file for `move-to-archive`) and `token_estimate = estimator.estimate_for_report(span_text)` (v1 `raw_tokens` only, weighting null); assemble the envelope `schema_version` `"1.0"`, `tool_version` from `plugin.json`, per-entry `generated_at` = that file's hash instant, envelope `generated_at` = the run instant (which also becomes `last_check`). Also emit the deterministic human-report skeleton (the mechanical parts); the optional per-entry "why" gloss is model-written. 5. **(D) Validate BEFORE writing** — run `validate_report.py` on a **scratch** path (scratchpad, NOT `.dochygiene/`). `write_report` deletes the prior pair first, so validating after the write would destroy the last good report (invariant #4). Only on exit 0 proceed. 6. **(D) Write + rollover** — `StateStore.write_report(json_blob, md_blob)` (atomic, keeps exactly one report pair, invariant #4). 7. **(D) Stamp** — `StateStore.set_last_check(generated_at)` using the same run instant from step 4. 8. Surface the human-report summary plus the two report paths. **Model routing:** classification = Sonnet; escalate a single file to Opus only on low confidence for hard distinctions (stale-vs-bloat; `delete-range`/destructive vs generative rewrite of the same contradicted/superseded content). Steps 1, 2, 4, 5, 6, 7 use no model (invariant #6). ### D4. Failure handling - **Validation fail (exit 1):** do NOT `write_report` (the prior pair is preserved). Map violations to `entries[i]`, re-prompt the subagent to fix only the offending proposals or drop an unfixable entry and re-validate. Never write an invalid report. - **Validator usage error (exit 2):** an internal bug; stop. - **Empty shortlist / no signal-bearing files:** write a report with empty `entries` (still valid) and still `set_last_check`. - **Malformed proposal JSON:** the assembler rejects it before computing; re-prompt. ### D5. Scoping `--scope` narrows the scanner (`--globs` or a path-prefix filter on the shortlist). `--category` filters which **entries** are produced — the scanner is category-agnostic, so the filter is applied after classification. Both are recorded in the human-report header. ### D6. Reports — fixed paths, one pair, human skeleton `StateStore` hard-codes the paths: machine `.dochygiene/report.json`, human `.dochygiene/report.md`, both written via `StateStore.write_report`; rollover keeps exactly one pair (invariant #4); `.dochygiene/` is gitignored. The human report skeleton groups by Stale / Bloat / Cleared with per-entry path, category, op, tier, ~tokens, signal; the header shows timestamp, scope, files scanned, and candidate/cleared counts. The structural parts are script-built; only the optional per-entry "why" gloss is model-written. ### D7. Classifier golden examples — distinct from schema fixtures, hermetic harness Classifier goldens are **distinct** from `examples/golden/valid_report.json` / `invalid_report.json` (those are schema-shape fixtures for `validate_report.py` only). Classifier goldens are the Layer-2 reversion-protection layer: input doc-tree → expected classification. Layout `examples/golden/classifier/-/`, each with `input/` (a small static fixture tree, stable sha256s), `expected.json` (a full schema-valid machine report = the expected classification), and an optional `notes.md`. 3–5 cases, each mapping a subtype to a **distinct** `exact_edit.kind` to cover the kind table + tier derivation: - `orphaned` → `stale`/`orphaned`/`deterministic`/`delete-range`/`confirm` - `superseded` → `stale`/`superseded`/`deterministic`/`move-to-archive`/`auto` - `completed-in-place` → `stale`/`completed-in-place`/`deterministic`/`insert-frontmatter`/`auto` - `duplicated` → `stale`/`duplicated`/`deterministic`/`dedupe`/`auto` - `distill` → `bloat`/`distill`/`generative`/(none)/`confirm` The harness MUST be **hermetic** (no live model call in pytest): `tests/test_classifier_golden.py` asserts only deterministic/stable parts — (1) `scanner.py` on `input/` emits the expected signals on the right paths; (2) `validate_report.py` on each `expected.json` → exit 0; (3) a stable-field match against a **captured/committed** check output (`category.class`, `category.subtype`, `op_type`, the derived `safety_tier`, `exact_edit.kind`). The **live** model-classification regression (running an actual check against `input/` and diffing) is a **separate, manually/agent-invoked** harness — NOT part of the unit suite. Op-prose and exact anchor line numbers are advisory (flag a mismatch for human review, not a hard fail). Adding or changing classifier goldens is **human-gated** per the META-RULE. ## Risks / Trade-offs - **[`insert-frontmatter` has no content guard — DEFERRED hole]** → `insert-frontmatter` has `has_anchor = False`, so it carries **no** `expected_sha256`; invariant #8's content guard cannot protect it at clean time. This is a known deferred hole — Phase 4 (clean) MUST handle `insert-frontmatter` application explicitly (e.g. re-derive frontmatter presence at apply time rather than trusting a cached hash). Recorded here and in CLAUDE.md so it is not silently inherited. - **[Guard is a content hash despite the "mtime guard" naming]** → The guard compares `expected_sha256`, not mtime. Per-entry `generated_at` is **that file's hash instant** (distinct from the envelope `generated_at` / `last_check` stamp). The naming is historical; the mechanism is content-hash. - **[Zero-signal files are unread]** → Zero-signal shortlisted files are presumptively cleared and never read by the model (a cost decision). This is a known v1 recall limit — a file with a real problem but no scanner signal is missed. Documented. - **[`tool_version` source mismatch]** → `tool_version` is read from `plugin.json` (currently `0.0.1`), while `valid_report.json` shows `0.1.0`. Read from `plugin.json` and flag the mismatch; do not hardcode. - **[Generative `raw_tokens` needs a span to count]** → A generative op has no `exact_edit`, so the model returns a **non-persisted** `reducible_range`; the assembler counts `raw_tokens` over that span. The range is not written to the report. ## Migration Plan Additive only — no existing behavior changes. Deploy order follows the task order: `report_builder.py` + its tests first (it is the guardrail all entries flow through), `commands/hygiene.md` in parallel, then the `hygiene-check` skill (depends on the builder and the command), then the classifier goldens + hermetic harness, then the CONTEXT.md updates. Rollback is removing the new script, command, skill, and fixtures; the deterministic core and estimator are untouched. ## Open Questions (resolved — recorded as decisions) 1. **`report_builder.py` is standalone**, importing `derive_safety_tier` + `KIND_TABLE` from `validate_report.py` (no re-implementation). 2. **Entry signals = scanner signals verbatim** + an optional model gloss in `detail` (the validator does not constrain signal names; verbatim pass-through is a convention for trust). 3. **`insert-frontmatter` has `has_anchor = False`** → no `expected_sha256` → no #8 content guard at clean time: a **deferred hole**, Phase 4 must handle it. 4. **The guard is a content hash (`expected_sha256`)** despite the "mtime guard" naming; per-entry `generated_at` = that file's hash instant (distinct from the envelope/`last_check` stamp). 5. **Zero-signal files are unread** (a cost decision) — a known v1 recall limit, documented. 6. **Generative `raw_tokens`:** the model returns a non-persisted `reducible_range`; the assembler counts that span. 7. **Validate-before-rollover** is a hard sequencing constraint (validate on a scratch path before `write_report` deletes the prior pair). 8. **`tool_version` from `plugin.json`** (currently `0.0.1`; `valid_report.json` shows `0.1.0` — read from `plugin.json`, flag the mismatch). 9. **A subagent + Bash inside a skill is sanctioned** per the `commit`-skill precedent.