cc-os/plugins/os-doc-hygiene/openspec/changes/archive/2026-06-24-add-check/design.md

274 lines
16 KiB
Markdown
Raw Normal View History

# 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 <glob-or-path>] [--category <class|subtype>]` → 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/<n>-<name>/`, 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`. 35 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.