cc-os/plugins/os-doc-hygiene/openspec/specs/doc-clean/spec.md

342 lines
19 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# doc-clean Specification
## Purpose
Defines the `clean` skill and patch applier that consume a validated `check`
report and apply its findings git-safely: per-file transactional edits guarded
by content hashes, safety-tier gating that escalates destructive or generative
ops for confirmation, tracked-only true deletion under the lifecycle tier
matrix, and a single reviewable commit per run (with `sweep` composing `check`
then `clean`).
## Requirements
### Requirement: Per-File Transaction with Content-Hash Guard
The patch applier SHALL apply all entries for a given file as a single in-memory
transaction. It SHALL read the file's bytes once, verify that `sha256(bytes)`
matches `expected_sha256` for every anchored entry on that file, and on any
mismatch skip the **entire file batch** with reason `content-changed-since-check`
(recommending re-analysis) while continuing to process other files. It SHALL apply
anchored edits in memory in descending order by `anchor.start_line`, and SHALL apply
`insert-frontmatter` last (after all anchor edits) because it prepends and shifts
line numbers. It SHALL write the file once and stage once after all in-memory edits
succeed.
#### Scenario: Mismatch skips the entire file batch
- **WHEN** a file's current sha256 differs from any anchored entry's `expected_sha256`
- **THEN** the applier skips the entire file batch with reason `content-changed-since-check`, leaves the file untouched, and continues processing other files
#### Scenario: Descending-anchor application preserves line offsets
- **WHEN** a file has multiple anchored entries (e.g., line 40 and line 10)
- **THEN** the applier applies the line-40 edit first, then the line-10 edit, so the second edit lands on the correct line in the still-unmodified earlier portion of the file
#### Scenario: insert-frontmatter is applied last
- **WHEN** a file has both an anchored edit and an insert-frontmatter entry
- **THEN** the anchored edit is applied first in memory, and insert-frontmatter is appended last (after all anchor edits) before the single file write
#### Scenario: Write and stage occur once per file
- **WHEN** a file has multiple entries applied in memory
- **THEN** the file is written to disk exactly once and staged exactly once after all in-memory edits succeed
### Requirement: insert-frontmatter Re-Derives Freshness at Apply Time
The patch applier SHALL re-derive frontmatter state at apply time for
`insert-frontmatter` entries (which carry no `expected_sha256` because they have no
anchor) by re-reading the file and parsing its frontmatter. If the target key is
already present with the target value, the applier SHALL treat the operation as an
idempotent no-op (success, no write). If the target key is present with a different
value, the applier SHALL skip the entry with reason `frontmatter-key-conflict` and
SHALL NOT overwrite the existing value. If the key is absent, the applier SHALL
insert `key: value` (creating a `---` block if none exists) as the last in-memory
step.
#### Scenario: Key already present with target value — idempotent no-op
- **WHEN** the applier processes an insert-frontmatter entry and the file already contains `hygiene: frozen`
- **THEN** the applier treats the entry as a success and does not modify the file
#### Scenario: Key present with conflicting value — skip
- **WHEN** the applier processes an insert-frontmatter entry and the file already contains `hygiene: review`
- **THEN** the applier skips the entry with reason `frontmatter-key-conflict` and does not overwrite the existing value
#### Scenario: Key absent — insert
- **WHEN** the applier processes an insert-frontmatter entry and the file has no `hygiene` frontmatter key
- **THEN** the applier inserts the key-value pair (creating a `---` block if needed) as the last in-memory step before writing
### Requirement: Incompatible-Ops Detection Skips the File
The patch applier SHALL detect incompatible-op combinations on a per-file basis
before performing any edits. If `move-to-archive` co-occurs with any other op on the
same file, or if any anchor ranges overlap, the applier SHALL skip the entire file
with reason `incompatible-ops-on-file` and SHALL recommend re-analysis. The applier
SHALL NOT attempt to compose or sequence incompatible ops in v1.
#### Scenario: move-to-archive with another op — skip
- **WHEN** a file has both a move-to-archive entry and a replace-text entry
- **THEN** the applier skips the entire file with reason `incompatible-ops-on-file` and records a re-analysis recommendation
#### Scenario: Overlapping anchors — skip
- **WHEN** two entries on the same file have anchor ranges that overlap (e.g., lines 515 and lines 1020)
- **THEN** the applier skips the entire file with reason `incompatible-ops-on-file`
#### Scenario: Non-conflicting entries on a file proceed normally
- **WHEN** a file has two entries with non-overlapping anchors and no move-to-archive
- **THEN** the applier applies both entries via the descending-anchor transaction
### Requirement: Safety-Tier Gating Before Any Mutation
The clean skill SHALL partition report entries by safety tier (read from the
report — never recomputed) into `auto` entries (applied without prompt) and
`confirm` entries (escalated before any mutation). It SHALL present all
`confirm`-tier entries as a single batch-confirm list showing path, category,
op, token count, and rationale with per-entry opt-out, visually
distinguishing irreversible `delete-range`, `delete`, and
`extract-then-delete` entries from reversible entries. The approved set
SHALL be all `auto` entries plus any user-approved `confirm` entries. The
gate SHALL run identically under `sweep` — the `/os-doc-hygiene:sweep`
convenience path SHALL NOT bypass invariant #7. Regardless of the tier
recorded in the report, any `delete` or `extract-then-delete` entry SHALL be
re-verified at apply time per the `lifecycle-deletion` tier matrix (tracked
+ clean required for auto) before being applied without a prompt.
#### Scenario: auto entries apply without prompt
- **WHEN** the report contains only auto-tier entries (move-to-archive, insert-frontmatter, replace-text, dedupe)
- **THEN** the clean skill applies them without presenting a confirm prompt
#### Scenario: confirm entries escalate before any mutation
- **WHEN** the report contains confirm-tier entries (delete-range, delete, extract-then-delete, or any generative op)
- **THEN** the clean skill presents a batch-confirm list before any file is modified, and applies only user-approved entries
#### Scenario: per-entry opt-out is respected
- **WHEN** the user approves some confirm entries and opts out of others
- **THEN** the skill applies the approved entries and skips the opted-out entries
#### Scenario: sweep does not bypass the gate
- **WHEN** the user runs /os-doc-hygiene:sweep and the report contains confirm-tier entries
- **THEN** the confirm gate runs identically to a standalone /os-doc-hygiene:clean
#### Scenario: A report-tier auto delete is downgraded if runtime state has changed
- **WHEN** a `delete` entry was tiered `auto` in the report but the applier's runtime check finds the file is now dirty or untracked
- **THEN** the applier does not apply it silently; it is skipped and reported for re-analysis, never trusted from the cached tier
### Requirement: Git-Safe Single Commit
The clean skill SHALL produce exactly one git commit per run. Before any
mutation it SHALL resolve the project root via `StateStore`, run
`git status --porcelain`, and if the tree is dirty, SHALL automatically
create a WIP checkpoint commit of the user's work before proceeding, so the
cleanup commit remains exactly one. The cleanup commit SHALL be created via
`git-context commit-apply --message-stdin` with a generated message
summarizing auto/confirmed/skipped counts and op breakdown, including
lifecycle delete/extract-then-delete counts when present. Staging SHALL be
precise: for non-move, non-delete ops the skill calls `git add
<staged_paths>` from the applier result; for `move-to-archive` the applier
calls `git mv` (staging both sides) and the skill SHALL NOT `git add` the
destination path again; for `delete` and the delete half of
`extract-then-delete` the applier calls `git rm` (staging the removal
itself) and the skill SHALL NOT separately stage the removed path. The skill
SHALL NEVER use `git add -A` or `git add .`. If a hard failure occurs
(applier exit 2, `git mv`/`git rm` fail, or write error), the skill SHALL
roll back via `git restore`/`reset` to the pre-run baseline and abort with a
structured error. Partial success (some file batches guard-skipped) SHALL
NOT trigger rollback — the skill SHALL commit what applied and report
skipped files. Untracked candidate docs SHALL be skipped and reported
(tracked-files-only) except where a lifecycle rule explicitly escalates an
untracked delete to confirm and the user approves it. `last_clean` SHALL be
stamped to the commit instant, not the run-start instant.
#### Scenario: Clean tree produces exactly one commit
- **WHEN** the user runs /os-doc-hygiene:clean with a clean git tree
- **THEN** the run produces exactly one git commit containing all applied edits, including any lifecycle deletes
#### Scenario: Dirty tree gets a WIP checkpoint then one cleanup commit
- **WHEN** the user runs /os-doc-hygiene:clean with unstaged changes in the working tree
- **THEN** the skill auto-creates a WIP checkpoint commit of the user's work, then produces exactly one cleanup commit — two commits total, cleanup still exactly one
#### Scenario: All-skipped produces zero commits
- **WHEN** all entries are guard-skipped (every file changed since check)
- **THEN** the skill produces zero commits and reports all files as skipped with re-analysis recommended
#### Scenario: Hard failure triggers rollback
- **WHEN** a write error, applier exit 2, or a `git rm` failure occurs mid-run
- **THEN** the skill rolls back to the pre-run baseline and aborts with a structured error; no partial commit is created
#### Scenario: Partial success commits what applied
- **WHEN** some file batches are guard-skipped (applier exit 1) and others succeed
- **THEN** the skill commits the applied edits and reports the skipped files, without rolling back the applied changes
#### Scenario: move-to-archive is not double-staged
- **WHEN** the applier stages a move-to-archive via git mv (both source and dest)
- **THEN** the skill does not call git add on the destination path again
#### Scenario: A lifecycle delete is not double-staged
- **WHEN** the applier stages a `delete` via `git rm`
- **THEN** the skill does not separately call `git add` or any other staging command on the removed path
### Requirement: Applier Applies Delete and Extract-Then-Delete Under the Tier Matrix
The patch applier SHALL support two new op kinds, `delete` and
`extract-then-delete`. For both, immediately before applying, it SHALL
re-run `git ls-files <path>` and a dirty check against that specific path —
never trusting the cached report tier or rule claim — and SHALL apply the
tier matrix from the `lifecycle-deletion` spec to decide whether the entry
may proceed as `auto` or must be treated as `confirm` (already gated
upstream by the clean skill). `delete` SHALL perform a `git rm` (recursive
for a directory-rule aggregate entry) staged into the run's single hygiene
commit. `extract-then-delete` SHALL first complete its generative extraction
step (repo-durable via the existing live-read Sonnet distillation path
writing into an ADR/CLAUDE.md/docs target, or cross-repo via
`/os-vault:write`) and SHALL only perform the `git rm` once extraction has
succeeded; both steps SHALL land in the same single hygiene commit, or, on
extraction failure, neither SHALL be applied for that entry (skip, not a
run-level hard failure, unless the failure matches an existing hard-failure
trigger). When the extraction destination is the vault (the content
physically leaves the repo), the op SHALL additionally append a pointer
entry to the deleted file's per-directory `extracted.md` index (creating the
file if absent), in the same atomic sequence — distill → `/os-vault:write`
append the pointer line → `git rm` — all staged into the same single hygiene
commit, so there is no window where the doc is gone but undiscoverable. The
pointer entry SHALL name the vault note, state why a future reader would
follow it, and record the source filename and date. Repo-durable extraction
targets (ADR, CLAUDE.md, docs) SHALL NOT produce an index entry — they are
already discoverable in-repo.
#### Scenario: delete performs a true git rm at apply time
- **WHEN** the applier applies a `delete` entry
- **THEN** it re-verifies tracked/clean status via `git ls-files` and a dirty check, then performs a `git rm` staged into the single hygiene commit
#### Scenario: extract-then-delete only deletes after extraction succeeds
- **WHEN** the applier applies an `extract-then-delete` entry
- **THEN** it completes the extraction write (ADR/CLAUDE.md/docs or vault) first, and performs the `git rm` only after that write succeeds
#### Scenario: A failed extraction skips the delete for that entry
- **WHEN** the extraction step of an `extract-then-delete` entry fails
- **THEN** the delete is not applied for that entry, the entry is reported as skipped, and the run is not treated as a hard failure unless the failure matches an existing hard-failure trigger (applier exit 2, write error)
#### Scenario: Directory-rule aggregate delete removes the whole directory
- **WHEN** the applier applies a `delete` entry whose path is a directory-rule aggregate entry
- **THEN** it performs a recursive `git rm` removing the entire matched directory in one operation staged into the single hygiene commit
#### Scenario: A vault extraction leaves an extracted.md pointer in the same commit
- **WHEN** an `extract-then-delete` entry extracts to the vault via `/os-vault:write`
- **THEN** a pointer entry naming the vault note, the reason to follow it, the source filename, and the date is appended to the deleted file's directory `extracted.md` (created if absent), and the append, the deletion, and the index file are all staged into the same single hygiene commit
#### Scenario: A repo-durable extraction leaves no index entry
- **WHEN** an `extract-then-delete` entry extracts into an ADR, CLAUDE.md, or docs target inside the repo
- **THEN** no `extracted.md` entry is written — the residue is already discoverable in-repo
#### Scenario: A failed pointer append skips the delete
- **WHEN** the vault write succeeds but appending the `extracted.md` pointer fails
- **THEN** the `git rm` is not applied for that entry and it is reported as skipped, preserving the invariant that a doc is never gone but undiscoverable
### Requirement: Clean Skill Orchestration
The `clean` skill SHALL load the current report via `StateStore.read_report`
(if none, it SHALL tell the user to run `/os-doc-hygiene:check` and stop). It SHALL
re-validate the loaded report via `validate_report.py` (if invalid, stop). It SHALL
apply a scope/category filter to entries. It SHALL partition entries into
`auto+deterministic`, `confirm+deterministic` (i.e., `delete-range`), and
`generative` (always `confirm`) subsets. It SHALL gate `confirm` entries before any
mutation. It SHALL run the git preflight (clean check / WIP checkpoint). It SHALL
apply deterministic approved entries via `patch_applier.py`. It SHALL dispatch
generative approved entries to a Sonnet subagent via `workflows/distill.md`
(LOOP-GUARD subagent pointer). It SHALL stage precisely and commit once via
`git-context commit-apply --message-stdin`. It SHALL stamp `last_clean`. It SHALL
surface applied/skipped (with re-analysis notes) / confirmed counts and the commit
SHA.
#### Scenario: No report — prompt to check first
- **WHEN** the user runs /os-doc-hygiene:clean and no report exists in .dochygiene/
- **THEN** the skill tells the user to run /os-doc-hygiene:check first and stops without modifying anything
#### Scenario: Invalid report — stop
- **WHEN** the loaded report fails re-validation
- **THEN** the skill stops and reports the validation error without modifying anything
#### Scenario: Zero in-scope entries — no-op report
- **WHEN** all entries are filtered out by scope/category
- **THEN** the skill reports zero in-scope entries and exits without modifying the tree or creating a commit
#### Scenario: Full pipeline succeeds
- **WHEN** the report contains valid entries and the user approves all confirm entries
- **THEN** the skill applies deterministic entries via the applier, dispatches generative entries to Sonnet, stages precisely, commits once, stamps last_clean, and surfaces the commit SHA
### Requirement: Generative Distillation via Live-Read Sonnet Subagent
For approved generative entries, the clean skill SHALL confirm the file still exists
(a preceding `move-to-archive` on the same file would have removed it). It SHALL read
the **live** file contents at dispatch time (not from the report cache) to guarantee
freshness, because generative entries carry no `expected_sha256`. It SHALL dispatch
to a Sonnet subagent pointing at `workflows/distill.md` (LOOP-GUARD — the subagent
reads that workflow, not SKILL.md, to avoid recursion). The subagent SHALL return
new prose (or new-primary + archived-section for split). The skill SHALL write and
stage the result. A file with both a generative entry and a deterministic entry SHALL
be treated as `incompatible-ops-on-file` and skipped.
#### Scenario: File missing at dispatch time — skip
- **WHEN** a generative entry targets a file that no longer exists (e.g., moved by a prior step)
- **THEN** the skill skips the generative entry and reports it as skipped
#### Scenario: Live read ensures freshness
- **WHEN** the skill dispatches a generative entry to Sonnet
- **THEN** it reads the file's current bytes immediately before dispatch, not from the cached report span
#### Scenario: Generative + deterministic on same file — incompatible
- **WHEN** a file has both a generative entry and a deterministic entry
- **THEN** the applier (or skill) skips the file with reason incompatible-ops-on-file and recommends re-analysis
### Requirement: Sweep Composes Check Then Clean
`/os-doc-hygiene:sweep` SHALL invoke the `check` skill followed by the
`clean` skill, passing `--scope` and `--category` to both. The sweep SHALL
NOT produce a double-commit: `check` writes only to the gitignored `.dochygiene/`
directory and never commits; the single cleanup commit from `clean` is the only git
commit the sweep produces. The sweep routing SHALL live in its own `sweep` skill
(`skills/sweep/SKILL.md`), not by having the `clean` skill invoke `check` internally.
#### Scenario: Sweep routes through its own skill, not by nesting clean inside check
- **WHEN** the user runs /os-doc-hygiene:sweep
- **THEN** the `sweep` skill invokes the `check` skill then the `clean` skill sequentially, passing shared flags
#### Scenario: Sweep produces at most one cleanup commit
- **WHEN** the user runs /os-doc-hygiene:sweep on a clean tree
- **THEN** at most one git commit is produced (the cleanup commit from clean); check writes nothing to git