342 lines
19 KiB
Markdown
342 lines
19 KiB
Markdown
# 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 5–15 and lines 10–20)
|
||
- **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
|
||
|