16 KiB
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_tokenscomes from the local estimator, never a model. - Invariant #10 (
safety_tieris derived):safety_tieris computed only byderive_safety_tier(op_type, is_destructive, is_reversible)— never model-assigned. - Invariant #11 (
op_typeis a property of the chosen op):op_typeisdeterministiciff the op carries anexact_edit; a single subtype may map to either op-type depending on the chosen op. - Invariant #4 (report rollover): exactly one
report.json+report.mdpair survives each write;write_reportdeletes 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_sha256before applying a cached edit.
Goals / Non-Goals
Goals:
- A
/hygienecommand surface:check(scan + classify + report) andstatus(read-only timestamps), withclean/sweepreserved. - A
hygiene-checkskill orchestrating scan → Sonnet classify → deterministic finalize → validate → write + stamplast_check. - A standalone, model-free
report_builder.pyfinalize pass that owns the four fields the model must not author (expected_sha256,safety_tier,is_destructive/is_reversiblefor deterministic ops,raw_tokens). - Hermetic classifier golden examples + a unit harness that never calls a live model.
Non-Goals:
- The
cleanskill, patch-applier, mtime-guard application, andsweep(Phase 4). token_estimateweighting (injection_frequency,weighted_tokens, rollup) — v2 bonus; this change populates onlyraw_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 byderive_safety_tier(...), never model-assigned.is_destructive/is_reversiblefor deterministic ops — fixed byexact_edit.kindviaKIND_TABLEinvalidate_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 thehygiene-checkskill.status→ read-only:StateStore.get_last_check/get_last_clean/get_last_remindedplus whether a report exists. No scan, no model.statuslives inline in the command (a fewpython3calls), 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):
-
(D) Scan —
python3 scripts/scanner.py(auto-resolves root, default**/*.md, default excludes incl..dochygiene/).--scopeapplies a--globs/path filter. Capture the artifact{ project_root, scope_globs, excluded_dirs, files_scanned, shortlist, signals }. -
(D) Select candidates — real candidates = signal-bearing paths (the keys of
signals). Zero-signal shortlisted files are presumptively cleared: they stay inshortlist, produce no entries, and are not read by the model.--categoryfilters only at the entry stage. -
(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).signalspassed 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 indetail.op(a human sentence);op_typedeterministic|generative(a property of the chosen op, invariant #11).- If
deterministic: anexact_editskeleton —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 supplyexpected_sha256,is_destructive,is_reversible, orsafety_tier. - If
generative: noexact_edit; instead a non-persistedreducible_range{start_line,end_line}so the assembler countsraw_tokenson 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 →dedupewithcanonical_ref(→auto); known-target link fix →replace-text(→auto); prose condensation/splitting →generative(→confirm). -
(D) Finalize via
report_builder.py— per proposal: read the file; computeexpected_sha256over current bytes (anchor-bearing kinds only); set(is_destructive, is_reversible)fromKIND_TABLE[kind];safety_tier = derive_safety_tier(op_type, is_destructive, is_reversible)imported fromvalidate_report.py; extract the span (anchor range /reducible_range/ whole file formove-to-archive) andtoken_estimate = estimator.estimate_for_report(span_text)(v1raw_tokensonly, weighting null); assemble the envelopeschema_version"1.0",tool_versionfromplugin.json, per-entrygenerated_at= that file's hash instant, envelopegenerated_at= the run instant (which also becomeslast_check). Also emit the deterministic human-report skeleton (the mechanical parts); the optional per-entry "why" gloss is model-written. -
(D) Validate BEFORE writing — run
validate_report.pyon a scratch path (scratchpad, NOT.dochygiene/).write_reportdeletes the prior pair first, so validating after the write would destroy the last good report (invariant #4). Only on exit 0 proceed. -
(D) Write + rollover —
StateStore.write_report(json_blob, md_blob)(atomic, keeps exactly one report pair, invariant #4). -
(D) Stamp —
StateStore.set_last_check(generated_at)using the same run instant from step 4. -
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 toentries[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 stillset_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. 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/confirmsuperseded→stale/superseded/deterministic/move-to-archive/autocompleted-in-place→stale/completed-in-place/deterministic/insert-frontmatter/autoduplicated→stale/duplicated/deterministic/dedupe/autodistill→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-frontmatterhas no content guard — DEFERRED hole] →insert-frontmatterhashas_anchor = False, so it carries noexpected_sha256; invariant #8's content guard cannot protect it at clean time. This is a known deferred hole — Phase 4 (clean) MUST handleinsert-frontmatterapplication 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-entrygenerated_atis that file's hash instant (distinct from the envelopegenerated_at/last_checkstamp). 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_versionsource mismatch] →tool_versionis read fromplugin.json(currently0.0.1), whilevalid_report.jsonshows0.1.0. Read fromplugin.jsonand flag the mismatch; do not hardcode. - [Generative
raw_tokensneeds a span to count] → A generative op has noexact_edit, so the model returns a non-persistedreducible_range; the assembler countsraw_tokensover 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)
report_builder.pyis standalone, importingderive_safety_tier+KIND_TABLEfromvalidate_report.py(no re-implementation).- 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). insert-frontmatterhashas_anchor = False→ noexpected_sha256→ no #8 content guard at clean time: a deferred hole, Phase 4 must handle it.- The guard is a content hash (
expected_sha256) despite the "mtime guard" naming; per-entrygenerated_at= that file's hash instant (distinct from the envelope/last_checkstamp). - Zero-signal files are unread (a cost decision) — a known v1 recall limit, documented.
- Generative
raw_tokens: the model returns a non-persistedreducible_range; the assembler counts that span. - Validate-before-rollover is a hard sequencing constraint (validate on a scratch
path before
write_reportdeletes the prior pair). tool_versionfromplugin.json(currently0.0.1;valid_report.jsonshows0.1.0— read fromplugin.json, flag the mismatch).- A subagent + Bash inside a skill is sanctioned per the
commit-skill precedent.