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

258 lines
13 KiB
Markdown
Raw Normal View History

# Design: Clean
## Context
The check pipeline (build step #3) is in place: `scanner.py` + `report_builder.py` +
`validate_report.py` + `state_store.py` together produce a schema-valid
`.dochygiene/report.json` and `report.md`, then stamp `last_check`. The applier is
also in place: `patch_applier.py` exists, has 263 passing tests, and knows how to
apply every kind in the `KIND_TABLE` via per-file transactions with a content-hash
guard. What is missing is the orchestration skill that loads that report, gates
dangerous entries, invokes the applier, handles generative entries, and produces a
single reviewable git commit.
Constraints that shape this design:
- **Invariant #4 (no accumulating artifacts):** keep only the latest report pair.
The clean skill must not write new state files; `last_clean` is the only new state
entry.
- **Invariant #5 (git-safe single commit):** clean/committed tree required or an auto
WIP checkpoint; the cleanup output is exactly one commit. NEVER two commits.
- **Invariant #6 (deterministic-first):** apply, stage, and commit are scripts.
Generative distillation is the only model pass during clean.
- **Invariant #7 (safety-tier gating):** `auto` ops apply without prompt; `confirm`
ops escalate. Gating does NOT relax under `sweep`.
- **Invariant #8 (content guard):** the applier reads `expected_sha256` from the
report; if a file changed since check, the entire file batch is skipped (not just
the conflicting entry). The guard is a content hash, not mtime — but per the
META-RULE, invariant #8's wording ("mtime guard") must not change without human
approval.
## Goals / Non-Goals
**Goals:**
- A `hygiene-clean` skill that loads the report, gates confirms, runs the applier,
handles generative entries via Sonnet, stages precisely, commits once, and stamps
`last_clean`.
- A `workflows/distill.md` Sonnet workflow for generative entries (LOOP-GUARD via
subagent pointer).
- Live routing in `commands/hygiene.md` for `clean` and `sweep` (replacing Phase 3
stubs).
- Integration tests covering the invariant #5 and #7 GAPs identified in the design.
**Non-Goals:**
- `token_estimate` weighting (`injection_frequency`, `weighted_tokens`, rollup) — v2
bonus (PRD phase 5).
- Inbound-link rewriting after `move-to-archive` — v1 move only; next check flags new
orphans.
- The live model-classification regression harness — separate, manually invoked.
## Decisions
### D1. Per-file transaction with descending-anchor application — the content-hash guard lives in `patch_applier.py`
The core correctness invariant: applying edits one-at-a-time to a file is wrong
because each write shifts line numbers, causing subsequent anchors to land on the
wrong lines and potentially tripping the guard on stale content. The resolution:
1. **Read file bytes once.**
2. **Verify `sha256(bytes) == expected_sha256`** for every anchored entry on that
file. On any mismatch, skip the **entire file batch** (reason:
`content-changed-since-check`, recommendation: re-analysis) and continue other
files.
3. **Apply anchored edits in memory, descending by `anchor.start_line`** — edits to
later lines first so earlier-line anchors remain valid.
4. **Apply `insert-frontmatter` last** (it prepends, shifting all lines; doing it
last means descending-anchor edits see the correct line numbers).
5. **Write once; stage once.**
The guard is a content hash only — no mtime pre-filter. Per-entry `generated_at` in
the report records the file's hash instant (distinct from the envelope/`last_check`
stamp); the applier does not use `generated_at`, only `expected_sha256`.
**Alternative considered:** apply edits sequentially with line-offset tracking — rejected:
complex, error-prone across multiple edit kinds, and untestable in isolation.
### D2. `insert-frontmatter`: re-derive at apply time (resolves the deferred hole from `add-check`)
The `add-check` design recorded a deferred hole: `insert-frontmatter` has
`has_anchor = False` and therefore carries **no** `expected_sha256`, so invariant #8
cannot protect it at apply time via the normal content-hash path.
Resolution (implemented in `patch_applier.py`): at apply time, re-read the file and
parse its frontmatter fresh:
- Key already present with the target value → **idempotent no-op** (success).
- Key already present with a different value → **skip** with reason
`frontmatter-key-conflict`; NEVER overwrite.
- Key absent → insert `key: value` (creating a `---` block if needed) as the
**last** in-memory step (after all descending-anchor edits).
This re-derivation replaces the missing hash guard for frontmatter; it is
idempotent and does not trust any cached state. This resolves the deferred hole
noted in `add-check` and in `CLAUDE.md`.
### D3. Incompatible-ops detection — skip the file, never compose
Per file, before any in-memory editing, the applier checks:
- Does `move-to-archive` co-occur with any other op on the same file?
- Do any anchor ranges overlap?
If either is true, skip the entire file with reason `incompatible-ops-on-file` and
record a recommendation for re-analysis. V1 never attempts to compose or sequence
these — the interaction is undefined and the risk of silent corruption is too high.
**Alternative considered:** attempt partial application (apply non-conflicting ops) —
rejected: partial application of overlapping anchors produces unpredictable results
and leaves the file in a state that cannot be re-analyzed cleanly.
### D4. Safety-tier gating — one batch-confirm before any mutation
Invariant #7 requires `auto` ops to apply without prompt and `confirm` ops to
escalate. The skill reads `safety_tier` from the report (derived by `report_builder`
via `derive_safety_tier`) — never recomputes or trusts a model.
Gating sequence:
1. Partition entries into `auto+deterministic` → mechanical; `confirm+deterministic`
(i.e., `delete-range`) → approval; `generative` (always `confirm`) → approval.
2. **Gate confirm-tier FIRST**, before any mutation: present a single batch-confirm
list (path · category · op · tokens · why) with per-entry opt-out. Visually
distinguish `delete-range` (irreversible) from reversible `auto` ops.
3. Approved set = all `auto` entries + approved `confirm` entries.
4. Apply in that order.
Under `sweep` (`/hygiene sweep` = check-then-clean), the confirm gate runs
identically — the convenience path does NOT bypass invariant #7.
Audit of approvals is folded into the commit body (no separate
`.dochygiene/decisions` file — avoids new artifact, respects invariant #4).
### D5. Git safety — WIP checkpoint, single commit, precise staging, rollback
**Pre-flight:**
1. Resolve `project_root` via `StateStore`.
2. `git status --porcelain`. Clean → `baseline = HEAD`. Dirty → auto-create a WIP
checkpoint commit of user work FIRST (per invariant #5 "or auto WIP checkpoint");
`baseline = that checkpoint`. The cleanup commit is still exactly one.
3. Tracked-files-only: untracked candidate docs are skipped and reported (they have no
git history to roll back to).
**Staging (precise):**
- Non-move ops: applier writes the file; skill calls `git add <staged_paths>`.
- `move-to-archive`: applier calls `git mv` (stages both source removal and dest
add); skill must NOT `git add` the dest again (double-add corrupts the index).
- NEVER `git add -A` or `git add .` (the report lives in gitignored `.dochygiene/`
and must never enter the commit).
**Single commit** via `git-context commit-apply --message-stdin` (gem 0.4.0). The
commit message format:
```
docs: hygiene cleanup (N edits across M files)
Auto: <count> (move-to-archive ×a, replace-text ×b, dedupe ×c, freeze ×d)
Confirmed: <count> (delete-range ×e, distill ×f, split ×g)
Skipped (re-analysis recommended): <count>
```
No `Co-Authored-By`, no AI attribution, no emoji, no trailers (house rule confirmed
by user). Do NOT delegate to the full commit skill (its grouping/secret-scan logic
may split into multiple commits, violating invariant #5).
**Failure handling:**
- Hard failure (applier exit 2, `git mv` fail, write error) → `git restore`/`reset`
to `baseline`; abort with a structured error.
- Partial success (applier exit 1, some file batches guard-skipped) is NOT a
rollback — commit what applied, report skipped files with `re-analysis recommended`.
**Mid-run `last_clean` stamp:** stamped only after the commit succeeds, to the commit
instant (not the run-start instant, so `last_clean` always points at an actual commit).
### D6. Generative entries — live-read freshness, Sonnet subagent, skill writes
Generative entries (`distill`, `split`, and any unknown `op_type`) always require
`confirm` gating. After approval, the skill (not the applier) handles them:
1. Confirm the file still exists (a `move-to-archive` for the same file earlier in
the run would make it gone).
2. Read the **live** file contents now (not from the report cache — generative entries
have no `expected_sha256`, so the only freshness guarantee is a live read).
3. Dispatch to a Sonnet subagent pointing at `workflows/distill.md` (LOOP-GUARD —
the subagent reads that workflow doc, not SKILL.md, to avoid recursion).
4. Subagent returns new prose (or new-primary + archived-section for split).
5. Skill writes + stages the result.
A file with BOTH a generative entry AND a deterministic entry is treated as having
`incompatible-ops-on-file` (see D3) — v1 never composes; re-analysis recommended.
### D7. `sweep` is command-level sequencing, not a third skill
`/hygiene sweep` → invoke `hygiene-check` skill, then invoke `hygiene-clean` skill,
passing `--scope`/`--category` to both. This is wired in `commands/hygiene.md`, not
in a third skill or by having `clean` call `check` internally.
No double-commit: `check` writes only to the gitignored `.dochygiene/`; the single
clean commit is the only git commit produced by the sweep.
**Alternative considered:** a `hygiene-sweep` skill — rejected: `sweep` is purely
`check ; clean` with shared flags; a dedicated skill adds ceremony with no new logic.
## Risks / Trade-offs
- **[delete-range is irreversible]** → Confirm-gated (invariant #7); the batch-confirm
display MUST visually distinguish deletes from reversible auto ops. Mitigated by the
WIP checkpoint (user can always `git reset --hard <checkpoint>`) but the content is
gone from that commit. Documented.
- **[move-to-archive creates link orphans]** → V1 move only; the next `/hygiene check`
flags new orphans via the `broken_reference` signal. Documented in the clean
summary as a follow-up action.
- **[generative second-guard hole]** → Generative entries have no `expected_sha256`;
freshness is guaranteed only via the live read at dispatch time. Do not cache the
span between the report and the Sonnet call. If the file changes between skill start
and Sonnet dispatch, the live read picks up the change (safe); do not add a
pre-write hash check (would need a second read, adding complexity with minimal
benefit in practice).
- **[file with both generative + deterministic entries]** → Incompatible-ops-on-file;
entire file is skipped, re-analysis recommended. V1 never composes.
- **[`insert-frontmatter` key conflict]** → Preserved (skip + `frontmatter-key-conflict`
reason), never overwritten. The idempotent re-derivation in D2 covers this.
- **[tracked-files-only]** → Untracked candidate docs are skipped and reported. A
user who adds a doc without `git add`-ing it won't see it cleaned. Documented.
- **[invariant #8 naming vs. mechanism]** → Invariant #8 is titled "mtime guard" but
the enforcement is a whole-file content sha256. The applier docstring notes this;
the invariant wording is not changed without human approval (META-RULE).
## Migration Plan
Additive only — no existing behavior changes. Deploy order:
1. `workflows/distill.md` (no dependencies beyond existing scripts).
2. `skills/hygiene-clean/SKILL.md` (depends on `distill.md` and the already-built
`patch_applier.py`).
3. Integration tests (`tests/test_clean_integration.py`).
4. `commands/hygiene.md` update (stub replacement only — replaces the "Phase 4"
stubs; no structural change to the command file).
5. CONTEXT.md updates (`scripts/CONTEXT.md`, `skills/hygiene-clean/CONTEXT.md`).
Rollback: remove `skills/hygiene-clean/SKILL.md`, `workflows/distill.md`, and the
integration tests; revert `commands/hygiene.md` to Phase 3 stubs. The deterministic
core, report-builder, state-store, and `patch_applier.py` are untouched.
## Open Questions (resolved — recorded as decisions)
1. **Insert-frontmatter at apply time** → re-derive at apply time (D2); the deferred
hole from `add-check` is now resolved.
2. **Guard mechanism** → content hash only (`expected_sha256`), no mtime pre-filter.
Invariant #8 wording preserved (META-RULE).
3. **Commit attribution** → NONE (`git-context` house rule, user-confirmed). No
`Co-Authored-By`, no AI attribution, no emoji.
4. **Generative freshness** → live read at dispatch time is the only guard; no cached
span (D6).
5. **Sweep is command-level** → wired in `commands/hygiene.md`, not a third skill (D7).
6. **Partial success** → NOT a rollback; commit what applied, report skipped (D5).
7. **Audit trail** → folded into the commit body; no `.dochygiene/decisions` file
(respects invariant #4).
8. **`git mv` double-staging** → the applier stages `git mv` both sides; skill must
NOT `git add` the dest path again (D5).