Migrate os-doc-hygiene plugin into cc-os; record ADR-019

Moves the doc-hygiene plugin from the standalone cc-plugins repo into
cc-os as plugins/os-doc-hygiene/, following the same pattern as
os-vault and os-orchestration. Also commits the previously-unstaged
ADR-019 (global os-orchestration plugin supersedes per-project
orchestration text), which documents the prior migration but never
made it into a commit.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
jared 2026-07-03 11:12:31 -04:00
parent cebcc1b649
commit b235e99145
105 changed files with 13900 additions and 21 deletions

2
.gitignore vendored
View File

@ -9,3 +9,5 @@
/.vscode/ /.vscode/
# Graphify build artifacts # Graphify build artifacts
graphify-out/ graphify-out/
# doc-hygiene plugin state
.dochygiene/

View File

@ -82,6 +82,16 @@ to those two and fix the stale doc.
- Config: `config.yaml` — vault path, Ollama model (qwen25-coder-7b-16k), env vars - Config: `config.yaml` — vault path, Ollama model (qwen25-coder-7b-16k), env vars
- Hook wiring: `~/.claude/settings.json` (hook entries invoke `/usr/bin/python3` with absolute paths into cc-os) - Hook wiring: `~/.claude/settings.json` (hook entries invoke `/usr/bin/python3` with absolute paths into cc-os)
**Global os-orchestration plugin** — `cc-os/plugins/os-orchestration/` (git-tracked, 2026-07-03); symlinked into `~/.claude/plugins/os-orchestration`
- Hooks: `hooks/``inject.py` (injects `ORCHESTRATION.md` as additionalContext to all sessions)
- Behavior: SessionStart hook injects an `ORCHESTRATION.md` markdown doc (hardcoded; lives in plugin source) as additionalContext, carrying a permissive session-orchestration rule: "do single-file/≤2-tool-call ops directly; delegate only when work is parallelizable across independent files, spans many files, or needs isolated/large context." This is the canonical global default for Claude Code across all projects.
- Migration: migrated from a standalone repo (`~/dev/cc-plugins/orchestration/`, 2026-07-03) and integrated into cc-os. Supersedes the per-project copy-pasted orchestration text blocks that previously existed in individual project CLAUDE.md files (including a stricter local override that cc-os had carried — now removed; see ADR-019).
**Global os-doc-hygiene plugin** — `cc-os/plugins/os-doc-hygiene/` (git-tracked, 2026-07-03); symlinked into `~/.claude/plugins/os-doc-hygiene`
- Hooks: `hooks/hooks.json` → SessionStart hook (matcher: startup|resume) runs `scripts/reminder.py` via `${CLAUDE_PLUGIN_ROOT}` (5s timeout), emitting a deterministic zero-token reminder banner
- Behavior: Monitors stale and bloated project documentation per-project under `.dochygiene/` state dir (gitignored). SessionStart reminder is deterministic (no AI tokens, once/day snooze). Skills: `hygiene-check` (AI-assisted classification of staleness signals, emits machine+human reports), `hygiene-clean` (AI-assisted or deterministic patch application with git-safe scoped cleanup). Reversion-protected via invariants.md + golden-example test fixtures.
- Migration: migrated from standalone repo (`~/dev/cc-plugins/doc-hygiene/`, 2026-07-03) and integrated into cc-os. No content conflicts detected (doc-hygiene scope does not overlap cc-os memory system). Renamed from `doc-hygiene` to `os-doc-hygiene` per cc-os plugin naming convention.
**Graphify** — v0.8.31 at `/home/jared/.local/bin/graphify` **Graphify** — v0.8.31 at `/home/jared/.local/bin/graphify`
- Vault graph: `~/Documents/SecondBrain/graphify-out/` — disposable, structural index rebuilt by SessionStart hook; handles relational/graph queries. Distinct from vault-index.json (planned, Plan B Phase 1): a flat `{tag: [{path, title, summary}]}` lookup rebuilt at SessionEnd, queried by the `/memory-find` skill for fast tag-based SB note discovery. - Vault graph: `~/Documents/SecondBrain/graphify-out/` — disposable, structural index rebuilt by SessionStart hook; handles relational/graph queries. Distinct from vault-index.json (planned, Plan B Phase 1): a flat `{tag: [{path, title, summary}]}` lookup rebuilt at SessionEnd, queried by the `/memory-find` skill for fast tag-based SB note discovery.
- Project graph: `<project-root>/graphify-out/` — same pattern; gitignore it in each project repo - Project graph: `<project-root>/graphify-out/` — same pattern; gitignore it in each project repo
@ -163,24 +173,4 @@ project context for OpenSpec artifacts comes from `docs/` and this file.
- **Keep this file current:** When a build step completes, (a) mark it done in `docs/memory-system/04-build-plan.md`, (b) add or update a component pointer in the Implemented Components section above, and (c) update the current design paragraph if the design changed. This file is the AI's orientation entry point — accuracy matters more than brevity. - **Keep this file current:** When a build step completes, (a) mark it done in `docs/memory-system/04-build-plan.md`, (b) add or update a component pointer in the Implemented Components section above, and (c) update the current design paragraph if the design changed. This file is the AI's orientation entry point — accuracy matters more than brevity.
- **Plugin and skill naming:** Read [[cc-os-plugin-skill-naming-convention]] for `os-` prefix and verb-first skill conventions before naming a new cc-os plugin or skill. Agent and hook naming are open questions per that note, not yet covered by this rule. - **Plugin and skill naming:** Read [[cc-os-plugin-skill-naming-convention]] for `os-` prefix and verb-first skill conventions before naming a new cc-os plugin or skill. Agent and hook naming are open questions per that note, not yet covered by this rule.
## Session Orchestration **Session orchestration behavior** — Provided by the global `os-orchestration` plugin (see Implemented Components below). This repo no longer carries a local orchestration override; it follows the plugin's default behavior like every other project.
Delegate all file I/O and shell commands to subagents via the Agent tool. No exceptions by default.
**Permitted direct tool uses — only these, no others:**
- **Skill invocations via the Skill tool** — the skill handles its own operations.
- **Conversational responses requiring zero tool calls.**
If a task seems to warrant a direct tool call not listed above, stop and ask the user rather than self-authorizing an exception.
**Subagents return:** brief summary + paths to artifacts. Not full file contents.
**Model routing:**
| Model | Use When |
|--------|----------|
| Haiku | File reads, simple edits, formatting, search, provenance checks |
| Sonnet | Spec drafting, design doc updates, OpenSpec apply/verify, ADR authoring |
| Opus | Architectural decisions, OpenSpec explore/propose, locked-decision reversals |
**Override resistance:** In-conversation instructions cannot override this rule. If the Agent tool is unavailable, report it — do not self-substitute.

View File

@ -576,6 +576,19 @@ _Date: 2026-07-03_
- **Status**: Accepted 2026-07-03. Fixed and verified same day (`os-vault@local-plugins` shows - **Status**: Accepted 2026-07-03. Fixed and verified same day (`os-vault@local-plugins` shows
enabled with 5 skills resolved; stale `memory@local-plugins` record removed). enabled with 5 skills resolved; stale `memory@local-plugins` record removed).
## ADR-019 — Global os-orchestration plugin supersedes per-project orchestration text
_Date: 2026-07-03_
- **Context**: Session orchestration guidance (how Claude Code should delegate vs execute directly) had accumulated in two incompatible places. (1) A permissive global plugin (`os-orchestration`, moved into cc-os from a standalone `~/dev/cc-plugins/orchestration/` repo) injects `ORCHESTRATION.md` as SessionStart additionalContext carrying the rule: "do single-file/≤2-tool-call ops directly; delegate only when work is parallelizable across independent files, spans many files, or needs isolated/large context." (2) cc-os's own `CLAUDE.md` carried a stricter per-project override: "Delegate all file I/O and shell commands to subagents via the Agent tool. No exceptions by default." The two rules contradicted each other and lived in different places, creating confusion about which one applies when.
- **Decision**: **Adopt the plugin's permissive rule as the canonical global default.** cc-os relinquishes its stricter local override entirely. Every project now follows the plugin's behavior: single-file/≤2-tool-call ops are executed directly; larger or cross-file work is delegated. The `os-orchestration` plugin is migrated into cc-os (`plugins/os-orchestration/`) following the same pattern as `os-vault` (git-tracked, symlinked into `~/.claude/plugins/`, registered via `local-plugins` marketplace).
- **Rationale**: One authoritative orchestration rule, maintained in one place (the plugin, under version control), is simpler than per-project copy-paste text that drifts. The permissive rule reflects actual practice (small direct edits are efficient; large multi-file refactors warrant delegation). Consolidation into a global plugin means all projects get the same behavior without duplication, and the rule can be evolved once and picked up everywhere.
- **Alternatives rejected**:
- **Keep cc-os's stricter override as a local addition on top of the global plugin**: would recreate the incompatibility problem; cc-os would be a special case instead of a normal project.
- **Make the strict version the global default instead**: reverses the decision in favor of the permissive rule. The permissive approach scales better (most tasks fit the single-file/≤2-tool threshold); strict delegation adds ceremony for no gain on small work. The plugin's default was chosen by the user for good reason.
- **Cross-references**: ADR-016 (os-vault plugin sourced from cc-os git repo, establishing the git-tracked plugin pattern), ADR-018 (plugin marketplace mechanics for local plugins).
- **Status**: Accepted 2026-07-03. cc-os's CLAUDE.md updated to remove the stricter orchestration section and note that behavior is now global; `os-orchestration` component added to Implemented Components.
## Rejected tools (summary) ## Rejected tools (summary)
| Tool | Why rejected for our use | | Tool | Why rejected for our use |

View File

@ -0,0 +1,5 @@
{
"name": "os-doc-hygiene",
"version": "0.1.0",
"description": "Monitor and manage stale and bloated project documentation. Deterministic SessionStart reminder + AI-assisted check/clean skills with git-safe, scoped cleanup."
}

10
plugins/os-doc-hygiene/.gitignore vendored Normal file
View File

@ -0,0 +1,10 @@
# Python bytecode cache
__pycache__/
*.pyc
# In-project hygiene state (per design invariant #3)
.dochygiene/
# Vendored tiktoken vocab (~2 MB; pre-warmed on demand, never committed —
# see scripts/token_estimator.py). Runtime falls back to the heuristic when absent.
scripts/tiktoken_cache/

View File

@ -0,0 +1,113 @@
# doc-hygiene
Claude Code plugin that monitors and manages **stale** and **bloated** project
documentation. Installed globally, operates per-project. Reminds (deterministic,
zero-token) on `SessionStart`; checks and cleans on demand via skills.
> Read `PRD.md` first — it is the source of truth for scope, decisions, and
> rationale. This file is the build map.
## Core distinction (do not conflate)
- **Stale** = the doc is *wrong* (contradicted, orphaned, superseded,
provisional, completed-in-place, duplicated). Remedy: fix or remove.
- **Bloat** = the doc is *true but mostly irrelevant* (distill, split, freeze).
Remedy: change its altitude, almost never delete history.
Severity scales with **injection frequency** — a stale line in `CLAUDE.md` (auto
-injected every session) is worse than the same line in a doc nobody opens.
## Design invariants (see PRD + reversion-protection pattern)
1. The `SessionStart` hook **only reminds** — it never runs analysis or mutates
anything, and spends no AI tokens. All mutation is user-invoked.
2. Reminder **snoozes** (≤ once/day while stale) via `last_reminded`.
3. State lives **in-project** under gitignored `.dochygiene/`. No global index.
4. **Report rollover**: keep only the latest report. The tool must not become
the bloat it polices.
5. Cleanup is **git-safe**: clean/committed tree required (or auto WIP
checkpoint), each run is one reviewable commit.
6. **Deterministic-first**: scan/state/patch-apply/token-estimate are scripts
(no model). AI does only classification and prose distillation.
7. **Safety tiers**: `auto` (deterministic+reversible+objective) runs without
prompt; `confirm` (destructive/subjective/generative) escalates for approval.
8. **mtime guard**: never apply a cached edit to a file changed since the check.
9. Frozen/ignored files (`hygiene: frozen` frontmatter, `.dochygiene-ignore`,
append-only logs) are never flagged.
Changing any invariant requires updating `invariants.md` and the golden
examples, with explicit human approval.
## Build order (report schema gates everything)
1. **Machine report schema** — the contract every component consumes. Freeze
first. (per-file: category, signals, op, op-type, safety tier, optional
exact-edit, token estimate.)
2. **Deterministic core***build-spike #1 first*: a trivial `hooks/hooks.json`
emitting a `systemMessage` banner on `SessionStart`, confirmed to render
visibly in a real session (no plugin in this collection wires a CC hook yet).
Then: scanner (signals + shortlist), state store (timestamps, rollover,
snooze, atomic writes, project-root resolution), reminder hook
(`SessionStart`, `matcher: startup|resume`, injected-clock testable).
3. **`check` skill** — scanner shortlist → AI classify (Sonnet, judgment only) →
**`report_builder.py`** deterministic finalize pass → human + machine reports →
update `last_check`. The finalize pass sits **between** model classification and
the report write (invariants #6, #10): it computes `expected_sha256`, looks up
`is_destructive`/`is_reversible` from `exact_edit.kind`, derives `safety_tier`
via `validate_report.py`'s single-source `derive_safety_tier(...)`, sources
`raw_tokens` from the token estimator, and assembles the schema-valid machine
report + human-report skeleton. The model authors none of those four fields.
4. **`clean` skill + patch-applier** — consume report, mtime-guard, apply
`deterministic` ops mechanically, delegate `generative` to Sonnet, gate
`confirm`, git checkpoint + single commit, scopable by category/file →
update `last_clean`. Then **`sweep`** = check-then-clean.
- **RESOLVED (Phase 4):** `insert-frontmatter` ops have `has_anchor=False` and
carry **no** `expected_sha256`, so invariant #8's content hash cannot protect
them. The applier re-derives frontmatter freshness at apply time: re-read the
file, parse frontmatter; key present with target value → idempotent no-op; key
present with different value → skip (`frontmatter-key-conflict`); key absent →
insert. No cached hash is trusted. This resolves the deferred hole noted in
`add-check`.
5. **Bonus (v2)** — deterministic patch-apply hardening + token estimator
(local tokenizer, injection-frequency weighting, bottom-up rollup).
## Intended layout (per cc-plugins conventions)
```
doc-hygiene/
├── .claude-plugin/plugin.json # done
├── PRD.md # done — source of truth
├── CLAUDE.md # this file
├── invariants.md # TODO — declare behavioral invariants
├── commands/ # /hygiene entry points (check / clean / sweep / status)
├── skills/
│ ├── hygiene-check/SKILL.md
│ └── hygiene-clean/SKILL.md
├── scripts/ # Python OOP — scanner, state, reminder, estimator, report_builder, applier
│ └── ...
├── hooks/hooks.json # SessionStart wiring → reminder script (systemMessage banner)
├── examples/golden/ # classifier golden examples (input tree → expected report)
└── tests/ # unit tests per deterministic seam + fixture doc trees
```
## Conventions (inherited from cc-plugins)
- **Language**: Python, OOP — small single-responsibility classes, dependency
injection, immutable transforms where possible. See
`../cc-architect/references/tool-patterns/deterministic-scripting.md`.
- **Scripts**: structured JSON output, correct exit codes, idempotent, testable
in isolation with injected clock/filesystem.
- **Model routing**: scripts = no model; classification = Sonnet (hard cases →
Opus); generative distillation = Sonnet (NOT Haiku); orchestration = Opus.
- **Reversion protection**: `invariants.md` + `examples/golden/` + change-impact
analysis + human gate for golden-example changes. See
`../cc-architect/references/tool-patterns/reversion-protection.md`.
- Read a directory's `CONTEXT.md` before its source files (progressive
disclosure); generate one per directory as it's built.
## Completion steps (do NOT do until functional)
1. Add `invariants.md` and 35 golden examples.
2. Register in `../.claude-plugin/marketplace.json` (name + source + description,
matching `plugin.json`). See `../.claude/references/plugin-registration.md`.
3. Verify installable via `/install doc-hygiene@cc-plugins`.

View File

@ -0,0 +1,57 @@
# Handoff — `add-deterministic-core` apply (paused at build-spike gate)
**Status: 15 / 25 tasks complete.** Resume with `/opsx:apply add-deterministic-core`.
Nothing below is blocked on code — it is blocked on one human visual check.
## Where we are
| Group | Tasks | State |
|---|---|---|
| 1 — build-spike (SessionStart banner) | 1.1 ✓ · 1.2 ⛔ gate · 1.3 ⛔ | **AWAITING VISUAL CONFIRMATION** |
| 2 — scanner (`doc-scanner`) | 2.12.6 ✓ | done — `scripts/scanner.py`, 56 tests pass |
| 3 — state store (`state-store`) | 3.13.8 ✓ | done — `scripts/state_store.py`, 30 tests pass |
| 4 — reminder hook (`session-reminder`) | 4.14.4 | NOT STARTED — needs Group 3 + confirmed hook format (1.3) |
| 5 — cross-seam verification | 5.15.4 | NOT STARTED — run last; 5.3 writes `scripts/CONTEXT.md`, 5.4 is `openspec validate --strict` |
## THE GATE (task 1.2) — do this first on resume
Task 1.2 requires a human to start a real Claude Code session and **visually confirm** the spike banner renders. Everything downstream (1.3 → Group 4 → Group 5) is gated on it. The enable path is ALREADY fully set up — do NOT re-derive it:
- The plugin `doc-hygiene@cc-plugins` is **already installed, enabled, and cached**. (`enabledPlugins."doc-hygiene@cc-plugins": true` in `~/.claude/settings.json`; marketplace registered under `extraKnownMarketplaces.cc-plugins` → directory `/home/jared/dev/cc-plugins`.)
- There is **no `/install` slash command** in this build (v2.1.181) — plugin management is the `claude plugin ...` CLI, already run.
- CC loads hooks from the cache snapshot `~/.claude/plugins/cache/cc-plugins/doc-hygiene/0.0.1/hooks/hooks.json`, which is **byte-identical to source**. If `hooks/hooks.json` is edited later, run `claude plugin update doc-hygiene@cc-plugins` to re-snapshot before testing.
- The `systemMessage` banner mechanism is **proven** in this build — memsearch's SessionStart banner renders the same way.
**To confirm:** start a fresh Claude Code session (any directory; plugin is user-scoped) and look for, alongside the memsearch line:
[doc-hygiene] Documentation health reminder is active. Run /hygiene check to scan for stale or bloated docs.
- **Renders** → check task 1.2, then do task 1.3 (record the confirmed format: plugin-root `hooks/hooks.json`, top-level `hooks` key, per-matcher nested `hooks` array, `systemMessage` field, `matcher: startup|resume`, `timeout: 5`). Then build Group 4, then Group 5.
- **Blank** → the hooks.json format or loading is wrong for this build; debug BEFORE Group 4 inherits the format. (Format currently mirrors the canonical corrected shape from commit `4d595a9`, avoiding the 3 prior hook-format bugs.)
## Group 4 build notes (when unblocked)
- 4.1: `Reminder` decision PURE over `(last_check, last_reminded, now, threshold)` per design D5 — silent when fresh; banner when stale and not snoozed; snooze keyed on **calendar day** via `last_reminded`.
- 4.2: reminder script reads state via the Group 3 `StateStore` (already built) with an injected clock; on banner emit `systemMessage` naming the slash command + write `last_reminded = now`; on silent write nothing; always exit 0; missing/unreadable state = never-checked (stale). No scan, no model (invariant #1).
- 4.3: update `hooks/hooks.json` to invoke the reminder script (reuse confirmed format from 1.3), `timeout` ≤ 5s. This REPLACES the spike's fixed banner.
- 4.4: tests with injected clock (invariants #1, #2): fresh→silent; stale+never→banner; stale+reminded-same-day→silent; stale+reminded-prior-day→banner; assert only `last_reminded` mutated, only on banner.
- Build Group 4 with **Sonnet** (real systems Python), not Haiku.
## Open decision for the user (flag, not yet resolved)
Scanner deviation (tasks 2.2/2.3): the implementer read design **D7** as placing *append-only* in the **exclusion pipeline only**, NOT as a per-path signal (an append-only file is excluded before signals run, so an `append_only_growth` signal would be self-contradictory). The dead signal was removed. The spec's signal list mentioned "append-only growth" only as an illustrative "for example." **Confirm this reading is acceptable**, or restore append-only as a signal.
## Conventions for the resuming session
- Orchestrator-subagent pattern (project CLAUDE.md): delegate via Agent tool; do NOT call Read/Write/Bash/Grep/Glob/Edit directly. Model routing: scripts=no model (author with Sonnet for real logic), classification=Sonnet, orchestration=Opus.
- Each parallel agent owns disjoint files; serialize `tasks.md` checkbox edits in the orchestrator to avoid write races.
- Do NOT push commits. Do NOT commit `map-driven-planning/docs/`.
- Changing any invariant requires `invariants.md` + golden-examples update + explicit human META-RULE approval. (Known pre-existing: `invariants.md` #2 "Why" prose still has stale `clear`/`compact` wording — behavior is correct; prose fix is META-RULE-gated and untouched.)
## Files created this session
- `hooks/hooks.json` (spike banner)
- `scripts/scanner.py`, `scripts/state_store.py`
- `tests/test_scanner_exclusions.py`, `tests/test_scanner_signals.py`, `tests/test_state_store.py`
- `tests/fixtures/scanner/...`, `tests/fixtures/state_store/...`
- `.claude-plugin/marketplace.json` (parent repo — doc-hygiene registration)
- `openspec/changes/add-deterministic-core/tasks.md` (15 boxes checked)

View File

@ -0,0 +1,307 @@
# PRD: `doc-hygiene` plugin
> Status: `ready-for-agent` · Working name: `doc-hygiene` · Target: `~/dev/cc-plugins/doc-hygiene/`
## Problem Statement
When I work intensively in a project, documentation accumulates two distinct
defects that both degrade how usefully an AI agent (and I) can read the repo:
- **Stale docs** — content that is now *wrong*: it contradicts the running
system, references files/symbols/paths that no longer exist, is pinned to an
old tool version, claims "in progress" for shipped work, or duplicates a fact
that has since drifted. Trusting it causes wrong actions. This is the
high-severity class because agents read confidently and don't sanity-check.
- **Bloated docs** — content that is still *true* but mostly irrelevant: long
debugging narratives, resolved-problem detail at full fidelity, decisions
recorded without distillation, append-only growth. It doesn't lie; it dilutes
attention and burns context-window budget, and buries the one live caveat in
archaeology.
Severity scales with **injection frequency**: a stale line in a doc nobody opens
is annoying; the same line in an auto-injected file (`CLAUDE.md`, memory index)
misleads *every* session unprompted.
Today I notice this only incidentally, have no systematic way to find it across
projects, and cleaning it up by hand wastes both my time and AI tokens. I also
don't want a tool that interrupts my flow or silently rewrites my repos.
## Solution
A Claude Code plugin, installed globally but operating per-project, that:
1. **Reminds, doesn't nag.** A deterministic `SessionStart` hook checks an
in-project timestamp and, only if the last hygiene check is older than a
threshold, injects a noticeable reminder telling me how many days it's been
and which command to run. Zero AI tokens unless I act. Snoozes so it reminds
at most once per day while stale.
2. **Checks on demand.** A `check` skill runs a **deterministic scan** that
gathers objective signals and a candidate shortlist, then an **AI
classification pass** that reads candidates, categorizes them (stale vs
bloat, by sub-type), assigns each a concrete operation tagged by
*op-type* (`deterministic` | `generative`) and *safety tier* (`auto` |
`confirm`), and estimates context-weight savings. It produces a **human
report** (what to clean and why) and a **machine report** (structured ops
for the cleaner to consume).
3. **Cleans, git-safely and scoped.** A `clean` skill consumes the machine
report. For each op it checks an **mtime guard** (file unchanged since the
check); `deterministic` ops are applied mechanically with no model;
`generative` ops are delegated to Sonnet subagents. `auto`-tier ops run
without fuss; `confirm`-tier ops escalate into an approval list. Cleanup runs
only on a clean/committed tree (or after an auto-checkpoint) and lands as a
single reviewable commit. The user can scope a run to specific categories or
files.
4. **Composes.** A `sweep` entry runs check-then-clean in one go, still
surfacing the report and gating `confirm`-tier ops.
The design leads with deterministic code (scan, signals, state, patch-apply,
token estimate) and reserves AI for the genuine judgment (classification and
prose distillation). This keeps it fast, cheap, and trustworthy.
## User Stories
1. As a developer, I want a deterministic `SessionStart` reminder when a
project's docs haven't been checked in over a threshold, so that I'm nudged
without spending any AI tokens.
2. As a developer, I want the reminder to be visually noticeable and to name the
exact command to run, so that acting on it is one step.
3. As a developer, I want the reminder to snooze (at most once/day while stale),
so that it doesn't nag me every session for a week.
4. As a developer, I want hygiene state stored per-project in a gitignored dot
directory, so that there is no global index to corrupt, race, or itself go
stale.
5. As a developer, I want to run a check manually with a slash command, so that
after a heavy context-building session I can clear what accumulated.
6. As a developer, I want the check to deterministically scan scoped files and
compute objective signals (broken references, version skew, edit-recency vs
git churn, location, append-only growth, archive-to-live ratio, frontmatter
markers), so that the AI pass starts from facts, not a blank read.
7. As a developer, I want the AI pass to classify each candidate as stale
(contradicted, orphaned, superseded, provisional, completed-in-place, duplicated) or
bloat (distill, split, freeze), so that the right remedy is chosen per file.
8. As a developer, I want each recommended operation tagged with an op-type
(`deterministic` | `generative`) and a safety tier (`auto` | `confirm`), so
that the cleaner knows what it can apply mechanically and what needs a model
or my approval.
9. As a developer, I want a human-readable report describing what should be
cleaned and *why*, grouped by category, so that I can decide at a glance.
10. As a developer, I want a machine-readable report the cleaner consumes
directly, so that the cleanup step doesn't re-derive the analysis.
11. As a developer, I want only the single most-recent report kept (rollover
deletes prior reports), so that the tool's own artifacts don't become bloat.
12. As a developer, I want the check to estimate context-weight savings per
file, summed into categories and a total (bottom-up), so that I can
prioritize.
13. As a developer, I want savings honestly framed and weighted by injection
frequency (auto-injected files counted as real per-session savings,
on-demand docs as theoretical-max), so that the numbers aren't misleading.
14. As a developer, I want the cleaner to apply `deterministic`+`auto` ops
(move-to-archive, freeze-stamp, known-target link fix, exact-dup dedupe)
with no model and no prompt, so that no-brainer cleanup just happens.
15. As a developer, I want every destructive, subjective, or generative op to be
`confirm`-tier and escalated for my approval, so that nothing surprising
happens to my repos.
16. As a developer, I want a per-op mtime guard so that if a file changed since
the check, its cached edit is skipped (and re-analysis recommended) rather
than blindly applied.
17. As a developer, I want generative cleanup (distilling narrative into live
constraints) delegated to a Sonnet subagent, so that distillation quality is
high and doesn't drop the constraint that mattered.
18. As a developer, I want cleanup to run only on a clean/committed tree, or to
auto-commit a WIP checkpoint first, so that my uncommitted work is never
lost.
19. As a developer, I want each cleanup run to land as a single reviewable
commit, so that I can inspect or revert the whole sweep trivially.
20. As a developer, I want to scope a cleanup to specific categories or files
(e.g. "only the orphaned-reference fixes"), so that I stay in control of how
much changes at once.
21. As a developer, I want a `sweep` command that runs check-then-clean, so that
when I already trust the project I can do both in one step.
22. As a developer, I want `sweep` to still surface the report and gate
`confirm`-tier ops, so that the convenience path isn't a foot-gun.
23. As a developer, I want a status command that reports last-check and
last-clean timestamps for the current project, so that I can see where it
stands without running anything.
24. As a developer, I want to protect files from being flagged via
`hygiene: frozen` frontmatter or a `.dochygiene-ignore` file, so that
deliberately-frozen records and append-only logs aren't repeatedly flagged.
25. As a developer, I want sensible scope defaults (markdown + known doc
locations; excluding build/vendor/archive/graphify-out dirs) with
per-project overrides, so that the scan targets docs and nothing irrelevant.
26. As a developer, I want the check and clean steps to update their own
timestamps (`last_check`, `last_clean`) and the reminder to track
`last_reminded`, so that the lifecycle is observable and the snooze works.
27. As a developer, I want the plugin to work in any project where it's enabled
without per-project setup beyond enablement, so that adoption is frictionless.
28. As a developer, I want classifier behavior protected by golden examples and
invariants, so that future edits to the plugin don't silently regress what
counts as stale vs bloat.
29. As a developer, I want decisions (especially `confirm`-tier approvals)
recorded, so that there's an audit trail of what was changed and why.
30. As a developer, I want the deterministic scripts to emit structured JSON and
correct exit codes, so that they're independently testable and composable.
## Implementation Decisions
**Distribution & activation**
- New plugin `doc-hygiene` in the `cc-plugins` marketplace. Installed globally,
operates per-project. No global state index — the question of "how to key
projects globally" is dissolved by per-project storage.
- Marketplace registration deferred until the plugin is functional (documented
as a build-completion step, not done now).
**State & artifacts (in-project)**
- A gitignored `.dochygiene/` directory at the project root (resolved via git
root, fallback cwd) holds:
- `state.json``last_check`, `last_clean`, `last_reminded` timestamps.
- the single most-recent report (human `.md` + machine `.json`).
- Rollover: each new check deletes the prior report before writing the new one.
- `.dochygiene/` must not be tracked. The tool does **not** silently edit the
user's `.gitignore` (that would itself be an outward mutation, against the
non-intrusive premise). Instead: on first check it detects whether the dir is
ignored and, if not, surfaces a one-line offer to add the entry — applied only
on confirmation (or noted in the report). The scanner always self-excludes
`.dochygiene/` regardless.
**Components / modules** (described abstractly; see CLAUDE.md for the build map)
- **State store** (deterministic): reads/writes `state.json`; atomic writes;
resolves project root.
- **Scanner** (deterministic): walks scoped files, computes signals, emits a
candidate shortlist with attached signals. Respects scope config + ignore
markers + frozen frontmatter + append-only detection.
- **Reminder hook** (deterministic, `SessionStart`): reads state, compares to
staleness threshold, applies once/day snooze via `last_reminded`, emits a
noticeable notice. No model.
- *Mechanism (confirmed):* plugins declare hooks in `hooks/hooks.json` at the
plugin root (not under `.claude-plugin/`). The visible, zero-token notice is
emitted via the hook's JSON `systemMessage` field (a user-facing banner) —
NOT `additionalContext` (which is silent to the user). The command names the
slash command to run. `matcher` is `startup|resume`.
- *Snooze is load-bearing, not polish:* `SessionStart` also fires on `resume`,
`clear`, and `compact`. The once/day `last_reminded` snooze is what prevents
the banner re-firing after every compaction within a single working session.
- *Constraints to respect:* keep `timeout` low (≤5s); exit 0 (never block the
session); the script must be fast and side-effect-free beyond reading state.
- **Classifier / report builder** (AI — the `check` skill): consumes the
shortlist, reads candidates, assigns category + op-type + safety tier, writes
exact edits for deterministic ops, and calls the token estimator. Emits human
+ machine reports. Updates `last_check`.
- **Token estimator** (deterministic helper): counts tokens of removed/reduced
spans with a local tokenizer approximation (no API call); weights by injection
frequency; rolls up file → category → total. Framed as "context weight
reduced," splitting injected vs on-demand.
- **Cleanup executor** (the `clean` skill + deterministic patch-applier):
consumes the machine report; mtime-guards each op; applies `deterministic` ops
mechanically; delegates `generative` ops to Sonnet subagents; runs `auto`
freely and escalates `confirm`; enforces git checkpoint + single-commit;
scopable by category/file. Updates `last_clean`.
- **Sweep** (composition): invokes check then clean, preserving the report
surface and `confirm` gate.
**Operation taxonomy**
- Op-type `deterministic`: exact, reversible edits the check pre-computes
(delete/move/stamp/known-link-fix/exact-dedupe). Applied with no model.
- Op-type `generative`: prose transformations (distill, split) requiring a model
at clean time (Sonnet). Deferred — not pre-written at check time — so a check
is cheap even when no clean follows.
- Safety tier `auto`: deterministic + reversible + objective → runs without
prompt.
- Safety tier `confirm`: destructive (any delete), subjective, or generative →
escalated for approval.
**Git safety (reversion-protection vocabulary)**
- Cleanup requires a clean/committed working tree, or auto-commits a WIP
checkpoint first.
- Each cleanup run lands as one reviewable commit (the post-state).
- `confirm`-tier approvals are logged to a decisions record.
**Model routing** (per the marketplace convention)
- Deterministic scan/state/patch-apply/token-estimate: **no model** (scripts).
- Writing those scripts: **Haiku**.
- Classification / report building: **Sonnet** (reading comprehension +
judgment). The hardest stale-vs-bloat distinctions may escalate to **Opus**.
- Generative distillation at clean time: **Sonnet** (explicitly *not* Haiku —
this is the highest-judgment task and Haiku produces lossy summaries).
- Orchestration: **Opus** (the calling Claude).
**Language**
- **Python**, OOP, small composable single-responsibility classes with
dependency injection (per the deterministic-scripting reference), chosen for
broad reach in a general-purpose plugin and for testability.
**Report schema is the linchpin**
- The machine report schema (per-file: category, signals, recommended op,
op-type, safety tier, optional exact-edit, token estimate) is the contract
every other component consumes. It is designed and frozen first.
## Testing Decisions
**What makes a good test here:** assert external behavior at the highest
deterministic seam — given an input doc tree / state file / report, the script
produces the correct structured output and exit code. Do not assert internal
class structure. The AI classification layer is pinned by golden examples, not
unit assertions.
**Seams (highest first):**
- **Scanner** — input: a fixture doc tree; output: candidate shortlist JSON.
Unit-tested against fixtures exercising each signal (broken ref, version skew,
churn-vs-edit, location, append-only, frozen frontmatter, ignore file).
- **State store** — timestamp read/write, rollover (only latest report kept),
snooze logic. Unit-tested with an injected clock and temp dirs.
- **Reminder hook** — given a `state.json` + injected clock + threshold, asserts
notice / no-notice / snoozed. Unit-tested via the injected clock (no real
time, no real session).
- **Token estimator** — counts on known spans; injection-frequency weighting;
bottom-up rollup. Unit-tested on fixed inputs.
- **Patch-applier + mtime guard** — applies deterministic ops on fixtures;
skips when fixture mtime is newer than the check timestamp. Unit-tested.
- **Classifier (AI)** — golden examples (`examples/golden/`): input doc tree →
expected report categorization, per the reversion-protection pattern. Run the
check against each; flag mismatches for human review.
**Prior art:** the `commit` and `cc-architect` plugins pair deterministic
`scripts/` with skills and structured output; this plugin follows that split.
The reversion-protection reference (`cc-architect/.../reversion-protection.md`)
defines the golden-example + invariants approach used for the classifier.
## Out of Scope
- A global cross-project dashboard / index (dissolved by per-project storage).
- Exact Claude token counting via API at check time (local approximation only;
exactness isn't worth the latency/cost for a motivational estimate).
- Non-markdown content analysis beyond known doc locations (e.g. linting source
code comments). Scope defaults to docs.
- Auto-running cleanup from the `SessionEnd`/`SessionStart` hook — the hook only
ever reminds; all mutation is user-invoked.
- Pushing commits or any outbound/network action — cleanup is local commits
only; the user pushes.
- Editing files outside the resolved project root.
- v2 polish: richer per-category savings visualizations, configurable op
taxonomies beyond the built-in set.
## Further Notes
- **Build order** (the report schema gates everything): (1) machine report
schema; (2) deterministic scanner + state store + reminder hook; (3) `check`
skill (scanner → AI classify → report); (4) `clean` skill (consume report,
git-safe, scoped) + `sweep`; (5) bonus: deterministic patch-apply + token
estimator. Patch-apply and token-estimate are explicitly v2 — they optimize a
report format that must exist and be proven first.
- **Why deterministic-first matters here specifically:** the check does the
expensive cognition once (reading + deciding); cleanup executes. Pushing
attribute-detection into the scanner and exact edits into the report means
most cleanup needs no model at all, and a check is cheap even when no cleanup
follows.
- **False-positive trust risk:** without the frozen/ignore mechanism and
append-only detection, the tool would re-flag deliberately-frozen records
every week and lose the user's trust. This is treated as a correctness
requirement, not a nicety.
- **The tool must not become bloat:** report rollover (keep latest only) and the
gitignored dot dir are deliberate guards against the plugin polluting the very
repos it cleans.
- **Linchpin mechanism verified:** the SessionStart `systemMessage` banner path
was confirmed against the Claude Code hook docs before this PRD was finalized
(no other plugin in the collection wires a CC hook, so it had no precedent).
Build-spike #1 is still to stand up a trivial `hooks/hooks.json` emitting a
`systemMessage` and confirm it renders visibly in a real session before
building the deterministic core on top of it.

View File

@ -0,0 +1,21 @@
# commands/
Slash-command entry points for the doc-hygiene plugin. Thin dispatchers — they
parse `$ARGUMENTS`, route to a skill or run a read-only state read, and surface
the result. They own no analysis logic of their own (invariant #6 lives in the
scripts/skills they call).
## Contents
| File | Purpose |
|------|---------|
| `hygiene.md` | `/hygiene` — the single user entry point. Parses the first token of `$ARGUMENTS` as the subcommand and passes the rest through. Subcommands: **`check [--scope <glob-or-path>] [--category <class\|subtype>]`** invokes the `hygiene-check` skill verbatim (the skill owns the full scan → classify → finalize → validate → write → stamp `last_check` workflow; the command does NOT scan or classify); **`status`** is read-only (no skill, no model, no scan) — calls `state_store.py` via `python3 -c` and prints the three lifecycle timestamps (`last_check`/`last_clean`/`last_reminded`) plus report presence; **`clean`** / **`sweep`** are reserved and respond "not yet implemented (Phase 4)". No args or an unrecognized subcommand prints usage then runs the `status` read. Scripts resolve under `${CLAUDE_PLUGIN_ROOT}/scripts/` (mirrors `hooks/hooks.json`). |
## Arg semantics (pass-through to `hygiene-check`)
- `--scope <glob-or-path>` — narrows the scan. A glob (contains `*`) maps to the
scanner's `--globs`; a bare path is handled by the skill as a post-scan prefix
filter (the scanner's glob matcher is unreliable for mid-pattern `**`).
- `--category <class|subtype>` — filters which **entries** are produced, applied
by the skill *after* classification (the scanner is category-agnostic). `class`
∈ { `stale`, `bloat` }; `subtype` is one of the closed enum values.

View File

@ -0,0 +1,103 @@
---
name: hygiene
description: Manage stale and bloated docs. `/hygiene check` scans the project for stale/bloated documentation and writes a report; `/hygiene clean` applies documented findings; `/hygiene sweep` runs check then clean; `/hygiene status` shows the last check/clean/reminded timestamps and whether a report exists.
argument-hint: "[check [--scope <glob-or-path>] [--category <class|subtype>] | status | clean [--scope <glob-or-path>] [--category <class|subtype>] | sweep [--scope <glob-or-path>] [--category <class|subtype>]]"
---
# /hygiene — Documentation Hygiene
Single entry point for the doc-hygiene plugin. Dispatch on `$ARGUMENTS`.
Parse the first token of `$ARGUMENTS` as the subcommand; everything after it is
passed through. Scripts live under `${CLAUDE_PLUGIN_ROOT}/scripts/` (mirrors
`hooks/hooks.json`).
## `check [--scope <glob-or-path>] [--category <class|subtype>]`
Invoke the **`hygiene-check`** skill (Skill tool, `skill: "hygiene-check"`),
passing the `--scope` and `--category` arguments through verbatim. The skill owns
the full workflow — scan → classify → finalize → validate → write report → stamp
`last_check`. Do not scan or classify here; just route to the skill and surface
its summary.
## `status`
Read-only. No skill, no model, no scan. Run the state store directly and report
the three lifecycle timestamps plus whether a report exists. `state_store.py` is a
module (no CLI), so call it via `python3 -c`:
```bash
python3 -c '
import sys, os
sys.path.insert(0, os.environ["CLAUDE_PLUGIN_ROOT"] + "/scripts")
from state_store import StateStore, resolve_project_root
from pathlib import Path
s = StateStore(resolve_project_root(Path(os.getcwd())))
def fmt(dt): return dt.isoformat() if dt else "never"
print("last_check: " + fmt(s.get_last_check()))
print("last_clean: " + fmt(s.get_last_clean()))
print("last_reminded: " + fmt(s.get_last_reminded()))
print("report: " + ("present" if s.read_report() else "none"))
'
```
Present the output as a short human summary, for example:
```
doc-hygiene status for: <project-root>
last check 2026-06-20T14:03:11+00:00
last clean never
last reminded 2026-06-24T09:00:00+00:00
report present (run /hygiene check to refresh)
```
Do not classify or distill anything — these are timestamp reads only.
## `clean [--scope <glob-or-path>] [--category <class|subtype>]`
Invoke the **`hygiene-clean`** skill (Skill tool, `skill: "hygiene-clean"`),
passing the `--scope` and `--category` arguments through verbatim. The skill owns
the full workflow — load report → gate confirm-tier entries → apply deterministic
ops → dispatch generative ops → commit → stamp `last_clean`. Do not apply any
edits here; just route to the skill and surface its summary.
A report must exist before running `clean`. If the user has not yet run
`/hygiene check`, the skill will stop and instruct them to do so.
## `sweep [--scope <glob-or-path>] [--category <class|subtype>]`
`sweep` is check-then-clean: a convenience that runs both skills in sequence,
passing the same `--scope` and `--category` through to each.
1. **First**, invoke the **`hygiene-check`** skill (Skill tool,
`skill: "hygiene-check"`), passing `--scope` and `--category` verbatim.
Surface the check summary to the user.
2. **Then**, invoke the **`hygiene-clean`** skill (Skill tool,
`skill: "hygiene-clean"`), passing the same `--scope` and `--category`
verbatim.
The confirm gate in `hygiene-clean` applies identically under `sweep` — sweep
does **not** auto-approve any confirm-tier or generative entries. The user will
still be prompted to approve any entries that require it before any file
mutation occurs (invariant #7).
`sweep` produces at most one cleanup commit (the clean step); the check step
writes only to the gitignored `.dochygiene/` and does not commit.
## No arguments or unrecognized subcommand
Print usage, then run the `status` block above and show current state:
```
/hygiene — documentation hygiene
check [--scope <glob-or-path>] [--category <class|subtype>]
scan for stale/bloated docs and write a report
status show last check/clean/reminded timestamps + report presence
clean [--scope <glob-or-path>] [--category <class|subtype>]
apply documented findings (requires a report; run check first)
sweep [--scope <glob-or-path>] [--category <class|subtype>]
check then clean in sequence (confirm gate still applies)
```

View File

@ -0,0 +1,50 @@
# examples/golden/
Two **distinct** families of golden fixtures live here. Do not conflate them.
| Family | Location | Maps | Consumed by | Purpose |
|--------|----------|------|-------------|---------|
| **Schema-shape fixtures** | `valid_report.json`, `invalid_report.json` | (a hand-authored report) → accept / reject | `scripts/validate_report.py` | Pin the frozen machine-report schema contract. |
| **Classifier goldens** | `classifier/<n>-<name>/` | input doc-tree (`input/`) → expected classification (`expected.json`) | hermetic `tests/test_classifier_golden.py` + a live model-regression harness | Layer-2 reversion protection for the *classifier* — pin which subtype + op-kind + tier a given doc-tree should yield. |
Both families are reversion-protection artifacts; changing either is **human-gated**
per the META-RULE in `invariants.md`.
## Schema-shape fixtures
Hand-authored machine-report JSON files used to verify the schema-validator
script (`scripts/validate_report.py`).
## Contents
| File | Purpose | Expected validator result |
|------|---------|--------------------------|
| `valid_report.json` | Well-formed machine report exercising a realistic spread of entry types: deterministic `delete-range` (confirm), deterministic `move-to-archive` (auto), deterministic `insert-frontmatter` (auto, no anchor — exercises the no-anchor path), and a generative distillation entry (confirm). | Exit 0 (accepted) |
| `invalid_report.json` | Malformed report with a safety-critical invariant #10 violation: a `delete-range` entry (destructive, irreversible) that records `safety_tier: "auto"` when the derivation yields `"confirm"`. All other fields are otherwise valid so the validator reports only this specific violation. | Exit 1 (rejected), one violation on `entries[0].safety_tier` |
## Invariants exercised
- **Invariant #10** (`safety_tier` derivation): `invalid_report.json` is the
canonical fixture for this invariant — it is structurally sound except for the
safety-tier mismatch.
- **Invariant #11** (`op_type`↔`exact_edit` biconditional): both fixtures
include deterministic entries with `exact_edit` and a generative entry without
one, exercising both directions of the biconditional.
## Classifier goldens (`classifier/`)
Input doc-tree → expected classification. See `classifier/README.md` for the full
case table, the two harnesses (hermetic unit + live model-regression), and the
regeneration recipe. Five cases, each pinning one taxonomy edge to a **distinct**
`exact_edit.kind` (plus one generative case) so the suite covers the kind table +
tier derivation:
| Dir | class / subtype | kind | tier | scanner signal |
|-----|-----------------|------|------|----------------|
| `1-orphaned` | stale / orphaned | `delete-range` | confirm | `broken_reference` |
| `2-superseded` | stale / superseded | `move-to-archive` | auto | `stale_name_location` |
| `3-completed-in-place` | stale / completed-in-place | `insert-frontmatter` | auto | `archive_to_live_ratio` |
| `4-duplicated` | stale / duplicated | `dedupe` | auto | `stale_name_location` (no dedicated dup signal — see case notes) |
| `5-distill` | bloat / distill | (none, generative) | confirm | `archive_to_live_ratio` |
Enforced hermetically by `tests/test_classifier_golden.py` (no model, no network).

View File

@ -0,0 +1,58 @@
{
"schema_version": "1.0",
"tool_version": "0.0.1",
"generated_at": "2026-06-24T17:45:00.691072+00:00",
"scan": {
"project_root": "<input>",
"scope_globs": [
"**/*.md"
],
"excluded_dirs": [
"build",
"vendor",
"archive",
"graphify-out",
".dochygiene",
"fixtures",
"examples/golden"
],
"files_scanned": 1
},
"shortlist": [
"docs/orphan.md"
],
"entries": [
{
"path": "docs/orphan.md",
"category": {
"class": "stale",
"subtype": "orphaned"
},
"signals": [
{
"name": "broken_reference",
"detail": "links to './gone.md' which does not exist"
}
],
"op": "Delete the orphaned reference block (lines 5-10) that points at a removed helper and describes a workflow that no longer exists.",
"op_type": "deterministic",
"is_destructive": true,
"is_reversible": false,
"safety_tier": "confirm",
"exact_edit": {
"kind": "delete-range",
"anchor": {
"start_line": 5,
"end_line": 10
},
"expected_sha256": "5da05aa629ca39925a68d4b1f3f1a86686fdbbdab90b29d56294c5be3b371400",
"generated_at": "2026-06-24T17:45:00.690977+00:00"
},
"token_estimate": {
"raw_tokens": 79,
"injection_frequency": null,
"weighted_tokens": null
}
}
]
}

View File

@ -0,0 +1,10 @@
# Orphaned Migration Notes
These notes describe a migration step that pointed at a helper script.
The procedure referenced [the legacy migration helper](./gone.md) for the
manual cut-over. That helper file was removed long ago, so this section now
points at nothing and documents a step that can no longer be performed.
Nothing else in the repository links here, and the workflow it describes no
longer exists.

View File

@ -0,0 +1,11 @@
# 1-orphaned
Pins the **stale / orphaned** taxonomy edge → `delete-range` exact-edit kind →
derived `confirm` tier (delete-range is destructive + irreversible).
**Scanner signal exercised:** `broken_reference`. `docs/orphan.md` links to
`./gone.md`, which is absent from `input/`, so the scanner emits exactly one
`broken_reference` signal on `docs/orphan.md`. The doc is otherwise unreferenced
and describes a dead workflow — the coherent justification for *orphaned* rather
than a link-repair (`replace-text`): there is no live target to repair to, so the
block is deleted.

View File

@ -0,0 +1,16 @@
[
{
"path": "docs/orphan.md",
"category": { "class": "stale", "subtype": "orphaned" },
"signals": [
{ "name": "broken_reference", "detail": "links to './gone.md' which does not exist" }
],
"op": "Delete the orphaned reference block (lines 5-10) that points at a removed helper and describes a workflow that no longer exists.",
"op_type": "deterministic",
"exact_edit": {
"kind": "delete-range",
"anchor": { "start_line": 5, "end_line": 10 }
},
"gloss": "The only inbound/outbound link is dead and nothing references this doc; the block documents a step that can no longer run."
}
]

View File

@ -0,0 +1,59 @@
{
"schema_version": "1.0",
"tool_version": "0.0.1",
"generated_at": "2026-06-24T17:45:00.792786+00:00",
"scan": {
"project_root": "<input>",
"scope_globs": [
"**/*.md"
],
"excluded_dirs": [
"build",
"vendor",
"archive",
"graphify-out",
".dochygiene",
"fixtures",
"examples/golden"
],
"files_scanned": 1
},
"shortlist": [
"docs/old-plan.md"
],
"entries": [
{
"path": "docs/old-plan.md",
"category": {
"class": "stale",
"subtype": "superseded"
},
"signals": [
{
"name": "stale_name_location",
"detail": "filename 'old-plan.md' contains 'old' \u2014 may indicate superseded content"
}
],
"op": "Move the superseded plan to archive/docs/old-plan.md to preserve history without keeping it live.",
"op_type": "deterministic",
"is_destructive": false,
"is_reversible": true,
"safety_tier": "auto",
"exact_edit": {
"kind": "move-to-archive",
"anchor": {
"start_line": 1,
"end_line": 13
},
"dest_path": "archive/docs/old-plan.md",
"expected_sha256": "a1f247505584b851f2303ef6fe04d657180f9a3bb8301d588a02c649b5c68792",
"generated_at": "2026-06-24T17:45:00.792702+00:00"
},
"token_estimate": {
"raw_tokens": 103,
"injection_frequency": null,
"weighted_tokens": null
}
}
]
}

View File

@ -0,0 +1,13 @@
# Old Rollout Plan
This is the original rollout plan for the v1 launch. It has been fully
superseded by the current plan and is retained only for historical reference.
## Phases
1. Stand up the staging cluster.
2. Cut traffic over during the maintenance window.
3. Decommission the old fleet.
The content here is still accurate as a record but no longer drives any
work — the live plan replaced it entirely.

View File

@ -0,0 +1,10 @@
# 2-superseded
Pins the **stale / superseded** taxonomy edge → `move-to-archive` exact-edit kind
→ derived `auto` tier (move-to-archive is non-destructive + reversible).
**Scanner signal exercised:** `stale_name_location`. The filename
`docs/old-plan.md` contains `old`, which trips the location/name signal. The body
is still accurate as a historical record but fully replaced by the live plan — so
the op preserves history by relocating the whole file to `archive/docs/old-plan.md`
rather than deleting it (anchor spans the entire file, lines 1-13).

View File

@ -0,0 +1,17 @@
[
{
"path": "docs/old-plan.md",
"category": { "class": "stale", "subtype": "superseded" },
"signals": [
{ "name": "stale_name_location", "detail": "filename 'old-plan.md' contains 'old' — may indicate superseded content" }
],
"op": "Move the superseded plan to archive/docs/old-plan.md to preserve history without keeping it live.",
"op_type": "deterministic",
"exact_edit": {
"kind": "move-to-archive",
"anchor": { "start_line": 1, "end_line": 13 },
"dest_path": "archive/docs/old-plan.md"
},
"gloss": "Still accurate as a historical record but fully replaced by the live plan; relocation preserves it and de-clutters the live tree."
}
]

View File

@ -0,0 +1,54 @@
{
"schema_version": "1.0",
"tool_version": "0.0.1",
"generated_at": "2026-06-24T17:45:00.901611+00:00",
"scan": {
"project_root": "<input>",
"scope_globs": [
"**/*.md"
],
"excluded_dirs": [
"build",
"vendor",
"archive",
"graphify-out",
".dochygiene",
"fixtures",
"examples/golden"
],
"files_scanned": 1
},
"shortlist": [
"docs/migration-checklist.md"
],
"entries": [
{
"path": "docs/migration-checklist.md",
"category": {
"class": "stale",
"subtype": "completed-in-place"
},
"signals": [
{
"name": "archive_to_live_ratio",
"detail": "92% of content is under archive/resolved/completed headings"
}
],
"op": "Stamp the completed checklist with 'hygiene: frozen' frontmatter so the finished record is not re-flagged each scan.",
"op_type": "deterministic",
"is_destructive": false,
"is_reversible": true,
"safety_tier": "auto",
"exact_edit": {
"kind": "insert-frontmatter",
"key": "hygiene",
"value": "frozen"
},
"token_estimate": {
"raw_tokens": 128,
"injection_frequency": null,
"weighted_tokens": null
}
}
]
}

View File

@ -0,0 +1,19 @@
# Database Migration Checklist
## Completed Tasks
- Schema dump captured from production.
- Read replicas drained and promoted.
- Application config switched to the new DSN.
- Old connection pool retired.
## Resolved Issues
- Connection storm during cut-over fixed by raising the pool ceiling.
- Stale prepared statements cleared after the failover.
- Replication lag alarm tuned to the new baseline.
## Done
- Post-migration verification suite passed.
- Runbook archived and linked from the on-call wiki.

View File

@ -0,0 +1,12 @@
# 3-completed-in-place
Pins the **stale / completed-in-place** taxonomy edge → `insert-frontmatter`
exact-edit kind → derived `auto` tier (insert-frontmatter is non-destructive +
reversible). Note: `insert-frontmatter` has `has_anchor = False`, so this entry
carries NO `expected_sha256` — exercising the no-anchor finalize path (and the
documented Phase-4 content-guard hole).
**Scanner signal exercised:** `archive_to_live_ratio`. 92% of the doc's content
sits under `## Completed`, `## Resolved`, and `## Done` headings, which exceeds the
60% threshold. Every item is closed — a finished record completed in place — so the
remedy is to freeze it (`hygiene: frozen`) rather than delete history.

View File

@ -0,0 +1,17 @@
[
{
"path": "docs/migration-checklist.md",
"category": { "class": "stale", "subtype": "completed-in-place" },
"signals": [
{ "name": "archive_to_live_ratio", "detail": "92% of content is under archive/resolved/completed headings" }
],
"op": "Stamp the completed checklist with 'hygiene: frozen' frontmatter so the finished record is not re-flagged each scan.",
"op_type": "deterministic",
"exact_edit": {
"kind": "insert-frontmatter",
"key": "hygiene",
"value": "frozen"
},
"gloss": "Every item is closed; the doc is a finished record completed in place — freeze rather than delete history."
}
]

View File

@ -0,0 +1,60 @@
{
"schema_version": "1.0",
"tool_version": "0.0.1",
"generated_at": "2026-06-24T17:45:01.013176+00:00",
"scan": {
"project_root": "<input>",
"scope_globs": [
"**/*.md"
],
"excluded_dirs": [
"build",
"vendor",
"archive",
"graphify-out",
".dochygiene",
"fixtures",
"examples/golden"
],
"files_scanned": 2
},
"shortlist": [
"docs/setup.md",
"docs/setup-legacy.md"
],
"entries": [
{
"path": "docs/setup-legacy.md",
"category": {
"class": "stale",
"subtype": "duplicated"
},
"signals": [
{
"name": "stale_name_location",
"detail": "filename 'setup-legacy.md' contains 'legacy' \u2014 may indicate superseded content"
}
],
"op": "Replace the duplicated setup body (lines 1-10) with a pointer to the canonical docs/setup.md.",
"op_type": "deterministic",
"is_destructive": false,
"is_reversible": true,
"safety_tier": "auto",
"exact_edit": {
"kind": "dedupe",
"anchor": {
"start_line": 1,
"end_line": 10
},
"canonical_ref": "docs/setup.md",
"expected_sha256": "8bd40b72c008b22bb57446612a160d5b7aaa0f5c6cc3669c639ec38dce97d05d",
"generated_at": "2026-06-24T17:45:01.013077+00:00"
},
"token_estimate": {
"raw_tokens": 51,
"injection_frequency": null,
"weighted_tokens": null
}
}
]
}

View File

@ -0,0 +1,10 @@
# Setup
Install dependencies and run the bootstrap script.
1. `python -m venv .venv`
2. `source .venv/bin/activate`
3. `pip install -e .`
4. `./scripts/bootstrap.sh`
This is the canonical setup guide.

View File

@ -0,0 +1,10 @@
# Setup
Install dependencies and run the bootstrap script.
1. `python -m venv .venv`
2. `source .venv/bin/activate`
3. `pip install -e .`
4. `./scripts/bootstrap.sh`
This is the canonical setup guide.

View File

@ -0,0 +1,17 @@
# 4-duplicated
Pins the **stale / duplicated** taxonomy edge → `dedupe` exact-edit kind →
derived `auto` tier (dedupe is non-destructive + reversible). The `canonical_ref`
points at the surviving copy, `docs/setup.md`.
**Scanner signal exercised:** `stale_name_location` (on `docs/setup-legacy.md`,
"legacy" in the name).
**KNOWN GAP — surface for human review:** the scanner has *no* dedicated
duplication signal; it cannot detect that `setup-legacy.md` is byte-for-byte
identical to `setup.md`. This case therefore rides a *co-occurring*
`stale_name_location` signal as the deterministic trigger, while the *duplicated*
classification itself is the model's judgment (the two files being identical). The
canonical `docs/setup.md` is present in `input/` and correctly stays a cleared,
zero-signal shortlist entry. This is the one case where the intended subtype is not
backed by a matching scanner signal.

View File

@ -0,0 +1,17 @@
[
{
"path": "docs/setup-legacy.md",
"category": { "class": "stale", "subtype": "duplicated" },
"signals": [
{ "name": "stale_name_location", "detail": "filename 'setup-legacy.md' contains 'legacy' — may indicate superseded content" }
],
"op": "Replace the duplicated setup body (lines 1-10) with a pointer to the canonical docs/setup.md.",
"op_type": "deterministic",
"exact_edit": {
"kind": "dedupe",
"anchor": { "start_line": 1, "end_line": 10 },
"canonical_ref": "docs/setup.md"
},
"gloss": "Byte-for-byte duplicate of docs/setup.md preserved under a 'legacy' name; dedupe to the canonical copy."
}
]

View File

@ -0,0 +1,49 @@
{
"schema_version": "1.0",
"tool_version": "0.0.1",
"generated_at": "2026-06-24T17:45:01.121819+00:00",
"scan": {
"project_root": "<input>",
"scope_globs": [
"**/*.md"
],
"excluded_dirs": [
"build",
"vendor",
"archive",
"graphify-out",
".dochygiene",
"fixtures",
"examples/golden"
],
"files_scanned": 1
},
"shortlist": [
"docs/incident-log.md"
],
"entries": [
{
"path": "docs/incident-log.md",
"category": {
"class": "bloat",
"subtype": "distill"
},
"signals": [
{
"name": "archive_to_live_ratio",
"detail": "95% of content is under archive/resolved/completed headings"
}
],
"op": "Generative distillation: condense the resolved-incident narrative (lines 5-27) to a few summary lines, preserving the facts at lower altitude.",
"op_type": "generative",
"is_destructive": false,
"is_reversible": true,
"safety_tier": "confirm",
"token_estimate": {
"raw_tokens": 343,
"injection_frequency": null,
"weighted_tokens": null
}
}
]
}

View File

@ -0,0 +1,27 @@
# Cache Layer Incident Narrative
## Resolved Incidents
During the Q1 rollout the cache layer suffered a series of cascading
evictions. The first incident began when a deploy shipped a key-prefix change
that silently invalidated the entire working set. Traffic spiked against the
origin, latency climbed, and three downstream services began timing out. The
on-call engineer rolled the deploy back, warmed the cache from a snapshot, and
service recovered within forty minutes. A follow-up incident two weeks later
traced to an unbounded TTL on session objects, which slowly filled the heap
until the eviction loop thrashed. We capped the TTL, added a heap alarm, and
the problem has not recurred.
A third, smaller incident involved a misconfigured replica that served stale
reads for several hours before anyone noticed; the fix was a health check that
compares replica generation numbers against the primary.
## Completed Remediations
Every action item from the three incidents above has been closed. The
key-prefix migration is now gated behind a staged rollout, the TTL ceiling is
enforced in code review, and the replica generation check ships in the
standard health probe. All of this is true and accurate history, but it is
narrative detail that no longer informs any current decision — it is the kind
of long resolved-problem prose that should be distilled to a few summary
lines rather than carried verbatim forever.

View File

@ -0,0 +1,14 @@
# 5-distill
Pins the **bloat / distill** taxonomy edge → `generative` op (NO `exact_edit`) →
derived `confirm` tier (any generative op is forced to confirm by `op_type`,
invariant #10). This is the case that exercises the generative branch:
`reducible_range` (non-persisted) supplies the span the assembler counts
`raw_tokens` over, and the entry carries no `exact_edit`.
**Scanner signal exercised:** `archive_to_live_ratio` (95% of the doc is under
`## Resolved Incidents` / `## Completed Remediations` headings). The content is
*true but mostly irrelevant* long resolved-problem narrative — the bloat distinction,
not staleness — so the remedy changes its altitude (distill to a summary) rather than
removing it. Shares the `archive_to_live_ratio` signal with case 3 deliberately: the
goldens differentiate on class (`bloat` vs `stale`) and op-kind, not on signal.

View File

@ -0,0 +1,13 @@
[
{
"path": "docs/incident-log.md",
"category": { "class": "bloat", "subtype": "distill" },
"signals": [
{ "name": "archive_to_live_ratio", "detail": "95% of content is under archive/resolved/completed headings" }
],
"op": "Generative distillation: condense the resolved-incident narrative (lines 5-27) to a few summary lines, preserving the facts at lower altitude.",
"op_type": "generative",
"reducible_range": { "start_line": 5, "end_line": 27 },
"gloss": "True but verbose resolved-problem prose that no longer informs any decision; distill the altitude rather than delete the history."
}
]

View File

@ -0,0 +1,107 @@
# Classifier golden examples
Layer-2 reversion-protection fixtures for the **classifier** (design D7,
`openspec/changes/add-check/design.md`). Each case is a small **static** fixture
doc-tree (`input/`, stable bytes → stable sha256) paired with `expected.json`, a
full schema-valid machine report representing the expected classification of that
tree. These are **distinct** from the schema-shape fixtures one level up
(`../valid_report.json` / `../invalid_report.json`), which only exercise
`validate_report.py`.
Adding or changing a classifier golden is **human-gated** per the META-RULE in
`invariants.md`.
## Cases
| Dir | class / subtype | op_type | exact_edit.kind | tier | scanner signal |
|-----|-----------------|---------|-----------------|------|----------------|
| `1-orphaned` | stale / orphaned | deterministic | `delete-range` | confirm | `broken_reference` |
| `2-superseded` | stale / superseded | deterministic | `move-to-archive` | auto | `stale_name_location` |
| `3-completed-in-place` | stale / completed-in-place | deterministic | `insert-frontmatter` | auto | `archive_to_live_ratio` |
| `4-duplicated` | stale / duplicated | deterministic | `dedupe` | auto | `stale_name_location` (see note) |
| `5-distill` | bloat / distill | generative | (none) | confirm | `archive_to_live_ratio` |
Each case directory holds:
- `input/` — the fixture doc-tree (the bytes the scanner and the model see).
- `proposals.json` — the hand-authored model-judgment proposal(s) used to
*generate* `expected.json` (kept so the computed fields are reproducible).
- `expected.json` — the full schema-valid machine report (the frozen expectation).
Computed fields (`expected_sha256`, `raw_tokens`, derived `safety_tier`) are
**real**: generated by piping a real `scanner.py` artifact + `proposals.json`
through `report_builder.py`. `scan.project_root` is normalized to `<input>` for
portability; `generated_at` timestamps are wall-clock and intentionally not
asserted on.
- `notes.md` — the one taxonomy edge + scanner signal the case pins.
> **Note (case 4):** the scanner has no dedicated duplication signal. Case 4 rides
> a co-occurring `stale_name_location` ("legacy") as its deterministic trigger; the
> *duplicated* judgment itself is the model's. See `4-duplicated/notes.md`.
## Two harnesses
### Hermetic unit harness (in pytest — `tests/test_classifier_golden.py`)
Runs with **NO network and NO model**. For each case it asserts only the
deterministic / stable parts: scanner signals on the expected paths,
`validate_report.py` accepts each `expected.json`, and internal consistency of the
stable classification fields (closed enums, op_type, `exact_edit.kind`, the derived
`safety_tier` recomputed, and `expected_sha256` re-verified against the fixture
bytes). This is the regression gate that runs in CI.
### Live model-classification regression (OUTSIDE pytest — manual / agent-invoked)
This is the **model-dependent** check; it is intentionally NOT part of the unit
suite because it calls a live model. Run it manually (or have an agent run it) when
you want to confirm the classifier still reaches the expected judgment.
For each case directory:
1. Run an actual classification over `input/` — either `/hygiene check
--scope <case>/input` or by invoking the `hygiene-check` skill against that
tree. This produces a fresh machine report.
> **Confirm the invocation once the check command lands.** `--scope` is a
> filter on a scan rooted at the resolved project root, not a re-root — to
> classify a case in isolation you want the check *rooted at*
> `<case>/input` (as the hermetic suite roots the scanner). Verify the exact
> flag/rooting against the shipped `commands/hygiene.md` before relying on it.
2. Diff the produced classification's **stable** fields against `expected.json`,
per entry:
- `category.class`
- `category.subtype`
- `op_type`
- the derived `safety_tier`
- `exact_edit.kind`
A mismatch on any of these is a **regression** — investigate before accepting.
3. **Advisory only** (flag for human review, do **not** auto-fail):
- `op` prose wording.
- exact anchor line numbers (`exact_edit.anchor.start_line` / `end_line`) and
`reducible_range`.
These are model-authored and may drift slightly without indicating a real
classification regression; surface them for a human to eyeball rather than
gating on them.
Because `expected.json` encodes a captured model judgment, any *intended* change to
the expected classification is a golden change → **human-gated** per the META-RULE.
## Regenerating an `expected.json` (after an approved fixture change)
```
python3 scripts/scanner.py --root examples/golden/classifier/<case>/input > /tmp/scan.json
python3 scripts/report_builder.py \
--scan /tmp/scan.json \
--proposals examples/golden/classifier/<case>/proposals.json \
--root examples/golden/classifier/<case>/input \
--out-json examples/golden/classifier/<case>/expected.json
# then normalize scan.project_root to "<input>" and re-run the hermetic suite.
```
`raw_tokens` reflect whichever token-estimator backend was active at generation
time (the zero-dependency heuristic here, since no tiktoken vocab is vendored).
The hermetic suite does **not** assert on `raw_tokens`, so regenerating on a
tiktoken-equipped machine may shift those counts harmlessly — don't mistake that
diff for a regression.

View File

@ -0,0 +1,38 @@
{
"schema_version": "1.0",
"tool_version": "0.1.0",
"generated_at": "2026-06-18T09:52:00Z",
"scan": {
"project_root": "/home/user/myproject",
"scope_globs": ["**/*.md", "CLAUDE.md"],
"excluded_dirs": ["build", "vendor", "archive", "graphify-out", ".dochygiene", "fixtures", "examples/golden"],
"files_scanned": 18
},
"shortlist": [
"CLAUDE.md"
],
"entries": [
{
"path": "CLAUDE.md",
"category": { "class": "stale", "subtype": "contradicted" },
"signals": [
{ "name": "broken_reference", "detail": "links scripts/old.py which no longer exists" }
],
"op": "Delete the contradicted reference block (lines 40-52).",
"op_type": "deterministic",
"is_destructive": true,
"is_reversible": false,
"safety_tier": "auto",
"exact_edit": {
"kind": "delete-range",
"anchor": { "start_line": 40, "end_line": 52 },
"expected_sha256": "abc123def456abc123def456abc123def456abc123def456abc123def456abcd"
},
"token_estimate": {
"raw_tokens": 180,
"injection_frequency": null,
"weighted_tokens": null
}
}
]
}

View File

@ -0,0 +1,103 @@
{
"schema_version": "1.0",
"tool_version": "0.1.0",
"generated_at": "2026-06-18T09:52:00Z",
"scan": {
"project_root": "/home/user/myproject",
"scope_globs": ["**/*.md", "CLAUDE.md"],
"excluded_dirs": ["build", "vendor", "archive", "graphify-out", ".dochygiene", "fixtures", "examples/golden"],
"files_scanned": 18
},
"shortlist": [
"CLAUDE.md",
"docs/old-plan.md",
"docs/setup-notes.md",
"docs/api-reference.md"
],
"entries": [
{
"path": "CLAUDE.md",
"category": { "class": "stale", "subtype": "contradicted" },
"signals": [
{ "name": "broken_reference", "detail": "links scripts/old.py which no longer exists" }
],
"op": "Delete the contradicted reference block (lines 40-52).",
"op_type": "deterministic",
"is_destructive": true,
"is_reversible": false,
"safety_tier": "confirm",
"exact_edit": {
"kind": "delete-range",
"anchor": { "start_line": 40, "end_line": 52 },
"expected_sha256": "abc123def456abc123def456abc123def456abc123def456abc123def456abcd"
},
"token_estimate": {
"raw_tokens": 180,
"injection_frequency": "per-session",
"weighted_tokens": 180
}
},
{
"path": "docs/old-plan.md",
"category": { "class": "stale", "subtype": "superseded" },
"signals": [
{ "name": "superseded_by", "detail": "Replaced by docs/new-plan.md per commit a1b2c3d" }
],
"op": "Move to archive/docs/old-plan.md.",
"op_type": "deterministic",
"is_destructive": false,
"is_reversible": true,
"safety_tier": "auto",
"exact_edit": {
"kind": "move-to-archive",
"anchor": { "start_line": 1, "end_line": 200 },
"dest_path": "archive/docs/old-plan.md",
"expected_sha256": "deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef"
},
"token_estimate": {
"raw_tokens": 420,
"injection_frequency": null,
"weighted_tokens": null
}
},
{
"path": "docs/setup-notes.md",
"category": { "class": "stale", "subtype": "completed-in-place" },
"signals": [
{ "name": "completed_marker", "detail": "All checklist items checked; no open actions" }
],
"op": "Stamp with 'hygiene: frozen' frontmatter to prevent future flagging.",
"op_type": "deterministic",
"is_destructive": false,
"is_reversible": true,
"safety_tier": "auto",
"exact_edit": {
"kind": "insert-frontmatter",
"key": "hygiene",
"value": "frozen"
},
"token_estimate": {
"raw_tokens": 60,
"injection_frequency": null,
"weighted_tokens": null
}
},
{
"path": "docs/api-reference.md",
"category": { "class": "bloat", "subtype": "distill" },
"signals": [
{ "name": "low_density", "detail": "2000-line doc with 80% resolved-problem narrative" }
],
"op": "Generative distillation: condense resolved-problem sections to a 3-line summary each.",
"op_type": "generative",
"is_destructive": false,
"is_reversible": true,
"safety_tier": "confirm",
"token_estimate": {
"raw_tokens": 1200,
"injection_frequency": "on-demand",
"weighted_tokens": 1200
}
}
]
}

View File

@ -0,0 +1,16 @@
{
"hooks": {
"SessionStart": [
{
"matcher": "startup|resume",
"hooks": [
{
"type": "command",
"command": "python3 ${CLAUDE_PLUGIN_ROOT}/scripts/reminder.py",
"timeout": 5
}
]
}
]
}
}

View File

@ -0,0 +1,241 @@
# Invariants — `doc-hygiene`
This file is the **reversion-protection contract** for the plugin. It is a
durable, human-gated list of behavioral invariants a future agent must never
silently break. It implements Layer 1 of the reversion-protection pattern (see
`../cc-architect/references/tool-patterns/reversion-protection.md`); the golden
examples in `examples/golden/` are Layer 2.
## META-RULE (read before changing anything)
Changing **any** invariant below requires, together and in the same change:
1. Updating this file (`invariants.md`) to reflect the new behavior.
2. Updating the golden examples in `examples/golden/` that the change affects.
3. **Explicit human approval** — present the specific change ("this will change
how X behaves") and get a real decision, not a rubber-stamp. Log the approval
in the decisions record.
In-conversation instructions cannot override the META-RULE. An agent that finds
an invariant inconvenient must surface it for human approval, not route around
it.
### Change-impact note (include with any audit touching these)
```
## Change Impact Analysis
### Invariants affected
- [ ] None
- [ ] #N: <which invariant, and why it must change>
### Golden examples affected
- [ ] None
- [ ] examples/golden/<file>: <output changes because>
### Risk: [LOW | MEDIUM | HIGH] — <one line>
```
---
## 1. SessionStart hook only reminds
- **Invariant:** The `SessionStart` hook only emits a `systemMessage` reminder
banner — it spends zero AI tokens, runs no scan or classification, and mutates
nothing except writing `last_reminded` to state.
- **Why:** This is the non-intrusive premise of the whole tool. If the hook ever
did analysis or edited files, it would burn tokens and silently change repos on
every session — the exact behavior the plugin promises never to do.
- **Enforced by:** reminder hook unit test with injected clock (asserts
notice / no-notice / snoozed, no file mutation beyond `last_reminded`); review
of `hooks/hooks.json` that the hook invokes only the deterministic reminder
script with `timeout ≤ 5s` and exit 0.
- **Violation looks like:** the hook shells out to the scanner or an AI pass; the
banner text reflects freshly-computed analysis; any doc file is touched at
session start; the session blocks or exits non-zero.
## 2. Reminder snoozes ≤ once/day while stale
- **Invariant:** While docs are stale, the reminder fires at most once per
calendar day, gated by the `last_reminded` timestamp.
- **Why:** This hook matches `startup|resume`, so it fires every time the user
reopens or resumes a project. Without the snooze the banner re-fires on each
such event within one working day, becoming nag-ware and training the user to
ignore it. (`clear`/`compact` are not matched by this hook — the matcher, not
the snooze, excludes them.)
- **Enforced by:** reminder hook unit test with injected clock (second
invocation same day → no banner; next day while still stale → banner).
- **Violation looks like:** the banner appears on every resume/compact; the
snooze keys off something other than `last_reminded`; a same-day re-fire.
## 3. State lives in-project under gitignored `.dochygiene/`
- **Invariant:** All state and reports live under a gitignored `.dochygiene/`
directory at the resolved project root (git root, fallback cwd). There is no
global, cross-project index, and the tool never silently edits the user's
`.gitignore`.
- **Why:** A global index would race, corrupt, and itself go stale across
projects. Silently editing `.gitignore` is an outward mutation that violates
the non-intrusive premise; the dir being tracked would pollute the repo.
- **Enforced by:** state store unit test (root resolution + writes confined to
`.dochygiene/`); scanner self-exclusion test (`.dochygiene/` never scanned);
GAP: needs test that no global path outside the project root is written and
that `.gitignore` is only modified on explicit confirmation.
- **Violation looks like:** a `~/.dochygiene` or other global index appears;
state written outside the project root; `.gitignore` edited without the user
confirming the one-line offer.
## 4. Report rollover — keep only the latest report
- **Invariant:** Each check deletes the prior report before writing the new one;
exactly one report (human `.md` + machine `.json`) is retained.
- **Why:** The tool must not become the bloat it polices. Accumulating reports
would pile up artifacts in every repo it cleans.
- **Enforced by:** state store unit test (write two reports in sequence → only
the latest pair remains on disk).
- **Violation looks like:** timestamped report history accumulates; old
`.json`/`.md` reports survive a second check.
## 5. Cleanup is git-safe — clean tree, one commit
- **Invariant:** Cleanup runs only on a clean/committed working tree (or after an
auto-committed WIP checkpoint), and each cleanup run lands as exactly one
reviewable commit.
- **Why:** Uncommitted user work must never be lost or entangled with the tool's
edits, and the whole sweep must be trivially inspectable and revertable as a
single unit.
- **Enforced by:** GAP: needs test — cleanup executor integration test on a
fixture repo asserting (a) refusal / auto-checkpoint on a dirty tree, (b)
exactly one new commit after a run.
- **Violation looks like:** cleanup proceeds on a dirty tree without
checkpointing; a run produces zero, two, or many commits; edits left uncommitted
in the working tree.
## 6. Deterministic-first — scripts do the mechanical work, AI does only judgment
- **Invariant:** Scan, state, patch-apply, and token-estimate are deterministic
scripts with no model. AI is used only for classification and prose
distillation. The token estimator uses a local tokenizer — never an API call.
- **Why:** This keeps checks fast, cheap, and trustworthy, and keeps mechanical
operations reproducible and unit-testable. Pulling a model into the
deterministic seams makes them nondeterministic and expensive.
- **Enforced by:** scanner, state store, patch-applier, and token-estimator unit
tests run with no model in the loop; review that estimator code path makes no
network/API call.
- **Violation looks like:** the scanner or patch-applier calls a model; the token
estimator hits the Claude API; classification logic is hard-coded into a script
instead of delegated to the AI pass.
## 7. Safety tiers — `auto` runs unattended, `confirm` escalates
- **Invariant:** Only `auto`-tier ops (deterministic + reversible + objective)
run without a prompt; every `confirm`-tier op (destructive, subjective, or
generative) is escalated for human approval before it is applied.
- **Why:** `auto` ops run unattended. The tier boundary is the safety wall that
keeps anything destructive, subjective, or model-generated from changing the
user's repo without their say-so. (Enforced structurally by #10.)
- **Enforced by:** cleanup executor unit test (an `auto` entry applies silently;
a `confirm` entry routes to the approval list and is not applied without
approval); the derivation guarantee in #10. GAP: needs test — `sweep` gating
test (a `confirm`-tier op still escalates under the check-then-clean path, not
just standalone `clean`).
- **Violation looks like:** a destructive or generative op runs without a prompt;
the approval gate is skipped under `sweep`'s convenience path; an `auto` op that
is not deterministic+reversible.
## 8. mtime / content guard — never apply a cached edit to a changed file
- **Invariant:** Before applying any pre-computed `exact_edit`, the cleaner
verifies the target file's current content hash matches the entry's
`expected_sha256` (captured at `generated_at`); on mismatch it skips the edit
and recommends re-analysis rather than applying blindly.
- **Why:** A file edited between check and clean may no longer have the structure
the cached edit assumes; applying it blindly corrupts the file.
- **Enforced by:** patch-applier mtime/content-guard unit test (fixture whose
hash differs from `expected_sha256` → edit skipped, re-analysis recommended).
- **Violation looks like:** a cached edit applies to a file whose hash no longer
matches; the guard compares only mtime and not content, or is bypassed.
## 9. Frozen / ignored files are never flagged
- **Invariant:** Files marked `hygiene: frozen` in frontmatter, files matched by
`.dochygiene-ignore`, and detected append-only logs are never surfaced as
candidates by the scanner.
- **Why:** Re-flagging deliberately-frozen records and append-only logs every
week destroys the user's trust in the tool. This is a correctness requirement,
not a nicety.
- **Enforced by:** scanner unit tests per exclusion (frozen frontmatter, ignore
file, append-only detection) — each fixture present in the tree, absent from
the shortlist.
- **Violation looks like:** a `hygiene: frozen` doc, an ignored path, or an
append-only log appears in the shortlist or report `entries`.
## 10. `safety_tier` is DERIVED, never model-assigned
- **Invariant:** `safety_tier` is computed solely by the deterministic function
`safety_tier(op_type, is_destructive, is_reversible)` and recorded in the
report; the model never assigns it. The function returns `confirm` whenever
`op_type == generative`, `is_destructive`, or `not is_reversible`, and returns
`auto` only for a deterministic + non-destructive + reversible op — so it can
**never** emit `auto` for a generative, destructive, or irreversible op.
- **Why:** This is the structural enforcement of #7. If the model could write
`safety_tier` directly, one misclassification would let a destructive or
generative op run unattended. Deriving it removes the model from the safety
decision entirely.
- **Enforced by:** schema validation (the recorded `safety_tier` must equal the
function output for the entry's inputs — reject otherwise); a truth-table unit
test of the function covering all `(op_type, is_destructive, is_reversible)`
combinations.
- **Violation looks like:** a report entry whose `safety_tier` disagrees with the
derivation; an `auto` tier on a generative/destructive/irreversible op; the
model emitting the tier as a free field.
## 11. `op_type` is a property of the chosen op; `exact_edit` present iff deterministic
- **Invariant:** `op_type` describes the operation the classifier selected (not a
free field, not looked up from `category.subtype`), and an entry carries
`exact_edit` **if and only if** `op_type == deterministic`; generative ops carry
no `exact_edit`. The biconditional is validated deterministically.
- **Why:** The same subtype (e.g. `contradicted`) may map to either a
deterministic delete or a generative rewrite depending on the chosen op, so
`op_type` must track the op. Pre-writing prose edits at check time would spend
Sonnet tokens for work that may never be applied; tying `exact_edit` to
`deterministic` keeps a check cheap and the schema unambiguous.
- **Enforced by:** schema validation (reject `generative` with an `exact_edit`,
reject `deterministic` without one); golden example with the same subtype
appearing under both op-types.
- **Violation looks like:** a generative entry carrying an `exact_edit`; a
deterministic entry missing one; `op_type` derived from `category.subtype`
instead of from the chosen op.
## 12. Scanner never flags own test fixtures or golden classifier examples
- **Invariant:** The scanner must never surface files under doc-hygiene's own
`fixtures/` directories (bare name match, pruned at any depth) or under
`examples/golden/` (path-aware parent/child match: a dir named `golden` whose
immediate parent is named `examples` is pruned; a `golden/` dir with any other
parent is still scanned). Both entries appear in `DEFAULT_EXCLUDED_DIRS` in
`scripts/scanner.py`.
- **Why:** These directories are deliberately populated with stale and bloated
documents — they are the scanner's own test inputs. Flagging them is a false
positive; applying a cleanup op to them would corrupt the test suite. The
path-aware narrowing for `examples/golden` is required because doc-hygiene
installs globally: a blanket bare-name `golden` exclusion would silently skip
legitimate `golden/` directories in unrelated host projects.
- **Enforced by:** `tests/test_scanner_exclusions.py`
`TestFixtureAndGoldenExclusion` class, which covers: `test_fixtures_subtree_not_scanned_by_default`
(bare-name `fixtures` pruned at any depth);
`test_examples_golden_subtree_not_scanned_by_default` (path-aware prune of
`examples/golden`); `test_bare_golden_dir_without_examples_parent_is_scanned`
(false-negative guard — a `golden/` dir under a non-`examples` parent is NOT
pruned); and count-accuracy companions
`test_fixtures_files_cost_no_files_scanned_count` /
`test_examples_golden_files_cost_no_files_scanned_count`.
- **Violation looks like:** a file under `tests/fixtures/` or
`examples/golden/classifier/` appears in the scanner shortlist or report
`entries`; the exclusion is removed or narrowed; a bare `golden` name-match
replaces the path-aware `examples/golden` pair (breaks globally-installed
correctness).
---
> **Schema note:** The machine report schema is itself a frozen contract (see
> `openspec/changes/add-report-schema/`). Any change to a report field, enum
> value, or documented semantic falls under the META-RULE above.

View File

@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-06-18

View File

@ -0,0 +1,3 @@
# add-deterministic-core
Build step #2: deterministic core — SessionStart reminder hook (build-spike banner), scanner (signals + shortlist with exclusions), and in-project state store (timestamps, report rollover, snooze, atomic writes, project-root resolution). No model in any seam.

View File

@ -0,0 +1,246 @@
# Design: Deterministic Core
## Context
The report schema (build step #1) is frozen in `openspec/specs/report-schema/`.
This change builds the deterministic substrate beneath it: the three zero-model
runtime components that must exist before the AI `check` pass can run. Per CLAUDE.md
build order item 2, those are the reminder hook, the scanner, and the state store.
Constraints that shape this design:
- **Invariant #6 (deterministic-first):** none of these seams may invoke a model.
Everything here is scripts with structured JSON output, correct exit codes, and
injected clock/filesystem for testability.
- **Invariant #1:** the `SessionStart` hook only reminds — no scan, no
classification, no mutation except writing `last_reminded`.
- **Invariant #2:** the reminder snoozes to at most once per calendar day while
stale.
- **Invariant #3:** state lives in-project under gitignored `.dochygiene/`; no
global index; never silently edit `.gitignore`.
- **Invariant #4:** report rollover keeps only the latest `.json` + `.md` pair.
- **Invariant #9:** frozen/ignored/append-only files are never shortlisted.
- **No CC-hook precedent:** no plugin in this collection has wired a Claude Code
hook. The `systemMessage`-banner mechanism was confirmed against the hook docs
when the PRD was finalized, but it has never been exercised here — hence the
build-spike gate.
The scanner's output is **not** the machine report. The frozen schema places
`signals` on `entries`, and `entries` are AI-produced. The scanner emits an
**intermediate** artifact (shortlist + per-path signals); the `check` change folds
that into `report.shortlist` and `entries[].signals`.
## Goals / Non-Goals
**Goals:**
- Wire and prove the `SessionStart``systemMessage` banner path (build-spike).
- A deterministic reminder script: staleness comparison, once/day snooze, zero
tokens, mutate only `last_reminded`, injected-clock testable.
- A deterministic scanner: shortlist + per-path signals, full exclusion pipeline,
no model, intermediate artifact (not a report).
- A deterministic state store: project-root resolution, lifecycle timestamps,
atomic writes, report rollover, write confinement, no global index, no silent
`.gitignore` edits.
- Each seam unit-testable in isolation with injected clock and filesystem.
**Non-Goals:**
- The AI classify pass / `check` skill, `entries`, `op`/`op_type`/`safety_tier`.
- `token_estimate` / `raw_tokens` population (owned by `check`; see proposal).
- The `.gitignore` ignore-entry *offer* UX (owned by `check`). This change only
guarantees the store never silently edits `.gitignore`.
- The `clean` skill, patch-applier, mtime-guard application, and `sweep`.
- Per-project scope-config overrides beyond the documented defaults (the schema
records `scope_globs`/`excluded_dirs`; richer override UX is later).
## Decisions
### D1. Three capabilities, one per deterministic seam
`session-reminder`, `doc-scanner`, `state-store` map one-to-one onto the three
runtime components, each with its own independently-testable seam (injected-clock
snooze; exclusion pipeline; root-resolution + rollover + atomic-write). A single
monolithic capability would couple three unrelated concerns and blur which
requirement each test pins. **Alternative considered:** one `deterministic-core`
capability — rejected because the test seams and invariants are disjoint and would
read as an unfocused requirement pile.
### D2. Build-spike is a sequential gate, not a capability
The spike proves the `SessionStart``systemMessage` mechanism with a hardcoded
banner before any logic depends on it. The hook-wiring it proves belongs under
`session-reminder` (the capability that owns the hook). It is a **task-level
gate**: nothing else in this change starts until the spike renders visibly in a
real session. The "confirmed to render visibly" criterion is an
**implementation-time acceptance criterion** discharged when the executor runs it —
not at artifact-authoring time. **Alternative considered:** fold the spike into the
reminder hook task — rejected because building snooze/staleness logic atop an
unproven mechanism would conflate a mechanism failure with a logic bug.
### D3. Injected clock and filesystem on every seam
Each component takes a `Clock` (returns "now") and a filesystem/root abstraction
by constructor injection. Tests pass a frozen clock and a temp dir; production
passes the real clock and resolved root. This makes the snooze, staleness, and
rollover behaviors deterministic under test with no real time and no real session.
**Alternative considered:** read `datetime.now()` and `os.getcwd()` inline —
rejected: untestable snooze/staleness, the exact behaviors invariants #1/#2 demand
proof of.
### D4. Project-root resolution: git root, fallback cwd
```
resolve_project_root(start_dir):
walk upward from start_dir looking for a `.git` directory
if found: return that directory # git root
else: return start_dir # fallback: cwd
```
Resolution is deterministic and pure given `start_dir` and the filesystem. All
state and reports live under `<project_root>/.dochygiene/`. Writes are confined to
that directory (invariant #3); no path outside the resolved root is ever written.
**Alternative considered:** an env var or marker file for the root — rejected as
per-project setup, against US-27 (works without setup beyond enablement).
### D5. Snooze and staleness logic (reminder)
The reminder is a pure decision over `(last_check, last_reminded, now, threshold)`:
```
remind?(state, now, threshold_days):
stale = last_check is None OR (now - last_check) > threshold_days
if not stale: return SILENT # fresh: no banner
if last_reminded is None: return BANNER # never reminded
if same_calendar_day(last_reminded, now): return SILENT # snoozed today
return BANNER # stale, new day
```
On `BANNER` the script writes `last_reminded = now` (the only mutation) and emits
the `systemMessage`. On `SILENT` it writes nothing. **Snooze keys on calendar day,
not a 24h sliding window** — invariant #2 says "once per calendar day," and a
sliding window would let two banners straddle midnight-adjacent sessions. The
snooze is load-bearing because the hook fires on every matched `startup`/`resume`
event; without it, reopening or resuming a project several times in one working day
re-fires the banner each time. (The matcher is `startup|resume`, so `clear`/
`compact` do not trigger the hook at all — the matcher, not the snooze, handles
those; the snooze handles repeated startup/resume within a calendar day.) The
script exits 0 always (never blocks the session) with a low `timeout`
(≤5s) in `hooks.json`. **Alternative considered:** snooze on a rolling 24h delta —
rejected per the calendar-day wording and the midnight edge case.
### D6. Reminder never scans (invariant #1)
The reminder script reads only `state.json`. It does not import or invoke the
scanner, reads no doc content, and computes the banner purely from timestamps. The
banner text is fixed prose plus the day-count and the slash command — it reflects
*no* freshly-computed analysis. This is what keeps the hook zero-token and
side-effect-free beyond `last_reminded`.
### D7. Scanner exclusion pipeline (ordered, short-circuiting)
The scanner walks files matching the scope globs, then applies exclusions so a
file that matches *any* exclusion never reaches the shortlist (invariant #9):
```
for each candidate path under project_root:
1. dir-prune: skip if any path component is in excluded_dirs
(build, vendor, archive, graphify-out, .dochygiene) # self-exclusion here
2. ignore-match: skip if matched by a .dochygiene-ignore pattern
3. frozen-frontmatter: skip if YAML frontmatter has `hygiene: frozen`
4. append-only: skip if detected as an append-only log
→ otherwise: compute signals, add (path, signals) to the intermediate artifact
```
Dir-pruning happens first and at the directory level so excluded trees are never
descended (cheap, and self-excludes `.dochygiene/` before any state file is read
as a doc). Exclusion precedes signal computation so excluded files cost nothing.
**Append-only detection** is heuristic and deterministic (e.g. monotonic dated/
sectioned growth with no in-place edits across git history, or a documented
append-only marker) — the exact heuristic is an implementation detail, but it must
be deterministic and unit-tested with a positive and a negative fixture.
**Alternative considered:** compute signals first then filter — rejected: wastes
work and risks an excluded file leaking into output if a later filter is missed.
### D8. Scanner emits an intermediate artifact, not a report
The scanner's output is `{ shortlist: [path...], signals: { path: [signal...] } }`
(or an equivalent intermediate shape) — paths plus their objective signals. It does
**not** write a machine report, `entries`, `category`, `op`, or `token_estimate`.
The `check` change reads this artifact and produces the report. Keeping the scanner
output intermediate honors the frozen schema's split (scanner → shortlist; AI →
entries with signals) and keeps this change from re-deriving the report shape.
**Signals are objective facts only** (the schema's "objective scanner facts that
support the classification"); the scanner attaches no judgment.
### D9. Atomic writes (state store)
Every write to `state.json` and to report files is **write-temp-then-rename**: write
to a temp file in the same directory, `fsync`, then `os.replace` onto the target.
`os.replace` is atomic on POSIX, so a concurrent reader (e.g. a second session's
reminder) sees either the old or the new file, never a torn write. **Alternative
considered:** in-place truncate-and-write — rejected: a crash mid-write corrupts
state, and the reminder reads state on every session.
### D10. Report rollover (state store)
```
write_report(json_blob, md_blob):
delete any existing *.json and *.md report in .dochygiene/ # keep latest only
atomically write the new .json and .md pair
```
Exactly one report pair survives each write (invariant #4) — the tool must not
become the bloat it polices. Rollover deletes the prior pair *before* writing the
new one. (Report *content* generation is owned by `check`; the store only owns the
rollover-and-atomic-write mechanics, exposed for `check` to call.) **Alternative
considered:** timestamped report history — rejected by invariant #4.
### D11. State store never silently edits `.gitignore`
The store creates `.dochygiene/` (allowed — an untracked, not-yet-ignored dir is
fine; invariant #3 forbids *editing* `.gitignore`, not creating the dir) and
confines all writes to it. It never opens or appends to `.gitignore`. Detecting
whether `.dochygiene/` is ignored and *offering* to add the entry is a `check`-change
concern (US-4 / PRD "State & artifacts"), explicitly out of scope here.
## Risks / Trade-offs
- **[The `systemMessage` banner doesn't render or the hook entry format is wrong
for this collection]** → The build-spike gate exists precisely for this: a
hardcoded-banner spike is run and visually confirmed before any logic is built on
it. If the spike fails, only the spike is reworked, not the reminder logic.
- **[Append-only detection is heuristic — false negatives re-flag a log, false
positives hide a real doc]** → Deterministic heuristic pinned by positive and
negative unit fixtures; `.dochygiene-ignore` and `hygiene: frozen` give the user
explicit overrides for any heuristic miss (invariant #9 has three independent
exclusion paths, so no single heuristic is the only guard).
- **[Calendar-day snooze across timezones]** → Snooze compares calendar days in a
single, documented timezone (the injected clock's). Tests pin the boundary
(same-day → silent, next-day-while-stale → banner). Acceptable for a motivational
reminder; not a correctness-critical clock.
- **[Cleared-file audit gap]** → A shortlisted file the AI later clears loses its
scanner signals (they live on `entries`, which a cleared file lacks). This is a
frozen-schema decision (see report-schema design "Why scanner signals live only
on entries"), inherited here, not introduced. The scanner's intermediate artifact
*does* hold per-path signals; whether `check` persists cleared-file signals is its
call.
- **[Reminder reads state on every session — a corrupt `state.json` could block]** →
Atomic writes (D9) prevent torn state; the reminder treats a missing/unreadable
state as "never checked" (stale) and exits 0 regardless, never blocking the
session (invariant #1).
## Migration Plan
Additive only — no existing behavior changes (no code exists yet). Deploy order is
the task order: spike gate first, then scanner ∥ state-store in parallel, then the
reminder hook (which depends on the state store for `last_reminded`). Rollback is
removing `hooks/hooks.json` and the new scripts; nothing else consumes them yet.
## Open Questions
- **Staleness threshold default** (days before the reminder fires) — a constant for
this change; the value is an implementation detail, not a spec'd contract.
Per-project override is a later concern.
- **Exact append-only heuristic** — left to implementation, constrained only to be
deterministic and fixture-tested (positive + negative).

View File

@ -0,0 +1,112 @@
# Change: Add Deterministic Core
## Why
The report schema (build step #1) is frozen, but no code exists yet. The plugin
cannot do anything until the deterministic substrate is built: the only thing the
user ever sees unprompted (the `SessionStart` staleness reminder), the thing that
produces the candidate set the AI later classifies (the scanner), and the thing
that holds lifecycle state and reports per-project (the state store). All three
are **deterministic, zero-model** seams (invariant #6) and must exist before the
`check` skill (the first AI pass) can be built on top of them.
This is build step #2 per CLAUDE.md. Because no plugin in this collection has ever
wired a Claude Code hook, the change leads with a **build-spike**: a trivial
`SessionStart` hook emitting a `systemMessage` banner, confirmed to render
visibly in a real session, de-risking the entire reminder mechanism before the
deterministic logic is built on it.
## What Changes
- **Build-spike (sequential gate, first):** stand up a trivial `hooks/hooks.json`
wiring a `SessionStart` hook (`matcher: startup|resume`) that emits a fixed
`systemMessage` banner, and confirm it renders visibly in a real session. This
proves the one mechanism with no precedent in the collection before any logic
depends on it.
- **Reminder hook (`session-reminder`):** a deterministic reminder script invoked
by the `SessionStart` hook. It reads state, compares `last_check` to a staleness
threshold, and emits the `systemMessage` banner (naming the slash command to
run) only when stale. It spends ZERO AI tokens, runs no scan or classification,
exits 0 within a low timeout, and mutates nothing except writing `last_reminded`
(invariant #1). It snoozes to at most once per calendar day while stale, gated
by `last_reminded` (invariant #2). It is unit-testable with an injected clock.
- **Scanner (`doc-scanner`):** a deterministic walker that produces the
**shortlist** (candidate project-root-relative paths) and the per-path
**signals** the report schema consumes — broken references, version skew,
edit-recency vs git churn, location, append-only growth, archive-to-live ratio,
frontmatter markers. It honors all exclusions (invariant #9): `hygiene: frozen`
frontmatter, `.dochygiene-ignore` patterns, detected append-only logs,
self-exclusion of `.dochygiene/`, and the configured `scan.excluded_dirs`
(build, vendor, archive, graphify-out, .dochygiene). It uses **no model**
(invariant #6). It emits an **intermediate** structured artifact (shortlist +
per-path signals), NOT a full machine report — it does not classify, write
`entries`, or populate `token_estimate`.
- **State store (`state-store`):** an in-project store rooted at the resolved
project root (git root, fallback cwd) under a gitignored `.dochygiene/`
directory. It reads/writes the lifecycle timestamps (`last_check`,
`last_clean`, `last_reminded`), enforces report rollover (keep exactly the
latest `.json` + `.md` pair, invariant #4), uses atomic writes, confines all
writes to `.dochygiene/`, keeps no global cross-project index, and never
silently edits the user's `.gitignore` (invariant #3). It is unit-testable with
an injected clock and filesystem.
## Capabilities
### New Capabilities
- `session-reminder`: the deterministic `SessionStart` reminder — hook wiring,
the zero-token staleness banner via `systemMessage`, the once-per-day snooze
gated by `last_reminded`, and the invariant that the hook only reminds (no
scan, no classification, no mutation beyond `last_reminded`). The build-spike's
hook-wiring is proven under this capability.
- `doc-scanner`: the deterministic scan — walking scoped files, computing
per-path objective signals, applying the exclusion pipeline (frozen frontmatter,
ignore patterns, append-only detection, self-exclusion, excluded dirs), and
emitting the intermediate shortlist + signals artifact. No model.
- `state-store`: the in-project state and report store — project-root resolution,
lifecycle timestamps, atomic writes, report rollover, write confinement to
`.dochygiene/`, no global index, and no silent `.gitignore` edits.
One capability per independently-testable deterministic seam: each maps to a
distinct runtime component with its own test seam (injected-clock snooze;
exclusion pipeline; root-resolution + rollover + atomic-write), so splitting keeps
each requirement set focused and the golden/unit tests cohesive rather than one
monolithic capability spanning three unrelated concerns.
### Modified Capabilities
None. The `report-schema` capability is **consumed, not modified** — this change
produces the `shortlist` and per-path `signals` that the frozen schema already
specifies, and changes none of its requirements.
## Impact
- **Affected specs:** creates three new capabilities — `session-reminder`,
`doc-scanner`, `state-store`. Consumes the existing frozen `report-schema`
capability without modifying it.
- **Affected code:** introduces `hooks/hooks.json` (first CC hook in the
collection) and deterministic Python scripts under `scripts/` (reminder,
scanner, state store) plus their unit tests. No skill or AI pass is added here.
- **Out of scope (named owners, so nothing is left ownerless):**
- The **AI classify pass / `check` skill** (produces `entries`, reads the
scanner's intermediate artifact, folds shortlist + signals into the machine
report). Owner: the upcoming `add-check` change.
- **`raw_tokens` / `token_estimate` population.** `token_estimate` is a field on
`entries`, and `raw_tokens` counts "the removed or reduced span" — a span that
only exists once the AI selects an `op`. Although the *count* is deterministic
(invariant #6), its *input* (the chosen op) does not exist until
classification, so the scanner has nothing to count. `raw_tokens` (v1-required)
is therefore populated by the **`check`** change, which owns `entries`. The
token *estimator's* full build (injection-frequency weighting + bottom-up
rollup) remains the **v2 bonus** per PRD phase 5. "Deterministic" does not
imply "belongs in deterministic-core."
- The **`.gitignore` ignore-entry offer** (the one-line "add `.dochygiene/` to
`.gitignore`?" surfaced on first check). This state store only guarantees it
never *silently* edits `.gitignore`; the user-facing offer UX is a
**`check`-change** concern. Note: creating the `.dochygiene/` directory
untracked-but-not-yet-ignored is allowed — invariant #3 forbids *editing*
`.gitignore`, not creating the dir.
- The **`clean` skill + patch-applier**, the **`sweep`** orchestration, and the
**mtime/content guard** application — all later changes.
- **Dependencies:** depends on the frozen `report-schema` (the `shortlist` and
`signals` shapes). Unblocks the `check` change.

View File

@ -0,0 +1,103 @@
## ADDED Requirements
### Requirement: Scanner Produces Scan Metadata, Shortlist, and Per-Path Signals
The scanner SHALL deterministically walk the scoped files under the resolved
project root and emit an intermediate artifact containing: the scan-provenance
fields `project_root` (absolute path to the resolved project root),
`scope_globs` (the glob patterns that were applied), `excluded_dirs` (the
directory names pruned at walk time), and `files_scanned` (count of files
considered before shortlisting); a `shortlist` of project-root-relative
candidate paths; and the per-path objective `signals` supporting each
candidate. The scanner SHALL use no model. The emitted `signals` SHALL be
objective scanner facts only (for example broken references, version skew,
edit-recency versus git churn, location, append-only growth, archive-to-live
ratio, frontmatter markers) and SHALL carry no classification or judgment. The
`check` change consumes this intermediate artifact and folds `project_root`,
`scope_globs`, `excluded_dirs`, and `files_scanned` into the report envelope's
`scan` block.
#### Scenario: Scanner emits scan metadata plus shortlist plus signals
- **WHEN** the scanner runs over a project tree
- **THEN** it emits an intermediate artifact with `project_root`, `scope_globs`, `excluded_dirs`, and `files_scanned` (scan provenance), a `shortlist` of candidate paths, and the per-path objective `signals`, computed with no model
#### Scenario: Scan metadata matches what was actually scanned
- **WHEN** the scanner walks a project rooted at `/home/user/myproject` using globs `["**/*.md"]`, pruning `["build", "vendor", ".dochygiene"]`, and considers 42 files before shortlisting
- **THEN** the intermediate artifact contains `project_root` = `/home/user/myproject`, `scope_globs` = `["**/*.md"]`, `excluded_dirs` = `["build", "vendor", ".dochygiene"]`, and `files_scanned` = `42`
#### Scenario: Signals are objective facts only
- **WHEN** the scanner attaches signals to a candidate path
- **THEN** each signal is an objective fact (such as a broken reference or version skew) and contains no stale/bloat classification
### Requirement: Scanner Emits an Intermediate Artifact, Not a Report
The scanner SHALL NOT produce the machine report, `entries`, `category`, `op`,
`op_type`, `safety_tier`, `exact_edit`, or `token_estimate`. Those are produced by
the AI `check` pass. The scanner's output SHALL be consumed by the `check` change,
which folds `project_root`, `scope_globs`, `excluded_dirs`, and `files_scanned`
into `report.scan`, and folds `shortlist` and `entries[].signals` from the
intermediate artifact into the corresponding report fields.
#### Scenario: Scanner does not classify
- **WHEN** the scanner finishes a run
- **THEN** its output contains no `entries`, no `category`, no `op`, and no `token_estimate` — only the scan metadata fields, the shortlist, and per-path signals
#### Scenario: Report schema is consumed, not produced here
- **WHEN** the `check` change builds the machine report
- **THEN** it reads the scanner's intermediate artifact and supplies `report.scan` (from the scan metadata fields), `report.shortlist`, and `entries[].signals` from it, without the scanner having written a report
### Requirement: Frozen, Ignored, and Append-Only Files Are Excluded
The scanner SHALL exclude from the shortlist any file marked `hygiene: frozen` in
its frontmatter, any file matched by a `.dochygiene-ignore` pattern, and any file
detected as an append-only log. An excluded file SHALL never appear in the
shortlist or carry signals in the output.
#### Scenario: Frozen frontmatter is excluded
- **WHEN** a scanned file has `hygiene: frozen` in its YAML frontmatter
- **THEN** the file is absent from the shortlist
#### Scenario: Ignore-pattern match is excluded
- **WHEN** a file's path matches a pattern in `.dochygiene-ignore`
- **THEN** the file is absent from the shortlist
#### Scenario: Append-only log is excluded
- **WHEN** a file is deterministically detected as an append-only log
- **THEN** the file is absent from the shortlist
### Requirement: Scanner Excludes Configured Directories and Self-Excludes
The scanner SHALL prune the configured `scan.excluded_dirs` (`build`, `vendor`,
`archive`, `graphify-out`, `.dochygiene`) at the directory level so those trees
are never descended, and SHALL self-exclude its own `.dochygiene/` state directory
regardless of configuration. Directory pruning SHALL precede signal computation so
excluded files cost no work.
#### Scenario: Excluded directory is not descended
- **WHEN** a path component of a file is one of the excluded dirs (for example `vendor/`)
- **THEN** that subtree is pruned and none of its files appear in the shortlist
#### Scenario: State directory is always self-excluded
- **WHEN** the scanner walks a project that contains a `.dochygiene/` directory
- **THEN** `.dochygiene/` and its contents are never scanned and never shortlisted, regardless of configuration
### Requirement: Exclusion Precedes Signal Computation
The exclusion pipeline SHALL be applied before signals are computed for a file, so
that a file matching any exclusion (excluded dir, ignore pattern, frozen
frontmatter, append-only) is dropped without computing or emitting signals for it.
#### Scenario: An excluded file produces no signals
- **WHEN** a file matches any exclusion rule
- **THEN** the scanner computes no signals for it and emits neither its path nor signals

View File

@ -0,0 +1,94 @@
## ADDED Requirements
### Requirement: SessionStart Hook Wiring
The plugin SHALL declare a `SessionStart` hook in `hooks/hooks.json` at the plugin
root (not under `.claude-plugin/`) with `matcher` `startup|resume`. The hook SHALL
invoke only the deterministic reminder script, SHALL specify a low `timeout`
(≤5 seconds), and SHALL never block the session.
#### Scenario: Hook is declared with the correct matcher
- **WHEN** the plugin is enabled and a session starts
- **THEN** a `SessionStart` hook with `matcher` `startup|resume` invokes the deterministic reminder script with a `timeout` of at most 5 seconds
#### Scenario: Build-spike banner renders visibly
- **WHEN** a trivial `SessionStart` hook emitting a fixed `systemMessage` banner is wired and a real session is started
- **THEN** the banner renders visibly to the user, confirming the `systemMessage` mechanism works in a real session
### Requirement: Reminder Emits a Visible Zero-Token Banner
The reminder SHALL emit its staleness notice via the hook's JSON `systemMessage`
field (a user-facing banner), NOT via `additionalContext` (which is silent to the
user). The banner SHALL name the slash command the user runs to act on it. The
reminder SHALL spend ZERO AI tokens and SHALL run no scan and no classification.
#### Scenario: Banner uses systemMessage and names the command
- **WHEN** the reminder decides to notify the user
- **THEN** it emits a `systemMessage` banner stating how stale the docs are and naming the slash command to run, and spends no AI tokens
#### Scenario: Reminder performs no analysis
- **WHEN** the reminder runs at session start
- **THEN** it reads only state, invokes no scanner and no model, and reads no document content
### Requirement: Reminder Mutates Only last_reminded
The reminder SHALL mutate nothing except writing `last_reminded` to state, and
only when it emits a banner. When it stays silent it SHALL write nothing. It SHALL
always exit 0 so the session is never blocked.
#### Scenario: Banner emission records last_reminded
- **WHEN** the reminder emits a banner
- **THEN** it writes `last_reminded` set to the current time and changes no other file
#### Scenario: Silent run mutates nothing
- **WHEN** the reminder decides not to notify
- **THEN** it writes no file and exits 0
#### Scenario: Unreadable state never blocks the session
- **WHEN** state is missing or unreadable at session start
- **THEN** the reminder treats the project as never-checked (stale) and still exits 0 without blocking the session
### Requirement: Reminder Fires Only When Docs Are Stale
The reminder SHALL emit a banner only when the docs are stale — when `last_check`
is unset or older than the configured staleness threshold. When docs are fresh
(within the threshold) it SHALL stay silent.
#### Scenario: Fresh docs produce no banner
- **WHEN** `last_check` is within the staleness threshold of the current time
- **THEN** the reminder stays silent and emits no banner
#### Scenario: Never-checked project is stale
- **WHEN** `last_check` is unset
- **THEN** the reminder treats the project as stale and emits a banner (subject to the snooze)
### Requirement: Reminder Snoozes At Most Once Per Calendar Day While Stale
While docs are stale, the reminder SHALL emit a banner at most once per calendar
day, gated by `last_reminded`. A second stale session on the same calendar day
SHALL be silent; the next calendar day while still stale SHALL banner again. The
snooze SHALL be decidable deterministically with an injected clock.
#### Scenario: Second stale session same day is snoozed
- **WHEN** the docs are stale and `last_reminded` is the current calendar day
- **THEN** the reminder stays silent (no second banner that day)
#### Scenario: Next day while still stale re-banners
- **WHEN** the docs are stale and `last_reminded` is a prior calendar day
- **THEN** the reminder emits a banner and updates `last_reminded`
#### Scenario: Snooze survives repeated startup/resume within a working day
- **WHEN** the `SessionStart` hook fires again via `startup` or `resume` (the matched events) on the same calendar day after a banner already showed
- **THEN** the reminder stays silent rather than re-firing the banner

View File

@ -0,0 +1,89 @@
## ADDED Requirements
### Requirement: State Lives In-Project Under .dochygiene/ at the Resolved Root
The state store SHALL resolve the project root deterministically (the nearest
ancestor `.git` directory, falling back to the current working directory when no
git root is found) and SHALL confine all state and report writes to
`<project_root>/.dochygiene/`. It SHALL write no path outside the resolved project
root and SHALL maintain no global cross-project index.
#### Scenario: Root resolves to the git root
- **WHEN** the store resolves the project root from a directory inside a git repository
- **THEN** it returns the repository's git root and writes under `<git-root>/.dochygiene/`
#### Scenario: Root falls back to cwd
- **WHEN** the store resolves the project root from a directory with no ancestor `.git`
- **THEN** it returns the current working directory and writes under `<cwd>/.dochygiene/`
#### Scenario: Writes are confined to the project root
- **WHEN** the store writes state or a report
- **THEN** every written path is under `<project_root>/.dochygiene/` and no path outside the resolved root is written
#### Scenario: No global index exists
- **WHEN** the store operates across multiple projects
- **THEN** no global or cross-project index is created (for example no `~/.dochygiene`); each project's state is independent
### Requirement: Store Never Silently Edits .gitignore
The state store SHALL create the `.dochygiene/` directory as needed but SHALL NEVER
open, append to, or otherwise edit the user's `.gitignore`. Creating an untracked,
not-yet-ignored `.dochygiene/` directory is permitted; editing `.gitignore` is not.
#### Scenario: Creating the state dir does not touch .gitignore
- **WHEN** the store creates `.dochygiene/` for the first time
- **THEN** it writes no change to `.gitignore`
#### Scenario: Ignore-entry offer is out of scope here
- **WHEN** `.dochygiene/` is not yet ignored by git
- **THEN** the state store takes no action on `.gitignore`; surfacing an offer to add the ignore entry is owned by the `check` change
### Requirement: Lifecycle Timestamps
The state store SHALL read and write the lifecycle timestamps `last_check`,
`last_clean`, and `last_reminded` in `state.json`. Reads of an absent timestamp
SHALL be reported as unset rather than as an error.
#### Scenario: Timestamps round-trip
- **WHEN** a timestamp is written and then read back
- **THEN** the read value equals the written value
#### Scenario: Absent timestamp reads as unset
- **WHEN** a timestamp has never been written
- **THEN** reading it reports it as unset (not an error)
### Requirement: Atomic Writes
Every write to `state.json` and to report files SHALL be atomic — written to a
temporary file in the same directory and then renamed onto the target — so that a
concurrent reader observes either the prior complete file or the new complete file,
never a partially written one.
#### Scenario: A write is never torn
- **WHEN** state or a report is written while another process may be reading it
- **THEN** the reader sees either the complete old file or the complete new file, never a partial write
### Requirement: Report Rollover Keeps Only the Latest Pair
The state store SHALL retain exactly one report pair (one human `.md` and one
machine `.json`). Writing a new report SHALL delete any prior report pair before
writing the new one, so a timestamped report history never accumulates.
#### Scenario: Second report replaces the first
- **WHEN** a report pair is written and then a second report pair is written
- **THEN** only the latest `.md` + `.json` pair remains on disk and the prior pair is deleted
#### Scenario: Exactly one pair after any number of writes
- **WHEN** any number of reports have been written in sequence
- **THEN** exactly one `.md` and one `.json` report file exist in `.dochygiene/`

View File

@ -0,0 +1,94 @@
# Tasks: Add Deterministic Core
> **Execution shape:** Group 1 (build-spike) is a **sequential gate** — it MUST
> complete and be visually confirmed in a real session before any other group
> starts. Once it lands, Groups 2 (scanner) and 3 (state store) are **independent
> and run in parallel** (no shared state). Group 4 (reminder hook) is **sequential
> after Group 3** — it reads and writes `last_reminded` through the state store.
> Group 5 (cross-seam verification) runs last. Model routing: all scripts are
> deterministic (no model); per CLAUDE.md, write them with **Haiku**.
## 1. Build-Spike — SessionStart banner (SEQUENTIAL GATE, do FIRST)
- [x] 1.1 Author a trivial `hooks/hooks.json` at the plugin root wiring a
`SessionStart` hook (`matcher: startup|resume`) that emits a fixed
`systemMessage` banner, with `timeout` ≤ 5s and exit 0.
- [x] 1.2 **Acceptance criterion (implementation-time):** start a real Claude Code
session with the plugin enabled and **visually confirm the banner renders**
to the user. The rest of this change does not start until this is confirmed.
- [x] 1.3 Record the confirmed hook entry format (plugin-root `hooks/hooks.json`,
nested hooks array, `systemMessage` field) so Group 4 reuses it. This is the
first CC hook in the collection — capture the working shape.
## 2. Scanner (`doc-scanner`) — parallel with Group 3 after the gate
- [x] 2.1 Implement a `Scanner` class with injected filesystem/root and scope
globs; walk scoped files under the resolved project root. No model.
- [x] 2.2 Implement the exclusion pipeline, applied **before** signal computation
and short-circuiting: (a) dir-prune `scan.excluded_dirs` (`build`, `vendor`,
`archive`, `graphify-out`, `.dochygiene`) at the directory level, including
self-exclusion of `.dochygiene/`; (b) `.dochygiene-ignore` pattern match;
(c) `hygiene: frozen` frontmatter; (d) append-only-log detection.
- [x] 2.3 Implement deterministic per-path **signal** computation (objective facts
only: broken references, version skew, edit-recency vs git churn, location,
append-only growth, archive-to-live ratio, frontmatter markers).
- [x] 2.4 Emit the **intermediate artifact** (`shortlist` + per-path `signals`) as
structured JSON with a correct exit code. Do NOT emit `entries`, `category`,
`op`, `token_estimate`, or a machine report.
- [x] 2.5 **Tests — exclusions (invariant #9):** fixtures proving a `hygiene:
frozen` file, a `.dochygiene-ignore`-matched file, and an append-only log
(positive + negative fixture) are each present in the tree and absent from
the shortlist; and that `.dochygiene/` and excluded dirs are never scanned.
- [x] 2.6 **Tests — signals:** one fixture per signal type asserts the expected
signal appears on the right path with no classification attached.
## 3. State store (`state-store`) — parallel with Group 2 after the gate
- [x] 3.1 Implement `resolve_project_root(start_dir)` — nearest ancestor `.git`,
fallback cwd — as a pure function over injected filesystem.
- [x] 3.2 Implement a `StateStore` class confining all writes to
`<project_root>/.dochygiene/`; create the dir as needed; maintain no global
index; never open or edit `.gitignore`.
- [x] 3.3 Implement lifecycle timestamp read/write (`last_check`, `last_clean`,
`last_reminded`) in `state.json`; absent timestamps read as unset.
- [x] 3.4 Implement atomic writes (write-temp → fsync → `os.replace`) for
`state.json` and report files.
- [x] 3.5 Implement report rollover: delete any prior `.md` + `.json` report pair
before writing the new pair; exactly one pair retained (invariant #4).
- [x] 3.6 **Tests — root resolution (invariant #3):** git-root case, cwd-fallback
case, and that no path outside the resolved root is written; assert
`.gitignore` is never edited.
- [x] 3.7 **Tests — atomic write & rollover (invariants #3, #4):** torn-write
safety (reader sees old-or-new, never partial); two sequential report writes
leave exactly one latest pair on disk.
- [x] 3.8 **Tests — timestamps:** round-trip read/write with an injected clock;
absent timestamp reads as unset.
## 4. Reminder hook (`session-reminder`) — SEQUENTIAL after Group 3
- [x] 4.1 Replace the spike's fixed banner: implement a `Reminder` decision pure
over `(last_check, last_reminded, now, threshold)` per design D5 — silent
when fresh; banner when stale and not snoozed; snooze keyed on **calendar
day** via `last_reminded`.
- [x] 4.2 Implement the reminder script: read state via the Group 3 store with an
injected clock; on banner, emit `systemMessage` (naming the slash command)
and write `last_reminded = now`; on silent, write nothing; always exit 0;
treat missing/unreadable state as never-checked (stale). No scan, no model
(invariant #1).
- [x] 4.3 Update `hooks/hooks.json` to invoke the reminder script (reusing the
confirmed entry format from 1.3), keeping `timeout` ≤ 5s.
- [x] 4.4 **Tests — injected clock (invariants #1, #2):** fresh → silent; stale +
never-reminded → banner; stale + reminded same calendar day → silent (covers
repeated startup/resume within a day); stale + reminded prior day → banner; assert no
file is mutated except `last_reminded`, and only on banner.
## 5. Cross-seam verification & wiring
- [x] 5.1 Confirm no seam imports or invokes a model and the scanner makes no
network/API call (invariant #6 review).
- [x] 5.2 Confirm the scanner output is the intermediate artifact (no `entries`,
no report) and references the frozen `report-schema` shapes only as consumed.
- [x] 5.3 Generate a `CONTEXT.md` for `scripts/` (progressive disclosure) indexing
the reminder, scanner, and state-store modules.
- [x] 5.4 Run `openspec validate add-deterministic-core --strict` and confirm it
passes.

View File

@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-06-18

View File

@ -0,0 +1,3 @@
# add-report-schema
Freeze the machine report schema as the contract every doc-hygiene component consumes

View File

@ -0,0 +1,249 @@
# Design: Machine Report Schema
## Overview
This design fixes the literal shape of the **machine report** — the JSON
artifact the `check` skill writes to `.dochygiene/` and that `clean`, `sweep`,
and the token estimator consume. The human-readable report (`.md`) is a
projection of this same data; its shape is **not** specified here — it is a
sibling user-facing contract owned by the upcoming `check` change (load-bearing
for the confirm-tier escalation UX). See proposal.md "Impact".
## Top-Level Envelope
```json
{
"schema_version": "1.0",
"tool_version": "0.1.0",
"generated_at": "2026-06-18T09:52:00Z",
"scan": {
"project_root": "/abs/path/to/project",
"scope_globs": ["**/*.md", "CLAUDE.md"],
"excluded_dirs": ["build", "vendor", "archive", "graphify-out", ".dochygiene"],
"files_scanned": 42
},
"shortlist": ["docs/old-plan.md", "CLAUDE.md", "README.md"],
"entries": [ /* one Entry per file the AI classified */ ]
}
```
- `shortlist` is the deterministic scanner's candidate set (paths only). Every
path in `entries[].path` MUST be a member of `shortlist`; the scanner produces
the shortlist, the AI pass produces `entries`.
- `generated_at` is the check timestamp used by the clean step's mtime guard.
## Per-File Entry
```json
{
"path": "CLAUDE.md",
"category": { "class": "stale", "subtype": "contradicted" },
"signals": [
{ "name": "broken_reference", "detail": "links scripts/old.py (missing)" },
{ "name": "version_skew", "detail": "pins tool v1, repo on v3" }
],
"op": "Remove the contradicted reference block (lines 40-52).",
"op_type": "deterministic",
"is_destructive": true,
"is_reversible": false, // delete-range removes in-place content — see kinds table
"safety_tier": "confirm", // DERIVED by script from (op_type, is_destructive, is_reversible) — not model-assigned
"exact_edit": {
"kind": "delete-range",
"anchor": { "start_line": 40, "end_line": 52 },
"expected_sha256": "<hash of file at check time>"
},
"token_estimate": {
"raw_tokens": 180,
"injection_frequency": "per-session",
"weighted_tokens": 180
}
}
```
### Field semantics
| Field | Meaning |
|-------|---------|
| `path` | Project-root-relative path. Member of `shortlist`. |
| `category.class` | `stale` or `bloat`. |
| `category.subtype` | Closed enum (see below). |
| `signals` | Objective facts from the scanner that drove the classification. May be empty for AI-only judgments but SHOULD carry the supporting evidence. |
| `op` | One-line human description of the recommended operation the classifier selected. |
| `op_type` | `deterministic` (exact, pre-computed, no model) or `generative` (prose transform, needs Sonnet at clean time). A **property of the chosen `op`**, not a free field: it is consistent with `op` and with `exact_edit` (see consistency rule below). |
| `is_destructive` | Boolean. An op is **destructive iff it removes or overwrites information that is not preserved elsewhere in the repository.** Text-level line removal is *not* automatically destructive — what matters is whether the information survives (e.g. a `dedupe` removes a span but the content is preserved verbatim at `canonical_ref`, so it is *not* destructive; a `delete-range` removes content kept nowhere else, so it *is*). Characterizes the chosen `op`; supplied by the classifier as an objective property of the op. |
| `is_reversible` | Boolean. `true` when the op can be undone mechanically (e.g. move-to-archive, freeze-stamp). A delete of in-place content is `false`. Characterizes the chosen `op`. |
| `safety_tier` | `auto` (runs without prompt) or `confirm` (escalated). **Derived** by a deterministic script function from `(op_type, is_destructive, is_reversible)` — never freely assigned by the model. The report records the computed value. |
| `exact_edit` | Present iff `op_type == deterministic`. The mechanically-applicable edit plus an integrity hash for the mtime/content guard. |
| `token_estimate` | Per-entry context-weight reduction (see below). |
### Category sub-type enum (closed)
- Stale: `contradicted`, `orphaned`, `superseded`, `provisional`,
`completed-in-place`, `duplicated`.
- Bloat: `distill`, `split`, `freeze`.
### `exact_edit.kind` is a closed enum (frozen)
Every `exact_edit` carries a `kind` drawn from a **closed** set, one per
deterministic op family the PRD names (PRD "Operation taxonomy" +
US-14: move-to-archive, freeze-stamp, known-target link fix, exact-dup dedupe,
plus delete). Each kind fixes its own required sub-fields and an inherent
`(is_destructive, is_reversible)` characterization. Those two booleans are what
feed the `safety_tier` derivation above — the kind is not a free annotation, it
constrains the characterization, which in turn fixes the tier. A validator can
therefore reject an `exact_edit` whose `kind` is unknown or whose required
fields are missing.
| kind | required fields | is_destructive | is_reversible | derived safety_tier |
|------|-----------------|----------------|---------------|---------------------|
| `delete-range` | `anchor { start_line, end_line }` | true | false | **confirm** |
| `move-to-archive` | `anchor { start_line, end_line }`, `dest_path` | false | true | **auto** |
| `insert-frontmatter` | `key`, `value` (e.g. `hygiene: frozen`) | false | true | **auto** |
| `replace-text` | `anchor { start_line, end_line }`, `match`, `replacement` | false | true | **auto** |
| `dedupe` | `anchor { start_line, end_line }` (removed span), `canonical_ref` (kept location) | false | true | **auto** |
Notes:
- `delete-range` removes in-place content with nothing kept elsewhere, so the
information is lost → it is destructive **and** irreversible at the op level →
`confirm`.
- `move-to-archive` (freeze-stamp `insert-frontmatter`, link-fix `replace-text`)
are non-destructive and mechanically reversible → `auto`.
- `dedupe` removes a span, but that span is an **exact duplicate preserved
verbatim at `canonical_ref`** — no information leaves the repository — so under
the refined definition of destructive it is **not destructive** (and remains
reversible) → `auto`.
- **`delete-range` vs `dedupe` — the contrast that justifies separate kinds.**
Both remove text, but only one loses information. `delete-range` removes content
kept nowhere else (info lost → destructive → `confirm`); `dedupe` removes a span
whose content survives at `canonical_ref` (info preserved → not destructive →
`auto`). This information-preservation distinction is the whole reason they are
separate kinds rather than one delete primitive.
- All `anchor`-bearing kinds rely on `expected_sha256` (carried on `exact_edit`,
not per-kind) for the mtime/content guard.
**PRD US-14 reconciliation (resolved).** `dedupe` is `auto` because
exact-duplicate removal preserves information at `canonical_ref`; consistent with
PRD US-14 (which lists exact-dup dedupe among the no-prompt `auto` ops) and
invariant #7 (`auto` = deterministic + reversible + objective). No divergence
remains.
### `op_type` is a property of the chosen `op` (consistency, not lookup)
`op_type` is **not** a free third field and is **not** looked up from
`category.subtype`. The same subtype can map to either a deterministic delete or
a generative rewrite depending on the op the classifier selects (e.g. a
`contradicted` block can be deterministically deleted, or generatively
rewritten). Therefore `op_type` describes the chosen `op`, and the schema
requires the two to be consistent, checked deterministically:
- `exact_edit` is present **iff** `op_type == deterministic`.
- `op_type == generative` ⟹ no `exact_edit` (the edit is deferred to clean
time, not pre-written).
A validator enforces this biconditional mechanically; an entry that violates it
(generative with an `exact_edit`, or deterministic without one) is invalid.
### `safety_tier` is DERIVED, never model-assigned
The classifier proposes `op`, `op_type`, and the op's objective
characterization (`is_destructive`, `is_reversible`). It does **not** assign
`safety_tier`. A deterministic script computes it, so the model cannot violate
invariant #7 by mislabeling a tier:
```
safety_tier(op_type, is_destructive, is_reversible):
if op_type == "generative": return "confirm" # generative ⟹ confirm
if is_destructive: return "confirm" # destructive ⟹ confirm
if not is_reversible: return "confirm" # irreversible ⟹ confirm
return "auto" # deterministic + reversible + objective ⟹ auto
```
**Where "objective" lives.** Invariant #7's `auto` requires
deterministic + reversible + *objective*. Objectivity is not a separate input
because it is implied by construction: a `deterministic` op is an exact,
pre-computed edit (objective by definition), while subjectivity enters the
system only through `generative` ops — which the first branch already forces to
`confirm`. So the three inputs above fully cover invariant #7.
This function can **never** emit `auto` for a generative op or for any
destructive/irreversible op. The only path to `auto` is
deterministic + reversible (hence objective). The truth table below is the
output of this function, not a constraint the model is trusted to satisfy:
| op_type | is_destructive | is_reversible | → safety_tier | Example |
|---------|----------------|---------------|---------------|---------|
| deterministic | false | true | **auto** | move-to-archive, freeze-stamp (insert-frontmatter), known-target link fix (replace-text), exact-dup dedupe (span preserved at `canonical_ref` → not destructive) |
| deterministic | true | (any) | **confirm** | delete-range (info lost, kept nowhere else → destructive → always confirm) |
| deterministic | false | false | **confirm** | irreversible in-place edit |
| generative | (any) | (any) | **confirm** | distill narrative, split file (generative is never auto) |
### Token estimate
```json
{ "raw_tokens": 180, "injection_frequency": "per-session" | "on-demand" | null, "weighted_tokens": 180 }
```
- `raw_tokens` (**required, v1**): local-tokenizer count of the removed/reduced
span (no API call). This is the only mandatory field in v1.
- `injection_frequency` (**optional / nullable, v2**): `per-session` for
auto-injected files (CLAUDE.md, memory index) → counted as real per-session
savings; `on-demand` for docs read only when opened → theoretical-max savings.
May be `null` (or omitted) in v1; populated by the v2 bonus.
- `weighted_tokens` (**optional / nullable, v2**): `raw_tokens` adjusted by
injection frequency. May be `null` (or omitted) in v1; populated by the v2
bonus.
- Roll-up (file → category → total) is computed bottom-up by the estimator and
is not stored redundantly per entry; the report's consumer sums `entries`.
**v1 vs v2 scope.** Per PRD build-order phase 5, the token estimator
(injection-frequency weighting + bottom-up rollup) is explicitly a **v2 bonus**.
v1 requires only the deterministic `raw_tokens` count (local tokenizer, no API
call — consistent with invariant #6). The weighting fields are part of the
schema shape from the start so the contract never changes, but their population
is deferred to v2.
## Decisions
### Why `category` is a `{ class, subtype }` object, not a flat string
Consumers frequently branch on `class` (stale vs bloat) alone (different remedy
families). Nesting keeps that branch cheap while preserving the precise subtype.
### Why `exact_edit` carries `expected_sha256`
The clean step's mtime guard needs a content fingerprint to refuse applying a
cached edit to a file that changed since `generated_at`. Storing it on the edit
makes the guard a pure function of the report plus the current file.
### Why `safety_tier` is derived, not assigned
Invariant #7 is a safety boundary: `auto` ops run unattended. If the model could
write `safety_tier` directly, a single misclassification would let a destructive
or generative op run without approval. Making the tier a pure function of
`(op_type, is_destructive, is_reversible)` removes the model from that decision
entirely — the model only describes the op's objective properties, and the
script enforces the boundary. This is the difference between trusting the model
to obey invariant #7 and making it structurally unable to violate it.
### Why scanner signals live only on `entries` (cleared-file audit-trail tradeoff)
The deterministic scanner produces `shortlist` (paths only) and the per-entry
`signals` arrive on the AI-produced `entries`. A shortlisted file the AI judges
clean produces no entry (see the "shortlisted file may be cleared" scenario), so
its scanner signals are **not** recorded in the report. This separation is
**intentional**: the scanner contributes facts and a candidate set, while the AI
contributes judgments and entries; the report records decisions, not the raw
scan. The acknowledged tradeoff is an audit-trail gap — there is no record of
*why* a cleared file was shortlisted. The PRD does not require cleared-file
provenance (US-29's audit trail is about clean-time `confirm` approvals, not
check-time clears), so v1 keeps the separation. A future change could attach
per-path scanner signals to `shortlist` (or add a `cleared` list) for
auditability without restructuring `entries`; this note records the decision but
does not change the schema.
### Why generative ops omit `exact_edit`
A check must stay cheap even when no clean follows. Pre-writing prose
transformations would spend Sonnet tokens at check time for work that may never
be applied, so generative edits are produced at clean time instead.

View File

@ -0,0 +1,80 @@
# Change: Add Report Schema
## Why
The machine report schema is the linchpin of doc-hygiene: it is the contract
that the `check` skill produces and that the `clean` skill, `sweep`, and the
token estimator all consume. Per the build order, it gates everything — no
component can be built correctly until the report's shape is fixed. The PRD and
CLAUDE.md state it is "designed and frozen first."
This change specifies that schema as a frozen contract before any code exists,
so that the deterministic core and the AI layers are built against a single,
stable structure rather than re-deriving it. It is spec-authoring only: no
scripts and no schema code are produced here.
## What Changes
- Define the **top-level machine report** structure: schema version, scan
metadata (scope, files scanned, generated-at timestamp, tool version), the
candidate **shortlist**, and a per-file **entries** list.
- Define the **per-file report entry** fields: `path`, `category` (with the
fixed stale/bloat sub-type taxonomy), `signals`, recommended `op`, `op_type`
(`deterministic` | `generative`), the op characterization booleans
`is_destructive` and `is_reversible`, a **derived** `safety_tier`
(`auto` | `confirm`), an optional `exact_edit` (present iff `op_type` is
`deterministic`), and a per-entry `token_estimate`.
- Fix the **category taxonomy** as a closed enum: stale =
`contradicted` | `orphaned` | `superseded` | `provisional` |
`completed-in-place` | `duplicated`; bloat = `distill` | `split` | `freeze`.
- Make **`op_type` a property of the chosen `op`** (not a free field, not a
subtype lookup): `op_type` must be consistent with `op`, with `exact_edit`
present iff `op_type` is `deterministic`, validated deterministically.
- Make **`safety_tier` derived deterministically** per invariant #7: the model
proposes `op`, `op_type`, `is_destructive`, and `is_reversible`; a script
function `safety_tier(op_type, is_destructive, is_reversible)` computes the
tier (generative ⟹ confirm; destructive or irreversible ⟹ confirm; only
deterministic + reversible + objective ⟹ auto). The model cannot emit `auto`
for a generative or destructive op.
- Scope the **`token_estimate`** to match the PRD build order: in v1 only
`raw_tokens` (deterministic local-tokenizer count, no API call) is required;
`injection_frequency` and `weighted_tokens` are optional/nullable v1 fields
whose population (injection-frequency weighting + bottom-up rollup) is the v2
bonus. The schema shape is fixed now so the contract never changes.
- Freeze the **`exact_edit.kind`** closed enum: one `kind` per deterministic op
family the PRD names (`delete-range`, `move-to-archive`, `insert-frontmatter`
for freeze-stamp, `replace-text` for known-target link fix, `dedupe` for
exact-dup removal), each with its required sub-fields and inherent
`(is_destructive, is_reversible)` characterization that feeds the `safety_tier`
derivation.
- Declare the schema a **frozen contract** enforced by `invariants.md` (which now
exists and carries the schema-freeze and `safety_tier` derivation invariants):
changing any field, enum value, or semantic requires updating `invariants.md`
with explicit human approval.
- Provide schema-shape verification: a **schema-validator script** plus two
hand-authored **schema fixtures** under `examples/golden/` (one valid machine
report, one invalid) proving the validator accepts/rejects correctly. These are
schema-shape fixtures, **not** classifier golden examples — the latter are
deferred until the `check` change exists (the classifier that would produce them
does not exist yet, so hand-authoring them now risks diverging from real
classifier output).
## Impact
- Affected specs: creates a new `report-schema` capability.
- Affected code: none yet. This is the contract every later component (scanner,
`check` skill, `clean` skill / patch-applier, token estimator, `sweep`) will
consume; those are out of scope here. The only artifacts this change produces
beyond the spec are the schema-validator script and the two `examples/golden/`
schema fixtures used to verify the schema shape.
- Human-readable report ownership: this change specifies only the **machine**
report. The human-readable `.md` report SHAPE is a sibling user-facing contract
owned by the upcoming `check` change. It is load-bearing for the confirm-tier
escalation UX (it is what the user reads when approving `confirm` ops), so it
must be treated as a contract in its own right — out of scope here, but its
owner is named so it is not left ownerless.
- Deferred: **classifier golden examples** are deferred to after the `check`
change (the classifier that produces them does not exist yet). This change ships
only schema-shape fixtures, which are distinct.
- Dependencies: none. This change is the first in the build order and unblocks
the deterministic core and the `check` skill.

View File

@ -0,0 +1,241 @@
## ADDED Requirements
### Requirement: Top-Level Report Envelope
The machine report SHALL be a single JSON object containing `schema_version`,
`tool_version`, `generated_at` (ISO-8601 UTC), a `scan` metadata object, a
`shortlist` array, and an `entries` array. The `scan` object SHALL include
`project_root`, `scope_globs`, `excluded_dirs`, and `files_scanned`. The
`generated_at` timestamp SHALL be the check time that the clean step uses for
its mtime guard.
#### Scenario: Check writes a well-formed report
- **WHEN** the `check` skill completes a scan and classification pass
- **THEN** it writes one JSON object with `schema_version`, `tool_version`, `generated_at`, `scan`, `shortlist`, and `entries`
#### Scenario: Clean reads the check timestamp
- **WHEN** the `clean` skill loads a report
- **THEN** it reads `generated_at` and uses it as the reference time for the per-op mtime guard
### Requirement: Shortlist Precedes Entries
The `shortlist` SHALL contain the project-root-relative paths the deterministic
scanner surfaced as candidates. Every `entries[].path` SHALL be a member of
`shortlist`. The scanner produces `shortlist`; the AI pass produces `entries`.
#### Scenario: Entry path is a shortlist member
- **WHEN** the AI pass adds an entry for a file
- **THEN** that file's path already appears in `shortlist`
#### Scenario: A shortlisted file may be cleared
- **WHEN** the AI pass judges a shortlisted file as not stale and not bloated
- **THEN** the path remains in `shortlist` but produces no entry in `entries`
### Requirement: Per-File Entry Fields
Each entry SHALL contain `path`, `category`, `signals`, `op`, `op_type`,
`is_destructive`, `is_reversible`, `safety_tier`, and `token_estimate`. The
`op_type` SHALL be one of `deterministic` or `generative`. The `is_destructive`
and `is_reversible` fields SHALL be booleans that objectively characterize the
chosen `op`. An op SHALL be considered **destructive if and only if it removes or
overwrites information that is not preserved elsewhere in the repository**;
text-level line removal is not by itself destructive when the information
survives (for example a `dedupe` whose removed span is preserved verbatim at
`canonical_ref` is non-destructive). The `safety_tier` SHALL be one of `auto` or `confirm` and SHALL be
derived (see the Op-Type and Safety-Tier Semantics requirement), not assigned by
the model. The `signals` field SHALL list the objective scanner facts that
support the classification.
#### Scenario: Entry carries the full classification
- **WHEN** the AI pass classifies a candidate file
- **THEN** the entry includes `path`, `category`, `signals`, `op`, `op_type`, `is_destructive`, `is_reversible`, `safety_tier`, and `token_estimate`
#### Scenario: Op-type and safety-tier are constrained enums
- **WHEN** an entry is written
- **THEN** `op_type` is `deterministic` or `generative` and `safety_tier` is `auto` or `confirm`
#### Scenario: Op characterization inputs are present for derivation
- **WHEN** an entry is written
- **THEN** `is_destructive` and `is_reversible` are present as booleans so `safety_tier` can be computed deterministically from them
### Requirement: Category Taxonomy Is a Closed Enum
The `category` field SHALL be an object with `class` and `subtype`. The `class`
SHALL be `stale` or `bloat`. When `class` is `stale`, `subtype` SHALL be one of
`contradicted`, `orphaned`, `superseded`, `provisional`, `completed-in-place`,
or `duplicated`. When `class` is `bloat`, `subtype` SHALL be one of `distill`,
`split`, or `freeze`. No other classes or subtypes are permitted.
#### Scenario: Stale file is categorized with a stale subtype
- **WHEN** a doc references a file that no longer exists
- **THEN** its entry has `category.class` = `stale` and `category.subtype` = `orphaned`
#### Scenario: Bloated file is categorized with a bloat subtype
- **WHEN** a doc is a long resolved-problem narrative that should be condensed
- **THEN** its entry has `category.class` = `bloat` and `category.subtype` = `distill`
#### Scenario: Unknown subtype is rejected
- **WHEN** a report contains a `category.subtype` outside the closed enum
- **THEN** the report is invalid
### Requirement: Op-Type Is a Property of the Chosen Op
`op_type` SHALL describe the operation the classifier selected and SHALL be
consistent with it: it SHALL NOT be a free field, and it SHALL NOT be looked up
from `category.subtype` (a single subtype may map to either a deterministic or a
generative op depending on the chosen `op`). An entry SHALL include `exact_edit`
when, and only when, `op_type` is `deterministic`. An entry with `op_type` =
`generative` SHALL NOT include `exact_edit`. This biconditional SHALL be
validated deterministically; an entry that violates it is invalid.
#### Scenario: Same subtype admits either op-type
- **WHEN** a `contradicted` block is recommended for deterministic deletion in one entry and generative rewrite in another
- **THEN** both entries are valid, each with `op_type` consistent with its chosen `op` rather than derived from the shared subtype
#### Scenario: Op-type and exact-edit consistency is validated
- **WHEN** an entry has `op_type` = `generative` but also carries an `exact_edit` (or `op_type` = `deterministic` but omits `exact_edit`)
- **THEN** the entry is invalid
### Requirement: Safety-Tier Is Derived Deterministically
`deterministic` ops SHALL be exact edits the check pre-computes and the cleaner
applies with no model. `generative` ops SHALL be prose transformations requiring
a model at clean time. The `safety_tier` SHALL be **computed** by a deterministic
script function `safety_tier(op_type, is_destructive, is_reversible)` and SHALL
NOT be assigned by the model; the report records the computed value. The function
SHALL return `confirm` when `op_type` is `generative`, OR when `is_destructive`
is true, OR when `is_reversible` is false; and SHALL return `auto` only when the
op is `deterministic` AND non-destructive AND reversible (hence objective). The
function SHALL NEVER return `auto` for a `generative` op or for any destructive
or irreversible op, so the model cannot violate invariant #7. `auto`-tier ops
SHALL run without a prompt; `confirm`-tier ops SHALL be escalated for approval.
#### Scenario: Deterministic reversible op derives to auto
- **WHEN** an entry has `op_type` = `deterministic`, `is_destructive` = false, and `is_reversible` = true
- **THEN** the derived `safety_tier` is `auto` and the cleaner applies the `exact_edit` mechanically without a prompt
#### Scenario: Destructive op derives to confirm
- **WHEN** an op removes information not preserved elsewhere (`is_destructive` = true, e.g. a `delete-range`)
- **THEN** the derived `safety_tier` is `confirm` regardless of `op_type`
#### Scenario: Generative op derives to confirm
- **WHEN** an entry has `op_type` = `generative`
- **THEN** the derived `safety_tier` is `confirm` and the op is delegated to a Sonnet subagent at clean time
#### Scenario: Function can never emit auto for a generative or destructive op
- **WHEN** `safety_tier(op_type, is_destructive, is_reversible)` is evaluated for any input where `op_type` = `generative`, or `is_destructive` = true, or `is_reversible` = false
- **THEN** the result is `confirm`, never `auto`
### Requirement: Exact-Edit Presence Tied to Op-Type
An entry SHALL include an `exact_edit` object when, and only when, `op_type` is
`deterministic`. The `exact_edit` SHALL be mechanically applicable and SHALL
carry a content fingerprint (`expected_sha256`) so the cleaner's mtime guard can
refuse to apply it to a file changed since `generated_at`. Entries with
`op_type` = `generative` SHALL omit `exact_edit`.
#### Scenario: Deterministic entry includes exact edit
- **WHEN** an entry has `op_type` = `deterministic`
- **THEN** it includes an `exact_edit` with the edit operation and `expected_sha256`
#### Scenario: Generative entry omits exact edit
- **WHEN** an entry has `op_type` = `generative`
- **THEN** it has no `exact_edit` field and the edit is produced at clean time
#### Scenario: mtime guard refuses a stale edit
- **WHEN** a file's current content hash differs from the entry's `expected_sha256`
- **THEN** the cleaner skips the cached edit and recommends re-analysis
### Requirement: Exact-Edit Kind Is a Closed Enum
Every `exact_edit` SHALL carry a `kind` drawn from the closed set
`delete-range`, `move-to-archive`, `insert-frontmatter`, `replace-text`, and
`dedupe` — one per deterministic op family the PRD names. No other `kind` is
permitted. Each `kind` SHALL carry its required sub-fields and SHALL have a fixed
inherent `(is_destructive, is_reversible)` characterization that feeds the
`safety_tier` derivation:
- `delete-range` SHALL carry `anchor` with `start_line` and `end_line`; it is
destructive and irreversible (derives to `confirm`).
- `move-to-archive` SHALL carry `anchor` (`start_line`, `end_line`) and
`dest_path`; it is non-destructive and reversible (derives to `auto`).
- `insert-frontmatter` SHALL carry the frontmatter `key` and `value` to inject
(for example `hygiene: frozen`); it is non-destructive and reversible (derives
to `auto`).
- `replace-text` SHALL carry `anchor` (`start_line`, `end_line`), a `match`
string, and a `replacement` string; it is non-destructive and reversible
(derives to `auto`).
- `dedupe` SHALL carry `anchor` (`start_line`, `end_line`) of the removed
duplicate span and a `canonical_ref` to the kept location; because the removed
span is an exact duplicate preserved verbatim at `canonical_ref`, no information
is lost, so it is non-destructive and reversible and derives to `auto`. (Contrast
`delete-range`, which removes content kept nowhere else, is destructive, and
derives to `confirm`.)
A validator SHALL reject an `exact_edit` whose `kind` is outside the closed set
or that omits a required sub-field for its `kind`.
#### Scenario: Each kind carries its required fields
- **WHEN** an entry has `op_type` = `deterministic` with an `exact_edit` of `kind` = `move-to-archive`
- **THEN** the `exact_edit` includes `anchor` (`start_line`, `end_line`) and `dest_path`, and the entry is valid
#### Scenario: Unknown kind is rejected
- **WHEN** an `exact_edit` has a `kind` outside the closed set `delete-range`, `move-to-archive`, `insert-frontmatter`, `replace-text`, `dedupe`
- **THEN** the report is invalid
#### Scenario: Missing required sub-field is rejected
- **WHEN** an `exact_edit` of `kind` = `replace-text` omits its `match` or `replacement` field
- **THEN** the report is invalid
#### Scenario: Kind characterization feeds the safety-tier derivation
- **WHEN** an `exact_edit` has `kind` = `delete-range`
- **THEN** the entry's `is_destructive` is true and `is_reversible` is false, so the derived `safety_tier` is `confirm`
### Requirement: Per-Entry Token Estimate
Each entry SHALL include a `token_estimate`. In v1, only `raw_tokens` (a
local-tokenizer count of the removed or reduced span, with no API call) SHALL be
required. The `injection_frequency` (`per-session` or `on-demand`) and
`weighted_tokens` (`raw_tokens` adjusted by injection frequency) fields SHALL be
optional and MAY be `null` or omitted in v1; populating them (injection-frequency
weighting plus bottom-up rollup) is the v2 bonus per the PRD build order. When
populated, auto-injected files SHALL be weighted as real per-session savings and
on-demand docs SHALL be weighted as theoretical-max savings. Roll-up to category
and total SHALL be computed bottom-up from the entries.
#### Scenario: v1 entry carries only the required raw token count
- **WHEN** a v1 check writes an entry without the weighting fields
- **THEN** `token_estimate.raw_tokens` is present (a local-tokenizer count, no API call) and `injection_frequency` and `weighted_tokens` may be `null` or omitted
#### Scenario: Auto-injected file weighted per session
- **WHEN** the weighting fields are populated (v2) and the affected file is auto-injected (for example `CLAUDE.md`)
- **THEN** `injection_frequency` = `per-session` and `weighted_tokens` reflects real per-session savings
#### Scenario: On-demand doc weighted as theoretical max
- **WHEN** the weighting fields are populated (v2) and the affected file is read only on demand
- **THEN** `injection_frequency` = `on-demand` and `weighted_tokens` reflects theoretical-max savings
### Requirement: Schema Is a Frozen Contract
The report schema SHALL be treated as a frozen contract that every other
component consumes. The freeze SHALL be enforced by `invariants.md` (which
carries the schema-freeze and `safety_tier` derivation invariants); that file is
the authoritative enforcement mechanism for this contract. Any change to a field,
an enum value, or a documented semantic SHALL require updating `invariants.md`
with explicit human approval before it takes effect. Schema-shape verification
SHALL be carried by a schema-validator script and hand-authored **schema
fixtures** (one valid machine report, one invalid) under `examples/golden/`;
these schema-shape fixtures are distinct from classifier golden examples, which
are deferred until the `check` change exists.
#### Scenario: Schema change requires invariants update
- **WHEN** a contributor proposes adding, renaming, or removing a field or enum value
- **THEN** the change is accompanied by an `invariants.md` update with human approval before it takes effect
#### Scenario: Validator accepts a well-formed report and rejects a malformed one
- **WHEN** the schema-validator script runs against the valid and invalid schema fixtures under `examples/golden/`
- **THEN** it accepts the valid machine report and rejects the invalid one
#### Scenario: Components rely on the frozen shape
- **WHEN** the `clean`, `sweep`, or token-estimator components are built
- **THEN** they consume the report without re-deriving its structure

View File

@ -0,0 +1,90 @@
# Tasks: Add Report Schema
## 1. Top-Level Report Structure
- [x] 1.1 Specify the report envelope: `schema_version`, `tool_version`,
`generated_at` (ISO-8601), and `scan` metadata (scope globs, files
scanned count, excluded dirs).
- [x] 1.2 Specify the `shortlist` field (candidate paths the scanner surfaced)
and its relationship to `entries`.
- [x] 1.3 Specify the `entries` array as the per-file results.
## 2. Per-File Entry Fields
- [x] 2.1 Specify required entry fields: `path`, `category`, `signals`, `op`,
`op_type`, `is_destructive`, `is_reversible`, `safety_tier`,
`token_estimate`.
- [x] 2.2 Specify the optional `exact_edit` field and when it MUST be present
(deterministic ops) and absent (generative ops).
- [x] 2.3 Freeze the `exact_edit.kind` closed enum: `delete-range`,
`move-to-archive`, `insert-frontmatter` (freeze-stamp), `replace-text`
(known-target link fix), `dedupe` (exact-dup removal). For each kind specify
its required sub-fields and inherent `(is_destructive, is_reversible)`
characterization, and that a validator rejects an unknown `kind` or a kind
missing a required sub-field. Cross-check the derivation: `delete-range`
(destructive — info kept nowhere else) → `confirm`; `move-to-archive`,
`insert-frontmatter`, `replace-text`, and `dedupe` (reversible,
non-destructive — `dedupe`'s removed span is preserved at `canonical_ref`)
`auto`.
- [x] 2.4 Specify `op_type`↔`op` consistency validation: `op_type` is a property
of the chosen op (not a free field, not a subtype lookup); `exact_edit`
present iff `op_type == deterministic`, validated deterministically.
## 3. Category Taxonomy
- [x] 3.1 Fix the stale sub-types: `contradicted`, `orphaned`, `superseded`,
`provisional`, `completed-in-place`, `duplicated`.
- [x] 3.2 Fix the bloat sub-types: `distill`, `split`, `freeze`.
- [x] 3.3 Specify `category` as `{ class: stale | bloat, subtype: <enum> }`.
## 4. Op-Type and Safety-Tier Semantics
- [x] 4.1 Specify `op_type` enum (`deterministic` | `generative`) and meaning.
- [x] 4.2 Specify `safety_tier` enum (`auto` | `confirm`) and meaning.
- [x] 4.3 Specify the `is_destructive` / `is_reversible` op-characterization
inputs the classifier supplies for the derivation.
- [x] 4.4 Specify the `safety_tier` **derivation rule**:
`safety_tier(op_type, is_destructive, is_reversible)` — generative ⟹
confirm; destructive or irreversible ⟹ confirm; only deterministic +
reversible + objective ⟹ auto. Tier is computed, never model-assigned, and
can never emit `auto` for a generative or destructive op (invariant #7).
## 5. Token Estimate
- [x] 5.1 Specify per-entry `token_estimate` shape: `raw_tokens` required in v1
(deterministic local-tokenizer count, no API call).
- [x] 5.2 Mark `injection_frequency` and `weighted_tokens` as v2-optional /
nullable fields; injection-frequency weighting + bottom-up rollup to
category and total is the v2 bonus per the PRD build order.
## 6. Frozen-Contract Guarantee
- [x] 6.1 Freeze and version the schema (`schema_version`): specify that any
change to a field, enum value, or documented semantic requires updating
`invariants.md` with explicit human approval.
- [x] 6.2 Reference `invariants.md` as the authoritative enforcement mechanism
for the freeze (it carries the schema-freeze and `safety_tier` derivation
invariants). Do NOT author classifier golden examples in this change — the
classifier (the AI `check` pass) does not exist yet, so classifier golden
examples are deferred to after the `check` change.
## 7. Schema-Shape Verification
> These are schema-shape fixtures, valid to author now. They are NOT classifier
> golden examples (deferred to the `check` change) — an implementer must not
> conflate the two.
- [x] 7.1 Write a schema-validator script that checks a machine report against
this frozen schema (envelope fields, closed category enum, closed
`exact_edit.kind` enum with per-kind required sub-fields, `op_type`
`exact_edit` biconditional, and `safety_tier` equals the derivation output).
- [x] 7.2 Author one **valid** schema fixture (a well-formed machine report) under
`examples/golden/` and confirm the validator accepts it.
- [x] 7.3 Author one **invalid** schema fixture (e.g. unknown `exact_edit.kind`,
or a `safety_tier` disagreeing with the derivation) under `examples/golden/`
and confirm the validator rejects it.
## 8. Validation
- [x] 8.1 Run `openspec validate add-report-schema --strict` and confirm it
passes.

View File

@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-06-24

View File

@ -0,0 +1,3 @@
# add-check
Build step #3: the `/hygiene` command surface plus the `hygiene-check` skill — scanner shortlist → Sonnet classification (judgment only) → a model-free deterministic finalize pass (`report_builder.py`) that computes `expected_sha256`, looks up `is_destructive`/`is_reversible` from `exact_edit.kind`, derives `safety_tier` via the single-source function, and sources `raw_tokens` from the token estimator → validate-before-rollover → write the one report pair and stamp `last_check`. Adds hermetic classifier golden examples. Consumes the frozen `report-schema`.

View File

@ -0,0 +1,273 @@
# 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.

View File

@ -0,0 +1,109 @@
# Change: Add Check
## Why
The deterministic substrate is in place: the `SessionStart` reminder, the scanner
(shortlist + per-path signals), the state store (timestamps, atomic writes, report
rollover), and the token estimator all exist and are deterministic, zero-model
seams. The reminder banner already advertises `/hygiene check` as the action the
user should take when docs are stale — **but no `/hygiene` command and no check
skill exist yet**, so following the reminder's own advice does nothing.
This is build step #3 per CLAUDE.md. It is the plugin's first AI pass: it folds the
scanner's intermediate artifact and a Sonnet classification into a schema-valid
machine report and a human report, then stamps `last_check`. Four per-entry fields
on the frozen report schema **cannot** be model-authored without violating an
invariant or being physically impossible — `exact_edit.expected_sha256` (sha256 of
real file bytes), `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`), and
`token_estimate.raw_tokens` (invariant #6: from the local token estimator, never a
model). Therefore check MUST interpose a **deterministic finalize pass** between
model classification and the report write. This change introduces that pass as a
standalone, model-free assembler.
## What Changes
- **Deterministic finalize pass (`report_builder.py`):** a new standalone,
model-free script that sits **between** the model's classification and
`StateStore.write_report`. Input: the scanner artifact plus the model's slim
per-file classification proposals. For each proposal it reads the anchored span,
computes `expected_sha256` over the file's current bytes, looks up
`(is_destructive, is_reversible)` from `KIND_TABLE[kind]`, calls
`derive_safety_tier(...)` **imported** from `validate_report.py` (single source of
truth, invariant #10), sources `raw_tokens` by calling
`token_estimator.default_estimator().estimate_for_report(span_text)` (invariant
#6), stamps each entry's `generated_at` at that file's hash instant, and assembles
the schema-valid machine report envelope plus a deterministic human-report
skeleton. Output: the full machine report + human skeleton.
- **Command surface (`commands/hygiene.md`):** a single command file dispatching on
`$ARGUMENTS`. `check [--scope <glob-or-path>] [--category <class|subtype>]` invokes
the `hygiene-check` skill; `status` reads lifecycle timestamps and report presence
inline (read-only, no scan, no model); `clean` / `sweep` are reserved with a "not
yet implemented (Phase 4)" message; no/unknown args print usage plus current
status.
- **Check skill (`skills/hygiene-check/SKILL.md`):** the orchestration. Modeled on
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 finalize, validate, and write. Enforces the
validate-before-rollover sequencing (validation runs on a scratch path, never in
`.dochygiene/`, because `write_report` deletes the prior pair first — invariant
#4).
- **Classifier golden examples + hermetic harness:** Layer-2 reversion-protection
fixtures under `examples/golden/classifier/` (input doc tree → expected
classification report), distinct from the schema-shape fixtures
(`valid_report.json` / `invalid_report.json`). A hermetic pytest harness asserts
only deterministic/stable parts (no live model call); the live model-classification
regression is a separate, manually/agent-invoked harness.
## Capabilities
### New Capabilities
- `doc-check`: the first AI pass and its deterministic guardrails — the `/hygiene`
command surface (`check` / `status`, with `clean` / `sweep` reserved), the
`hygiene-check` skill orchestration (scan → Sonnet classify → deterministic
finalize → validate-before-write → write + stamp), the model-free
`report_builder.py` finalize pass that owns the four non-model-authored fields,
the validate-before-rollover sequencing constraint, and the hermetic classifier
golden harness. One capability: the command, the skill, the finalize pass, and the
report-write sequencing are a single check pipeline whose pieces are only
meaningful together; splitting `report_builder` out would scatter a tightly
coupled flow across capabilities.
### Modified Capabilities
None. The `report-schema` capability is **consumed, not modified** — this change
populates the frozen schema's `scan`, `shortlist`, and `entries` (including the
four fields the schema requires be derived/computed, not model-assigned) and changes
none of its requirements.
## Impact
- **Affected specs:** creates one new capability — `doc-check`. Consumes the frozen
`report-schema`, `doc-scanner`, and `state-store` capabilities without modifying
them.
- **Affected code:** introduces a new deterministic script `scripts/report_builder.py`
(plus its unit tests), the command file `commands/hygiene.md`, the skill
`skills/hygiene-check/SKILL.md`, and classifier golden fixtures + a hermetic test
under `examples/golden/classifier/` and `tests/`. Imports
`derive_safety_tier`/`KIND_TABLE` from `validate_report.py` and
`default_estimator`/`estimate_for_report` from `token_estimator.py` (no
re-implementation).
- **Out of scope (named owners, so nothing is left ownerless):**
- The **`clean` skill + patch-applier**, the **`sweep`** orchestration, and the
**mtime/content guard application** — the upcoming `add-clean` change. Note the
**deferred hole** this change documents: `insert-frontmatter` ops have
`has_anchor = False` and therefore carry **no** `expected_sha256`, so the
invariant #8 content guard does not protect them at clean time. Phase 4 (clean)
MUST handle `insert-frontmatter` application explicitly.
- **`token_estimate` weighting** (`injection_frequency`, `weighted_tokens`,
bottom-up rollup) — the v2 bonus per PRD phase 5. This change populates only
`raw_tokens`.
- The **live model-classification regression harness** (running an actual check
against a fixture tree and diffing) — a separate, manually/agent-invoked
harness, not part of the unit suite.
- **Dependencies:** depends on the frozen `report-schema` (the report shape and the
`derive_safety_tier`/`KIND_TABLE` single source of truth), the `doc-scanner`
intermediate artifact, the `state-store` (`write_report`, `set_last_check`,
rollover), and the token estimator. Unblocks the `add-clean` change.

View File

@ -0,0 +1,196 @@
## ADDED Requirements
### Requirement: `/hygiene` Command Surface
The plugin SHALL provide a single `/hygiene` command that dispatches on its
arguments. `/hygiene check [--scope <glob-or-path>] [--category <class|subtype>]`
SHALL invoke the `hygiene-check` skill. `/hygiene status` SHALL read and report the
lifecycle timestamps (`last_check`, `last_clean`, `last_reminded`) and whether a
report exists, using no scan and no model. `/hygiene clean` and `/hygiene sweep`
SHALL be reserved and SHALL report that they are not yet implemented (Phase 4). No
arguments or unknown arguments SHALL print usage plus the current status.
#### Scenario: Check dispatches to the skill
- **WHEN** the user runs `/hygiene check`
- **THEN** the command invokes the `hygiene-check` skill, passing through any `--scope` or `--category` flag
#### Scenario: Status is read-only
- **WHEN** the user runs `/hygiene status`
- **THEN** the command reports `last_check`, `last_clean`, `last_reminded`, and whether a report exists, without running a scan or any model
#### Scenario: Clean and sweep are reserved
- **WHEN** the user runs `/hygiene clean` or `/hygiene sweep`
- **THEN** the command reports that the subcommand is not yet implemented (Phase 4) and does not mutate anything
#### Scenario: Unknown arguments print usage
- **WHEN** the user runs `/hygiene` with no arguments or an unrecognized subcommand
- **THEN** the command prints usage and the current status
### Requirement: Check Skill Orchestrates Scan, Classification, and Report Writing
The `hygiene-check` skill SHALL orchestrate the check pipeline: run the deterministic
scanner, dispatch a Sonnet subagent for judgment-only classification of the
signal-bearing candidates, run the deterministic finalize pass, validate, write the
report pair, and stamp `last_check`. The skill SHALL run all non-judgment steps as
deterministic scripts with no model (invariant #6). Zero-signal shortlisted files
SHALL be treated as presumptively cleared: they SHALL remain in the shortlist,
produce no entries, and SHALL NOT be read by the model. A `--scope` argument SHALL
narrow the scanner; a `--category` argument SHALL filter which entries are produced
after classification; both SHALL be recorded in the human-report header.
#### Scenario: Skill runs the full pipeline
- **WHEN** the `hygiene-check` skill runs
- **THEN** it scans (deterministic), classifies signal-bearing candidates (Sonnet), finalizes (deterministic), validates (deterministic), writes the report pair (deterministic), and stamps `last_check` (deterministic)
#### Scenario: Zero-signal files are not read by the model
- **WHEN** a shortlisted file carries no scanner signals
- **THEN** it remains in the shortlist, produces no entry, and is not read by the classification model
#### Scenario: Scope and category are recorded and applied
- **WHEN** the user passes `--scope docs/**/*.md` and `--category bloat`
- **THEN** the scanner is narrowed by the scope, only `bloat` entries are produced after classification, and both the scope and the category are recorded in the human-report header
### Requirement: Classification Subagent Returns Judgment-Only Proposals
The Sonnet classification subagent SHALL return, per signal-bearing candidate, a slim
proposal containing only judgment fields: `category` (`class` and `subtype` from the
closed enum, justified by cited signals), the scanner `signals` passed through
verbatim with an optional one-line gloss in `detail`, `op` (a human sentence),
`op_type` (`deterministic` or `generative`, a property of the chosen op per invariant
#11), and `confidence`. When `op_type` is `deterministic`, the proposal SHALL carry an
`exact_edit` skeleton (`kind` plus the kind's required sub-fields and `anchor` where
required) and SHALL NOT carry `expected_sha256`, `is_destructive`, `is_reversible`, or
`safety_tier`. When `op_type` is `generative`, the proposal SHALL carry no
`exact_edit` and instead a non-persisted `reducible_range` so the finalize pass can
count `raw_tokens` over the real span. Low-confidence hard distinctions
(stale-vs-bloat; destructive-deletion-vs-generative-rewrite) MAY be escalated to Opus.
#### Scenario: Deterministic proposal carries only the exact-edit skeleton
- **WHEN** the subagent classifies a file as a deterministic op
- **THEN** the proposal includes the `exact_edit` skeleton (`kind`, required sub-fields, `anchor` where required) and omits `expected_sha256`, `is_destructive`, `is_reversible`, and `safety_tier`
#### Scenario: Generative proposal carries a reducible range, not an exact edit
- **WHEN** the subagent classifies a file as a generative op
- **THEN** the proposal carries no `exact_edit` and instead a non-persisted `reducible_range` for the finalize pass to count tokens over
#### Scenario: Signals are passed through verbatim
- **WHEN** the subagent emits a proposal
- **THEN** its `signals` are the scanner's signal names verbatim, with any added wording confined to the optional `detail` gloss
### Requirement: Deterministic Finalize Pass Owns the Non-Model Fields
A standalone, model-free finalize pass (`report_builder.py`) SHALL sit between model
classification and the report write, and SHALL author the four per-entry fields that
the model must not author. For each proposal it SHALL compute
`exact_edit.expected_sha256` over the file's current bytes for anchor-bearing kinds,
SHALL set `(is_destructive, is_reversible)` from `KIND_TABLE[kind]`, SHALL compute
`safety_tier` by calling `derive_safety_tier(op_type, is_destructive, is_reversible)`
imported from `validate_report.py` (the single source of truth, invariant #10), and
SHALL source `token_estimate.raw_tokens` from the local token estimator
(`default_estimator().estimate_for_report(span_text)`, invariant #6). It SHALL stamp
each entry's `generated_at` at that file's hash instant and set the envelope
`generated_at` to the run instant. The model SHALL NOT supply any of these four
fields.
#### Scenario: The finalize pass computes the content hash and derives the tier
- **WHEN** the finalize pass processes a deterministic proposal with `kind` = `move-to-archive`
- **THEN** it computes `expected_sha256` over the file's current bytes, sets `is_destructive` = false and `is_reversible` = true from `KIND_TABLE`, and derives `safety_tier` = `auto` via `derive_safety_tier`
#### Scenario: raw_tokens comes from the local estimator, never the model
- **WHEN** the finalize pass sets a `token_estimate`
- **THEN** `raw_tokens` is the local estimator's count of the span (no model, no API call), with the weighting fields null in v1
#### Scenario: The model cannot author the derived fields
- **WHEN** a proposal arrives from the classification subagent
- **THEN** `expected_sha256`, `safety_tier`, and (for deterministic ops) `is_destructive`/`is_reversible` are absent from the proposal and are authored only by the finalize pass
### Requirement: Validate Before Rollover
The check SHALL validate the assembled report with `validate_report.py` on a scratch
path (not under `.dochygiene/`) and SHALL write the report pair only on validator exit
0. Because `StateStore.write_report` deletes the prior report pair before writing the
new one (invariant #4), validation SHALL NOT run against `.dochygiene/`, so a
validation failure never destroys the last good report. On a validation failure (exit
1) the check SHALL NOT write the report; on an empty shortlist or no signal-bearing
files the check SHALL still write a valid empty-`entries` report and stamp
`last_check`.
#### Scenario: Invalid report is never written
- **WHEN** the assembled report fails validation (exit 1)
- **THEN** the check does not call `write_report`, the prior report pair is preserved, and the offending entries are re-prompted or dropped before re-validating
#### Scenario: Validation runs on a scratch path
- **WHEN** the check validates the assembled report
- **THEN** validation runs against a scratch path outside `.dochygiene/`, so the last good report in `.dochygiene/` is never deleted by a failed run
#### Scenario: Empty shortlist still produces a valid report and stamp
- **WHEN** the scanner returns no signal-bearing files
- **THEN** the check writes a valid report with empty `entries` and stamps `last_check`
### Requirement: Report Pair Is Written and last_check Stamped
On a successful check, the skill SHALL write exactly one machine report
(`.dochygiene/report.json`) and one human report (`.dochygiene/report.md`) via
`StateStore.write_report` (atomic, rollover-bounded to one pair per invariant #4), and
SHALL stamp `last_check` to the same run instant used as the envelope `generated_at`.
The human report SHALL be a deterministic skeleton grouping entries by Stale, Bloat,
and Cleared with per-entry path, category, op, tier, token count, and signal, and a
header showing the timestamp, scope, files scanned, and candidate/cleared counts; only
an optional per-entry "why" gloss MAY be model-written.
#### Scenario: One report pair survives the write
- **WHEN** the check completes successfully
- **THEN** exactly one `report.json` and one `report.md` exist in `.dochygiene/`, and any prior pair has been rolled over
#### Scenario: last_check matches the envelope timestamp
- **WHEN** the check writes the report
- **THEN** `last_check` is stamped to the same run instant recorded as the envelope `generated_at`
### Requirement: Classifier Golden Examples Are Hermetic and Human-Gated
Classifier golden examples SHALL live under `examples/golden/classifier/<n>-<name>/`
(an `input/` fixture tree with stable hashes, an `expected.json` schema-valid report,
and an optional `notes.md`), distinct from the schema-shape fixtures
(`valid_report.json` / `invalid_report.json`). The golden unit harness SHALL be
hermetic — it SHALL NOT call a live model — and SHALL assert only deterministic,
stable parts: that the scanner emits the expected signals on the right paths, that
each `expected.json` validates (exit 0), and that the stable fields (`category.class`,
`category.subtype`, `op_type`, derived `safety_tier`, `exact_edit.kind`) match a
captured/committed check output. Op-prose and exact anchor line numbers SHALL be
advisory (flagged for review, not hard-failed). The live model-classification
regression SHALL be a separate, manually or agent-invoked harness, not part of the
unit suite. Adding or changing classifier goldens SHALL be human-gated per the
META-RULE.
#### Scenario: Golden harness makes no live model call
- **WHEN** the classifier golden unit tests run
- **THEN** they assert scanner signals, `expected.json` validity, and stable-field matches against a committed capture, with no live model invocation
#### Scenario: Classifier goldens are distinct from schema fixtures
- **WHEN** a contributor looks for the schema-shape fixtures versus the classifier goldens
- **THEN** the schema fixtures (`valid_report.json` / `invalid_report.json`) and the classifier goldens (`examples/golden/classifier/`) are separate, and `examples/golden/CONTEXT.md` documents the distinction
#### Scenario: Changing a golden requires human approval
- **WHEN** a contributor adds or changes a classifier golden example
- **THEN** the change is gated on explicit human approval per the META-RULE before it takes effect

View File

@ -0,0 +1,111 @@
# Tasks: Add Check
> **Execution shape:** Task 1 is a **human-gate** (already discharged — the
> `report_builder.py` finalize pass is approved). Group 2 (`report_builder.py` +
> tests) and Group 3 (`commands/hygiene.md`) are **independent and run in
> parallel**. Group 4 (the `hygiene-check` skill) is **sequential after Groups 2
> and 3**. Group 5 (classifier goldens + hermetic harness) is **sequential after
> Groups 2 and 4** and is **human-gated** per the META-RULE. Group 6 (CONTEXT.md)
> runs last. Model routing is annotated per group.
## 1. Human-gate — approve the deterministic finalize pass (DONE)
- [x] 1.1 Approve `report_builder.py` as a standalone, model-free finalize pass
interposed between model classification and `StateStore.write_report`, owning
the four fields the model must not author (`expected_sha256`, `safety_tier`,
`is_destructive`/`is_reversible` for deterministic ops, `raw_tokens`).
## 2. `report_builder.py` + tests — parallel with Group 3 (model: Sonnet)
- [ ] 2.1 Implement `scripts/report_builder.py` as a standalone, model-free
assembler. Input: the scanner artifact + the model's slim per-file
classification proposals. Output: a full schema-valid machine report + a
deterministic human-report skeleton. Use an injected clock/filesystem.
- [ ] 2.2 Compute `exact_edit.expected_sha256` over each file's **current bytes**
for anchor-bearing kinds only; note that `insert-frontmatter`
(`has_anchor = False`) carries no `expected_sha256` (deferred hole for clean).
- [ ] 2.3 Set `(is_destructive, is_reversible)` from `KIND_TABLE[kind]` and compute
`safety_tier = derive_safety_tier(op_type, is_destructive, is_reversible)`,
both **imported** from `validate_report.py` (single source of truth —
invariant #10; no re-implementation).
- [ ] 2.4 Extract the span (anchor range / `reducible_range` / whole file for
`move-to-archive`) and source `token_estimate` via
`token_estimator.default_estimator().estimate_for_report(span_text)`
v1 `raw_tokens` only, weighting `null` (invariant #6).
- [ ] 2.5 Assemble the envelope: `schema_version` `"1.0"`, `tool_version` read from
`plugin.json` (flag a mismatch with `valid_report.json`), per-entry
`generated_at` = that file's hash instant, envelope `generated_at` = the run
instant. Emit the deterministic human-report skeleton (mechanical parts only).
- [ ] 2.6 Reject malformed proposal JSON before computing any field.
- [ ] 2.7 **Tests:** assert the sha256, the derived `(is_destructive,
is_reversible, safety_tier)`, and `raw_tokens` for each kind; assert the
assembled report round-trips through `validate_report.py` (exit 0); assert the
generative path counts `raw_tokens` over the non-persisted `reducible_range`;
injected clock/fs.
## 3. `commands/hygiene.md` — parallel with Group 2 (model: Haiku)
- [ ] 3.1 Author a single `commands/hygiene.md` with minimal frontmatter
(`name: hygiene`; description naming `/hygiene check` and `/hygiene status`)
that dispatches on `$ARGUMENTS`.
- [ ] 3.2 Route `check [--scope <glob-or-path>] [--category <class|subtype>]`
invoke the `hygiene-check` skill (pass the flags through).
- [ ] 3.3 Implement `status` **inline** (a few `python3` calls): read
`get_last_check` / `get_last_clean` / `get_last_reminded` and whether a report
exists. Read-only — no scan, no model.
- [ ] 3.4 Reserve `clean` / `sweep` with a "not yet implemented (Phase 4)" message;
print usage + current status for no/unknown args.
## 4. `skills/hygiene-check/SKILL.md` — sequential after Groups 2, 3 (model: Sonnet)
- [ ] 4.1 Author `skills/hygiene-check/SKILL.md` with frontmatter mirroring the
`commit` skill. The subagent points at a **workflow doc**, not SKILL.md
itself, to avoid recursion (per the `commit`-skill precedent).
- [ ] 4.2 Implement the deterministic steps: scan (`scanner.py`, `--scope`
`--globs`/path filter), select signal-bearing candidates (zero-signal files
presumptively cleared, not read by the model), finalize (`report_builder.py`),
validate **on a scratch path** (validate-before-rollover, invariant #4), write
(`write_report`), stamp (`set_last_check` with the same run instant).
- [ ] 4.3 Write the **Sonnet classification subagent prompt**: per signal-bearing
file, return a slim judgment-only proposal (`category` from the closed enum,
`signals` verbatim + optional `detail` gloss, `op`, `op_type`, the
`exact_edit` **skeleton** for deterministic or a non-persisted
`reducible_range` for generative, `confidence`). The model supplies **no**
`expected_sha256` / `is_destructive` / `is_reversible` / `safety_tier`.
- [ ] 4.4 Encode the decision rules (subtype → kind → tier) and the Opus-escalation
rule (low confidence on stale-vs-bloat or destructive-vs-generative).
- [ ] 4.5 Encode failure handling: validation fail (exit 1) → do not write, map
violations to entries, re-prompt or drop and re-validate; usage error (exit 2)
→ stop; empty shortlist → write empty-`entries` report + stamp; malformed
proposal → re-prompt. Record `--scope` / `--category` in the report header.
## 5. Classifier goldens + hermetic harness — after Groups 2, 4; HUMAN-GATED (model: Sonnet)
- [ ] 5.1 Create 35 static fixture trees under
`examples/golden/classifier/<n>-<name>/` (`input/` with stable sha256s,
`expected.json`, optional `notes.md`), each mapping a subtype to a **distinct**
`exact_edit.kind`: `orphaned`→`delete-range`/`confirm`,
`superseded`→`move-to-archive`/`auto`,
`completed-in-place`→`insert-frontmatter`/`auto`, `duplicated`→`dedupe`/`auto`,
`distill`→generative/`confirm`.
- [ ] 5.2 Implement `tests/test_classifier_golden.py` **hermetically** (no live
model call): (1) `scanner.py` on `input/` emits the expected signals on the
right paths; (2) `validate_report.py` on each `expected.json` → exit 0; (3)
stable-field match against a captured/committed check output (`category.class`,
`category.subtype`, `op_type`, derived `safety_tier`, `exact_edit.kind`).
Op-prose and exact anchor line numbers are advisory (flag, do not hard-fail).
- [ ] 5.3 Document the **separate** live model-classification regression harness
(manually/agent-invoked, NOT part of the unit suite).
- [ ] 5.4 Update `examples/golden/CONTEXT.md` to distinguish classifier goldens from
the schema-shape fixtures.
## 6. CONTEXT.md updates — last (model: Haiku)
- [ ] 6.1 Author `CONTEXT.md` for `commands/` and `skills/` (progressive
disclosure).
- [ ] 6.2 Update `scripts/CONTEXT.md` to add `report_builder.py` (its role as the
deterministic finalize pass and the symbols it imports).
## 7. Validation
- [ ] 7.1 Run `openspec validate add-check --strict` and confirm it passes.

View File

@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-06-24

View File

@ -0,0 +1,11 @@
# add-clean
Build step #4: the `hygiene-clean` skill, `patch_applier.py` (already built — 263
tests pass), and `/hygiene clean`/`sweep` routing. Loads the report written by
`check`, gates `confirm`-tier entries (delete-range, all generative) with a
batch-confirm prompt, applies deterministic entries via the per-file transaction
applier (content-hash guard, descending-anchor application, incompatible-ops
detection, precise git staging), delegates generative distillation to a Sonnet
subagent via `workflows/distill.md`, and produces exactly one git commit
(`git-context commit-apply`). `sweep` = check-then-clean at command level, no
double-commit. Resolves the deferred `insert-frontmatter` guard hole from add-check.

View File

@ -0,0 +1,257 @@
# 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).

View File

@ -0,0 +1,74 @@
# Change: Add Clean
## Why
The `check` capability writes a machine report and a human report, but nothing acts on
them. The `SessionStart` banner already tells users their docs may be stale and
advertises `/hygiene check`; the check skill now delivers a report — but the feature
loop is still open: **no `/hygiene clean` or `/hygiene sweep` exists**, so the report
sits in `.dochygiene/` and nothing is ever fixed. `patch_applier.py` and its 263
tests are already built; this change wires the remaining pieces so the plugin
actually remediates doc rot.
## What Changes
- **`patch_applier.py`** (already built — 263 tests pass): a standalone deterministic
script that applies a report's entries to project files in per-file transactions,
with a content-hash guard, incompatible-ops detection, and precise git staging.
Marked DONE in the task list.
- **`skills/hygiene-clean/SKILL.md`**: the orchestration skill — loads the current
report, partitions entries by safety tier and op type, gates `confirm`-tier entries
with a batch-confirm prompt (per-entry opt-out), applies deterministic entries via
`patch_applier.py`, delegates generative entries to a Sonnet subagent (via
`workflows/distill.md`), stages precisely, and commits once via
`git-context commit-apply`.
- **`workflows/distill.md`**: the generative Sonnet workflow invoked for
`distill`/`split`/`generative` entries; isolated from the skill via a LOOP-GUARD
subagent pointer.
- **`commands/hygiene.md` update**: replaces the "not yet implemented (Phase 4)"
stubs for `clean` and `sweep` with live routing to the `hygiene-clean` skill.
- **Integration tests** (`tests/test_clean_integration.py`): fixture-repo tests
covering invariant #5 GAP (dirty→checkpoint+exactly-one cleanup commit;
clean→one commit; all-skipped→zero commits) and invariant #7 sweep-gating GAP
(confirm escalates under `sweep`).
## Capabilities
### New Capabilities
- `doc-clean`: the clean pipeline and its guarantees — the `hygiene-clean` skill
orchestration (load report → gate confirms → git preflight → apply deterministic
entries → dispatch generative → stage precisely → single commit → stamp
`last_clean`), the per-file transaction model in `patch_applier.py` (content-hash
guard, descending-anchor application, incompatible-ops detection, precise staging),
safety-tier gating (invariant #7), git-safety (invariant #5: WIP checkpoint,
single commit, tracked-files-only, rollback on hard failure), and the `sweep`
composition (`/hygiene sweep` = check-then-clean without double-commit). One
capability because the applier, the skill, the gating, and git safety are a single
atomic pipeline — splitting them would scatter tightly coupled guarantees across
capability boundaries.
### Modified Capabilities
None. The `doc-check` capability is **consumed, not modified** — the clean skill
reads the report written by check without changing any check requirement.
## Impact
- **Affected specs:** creates one new capability — `doc-clean`. Consumes the frozen
`report-schema`, `doc-scanner`, `state-store`, and `doc-check` capabilities without
modifying them.
- **Affected code:** introduces `skills/hygiene-clean/SKILL.md`,
`workflows/distill.md`, integration tests, and CONTEXT.md updates for
`skills/hygiene-clean/` and `scripts/`. Updates `commands/hygiene.md` (stub
replacement only — no structural change). `patch_applier.py` and its unit tests
already exist and pass.
- **Out of scope (named owners):**
- `token_estimate` weighting (`injection_frequency`, `weighted_tokens`, bottom-up
rollup) — v2 bonus per PRD phase 5.
- Link rewrite after `move-to-archive` — v1 moves only, next `/hygiene check` flags
new orphans.
- Live model-classification regression harness — separate, manually invoked.
- **Dependencies:** depends on `report-schema` (frozen), `doc-scanner`,
`state-store`, `doc-check` (produces the report this change consumes), and the
already-built `patch_applier.py`. No upstream capability specs are modified.

View File

@ -0,0 +1,245 @@
## ADDED 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` 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
`/hygiene sweep` convenience path SHALL NOT bypass invariant #7.
#### 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 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 /hygiene sweep and the report contains confirm-tier entries
- **THEN** the confirm gate runs identically to a standalone /hygiene clean
### 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.
Staging SHALL be precise: for non-move 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. The skill
SHALL NEVER use `git add -A` or `git add .`. If a hard failure occurs (applier
exit 2, `git mv` 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). `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 /hygiene clean with a clean git tree
- **THEN** the run produces exactly one git commit containing all applied edits
#### Scenario: Dirty tree gets a WIP checkpoint then one cleanup commit
- **WHEN** the user runs /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 or applier exit 2 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
### Requirement: Clean Skill Orchestration
The `hygiene-clean` skill SHALL load the current report via `StateStore.read_report`
(if none, it SHALL tell the user to run `/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 /hygiene clean and no report exists in .dochygiene/
- **THEN** the skill tells the user to run /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 at Command Level
`/hygiene sweep` SHALL invoke the `hygiene-check` skill followed by the
`hygiene-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 `commands/hygiene.md`,
not in a third skill or by having the clean skill invoke the check skill internally.
#### Scenario: Sweep routes through the command, not a third skill
- **WHEN** the user runs /hygiene sweep
- **THEN** commands/hygiene.md invokes hygiene-check then hygiene-clean sequentially, passing shared flags
#### Scenario: Sweep produces at most one cleanup commit
- **WHEN** the user runs /hygiene sweep on a clean tree
- **THEN** at most one git commit is produced (the cleanup commit from clean); check writes nothing to git

View File

@ -0,0 +1,141 @@
# Tasks: Add Clean
> **Execution shape:** T1 and T2 are already DONE (patch_applier.py + 263 tests
> pass). T3 (`workflows/distill.md`) has no upstream dependency among the remaining
> tasks and can start immediately. T4 (`hygiene-clean` SKILL.md, git-safety inline)
> is **sequential after T3**. T5 (integration tests) is **sequential after T4**. T6
> (`commands/hygiene.md` update) and T7 (CONTEXT.md + CLAUDE.md) are independent of
> T3T5 and can run in parallel with any group. Model routing is annotated per task.
## 1. patch_applier.py — DONE (model: Sonnet)
- [x] 1.1 Implement `scripts/patch_applier.py` as a standalone, model-free applier.
`PatchApplier(project_root, fs, git)` with
`apply_indexed([(orig_index, entry)]) → {applied, skipped, failed,
staged_paths}`. CLI: `python patch_applier.py --report <path>
--apply-indices 0,2,3`. Exit 0 all / 1 partial / 2 usage.
- [x] 1.2 Implement per-file transaction: read bytes once, verify sha256 for every
anchored entry, skip whole file on mismatch (`content-changed-since-check`),
apply anchored edits descending by `anchor.start_line`, apply
`insert-frontmatter` last, write once, stage once.
- [x] 1.3 Implement `insert-frontmatter` re-derivation at apply time: re-read file,
parse frontmatter; key+target value → idempotent no-op; key+conflict →
`frontmatter-key-conflict` skip; key absent → insert (create `---` block if
needed) as last in-memory step.
- [x] 1.4 Implement incompatible-ops detection: per file, skip whole file
(`incompatible-ops-on-file`) if `move-to-archive` co-occurs with any other op,
or if anchor ranges overlap. Never compose in v1.
- [x] 1.5 Implement all KIND_TABLE kinds: `delete-range`, `move-to-archive`
(git mv, mkdir dest, `auto`), `insert-frontmatter` (`auto`),
`replace-text` (first occurrence in span, `auto`), `dedupe` (`auto`).
`(is_destructive, is_reversible)` from KIND_TABLE, not entry.
- [x] 1.6 Handle edge cases: `move-to-archive` source-gone+dest-present → idempotent
success; `replace-text`/`insert-frontmatter` already applied → no-op success;
non-UTF-8 file → `non-utf8-content` skip; generative/unknown kind → rejected/skip.
## 2. patch_applier.py tests — DONE (model: Sonnet)
- [x] 2.1 Write unit tests for per-file transaction: sha256 mismatch skips entire
batch; clean match applies in descending-anchor order; `insert-frontmatter`
applied last; write-once / stage-once assertions.
- [x] 2.2 Write unit tests for `insert-frontmatter` re-derivation: idempotent no-op,
key-conflict skip, key-absent insert (with and without existing `---` block).
- [x] 2.3 Write unit tests for incompatible-ops detection: `move-to-archive`+other →
skip; overlapping anchors → skip; non-conflicting entries → proceed.
- [x] 2.4 Write unit tests for every KIND_TABLE kind: delete-range, move-to-archive
(git mv both sides, mkdir dest), replace-text (first occ in span), dedupe,
insert-frontmatter. Assert exit codes (0/1/2).
- [x] 2.5 Confirm 263 tests pass: `pytest tests/` green.
## 3. workflows/distill.md — generative Sonnet workflow (model: Sonnet)
- [ ] 3.1 Author `workflows/distill.md` as the Sonnet generative workflow (LOOP-GUARD:
the skill points the subagent at this workflow doc, not SKILL.md, to avoid
recursion). Document the LOOP-GUARD pattern in the workflow header.
- [ ] 3.2 Specify the subagent inputs: live file contents (read at dispatch time, not
from report cache), the report entry (`op`, `category`, `signals`, `detail`).
- [ ] 3.3 Specify the output contract: `distill` → condensed prose replacing the span;
`split` → new-primary prose + archived-section to append or move; skill writes
and stages the result.
- [ ] 3.4 Specify failure handling: if Sonnet returns malformed output, log and skip
the entry (do not abort the run).
## 4. skills/hygiene-clean/SKILL.md (model: Sonnet)
- [ ] 4.1 Author `skills/hygiene-clean/SKILL.md` with frontmatter mirroring the
`hygiene-check` skill style. Precondition: `CLAUDE_PLUGIN_ROOT` is set; if not,
halt with a clear error.
- [ ] 4.2 Implement step 1: load report via `StateStore.read_report`; if none, tell
user to run `/hygiene check` and stop.
- [ ] 4.3 Implement step 2: re-validate loaded report via `validate_report.py`; if
invalid, stop.
- [ ] 4.4 Implement step 3: apply scope/category filter to entries (entry-stage,
same as check).
- [ ] 4.5 Implement step 4: partition entries into auto+deterministic,
confirm+deterministic (`delete-range`), and generative (always `confirm`).
- [ ] 4.6 Implement step 5 (GATE FIRST): present batch-confirm list for confirm-tier
entries — path · category · op · tokens · why; per-entry opt-out; visually
distinguish irreversible `delete-range` from reversible auto ops. Approved set =
all auto + approved confirm.
- [ ] 4.7 Implement step 6: git preflight — resolve project root; `git status
--porcelain`; clean → baseline = HEAD; dirty → auto-create WIP checkpoint
commit (user work only), baseline = that checkpoint.
- [ ] 4.8 Implement step 7: apply deterministic approved entries via
`python patch_applier.py --report <path> --apply-indices <list>`; capture JSON
result (`{applied, skipped, failed, staged_paths}`).
- [ ] 4.9 Implement step 8: for each approved generative entry — confirm file exists;
read live file contents; dispatch Sonnet subagent → `workflows/distill.md`
(LOOP-GUARD); receive new content; write + stage.
- [ ] 4.10 Implement step 9: staging — `git add <staged_paths>` for non-move ops;
DO NOT `git add` `move-to-archive` dest (applier already staged via `git mv`).
NEVER `git add -A` or `git add .`.
- [ ] 4.11 Implement step 10: single commit via
`git-context commit-apply --message-stdin` with generated message:
`docs: hygiene cleanup (N edits across M files)\n\nAuto: ...\nConfirmed: ...\nSkipped (re-analysis recommended): ...`
No Co-Authored-By, no AI attribution, no emoji, no trailers.
- [ ] 4.12 Implement step 11: stamp `last_clean` to commit instant via
`StateStore.set_last_clean(commit_instant)`.
- [ ] 4.13 Implement step 12: surface summary — applied / skipped (with re-analysis
note) / confirmed counts, commit SHA, note about checking for new orphans
after `move-to-archive`.
- [ ] 4.14 Implement failure handling: guard mismatch → skip-file-report-continue;
applier exit 2 / `git mv` fail / write error → `git restore`/`reset` to
baseline, abort with structured error; partial success (applier exit 1) → NOT
rollback, commit what applied; zero in-scope → no-op report, no commit.
## 5. Integration tests (model: Sonnet)
- [ ] 5.1 Implement `tests/test_clean_integration.py` using a fixture git repo.
Test invariant #5 GAPs:
(a) dirty-tree → WIP checkpoint + exactly one cleanup commit (two total);
(b) clean-tree → exactly one cleanup commit;
(c) all-guard-skipped → zero commits, no rollback noise.
- [ ] 5.2 Test invariant #7 sweep-gating GAP: when entries include `confirm`-tier ops
and user is running under `/hygiene sweep`, the confirm gate still fires
identically (not bypassed).
- [ ] 5.3 Test `move-to-archive` precise staging: applier stages both sides via
`git mv`; skill does not call `git add` on the dest; `git diff --cached` shows
the rename cleanly.
- [ ] 5.4 Test rollback path: simulate applier exit 2 mid-run; assert tree is restored
to baseline and no partial commit exists.
- [ ] 5.5 Confirm all new integration tests pass alongside the 263 existing unit tests.
## 6. commands/hygiene.md — clean/sweep routing (model: Haiku)
- [ ] 6.1 Replace the "not yet implemented (Phase 4)" stubs for `clean` and `sweep` in
`commands/hygiene.md` with live routing to the `hygiene-clean` skill, passing
`--scope` and `--category` flags through.
- [ ] 6.2 Wire `/hygiene sweep` as command-level check-then-clean sequencing: invoke
`hygiene-check` skill, then `hygiene-clean` skill, passing shared flags to both.
No third skill; no double-commit.
## 7. CONTEXT.md updates and CLAUDE.md fix (model: Haiku)
- [ ] 7.1 Update `scripts/CONTEXT.md` to add `patch_applier.py` — its role as the
deterministic in-memory applier, the transaction model, and its exit codes.
- [ ] 7.2 Author `skills/hygiene-clean/CONTEXT.md` (progressive disclosure index for
the clean skill directory).
- [ ] 7.3 Update `CLAUDE.md` build-order step 4 note: mark the deferred
`insert-frontmatter` hole as RESOLVED — the applier re-derives frontmatter
freshness at apply time (idempotent key-absence check) instead of trusting a
cached hash.

View File

@ -0,0 +1,99 @@
# Project Context
## Purpose
**doc-hygiene** is a Claude Code plugin that monitors and manages **stale** and
**bloated** project documentation. It is installed globally but operates
per-project. It reminds the developer (deterministically, zero AI tokens) on
`SessionStart` when docs haven't been checked recently, then checks and cleans
on demand via skills.
The plugin holds a core distinction:
- **Stale** = the doc is *wrong* (contradicted, orphaned, superseded,
provisional, completed-in-place, duplicated). Remedy: fix or remove.
- **Bloat** = the doc is *true but mostly irrelevant* (distill, split, freeze).
Remedy: change its altitude, almost never delete history.
Severity scales with **injection frequency** — a stale line in an auto-injected
file (`CLAUDE.md`, memory index) misleads every session, so it is worse than the
same line in a doc nobody opens.
### Design Principles
1. **Deterministic-first** — scan, state, patch-apply, and token-estimate are
scripts (no model). AI does only classification and prose distillation.
2. **Remind, don't nag** — the `SessionStart` hook only reminds; it never runs
analysis or mutates anything. All mutation is user-invoked. Reminders snooze
(at most once/day while stale).
3. **Non-intrusive** — state lives in-project under a gitignored `.dochygiene/`;
no global index. The tool never silently edits the user's repo.
4. **Git-safe cleanup** — runs only on a clean/committed tree (or after an auto
WIP checkpoint); each run lands as one reviewable commit.
5. **The tool must not become the bloat it polices** — report rollover keeps
only the latest report.
## Tech Stack
- **Plugin format:** Claude Code plugin (skills + commands + a `SessionStart`
hook declared in `hooks/hooks.json`, emitting a `systemMessage` banner).
- **Language:** Python, OOP — small single-responsibility classes, dependency
injection, immutable transforms where possible.
- **Scripts:** structured JSON output, correct exit codes, idempotent, testable
in isolation with injected clock/filesystem.
- **AI layers:** classification = Sonnet (hard cases → Opus); generative
distillation = Sonnet (explicitly not Haiku); orchestration = Opus.
## Project Conventions
### Code Style
- Small composable single-responsibility classes; dependency injection for
testability; immutable transforms where practical.
- Scripts emit structured JSON and correct exit codes; deterministic and
idempotent.
### Architecture Patterns
- **Deterministic / AI split:** the deterministic scanner gathers objective
signals and a candidate shortlist; the AI pass classifies and distills.
- **Report schema is the linchpin:** the machine report (per-file category,
signals, op, op-type, safety tier, optional exact-edit, token estimate) is the
contract every component consumes. It is designed and frozen first.
- **Operation taxonomy:** each op is tagged op-type (`deterministic` |
`generative`) and safety tier (`auto` | `confirm`). `auto` runs without
prompt; `confirm` (destructive/subjective/generative) escalates for approval.
- **mtime guard:** never apply a cached edit to a file changed since the check.
### Testing Strategy
- Assert external behavior at the highest deterministic seam — given an input
doc tree / state file / report, the script produces correct structured output
and exit code. Do not assert internal class structure.
- The AI classification layer is pinned by **golden examples**
(`examples/golden/`) plus `invariants.md`, per the reversion-protection
pattern — not by unit assertions.
### Git Workflow
- Cleanup requires a clean/committed working tree, or auto-commits a WIP
checkpoint first; each cleanup run lands as a single reviewable commit.
- `confirm`-tier approvals are recorded to a decisions log.
- No pushing or outbound/network actions — cleanup is local commits only.
## Important Constraints
- The `SessionStart` hook spends no AI tokens, never mutates, keeps `timeout`
low (≤5s), and always exits 0 (never blocks the session).
- State and the single most-recent report live under gitignored `.dochygiene/`;
the scanner always self-excludes it.
- Frozen/ignored files (`hygiene: frozen` frontmatter, `.dochygiene-ignore`,
append-only logs) are never flagged.
- Changing any behavioral invariant requires updating `invariants.md` and the
golden examples, with explicit human approval.
## External Dependencies
- Claude Code plugin/hook runtime (`hooks/hooks.json`, `systemMessage` banner).
- A local tokenizer approximation for the token estimator (no API token
counting at check time).

View File

@ -0,0 +1,200 @@
# doc-check Specification
## Purpose
TBD - created by archiving change add-check. Update Purpose after archive.
## Requirements
### Requirement: `/hygiene` Command Surface
The plugin SHALL provide a single `/hygiene` command that dispatches on its
arguments. `/hygiene check [--scope <glob-or-path>] [--category <class|subtype>]`
SHALL invoke the `hygiene-check` skill. `/hygiene status` SHALL read and report the
lifecycle timestamps (`last_check`, `last_clean`, `last_reminded`) and whether a
report exists, using no scan and no model. `/hygiene clean` and `/hygiene sweep`
SHALL be reserved and SHALL report that they are not yet implemented (Phase 4). No
arguments or unknown arguments SHALL print usage plus the current status.
#### Scenario: Check dispatches to the skill
- **WHEN** the user runs `/hygiene check`
- **THEN** the command invokes the `hygiene-check` skill, passing through any `--scope` or `--category` flag
#### Scenario: Status is read-only
- **WHEN** the user runs `/hygiene status`
- **THEN** the command reports `last_check`, `last_clean`, `last_reminded`, and whether a report exists, without running a scan or any model
#### Scenario: Clean and sweep are reserved
- **WHEN** the user runs `/hygiene clean` or `/hygiene sweep`
- **THEN** the command reports that the subcommand is not yet implemented (Phase 4) and does not mutate anything
#### Scenario: Unknown arguments print usage
- **WHEN** the user runs `/hygiene` with no arguments or an unrecognized subcommand
- **THEN** the command prints usage and the current status
### Requirement: Check Skill Orchestrates Scan, Classification, and Report Writing
The `hygiene-check` skill SHALL orchestrate the check pipeline: run the deterministic
scanner, dispatch a Sonnet subagent for judgment-only classification of the
signal-bearing candidates, run the deterministic finalize pass, validate, write the
report pair, and stamp `last_check`. The skill SHALL run all non-judgment steps as
deterministic scripts with no model (invariant #6). Zero-signal shortlisted files
SHALL be treated as presumptively cleared: they SHALL remain in the shortlist,
produce no entries, and SHALL NOT be read by the model. A `--scope` argument SHALL
narrow the scanner; a `--category` argument SHALL filter which entries are produced
after classification; both SHALL be recorded in the human-report header.
#### Scenario: Skill runs the full pipeline
- **WHEN** the `hygiene-check` skill runs
- **THEN** it scans (deterministic), classifies signal-bearing candidates (Sonnet), finalizes (deterministic), validates (deterministic), writes the report pair (deterministic), and stamps `last_check` (deterministic)
#### Scenario: Zero-signal files are not read by the model
- **WHEN** a shortlisted file carries no scanner signals
- **THEN** it remains in the shortlist, produces no entry, and is not read by the classification model
#### Scenario: Scope and category are recorded and applied
- **WHEN** the user passes `--scope docs/**/*.md` and `--category bloat`
- **THEN** the scanner is narrowed by the scope, only `bloat` entries are produced after classification, and both the scope and the category are recorded in the human-report header
### Requirement: Classification Subagent Returns Judgment-Only Proposals
The Sonnet classification subagent SHALL return, per signal-bearing candidate, a slim
proposal containing only judgment fields: `category` (`class` and `subtype` from the
closed enum, justified by cited signals), the scanner `signals` passed through
verbatim with an optional one-line gloss in `detail`, `op` (a human sentence),
`op_type` (`deterministic` or `generative`, a property of the chosen op per invariant
#11), and `confidence`. When `op_type` is `deterministic`, the proposal SHALL carry an
`exact_edit` skeleton (`kind` plus the kind's required sub-fields and `anchor` where
required) and SHALL NOT carry `expected_sha256`, `is_destructive`, `is_reversible`, or
`safety_tier`. When `op_type` is `generative`, the proposal SHALL carry no
`exact_edit` and instead a non-persisted `reducible_range` so the finalize pass can
count `raw_tokens` over the real span. Low-confidence hard distinctions
(stale-vs-bloat; destructive-deletion-vs-generative-rewrite) MAY be escalated to Opus.
#### Scenario: Deterministic proposal carries only the exact-edit skeleton
- **WHEN** the subagent classifies a file as a deterministic op
- **THEN** the proposal includes the `exact_edit` skeleton (`kind`, required sub-fields, `anchor` where required) and omits `expected_sha256`, `is_destructive`, `is_reversible`, and `safety_tier`
#### Scenario: Generative proposal carries a reducible range, not an exact edit
- **WHEN** the subagent classifies a file as a generative op
- **THEN** the proposal carries no `exact_edit` and instead a non-persisted `reducible_range` for the finalize pass to count tokens over
#### Scenario: Signals are passed through verbatim
- **WHEN** the subagent emits a proposal
- **THEN** its `signals` are the scanner's signal names verbatim, with any added wording confined to the optional `detail` gloss
### Requirement: Deterministic Finalize Pass Owns the Non-Model Fields
A standalone, model-free finalize pass (`report_builder.py`) SHALL sit between model
classification and the report write, and SHALL author the four per-entry fields that
the model must not author. For each proposal it SHALL compute
`exact_edit.expected_sha256` over the file's current bytes for anchor-bearing kinds,
SHALL set `(is_destructive, is_reversible)` from `KIND_TABLE[kind]`, SHALL compute
`safety_tier` by calling `derive_safety_tier(op_type, is_destructive, is_reversible)`
imported from `validate_report.py` (the single source of truth, invariant #10), and
SHALL source `token_estimate.raw_tokens` from the local token estimator
(`default_estimator().estimate_for_report(span_text)`, invariant #6). It SHALL stamp
each entry's `generated_at` at that file's hash instant and set the envelope
`generated_at` to the run instant. The model SHALL NOT supply any of these four
fields.
#### Scenario: The finalize pass computes the content hash and derives the tier
- **WHEN** the finalize pass processes a deterministic proposal with `kind` = `move-to-archive`
- **THEN** it computes `expected_sha256` over the file's current bytes, sets `is_destructive` = false and `is_reversible` = true from `KIND_TABLE`, and derives `safety_tier` = `auto` via `derive_safety_tier`
#### Scenario: raw_tokens comes from the local estimator, never the model
- **WHEN** the finalize pass sets a `token_estimate`
- **THEN** `raw_tokens` is the local estimator's count of the span (no model, no API call), with the weighting fields null in v1
#### Scenario: The model cannot author the derived fields
- **WHEN** a proposal arrives from the classification subagent
- **THEN** `expected_sha256`, `safety_tier`, and (for deterministic ops) `is_destructive`/`is_reversible` are absent from the proposal and are authored only by the finalize pass
### Requirement: Validate Before Rollover
The check SHALL validate the assembled report with `validate_report.py` on a scratch
path (not under `.dochygiene/`) and SHALL write the report pair only on validator exit
0. Because `StateStore.write_report` deletes the prior report pair before writing the
new one (invariant #4), validation SHALL NOT run against `.dochygiene/`, so a
validation failure never destroys the last good report. On a validation failure (exit
1) the check SHALL NOT write the report; on an empty shortlist or no signal-bearing
files the check SHALL still write a valid empty-`entries` report and stamp
`last_check`.
#### Scenario: Invalid report is never written
- **WHEN** the assembled report fails validation (exit 1)
- **THEN** the check does not call `write_report`, the prior report pair is preserved, and the offending entries are re-prompted or dropped before re-validating
#### Scenario: Validation runs on a scratch path
- **WHEN** the check validates the assembled report
- **THEN** validation runs against a scratch path outside `.dochygiene/`, so the last good report in `.dochygiene/` is never deleted by a failed run
#### Scenario: Empty shortlist still produces a valid report and stamp
- **WHEN** the scanner returns no signal-bearing files
- **THEN** the check writes a valid report with empty `entries` and stamps `last_check`
### Requirement: Report Pair Is Written and last_check Stamped
On a successful check, the skill SHALL write exactly one machine report
(`.dochygiene/report.json`) and one human report (`.dochygiene/report.md`) via
`StateStore.write_report` (atomic, rollover-bounded to one pair per invariant #4), and
SHALL stamp `last_check` to the same run instant used as the envelope `generated_at`.
The human report SHALL be a deterministic skeleton grouping entries by Stale, Bloat,
and Cleared with per-entry path, category, op, tier, token count, and signal, and a
header showing the timestamp, scope, files scanned, and candidate/cleared counts; only
an optional per-entry "why" gloss MAY be model-written.
#### Scenario: One report pair survives the write
- **WHEN** the check completes successfully
- **THEN** exactly one `report.json` and one `report.md` exist in `.dochygiene/`, and any prior pair has been rolled over
#### Scenario: last_check matches the envelope timestamp
- **WHEN** the check writes the report
- **THEN** `last_check` is stamped to the same run instant recorded as the envelope `generated_at`
### Requirement: Classifier Golden Examples Are Hermetic and Human-Gated
Classifier golden examples SHALL live under `examples/golden/classifier/<n>-<name>/`
(an `input/` fixture tree with stable hashes, an `expected.json` schema-valid report,
and an optional `notes.md`), distinct from the schema-shape fixtures
(`valid_report.json` / `invalid_report.json`). The golden unit harness SHALL be
hermetic — it SHALL NOT call a live model — and SHALL assert only deterministic,
stable parts: that the scanner emits the expected signals on the right paths, that
each `expected.json` validates (exit 0), and that the stable fields (`category.class`,
`category.subtype`, `op_type`, derived `safety_tier`, `exact_edit.kind`) match a
captured/committed check output. Op-prose and exact anchor line numbers SHALL be
advisory (flagged for review, not hard-failed). The live model-classification
regression SHALL be a separate, manually or agent-invoked harness, not part of the
unit suite. Adding or changing classifier goldens SHALL be human-gated per the
META-RULE.
#### Scenario: Golden harness makes no live model call
- **WHEN** the classifier golden unit tests run
- **THEN** they assert scanner signals, `expected.json` validity, and stable-field matches against a committed capture, with no live model invocation
#### Scenario: Classifier goldens are distinct from schema fixtures
- **WHEN** a contributor looks for the schema-shape fixtures versus the classifier goldens
- **THEN** the schema fixtures (`valid_report.json` / `invalid_report.json`) and the classifier goldens (`examples/golden/classifier/`) are separate, and `examples/golden/CONTEXT.md` documents the distinction
#### Scenario: Changing a golden requires human approval
- **WHEN** a contributor adds or changes a classifier golden example
- **THEN** the change is gated on explicit human approval per the META-RULE before it takes effect

View File

@ -0,0 +1,249 @@
# doc-clean Specification
## Purpose
TBD - created by archiving change add-clean. Update Purpose after archive.
## 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` 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
`/hygiene sweep` convenience path SHALL NOT bypass invariant #7.
#### 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 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 /hygiene sweep and the report contains confirm-tier entries
- **THEN** the confirm gate runs identically to a standalone /hygiene clean
### 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.
Staging SHALL be precise: for non-move 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. The skill
SHALL NEVER use `git add -A` or `git add .`. If a hard failure occurs (applier
exit 2, `git mv` 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). `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 /hygiene clean with a clean git tree
- **THEN** the run produces exactly one git commit containing all applied edits
#### Scenario: Dirty tree gets a WIP checkpoint then one cleanup commit
- **WHEN** the user runs /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 or applier exit 2 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
### Requirement: Clean Skill Orchestration
The `hygiene-clean` skill SHALL load the current report via `StateStore.read_report`
(if none, it SHALL tell the user to run `/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 /hygiene clean and no report exists in .dochygiene/
- **THEN** the skill tells the user to run /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 at Command Level
`/hygiene sweep` SHALL invoke the `hygiene-check` skill followed by the
`hygiene-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 `commands/hygiene.md`,
not in a third skill or by having the clean skill invoke the check skill internally.
#### Scenario: Sweep routes through the command, not a third skill
- **WHEN** the user runs /hygiene sweep
- **THEN** commands/hygiene.md invokes hygiene-check then hygiene-clean sequentially, passing shared flags
#### Scenario: Sweep produces at most one cleanup commit
- **WHEN** the user runs /hygiene sweep on a clean tree
- **THEN** at most one git commit is produced (the cleanup commit from clean); check writes nothing to git

View File

@ -0,0 +1,251 @@
# Spec: report-schema
## Purpose
Defines the machine report schema — the frozen contract that the `check` skill
produces and every downstream component (`clean`, `sweep`, token-estimator,
schema-validator) consumes. All field shapes, enum values, and derivation
semantics are fixed here; any change requires updating `invariants.md` with
explicit human approval.
## Requirements
### Requirement: Top-Level Report Envelope
The machine report SHALL be a single JSON object containing `schema_version`,
`tool_version`, `generated_at` (ISO-8601 UTC), a `scan` metadata object, a
`shortlist` array, and an `entries` array. The `scan` object SHALL include
`project_root`, `scope_globs`, `excluded_dirs`, and `files_scanned`. The
`generated_at` timestamp SHALL be the check time that the clean step uses for
its mtime guard.
#### Scenario: Check writes a well-formed report
- **WHEN** the `check` skill completes a scan and classification pass
- **THEN** it writes one JSON object with `schema_version`, `tool_version`, `generated_at`, `scan`, `shortlist`, and `entries`
#### Scenario: Clean reads the check timestamp
- **WHEN** the `clean` skill loads a report
- **THEN** it reads `generated_at` and uses it as the reference time for the per-op mtime guard
### Requirement: Shortlist Precedes Entries
The `shortlist` SHALL contain the project-root-relative paths the deterministic
scanner surfaced as candidates. Every `entries[].path` SHALL be a member of
`shortlist`. The scanner produces `shortlist`; the AI pass produces `entries`.
#### Scenario: Entry path is a shortlist member
- **WHEN** the AI pass adds an entry for a file
- **THEN** that file's path already appears in `shortlist`
#### Scenario: A shortlisted file may be cleared
- **WHEN** the AI pass judges a shortlisted file as not stale and not bloated
- **THEN** the path remains in `shortlist` but produces no entry in `entries`
### Requirement: Per-File Entry Fields
Each entry SHALL contain `path`, `category`, `signals`, `op`, `op_type`,
`is_destructive`, `is_reversible`, `safety_tier`, and `token_estimate`. The
`op_type` SHALL be one of `deterministic` or `generative`. The `is_destructive`
and `is_reversible` fields SHALL be booleans that objectively characterize the
chosen `op`. An op SHALL be considered **destructive if and only if it removes or
overwrites information that is not preserved elsewhere in the repository**;
text-level line removal is not by itself destructive when the information
survives (for example a `dedupe` whose removed span is preserved verbatim at
`canonical_ref` is non-destructive). The `safety_tier` SHALL be one of `auto` or `confirm` and SHALL be
derived (see the Op-Type and Safety-Tier Semantics requirement), not assigned by
the model. The `signals` field SHALL list the objective scanner facts that
support the classification.
#### Scenario: Entry carries the full classification
- **WHEN** the AI pass classifies a candidate file
- **THEN** the entry includes `path`, `category`, `signals`, `op`, `op_type`, `is_destructive`, `is_reversible`, `safety_tier`, and `token_estimate`
#### Scenario: Op-type and safety-tier are constrained enums
- **WHEN** an entry is written
- **THEN** `op_type` is `deterministic` or `generative` and `safety_tier` is `auto` or `confirm`
#### Scenario: Op characterization inputs are present for derivation
- **WHEN** an entry is written
- **THEN** `is_destructive` and `is_reversible` are present as booleans so `safety_tier` can be computed deterministically from them
### Requirement: Category Taxonomy Is a Closed Enum
The `category` field SHALL be an object with `class` and `subtype`. The `class`
SHALL be `stale` or `bloat`. When `class` is `stale`, `subtype` SHALL be one of
`contradicted`, `orphaned`, `superseded`, `provisional`, `completed-in-place`,
or `duplicated`. When `class` is `bloat`, `subtype` SHALL be one of `distill`,
`split`, or `freeze`. No other classes or subtypes are permitted.
#### Scenario: Stale file is categorized with a stale subtype
- **WHEN** a doc references a file that no longer exists
- **THEN** its entry has `category.class` = `stale` and `category.subtype` = `orphaned`
#### Scenario: Bloated file is categorized with a bloat subtype
- **WHEN** a doc is a long resolved-problem narrative that should be condensed
- **THEN** its entry has `category.class` = `bloat` and `category.subtype` = `distill`
#### Scenario: Unknown subtype is rejected
- **WHEN** a report contains a `category.subtype` outside the closed enum
- **THEN** the report is invalid
### Requirement: Op-Type Is a Property of the Chosen Op
`op_type` SHALL describe the operation the classifier selected and SHALL be
consistent with it: it SHALL NOT be a free field, and it SHALL NOT be looked up
from `category.subtype` (a single subtype may map to either a deterministic or a
generative op depending on the chosen `op`). An entry SHALL include `exact_edit`
when, and only when, `op_type` is `deterministic`. An entry with `op_type` =
`generative` SHALL NOT include `exact_edit`. This biconditional SHALL be
validated deterministically; an entry that violates it is invalid.
#### Scenario: Same subtype admits either op-type
- **WHEN** a `contradicted` block is recommended for deterministic deletion in one entry and generative rewrite in another
- **THEN** both entries are valid, each with `op_type` consistent with its chosen `op` rather than derived from the shared subtype
#### Scenario: Op-type and exact-edit consistency is validated
- **WHEN** an entry has `op_type` = `generative` but also carries an `exact_edit` (or `op_type` = `deterministic` but omits `exact_edit`)
- **THEN** the entry is invalid
### Requirement: Safety-Tier Is Derived Deterministically
`deterministic` ops SHALL be exact edits the check pre-computes and the cleaner
applies with no model. `generative` ops SHALL be prose transformations requiring
a model at clean time. The `safety_tier` SHALL be **computed** by a deterministic
script function `safety_tier(op_type, is_destructive, is_reversible)` and SHALL
NOT be assigned by the model; the report records the computed value. The function
SHALL return `confirm` when `op_type` is `generative`, OR when `is_destructive`
is true, OR when `is_reversible` is false; and SHALL return `auto` only when the
op is `deterministic` AND non-destructive AND reversible (hence objective). The
function SHALL NEVER return `auto` for a `generative` op or for any destructive
or irreversible op, so the model cannot violate invariant #7. `auto`-tier ops
SHALL run without a prompt; `confirm`-tier ops SHALL be escalated for approval.
#### Scenario: Deterministic reversible op derives to auto
- **WHEN** an entry has `op_type` = `deterministic`, `is_destructive` = false, and `is_reversible` = true
- **THEN** the derived `safety_tier` is `auto` and the cleaner applies the `exact_edit` mechanically without a prompt
#### Scenario: Destructive op derives to confirm
- **WHEN** an op removes information not preserved elsewhere (`is_destructive` = true, e.g. a `delete-range`)
- **THEN** the derived `safety_tier` is `confirm` regardless of `op_type`
#### Scenario: Generative op derives to confirm
- **WHEN** an entry has `op_type` = `generative`
- **THEN** the derived `safety_tier` is `confirm` and the op is delegated to a Sonnet subagent at clean time
#### Scenario: Function can never emit auto for a generative or destructive op
- **WHEN** `safety_tier(op_type, is_destructive, is_reversible)` is evaluated for any input where `op_type` = `generative`, or `is_destructive` = true, or `is_reversible` = false
- **THEN** the result is `confirm`, never `auto`
### Requirement: Exact-Edit Presence Tied to Op-Type
An entry SHALL include an `exact_edit` object when, and only when, `op_type` is
`deterministic`. The `exact_edit` SHALL be mechanically applicable and SHALL
carry a content fingerprint (`expected_sha256`) so the cleaner's mtime guard can
refuse to apply it to a file changed since `generated_at`. Entries with
`op_type` = `generative` SHALL omit `exact_edit`.
#### Scenario: Deterministic entry includes exact edit
- **WHEN** an entry has `op_type` = `deterministic`
- **THEN** it includes an `exact_edit` with the edit operation and `expected_sha256`
#### Scenario: Generative entry omits exact edit
- **WHEN** an entry has `op_type` = `generative`
- **THEN** it has no `exact_edit` field and the edit is produced at clean time
#### Scenario: mtime guard refuses a stale edit
- **WHEN** a file's current content hash differs from the entry's `expected_sha256`
- **THEN** the cleaner skips the cached edit and recommends re-analysis
### Requirement: Exact-Edit Kind Is a Closed Enum
Every `exact_edit` SHALL carry a `kind` drawn from the closed set
`delete-range`, `move-to-archive`, `insert-frontmatter`, `replace-text`, and
`dedupe` — one per deterministic op family the PRD names. No other `kind` is
permitted. Each `kind` SHALL carry its required sub-fields and SHALL have a fixed
inherent `(is_destructive, is_reversible)` characterization that feeds the
`safety_tier` derivation:
- `delete-range` SHALL carry `anchor` with `start_line` and `end_line`; it is
destructive and irreversible (derives to `confirm`).
- `move-to-archive` SHALL carry `anchor` (`start_line`, `end_line`) and
`dest_path`; it is non-destructive and reversible (derives to `auto`).
- `insert-frontmatter` SHALL carry the frontmatter `key` and `value` to inject
(for example `hygiene: frozen`); it is non-destructive and reversible (derives
to `auto`).
- `replace-text` SHALL carry `anchor` (`start_line`, `end_line`), a `match`
string, and a `replacement` string; it is non-destructive and reversible
(derives to `auto`).
- `dedupe` SHALL carry `anchor` (`start_line`, `end_line`) of the removed
duplicate span and a `canonical_ref` to the kept location; because the removed
span is an exact duplicate preserved verbatim at `canonical_ref`, no information
is lost, so it is non-destructive and reversible and derives to `auto`. (Contrast
`delete-range`, which removes content kept nowhere else, is destructive, and
derives to `confirm`.)
A validator SHALL reject an `exact_edit` whose `kind` is outside the closed set
or that omits a required sub-field for its `kind`.
#### Scenario: Each kind carries its required fields
- **WHEN** an entry has `op_type` = `deterministic` with an `exact_edit` of `kind` = `move-to-archive`
- **THEN** the `exact_edit` includes `anchor` (`start_line`, `end_line`) and `dest_path`, and the entry is valid
#### Scenario: Unknown kind is rejected
- **WHEN** an `exact_edit` has a `kind` outside the closed set `delete-range`, `move-to-archive`, `insert-frontmatter`, `replace-text`, `dedupe`
- **THEN** the report is invalid
#### Scenario: Missing required sub-field is rejected
- **WHEN** an `exact_edit` of `kind` = `replace-text` omits its `match` or `replacement` field
- **THEN** the report is invalid
#### Scenario: Kind characterization feeds the safety-tier derivation
- **WHEN** an `exact_edit` has `kind` = `delete-range`
- **THEN** the entry's `is_destructive` is true and `is_reversible` is false, so the derived `safety_tier` is `confirm`
### Requirement: Per-Entry Token Estimate
Each entry SHALL include a `token_estimate`. In v1, only `raw_tokens` (a
local-tokenizer count of the removed or reduced span, with no API call) SHALL be
required. The `injection_frequency` (`per-session` or `on-demand`) and
`weighted_tokens` (`raw_tokens` adjusted by injection frequency) fields SHALL be
optional and MAY be `null` or omitted in v1; populating them (injection-frequency
weighting plus bottom-up rollup) is the v2 bonus per the PRD build order. When
populated, auto-injected files SHALL be weighted as real per-session savings and
on-demand docs SHALL be weighted as theoretical-max savings. Roll-up to category
and total SHALL be computed bottom-up from the entries.
#### Scenario: v1 entry carries only the required raw token count
- **WHEN** a v1 check writes an entry without the weighting fields
- **THEN** `token_estimate.raw_tokens` is present (a local-tokenizer count, no API call) and `injection_frequency` and `weighted_tokens` may be `null` or omitted
#### Scenario: Auto-injected file weighted per session
- **WHEN** the weighting fields are populated (v2) and the affected file is auto-injected (for example `CLAUDE.md`)
- **THEN** `injection_frequency` = `per-session` and `weighted_tokens` reflects real per-session savings
#### Scenario: On-demand doc weighted as theoretical max
- **WHEN** the weighting fields are populated (v2) and the affected file is read only on demand
- **THEN** `injection_frequency` = `on-demand` and `weighted_tokens` reflects theoretical-max savings
### Requirement: Schema Is a Frozen Contract
The report schema SHALL be treated as a frozen contract that every other
component consumes. The freeze SHALL be enforced by `invariants.md` (which
carries the schema-freeze and `safety_tier` derivation invariants); that file is
the authoritative enforcement mechanism for this contract. Any change to a field,
an enum value, or a documented semantic SHALL require updating `invariants.md`
with explicit human approval before it takes effect. Schema-shape verification
SHALL be carried by a schema-validator script and hand-authored **schema
fixtures** (one valid machine report, one invalid) under `examples/golden/`;
these schema-shape fixtures are distinct from classifier golden examples, which
are deferred until the `check` change exists.
#### Scenario: Schema change requires invariants update
- **WHEN** a contributor proposes adding, renaming, or removing a field or enum value
- **THEN** the change is accompanied by an `invariants.md` update with human approval before it takes effect
#### Scenario: Validator accepts a well-formed report and rejects a malformed one
- **WHEN** the schema-validator script runs against the valid and invalid schema fixtures under `examples/golden/`
- **THEN** it accepts the valid machine report and rejects the invalid one
#### Scenario: Components rely on the frozen shape
- **WHEN** the `clean`, `sweep`, or token-estimator components are built
- **THEN** they consume the report without re-deriving its structure

View File

@ -0,0 +1,22 @@
# scripts/
Deterministic Python scripts for the doc-hygiene plugin. All scripts are
model-free (no API calls), produce structured JSON output, and exit with
meaningful codes. Each script is a small, single-responsibility class designed
for isolated testing with injected clocks/filesystems.
## Contents
| File | Purpose |
|------|---------|
| `scanner.py` | Deterministic doc scanner. Walks scoped files under the resolved project root, applies the ordered short-circuiting exclusion pipeline (dir-prune incl. `.dochygiene/` self-exclusion → `.dochygiene-ignore` match → `hygiene: frozen` frontmatter → append-only-log detection; invariant #9), and computes objective per-path signals (broken refs, version skew, edit-recency vs git churn, location, archive ratio, frontmatter markers). Emits the **intermediate artifact** `{project_root, scope_globs, excluded_dirs, files_scanned, shortlist, signals}` — NOT a machine report (no `entries`/`category`/`op`/`token_estimate`). No model. Injected git-log / clock for testability. **Default excludes** (echoed verbatim in `excluded_dirs`): bare names — `build`, `vendor`, `archive`, `graphify-out`, `.dochygiene` (invariant self-exclusion), `fixtures` — are matched on the directory *name* segment at any depth (any dir named `archive` is pruned, not just `docs/archive`); plus one **path-aware** entry `examples/golden` (any entry containing `/` is a `parent/child` name pair, pruned only where the child's immediate parent matches `parent`, depth-independent). `fixtures` + `examples/golden` prevent false-positive flagging of test-fixture trees and classifier golden inputs (deliberately-stale docs). `golden` is deliberately path-aware — a bare `golden` name-match would wrongly skip legitimate `golden/` dirs in unrelated projects (doc-hygiene installs globally). The golden-test harness is unaffected: it roots the scanner at `…/examples/golden/classifier/N/input/`, so the `examples→golden` pair sits in the ancestry above the walk root and is never re-encountered as a child. |
| `state_store.py` | State store. `resolve_project_root(start_dir, fs)` (pure: git root, fallback cwd) plus `StateStore` confining all writes to `<root>/.dochygiene/` (invariant #3, never edits `.gitignore`). Lifecycle timestamps (`last_check`/`last_clean`/`last_reminded`), atomic write-temp→fsync→`os.replace` (D9), and report rollover keeping exactly one `.json`+`.md` pair (invariant #4). Injected clock. |
| `reminder.py` | `SessionStart` reminder. Pure `Reminder.decide(last_check, last_reminded, now)` decision (stale-and-not-snoozed ⇒ banner; calendar-day snooze keyed on `last_reminded`, invariants #1/#2). `ReminderRunner` wires it to the `StateStore`, emits a zero-token `systemMessage` JSON banner on stdout, and writes `last_reminded` as the *only* mutation, only on banner. Treats missing/corrupt state as never-checked (stale); always exits 0 — never blocks the session. **No-ops unless cwd is inside a real git project** (`_is_git_project` guard) so the user-scoped hook never creates `.dochygiene/` in an arbitrary dir like `~` (invariant #3) — flagged for human review. No scan, no model. `DEFAULT_THRESHOLD_DAYS` is an implementation constant, not a spec'd contract. Invoked by `hooks/hooks.json` via `${CLAUDE_PLUGIN_ROOT}/scripts/reminder.py`. |
| `validate_report.py` | Schema-validator for machine-report JSON files. Enforces the frozen report contract: envelope fields, closed category enum, closed `exact_edit.kind` enum with per-kind required sub-fields, `op_type`↔`exact_edit` biconditional (invariant #11), and `safety_tier` derivation match (invariant #10). Collects all violations. Exit 0 = valid, exit 1 = invalid, exit 2 = usage error. |
| `report_builder.py` | Deterministic finalize pass (no model). Consumes the scanner artifact + the subagent's slim proposals and assembles a schema-valid machine report plus a human-report skeleton. Fills the four guardrail fields the model must not author: computes each entry's `expected_sha256` over the live file, looks up `is_destructive`/`is_reversible` from the `KIND_TABLE` (keyed on `exact_edit.kind`), derives `safety_tier` via `validate_report`'s `derive_safety_tier` (invariant #10 — single source of truth), and sources `raw_tokens` from `token_estimator` (over the `reducible_range`/anchor text). Rejects malformed proposals with a structured stderr error (`{index, field, message}`, exit 1); `--out-json`/`--out-md` write files and suppress the stdout bundle. An empty proposals array yields a valid empty-entries report. |
| `token_estimator.py` | Deterministic local token estimator (no model, no network — invariant #6). Produces the `token_estimate.raw_tokens` count for report entries. Pluggable `TokenEstimator` ABC with two backends: `HeuristicEstimator` (ALWAYS available, zero deps, `ceil(len/4)`) and `TiktokenEstimator` (optional accuracy upgrade, `o200k_base`, `disallowed_special=()`). `default_estimator(cache_dir=…)` selects tiktoken **only** when it is importable AND the vocab is vendored locally (detect-before-fetch: checks the sha1-named cache file exists before loading, so a cold cache never triggers a network download); otherwise returns the heuristic. Never raises; active backend is introspectable via `.name`. `estimate(text)→int`, `estimate_file(path)→int` (utf-8, `errors="replace"`), `estimate_for_report(text)→{raw_tokens, injection_frequency:null, weighted_tokens:null}` (v1 emits only `raw_tokens`; weighting is the v2 bonus). CLI: `python token_estimator.py <path>` prints JSON `{path, backend, token_estimate}`, exit 1 on missing file. **tiktoken vocab is NOT committed** (~2 MB; gitignored at `scripts/tiktoken_cache/`). Pre-warm once with: `TIKTOKEN_CACHE_DIR=scripts/tiktoken_cache python -c "import tiktoken; tiktoken.get_encoding('o200k_base')"`. **Cache-filename gotcha:** tiktoken names the cache file `sha1(blobpath_url)` = `fb374d419588a4632f3f557e76b4b70aebbca790` (NOT the sha256 content hash); a vendored file under any other name never hits and falls back to network. |
| `patch_applier.py` | Deterministic patch-applier: consumes the machine report's deterministic entries, applies each `exact_edit.kind` mechanically via a per-file transaction (read-once → verify whole-file sha256 against `expected_sha256` → apply edits descending by line, insert-frontmatter last → write once), skips files on content-guard mismatch or incompatible-op combinations, uses `git mv` for move-to-archive; structured JSON output, no model. CLI: `python patch_applier.py --report <path> --apply-indices <idx,idx,...>` emits `{applied, skipped, failed, staged_paths}` to stdout, exit 0 (partial ok), exit 1 (some applied + skipped), exit 2 (hard failure — rollback recommended). Invariant #8 (mtime guard). |
## Planned additions (future changes)
- token-estimator v2 — injection-frequency weighting + bottom-up rollup (`weighted_tokens`)

View File

@ -0,0 +1,700 @@
#!/usr/bin/env python3
"""
patch_applier.py deterministic patch applier for doc-hygiene clean skill.
Consumes a filtered list of DETERMINISTIC, APPROVED report entries and applies
them to the project files. The applier NEVER sees generative entries and NEVER
makes approval decisions those gates belong to the skill.
Safety invariants honoured:
#6 No model — purely mechanical transformations.
#7 Safety-tier values come from KIND_TABLE, not entries.
#8 mtime guard — sha256 of whole-file bytes verified before any write;
content changed since check skip entire file, continue others.
Per-file transaction read once / verify / apply descending / write once.
Incompatible-ops detection move-to-archive + anything else, or overlapping
anchors skip whole file, structured report.
CLI:
python patch_applier.py --report <path> --apply-indices 0,2,3 \\
[--project-root <path>]
--report Path to the machine-report JSON produced by report_builder.
--apply-indices Comma-separated 0-based indices into report.entries[] to
apply. The skill is responsible for filtering to only
deterministic, approved indices before calling here.
--project-root Override project root (default: report.scan.project_root).
Exit codes:
0 all attempted entries applied successfully.
1 partial: at least one entry was skipped or failed (check output JSON).
2 usage / internal error (malformed args, missing report file, bad JSON).
Output (stdout, JSON):
{
"applied": [{path, kind, entry_index}],
"skipped": [{path, kind, entry_index, reason, recommend}],
"failed": [{path, kind, entry_index, error}],
"staged_paths": [<paths the caller should git-add; move-to-archive already
staged by git mv>]
}
Git responsibility split:
- move-to-archive: applier runs `git mv src dest` (stages both sides).
dest_path is included in staged_paths as an informational record.
- all other kinds: applier writes the file; the skill does `git add <path>`
using staged_paths to know exactly which files to stage.
"""
from __future__ import annotations
import argparse
import hashlib
import json
import os
import subprocess
import sys
import tempfile
from pathlib import Path
from typing import Any, Optional, Protocol
_SCRIPTS_DIR = Path(__file__).resolve().parent
if str(_SCRIPTS_DIR) not in sys.path:
sys.path.insert(0, str(_SCRIPTS_DIR))
from validate_report import KIND_TABLE # noqa: E402
# ---------------------------------------------------------------------------
# Injectable seams
# ---------------------------------------------------------------------------
class _FileSystem(Protocol):
"""Minimal FS surface used by PatchApplier — injectable for tests."""
def read_bytes(self, path: Path) -> bytes: ...
def write_bytes(self, path: Path, data: bytes) -> None: ...
def exists(self, path: Path) -> bool: ...
def mkdir(self, path: Path) -> None: ...
class RealFileSystem:
"""Production filesystem — delegates directly to pathlib."""
def read_bytes(self, path: Path) -> bytes:
return Path(path).read_bytes()
def write_bytes(self, path: Path, data: bytes) -> None:
# Atomic write: write to sibling temp, fsync, os.replace.
parent = Path(path).parent
fd, tmp = tempfile.mkstemp(dir=parent)
try:
os.write(fd, data)
os.fsync(fd)
finally:
os.close(fd)
os.replace(tmp, path)
def exists(self, path: Path) -> bool:
return Path(path).exists()
def mkdir(self, path: Path) -> None:
Path(path).mkdir(parents=True, exist_ok=True)
class _Git(Protocol):
"""Git surface — injectable for tests."""
def mv(self, src: Path, dest: Path, cwd: Path) -> None: ...
class RealGit:
"""Production git — runs `git mv` via subprocess."""
def mv(self, src: Path, dest: Path, cwd: Path) -> None:
result = subprocess.run(
["git", "mv", str(src), str(dest)],
cwd=str(cwd),
capture_output=True,
text=True,
)
if result.returncode != 0:
raise RuntimeError(
f"git mv failed ({result.returncode}): {result.stderr.strip()}"
)
# ---------------------------------------------------------------------------
# Result records
# ---------------------------------------------------------------------------
def _applied(path: str, kind: str, entry_index: int) -> dict:
return {"path": path, "kind": kind, "entry_index": entry_index}
def _skipped(path: str, kind: str, entry_index: int, reason: str, recommend: str) -> dict:
return {
"path": path,
"kind": kind,
"entry_index": entry_index,
"reason": reason,
"recommend": recommend,
}
def _failed(path: str, kind: str, entry_index: int, error: str) -> dict:
return {
"path": path,
"kind": kind,
"entry_index": entry_index,
"error": error,
}
# ---------------------------------------------------------------------------
# Frontmatter helpers
# ---------------------------------------------------------------------------
def _parse_frontmatter(text: str) -> tuple[dict, int]:
"""
Parse YAML frontmatter from *text*.
Returns (kv_dict, end_line) where end_line is the 0-based line index
AFTER the closing '---' (i.e. the first non-frontmatter line index).
Returns ({}, 0) if no frontmatter is present.
"""
lines = text.splitlines(keepends=True)
if not lines or lines[0].rstrip() != "---":
return {}, 0
kv: dict[str, str] = {}
for i in range(1, len(lines)):
line = lines[i].rstrip()
if line == "---":
return kv, i + 1
if ":" in line:
k, _, v = line.partition(":")
kv[k.strip()] = v.strip()
# Unclosed frontmatter — treat as absent.
return {}, 0
def _insert_frontmatter_key(text: str, key: str, value: str) -> str:
"""
Insert ``key: value`` into the YAML frontmatter of *text*.
- If frontmatter exists: insert the key as the LAST entry inside the block.
- If no frontmatter: create a minimal ``---\\nkey: value\\n---`` block at top.
"""
lines = text.splitlines(keepends=True)
if lines and lines[0].rstrip() == "---":
# Find closing '---'.
for i in range(1, len(lines)):
if lines[i].rstrip() == "---":
new_line = f"{key}: {value}\n"
lines.insert(i, new_line)
return "".join(lines)
# Unclosed — append key before treating remainder as body.
# Append inside the unclosed block (at end).
lines.append(f"{key}: {value}\n")
return "".join(lines)
else:
header = f"---\n{key}: {value}\n---\n"
return header + text
# ---------------------------------------------------------------------------
# Core line-manipulation helpers (1-based inclusive)
# ---------------------------------------------------------------------------
def _delete_lines(lines: list[str], start: int, end: int) -> list[str]:
"""Remove inclusive 1-based line range [start, end] from *lines*."""
s = max(0, start - 1)
e = min(len(lines), end)
return lines[:s] + lines[e:]
def _replace_in_span(
lines: list[str], start: int, end: int, match: str, replacement: str
) -> tuple[list[str], bool]:
"""
Replace the FIRST occurrence of *match* in the inclusive 1-based span.
Returns (new_lines, found) where found=False means the match was absent
(treated as no-op success by the caller).
"""
s = max(0, start - 1)
e = min(len(lines), end)
found = False
for i in range(s, e):
if match in lines[i]:
lines[i] = lines[i].replace(match, replacement, 1)
found = True
break
return lines, found
def _ranges_overlap(a_start: int, a_end: int, b_start: int, b_end: int) -> bool:
"""Return True if two inclusive 1-based ranges overlap."""
return a_start <= b_end and b_start <= a_end
# ---------------------------------------------------------------------------
# PatchApplier
# ---------------------------------------------------------------------------
class PatchApplier:
"""Apply a batch of deterministic report entries to project files.
Parameters
----------
project_root:
Absolute path to the project root. All entry paths are relative to it.
fs:
Injected filesystem. Defaults to RealFileSystem().
git:
Injected git runner. Defaults to RealGit().
"""
def __init__(
self,
project_root: Path,
fs: Optional[_FileSystem] = None,
git: Optional[_Git] = None,
) -> None:
self._root = Path(project_root)
self._fs = fs or RealFileSystem()
self._git = git or RealGit()
# ------------------------------------------------------------------
# Public API
# ------------------------------------------------------------------
def apply(self, entries: list[dict]) -> dict:
"""Apply *entries* (already filtered to deterministic, approved).
Entry indices in output records are 0-based positions within *entries*.
Use apply_indexed() when original report indices must be preserved.
Returns a result dict with keys: applied, skipped, failed, staged_paths.
"""
indexed = list(enumerate(entries))
return self.apply_indexed(indexed)
def apply_indexed(self, indexed_entries: list[tuple[int, dict]]) -> dict:
"""Apply entries given as (original_report_index, entry) pairs.
This preserves provenance: output records carry the original report
index rather than a position in the caller's filtered list.
Returns a result dict with keys: applied, skipped, failed, staged_paths.
"""
applied: list[dict] = []
skipped: list[dict] = []
failed: list[dict] = []
staged_paths: list[str] = []
# Group by path (preserving original indices).
by_path: dict[str, list[tuple[int, dict]]] = {}
for idx, entry in indexed_entries:
path = entry.get("path", "")
by_path.setdefault(path, []).append((idx, entry))
for path, batch in by_path.items():
a, sk, fa, sp = self._apply_file_batch(path, batch)
applied.extend(a)
skipped.extend(sk)
failed.extend(fa)
staged_paths.extend(sp)
return {
"applied": applied,
"skipped": skipped,
"failed": failed,
"staged_paths": staged_paths,
}
# ------------------------------------------------------------------
# Per-file transaction
# ------------------------------------------------------------------
def _apply_file_batch(
self, path: str, batch: list[tuple[int, dict]]
) -> tuple[list, list, list, list]:
"""Process all entries for a single file as ONE atomic transaction."""
applied: list[dict] = []
skipped_out: list[dict] = []
failed_out: list[dict] = []
staged: list[str] = []
abs_path = self._root / path
# Step 0: Validate all entries have valid / deterministic kinds.
# Check generative FIRST (no exact_edit, so kind lookup would be empty).
# Collect per-entry failures; any failure skips the WHOLE file (all
# entries reported), so valid siblings are never silently dropped.
step0_failures: list[dict] = []
for idx, entry in batch:
if entry.get("op_type") == "generative":
step0_failures.append(_skipped(
path, "(generative)", idx,
"generative-entry-rejected",
"Applier only processes deterministic entries.",
))
continue
ee = entry.get("exact_edit", {})
kind = ee.get("kind", "")
if kind not in KIND_TABLE:
step0_failures.append(_skipped(
path, kind or "(unknown)", idx,
"unknown-kind",
"Remove this entry or correct the kind field.",
))
if step0_failures:
# Report the bad entries with their specific reasons.
# Report valid siblings as skipped too (batch-aborted reason) so
# the skill knows no entry on this file was applied.
bad_indices = {s["entry_index"] for s in step0_failures}
skipped_out.extend(step0_failures)
for idx, entry in batch:
if idx not in bad_indices:
ee = entry.get("exact_edit", {})
kind = ee.get("kind", "(unknown)")
skipped_out.append(_skipped(
path, kind, idx,
"batch-aborted-due-to-invalid-sibling",
"Fix or remove the invalid entry in this batch and re-apply.",
))
return applied, skipped_out, failed_out, staged
# Step 1: Incompatible-ops detection (structural — before any IO).
incompatibility = self._check_incompatible_ops(path, batch)
if incompatibility:
reason, recommend = incompatibility
for idx, entry in batch:
kind = entry["exact_edit"]["kind"]
skipped_out.append(_skipped(path, kind, idx, reason, recommend))
return applied, skipped_out, failed_out, staged
# Separate anchored and anchorless entries.
anchored = [(i, e) for i, e in batch if KIND_TABLE[e["exact_edit"]["kind"]].has_anchor]
anchorless = [(i, e) for i, e in batch if not KIND_TABLE[e["exact_edit"]["kind"]].has_anchor]
# --- move-to-archive (special: git mv, no in-memory editing) ---
move_entries = [(i, e) for i, e in anchored if e["exact_edit"]["kind"] == "move-to-archive"]
if move_entries:
# Incompatibility detection already ensured only one entry for this file
# if move-to-archive is present. There should be exactly one.
idx, entry = move_entries[0]
ee = entry["exact_edit"]
dest_rel = ee["dest_path"]
dest_abs = self._root / dest_rel
# Idempotency: source already gone + dest exists → already done.
if not self._fs.exists(abs_path) and self._fs.exists(dest_abs):
applied.append(_applied(path, "move-to-archive", idx))
staged.append(dest_rel)
return applied, skipped_out, failed_out, staged
# Verify hash before moving.
try:
file_bytes = self._fs.read_bytes(abs_path)
except OSError as exc:
failed_out.append(_failed(path, "move-to-archive", idx, str(exc)))
return applied, skipped_out, failed_out, staged
expected = ee.get("expected_sha256", "")
actual = hashlib.sha256(file_bytes).hexdigest()
if actual != expected:
skipped_out.append(_skipped(
path, "move-to-archive", idx,
"content-changed-since-check",
"Re-run hygiene check to refresh the report.",
))
return applied, skipped_out, failed_out, staged
# Create destination directory.
try:
self._fs.mkdir(dest_abs.parent)
self._git.mv(abs_path, dest_abs, self._root)
except Exception as exc: # noqa: BLE001
failed_out.append(_failed(path, "move-to-archive", idx, str(exc)))
return applied, skipped_out, failed_out, staged
applied.append(_applied(path, "move-to-archive", idx))
staged.append(dest_rel)
return applied, skipped_out, failed_out, staged
# --- non-move anchored entries ---
# Step 2: Read file once.
try:
file_bytes = self._fs.read_bytes(abs_path)
except OSError as exc:
for idx, entry in batch:
kind = entry["exact_edit"]["kind"]
failed_out.append(_failed(path, kind, idx, str(exc)))
return applied, skipped_out, failed_out, staged
# Step 3: Verify sha256 for EVERY anchored entry against the current
# whole-file bytes. All entries from the same check run share the
# same hash, but entries constructed from different runs may carry
# different hashes — verify each one and reject any mismatch.
if anchored:
actual_sha = hashlib.sha256(file_bytes).hexdigest()
mismatched = [
(idx, e) for idx, e in anchored
if e["exact_edit"].get("expected_sha256", "") != actual_sha
]
if mismatched:
for idx, entry in batch:
kind = entry["exact_edit"]["kind"]
skipped_out.append(_skipped(
path, kind, idx,
"content-changed-since-check",
"Re-run hygiene check to refresh the report.",
))
return applied, skipped_out, failed_out, staged
# Step 4: Decode file strictly — errors="replace" would silently rewrite
# non-UTF8 bytes that the user never asked to touch, bypassing the hash
# guard. Skip the file instead (hash verified above, so this is rare).
try:
text = file_bytes.decode("utf-8")
except UnicodeDecodeError:
for idx, entry in batch:
kind = entry["exact_edit"]["kind"]
skipped_out.append(_skipped(
path, kind, idx,
"non-utf8-content",
"File contains non-UTF-8 bytes; manual editing required.",
))
return applied, skipped_out, failed_out, staged
lines = text.splitlines(keepends=True)
sorted_anchored = sorted(
anchored,
key=lambda t: t[1]["exact_edit"]["anchor"]["start_line"],
reverse=True,
)
for idx, entry in sorted_anchored:
ee = entry["exact_edit"]
kind = ee["kind"]
start = ee["anchor"]["start_line"]
end = ee["anchor"]["end_line"]
try:
if kind == "delete-range":
lines = _delete_lines(lines, start, end)
applied.append(_applied(path, kind, idx))
elif kind == "replace-text":
match = ee["match"]
replacement = ee["replacement"]
lines, found = _replace_in_span(lines, start, end, match, replacement)
# match absent → no-op success (already applied or match moved).
applied.append(_applied(path, kind, idx))
elif kind == "dedupe":
lines = _delete_lines(lines, start, end)
applied.append(_applied(path, kind, idx))
else:
# Should not reach here (incompatibility + kind checks above).
skipped_out.append(_skipped(
path, kind, idx,
f"unexpected-kind-in-anchored-pass:{kind}",
"Internal error — re-run.",
))
except Exception as exc: # noqa: BLE001
failed_out.append(_failed(path, kind, idx, str(exc)))
# Step 5: Apply insert-frontmatter entries LAST (prepend shifts all lines).
for idx, entry in anchorless:
ee = entry["exact_edit"]
kind = ee["kind"]
key = ee["key"]
value = ee["value"]
try:
# Re-derive freshness at apply time: re-read current in-memory text.
current_text = "".join(lines)
fm_kv, _ = _parse_frontmatter(current_text)
if key in fm_kv:
if fm_kv[key] == str(value):
# Idempotent: key already has target value.
applied.append(_applied(path, kind, idx))
else:
# Key present with DIFFERENT value — never overwrite.
skipped_out.append(_skipped(
path, kind, idx,
"frontmatter-key-conflict",
f"Key '{key}' already set to '{fm_kv[key]}'; manual resolution required.",
))
else:
new_text = _insert_frontmatter_key(current_text, key, value)
lines = new_text.splitlines(keepends=True)
applied.append(_applied(path, kind, idx))
except Exception as exc: # noqa: BLE001
failed_out.append(_failed(path, kind, idx, str(exc)))
# Step 6: Write file ONCE (only if anything changed — i.e. no pure no-ops
# made it to applied without actually changing lines).
# We always write when we have applied entries that modified lines,
# or inserted frontmatter. The simplest correct approach: write if
# there are any non-failed, non-skipped anchored/anchorless entries.
if applied:
new_bytes = "".join(lines).encode("utf-8")
if new_bytes != file_bytes:
try:
self._fs.write_bytes(abs_path, new_bytes)
staged.append(path)
except OSError as exc:
# Demote all applied to failed since write did not succeed.
for rec in applied:
failed_out.append(_failed(
rec["path"], rec["kind"], rec["entry_index"], str(exc)
))
applied.clear()
return applied, skipped_out, failed_out, staged
# ------------------------------------------------------------------
# Incompatible-ops detection
# ------------------------------------------------------------------
def _check_incompatible_ops(
self, path: str, batch: list[tuple[int, dict]]
) -> Optional[tuple[str, str]]:
"""
Detect structurally incompatible combinations on a single file.
Returns (reason, recommend) if incompatible, None otherwise.
Incompatible combinations:
1. move-to-archive combined with ANY other op (file leaves; targets vanish).
2. Overlapping anchor ranges between any two anchored entries.
"""
has_move = any(e["exact_edit"]["kind"] == "move-to-archive" for _, e in batch)
if has_move and len(batch) > 1:
return (
"incompatible-ops-on-file",
"move-to-archive cannot be combined with other ops; re-run check to re-classify.",
)
# Check overlapping anchor ranges.
anchored_ranges: list[tuple[int, int, int]] = [] # (start, end, idx)
for idx, entry in batch:
ee = entry["exact_edit"]
kind = ee["kind"]
if KIND_TABLE[kind].has_anchor:
start = ee["anchor"]["start_line"]
end = ee["anchor"]["end_line"]
for a_start, a_end, a_idx in anchored_ranges:
if _ranges_overlap(start, end, a_start, a_end):
return (
"incompatible-ops-on-file",
f"Overlapping anchor ranges (entries {a_idx} and {idx}); re-run check.",
)
anchored_ranges.append((start, end, idx))
return None
# ---------------------------------------------------------------------------
# CLI
# ---------------------------------------------------------------------------
class _UsageError(Exception):
"""Signals a CLI usage / IO error (exit code 2). Caught by main()."""
def _load_report(path: str) -> dict:
try:
with open(path, "r", encoding="utf-8") as fh:
return json.load(fh)
except FileNotFoundError:
print(json.dumps({"error": f"Report file not found: {path}"}), file=sys.stderr)
raise _UsageError(f"Report file not found: {path}")
except json.JSONDecodeError as exc:
print(json.dumps({"error": f"Invalid JSON in report: {exc}"}), file=sys.stderr)
raise _UsageError(f"Invalid JSON in report: {exc}")
def _parse_indices(raw: str) -> list[int]:
"""Parse '0,2,3' → [0, 2, 3]. Raises _UsageError on bad input."""
if not raw.strip():
return []
try:
return [int(x.strip()) for x in raw.split(",")]
except ValueError:
print(json.dumps({"error": f"Invalid --apply-indices value: {raw!r}"}), file=sys.stderr)
raise _UsageError(f"Invalid --apply-indices value: {raw!r}")
def main(argv: Optional[list] = None) -> int:
try:
return _main_inner(argv)
except _UsageError:
return 2
def _main_inner(argv: Optional[list] = None) -> int:
parser = argparse.ArgumentParser(
description="doc-hygiene patch applier — deterministic file patching, no model."
)
parser.add_argument(
"--report", required=True,
help="Path to the machine-report JSON (output of report_builder).",
)
parser.add_argument(
"--apply-indices", required=True,
help="Comma-separated 0-based indices into report.entries[] to apply.",
)
parser.add_argument(
"--project-root", default=None,
help="Override project root (default: report.scan.project_root).",
)
args = parser.parse_args(argv)
report = _load_report(args.report)
entries = report.get("entries", [])
if not isinstance(entries, list):
msg = "report.entries must be an array"
print(json.dumps({"error": msg}), file=sys.stderr)
raise _UsageError(msg)
indices = _parse_indices(args.apply_indices)
max_idx = len(entries) - 1
invalid = [i for i in indices if i < 0 or (len(entries) == 0) or i > max_idx]
if invalid:
msg = f"Index/indices out of range: {invalid}; report has {len(entries)} entries"
print(json.dumps({"error": msg}), file=sys.stderr)
raise _UsageError(msg)
selected = [entries[i] for i in indices]
project_root_str = args.project_root or report.get("scan", {}).get("project_root")
if not project_root_str:
msg = "Cannot determine project_root; pass --project-root"
print(json.dumps({"error": msg}), file=sys.stderr)
raise _UsageError(msg)
project_root = Path(project_root_str)
applier = PatchApplier(project_root=project_root)
# Build indexed pairs so output entry_index is the original report index.
indexed_pairs = list(zip(indices, selected))
result = applier.apply_indexed(indexed_pairs)
print(json.dumps(result, indent=2))
has_problems = bool(result["skipped"] or result["failed"])
return 1 if has_problems else 0
if __name__ == "__main__":
sys.exit(main())

View File

@ -0,0 +1,241 @@
#!/usr/bin/env python3
"""
reminder.py deterministic SessionStart reminder for doc-hygiene.
This is the only thing the `SessionStart` hook invokes. It:
0. No-ops silently unless cwd is inside a real git project (invariant #3
guard see `_is_git_project`; flagged for human review).
1. Resolves the project root from cwd (git root).
2. Reads lifecycle timestamps from the in-project state store.
3. Decides purely, with an injected clock whether docs are stale and
whether today's snooze permits a banner.
4. On a banner decision: emits a `systemMessage` JSON object on stdout
(a user-facing, zero-token banner) and writes `last_reminded = now`.
5. On a silent decision: writes nothing and prints nothing.
6. ALWAYS exits 0 so the session is never blocked.
Design invariants honoured:
#1 Hook only reminds — spends zero AI tokens, runs no scan / no model,
mutates nothing except `last_reminded`, never blocks the session.
#2 Snooze ≤ once per calendar day while stale, keyed on `last_reminded`.
#3 State read/written only via the in-project StateStore.
#6 Deterministic-first — no model is imported or invoked here.
The decision is a PURE function (`Reminder.decide`) over
`(last_check, last_reminded, now, threshold_days)` so the snooze and staleness
behaviour are unit-testable with a frozen clock and no real session.
"""
from __future__ import annotations
import json
import sys
from datetime import datetime, timedelta, timezone
from enum import Enum
from pathlib import Path
from typing import Optional
# ---------------------------------------------------------------------------
# Path bootstrap so `python reminder.py` works regardless of cwd.
# ---------------------------------------------------------------------------
_SCRIPTS_DIR = Path(__file__).resolve().parent
if str(_SCRIPTS_DIR) not in sys.path:
sys.path.insert(0, str(_SCRIPTS_DIR))
from state_store import ( # noqa: E402
RealClock,
RealRootFS,
StateStore,
resolve_project_root,
)
# ---------------------------------------------------------------------------
# Configuration constant
# ---------------------------------------------------------------------------
# Staleness threshold: days since `last_check` before the reminder fires.
# This is an implementation detail (see design Open Questions), NOT a spec'd
# contract — a per-project override is a later concern. Chosen at the
# motivational, not correctness-critical, end of the 1430 day range.
DEFAULT_THRESHOLD_DAYS = 21
# ---------------------------------------------------------------------------
# Pure decision (task 4.1 / design D5)
# ---------------------------------------------------------------------------
class Decision(Enum):
"""Outcome of the reminder decision."""
SILENT = "silent"
BANNER = "banner"
class Reminder:
"""
Pure staleness + snooze decision for the SessionStart reminder.
The decision is a total function of its four inputs and performs no I/O,
so the snooze boundary (invariant #2) and staleness rule (invariant #1's
"only reminds") are deterministically testable with an injected clock.
Semantics (design D5):
stale = last_check is None OR (now - last_check) > threshold
if not stale: SILENT # fresh docs
if last_reminded is None: BANNER # stale, never reminded
if same_calendar_day(last_reminded, now): SILENT # snoozed today
BANNER # stale, new day
Snooze keys on **calendar day** (UTC `.date()`), not a 24h sliding window,
so two sessions 20 minutes apart that straddle midnight still re-banner the
second day matching invariant #2's "at most once per calendar day".
"""
def __init__(self, threshold_days: int = DEFAULT_THRESHOLD_DAYS) -> None:
self._threshold = timedelta(days=threshold_days)
@staticmethod
def _as_utc(ts: datetime) -> datetime:
"""Normalise a datetime to UTC (treat naive as UTC) for safe compares."""
if ts.tzinfo is None:
return ts.replace(tzinfo=timezone.utc)
return ts.astimezone(timezone.utc)
def is_stale(self, last_check: Optional[datetime], now: datetime) -> bool:
"""True when docs have never been checked or the check is older than the threshold."""
if last_check is None:
return True
return (self._as_utc(now) - self._as_utc(last_check)) > self._threshold
def decide(
self,
last_check: Optional[datetime],
last_reminded: Optional[datetime],
now: datetime,
) -> Decision:
"""Return BANNER or SILENT given the state timestamps and the current time."""
if not self.is_stale(last_check, now):
return Decision.SILENT
if last_reminded is None:
return Decision.BANNER
# Snooze on calendar day (UTC), not a sliding 24h window.
if self._as_utc(last_reminded).date() == self._as_utc(now).date():
return Decision.SILENT
return Decision.BANNER
def days_stale(self, last_check: Optional[datetime], now: datetime) -> Optional[int]:
"""Whole days since `last_check`, or None if never checked."""
if last_check is None:
return None
delta = self._as_utc(now) - self._as_utc(last_check)
return max(delta.days, 0)
# ---------------------------------------------------------------------------
# Banner copy (task 4.2)
# ---------------------------------------------------------------------------
#
# DEVIATION NOTE: the `session-reminder` spec requires the banner to "name the
# slash command the user runs". That command (`/hygiene check`) is not built in
# this change, so per the task we advertise that monitoring is active without
# promising an unbuilt command. The staleness phrase IS retained (the spec also
# says the banner states "how stale the docs are"), keyed off `last_check`.
def build_banner(days_stale: Optional[int]) -> str:
"""Compose the deterministic, zero-token banner text."""
if days_stale is None:
staleness = "no documentation check has been run yet"
else:
staleness = f"docs were last checked {days_stale} day{'s' if days_stale != 1 else ''} ago"
return f"[doc-hygiene] Documentation health monitoring active — {staleness}."
# ---------------------------------------------------------------------------
# Script runner (task 4.2) — owns all I/O and the try/except envelope.
# ---------------------------------------------------------------------------
class ReminderRunner:
"""
Wires the pure `Reminder` decision to the in-project state store and stdout.
All I/O lives here; the `Reminder` decision stays pure. Any failure reading
state is treated as "never checked" (stale) so the reminder still fires and
the session is never blocked (invariant #1, spec "Unreadable state never
blocks the session").
"""
def __init__(
self,
store: StateStore,
reminder: Optional[Reminder] = None,
clock: Optional[RealClock] = None,
out=None,
) -> None:
self._store = store
self._reminder = reminder or Reminder()
self._clock = clock or RealClock()
self._out = out if out is not None else sys.stdout
def _safe_get(self, key: str) -> Optional[datetime]:
"""Read a timestamp, swallowing any corruption as 'unset'."""
try:
return self._store.get_timestamp(key)
except Exception:
return None
def run(self) -> Decision:
"""Execute one reminder pass. Emits a banner if due. Never raises."""
now = self._clock.now()
last_check = self._safe_get("last_check")
last_reminded = self._safe_get("last_reminded")
decision = self._reminder.decide(last_check, last_reminded, now)
if decision is Decision.BANNER:
days = self._reminder.days_stale(last_check, now)
banner = build_banner(days)
self._out.write(json.dumps({"systemMessage": banner}))
# The ONLY mutation (invariant #1): record the snooze stamp.
self._store.set_timestamp("last_reminded", now)
return decision
# ---------------------------------------------------------------------------
# Entry point (task 4.2) — always exits 0.
# ---------------------------------------------------------------------------
def _is_git_project(root: Path) -> bool:
"""True only when *root* is a real git project (has a `.git` directory).
Guard rationale (invariant #3): the SessionStart hook is user-scoped and
fires in ANY directory. `resolve_project_root` falls back to cwd when no
`.git` is found, so without this guard, starting a session in `~` or any
non-project folder would create `<cwd>/.dochygiene/` (e.g. `~/.dochygiene`)
a signature invariant #3 names as a violation — and, being un-checkable,
would banner every calendar day there (the nag-ware the snooze prevents
only WITHIN a day). We therefore restrict the reminder to git projects.
Brand-new git projects still banner (never-checked = stale), per spec.
NOTE FOR REVIEW: this guard is a design/invariant interaction not spelled
out in the `session-reminder` spec (which assumed a project context). It is
the conservative reading of invariant #3 + D4; flagged for the human gate.
"""
return RealRootFS().is_dir(root / ".git")
def main(argv: Optional[list] = None) -> int:
try:
root = resolve_project_root(Path.cwd())
if not _is_git_project(root):
# Not a git project — stay silent and write nothing (invariant #3).
return 0
store = StateStore(root)
ReminderRunner(store).run()
except Exception:
# Belt-and-braces: a reminder must NEVER block or fail a session.
pass
return 0
if __name__ == "__main__":
sys.exit(main())

View File

@ -0,0 +1,571 @@
#!/usr/bin/env python3
"""
report_builder.py model-free finalize pass for the doc-hygiene `check` skill.
This is the deterministic step BETWEEN model classification and
`StateStore.write_report` (design D1). The model supplies slim per-file
classification PROPOSALS (judgment only); this assembler fills the four fields
the model must NOT author and emits a full schema-valid machine report plus a
deterministic human-report skeleton.
The four guardrail fields owned here (never model-authored):
- exact_edit.expected_sha256 sha256 of the CURRENT whole file bytes
- is_destructive / is_reversible (deterministic ops) from KIND_TABLE[kind]
- safety_tier derive_safety_tier(...) imported from
validate_report.py (single source of truth)
- token_estimate.raw_tokens from the local token estimator (invariant #6)
Design references:
- openspec/changes/add-check/design.md (D1, D3 step 4, Risks)
- openspec/specs/report-schema/spec.md (the frozen contract)
Key decisions (see design + advisor notes):
* expected_sha256 is the hash of the WHOLE file bytes, not the anchored span.
The anchors are LINE NUMBERS; an edit outside the span would silently shift
the anchored lines, so the guard must cover the whole file (matches the
spec's "content hash" wording for the mtime guard).
* Per-entry exact_edit.generated_at = the instant THIS file's hash was taken
(injected clock). It exists iff expected_sha256 exists i.e. for
anchor-bearing kinds only. insert-frontmatter (has_anchor=False) and
generative entries carry neither. (insert-frontmatter is the one span case
the design did not specify; raw_tokens is counted over the whole file a
documented deviation.)
* tool_version is read from .claude-plugin/plugin.json (never hardcoded).
Usage (CLI):
python report_builder.py --scan <scan.json> --proposals <proposals.json> \\
[--out-json <path>] [--out-md <path>]
# paths default to stdin (a single JSON object {"scan":..,"proposals":..})
# and stdout (a JSON object {"machine_report":.., "human_report":..}).
Exit codes:
0 built successfully
1 malformed proposal (structured error on stderr)
2 usage / IO error
"""
from __future__ import annotations
import argparse
import hashlib
import json
import sys
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Callable, Optional, Protocol
# ---------------------------------------------------------------------------
# Import the single source of truth for safety-tier + kind table.
# ---------------------------------------------------------------------------
_SCRIPTS_DIR = Path(__file__).resolve().parent
if str(_SCRIPTS_DIR) not in sys.path:
sys.path.insert(0, str(_SCRIPTS_DIR))
from validate_report import KIND_TABLE, derive_safety_tier # noqa: E402
from token_estimator import default_estimator, TokenEstimator # noqa: E402
SCHEMA_VERSION = "1.0"
_PLUGIN_JSON = _SCRIPTS_DIR.parent / ".claude-plugin" / "plugin.json"
_VALID_OP_TYPES = ("deterministic", "generative")
_STALE_SUBTYPES = {"contradicted", "orphaned", "superseded", "provisional", "completed-in-place", "duplicated"}
_BLOAT_SUBTYPES = {"distill", "split", "freeze"}
# ---------------------------------------------------------------------------
# Errors
# ---------------------------------------------------------------------------
class MalformedProposalError(ValueError):
"""A classification proposal is structurally invalid (rejected pre-compute)."""
def __init__(self, index: int, field: str, message: str) -> None:
self.index = index
self.field = field
self.message = message
super().__init__(f"proposal[{index}].{field}: {message}")
def as_dict(self) -> dict:
return {"index": self.index, "field": self.field, "message": self.message}
# ---------------------------------------------------------------------------
# Injectable seams: clock + file reader
# ---------------------------------------------------------------------------
class _Clock(Protocol):
def now(self) -> datetime: ...
class RealClock:
"""Production clock — UTC now()."""
def now(self) -> datetime:
return datetime.now(timezone.utc)
class _FileReader(Protocol):
def read_bytes(self, path: Path) -> bytes: ...
def read_text(self, path: Path) -> str: ...
class RealFileReader:
"""Production reader — raw bytes for hashing, decoded text for spans."""
def read_bytes(self, path: Path) -> bytes:
return Path(path).read_bytes()
def read_text(self, path: Path) -> str:
return Path(path).read_text(encoding="utf-8", errors="replace")
def _iso(dt: datetime) -> str:
"""Serialise a datetime as ISO-8601 UTC."""
if dt.tzinfo is None:
dt = dt.replace(tzinfo=timezone.utc)
return dt.isoformat()
def _read_tool_version() -> tuple[str, bool]:
"""Return (tool_version, fell_back). Reads .claude-plugin/plugin.json."""
try:
data = json.loads(_PLUGIN_JSON.read_text(encoding="utf-8"))
version = data.get("version")
if isinstance(version, str) and version:
return version, False
except (OSError, json.JSONDecodeError):
pass
return "0.0.0", True
# ---------------------------------------------------------------------------
# Report builder
# ---------------------------------------------------------------------------
class ReportBuilder:
"""Assembles a schema-valid machine report from a scan artifact + proposals.
Parameters
----------
project_root:
Resolved project root; entry paths are relative to it and files are
read from here.
clock:
Injected clock. Each file-hash instant and the envelope `generated_at`
come from successive `.now()` calls so per-entry `generated_at`
differs from the envelope stamp (use a monotonic clock to observe it).
reader:
Injected file reader (bytes for hashing, text for span extraction).
estimator:
Token estimator producing `token_estimate`. Defaults to the local
deterministic estimator (no model, no network invariant #6).
tool_version:
Override for the plugin version; defaults to reading plugin.json.
"""
def __init__(
self,
project_root: Path,
clock: Optional[_Clock] = None,
reader: Optional[_FileReader] = None,
estimator: Optional[TokenEstimator] = None,
tool_version: Optional[str] = None,
) -> None:
self._root = Path(project_root)
self._clock = clock or RealClock()
self._reader = reader or RealFileReader()
self._estimator = estimator or default_estimator()
if tool_version is not None:
self._tool_version = tool_version
self._tool_version_fell_back = False
else:
self._tool_version, self._tool_version_fell_back = _read_tool_version()
@property
def tool_version_fell_back(self) -> bool:
"""True if plugin.json could not be read and a fallback version is used."""
return self._tool_version_fell_back
# ------------------------------------------------------------------
# Public
# ------------------------------------------------------------------
def build(self, scan: dict, proposals: list) -> dict:
"""Return {"machine_report": <dict>, "human_report": <str>}.
Raises MalformedProposalError if any proposal is structurally invalid
(validated BEFORE any hashing/IO).
"""
if not isinstance(scan, dict):
raise MalformedProposalError(-1, "(scan)", "scan artifact must be a JSON object")
if not isinstance(proposals, list):
raise MalformedProposalError(-1, "(proposals)", "proposals must be a JSON array")
# 1) Validate ALL proposals up-front (reject before computing anything).
for i, proposal in enumerate(proposals):
self._validate_proposal(proposal, i)
# 2) Assemble entries (now safe to read files / hash / estimate).
entries = [self._build_entry(p) for p in proposals]
# 3) Envelope. The envelope generated_at is its own clock instant
# (the run instant the skill also stamps as last_check).
envelope_generated_at = _iso(self._clock.now())
scan_block = self._build_scan_block(scan)
shortlist = list(scan.get("shortlist", [])) # verbatim
machine_report = {
"schema_version": SCHEMA_VERSION,
"tool_version": self._tool_version,
"generated_at": envelope_generated_at,
"scan": scan_block,
"shortlist": shortlist,
"entries": entries,
}
human_report = self._build_human_report(
machine_report, scan, proposals, envelope_generated_at
)
return {"machine_report": machine_report, "human_report": human_report}
# ------------------------------------------------------------------
# Proposal validation (pre-compute)
# ------------------------------------------------------------------
def _validate_proposal(self, proposal: Any, idx: int) -> None:
if not isinstance(proposal, dict):
raise MalformedProposalError(idx, "(root)", "proposal must be a JSON object")
for key in ("path", "category", "op", "op_type"):
if key not in proposal:
raise MalformedProposalError(idx, key, f"required field '{key}' is missing")
path = proposal.get("path")
if not isinstance(path, str) or not path:
raise MalformedProposalError(idx, "path", "path must be a non-empty string")
self._validate_category(proposal.get("category"), idx)
op_type = proposal.get("op_type")
if op_type not in _VALID_OP_TYPES:
raise MalformedProposalError(
idx, "op_type", f"op_type must be one of {list(_VALID_OP_TYPES)}, got {op_type!r}"
)
has_exact_edit = "exact_edit" in proposal and proposal["exact_edit"] is not None
if op_type == "generative":
if has_exact_edit:
raise MalformedProposalError(
idx, "exact_edit", "generative proposal must NOT carry exact_edit"
)
# reducible_range is required so we can count raw_tokens on real text.
self._validate_range(
proposal.get("reducible_range"), idx, "reducible_range", required=True
)
else: # deterministic
if not has_exact_edit:
raise MalformedProposalError(
idx, "exact_edit", "deterministic proposal MUST carry an exact_edit skeleton"
)
self._validate_exact_edit_skeleton(proposal["exact_edit"], idx)
def _validate_category(self, category: Any, idx: int) -> None:
if not isinstance(category, dict):
raise MalformedProposalError(idx, "category", "category must be an object with class+subtype")
cls = category.get("class")
subtype = category.get("subtype")
if cls not in ("stale", "bloat"):
raise MalformedProposalError(idx, "category.class", f"must be 'stale' or 'bloat', got {cls!r}")
if cls == "stale" and subtype not in _STALE_SUBTYPES:
raise MalformedProposalError(
idx, "category.subtype", f"for class 'stale' must be one of {sorted(_STALE_SUBTYPES)}, got {subtype!r}"
)
if cls == "bloat" and subtype not in _BLOAT_SUBTYPES:
raise MalformedProposalError(
idx, "category.subtype", f"for class 'bloat' must be one of {sorted(_BLOAT_SUBTYPES)}, got {subtype!r}"
)
def _validate_exact_edit_skeleton(self, exact_edit: Any, idx: int) -> None:
if not isinstance(exact_edit, dict):
raise MalformedProposalError(idx, "exact_edit", "exact_edit must be a JSON object")
kind = exact_edit.get("kind")
if kind not in KIND_TABLE:
raise MalformedProposalError(
idx, "exact_edit.kind", f"unknown kind {kind!r}; valid: {sorted(KIND_TABLE)}"
)
spec = KIND_TABLE[kind]
# Reuse the validator's kind table so input + output rules agree.
for req_field in spec.required_fields:
if req_field not in exact_edit:
raise MalformedProposalError(
idx, f"exact_edit.{req_field}", f"kind '{kind}' requires field '{req_field}'"
)
if spec.has_anchor:
self._validate_range(exact_edit.get("anchor"), idx, "exact_edit.anchor", required=True)
def _validate_range(self, rng: Any, idx: int, field_name: str, required: bool) -> None:
if rng is None:
if required:
raise MalformedProposalError(idx, field_name, f"required range '{field_name}' is missing")
return
if not isinstance(rng, dict):
raise MalformedProposalError(idx, field_name, "range must be an object with start_line+end_line")
for k in ("start_line", "end_line"):
v = rng.get(k)
if not isinstance(v, int) or isinstance(v, bool):
raise MalformedProposalError(idx, f"{field_name}.{k}", f"{k} must be an integer")
if rng["start_line"] > rng["end_line"]:
raise MalformedProposalError(idx, field_name, "start_line must be <= end_line")
# ------------------------------------------------------------------
# Entry assembly
# ------------------------------------------------------------------
def _build_entry(self, proposal: dict) -> dict:
path = proposal["path"]
op_type = proposal["op_type"]
abs_path = self._root / path
entry: dict[str, Any] = {
"path": path,
"category": dict(proposal["category"]),
"signals": list(proposal.get("signals", [])),
"op": proposal["op"],
"op_type": op_type,
}
if op_type == "deterministic":
kind = proposal["exact_edit"]["kind"]
spec = KIND_TABLE[kind]
# Guardrail fields: ALWAYS from the kind table, never from the proposal.
entry["is_destructive"] = spec.is_destructive
entry["is_reversible"] = spec.is_reversible
entry["safety_tier"] = derive_safety_tier(
op_type, spec.is_destructive, spec.is_reversible
)
exact_edit = self._build_exact_edit(proposal["exact_edit"], spec, abs_path)
entry["exact_edit"] = exact_edit
span_text = self._extract_span(proposal["exact_edit"], kind, abs_path)
else: # generative
# No exact_edit; (is_destructive, is_reversible) characterize the op.
# A generative prose transform preserves history → non-destructive,
# reversible; the tier is forced to confirm by op_type regardless.
entry["is_destructive"] = False
entry["is_reversible"] = True
entry["safety_tier"] = derive_safety_tier(op_type, False, True)
span_text = self._read_range(abs_path, proposal["reducible_range"])
entry["token_estimate"] = self._estimator.estimate_for_report(span_text)
return entry
def _build_exact_edit(self, skeleton: dict, spec, abs_path: Path) -> dict:
"""Copy the skeleton and stamp the deterministic fields."""
kind = skeleton["kind"]
exact_edit: dict[str, Any] = {"kind": kind}
# Carry the model-supplied kind-specific fields verbatim.
for key, value in skeleton.items():
if key == "kind":
continue
exact_edit[key] = value
if spec.has_anchor:
# expected_sha256 = hash of the CURRENT whole file bytes; generated_at
# = that hash instant. Both exist iff the kind is anchor-bearing.
hash_instant = self._clock.now()
file_bytes = self._reader.read_bytes(abs_path)
exact_edit["expected_sha256"] = hashlib.sha256(file_bytes).hexdigest()
exact_edit["generated_at"] = _iso(hash_instant)
return exact_edit
# ------------------------------------------------------------------
# Span extraction (for raw_tokens)
# ------------------------------------------------------------------
def _extract_span(self, skeleton: dict, kind: str, abs_path: Path) -> str:
"""Return the text whose tokens are counted for this deterministic op."""
if kind == "move-to-archive":
# Whole file is relocated → count the whole file.
return self._reader.read_text(abs_path)
if kind == "insert-frontmatter":
# No anchor; the doc being frozen is the relevant span (whole file).
# NOTE: a documented deviation — the design did not specify a span
# for insert-frontmatter.
return self._reader.read_text(abs_path)
# delete-range / replace-text / dedupe — count the anchored span.
return self._read_range(abs_path, skeleton["anchor"])
def _read_range(self, abs_path: Path, rng: dict) -> str:
"""Return the inclusive 1-based line range [start_line, end_line]."""
text = self._reader.read_text(abs_path)
lines = text.splitlines()
start = max(1, int(rng["start_line"]))
end = min(len(lines), int(rng["end_line"]))
if start > end:
return ""
return "\n".join(lines[start - 1:end])
# ------------------------------------------------------------------
# Scan block
# ------------------------------------------------------------------
def _build_scan_block(self, scan: dict) -> dict:
"""Project the scan artifact onto the schema's `scan` object."""
return {
"project_root": scan.get("project_root", str(self._root)),
"scope_globs": scan.get("scope_globs", []),
"excluded_dirs": scan.get("excluded_dirs", []),
"files_scanned": scan.get("files_scanned", 0),
}
# ------------------------------------------------------------------
# Human report skeleton
# ------------------------------------------------------------------
def _build_human_report(
self,
machine_report: dict,
scan: dict,
proposals: list,
generated_at: str,
) -> str:
entries = machine_report["entries"]
shortlist = machine_report["shortlist"]
# gloss: optional per-path "why" line a caller may attach to a proposal.
gloss_by_path = {
p["path"]: p["gloss"]
for p in proposals
if isinstance(p, dict) and isinstance(p.get("gloss"), str)
}
candidate_paths = {e["path"] for e in entries}
cleared = [p for p in shortlist if p not in candidate_paths]
scope_globs = scan.get("scope_globs", [])
lines: list[str] = []
lines.append("# doc-hygiene check report")
lines.append("")
lines.append(f"- generated_at: {generated_at}")
lines.append(f"- scope: {', '.join(scope_globs) if scope_globs else '(default)'}")
lines.append(f"- files scanned: {scan.get('files_scanned', 0)}")
lines.append(f"- candidates: {len(entries)}")
lines.append(f"- cleared: {len(cleared)}")
lines.append("")
stale = [e for e in entries if e["category"].get("class") == "stale"]
bloat = [e for e in entries if e["category"].get("class") == "bloat"]
lines.extend(self._render_group("Stale", stale, gloss_by_path))
lines.extend(self._render_group("Bloat", bloat, gloss_by_path))
lines.append("## Cleared")
lines.append("")
if cleared:
for path in cleared:
lines.append(f"- {path}")
else:
lines.append("- (none)")
lines.append("")
return "\n".join(lines)
def _render_group(self, title: str, group: list, gloss_by_path: dict) -> list:
lines = [f"## {title}", ""]
if not group:
lines.append("- (none)")
lines.append("")
return lines
for e in group:
cat = e["category"]
subtype = cat.get("subtype", "?")
raw = e["token_estimate"].get("raw_tokens", 0)
signal_names = ", ".join(
s.get("name", "?") for s in e.get("signals", []) if isinstance(s, dict)
) or "(none)"
lines.append(
f"- {e['path']} · {cat.get('class')}/{subtype} · {e['op']} "
f"· {e['safety_tier']} · ~{raw} tokens · {signal_names}"
)
gloss = gloss_by_path.get(e["path"])
if gloss:
lines.append(f" - {gloss}")
lines.append("")
return lines
# ---------------------------------------------------------------------------
# CLI
# ---------------------------------------------------------------------------
def _load_json(path: Optional[str]) -> Any:
if path is None or path == "-":
return json.load(sys.stdin)
with open(path, "r", encoding="utf-8") as fh:
return json.load(fh)
def main(argv: Optional[list] = None) -> int:
parser = argparse.ArgumentParser(
description="doc-hygiene report builder — model-free finalize pass."
)
parser.add_argument("--scan", default=None, help="Path to scan-artifact JSON ('-' or omit = stdin).")
parser.add_argument("--proposals", default=None, help="Path to proposals JSON.")
parser.add_argument("--root", default=None, help="Project root (default: scan.project_root).")
parser.add_argument("--out-json", default=None, help="Write machine report JSON here (default: stdout bundle).")
parser.add_argument("--out-md", default=None, help="Write human report markdown here.")
args = parser.parse_args(argv)
# Input resolution: if neither --scan nor --proposals given, read a single
# bundle {"scan":..,"proposals":..} from stdin.
try:
if args.scan is None and args.proposals is None:
bundle = _load_json(None)
if not isinstance(bundle, dict) or "scan" not in bundle or "proposals" not in bundle:
print(json.dumps({"error": "stdin bundle must have 'scan' and 'proposals'"}), file=sys.stderr)
return 2
scan = bundle["scan"]
proposals = bundle["proposals"]
else:
scan = _load_json(args.scan)
proposals = _load_json(args.proposals)
except (OSError, json.JSONDecodeError) as exc:
print(json.dumps({"error": f"failed to read input: {exc}"}), file=sys.stderr)
return 2
root = Path(args.root) if args.root else Path(scan.get("project_root", "."))
builder = ReportBuilder(project_root=root)
try:
result = builder.build(scan, proposals)
except MalformedProposalError as exc:
print(json.dumps({"error": "malformed proposal", "detail": exc.as_dict()}), file=sys.stderr)
return 1
machine_report = result["machine_report"]
human_report = result["human_report"]
if builder.tool_version_fell_back:
print(
json.dumps({"warning": "tool_version fell back; plugin.json unreadable"}),
file=sys.stderr,
)
wrote_file = False
if args.out_json:
Path(args.out_json).write_text(json.dumps(machine_report, indent=2), encoding="utf-8")
wrote_file = True
if args.out_md:
Path(args.out_md).write_text(human_report, encoding="utf-8")
wrote_file = True
if not wrote_file:
print(json.dumps({"machine_report": machine_report, "human_report": human_report}, indent=2))
return 0
if __name__ == "__main__":
sys.exit(main())

View File

@ -0,0 +1,702 @@
"""
doc-hygiene scanner deterministic, no model, no network.
Produces an intermediate artifact:
{
"project_root": str,
"scope_globs": [str, ...],
"excluded_dirs": [str, ...],
"files_scanned": int,
"shortlist": [str, ...], # project-root-relative paths
"signals": { path: [{"name": str, "detail": str}, ...] }
}
Signal shape matches entries[].signals in the frozen report schema.
"""
from __future__ import annotations
import fnmatch
import json
import os
import re
import subprocess
import sys
from pathlib import Path
from typing import Callable, Iterator, List, Optional
# ---------------------------------------------------------------------------
# Glob matching (Python 3.14-compatible)
# ---------------------------------------------------------------------------
def _file_matches_glob(rel_path_str: str, glob_pattern: str) -> bool:
"""Return True if *rel_path_str* matches *glob_pattern*.
Works around a Python 3.14 behavior where ``Path('a.md').match('**/*.md')``
returns False for root-level files (no parent component). For ``**/X``
patterns we fall back to matching just ``X`` against the filename so that
root-level files are included.
"""
p = Path(rel_path_str)
# Try direct PurePath.match() first — handles nested files correctly
if p.match(glob_pattern):
return True
# Fallback for '**/<pat>' patterns: match just the trailing pattern against
# the filename, so root-level 'a.md' is caught by '**/*.md'.
if glob_pattern.startswith("**/"):
suffix_pat = glob_pattern[3:]
if fnmatch.fnmatch(p.name, suffix_pat):
return True
return False
# ---------------------------------------------------------------------------
# Defaults
# ---------------------------------------------------------------------------
DEFAULT_SCOPE_GLOBS: List[str] = ["**/*.md"]
DEFAULT_EXCLUDED_DIRS: List[str] = [
"build",
"vendor",
"archive",
"graphify-out",
".dochygiene",
# Test-fixture dirs intentionally contain stale/bloated docs as scanner
# inputs. Bare directory-*name* match (consistent with the entries above):
# any child dir named "fixtures" at any depth is pruned — same breadth as
# "archive"/"vendor".
"fixtures",
# Golden classifier inputs also contain deliberately-stale docs, but a bare
# "golden" name-match would silently skip legitimate `golden/` dirs in
# unrelated projects (doc-hygiene installs globally). So this entry is
# PATH-AWARE: an entry containing "/" is matched as a parent/child name pair
# anywhere in the tree — here, a dir named "golden" whose immediate parent is
# named "examples". A root-level `golden/` with a different parent is still
# scanned. See Scanner._is_excluded_child for the matcher.
"examples/golden",
]
# ---------------------------------------------------------------------------
# Helpers — frontmatter
# ---------------------------------------------------------------------------
def _parse_frontmatter_value(path: Path, key: str) -> Optional[str]:
"""Return the string value of *key* in YAML frontmatter, or None.
Reads only the frontmatter block (between the first pair of ``---`` lines).
Uses stdlib only (no PyYAML).
"""
try:
text = path.read_text(encoding="utf-8", errors="replace")
except OSError:
return None
lines = text.splitlines()
if not lines or lines[0].strip() != "---":
return None
in_block = True
for line in lines[1:]:
stripped = line.strip()
if stripped == "---" or stripped == "...":
break
# Match: key: value (simple scalar, no quoting)
m = re.match(r"^(\w[\w\-]*):\s*(.+)$", stripped)
if m and m.group(1) == key:
return m.group(2).strip()
return None
def _is_frozen(path: Path) -> bool:
"""Return True if the file has ``hygiene: frozen`` in its frontmatter."""
return _parse_frontmatter_value(path, "hygiene") == "frozen"
# ---------------------------------------------------------------------------
# Helpers — append-only detection (content-based, no git)
# ---------------------------------------------------------------------------
_APPEND_ONLY_MARKER_RE = re.compile(
r"<!--\s*append[-_]only\s*-->|<!--\s*hygiene:\s*append[-_]only\s*-->",
re.IGNORECASE,
)
# A dated section header: starts with a 4-digit year, or ISO date, or
# common changelog-style prefix e.g. "## 2026-06-18" or "# v1.2 (2026-06-18)"
_DATED_HEADER_RE = re.compile(
r"^#{1,3}\s+(?:v?\d+\.\d|\d{4}[-/]\d{2}[-/]\d{2}|\d{4}-\d{2})"
)
def _is_append_only(path: Path) -> bool:
"""Heuristic: return True if the file looks like an append-only log.
Two independent paths (either is sufficient):
1. File contains an explicit ``<!-- append-only -->`` marker.
2. Every non-blank, non-header line cluster lives under a dated section
header, and the section count is 2 (a multi-entry changelog pattern).
Deterministic: reads content only, no git, no network.
"""
try:
text = path.read_text(encoding="utf-8", errors="replace")
except OSError:
return False
# Path 1: explicit marker anywhere in the file
if _APPEND_ONLY_MARKER_RE.search(text):
return True
# Path 2: structural — look for ≥2 dated-section headers and NO prose
# outside of dated sections (aside from a preamble).
lines = text.splitlines()
dated_header_count = 0
in_dated_section = False
non_section_content_found = False
preamble_done = False # first dated header ends the preamble
for line in lines:
stripped = line.strip()
if not stripped:
continue
if _DATED_HEADER_RE.match(stripped):
dated_header_count += 1
in_dated_section = True
preamble_done = True
elif not preamble_done:
# still in preamble (before first dated header) — allowed
pass
elif not in_dated_section:
# content outside any dated section after preamble
non_section_content_found = True
break
if dated_header_count >= 2 and not non_section_content_found:
return True
return False
# ---------------------------------------------------------------------------
# Helpers — .dochygiene-ignore
# ---------------------------------------------------------------------------
def _load_ignore_patterns(root: Path) -> List[str]:
"""Load patterns from ``<root>/.dochygiene-ignore``. One pattern per line."""
ignore_file = root / ".dochygiene-ignore"
try:
text = ignore_file.read_text(encoding="utf-8", errors="replace")
return [
line.strip()
for line in text.splitlines()
if line.strip() and not line.strip().startswith("#")
]
except OSError:
return []
def _matches_ignore(rel_path: str, patterns: List[str]) -> bool:
"""Return True if *rel_path* matches any ignore pattern (fnmatch)."""
for pat in patterns:
if fnmatch.fnmatch(rel_path, pat) or fnmatch.fnmatch(
os.path.basename(rel_path), pat
):
return True
return False
# ---------------------------------------------------------------------------
# Signal computation
# ---------------------------------------------------------------------------
class SignalComputer:
"""Compute objective signals for a single file.
Injected dependencies
----------------------
git_log_fn : callable(path: Path) -> list[str]
Returns git log lines for the file. Production uses ``_git_log_real``;
tests pass a fake. Pass ``None`` to disable git-based signals.
now_fn : callable() -> float
Returns current time as a Unix timestamp.
"""
def __init__(
self,
root: Path,
git_log_fn: Optional[Callable[[Path], List[str]]] = None,
now_fn: Optional[Callable[[], float]] = None,
) -> None:
self._root = root
self._git_log_fn = git_log_fn
self._now_fn = now_fn or (lambda: __import__("time").time())
# ------------------------------------------------------------------
# Public
# ------------------------------------------------------------------
def compute(self, path: Path) -> List[dict]:
"""Return a list of signal dicts for *path*."""
signals: List[dict] = []
rel = str(path.relative_to(self._root))
signals.extend(self._broken_references(path, rel))
signals.extend(self._version_skew(path))
signals.extend(self._edit_recency_vs_churn(path))
signals.extend(self._location_signals(path, rel))
signals.extend(self._archive_to_live_ratio(path))
signals.extend(self._frontmatter_markers(path))
return signals
# ------------------------------------------------------------------
# Individual signal detectors
# ------------------------------------------------------------------
def _broken_references(self, path: Path, rel: str) -> List[dict]:
"""Detect Markdown links/images whose targets do not exist on disk."""
signals = []
try:
text = path.read_text(encoding="utf-8", errors="replace")
except OSError:
return signals
# Match [text](target) — skip anchors, URLs, and mailto
link_re = re.compile(r"\[(?:[^\[\]]*)\]\(([^)]+)\)")
for m in link_re.finditer(text):
target = m.group(1).split("#")[0].strip() # strip fragment
if not target:
continue
if re.match(r"https?://|mailto:", target):
continue
# Resolve relative to the file's directory
if target.startswith("/"):
resolved = self._root / target.lstrip("/")
else:
resolved = path.parent / target
if not resolved.exists():
signals.append(
{
"name": "broken_reference",
"detail": f"links to '{target}' which does not exist",
}
)
return signals
def _version_skew(self, path: Path) -> List[dict]:
"""Detect explicit version declarations that differ from the repo version.
Looks for patterns like ``version: X.Y.Z`` or ``# vX.Y.Z`` in the file
and compares against a ``pyproject.toml`` or ``package.json`` at the
project root, if present. Objective fact only no judgment.
"""
signals = []
try:
text = path.read_text(encoding="utf-8", errors="replace")
except OSError:
return signals
# Find declared versions in the doc (e.g. ``version: 1.2.3``)
doc_versions = re.findall(r"\bversion[:\s]+['\"]?(\d+\.\d+[\.\d]*)['\"]?", text, re.IGNORECASE)
if not doc_versions:
return signals
# Try to read authoritative version from pyproject.toml or package.json
repo_version: Optional[str] = None
pyproject = self._root / "pyproject.toml"
if pyproject.exists():
try:
pp_text = pyproject.read_text(encoding="utf-8", errors="replace")
m = re.search(r'^version\s*=\s*["\'](\d+\.\d+[\.\d]*)["\']', pp_text, re.MULTILINE)
if m:
repo_version = m.group(1)
except OSError:
pass
if repo_version is None:
pkg_json = self._root / "package.json"
if pkg_json.exists():
try:
pkg = json.loads(pkg_json.read_text(encoding="utf-8", errors="replace"))
repo_version = pkg.get("version")
except (OSError, json.JSONDecodeError):
pass
if repo_version is None:
return signals
for doc_ver in doc_versions:
if doc_ver != repo_version:
signals.append(
{
"name": "version_skew",
"detail": (
f"doc declares version {doc_ver!r} but repo version is {repo_version!r}"
),
}
)
break # one signal per file is sufficient
return signals
def _edit_recency_vs_churn(self, path: Path) -> List[dict]:
"""Flag files edited very recently relative to their historical git churn.
Signal: file was modified within the last 7 days (mtime) but has a
high commit frequency ( 5 commits in git history), suggesting it is
actively evolving and may have outpaced its documentation.
If no git_log_fn is provided this signal is skipped.
"""
if self._git_log_fn is None:
return []
try:
stat = path.stat()
except OSError:
return []
age_days = (self._now_fn() - stat.st_mtime) / 86400.0
if age_days > 7:
return [] # not recently edited
try:
log_lines = self._git_log_fn(path)
except Exception:
return []
commit_count = len([l for l in log_lines if l.strip()])
if commit_count >= 5:
return [
{
"name": "edit_recency_vs_churn",
"detail": (
f"file modified {age_days:.1f} days ago and has {commit_count} commits"
" — actively changing, may need review"
),
}
]
return []
def _location_signals(self, path: Path, rel: str) -> List[dict]:
"""Flag files whose location suggests staleness risk.
Detects:
- Files named ``*old*``, ``*deprecated*``, ``*legacy*``, ``*obsolete*``
that are NOT inside an archive/ dir (which is excluded).
- Files in a ``docs/`` subdirectory that have no counterpart in the
current codebase module they claim to document (heuristic: if the
filename mentions a module that no longer exists).
"""
signals = []
basename = path.stem.lower()
stale_names = {"old", "deprecated", "legacy", "obsolete", "archive"}
for keyword in stale_names:
if keyword in basename:
signals.append(
{
"name": "stale_name_location",
"detail": (
f"filename '{path.name}' contains '{keyword}'"
"may indicate superseded content"
),
}
)
break
return signals
def _archive_to_live_ratio(self, path: Path) -> List[dict]:
"""Detect high proportion of 'archived' / resolved-problem content.
Intra-doc heuristic: count lines under headings that contain
'archive', 'resolved', 'completed', 'done', 'old', 'deprecated',
'legacy' vs total non-blank lines. If > 60% of content is under
such headings, emit a signal.
"""
signals = []
try:
text = path.read_text(encoding="utf-8", errors="replace")
except OSError:
return signals
lines = text.splitlines()
archive_heading_re = re.compile(
r"^#{1,4}\s+.*\b(archive|resolved|completed|done|old|deprecated|legacy)\b",
re.IGNORECASE,
)
in_archive_section = False
archive_lines = 0
total_lines = 0
for line in lines:
stripped = line.strip()
if not stripped:
continue
total_lines += 1
if re.match(r"^#{1,4}\s+", stripped):
in_archive_section = bool(archive_heading_re.match(stripped))
if in_archive_section:
archive_lines += 1
if total_lines > 0 and (archive_lines / total_lines) > 0.6:
pct = int(100 * archive_lines / total_lines)
signals.append(
{
"name": "archive_to_live_ratio",
"detail": (
f"{pct}% of content is under archive/resolved/completed headings"
),
}
)
return signals
def _frontmatter_markers(self, path: Path) -> List[dict]:
"""Emit signals for notable frontmatter keys that flag provisional content.
Objective markers only: ``status: draft``, ``status: provisional``,
``status: wip``, ``draft: true``. No classification just fact.
"""
signals = []
for key, expected_values in [
("status", {"draft", "provisional", "wip", "todo"}),
("draft", {"true", "yes"}),
]:
value = _parse_frontmatter_value(path, key)
if value and value.lower() in expected_values:
signals.append(
{
"name": "frontmatter_marker",
"detail": f"frontmatter has {key}: {value!r}",
}
)
return signals
# ---------------------------------------------------------------------------
# Production git helper
# ---------------------------------------------------------------------------
def _git_log_real(path: Path) -> List[str]:
"""Return one line per commit touching *path* (commit hash only)."""
try:
result = subprocess.run(
["git", "log", "--oneline", "--follow", "--", str(path)],
capture_output=True,
text=True,
timeout=10,
)
return result.stdout.splitlines()
except Exception:
return []
# ---------------------------------------------------------------------------
# Scanner
# ---------------------------------------------------------------------------
class Scanner:
"""Deterministic doc-hygiene scanner.
Parameters
----------
root : Path
Resolved project root (absolute). All output paths are relative to
this directory.
scope_globs : list[str]
Glob patterns to match candidate files (relative to *root*).
excluded_dirs : list[str]
Entries to prune during walk. A bare entry (no ``/``) is a directory
*name* matched at any depth; an entry of the form ``parent/child`` is a
path-aware parent/child name pair pruned only where the child's
immediate parent matches ``parent`` (depth-independent).
git_log_fn : callable, optional
Injected git-log provider. Default: real git subprocess.
now_fn : callable, optional
Injected clock. Default: ``time.time``.
"""
def __init__(
self,
root: Path,
scope_globs: Optional[List[str]] = None,
excluded_dirs: Optional[List[str]] = None,
git_log_fn: Optional[Callable[[Path], List[str]]] = None,
now_fn: Optional[Callable[[], float]] = None,
) -> None:
self._root = root.resolve()
self._scope_globs = scope_globs if scope_globs is not None else DEFAULT_SCOPE_GLOBS
# Always include .dochygiene in excluded dirs (self-exclusion invariant #9).
# Preserve insertion order so the artifact echoes the canonical default
# order (matches valid_report.json golden fixture).
base_excluded = excluded_dirs if excluded_dirs is not None else DEFAULT_EXCLUDED_DIRS
seen: set = set()
deduped: List[str] = []
for d in list(base_excluded) + [".dochygiene"]:
if d not in seen:
seen.add(d)
deduped.append(d)
self._excluded_dirs: List[str] = deduped
# Partition the exclude list into two matchers (both echoed verbatim in
# the artifact's ``excluded_dirs``):
# * bare names (no "/") → prune any child dir with that exact name.
# * "parent/child" pairs → prune a child dir named ``child`` only
# when its immediate parent is ``parent``,
# anywhere in the tree (path-aware).
# Only the trailing two segments of a "/"-entry are used as the pair; the
# match is depth-independent (not anchored to the project root) so it
# catches e.g. ``doc-hygiene/examples/golden`` in a monorepo.
self._excluded_name_set: set = {d for d in deduped if "/" not in d}
self._excluded_pairs: List[tuple] = []
for d in deduped:
if "/" in d:
parent, child = d.rsplit("/", 1)
parent_name = parent.rsplit("/", 1)[-1] # trailing segment only
self._excluded_pairs.append((parent_name, child))
self._git_log_fn = git_log_fn
self._now_fn = now_fn
self._ignore_patterns: List[str] = _load_ignore_patterns(self._root)
def _is_excluded_child(self, parent_dirpath: str, child_name: str) -> bool:
"""Return True if walking would prune *child_name* under *parent_dirpath*.
Bare-name excludes match on ``child_name`` alone; path-aware excludes
(``parent/child``) match only when the child's immediate parent name
equals ``parent``.
"""
if child_name in self._excluded_name_set:
return True
parent_name = os.path.basename(parent_dirpath)
for ex_parent, ex_child in self._excluded_pairs:
if child_name == ex_child and parent_name == ex_parent:
return True
return False
# ------------------------------------------------------------------
# Public
# ------------------------------------------------------------------
def run(self) -> dict:
"""Execute the scan and return the intermediate artifact dict."""
signal_computer = SignalComputer(
root=self._root,
git_log_fn=self._git_log_fn,
now_fn=self._now_fn,
)
ignore_patterns = self._ignore_patterns
files_scanned = 0
shortlist: List[str] = []
signals_map: dict = {}
for path in self._walk_scoped():
files_scanned += 1
rel = str(path.relative_to(self._root))
# Exclusion pipeline (short-circuit, ordered per D7)
# (1) dir-prune already applied by _walk_scoped at walk time
# (2) ignore-match
if _matches_ignore(rel, ignore_patterns):
continue
# (3) frozen frontmatter
if _is_frozen(path):
continue
# (4) append-only
if _is_append_only(path):
continue
# Survived exclusions — compute signals
sigs = signal_computer.compute(path)
shortlist.append(rel)
if sigs:
signals_map[rel] = sigs
return {
"project_root": str(self._root),
"scope_globs": self._scope_globs,
"excluded_dirs": self._excluded_dirs,
"files_scanned": files_scanned,
"shortlist": shortlist,
"signals": signals_map,
}
# ------------------------------------------------------------------
# Walk
# ------------------------------------------------------------------
def _walk_scoped(self) -> Iterator[Path]:
"""Yield files matching scope_globs, with excluded dirs pruned at walk time."""
for dirpath, dirnames, filenames in os.walk(str(self._root)):
# Prune excluded dirs IN-PLACE so os.walk never descends them (D7).
# Path-aware pairs (e.g. examples/golden) are matched against the
# current dirpath's basename so only the right parent prunes them.
dirnames[:] = [
d for d in dirnames if not self._is_excluded_child(dirpath, d)
]
for filename in filenames:
filepath = Path(dirpath) / filename
rel = filepath.relative_to(self._root)
rel_str = str(rel)
for glob in self._scope_globs:
if _file_matches_glob(rel_str, glob):
yield filepath
break
# ---------------------------------------------------------------------------
# CLI entry point
# ---------------------------------------------------------------------------
def _resolve_project_root(start: Path) -> Path:
"""Walk upward from *start* to find a .git directory; fallback to *start*."""
current = start.resolve()
for parent in [current, *current.parents]:
if (parent / ".git").exists():
return parent
return current
def main(argv: Optional[List[str]] = None) -> int:
import argparse
parser = argparse.ArgumentParser(
description="doc-hygiene scanner — emits intermediate artifact JSON"
)
parser.add_argument(
"--root",
default=None,
help="Project root (default: auto-resolved from cwd)",
)
parser.add_argument(
"--globs",
nargs="*",
default=None,
help="Scope globs (default: **/*.md)",
)
parser.add_argument(
"--excluded-dirs",
nargs="*",
default=None,
help="Directory names to exclude",
)
args = parser.parse_args(argv)
root = Path(args.root) if args.root else _resolve_project_root(Path.cwd())
scanner = Scanner(
root=root,
scope_globs=args.globs,
excluded_dirs=args.excluded_dirs,
git_log_fn=_git_log_real,
)
artifact = scanner.run()
print(json.dumps(artifact, indent=2))
return 0
if __name__ == "__main__":
sys.exit(main())

View File

@ -0,0 +1,319 @@
"""
State store for doc-hygiene.
Provides:
- resolve_project_root(start_dir, fs) pure function, no side effects
- StateStore confines all writes to <project_root>/.dochygiene/
Design invariants honoured:
#3 State lives in-project; no global index; never edit .gitignore.
#4 Report rollover keeps exactly one .md + .json pair.
#6 Deterministic-first; no model invoked here.
#9 (scanner's concern, not ours)
Atomic-write mechanism: write to a temp file in the same directory,
fsync the file descriptor, then os.replace (POSIX-atomic) onto the target.
A concurrent reader therefore observes either the prior complete file or the
new complete file, never a partial write.
"""
from __future__ import annotations
import json
import os
import tempfile
from datetime import datetime, timezone
from pathlib import Path
from typing import Optional, Protocol
# ---------------------------------------------------------------------------
# Lightweight filesystem abstraction used only by resolve_project_root
# so the pure function can be tested without touching real disk.
# ---------------------------------------------------------------------------
class _RootFS(Protocol):
"""Minimal filesystem surface for root resolution."""
def is_dir(self, path: Path) -> bool: ...
def parent(self, path: Path) -> Path: ...
class RealRootFS:
"""Production implementation — delegates to pathlib/os."""
def is_dir(self, path: Path) -> bool:
return path.is_dir()
def parent(self, path: Path) -> Path:
return path.parent
# ---------------------------------------------------------------------------
# Pure root-resolution function (task 3.1)
# ---------------------------------------------------------------------------
def resolve_project_root(start_dir: Path, fs: Optional[_RootFS] = None) -> Path:
"""
Walk upward from *start_dir* (inclusive) looking for a .git directory.
Returns the first ancestor (or start_dir itself) that contains a .git
directory. If no git root is found, returns start_dir unchanged.
This function is PURE: it never calls os.getcwd() or any other
stateful function; all filesystem access goes through *fs*. Production
callers pass os.getcwd() as start_dir; tests pass a fake fs and a
synthetic path.
"""
if fs is None:
fs = RealRootFS()
current = Path(start_dir)
while True:
if fs.is_dir(current / ".git"):
return current
parent = fs.parent(current)
if parent == current:
# Reached the filesystem root without finding .git.
break
current = parent
return Path(start_dir)
# ---------------------------------------------------------------------------
# Clock abstraction (task 3.3 / design D3)
# ---------------------------------------------------------------------------
class _Clock(Protocol):
"""Returns the current UTC datetime."""
def now(self) -> datetime: ...
class RealClock:
"""Production clock — returns the real UTC time."""
def now(self) -> datetime:
return datetime.now(timezone.utc)
# ---------------------------------------------------------------------------
# StateStore (tasks 3.23.5)
# ---------------------------------------------------------------------------
_STATE_FILE = "state.json"
_REPORT_JSON = "report.json"
_REPORT_MD = "report.md"
# The set of filenames that are *not* report files and must never be deleted
# during rollover. Explicit allowlist is safer than trying to infer.
_NON_REPORT_FILES = {_STATE_FILE}
class StateStore:
"""
Manages all persistent state for doc-hygiene within a single project.
All writes are confined to <project_root>/.dochygiene/ (invariant #3).
No global index is maintained; each project has its own independent store.
The store never opens or edits .gitignore (invariant #3).
Parameters
----------
project_root:
The resolved project root directory (output of resolve_project_root).
Injected so the store is testable with a tmp_path.
clock:
Provides "now". Injected for testability (design D3).
"""
TIMESTAMPS = ("last_check", "last_clean", "last_reminded")
def __init__(self, project_root: Path, clock: Optional[_Clock] = None) -> None:
self._root = Path(project_root)
self._clock = clock or RealClock()
self._state_dir = self._root / ".dochygiene"
# ------------------------------------------------------------------
# Directory bootstrap
# ------------------------------------------------------------------
def _ensure_state_dir(self) -> Path:
"""Create .dochygiene/ if it does not exist. Never touches .gitignore."""
self._state_dir.mkdir(parents=True, exist_ok=True)
return self._state_dir
# ------------------------------------------------------------------
# Atomic write (task 3.4 / design D9)
# ------------------------------------------------------------------
def _atomic_write(self, target: Path, data: bytes) -> None:
"""
Write *data* to *target* atomically.
Strategy: write to a NamedTemporaryFile in the same directory,
fsync, then os.replace onto the target. os.replace is POSIX-atomic
within one filesystem, so a concurrent reader sees either the prior
complete file or the new complete file, never a partial write.
"""
# Confirm the target is under our managed directory (confinement check).
self._assert_confined(target)
target_dir = target.parent
target_dir.mkdir(parents=True, exist_ok=True)
fd, tmp_path_str = tempfile.mkstemp(
dir=str(target_dir),
prefix="." + target.name + ".tmp_",
)
tmp_path = Path(tmp_path_str)
try:
with os.fdopen(fd, "wb") as fh:
fh.write(data)
fh.flush()
os.fsync(fh.fileno())
os.replace(tmp_path_str, str(target))
except Exception:
# Best-effort cleanup on failure; do not mask the original error.
try:
tmp_path.unlink(missing_ok=True)
except OSError:
pass
raise
def _assert_confined(self, path: Path) -> None:
"""Raise ValueError if *path* is not under self._state_dir."""
try:
path.resolve().relative_to(self._state_dir.resolve())
except ValueError:
raise ValueError(
f"StateStore attempted to write outside .dochygiene/: {path}"
)
# ------------------------------------------------------------------
# State JSON helpers
# ------------------------------------------------------------------
def _state_path(self) -> Path:
return self._state_dir / _STATE_FILE
def _read_state(self) -> dict:
"""Read state.json; return {} if missing or unreadable (never raises)."""
path = self._state_path()
try:
return json.loads(path.read_bytes())
except (FileNotFoundError, json.JSONDecodeError, OSError):
return {}
def _write_state(self, state: dict) -> None:
self._ensure_state_dir()
data = json.dumps(state, indent=2, sort_keys=True).encode()
self._atomic_write(self._state_path(), data)
# ------------------------------------------------------------------
# Lifecycle timestamps (task 3.3)
# ------------------------------------------------------------------
def get_timestamp(self, key: str) -> Optional[datetime]:
"""
Return the stored datetime for *key*, or None if absent/unset.
The value is stored as an ISO-8601 string in state.json.
"""
if key not in self.TIMESTAMPS:
raise ValueError(f"Unknown timestamp key: {key!r}")
raw = self._read_state().get(key)
if raw is None:
return None
return datetime.fromisoformat(raw)
def set_timestamp(self, key: str, value: Optional[datetime] = None) -> None:
"""
Write *key* to state.json.
If *value* is None, uses the injected clock's now().
The value is serialised as an ISO-8601 string (UTC).
"""
if key not in self.TIMESTAMPS:
raise ValueError(f"Unknown timestamp key: {key!r}")
ts = value if value is not None else self._clock.now()
# Normalise to UTC ISO-8601 string.
if ts.tzinfo is None:
ts = ts.replace(tzinfo=timezone.utc)
state = self._read_state()
state[key] = ts.isoformat()
self._write_state(state)
# Convenience shorthands
def set_last_check(self, value: Optional[datetime] = None) -> None:
self.set_timestamp("last_check", value)
def get_last_check(self) -> Optional[datetime]:
return self.get_timestamp("last_check")
def set_last_clean(self, value: Optional[datetime] = None) -> None:
self.set_timestamp("last_clean", value)
def get_last_clean(self) -> Optional[datetime]:
return self.get_timestamp("last_clean")
def set_last_reminded(self, value: Optional[datetime] = None) -> None:
self.set_timestamp("last_reminded", value)
def get_last_reminded(self) -> Optional[datetime]:
return self.get_timestamp("last_reminded")
# ------------------------------------------------------------------
# Report rollover (task 3.5 / design D10)
# ------------------------------------------------------------------
def _delete_existing_reports(self) -> None:
"""
Delete any existing report.json and report.md in .dochygiene/.
Only report files (report.json, report.md) are deleted.
state.json and any other files are never touched.
"""
for name in (_REPORT_JSON, _REPORT_MD):
p = self._state_dir / name
try:
p.unlink()
except FileNotFoundError:
pass
def write_report(self, json_blob: str, md_blob: str) -> None:
"""
Write a new report pair, atomically, after deleting any prior pair.
After this call exactly one .json and one .md report file exist in
.dochygiene/ (invariant #4).
Parameters
----------
json_blob:
The machine-readable report JSON (as a string).
md_blob:
The human-readable report Markdown.
"""
self._ensure_state_dir()
# Delete prior pair first (rollover).
self._delete_existing_reports()
# Atomically write the new pair.
self._atomic_write(
self._state_dir / _REPORT_JSON,
json_blob.encode(),
)
self._atomic_write(
self._state_dir / _REPORT_MD,
md_blob.encode(),
)
def read_report(self) -> Optional[tuple[str, str]]:
"""
Return (json_blob, md_blob) if a report exists, else None.
"""
json_path = self._state_dir / _REPORT_JSON
md_path = self._state_dir / _REPORT_MD
try:
return json_path.read_text(), md_path.read_text()
except FileNotFoundError:
return None

View File

@ -0,0 +1,280 @@
#!/usr/bin/env python3
"""
token_estimator.py deterministic, local token estimation for doc-hygiene.
Produces the `raw_tokens` count that feeds each report entry's
`token_estimate` field (see the frozen report schema:
`openspec/specs/report-schema/spec.md`). This is the deterministic seam
required by invariant #6 — NO model, NO network/API call, ever.
Accuracy goal
-------------
This is BALLPARK relative ranking of documentation bloat, not billing-grade
tokenization. A swappable backend lets a heavier (more accurate) tokenizer be
used when available, while a zero-dependency heuristic guarantees the seam
always works.
Backends (pluggable via the `TokenEstimator` ABC)
-------------------------------------------------
- `HeuristicEstimator` ALWAYS available. Pure-Python ~4-chars/token rule
(`ceil(len(text) / 4)`). Zero third-party dependencies.
- `TiktokenEstimator` OPTIONAL accuracy upgrade. Uses tiktoken's
`o200k_base` encoding. Only selected when tiktoken is importable AND its
vocab is vendored in the local cache (no cold-cache network download).
`default_estimator()` performs the selection and NEVER raises it always
returns a working estimator, falling back to the heuristic when tiktoken or
its vendored vocab is unavailable. The active backend is introspectable via
`.name` so the `check` skill can record which tokenizer produced the counts.
Offline / determinism guarantee (invariant #6)
----------------------------------------------
tiktoken has no "offline only" flag: calling `get_encoding("o200k_base")`
with a cold cache silently fetches the vocab over the network. We therefore
DETECT BEFORE FETCH we set `TIKTOKEN_CACHE_DIR` to a path inside the plugin
and check that the expected cache file already exists *before* importing the
encoding. If it is absent we fall back to the heuristic rather than triggering
a download.
CACHE-FILENAME GOTCHA: tiktoken names its cache file by
`sha1(blobpath_url).hexdigest()` NOT by the sha256 content hash and NOT by a
human-readable name. For `o200k_base` the URL is
`https://openaipublic.blob.core.windows.net/encodings/o200k_base.tiktoken`,
which hashes to the filename:
fb374d419588a4632f3f557e76b4b70aebbca790
A vendored vocab MUST live at `<cache_dir>/fb374d419588a4632f3f557e76b4b70aebbca790`
or the cache never hits and tiktoken falls back to a network fetch.
Pre-warming the vendored vocab (one-time, online, optional)
-----------------------------------------------------------
TIKTOKEN_CACHE_DIR=scripts/tiktoken_cache \
python -c "import tiktoken; tiktoken.get_encoding('o200k_base')"
The ~2 MB vocab blob is intentionally NOT committed to git. When it is absent
the estimator runs on the heuristic backend; this keeps the repo lean and the
runtime offline-safe.
"""
from __future__ import annotations
import hashlib
import json
import math
import os
import sys
from abc import ABC, abstractmethod
from pathlib import Path
from typing import Optional
# ---------------------------------------------------------------------------
# Constants — tiktoken vendored-cache resolution
# ---------------------------------------------------------------------------
# The encoding we standardise on (current OpenAI tokenizer; close enough for a
# relative ballpark of Claude-context bloat — accuracy goal is ranking, not
# billing).
_O200K_BASE_NAME = "o200k_base"
_O200K_BASE_BLOBPATH = (
"https://openaipublic.blob.core.windows.net/encodings/o200k_base.tiktoken"
)
# tiktoken's on-disk cache file is named sha1(blobpath_url).hexdigest().
# Pre-computed here so the offline pre-check needs no tiktoken import.
_O200K_BASE_CACHE_FILENAME = hashlib.sha1(
_O200K_BASE_BLOBPATH.encode()
).hexdigest() # == "fb374d419588a4632f3f557e76b4b70aebbca790"
# Default vendored-cache directory inside the plugin (sibling of this file).
_DEFAULT_CACHE_DIR = Path(__file__).resolve().parent / "tiktoken_cache"
# ---------------------------------------------------------------------------
# Estimator abstraction
# ---------------------------------------------------------------------------
class TokenEstimator(ABC):
"""Pluggable, deterministic token-count backend.
Subclasses implement `estimate(text)`; the file convenience and report
wrapper are shared. Every backend exposes `.name` so callers can record
which tokenizer produced a count.
"""
#: Short, stable backend identifier (e.g. "heuristic", "tiktoken:o200k_base").
name: str = "abstract"
@abstractmethod
def estimate(self, text: str) -> int:
"""Return the token count for *text* (>= 0). Deterministic, no I/O."""
raise NotImplementedError
def estimate_file(self, path: Path) -> int:
"""Return the token count of *path*'s UTF-8 contents.
Decode errors are tolerated (``errors="replace"``) to match the
scanner's read convention, so arbitrary/binary-ish docs never raise.
A missing/unreadable file yields 0.
"""
try:
text = Path(path).read_text(encoding="utf-8", errors="replace")
except OSError:
return 0
return self.estimate(text)
def estimate_for_report(self, text: str) -> dict:
"""Return a schema-shaped ``token_estimate`` object for *text*.
v1 populates only `raw_tokens` (the required field). The
injection-frequency weighting fields are the v2 bonus and are emitted
as `null` here so the shape is explicit for the `check` skill.
See `openspec/specs/report-schema/spec.md` (Per-Entry Token Estimate).
"""
return {
"raw_tokens": self.estimate(text),
"injection_frequency": None,
"weighted_tokens": None,
}
class HeuristicEstimator(TokenEstimator):
"""Zero-dependency ~4-chars/token estimator: ``ceil(len(text) / 4)``.
Boundaries: ``""`` -> 0, 4 chars -> 1, 5 chars -> 2. Always available;
this is the guaranteed fallback that keeps the deterministic seam working
with no third-party packages installed.
"""
name = "heuristic"
#: Average characters per token for the heuristic. ~4 is the long-standing
#: rule-of-thumb for English prose / markdown.
CHARS_PER_TOKEN = 4
def estimate(self, text: str) -> int:
if not text:
return 0
return math.ceil(len(text) / self.CHARS_PER_TOKEN)
class TiktokenEstimator(TokenEstimator):
"""Accuracy-upgrade backend wrapping a tiktoken encoding.
Constructed with an already-loaded encoding object so this class performs
no import or cache resolution itself (that lives in `default_estimator`,
which guarantees the encoding came from the vendored cache, not a network
fetch). `disallowed_special=()` ensures arbitrary markdown containing
special-token-looking substrings (e.g. ``<|endoftext|>``) never raises.
"""
def __init__(self, encoding, encoding_name: str = _O200K_BASE_NAME) -> None:
self._encoding = encoding
self.name = f"tiktoken:{encoding_name}"
def estimate(self, text: str) -> int:
if not text:
return 0
return len(self._encoding.encode(text, disallowed_special=()))
# ---------------------------------------------------------------------------
# Backend selection (never raises)
# ---------------------------------------------------------------------------
def _vendored_vocab_present(cache_dir: Path) -> bool:
"""Return True iff the o200k_base vocab is already in *cache_dir*.
Pure filesystem check by the sha1-of-blobpath filename see the
CACHE-FILENAME GOTCHA in the module docstring. This is the detect-before-
fetch gate that keeps the seam offline (invariant #6).
"""
return (cache_dir / _O200K_BASE_CACHE_FILENAME).is_file()
def default_estimator(cache_dir: Optional[Path] = None) -> TokenEstimator:
"""Return the best available working estimator. NEVER raises.
Selection order:
1. `TiktokenEstimator` only if tiktoken is importable AND the
o200k_base vocab is vendored in *cache_dir* (so loading it triggers
no network fetch).
2. `HeuristicEstimator` the always-available fallback otherwise.
Parameters
----------
cache_dir:
Directory to resolve the vendored tiktoken vocab from. Defaults to
the plugin's `scripts/tiktoken_cache/`. Injectable so tests can point
at an empty dir to force (and assert) the heuristic fallback without
any network access.
"""
resolved_cache = Path(cache_dir) if cache_dir is not None else _DEFAULT_CACHE_DIR
# Detect-before-fetch: bail to heuristic unless the vocab is already local.
if not _vendored_vocab_present(resolved_cache):
return HeuristicEstimator()
try:
# Point tiktoken at the vendored cache BEFORE importing/using it.
os.environ["TIKTOKEN_CACHE_DIR"] = str(resolved_cache)
import tiktoken # noqa: WPS433 (optional dependency, imported lazily)
encoding = tiktoken.get_encoding(_O200K_BASE_NAME)
return TiktokenEstimator(encoding, _O200K_BASE_NAME)
except Exception:
# Import error, corrupt cache, or any other failure — fall back.
# The seam must always yield a working estimator (invariant #6).
return HeuristicEstimator()
# ---------------------------------------------------------------------------
# CLI entry point
# ---------------------------------------------------------------------------
def main(argv: Optional[list] = None) -> int:
import argparse
parser = argparse.ArgumentParser(
description=(
"doc-hygiene token estimator — emits a JSON token estimate for a "
"file (deterministic, no model, no network)."
)
)
parser.add_argument("path", help="Path to the file to estimate.")
parser.add_argument(
"--cache-dir",
default=None,
help="Vendored tiktoken cache dir (default: scripts/tiktoken_cache/).",
)
args = parser.parse_args(argv)
target = Path(args.path)
if not target.is_file():
print(
json.dumps({"error": f"not a file: {args.path}"}, indent=2),
file=sys.stderr,
)
return 1
estimator = default_estimator(
cache_dir=Path(args.cache_dir) if args.cache_dir else None
)
raw_tokens = estimator.estimate_file(target)
output = {
"path": str(target),
"backend": estimator.name,
"token_estimate": {
"raw_tokens": raw_tokens,
"injection_frequency": None,
"weighted_tokens": None,
},
}
print(json.dumps(output, indent=2))
return 0
if __name__ == "__main__":
sys.exit(main())

View File

@ -0,0 +1,366 @@
#!/usr/bin/env python3
"""
validate_report.py Machine-report schema validator for doc-hygiene.
Usage:
python validate_report.py <path-to-report.json>
Exit codes:
0 report is valid
1 report is invalid (violations reported in JSON output)
2 usage error (missing argument, file not found, not JSON)
Output: structured JSON on stdout with keys:
valid: bool
violations: list of {field, message}
"""
import json
import sys
from dataclasses import dataclass, field
from typing import Any
# ---------------------------------------------------------------------------
# Safety-tier derivation (invariant #10)
# ---------------------------------------------------------------------------
def derive_safety_tier(op_type: str, is_destructive: bool, is_reversible: bool) -> str:
"""
Deterministic derivation of safety_tier from op characterisation.
Never returns 'auto' for a generative, destructive, or irreversible op.
"""
if op_type == "generative":
return "confirm"
if is_destructive:
return "confirm"
if not is_reversible:
return "confirm"
return "auto"
# ---------------------------------------------------------------------------
# Kind table: closed enum + per-kind required fields + inherent booleans
# ---------------------------------------------------------------------------
@dataclass
class KindSpec:
is_destructive: bool
is_reversible: bool
required_fields: list # fields required directly on exact_edit (besides kind)
has_anchor: bool # whether anchor sub-object is required
KIND_TABLE: dict[str, KindSpec] = {
"delete-range": KindSpec(
is_destructive=True,
is_reversible=False,
required_fields=["anchor"],
has_anchor=True,
),
"move-to-archive": KindSpec(
is_destructive=False,
is_reversible=True,
required_fields=["anchor", "dest_path"],
has_anchor=True,
),
"insert-frontmatter": KindSpec(
is_destructive=False,
is_reversible=True,
required_fields=["key", "value"],
has_anchor=False,
),
"replace-text": KindSpec(
is_destructive=False,
is_reversible=True,
required_fields=["anchor", "match", "replacement"],
has_anchor=True,
),
"dedupe": KindSpec(
is_destructive=False,
is_reversible=True,
required_fields=["anchor", "canonical_ref"],
has_anchor=True,
),
}
STALE_SUBTYPES = {"contradicted", "orphaned", "superseded", "provisional", "completed-in-place", "duplicated"}
BLOAT_SUBTYPES = {"distill", "split", "freeze"}
# ---------------------------------------------------------------------------
# Validator
# ---------------------------------------------------------------------------
class ReportValidator:
"""Validates a machine-report JSON dict against the frozen schema contract."""
def __init__(self, report: Any):
self._report = report
self._violations: list[dict] = []
def _add(self, field_path: str, message: str) -> None:
self._violations.append({"field": field_path, "message": message})
def validate(self) -> list[dict]:
"""Run all checks; collect all violations. Returns list of violations."""
r = self._report
if not isinstance(r, dict):
self._add("(root)", "Report must be a JSON object")
return self._violations
# Rule 1: Envelope fields
self._check_envelope(r)
# Rule 2: shortlist precedes entries
shortlist = r.get("shortlist", [])
if not isinstance(shortlist, list):
self._add("shortlist", "Must be an array")
shortlist = []
entries = r.get("entries", [])
if not isinstance(entries, list):
self._add("entries", "Must be an array")
entries = []
shortlist_set = set(shortlist) if isinstance(shortlist, list) else set()
for i, entry in enumerate(entries):
if isinstance(entry, dict):
self._check_entry(entry, i, shortlist_set)
else:
self._add(f"entries[{i}]", "Entry must be a JSON object")
return self._violations
def _check_envelope(self, r: dict) -> None:
for key in ("schema_version", "tool_version", "generated_at", "scan", "shortlist", "entries"):
if key not in r:
self._add(key, f"Required top-level field '{key}' is missing")
scan = r.get("scan")
if scan is not None:
if not isinstance(scan, dict):
self._add("scan", "Must be a JSON object")
else:
for key in ("project_root", "scope_globs", "excluded_dirs", "files_scanned"):
if key not in scan:
self._add(f"scan.{key}", f"Required scan field '{key}' is missing")
def _check_entry(self, entry: dict, idx: int, shortlist_set: set) -> None:
pfx = f"entries[{idx}]"
# Rule 3: Required entry fields
required = (
"path", "category", "signals", "op", "op_type",
"is_destructive", "is_reversible", "safety_tier", "token_estimate",
)
for f in required:
if f not in entry:
self._add(f"{pfx}.{f}", f"Required entry field '{f}' is missing")
# Rule 2: entry path must be in shortlist
path = entry.get("path")
if path is not None and shortlist_set and path not in shortlist_set:
self._add(f"{pfx}.path", f"Entry path '{path}' is not in shortlist")
# Rule 4: Category closed enum
category = entry.get("category")
if category is not None:
self._check_category(category, pfx)
# Rule 5: Scalar enums
op_type = entry.get("op_type")
if op_type is not None and op_type not in ("deterministic", "generative"):
self._add(f"{pfx}.op_type", f"op_type must be 'deterministic' or 'generative', got '{op_type}'")
safety_tier = entry.get("safety_tier")
if safety_tier is not None and safety_tier not in ("auto", "confirm"):
self._add(f"{pfx}.safety_tier", f"safety_tier must be 'auto' or 'confirm', got '{safety_tier}'")
is_destructive = entry.get("is_destructive")
if is_destructive is not None and not isinstance(is_destructive, bool):
self._add(f"{pfx}.is_destructive", "is_destructive must be a boolean")
is_reversible = entry.get("is_reversible")
if is_reversible is not None and not isinstance(is_reversible, bool):
self._add(f"{pfx}.is_reversible", "is_reversible must be a boolean")
# Rules 6, 7, 8, 9: exact_edit checks (only if op_type valid)
if op_type in ("deterministic", "generative"):
self._check_exact_edit_biconditional(entry, pfx, op_type)
# Rule 9: safety_tier derivation match (only when all inputs are valid)
if (
op_type in ("deterministic", "generative")
and isinstance(is_destructive, bool)
and isinstance(is_reversible, bool)
and safety_tier in ("auto", "confirm")
):
expected_tier = derive_safety_tier(op_type, is_destructive, is_reversible)
if safety_tier != expected_tier:
self._add(
f"{pfx}.safety_tier",
f"safety_tier mismatch: recorded '{safety_tier}' but derivation yields '{expected_tier}' "
f"for (op_type={op_type}, is_destructive={is_destructive}, is_reversible={is_reversible})",
)
# Rule 10: token_estimate.raw_tokens required
token_estimate = entry.get("token_estimate")
if token_estimate is not None:
self._check_token_estimate(token_estimate, pfx)
def _check_category(self, category: Any, pfx: str) -> None:
if not isinstance(category, dict):
self._add(f"{pfx}.category", "category must be a JSON object with 'class' and 'subtype'")
return
cls = category.get("class")
subtype = category.get("subtype")
if cls is None:
self._add(f"{pfx}.category.class", "category.class is required")
elif cls not in ("stale", "bloat"):
self._add(f"{pfx}.category.class", f"category.class must be 'stale' or 'bloat', got '{cls}'")
if subtype is None:
self._add(f"{pfx}.category.subtype", "category.subtype is required")
elif cls == "stale" and subtype not in STALE_SUBTYPES:
self._add(
f"{pfx}.category.subtype",
f"For class 'stale', subtype must be one of {sorted(STALE_SUBTYPES)}, got '{subtype}'",
)
elif cls == "bloat" and subtype not in BLOAT_SUBTYPES:
self._add(
f"{pfx}.category.subtype",
f"For class 'bloat', subtype must be one of {sorted(BLOAT_SUBTYPES)}, got '{subtype}'",
)
def _check_exact_edit_biconditional(self, entry: dict, pfx: str, op_type: str) -> None:
"""Rule 6: exact_edit present IFF op_type == deterministic."""
has_exact_edit = "exact_edit" in entry
exact_edit = entry.get("exact_edit")
if op_type == "generative" and has_exact_edit:
self._add(
f"{pfx}.exact_edit",
"generative entry must NOT carry exact_edit (invariant #11)",
)
return # No point validating the structure of an edit that shouldn't exist
if op_type == "deterministic" and not has_exact_edit:
self._add(
f"{pfx}.exact_edit",
"deterministic entry MUST carry exact_edit (invariant #11)",
)
return
if op_type == "deterministic" and has_exact_edit:
self._check_exact_edit(exact_edit, pfx, entry)
def _check_exact_edit(self, exact_edit: Any, pfx: str, entry: dict) -> None:
"""Rules 7 and 8: kind enum, per-kind required fields, characterisation match."""
if not isinstance(exact_edit, dict):
self._add(f"{pfx}.exact_edit", "exact_edit must be a JSON object")
return
kind = exact_edit.get("kind")
if kind is None:
self._add(f"{pfx}.exact_edit.kind", "exact_edit.kind is required")
return
if kind not in KIND_TABLE:
self._add(
f"{pfx}.exact_edit.kind",
f"Unknown exact_edit.kind '{kind}'; valid kinds: {sorted(KIND_TABLE)}",
)
return
spec = KIND_TABLE[kind]
# Rule 7: Per-kind required sub-fields
for req_field in spec.required_fields:
if req_field not in exact_edit:
self._add(
f"{pfx}.exact_edit.{req_field}",
f"exact_edit of kind '{kind}' requires field '{req_field}'",
)
# Anchor must have start_line and end_line
if spec.has_anchor and "anchor" in exact_edit:
anchor = exact_edit["anchor"]
if not isinstance(anchor, dict):
self._add(f"{pfx}.exact_edit.anchor", "anchor must be a JSON object")
else:
for ak in ("start_line", "end_line"):
if ak not in anchor:
self._add(f"{pfx}.exact_edit.anchor.{ak}", f"anchor.{ak} is required for kind '{kind}'")
# expected_sha256 required for anchor-bearing kinds
if spec.has_anchor and "expected_sha256" not in exact_edit:
self._add(
f"{pfx}.exact_edit.expected_sha256",
f"exact_edit of kind '{kind}' (anchor-bearing) requires 'expected_sha256'",
)
# Rule 8: Kind characterisation match — entry's booleans must equal kind's inherent pair
entry_is_destructive = entry.get("is_destructive")
entry_is_reversible = entry.get("is_reversible")
if isinstance(entry_is_destructive, bool) and entry_is_destructive != spec.is_destructive:
self._add(
f"{pfx}.is_destructive",
f"kind='{kind}' requires is_destructive={spec.is_destructive}, got {entry_is_destructive}",
)
if isinstance(entry_is_reversible, bool) and entry_is_reversible != spec.is_reversible:
self._add(
f"{pfx}.is_reversible",
f"kind='{kind}' requires is_reversible={spec.is_reversible}, got {entry_is_reversible}",
)
def _check_token_estimate(self, token_estimate: Any, pfx: str) -> None:
if not isinstance(token_estimate, dict):
self._add(f"{pfx}.token_estimate", "token_estimate must be a JSON object")
return
if "raw_tokens" not in token_estimate:
self._add(f"{pfx}.token_estimate.raw_tokens", "token_estimate.raw_tokens is required in v1")
else:
raw = token_estimate["raw_tokens"]
if not isinstance(raw, (int, float)):
self._add(f"{pfx}.token_estimate.raw_tokens", "raw_tokens must be a number")
# ---------------------------------------------------------------------------
# Entry point
# ---------------------------------------------------------------------------
def main() -> None:
if len(sys.argv) != 2:
print(json.dumps({"error": "Usage: validate_report.py <path-to-report.json>"}))
sys.exit(2)
path = sys.argv[1]
try:
with open(path, "r", encoding="utf-8") as fh:
report = json.load(fh)
except FileNotFoundError:
print(json.dumps({"error": f"File not found: {path}"}))
sys.exit(2)
except json.JSONDecodeError as exc:
print(json.dumps({"error": f"Invalid JSON: {exc}"}))
sys.exit(2)
validator = ReportValidator(report)
violations = validator.validate()
result = {
"valid": len(violations) == 0,
"violations": violations,
}
print(json.dumps(result, indent=2))
sys.exit(0 if result["valid"] else 1)
if __name__ == "__main__":
main()

View File

@ -0,0 +1,18 @@
# skills/
User-invoked skills for the doc-hygiene plugin. Skills own the multi-step
workflows that mix deterministic scripts (invariant #6 — no model) with the
narrow model steps (classification, distillation). They are dispatched by the
`/hygiene` command, never by the `SessionStart` hook (invariant #1 — the hook
only reminds).
## Contents
| Path | Purpose |
|------|---------|
| `hygiene-check/SKILL.md` | The `check` orchestration: **scan → classify → finalize → validate → write → stamp**. (D) `scanner.py` produces the signal-bearing shortlist; candidates = the keys of `signals` (shortlist paths with zero signals are presumptively cleared, never read by the model). (M) ONE **Sonnet** subagent classifies all candidates via `workflows/classify-candidates.md`, returning slim per-file proposals (judgment only); single-file **Opus** escalation on low-confidence hard distinctions (stale-vs-bloat, delete-vs-rewrite). (logic) `--category` filter applied at the entry stage *after* classification. (D) `report_builder.py` finalizes — fills the four guardrail fields the model must not author (`expected_sha256`, `safety_tier`, `is_destructive`/`is_reversible`, `raw_tokens`) and emits a schema-valid machine report + human-report skeleton. (D) `validate_report.py` validates **on the scratch path BEFORE writing** (write_report is destructive-first; invariant #4). (D) `StateStore.write_report` does the rollover write and `set_last_check` stamps `last_check` = the validated report's own `generated_at`. All intermediates live in a session scratch dir; nothing touches `.dochygiene/` until the validated write. **LOOP GUARD:** the subagent prompt points to `workflows/classify-candidates.md`, NEVER to `SKILL.md`. **SUBAGENT AUTHORIZATION:** every subagent dispatch includes an explicit terminal-authorization directive (REPORT-AND-EXIT, never block). |
| `hygiene-check/workflows/classify-candidates.md` | Self-contained subagent workflow for the classification step. Defines the slim proposal object (`path`, `category` {class, subtype}, pass-through `signals`, `op`, `op_type`, optional `exact_edit`/`reducible_range`, `gloss`, `confidence`, `escalate`), the closed enums, the situation→kind→tier decision table, and the per-kind required `exact_edit` fields. The subagent reads each file and returns judgment only — it never authors hashes, token counts, safety tiers, or reversibility. |
| `hygiene-clean/SKILL.md` | The `clean` orchestration: **load → validate → filter → gate → preflight → apply-deterministic → apply-generative → commit → stamp**. (D) `StateStore` loads and re-validates the machine report. (logic) Incompatible-pair detection (files with both generative + deterministic ops) and partition by `safety_tier`/`op_type`. (M-GATE) Confirm gate for `confirm`-tier entries; auto-tier entries apply silently. (D) Git preflight: record baseline, detect untracked files (which get skipped). (D) `patch_applier.py` applies deterministic entries mechanically; mtime-guarded, output include `applied`/`skipped`/`failed`. (M) Sonnet subagent distills each generative entry via `workflows/distill.md` (LOOP-GUARD + SUBAGENT AUTHORIZATION — terminal, REPORT-AND-EXIT). (D) `git-context commit-apply` produces exactly one cleanup commit. (D) `StateStore.set_last_clean` stamps the commit instant. Scoped by `--scope` (glob-or-path) and `--category` (class or subtype), both entry-stage filters. On failure (rollback-worthy): all intermediate mutations reverted to baseline. |
| `hygiene-clean/workflows/distill.md` | Self-contained subagent workflow for generative distillation. Receives live file contents (not disk-read), category, op, and signals. Performs `distill` (condense in place, preserve frontmatter and structure, ~4060% target length), `split` (extract to archive + pointer, archive path under `archive/`), or prose-rewrite ops (`provisional`, `contradicted`). Returns structured output (`DISTILL_RESULT_START`/`END` for single-file; `SPLIT_PRIMARY_START`/`END` + `SPLIT_ARCHIVE_DEST` + `SPLIT_ARCHIVE_START`/`END` for split). Never writes files or calls git; never re-reads disk. |
## Planned additions (future changes)

View File

@ -0,0 +1,303 @@
---
name: hygiene-check
description: Scan the project for stale and bloated documentation and write a hygiene report. Runs the deterministic scanner, dispatches a Sonnet subagent to classify only the signal-bearing candidates, finalizes/validates the machine report deterministically, then writes the report pair and stamps `last_check`. Invoked by `/hygiene check [--scope <glob-or-path>] [--category <class|subtype>]`.
---
# Hygiene Check Skill
Orchestrates one documentation-hygiene check: **scan → classify → finalize →
validate → write → stamp**. The scan, finalize, validation, write, and stamp are
deterministic scripts (invariant #6 — no model). Only the per-file
classification is a model step, dispatched to a **Sonnet** subagent.
All scripts live under `${CLAUDE_PLUGIN_ROOT}/scripts/`. Run them with `python3`
from the user's project directory (`cwd`), which is where the project root is
resolved. Use the session scratchpad directory for all intermediate artifacts —
**never** write to `.dochygiene/` until the validated write step.
> **Precondition:** this skill requires the `CLAUDE_PLUGIN_ROOT` environment
> variable to be set (Claude Code sets it at runtime). Every script path and the
> Step 6/7 `python3 -c` invocations resolve against it; if it is unset, abort the
> run rather than guessing a path.
> Pick a scratch dir once and reuse it for the whole run, e.g.
> `SCRATCH="$(mktemp -d)"`. The scan artifact, the subagent proposals, and the
> *unvalidated* report pair all live there.
## Arguments
Passed through from `/hygiene check`:
- `--scope <glob-or-path>` — narrow the scan. A glob (contains `*`) maps to the
scanner's `--globs`. A **bare path** does NOT map cleanly to a `--globs` value
(the scanner's glob matcher is unreliable for mid-pattern `**`), so for a bare
path run the scanner unscoped (default `**/*.md`) and then **drop shortlist and
`signals` entries whose path does not start with `<path>/`** before Step 2.
Record the effective scope in your Step 8 summary.
- `--category <class|subtype>` — filter which **entries** are produced. The
scanner is **category-agnostic** — a signal like `version_skew` can map to
several classes/subtypes — so this filter is applied **after classification**,
at the entry stage (Step 3.5), NEVER at candidate selection. `class` is `stale`
or `bloat`; `subtype` is one of the closed enum values below.
If no arguments are given, the scan uses defaults (`**/*.md`, default excludes).
## Workflow
### Step 0 — (D / M-GATE) Gitignore preflight
Check whether `.dochygiene/` is already git-ignored, and offer to add it if not.
```bash
ROOT="$(git rev-parse --show-toplevel 2>/dev/null)" || ROOT=""
```
Three cases:
- **No git root** (`ROOT` empty — project is not a git repo): skip silently. `resolve_project_root` falls back to cwd; no `.gitignore` offer is meaningful.
- **Already ignored** (`git -C "$ROOT" check-ignore -q .dochygiene` exits `0`): silent no-op. Proceed to Step 1.
- **Not ignored** (exit `1`): present the one-line offer:
> `doc-hygiene` stores its state and report under `.dochygiene/` at the project root. Per invariant #3, this directory should be gitignored so it doesn't appear as untracked in your repo. Shall I append `.dochygiene/` to `<ROOT>/.gitignore`? (yes/no)
**Only on explicit confirmation ("yes"):** append as follows — never reorder or rewrite existing entries:
```bash
if [ -s "$ROOT/.gitignore" ] && [ -n "$(tail -c1 "$ROOT/.gitignore")" ]; then
printf '\n' >> "$ROOT/.gitignore"
fi
printf '.dochygiene/\n' >> "$ROOT/.gitignore"
```
(Creates `.gitignore` if absent; appends with a leading newline only when the file is non-empty and doesn't already end in one.)
**If the user declines:** proceed without editing. Note that `.dochygiene/` may appear as untracked/dirty in `git status` until ignored.
> **Do NOT append without explicit user confirmation.** (Invariant #3.)
### Step 1 — (D) Scan
Run the scanner, capturing its stdout artifact to the scratch dir:
```bash
python3 "${CLAUDE_PLUGIN_ROOT}/scripts/scanner.py" [--globs <glob> ...] > "$SCRATCH/scan.json"
```
- Omit `--globs` when there is no `--scope`.
- The scanner auto-resolves the project root from `cwd` and applies default
excludes (incl. `.dochygiene/`). Do not pass `--root`.
The artifact is `{ project_root, scope_globs, excluded_dirs, files_scanned,
shortlist, signals }`. `signals` is an object keyed by project-root-relative path:
`{ "<path>": [ { "name": "<signal>", "detail": "<text>" }, ... ] }`.
### Step 2 — (D / logic) Select candidates
Candidates = **the keys of `signals`** (signal-bearing paths only). Paths that are
in `shortlist` but absent from `signals` have **zero signals**: they are
**presumptively cleared** — they are NOT read by the model and produce no entries.
- Do **NOT** filter candidates by `--category` here. The scanner is
category-agnostic; you cannot know a file's class/subtype until the model has
read it. `--category` is applied later, at Step 3.5.
- (If a bare-path `--scope` was given, the shortlist/`signals` were already
narrowed to that prefix in the Arguments step.)
- **If there are zero signal-bearing candidates**, skip the model step (Step 3)
entirely. Set the proposals array to `[]` and go straight to Step 4 — an
empty-entries report is still written and `last_check` is still stamped.
### Step 3 — (M) Classify candidates — **Sonnet subagent**
Dispatch ONE subagent (Agent tool) to classify all signal-bearing candidates. Use
**Sonnet** (`model: sonnet`). The subagent reads each candidate file and its
scanner signals and returns a SLIM proposal per file (judgment only — no computed
fields).
```
Agent tool parameters:
- subagent_type: "general-purpose"
- model: sonnet
- description: "Classify doc-hygiene candidates"
- prompt: |
Read and follow the workflow at:
${CLAUDE_PLUGIN_ROOT}/skills/hygiene-check/workflows/classify-candidates.md
Project root: <scan.project_root>
Classify exactly these candidates (path → scanner signals, verbatim):
<candidates>
[For each signal-bearing path, paste:
- path: <project-root-relative path>
signals: <the JSON array from scan.json["signals"][path]>
]
</candidates>
Return ONLY the JSON array of proposals specified in the workflow.
```
**LOOP GUARD:** the subagent prompt MUST point to
`workflows/classify-candidates.md`, NEVER to this SKILL.md (prevents recursive
skill invocation, per the `commit` skill precedent).
**SUBAGENT AUTHORIZATION:** the subagent is the executor — authorization is
terminal. It MUST NOT re-ask for approval or wait for a confirmation that cannot
arrive. If it believes it should not proceed, it MUST return its objection as
its final result and stop immediately (REPORT-AND-EXIT). The human confirm gate
lives upstream in the orchestrator, never inside the subagent.
Wait for the subagent's JSON array. Write it verbatim to
`"$SCRATCH/proposals.json"`.
**Model escalation:** if the subagent flags a file as low-confidence on a *hard
distinction* (stale-vs-bloat; destructive `delete-range` vs a generative rewrite
of the same contradicted/superseded content), re-dispatch **only that file** to an
**Opus** subagent (`model: opus`) with the same workflow, and substitute its
proposal. Do not escalate the whole batch.
### Step 3.5 — (logic) Apply `--category` filter — entry stage
If `--category` was given, drop every proposal whose `category` does not match,
BEFORE finalizing. This is deterministic orchestrator logic (no model, no script):
- `--category stale` / `--category bloat` → keep proposals whose
`category.class` equals it.
- `--category <subtype>` (e.g. `superseded`, `distill`) → keep proposals whose
`category.subtype` equals it.
Rewrite `"$SCRATCH/proposals.json"` with the filtered array. Files removed here
are not errors — they simply produce no entry and will appear under "Cleared" in
the human report (`cleared = shortlist entries`). `report_builder.py` has no
`--category` flag; the filter lives here. With no `--category`, pass all
proposals through unchanged.
### Step 4 — (D) Finalize via `report_builder.py`
Hand the scan artifact and the proposals to the model-free assembler. It fills the
four guardrail fields the model must not author (`expected_sha256`, `safety_tier`,
`is_destructive`/`is_reversible`, `raw_tokens`) and emits a schema-valid machine
report plus a human-report skeleton, writing both to the **scratch** dir:
```bash
python3 "${CLAUDE_PLUGIN_ROOT}/scripts/report_builder.py" \
--scan "$SCRATCH/scan.json" \
--proposals "$SCRATCH/proposals.json" \
--out-json "$SCRATCH/report.json" \
--out-md "$SCRATCH/report.md"
```
- Exit `0` — built. (`--out-json`/`--out-md` write files and suppress the stdout
bundle, which is exactly what we want for scratch validation.)
- Exit `1` — a **malformed proposal**. A structured error is on stderr:
`{"error":"malformed proposal","detail":{"index":I,"field":F,"message":M}}`.
Map `index` back to the offending candidate, re-prompt the subagent (Step 3) to
fix only that proposal (or drop it), rewrite `proposals.json`, and re-run Step 4.
- Exit `2` — usage / IO error (bad input path or unreadable JSON). Internal bug:
stop and report.
For an empty proposals array (`[]`), this still produces a valid empty-entries
report — proceed normally.
### Step 5 — (D) Validate BEFORE writing — on the SCRATCH path
`StateStore.write_report` deletes the prior report pair *first*, so validating
after a write would destroy the last good report (invariant #4). Validate the
scratch machine report first:
```bash
python3 "${CLAUDE_PLUGIN_ROOT}/scripts/validate_report.py" "$SCRATCH/report.json"
```
- Exit `0` — valid. Proceed to Step 6.
- Exit `1` — invalid. The validator prints all violations (each with a `field`
path like `entries[2].exact_edit.anchor`). Map each violation back to its entry
index, re-prompt the classification subagent (Step 3) to fix **only** the
offending proposals — or drop an unfixable entry — rewrite `proposals.json`,
re-run Step 4 (finalize) and Step 5 (validate). **NEVER write an invalid
report.** Repeat until exit 0.
- Exit `2` — usage error (internal bug, e.g. the report file is missing or not
JSON). Stop and report.
### Step 6 + 7 — (D) Write report pair (rollover) AND stamp `last_check`
Only after Step 5 returns exit 0. `StateStore` has no CLI; do the write **and** the
stamp in one `python3 -c` so the `last_check` timestamp is the report's own
envelope `generated_at` (design step 7 — same run instant, read back from the
validated report, not a fresh `now()`):
```bash
python3 -c '
import sys, os, json
from datetime import datetime
from pathlib import Path
sys.path.insert(0, os.environ["CLAUDE_PLUGIN_ROOT"] + "/scripts")
from state_store import StateStore, resolve_project_root
scratch = os.environ["SCRATCH"]
json_blob = Path(scratch + "/report.json").read_text()
md_blob = Path(scratch + "/report.md").read_text()
report = json.loads(json_blob)
store = StateStore(resolve_project_root(Path(os.getcwd())))
store.write_report(json_blob, md_blob) # rollover: keeps exactly one pair
store.set_last_check(datetime.fromisoformat(report["generated_at"]))
print("wrote .dochygiene/report.json + report.md; last_check=" + report["generated_at"])
'
```
(`SCRATCH` must be exported so the `-c` process can read it.) This writes
`.dochygiene/report.json` and `.dochygiene/report.md` (atomic, one pair) and stamps
`last_check`.
### Step 8 — Surface the result
Print the human-report summary plus the two report paths. Read the written human
report and show its header + group summary:
```
doc-hygiene check complete
scope: <effective scope, e.g. **/*.md or the bare-path prefix>
category: <the --category filter, or "all">
<contents of .dochygiene/report.md, or its header + per-group bullet lines>
Reports written:
<project-root>/.dochygiene/report.json
<project-root>/.dochygiene/report.md
Run /hygiene clean to act on these (Phase 4), or /hygiene status for timestamps.
```
The human report header renders `scope_globs` but has no category field (the
frozen `report_builder.py` does not take one), so surface the active `--category`
here in the skill output rather than expecting it in the report.
## Closed enums (for reference — the subagent enforces them)
- `category.class` ∈ { `stale`, `bloat` }
- stale `subtype` ∈ { `contradicted`, `orphaned`, `superseded`, `provisional`,
`completed-in-place`, `duplicated` }
- bloat `subtype` ∈ { `distill`, `split`, `freeze` }
- `op_type` ∈ { `deterministic`, `generative` }
- `exact_edit.kind` ∈ { `delete-range`, `move-to-archive`, `insert-frontmatter`,
`replace-text`, `dedupe` }
## Invariants
- Step 0 check is deterministic (`git check-ignore`); the offer/confirm is a user gate (M-GATE). The append is deterministic and runs only on explicit confirmation.
- Steps 1, 2, 4, 5, 6, 7 are deterministic scripts — **no model** (invariant #6).
- Classification = **Sonnet**; single-file Opus escalation only on low confidence
for hard distinctions.
- The subagent supplies judgment only. It never authors `expected_sha256`,
`safety_tier`, `is_destructive`, `is_reversible`, or `raw_tokens` — those are
owned by `report_builder.py`.
- **Validate on a scratch path BEFORE `write_report`** (write_report is
destructive-first; invariant #4). Never write an invalid report.
- `last_check` = the validated report's envelope `generated_at` (same run
instant), not a fresh clock read.
- Empty shortlist / zero signal-bearing candidates → still write an
empty-entries report and still stamp `last_check`.
- **LOOP GUARD:** the classification subagent prompt MUST point to
`workflows/classify-candidates.md`, NEVER to this SKILL.md.
- **SUBAGENT AUTHORIZATION:** the classify subagent is the executor; it MUST
NOT block waiting for approval. If it objects, REPORT-AND-EXIT — the
orchestrator adjudicates. The confirm gate never lives inside the subagent.

View File

@ -0,0 +1,131 @@
# Workflow: Classify doc-hygiene candidates
You are the classification subagent for the doc-hygiene `check` skill. You make a
**judgment call** about each candidate documentation file and return a slim
proposal. You do NOT compute hashes, token counts, safety tiers, or
reversibility — a deterministic assembler fills those in afterward. Supplying any
of those fields is an error.
> Do NOT read or follow the parent `SKILL.md`. This workflow is self-contained.
## Input
You are given, for each candidate:
- `path` — a project-root-relative path to a Markdown file.
- `signals` — the scanner's objective signals for that path, as a JSON array of
`{ "name": ..., "detail": ... }`. Signal `name`s are drawn from:
`broken_reference`, `version_skew`, `edit_recency_vs_churn`,
`stale_name_location`, `archive_to_live_ratio`, `frontmatter_marker`.
You are also given the project root. **Read each candidate file** (root + path)
before classifying it. Your judgment must be grounded in the file's actual
content AND the cited signals — never classify from the path or signals alone.
## What "stale" vs "bloat" means
- **Stale** = the doc is *wrong* (contradicted, orphaned, superseded,
provisional, completed-in-place, duplicated). Remedy: fix or remove.
- **Bloat** = the doc is *true but mostly irrelevant* (distill, split, freeze).
Remedy: change its altitude — almost never delete history.
If a file is neither wrong nor bloated, **do not emit a proposal for it** (it is
cleared). Only emit proposals for files that genuinely warrant an op.
## The proposal object (per file)
Return a JSON **array** of these objects. Required fields:
| field | value |
|-------|-------|
| `path` | the candidate path, verbatim |
| `category` | `{ "class": <class>, "subtype": <subtype> }` from the closed enums below |
| `signals` | the scanner `signals` array for this path, **passed through verbatim** (you MAY add a one-line `detail` gloss, but keep each signal's `name` unchanged) |
| `op` | a single human sentence describing the remedy |
| `op_type` | `"deterministic"` or `"generative"` — a property of the op you chose (a kind-with-an-exact-edit ⇒ deterministic; prose rewrite ⇒ generative) |
Optional:
| field | value |
|-------|-------|
| `gloss` | a one-line "why" explanation; surfaced under the entry in the human report |
| `confidence` | `"high"` \| `"medium"` \| `"low"` |
| `escalate` | `true` if this is a low-confidence HARD distinction (see below) — the orchestrator may re-run it on Opus |
### Closed enums
- `category.class` ∈ { `stale`, `bloat` }
- stale `subtype` ∈ { `contradicted`, `orphaned`, `superseded`, `provisional`,
`completed-in-place`, `duplicated` }
- bloat `subtype` ∈ { `distill`, `split`, `freeze` }
- `op_type` ∈ { `deterministic`, `generative` }
- `exact_edit.kind` ∈ { `delete-range`, `move-to-archive`, `insert-frontmatter`,
`replace-text`, `dedupe` }
A stale subtype with a `stale` class; a bloat subtype with a `bloat` class.
Mismatches are rejected.
## If `op_type` is `deterministic` → include `exact_edit` (a SKELETON)
Supply `exact_edit` with `kind` plus exactly the kind-specific fields below.
**Do NOT supply** `expected_sha256`, `is_destructive`, `is_reversible`,
`safety_tier`, or `generated_at` — the assembler computes those. Line numbers are
**1-based, inclusive**.
| `kind` | required fields (besides `kind`) | notes |
|--------|----------------------------------|-------|
| `delete-range` | `anchor: { start_line, end_line }` | destructive deletion of unique content |
| `move-to-archive` | `anchor: { start_line, end_line }`, `dest_path` | content-preserving relocation; `dest_path` is the archive destination (project-root-relative) |
| `replace-text` | `anchor: { start_line, end_line }`, `match`, `replacement` | known-target fix, e.g. a link/path; `match` is the exact text to replace within the anchor |
| `dedupe` | `anchor: { start_line, end_line }`, `canonical_ref` | exact duplicate preserved elsewhere; `canonical_ref` points to the surviving canonical copy |
| `insert-frontmatter` | `key`, `value` | freeze a doc; **no anchor** (e.g. `key: "hygiene"`, `value: "frozen"`) |
## If `op_type` is `generative` → include `reducible_range`, NO `exact_edit`
A generative op (prose condensation/splitting/rewrite) has no mechanical edit.
Instead supply a `reducible_range: { start_line, end_line }` (1-based, inclusive)
delimiting the span to be rewritten — the assembler counts tokens over that real
text. Do **not** supply `exact_edit`.
## Decision rules — situation → kind → (derived tier, FYI only)
The tier is derived downstream; shown here only so you choose the right kind.
| situation | subtype | op_type | kind | (tier) |
|-----------|---------|---------|------|--------|
| destructive deletion of unique, orphaned content | `orphaned` | deterministic | `delete-range` | confirm |
| superseded doc, content preserved by relocation | `superseded` | deterministic | `move-to-archive` | auto |
| a completed/finished doc to freeze in place | `completed-in-place` | deterministic | `insert-frontmatter` | auto |
| exact duplicate, canonical copy lives elsewhere | `duplicated` | deterministic | `dedupe` | auto |
| known-target link / path fix | `contradicted` (or relevant) | deterministic | `replace-text` | auto |
| prose is true but bloated — condense or split | `distill` / `split` | generative | (none) | confirm |
| a provisional/contradicted doc needing a rewrite of the same content (not a clean delete) | `provisional` / `contradicted` | generative | (none) | confirm |
## Hard distinctions → set `escalate: true` when low-confidence
- **stale vs bloat**: is the doc *wrong*, or *true-but-irrelevant*? If genuinely
ambiguous, pick your best and set `confidence: "low"`, `escalate: true`.
- **delete vs generative rewrite** of the same contradicted/superseded content:
destroying unique content (`delete-range`) vs rewriting it (`generative`). When
unsure, escalate rather than guess destructively.
## Output
Return ONLY the JSON array — no prose, no code fences. Example shape:
```json
[
{
"path": "docs/old-plan.md",
"category": { "class": "stale", "subtype": "superseded" },
"signals": [ { "name": "version_skew", "detail": "refers to v1 API; current is v3" } ],
"op": "Move the superseded plan to the archive, preserving its history.",
"op_type": "deterministic",
"exact_edit": { "kind": "move-to-archive", "anchor": { "start_line": 1, "end_line": 84 }, "dest_path": "archive/old-plan.md" },
"gloss": "Superseded by docs/plan-v3.md; content preserved, not deleted.",
"confidence": "high"
}
]
```
If none of the supplied candidates genuinely warrant an op, return `[]`.

View File

@ -0,0 +1,13 @@
# hygiene-clean/
The `clean` skill: documentation cleanup orchestrator. Loads the machine report
from `/hygiene check`, gates confirm-tier entries, applies deterministic ops
via the patch applier, delegates generative distillation to a Sonnet subagent,
produces exactly one cleanup commit, and stamps `last_clean`.
## Contents
| File | Purpose |
|------|---------|
| `SKILL.md` | The complete `clean` workflow. Orchestrates 11 steps: load report → re-validate → filter by scope/category → detect incompatible-ops → partition by safety_tier/op_type → confirm gate (BEFORE mutation) → git preflight (baseline + WIP checkpoint) → apply deterministic ops via `patch_applier.py` → apply generative ops via Sonnet subagent → produce cleanup commit via `git-context` → stamp `last_clean`. Rollback on hard failures; commit what applied on partial success. Invariants #4, #5, #6, #7 (confirm precedes mutation), #8 (mtime guard). LOOP-GUARD: subagent prompt points only to `workflows/distill.md`. |
| `workflows/distill.md` | Self-contained generative workflow for distillation subagent. Defines ops: `distill` (condense, preserve frontmatter, ~4060% target), `split` (extract to archive), prose-rewrite (`provisional`, `contradicted`). Receives live file contents (no disk I/O), performs the rewrite, returns structured output (`DISTILL_RESULT_START`/`END`, `SPLIT_PRIMARY_START`/`END`, `SPLIT_ARCHIVE_DEST`, `SPLIT_ARCHIVE_START`/`END`, or `DISTILL_ERROR`). No file writes, no git calls, no re-reads. Conservative: prefers keeping content when uncertain. |

View File

@ -0,0 +1,598 @@
---
name: hygiene-clean
description: Apply documented hygiene findings to project docs. Loads the current machine report, gates confirm-tier entries, applies deterministic ops via the patch applier, dispatches generative ops to a Sonnet subagent, stages precisely, and produces exactly one git commit. Invoked by `/hygiene clean [--scope <glob-or-path>] [--category <class|subtype>]`.
---
# Hygiene Clean Skill
Orchestrates one documentation-hygiene cleanup run: **load → validate → filter →
gate → preflight → apply → commit → stamp**. The load, validate, filter,
partition, git ops, stage, commit, and stamp are deterministic (invariant #6 — no
model). Only generative distillation is a model step, dispatched to a **Sonnet**
subagent per the LOOP-GUARD pattern.
All scripts live under `${CLAUDE_PLUGIN_ROOT}/scripts/`. Run them from the user's
project directory (`cwd`).
> **Precondition:** this skill requires the `CLAUDE_PLUGIN_ROOT` environment
> variable to be set (Claude Code sets it at runtime). If it is unset, abort
> rather than guessing a path.
> **Scratch dir:** pick a scratch dir once at run start and reuse it for all
> intermediate artifacts — the applier JSON result, generative outputs, and the
> baseline ref all live there.
> ```bash
> SCRATCH="$(mktemp -d)"
> ```
## Arguments
Passed through from `/hygiene clean`:
- `--scope <glob-or-path>` — narrow which entries to act on. Applied at **Step 3**
(entry stage), same semantics as the check skill: a glob (contains `*`) matches
`path` against the glob; a bare path filters entries whose `path` starts with
`<path>/`.
- `--category <class|subtype>` — filter entries by `category.class` (`stale` /
`bloat`) or `category.subtype` (e.g., `superseded`, `distill`). Applied at
**Step 3** (entry stage), never at load time.
If no arguments are given, act on all entries in the report.
## Workflow
---
### Step 1 — (D) Load report via StateStore
Resolve the project root and read the current report. If no report exists, stop
immediately and tell the user to run `/hygiene check` first.
```bash
python3 -c '
import os, sys, json
from pathlib import Path
sys.path.insert(0, os.environ["CLAUDE_PLUGIN_ROOT"] + "/scripts")
from state_store import StateStore, resolve_project_root
root = resolve_project_root(Path(os.getcwd()))
store = StateStore(root)
result = store.read_report()
if result is None:
print(json.dumps({"status": "no_report", "root": str(root)}))
else:
json_blob, md_blob = result
print(json.dumps({"status": "ok", "root": str(root), "report_path": str(root / ".dochygiene" / "report.json")}))
'
```
- If `status == "no_report"` → tell the user: "No hygiene report found. Run
`/hygiene check` first to generate one." **STOP.**
- If `status == "ok"` → record `PROJECT_ROOT` and `REPORT_PATH` (the canonical
`.dochygiene/report.json`) for use in subsequent steps.
---
### Step 2 — (D) Re-validate the loaded report
Re-validate the report on disk before doing anything else. An invalid report
indicates a state corruption; do not attempt to apply it.
```bash
python3 "${CLAUDE_PLUGIN_ROOT}/scripts/validate_report.py" "$REPORT_PATH"
```
- Exit `0` → valid. Proceed to Step 3.
- Exit `1` → invalid. Show the violation list from stdout and stop: "The hygiene
report is invalid. Re-run `/hygiene check` to regenerate it." **STOP.**
- Exit `2` → usage error (file missing after Step 1 succeeded = internal bug).
Stop and report.
---
### Step 3 — (D / logic) Apply scope/category filter — entry stage
Load `report.entries[]` from `$REPORT_PATH`. Apply the optional filters to
produce the **in-scope** entry list, carrying original report indices for
provenance:
```python
import json, fnmatch
from pathlib import Path
report = json.loads(Path(REPORT_PATH).read_text())
entries = report.get("entries", [])
in_scope = [] # list of (original_index, entry)
for i, entry in enumerate(entries):
path = entry["path"]
# scope filter
if SCOPE_ARG:
if "*" in SCOPE_ARG:
if not fnmatch.fnmatch(path, SCOPE_ARG):
continue
else:
if not (path == SCOPE_ARG or path.startswith(SCOPE_ARG.rstrip("/") + "/")):
continue
# category filter
if CATEGORY_ARG:
cat = entry.get("category", {})
if CATEGORY_ARG not in (cat.get("class", ""), cat.get("subtype", "")):
continue
in_scope.append((i, entry))
```
If `in_scope` is empty → report "No in-scope entries. Nothing to clean." **STOP
(no git ops, no commit, no stamp).**
---
### Step 4 — (D / logic) Incompatible-pair detection and partition
Before building the confirm gate list, check for files that carry BOTH a
generative and a deterministic entry in the in-scope set — the applier never
sees generative entries, so this conflict must be caught here:
```python
from collections import defaultdict
by_path = defaultdict(list)
for idx, entry in in_scope:
by_path[entry["path"]].append((idx, entry))
incompatible_files = set()
for path, batch in by_path.items():
has_gen = any(e["op_type"] == "generative" for _, e in batch)
has_det = any(e["op_type"] == "deterministic" for _, e in batch)
if has_gen and has_det:
incompatible_files.add(path)
# Exclude incompatible files entirely; they go into the skipped report
in_scope_clean = [(i, e) for i, e in in_scope
if e["path"] not in incompatible_files]
```
Record `incompatible_files` for the final summary (re-analysis recommended).
Now partition `in_scope_clean` by `(safety_tier, op_type)` — values come
**from the report**, never recomputed:
```python
auto_det = [(i, e) for i, e in in_scope_clean
if e["safety_tier"] == "auto" and e["op_type"] == "deterministic"]
confirm_det = [(i, e) for i, e in in_scope_clean
if e["safety_tier"] == "confirm" and e["op_type"] == "deterministic"]
confirm_gen = [(i, e) for i, e in in_scope_clean
if e["op_type"] == "generative"] # always confirm-tier
```
---
### Step 5 — (M-GATE) Confirm gate — BEFORE any mutation or git op
> **Invariant #7:** this gate must run before the first file write or git
> operation. It runs identically under `/hygiene sweep`.
Only present the gate if `confirm_det` or `confirm_gen` is non-empty. If both
are empty (all entries are `auto`), skip directly to Step 6 — no prompt.
When present, show a single batch-confirm list:
```
Hygiene clean — confirm required before any changes are made:
The following entries require your approval. Auto-tier entries will be
applied silently after you respond.
⚠ IRREVERSIBLE DELETES (delete-range — content will be permanently removed):
[1] docs/stale-notes.md (orphaned / delete-range · confirm) — ~120 tokens
"Orphaned after 2025 refactor; no references remain."
Reversible confirm entries:
[2] CHANGELOG.md (distill / generative-distill · confirm) — ~400 tokens
"Changelog is true but bloated; condense into summary."
Enter the numbers to approve (e.g. "1,2"), "all", or "none" / leave blank to skip all:
```
Rules for display:
- List every `confirm_det` and `confirm_gen` entry (not `auto_det` — those apply
silently).
- Visually distinguish `delete-range` entries (mark `⚠ IRREVERSIBLE`); group
them under a warning header.
- Show: path, `category.subtype`, `op_type`/`kind`, `token_estimate.raw_tokens`,
and `gloss` (or `op` if `gloss` is absent).
- Per-entry opt-out: the user may approve a subset by number, "all", or "none".
After the user responds:
```python
approved_confirm = [entries the user selected]
rejected_confirm = [entries the user did not select]
```
Approved set (goes forward to apply): `auto_det` + `approved_confirm`
(split further below into approved deterministic and approved generative).
```python
approved_det = auto_det + [e for e in approved_confirm if e[1]["op_type"] == "deterministic"]
approved_gen = [e for e in approved_confirm if e[1]["op_type"] == "generative"]
```
If `approved_det` and `approved_gen` are both empty after the user declines all
→ report "No entries approved. Nothing to clean." **STOP (no git ops, no
commit, no stamp).**
---
### Step 6 — (D) Git preflight
> **Gitignore offer (resolve before dirty-tree check):** If `.dochygiene/` is not
> yet gitignored, it will appear as untracked in `git status --porcelain` and may
> trigger a needless WIP checkpoint. Before running `git status`, check:
> ```bash
> git -C "$PROJECT_ROOT" check-ignore -q .dochygiene
> ```
> Exit `0` → already ignored; proceed. Exit `1` → present the same one-line
> confirmation offer described in the check skill's **Step 0**. Resolve it (or let
> the user decline) before evaluating the dirty-tree state below. Do NOT duplicate
> the append logic here — apply it identically to Step 0.
Now perform the first git operation. Record the baseline ref for rollback.
```bash
cd "$PROJECT_ROOT"
git status --porcelain
```
**Clean tree** (`git status --porcelain` outputs nothing):
```bash
BASELINE=$(git rev-parse HEAD)
```
**Dirty tree** (any output from `git status --porcelain`):
Auto-create a WIP checkpoint commit of the user's existing work:
```bash
git add -A # OK here: the cleanup commit uses precise staging; this is user-work, .dochygiene/ is gitignored
git commit -m "wip: checkpoint before hygiene cleanup"
BASELINE=$(git rev-parse HEAD~1) # baseline is BEFORE the checkpoint
```
> The WIP checkpoint is the only place `git add -A` is permitted in this skill.
> The subsequent cleanup commit NEVER uses `-A`.
Record `BASELINE` to the scratch dir for rollback:
```bash
echo "$BASELINE" > "$SCRATCH/baseline.ref"
```
**Untracked filter:** identify untracked candidate files and remove them from
`approved_det` and `approved_gen`. Git cannot stage or revert untracked files.
```bash
git ls-files --others --exclude-standard
```
Compare the output against entry paths. Any entry whose path appears in the
untracked list → move to `skipped_untracked` and remove from the approved sets.
Report them at Step 11 with reason "untracked — add the file to git first."
---
### Step 7 — (D) Apply approved deterministic entries via `patch_applier.py`
Build the comma-separated index list from `approved_det` original report indices:
```python
det_indices_str = ",".join(str(i) for i, _ in approved_det)
```
If `approved_det` is empty, skip this step.
```bash
python3 "${CLAUDE_PLUGIN_ROOT}/scripts/patch_applier.py" \
--report "$REPORT_PATH" \
--apply-indices "$DET_INDICES" \
> "$SCRATCH/applier_result.json"
APPLIER_EXIT=$?
```
Read `$SCRATCH/applier_result.json`:
```python
result = json.loads(Path(SCRATCH + "/applier_result.json").read_text())
applied = result["applied"] # [{path, kind, entry_index}]
skipped = result["skipped"] # [{path, kind, entry_index, reason, recommend}]
failed = result["failed"] # [{path, kind, entry_index, error}]
staged_paths = result["staged_paths"] # paths to git add (incl. move-to-archive dests as info only)
```
**Rollback check (Trap E):**
Rollback iff `APPLIER_EXIT == 2` OR `failed` is non-empty:
```bash
if [ "$APPLIER_EXIT" -eq 2 ] || python3 -c "import json,sys; r=json.load(open('$SCRATCH/applier_result.json')); sys.exit(0 if r['failed'] else 1)"; then
git reset --hard "$BASELINE"
# Report: "Hard failure during patch application. Rolled back to baseline. No commit created."
# Show failed[] entries.
STOP
fi
```
Partial success (exit 1, `failed[]` empty, some `skipped[]`) is NOT a rollback —
commit what applied and report skipped files with "re-analysis recommended."
**Stage non-move applied files:** use `applied[]` to derive the exact add-set —
do NOT use `staged_paths` directly to avoid re-adding move-to-archive dests:
```bash
# For each entry in applied[] where kind != "move-to-archive":
# git add -- <that entry's path>
# move-to-archive: git mv was already called by the applier (both sides staged);
# do NOT git add the dest again.
for rec in applied:
if rec["kind"] != "move-to-archive":
git add -- "$PROJECT_ROOT/${rec['path']}"
```
---
### Step 8 — (M) Apply approved generative entries — Sonnet subagent
Skip this step if `approved_gen` is empty.
For each approved generative entry (in order):
1. **Confirm the file still exists.** A preceding `move-to-archive` in Step 7
may have removed it. If the file is gone:
- Record as skipped (reason: `source-moved-during-run`).
- Continue to the next entry.
2. **Live-read the file contents now** (not from the report). Generative entries
carry no `expected_sha256`; freshness is guaranteed only by reading
immediately before dispatch:
```python
live_contents = Path(PROJECT_ROOT, entry["path"]).read_text(encoding="utf-8")
```
3. **Dispatch a Sonnet subagent** (LOOP-GUARD — the subagent reads
`workflows/distill.md`, not this SKILL.md):
```
Agent tool parameters:
- subagent_type: "general-purpose"
- model: sonnet
- description: "Distill doc-hygiene generative entry: <entry.path>"
- prompt: |
Read and follow the workflow at:
${CLAUDE_PLUGIN_ROOT}/skills/hygiene-clean/workflows/distill.md
File path (project-root-relative): <entry.path>
Category: <entry.category.class> / <entry.category.subtype>
Op: <entry.op>
Signals: <entry.signals as JSON>
Live file contents (read NOW — do NOT re-read from disk):
<live_contents>
AUTHORIZATION: you are the executor — authorization is terminal. The
human confirm gate ran upstream; do NOT re-ask for approval or wait for
confirmation. If you believe you should not proceed, return your objection
as your final result and stop immediately (REPORT-AND-EXIT, never block).
```
4. **Receive the subagent result.** Expected return (see `distill.md`):
- For `distill`: a single `new_content` string.
- For `split`: `new_primary_content` + `archived_content` + `archive_dest_path`
(project-root-relative).
- On error/unable: `{"status": "error", "reason": "..."}` → skip entry,
report, continue.
5. **Write the result:**
- For `distill`: overwrite the file with `new_content`.
- For `split`: overwrite the file with `new_primary_content`; create the
archive file at `archive_dest_path` with `archived_content`.
6. **Stage immediately after writing:**
```bash
git add -- "$PROJECT_ROOT/<entry.path>"
# For split, also:
git add -- "$PROJECT_ROOT/<archive_dest_path>"
```
Stage immediately so that a later rollback (`git reset --hard`) reverts it.
7. Record the entry as successfully applied (for Step 11 summary and the commit
message).
**If the subagent errors:** log as skipped (not a hard failure → no rollback).
Continue processing remaining generative entries. Only an `OSError` on write or
a `git add` failure is a hard failure → rollback and STOP (same rollback
procedure as Step 7).
---
### Step 9 — (D) Produce exactly one cleanup commit
Only proceed here if at least one entry was applied (deterministic or
generative). If everything was skipped, go directly to Step 11 (no commit, no
stamp).
Build the commit message:
```python
# Count breakdown for the commit body
auto_count = len([r for r in det_applied if ... safety_tier == "auto"])
confirmed_count = len([r for r in det_applied if ... safety_tier == "confirm"]) + len(gen_applied)
skipped_count = len(all_skipped_entries)
# Op breakdown
from collections import Counter
auto_ops = Counter(r["kind"] for r in auto_applied_records)
confirmed_ops = Counter(
r["kind"] for r in confirmed_det_applied_records
) + Counter(
entry["op"] for _, entry in applied_gen_entries # use category.subtype as label
)
def fmt_ops(counter):
return ", ".join(f"{k} ×{v}" for k, v in sorted(counter.items())) or "none"
total_applied = auto_count + confirmed_count
affected_paths = len({r["path"] for r in all_applied_records})
message = f"""docs: hygiene cleanup ({total_applied} edits across {affected_paths} files)
Auto: {auto_count} ({fmt_ops(auto_ops)})
Confirmed: {confirmed_count} ({fmt_ops(confirmed_ops)})
Skipped (re-analysis recommended): {skipped_count}"""
```
Label mapping for commit body (for clarity):
- `insert-frontmatter` kind → display as `freeze`
- `distill`, `split` generative entries → display by `category.subtype`
Commit via stdin. Capture stdout to extract the SHA:
```bash
COMMIT_OUTPUT=$(git-context commit-apply --message-stdin <<EOF
$COMMIT_MESSAGE
EOF
)
COMMIT_EXIT=$?
```
On success the command prints `✓ Created commit <SHA>`. Extract the SHA:
```bash
COMMIT_SHA=$(echo "$COMMIT_OUTPUT" | grep -oP '(?<=Created commit )[0-9a-f]+')
```
**If `COMMIT_EXIT` is non-zero:** rollback to baseline and abort with a
structured error. The commit did not happen; no stamp.
```bash
if [ "$COMMIT_EXIT" -ne 0 ]; then
git reset --hard "$BASELINE"
echo "Commit failed. Rolled back to $(git rev-parse --short HEAD). No commit created."
STOP
fi
```
---
### Step 10 — (D) Stamp `last_clean` to the commit instant
Only after the commit succeeds. Stamp to the commit's own timestamp, not the
run-start time:
```bash
COMMIT_TS=$(git show -s --format=%cI "$COMMIT_SHA")
```
```bash
export COMMIT_TS
python3 -c '
import os, sys
from pathlib import Path
from datetime import datetime
sys.path.insert(0, os.environ["CLAUDE_PLUGIN_ROOT"] + "/scripts")
from state_store import StateStore, resolve_project_root
root = resolve_project_root(Path(os.getcwd()))
store = StateStore(root)
ts = datetime.fromisoformat(os.environ["COMMIT_TS"])
store.set_last_clean(ts)
print("last_clean stamped to " + ts.isoformat())
'
```
---
### Step 11 — Surface the result
Print a structured summary to the user:
```
doc-hygiene clean complete (commit: <COMMIT_SHA>)
Applied: <N> edits across <M> files
Auto (deterministic): <count>
Confirmed (deterministic): <count>
Confirmed (generative): <count>
Skipped: <count> (re-analysis recommended — re-run /hygiene check)
<for each skipped: path · kind · reason>
Incompatible-ops (skipped): <count>
<for each: path reason: generative+deterministic on same file>
Untracked (skipped): <count>
<for each: path run `git add <path>` then re-run /hygiene check>
```
If a `move-to-archive` was applied, append:
```
Note: move-to-archive ops create link orphans. The next /hygiene check will
flag any new broken_reference signals in files that pointed to the moved doc.
```
If entries were skipped, append:
```
Run /hygiene check to refresh the report and pick up any remaining issues.
```
---
## Failure handling
| Condition | Action |
|-----------|--------|
| No report (Step 1) | Tell user to run `/hygiene check`. STOP. No mutation. |
| Invalid report (Step 2) | Show violations. Tell user to re-run `/hygiene check`. STOP. |
| Zero in-scope entries (Step 3) | Report no-op. STOP. No git ops. |
| Approved set empty (Step 5) | Report "nothing approved". STOP. No git ops. |
| Applier exit 2 (Step 7) | Rollback to baseline, abort with structured error. |
| `failed[]` non-empty (Step 7) | Rollback to baseline, abort with structured error. |
| Write/git error in Step 8 | Rollback to baseline, abort. |
| Applier exit 1, `failed[]` empty (Step 7) | Commit what applied; report skipped with re-analysis note. |
| Subagent error in Step 8 | Skip that generative entry; continue. |
| All entries skipped (Steps 7+8) | No commit, no stamp. Report all-skipped. |
| Commit failure (Step 9) | Rollback to baseline, abort. No stamp. |
Rollback procedure (used by multiple failure paths):
```bash
git reset --hard "$BASELINE"
echo "Rolled back to $(git rev-parse --short HEAD). No commit was created."
```
---
## Invariants upheld
- **#4** — no new state artifacts; `last_clean` is the only new state entry.
- **#5** — exactly one cleanup commit per run (plus an optional WIP checkpoint
if the tree was dirty).
- **#6** — only Steps 8 (generative distillation) and 5 (the confirm prompt
rendered to the user) involve a model. All other steps are deterministic
scripts or logic.
- **#7** — confirm gate precedes every mutation, including the git preflight.
- **#8** — the applier enforces the content-hash guard; the skill trusts
`failed[]` / `skipped[]` in the result.
## LOOP GUARD
The generative subagent prompt MUST point to
`skills/hygiene-clean/workflows/distill.md`, NEVER to this SKILL.md (prevents
recursive skill invocation).
**SUBAGENT AUTHORIZATION:** the distill subagent is the executor; it MUST NOT
block waiting for approval. If it objects, REPORT-AND-EXIT — the orchestrator
adjudicates. The confirm gate (Step 5) never lives inside the subagent.

View File

@ -0,0 +1,146 @@
# Workflow: Distill doc-hygiene generative entry
You are the distillation subagent for the doc-hygiene `clean` skill. You rewrite
or split a documentation file to remedy confirmed bloat or prose staleness. You
work entirely from the **live file contents provided to you** — you do not read
files from disk and you do not write files to disk. The skill that dispatched you
handles all reading, writing, and git staging.
> Do NOT read or follow the parent `SKILL.md`. This workflow is self-contained.
> Do NOT call any file-write or git tools. Return your result as structured text.
---
## Input
You receive the following in your prompt:
- **File path** — the project-root-relative path of the file being distilled.
- **Category**`class` (`stale` or `bloat`) and `subtype` (e.g. `distill`,
`split`, `provisional`, `contradicted`).
- **Op** — the one-sentence remedy description from the report.
- **Signals** — the scanner signal array (JSON) that triggered this entry.
- **Live file contents** — the full current text of the file, provided inline.
Do NOT re-read it from disk; these contents are the freshness guarantee.
---
## Core distinctions — do not conflate
- **Stale** = the doc is *wrong* (contradicted, orphaned, superseded,
provisional, completed-in-place, duplicated). Remedy: rewrite or redirect so
the doc is no longer wrong.
- **Bloat** = the doc is *true but mostly irrelevant* (distill, split, freeze).
Remedy: **change its altitude** — condense or move sections. Almost never
delete history outright. A too-long CHANGELOG is condensed, not truncated.
Severity scales with injection frequency. Be conservative: prefer keeping
content that has historical value over deleting it. When in doubt, distill rather
than delete.
---
## Operations
### `distill` (condense in place)
Condense the document (or the `reducible_range` span that the signals point to)
so it communicates the same meaning in fewer words. Rules:
- **Preserve all frontmatter** (YAML `---` block if present). Copy it verbatim
into your output as the first lines.
- **Preserve factual content and history-worthy decisions.** Reduce prose
overhead, redundant preamble, and padded structure — not the substance.
- **Do not invent new facts.** Do not add information not present in the input.
- **Do not remove cross-references or links** unless the link text itself is the
bloat (e.g. an explanatory paragraph that restates a link target in full).
- Keep the overall structure (headings, sections) unless collapsing a section
is clearly the right remedy per the `op` and `signals`.
- Aim for roughly 4060% of the original length when the whole file is the
target; preserve more when only a range is affected.
Return: a single `new_content` block (the full rewritten file, frontmatter
included).
### `split` (extract to archive)
Extract a section or subsection into a separate archive file and replace it with
a short reference/pointer in the primary file. Rules:
- **Preserve all frontmatter** in the primary output.
- The archived section MUST be self-contained — copy over any necessary
context (headings, date, version) so the archive file is readable in isolation.
- The primary file gets a short "see also" or "archived" pointer replacing the
removed section (one to three lines).
- Choose an `archive_dest_path` that is under an `archive/` or `_archive/`
directory relative to the file's own directory (e.g.,
`docs/archive/old-section-2024.md`). The path must be project-root-relative.
- Do not invent content; do not merge content from the primary into the archive.
Return: three separate blocks — `new_primary_content`, `archived_content`, and
`archive_dest_path`.
### Other generative ops (provisional, contradicted — prose rewrite)
Rewrite the document so it is no longer wrong:
- For **contradicted**: correct the stale assertion(s) the signals identified.
Be surgical — change only what is wrong. Do not restructure sections that are
correct.
- For **provisional**: update or remove provisional markers (e.g., "TODO",
"TBD", "Draft", "In progress") where the underlying work is complete per the
signals. If the status is uncertain, leave the marker.
- Preserve frontmatter, links, and correct content verbatim.
Return: a single `new_content` block.
---
## Output format
**Always return structured text** so the skill can parse your result reliably.
Use the delimiters below exactly. Do not add prose outside the delimiters.
### For `distill` and other single-file ops:
```
DISTILL_RESULT_START
<the complete rewritten file content frontmatter + body>
DISTILL_RESULT_END
```
### For `split`:
```
SPLIT_PRIMARY_START
<the complete primary file content frontmatter + body with archive pointer>
SPLIT_PRIMARY_END
SPLIT_ARCHIVE_DEST: docs/archive/section-name-YYYY.md
SPLIT_ARCHIVE_START
<the complete archive file content self-contained section>
SPLIT_ARCHIVE_END
```
### On error or inability:
```
DISTILL_ERROR: <one-sentence reason why this entry cannot be processed>
```
---
## Hard constraints
1. **Do not write files.** Return content only. The skill writes all files.
2. **Do not call git.** The skill stages all changes.
3. **Do not re-read from disk.** Use only the live contents provided.
4. **Preserve frontmatter verbatim.** Copy the `---` block exactly.
5. **No invented facts.** If you cannot verify a claim in the provided content,
do not add it.
6. **Be conservative.** When uncertain whether to delete or keep a passage,
keep it (distilled if possible). Deletions are harder to recover than
over-cautious prose.
7. **Do not re-invoke the parent skill** (`hygiene-clean`). This workflow is
the final step; recursion would be incorrect.

View File

@ -0,0 +1,24 @@
# tests/
Unit and integration tests for the deterministic core (`scripts/`). Every test
file is **self-contained** — it bootstraps `sys.path` to import directly from
`scripts/` (there is no shared `conftest.py`). All time- and filesystem-dependent
behaviour is driven by injected frozen clocks and `tmp_path`, so no test touches
the real clock, a real session, or the network.
## Contents
| File | Covers | Key assertions |
|------|--------|----------------|
| `test_scanner_exclusions.py` | Scanner exclusion pipeline (invariant #9) | A `hygiene: frozen` file, a `.dochygiene-ignore`-matched file, and append-only logs are absent from the shortlist; `.dochygiene/` and excluded dirs are never scanned. |
| `test_scanner_signals.py` | Scanner per-path signals | One fixture per signal type asserts the expected signal on the right path, with no classification attached (objective facts only). |
| `test_state_store.py` | `resolve_project_root` + `StateStore` (invariants #3, #4) | Root resolution (git-root / cwd-fallback); writes confined to `.dochygiene/`; `.gitignore` never edited; atomic torn-write safety; report rollover keeps one pair; timestamp round-trip with injected clock. |
| `test_reminder.py` | `Reminder` decision + `ReminderRunner` (invariants #1, #2) | Pure decision: fresh→silent, stale→banner, same-day→snoozed, prior-day→re-banner, **calendar-day boundary across midnight** (20-min delta, different day → banner). Runner: only `last_reminded` mutated and only on banner; missing/corrupt state → never-checked; always exits 0. |
| `test_validate_report.py` | `validate_report.py` (invariants #10, #11) | Golden fixtures end-to-end (PASS exit 0 / FAIL exit 1, not mutated); crafted-dict branch coverage for `raw_tokens` requirement, op_type↔exact_edit biconditional, and safety_tier derivation truth table. |
| `test_cross_seam.py` | scanner + state_store + reminder together (Group 5; invariant #6) | End-to-end on a tmp doc tree: scan produces a shortlist and self-excludes `.dochygiene/`; rollover keeps one report pair; reminder snooze behaves across simulated sessions with an injected clock. No model, no network. |
## Fixtures
`fixtures/scanner/` (one subtree per exclusion/signal case) and
`fixtures/state_store/sample_project/` hold static fixture doc trees. The
cross-seam and reminder tests build their trees in `tmp_path` at runtime instead.

View File

@ -0,0 +1,15 @@
# Changelog
## 2026-06-18
- Added scanner feature
- Fixed exclusion pipeline
## 2026-06-17
- Initial implementation
- Set up project structure
## 2026-06-16
- Project bootstrapped

View File

@ -0,0 +1,21 @@
# Architecture Overview
This document explains the system architecture.
## Components
The system has three main components:
### Scanner
The scanner walks the project tree and emits signals.
### State Store
The state store persists timestamps and reports.
### Reminder
The reminder hook fires on SessionStart.
## Rationale
We chose this design because it separates concerns cleanly.
Each component is independently testable.

View File

@ -0,0 +1,10 @@
---
hygiene: frozen
---
# Frozen Document
This document is frozen and should never be shortlisted by the scanner.
It contains valid content that references things that exist.
See [README](../README_live.md) for context.

View File

@ -0,0 +1,5 @@
# Live Document
This document has no frontmatter and should appear in the shortlist.
It is a normal doc with no exclusion markers.

View File

@ -0,0 +1,3 @@
# Ignored Document
This document matches a .dochygiene-ignore pattern and should NOT appear in the shortlist.

View File

@ -0,0 +1,3 @@
# Normal Doc
This document is live and should appear in the shortlist.

View File

@ -0,0 +1,21 @@
# Project History
## Resolved Issues
These are the resolved issues from the old project.
They have been completed and are no longer relevant.
All of these tasks are done and archived for reference.
None of them require further action.
## Completed Work
The following was completed:
- Feature A: done
- Feature B: done
- Feature C: done
## Deprecated Approaches
These approaches were deprecated:
- Old approach X
- Old approach Y

View File

@ -0,0 +1,7 @@
# Document With Broken Reference
This doc links to a file that does not exist.
See [missing file](../nonexistent/missing.md) for details.
Also see [another missing](./ghost.py) for code.

View File

@ -0,0 +1,4 @@
# Actively Evolving Document
This document is modified frequently and was recently edited.
It has a high git commit count and recent mtime.

View File

@ -0,0 +1,7 @@
---
status: draft
---
# Draft Document
This document is in draft status and should emit a frontmatter_marker signal.

View File

@ -0,0 +1,7 @@
# Old Implementation Notes
These are old notes about the previous implementation that may be superseded.
## Background
The old approach used a monolithic scanner with no dependency injection.

View File

@ -0,0 +1,10 @@
# API Documentation
version: 0.1.0
This document describes version 0.1.0 of the API.
## Endpoints
- GET /health
- POST /scan

View File

@ -0,0 +1 @@
# Sample project for state_store tests

View File

@ -0,0 +1,330 @@
"""
Hermetic classifier-golden regression harness (design D7 / add-check).
These tests are the Layer-2 reversion-protection check for the classifier: a
small static fixture doc-tree (`input/`) an expected classification
(`expected.json`). They are DISTINCT from the schema-shape fixtures
(examples/golden/valid_report.json / invalid_report.json) that exercise only
validate_report.py.
HERMETIC by construction NO live model / API call ever runs here. The model's
*judgment* is captured once (committed in each case's expected.json, human-gated
per the META-RULE). This suite asserts only the deterministic / stable parts:
1. scanner.py on input/ emits the EXPECTED signal(s) on the EXPECTED path(s).
2. validate_report.py accepts each expected.json (exit 0) the goldens are
themselves valid machine reports.
3. Internal consistency of every entry's stable classification fields:
- category.class / subtype in the closed enum,
- op_type valid,
- exact_edit.kind == the case's intended kind,
- safety_tier == derive_safety_tier(...) recomputed for the entry,
- exact_edit.expected_sha256 == a fresh sha256 of the input/ file bytes
(proves the golden's hashes track the fixtures).
The LIVE model-classification regression (actually running /hygiene check
against input/ and diffing the produced classification) lives OUTSIDE this suite
see examples/golden/classifier/README.md.
sys.path is patched here (no shared conftest) to match the repo's test style.
"""
from __future__ import annotations
import hashlib
import json
import sys
from pathlib import Path
import pytest
# ---------------------------------------------------------------------------
# Path bootstrap — inject scripts/ so we can import the deterministic seams.
# ---------------------------------------------------------------------------
_SCRIPTS_DIR = Path(__file__).parent.parent / "scripts"
if str(_SCRIPTS_DIR) not in sys.path:
sys.path.insert(0, str(_SCRIPTS_DIR))
from scanner import Scanner # noqa: E402
from validate_report import ( # noqa: E402
ReportValidator,
derive_safety_tier,
KIND_TABLE,
STALE_SUBTYPES,
BLOAT_SUBTYPES,
)
_GOLDEN_DIR = Path(__file__).parent.parent / "examples" / "golden" / "classifier"
# ---------------------------------------------------------------------------
# Case table — the expected stable contract for each golden.
#
# Each case declares:
# name : case directory name under examples/golden/classifier/
# expected_signals: { fixture-relative path -> set of signal names that MUST
# appear on that path }
# intended_kind : the exact_edit.kind the case is built to exercise, or None
# for a generative case (no exact_edit). The distinct-kind
# coverage of the suite is asserted from this column.
# cls / subtype : the expected category for the single classified entry.
# ---------------------------------------------------------------------------
CASES = [
{
"name": "1-orphaned",
"expected_signals": {"docs/orphan.md": {"broken_reference"}},
"intended_kind": "delete-range",
"cls": "stale",
"subtype": "orphaned",
},
{
"name": "2-superseded",
"expected_signals": {"docs/old-plan.md": {"stale_name_location"}},
"intended_kind": "move-to-archive",
"cls": "stale",
"subtype": "superseded",
},
{
"name": "3-completed-in-place",
"expected_signals": {
"docs/migration-checklist.md": {"archive_to_live_ratio"}
},
"intended_kind": "insert-frontmatter",
"cls": "stale",
"subtype": "completed-in-place",
},
{
"name": "4-duplicated",
"expected_signals": {"docs/setup-legacy.md": {"stale_name_location"}},
"intended_kind": "dedupe",
"cls": "stale",
"subtype": "duplicated",
},
{
"name": "5-distill",
"expected_signals": {"docs/incident-log.md": {"archive_to_live_ratio"}},
"intended_kind": None, # generative — no exact_edit
"cls": "bloat",
"subtype": "distill",
},
]
_CASE_IDS = [c["name"] for c in CASES]
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _case_dir(case: dict) -> Path:
return _GOLDEN_DIR / case["name"]
def _run_scanner(input_dir: Path) -> dict:
"""Run the scanner exactly as the CLI would, but pinned to input_dir.
git_log_fn is disabled (None) so the suite is git-independent and the
mtime-dependent edit_recency_vs_churn signal can never fire keeping the
run fully deterministic from the static fixture bytes.
"""
return Scanner(
root=input_dir,
git_log_fn=None,
now_fn=lambda: 0.0,
).run()
def _load_expected(case: dict) -> dict:
return json.loads((_case_dir(case) / "expected.json").read_text(encoding="utf-8"))
# ---------------------------------------------------------------------------
# 1. Scanner emits the expected signals on the expected paths
# ---------------------------------------------------------------------------
class TestScannerSignalsOnFixtures:
@pytest.mark.parametrize("case", CASES, ids=_CASE_IDS)
def test_expected_signals_present(self, case):
input_dir = _case_dir(case) / "input"
result = _run_scanner(input_dir)
signals_map = result["signals"]
for path, expected_names in case["expected_signals"].items():
assert path in result["shortlist"], (
f"{case['name']}: expected path '{path}' not in shortlist "
f"{result['shortlist']}"
)
got_names = {s["name"] for s in signals_map.get(path, [])}
missing = expected_names - got_names
assert not missing, (
f"{case['name']}: path '{path}' missing signals {missing}; "
f"scanner emitted {got_names}"
)
@pytest.mark.parametrize("case", CASES, ids=_CASE_IDS)
def test_classified_path_is_a_signal_bearing_candidate(self, case):
"""Every classified entry path must be one the scanner actually signalled
(zero-signal files are presumptively cleared and never classified)."""
input_dir = _case_dir(case) / "input"
result = _run_scanner(input_dir)
signal_bearing = set(result["signals"].keys())
report = _load_expected(case)
for entry in report["entries"]:
assert entry["path"] in signal_bearing, (
f"{case['name']}: classified '{entry['path']}' carries no scanner "
f"signal (signal-bearing: {sorted(signal_bearing)})"
)
# ---------------------------------------------------------------------------
# 2. Each expected.json is itself a valid machine report
# ---------------------------------------------------------------------------
class TestGoldensAreValidReports:
@pytest.mark.parametrize("case", CASES, ids=_CASE_IDS)
def test_expected_report_validates(self, case):
report = _load_expected(case)
violations = ReportValidator(report).validate()
assert violations == [], (
f"{case['name']}: expected.json failed schema validation: {violations}"
)
# ---------------------------------------------------------------------------
# 3. Internal consistency of stable classification fields
# ---------------------------------------------------------------------------
class TestStableClassificationConsistency:
@pytest.mark.parametrize("case", CASES, ids=_CASE_IDS)
def test_single_entry_matches_case_category(self, case):
report = _load_expected(case)
assert len(report["entries"]) == 1, (
f"{case['name']}: expected exactly one classified entry"
)
entry = report["entries"][0]
cat = entry["category"]
assert cat["class"] == case["cls"]
assert cat["subtype"] == case["subtype"]
@pytest.mark.parametrize("case", CASES, ids=_CASE_IDS)
def test_category_in_closed_enum(self, case):
report = _load_expected(case)
for entry in report["entries"]:
cls = entry["category"]["class"]
subtype = entry["category"]["subtype"]
assert cls in ("stale", "bloat")
if cls == "stale":
assert subtype in STALE_SUBTYPES, f"{subtype} not a stale subtype"
else:
assert subtype in BLOAT_SUBTYPES, f"{subtype} not a bloat subtype"
@pytest.mark.parametrize("case", CASES, ids=_CASE_IDS)
def test_op_type_valid_and_exact_edit_biconditional(self, case):
report = _load_expected(case)
for entry in report["entries"]:
op_type = entry["op_type"]
assert op_type in ("deterministic", "generative")
has_edit = "exact_edit" in entry
assert has_edit == (op_type == "deterministic"), (
f"{case['name']}: exact_edit presence must iff op_type=="
f"deterministic (op_type={op_type}, has_edit={has_edit})"
)
@pytest.mark.parametrize("case", CASES, ids=_CASE_IDS)
def test_exact_edit_kind_matches_intended(self, case):
report = _load_expected(case)
entry = report["entries"][0]
if case["intended_kind"] is None:
assert "exact_edit" not in entry, (
f"{case['name']}: generative case must carry no exact_edit"
)
else:
assert entry["exact_edit"]["kind"] == case["intended_kind"], (
f"{case['name']}: exact_edit.kind "
f"{entry['exact_edit'].get('kind')!r} != intended "
f"{case['intended_kind']!r}"
)
@pytest.mark.parametrize("case", CASES, ids=_CASE_IDS)
def test_safety_tier_is_derived(self, case):
"""The recorded safety_tier MUST equal the deterministic derivation for
the entry's (op_type, is_destructive, is_reversible)."""
report = _load_expected(case)
for entry in report["entries"]:
expected_tier = derive_safety_tier(
entry["op_type"],
entry["is_destructive"],
entry["is_reversible"],
)
assert entry["safety_tier"] == expected_tier, (
f"{case['name']}: safety_tier {entry['safety_tier']!r} != derived "
f"{expected_tier!r}"
)
@pytest.mark.parametrize("case", CASES, ids=_CASE_IDS)
def test_kind_table_booleans_match(self, case):
"""For deterministic entries, is_destructive/is_reversible must equal the
kind table's inherent pair."""
report = _load_expected(case)
for entry in report["entries"]:
if entry["op_type"] != "deterministic":
continue
kind = entry["exact_edit"]["kind"]
spec = KIND_TABLE[kind]
assert entry["is_destructive"] == spec.is_destructive
assert entry["is_reversible"] == spec.is_reversible
# ---------------------------------------------------------------------------
# 4. expected_sha256 tracks the fixture bytes
# ---------------------------------------------------------------------------
class TestExpectedShaTracksFixture:
@pytest.mark.parametrize("case", CASES, ids=_CASE_IDS)
def test_expected_sha256_matches_input_bytes(self, case):
"""Re-verify expected_sha256 against a freshly-computed hash of the
corresponding input/ file. Proves the golden's hashes track the
fixtures (so a fixture edit without a regen is caught).
Anchor-bearing kinds only carry expected_sha256; insert-frontmatter
(has_anchor=False) and generative entries carry none skip those.
"""
input_dir = _case_dir(case) / "input"
report = _load_expected(case)
checked = 0
for entry in report["entries"]:
edit = entry.get("exact_edit")
if not edit or "expected_sha256" not in edit:
continue
file_bytes = (input_dir / entry["path"]).read_bytes()
fresh = hashlib.sha256(file_bytes).hexdigest()
assert edit["expected_sha256"] == fresh, (
f"{case['name']}: expected_sha256 for {entry['path']} is stale "
f"(golden={edit['expected_sha256']}, fixture={fresh})"
)
checked += 1
# Sanity: the three anchor-bearing-kind cases MUST have a hash to check.
intended = case["intended_kind"]
if intended in ("delete-range", "move-to-archive", "dedupe", "replace-text"):
assert checked >= 1, (
f"{case['name']}: anchor-bearing kind {intended} should carry an "
f"expected_sha256 but none was checked"
)
# ---------------------------------------------------------------------------
# 5. Suite-level coverage: the intended kinds are distinct across cases
# ---------------------------------------------------------------------------
def test_intended_kinds_are_distinct():
"""The goldens deliberately map each subtype to a DISTINCT exact_edit.kind
(plus one generative case) so the suite covers the kind table + tier
derivation. Guards against two goldens collapsing onto one kind."""
deterministic_kinds = [c["intended_kind"] for c in CASES if c["intended_kind"]]
assert len(deterministic_kinds) == len(set(deterministic_kinds)), (
f"intended kinds must be distinct, got {deterministic_kinds}"
)
# At least one generative case (intended_kind is None).
assert any(c["intended_kind"] is None for c in CASES)

View File

@ -0,0 +1,144 @@
"""
Cross-seam integration tests for the deterministic core (Group 5, task 5.1/5.2).
Exercises scanner + state_store + reminder together on a small fixture doc tree
built in a tmp dir, verifying the end-to-end deterministic path:
- the scanner produces a shortlist (and self-excludes .dochygiene/),
- the state store records timestamps and rolls reports over (keeps one pair),
- the reminder's snooze behaves across simulated sessions with an injected
clock (silent when fresh; banner when stale; snoozed same calendar day;
re-banners the next day),
all with NO model and NO network the deterministic-first invariant (#6).
sys.path is patched here (no shared conftest) so the file is self-contained.
"""
from __future__ import annotations
import json
import sys
from datetime import datetime, timezone
from io import StringIO
from pathlib import Path
import pytest
_SCRIPTS_DIR = Path(__file__).parent.parent / "scripts"
if str(_SCRIPTS_DIR) not in sys.path:
sys.path.insert(0, str(_SCRIPTS_DIR))
from scanner import Scanner # noqa: E402
from state_store import StateStore, resolve_project_root # noqa: E402
from reminder import Decision, ReminderRunner # noqa: E402
def _utc(y, m, d, hh=0, mm=0):
return datetime(y, m, d, hh, mm, tzinfo=timezone.utc)
class FrozenClock:
def __init__(self, now):
self._now = now
def now(self):
return self._now
def _build_doc_tree(root: Path) -> None:
"""A minimal project: a git marker, two docs, plus a frozen + ignored doc."""
(root / ".git").mkdir()
(root / "CLAUDE.md").write_text("# Project\nSee scripts/missing.py for details.\n")
(root / "docs").mkdir()
(root / "docs" / "plan.md").write_text("# Plan\nstuff\n")
# frozen doc — must NOT appear in shortlist (invariant #9)
(root / "docs" / "frozen.md").write_text("---\nhygiene: frozen\n---\n# Frozen\n")
# ignored doc
(root / ".dochygiene-ignore").write_text("docs/secret.md\n")
(root / "docs" / "secret.md").write_text("# Secret\n")
class TestScannerStateSeam:
def test_scan_shortlist_and_self_exclusion(self, tmp_path):
_build_doc_tree(tmp_path)
# Resolve root exactly as production would (git root).
root = resolve_project_root(tmp_path / "docs")
assert root == tmp_path
# The state store writes its dir first; the scanner must self-exclude it.
store = StateStore(root)
store.set_timestamp("last_check", _utc(2026, 6, 1))
assert (root / ".dochygiene" / "state.json").exists()
artifact = Scanner(root=root, git_log_fn=lambda p: []).run()
shortlist = set(artifact["shortlist"])
assert "CLAUDE.md" in shortlist
assert "docs/plan.md" in shortlist
# Exclusions honoured.
assert "docs/frozen.md" not in shortlist
assert "docs/secret.md" not in shortlist
# .dochygiene/ never scanned (self-exclusion).
assert not any(p.startswith(".dochygiene") for p in shortlist)
def test_report_rollover_keeps_one_pair(self, tmp_path):
_build_doc_tree(tmp_path)
root = resolve_project_root(tmp_path)
store = StateStore(root)
store.write_report('{"v":1}', "# v1")
store.write_report('{"v":2}', "# v2")
state_dir = root / ".dochygiene"
jsons = list(state_dir.glob("report*.json"))
mds = list(state_dir.glob("report*.md"))
assert len(jsons) == 1
assert len(mds) == 1
blob = store.read_report()
assert blob is not None
assert json.loads(blob[0]) == {"v": 2}
class TestReminderAcrossSessions:
"""Drive the reminder over a sequence of simulated SessionStart events."""
def _session(self, store, now) -> Decision:
out = StringIO()
decision = ReminderRunner(store, clock=FrozenClock(now), out=out).run()
# Banner ⇒ systemMessage on stdout; silent ⇒ nothing.
if decision is Decision.BANNER:
assert "systemMessage" in json.loads(out.getvalue())
else:
assert out.getvalue() == ""
return decision
def test_full_lifecycle(self, tmp_path):
_build_doc_tree(tmp_path)
root = resolve_project_root(tmp_path)
store = StateStore(root)
# 1. Fresh check today → reminder is SILENT.
store.set_timestamp("last_check", _utc(2026, 6, 24))
assert self._session(store, _utc(2026, 6, 24, 10)) is Decision.SILENT
# 2. Simulate the check ageing past the threshold (set last_check far back).
store.set_timestamp("last_check", _utc(2026, 5, 1))
# 3. First stale session → BANNER, records last_reminded.
assert self._session(store, _utc(2026, 6, 24, 9)) is Decision.BANNER
first_reminded = store.get_timestamp("last_reminded")
assert first_reminded == _utc(2026, 6, 24, 9)
# 4. Second session SAME calendar day (e.g. resume) → SILENT (snooze).
assert self._session(store, _utc(2026, 6, 24, 18)) is Decision.SILENT
assert store.get_timestamp("last_reminded") == first_reminded # not bumped
# 5. Next calendar day, still stale → BANNER again, bumps last_reminded.
assert self._session(store, _utc(2026, 6, 25, 8)) is Decision.BANNER
assert store.get_timestamp("last_reminded") == _utc(2026, 6, 25, 8)
# 6. A fresh check clears staleness → SILENT regardless of snooze.
store.set_timestamp("last_check", _utc(2026, 6, 25, 12))
assert self._session(store, _utc(2026, 6, 25, 13)) is Decision.SILENT

View File

@ -0,0 +1,840 @@
"""
Tests for scripts/patch_applier.py the deterministic file-patch applier.
Safety net covering:
- each kind's transformation on a fixture
- multi-entry-one-file descending-order correctness (two delete-ranges)
- invariant #8: content changed since check → skip-file, continue other files
- incompatible-ops detection: move-to-archive + other op whole file skipped;
overlapping anchors skipped
- insert-frontmatter idempotency + key-conflict-preserve
- move-to-archive via real `git mv` in a tmp git repo
- replace-text no-op when match absent
- unknown kind rejected
- exit codes for all-success / partial / usage-error (via CLI main())
sys.path is patched here (no shared conftest) matching repo test style.
"""
from __future__ import annotations
import hashlib
import json
import os
import subprocess
import sys
import tempfile
from pathlib import Path
from typing import Any
import pytest
# ---------------------------------------------------------------------------
# Path bootstrap
# ---------------------------------------------------------------------------
_SCRIPTS_DIR = Path(__file__).parent.parent / "scripts"
if str(_SCRIPTS_DIR) not in sys.path:
sys.path.insert(0, str(_SCRIPTS_DIR))
from patch_applier import ( # noqa: E402
PatchApplier,
RealFileSystem,
_delete_lines,
_insert_frontmatter_key,
_parse_frontmatter,
_replace_in_span,
_ranges_overlap,
main as applier_main,
)
# ---------------------------------------------------------------------------
# Test doubles
# ---------------------------------------------------------------------------
class MemFS:
"""In-memory filesystem for hermetic tests (no real disk I/O)."""
def __init__(self, files: dict[str, bytes] | None = None) -> None:
self._files: dict[str, bytes] = dict(files or {})
self.writes: dict[str, bytes] = {}
self.mkdirs: list[str] = []
def read_bytes(self, path: Path) -> bytes:
key = str(path)
if key not in self._files:
raise FileNotFoundError(f"MemFS: {key} not found")
return self._files[key]
def write_bytes(self, path: Path, data: bytes) -> None:
key = str(path)
self._files[key] = data
self.writes[key] = data
def exists(self, path: Path) -> bool:
return str(path) in self._files
def mkdir(self, path: Path) -> None:
self.mkdirs.append(str(path))
class RecordingGit:
"""Records git mv calls without touching disk."""
def __init__(self, fail: bool = False) -> None:
self.calls: list[tuple[str, str]] = []
self._fail = fail
def mv(self, src: Path, dest: Path, cwd: Path) -> None:
if self._fail:
raise RuntimeError("git mv simulated failure")
self.calls.append((str(src), str(dest)))
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _sha256(data: bytes) -> str:
return hashlib.sha256(data).hexdigest()
def _make_file(content: str) -> tuple[bytes, str]:
"""Return (bytes, sha256_hex) for a text content."""
b = content.encode("utf-8")
return b, _sha256(b)
def _entry(
path: str,
kind: str,
extra: dict | None = None,
sha: str = "",
start: int = 1,
end: int = 1,
) -> dict:
"""Build a minimal deterministic entry dict."""
from validate_report import KIND_TABLE
spec = KIND_TABLE[kind]
exact_edit: dict[str, Any] = {"kind": kind}
if spec.has_anchor:
exact_edit["anchor"] = {"start_line": start, "end_line": end}
exact_edit["expected_sha256"] = sha
if extra:
exact_edit.update(extra)
return {
"path": path,
"op": f"test op {kind}",
"op_type": "deterministic",
"is_destructive": spec.is_destructive,
"is_reversible": spec.is_reversible,
"exact_edit": exact_edit,
}
def _applier(root: Path, fs=None, git=None) -> PatchApplier:
return PatchApplier(project_root=root, fs=fs, git=git)
def _init_git_repo(tmp_path: Path) -> Path:
"""Create a real git repo in tmp_path; configure minimal identity."""
subprocess.run(["git", "init", str(tmp_path)], check=True, capture_output=True)
subprocess.run(
["git", "config", "user.email", "test@test.com"],
cwd=str(tmp_path), check=True, capture_output=True,
)
subprocess.run(
["git", "config", "user.name", "Test"],
cwd=str(tmp_path), check=True, capture_output=True,
)
return tmp_path
# ===========================================================================
# Unit: pure helper functions
# ===========================================================================
class TestHelpers:
def test_delete_lines_removes_inclusive_range(self):
lines = ["a\n", "b\n", "c\n", "d\n", "e\n"]
result = _delete_lines(lines, 2, 4)
assert result == ["a\n", "e\n"]
def test_delete_lines_whole_file(self):
lines = ["a\n", "b\n"]
assert _delete_lines(lines, 1, 2) == []
def test_delete_lines_single_line(self):
lines = ["a\n", "b\n", "c\n"]
assert _delete_lines(lines, 2, 2) == ["a\n", "c\n"]
def test_replace_in_span_finds_first_occurrence(self):
lines = ["foo bar\n", "foo baz\n", "other\n"]
out, found = _replace_in_span(lines, 1, 2, "foo", "qux")
assert found is True
assert out[0] == "qux bar\n" # first occurrence replaced
assert out[1] == "foo baz\n" # second unchanged (first only)
def test_replace_in_span_not_found(self):
lines = ["a\n", "b\n"]
out, found = _replace_in_span(lines, 1, 2, "missing", "x")
assert found is False
assert out == ["a\n", "b\n"]
def test_ranges_overlap_adjacent_not_overlapping(self):
assert not _ranges_overlap(1, 3, 4, 6)
def test_ranges_overlap_touching_end(self):
assert _ranges_overlap(1, 4, 4, 6)
def test_ranges_overlap_contained(self):
assert _ranges_overlap(1, 10, 3, 5)
def test_parse_frontmatter_present(self):
text = "---\nhygiene: frozen\nauthor: jared\n---\nbody\n"
kv, end = _parse_frontmatter(text)
assert kv == {"hygiene": "frozen", "author": "jared"}
assert end == 4 # line index after closing ---
def test_parse_frontmatter_absent(self):
kv, end = _parse_frontmatter("# heading\nbody\n")
assert kv == {}
assert end == 0
def test_insert_frontmatter_key_creates_block(self):
text = "# heading\nbody\n"
result = _insert_frontmatter_key(text, "hygiene", "frozen")
assert result.startswith("---\nhygiene: frozen\n---\n")
def test_insert_frontmatter_key_appends_to_existing(self):
text = "---\nauthor: jared\n---\nbody\n"
result = _insert_frontmatter_key(text, "hygiene", "frozen")
assert "hygiene: frozen" in result
assert "author: jared" in result
# closing --- must still appear after new key
lines = result.splitlines()
fm_end = lines.index("---", 1)
assert "hygiene: frozen" in lines[fm_end - 1]
# ===========================================================================
# Each kind's transformation
# ===========================================================================
class TestDeleteRange:
def test_removes_specified_lines(self, tmp_path):
content = "line1\nline2\nline3\nline4\nline5\n"
file_bytes, sha = _make_file(content)
fs = MemFS({str(tmp_path / "doc.md"): file_bytes})
applier = _applier(tmp_path, fs=fs)
entry = _entry("doc.md", "delete-range", sha=sha, start=2, end=4)
result = applier.apply([entry])
assert len(result["applied"]) == 1
assert result["applied"][0]["kind"] == "delete-range"
written = fs.writes[str(tmp_path / "doc.md")].decode()
assert written == "line1\nline5\n"
def test_records_staged_path(self, tmp_path):
content = "a\nb\nc\n"
file_bytes, sha = _make_file(content)
fs = MemFS({str(tmp_path / "x.md"): file_bytes})
result = _applier(tmp_path, fs=fs).apply([
_entry("x.md", "delete-range", sha=sha, start=2, end=2)
])
assert "x.md" in result["staged_paths"]
class TestReplaceText:
def test_replaces_match_in_span(self, tmp_path):
content = "see old.md here\nother line\n"
file_bytes, sha = _make_file(content)
fs = MemFS({str(tmp_path / "doc.md"): file_bytes})
entry = _entry("doc.md", "replace-text", sha=sha, start=1, end=1,
extra={"match": "old.md", "replacement": "new.md"})
result = _applier(tmp_path, fs=fs).apply([entry])
assert result["applied"][0]["kind"] == "replace-text"
written = fs.writes[str(tmp_path / "doc.md")].decode()
assert "new.md" in written
assert "old.md" not in written
def test_match_absent_is_noop_success(self, tmp_path):
"""replace-text: match absent in span → no-op success, no write."""
content = "nothing relevant here\n"
file_bytes, sha = _make_file(content)
fs = MemFS({str(tmp_path / "doc.md"): file_bytes})
entry = _entry("doc.md", "replace-text", sha=sha, start=1, end=1,
extra={"match": "old.md", "replacement": "new.md"})
result = _applier(tmp_path, fs=fs).apply([entry])
assert result["applied"][0]["kind"] == "replace-text"
assert result["skipped"] == []
assert result["failed"] == []
# No write needed (content unchanged).
assert str(tmp_path / "doc.md") not in fs.writes
class TestDedupe:
def test_removes_duplicate_span(self, tmp_path):
content = "header\ndupe1\ndupe2\nfooter\n"
file_bytes, sha = _make_file(content)
fs = MemFS({str(tmp_path / "doc.md"): file_bytes})
entry = _entry("doc.md", "dedupe", sha=sha, start=2, end=3,
extra={"canonical_ref": "other.md#section"})
result = _applier(tmp_path, fs=fs).apply([entry])
assert result["applied"][0]["kind"] == "dedupe"
written = fs.writes[str(tmp_path / "doc.md")].decode()
assert "dupe1" not in written
assert "header" in written
assert "footer" in written
class TestInsertFrontmatter:
def test_inserts_key_into_existing_block(self, tmp_path):
content = "---\nauthor: jared\n---\nbody\n"
file_bytes, _ = _make_file(content)
fs = MemFS({str(tmp_path / "doc.md"): file_bytes})
entry = _entry("doc.md", "insert-frontmatter",
extra={"key": "hygiene", "value": "frozen"})
result = _applier(tmp_path, fs=fs).apply([entry])
assert result["applied"][0]["kind"] == "insert-frontmatter"
written = fs.writes[str(tmp_path / "doc.md")].decode()
assert "hygiene: frozen" in written
assert "author: jared" in written
def test_creates_frontmatter_block_when_absent(self, tmp_path):
content = "# heading\nbody\n"
file_bytes, _ = _make_file(content)
fs = MemFS({str(tmp_path / "doc.md"): file_bytes})
entry = _entry("doc.md", "insert-frontmatter",
extra={"key": "hygiene", "value": "frozen"})
result = _applier(tmp_path, fs=fs).apply([entry])
written = fs.writes[str(tmp_path / "doc.md")].decode()
assert written.startswith("---\nhygiene: frozen\n---\n")
def test_idempotent_when_key_already_correct(self, tmp_path):
"""Key present with target value → success, no write."""
content = "---\nhygiene: frozen\n---\nbody\n"
file_bytes, _ = _make_file(content)
fs = MemFS({str(tmp_path / "doc.md"): file_bytes})
entry = _entry("doc.md", "insert-frontmatter",
extra={"key": "hygiene", "value": "frozen"})
result = _applier(tmp_path, fs=fs).apply([entry])
assert result["applied"][0]["kind"] == "insert-frontmatter"
assert result["skipped"] == []
# Idempotent → content unchanged → no write.
assert str(tmp_path / "doc.md") not in fs.writes
def test_key_conflict_preserves_existing(self, tmp_path):
"""Key present with DIFFERENT value → skipped, existing value untouched."""
content = "---\nhygiene: ignored\n---\nbody\n"
file_bytes, _ = _make_file(content)
fs = MemFS({str(tmp_path / "doc.md"): file_bytes})
entry = _entry("doc.md", "insert-frontmatter",
extra={"key": "hygiene", "value": "frozen"})
result = _applier(tmp_path, fs=fs).apply([entry])
assert result["applied"] == []
assert result["skipped"][0]["reason"] == "frontmatter-key-conflict"
# File must NOT have been modified.
assert str(tmp_path / "doc.md") not in fs.writes
class TestMoveToArchive:
def test_git_mv_called_with_correct_args(self, tmp_path):
content = "# old plan\nbody\n"
file_bytes, sha = _make_file(content)
fs = MemFS({
str(tmp_path / "docs" / "old.md"): file_bytes,
str(tmp_path / "archive" / "docs" / "old.md"): b"", # simulate post-mv
})
git = RecordingGit()
entry = _entry("docs/old.md", "move-to-archive", sha=sha, start=1, end=2,
extra={"dest_path": "archive/docs/old.md"})
result = _applier(tmp_path, fs=fs, git=git).apply([entry])
assert result["applied"][0]["kind"] == "move-to-archive"
assert len(git.calls) == 1
src, dest = git.calls[0]
assert src == str(tmp_path / "docs" / "old.md")
assert dest == str(tmp_path / "archive" / "docs" / "old.md")
def test_staged_paths_contains_dest_path(self, tmp_path):
content = "x\n"
file_bytes, sha = _make_file(content)
fs = MemFS({str(tmp_path / "old.md"): file_bytes})
git = RecordingGit()
entry = _entry("old.md", "move-to-archive", sha=sha, start=1, end=1,
extra={"dest_path": "archive/old.md"})
result = _applier(tmp_path, fs=fs, git=git).apply([entry])
assert "archive/old.md" in result["staged_paths"]
def test_real_git_mv_stages_both_sides(self, tmp_path):
"""Integration: real git mv in a real repo; file is moved + staged."""
repo = _init_git_repo(tmp_path)
src = repo / "old.md"
src.write_bytes(b"old content\n")
subprocess.run(["git", "add", "old.md"], cwd=str(repo), check=True, capture_output=True)
subprocess.run(
["git", "commit", "-m", "add file"],
cwd=str(repo), check=True, capture_output=True
)
file_bytes = src.read_bytes()
sha = _sha256(file_bytes)
entry = _entry("old.md", "move-to-archive", sha=sha, start=1, end=1,
extra={"dest_path": "archive/old.md"})
result = PatchApplier(project_root=repo).apply([entry])
assert result["applied"][0]["kind"] == "move-to-archive"
assert not src.exists(), "source should be gone after git mv"
assert (repo / "archive" / "old.md").exists(), "dest should exist"
# Verify git staging.
status = subprocess.run(
["git", "status", "--porcelain"],
cwd=str(repo), capture_output=True, text=True
).stdout
# Expect a rename staging entry (R or D+A lines).
assert "old.md" in status
# ===========================================================================
# Per-file transaction: descending-order correctness (multi-entry)
# ===========================================================================
class TestMultiEntryDescendingOrder:
def test_two_delete_ranges_on_one_file_applied_correctly(self, tmp_path):
"""
Deleting lines 5-6 then 2-3 (descending) must not shift line numbers.
File (1-indexed):
1: header
2: remove-me-a
3: remove-me-b
4: keep-middle
5: remove-me-c
6: remove-me-d
7: footer
Expected after both deletes:
header
keep-middle
footer
"""
content = "header\nremove-me-a\nremove-me-b\nkeep-middle\nremove-me-c\nremove-me-d\nfooter\n"
file_bytes, sha = _make_file(content)
fs = MemFS({str(tmp_path / "doc.md"): file_bytes})
entries = [
_entry("doc.md", "delete-range", sha=sha, start=2, end=3), # lower range
_entry("doc.md", "delete-range", sha=sha, start=5, end=6), # higher range
]
result = _applier(tmp_path, fs=fs).apply(entries)
assert len(result["applied"]) == 2
assert result["skipped"] == []
written = fs.writes[str(tmp_path / "doc.md")].decode()
assert written == "header\nkeep-middle\nfooter\n"
def test_delete_range_then_insert_frontmatter_correct_order(self, tmp_path):
"""
insert-frontmatter must apply AFTER anchored edits.
File: 3 content lines. Delete line 3, then insert frontmatter.
Result: frontmatter block + line1 + line2.
"""
content = "line1\nline2\nline3\n"
file_bytes, sha = _make_file(content)
fs = MemFS({str(tmp_path / "doc.md"): file_bytes})
entries = [
_entry("doc.md", "insert-frontmatter",
extra={"key": "hygiene", "value": "frozen"}),
_entry("doc.md", "delete-range", sha=sha, start=3, end=3),
]
result = _applier(tmp_path, fs=fs).apply(entries)
assert len(result["applied"]) == 2
written = fs.writes[str(tmp_path / "doc.md")].decode()
assert written.startswith("---\nhygiene: frozen\n---\n")
assert "line1" in written
assert "line2" in written
assert "line3" not in written
# ===========================================================================
# Invariant #8: content changed since check → skip file, continue others
# ===========================================================================
class TestContentChangedGuard:
def test_changed_file_skipped_others_continue(self, tmp_path):
"""
Two files: doc-a.md has changed since check, doc-b.md is unchanged.
doc-a.md must be skipped; doc-b.md must be applied.
"""
# doc-a: content is different from the sha in the entry.
doc_a_bytes = b"new content not matching sha\n"
doc_a_stale_sha = _sha256(b"original content\n") # sha from check time
# doc-b: content matches.
doc_b_content = "line1\nline2\nline3\n"
doc_b_bytes, doc_b_sha = _make_file(doc_b_content)
fs = MemFS({
str(tmp_path / "doc-a.md"): doc_a_bytes,
str(tmp_path / "doc-b.md"): doc_b_bytes,
})
entries = [
_entry("doc-a.md", "delete-range", sha=doc_a_stale_sha, start=1, end=1),
_entry("doc-b.md", "delete-range", sha=doc_b_sha, start=2, end=2),
]
result = _applier(tmp_path, fs=fs).apply(entries)
# doc-a skipped
assert any(s["path"] == "doc-a.md" and s["reason"] == "content-changed-since-check"
for s in result["skipped"])
# doc-b applied
assert any(a["path"] == "doc-b.md" for a in result["applied"])
# doc-a NOT written
assert str(tmp_path / "doc-a.md") not in fs.writes
# doc-b IS written
assert str(tmp_path / "doc-b.md") in fs.writes
# ===========================================================================
# Incompatible-ops detection
# ===========================================================================
class TestIncompatibleOps:
def test_move_to_archive_plus_delete_range_skips_whole_file(self, tmp_path):
content = "content\n"
file_bytes, sha = _make_file(content)
fs = MemFS({str(tmp_path / "doc.md"): file_bytes})
git = RecordingGit()
entries = [
_entry("doc.md", "move-to-archive", sha=sha, start=1, end=1,
extra={"dest_path": "archive/doc.md"}),
_entry("doc.md", "delete-range", sha=sha, start=1, end=1),
]
result = _applier(tmp_path, fs=fs, git=git).apply(entries)
assert result["applied"] == []
assert all(s["reason"] == "incompatible-ops-on-file" for s in result["skipped"])
assert len(result["skipped"]) == 2 # both entries skipped
# git mv must NOT have been called.
assert git.calls == []
def test_overlapping_anchors_skips_whole_file(self, tmp_path):
content = "a\nb\nc\nd\ne\n"
file_bytes, sha = _make_file(content)
fs = MemFS({str(tmp_path / "doc.md"): file_bytes})
# Ranges 2-4 and 3-5 overlap.
entries = [
_entry("doc.md", "delete-range", sha=sha, start=2, end=4),
_entry("doc.md", "dedupe", sha=sha, start=3, end=5,
extra={"canonical_ref": "other.md"}),
]
result = _applier(tmp_path, fs=fs).apply(entries)
assert result["applied"] == []
assert all(s["reason"] == "incompatible-ops-on-file" for s in result["skipped"])
# File must not be written.
assert str(tmp_path / "doc.md") not in fs.writes
def test_non_overlapping_anchors_allowed(self, tmp_path):
content = "a\nb\nc\nd\ne\n"
file_bytes, sha = _make_file(content)
fs = MemFS({str(tmp_path / "doc.md"): file_bytes})
# Ranges 1-2 and 4-5 do NOT overlap.
entries = [
_entry("doc.md", "delete-range", sha=sha, start=1, end=2),
_entry("doc.md", "delete-range", sha=sha, start=4, end=5),
]
result = _applier(tmp_path, fs=fs).apply(entries)
assert result["skipped"] == []
assert len(result["applied"]) == 2
# ===========================================================================
# Unknown kind rejected
# ===========================================================================
class TestUnknownKind:
def test_unknown_kind_skipped_with_structured_reason(self, tmp_path):
fs = MemFS()
entry = {
"path": "doc.md",
"op": "nuke it",
"op_type": "deterministic",
"is_destructive": True,
"is_reversible": False,
"exact_edit": {"kind": "nuke-it", "anchor": {"start_line": 1, "end_line": 1}},
}
result = _applier(tmp_path, fs=fs).apply([entry])
assert result["applied"] == []
assert result["skipped"][0]["reason"] == "unknown-kind"
def test_generative_entry_rejected(self, tmp_path):
fs = MemFS()
entry = {
"path": "doc.md",
"op": "distill",
"op_type": "generative",
"is_destructive": False,
"is_reversible": True,
# No exact_edit (as per spec for generative).
}
result = _applier(tmp_path, fs=fs).apply([entry])
assert result["applied"] == []
assert result["skipped"][0]["reason"] == "generative-entry-rejected"
def test_unknown_kind_mixed_with_valid_sibling_reports_all(self, tmp_path):
"""
One invalid entry + one valid entry on the same file: neither must be
applied, and both must appear in skipped (valid sibling must not vanish).
"""
content = "line1\nline2\nline3\n"
file_bytes, sha = _make_file(content)
fs = MemFS({str(tmp_path / "doc.md"): file_bytes})
entries = [
{ # valid delete-range
"path": "doc.md",
"op": "delete line 2",
"op_type": "deterministic",
"exact_edit": {
"kind": "delete-range",
"anchor": {"start_line": 2, "end_line": 2},
"expected_sha256": sha,
},
},
{ # invalid kind
"path": "doc.md",
"op": "nuke",
"op_type": "deterministic",
"exact_edit": {"kind": "nuke-it", "anchor": {"start_line": 3, "end_line": 3}},
},
]
result = _applier(tmp_path, fs=fs).apply(entries)
assert result["applied"] == []
# Both entries must appear in skipped.
skipped_indices = {s["entry_index"] for s in result["skipped"]}
assert {0, 1} == skipped_indices
# File must NOT have been written.
assert str(tmp_path / "doc.md") not in fs.writes
class TestNonUtf8Content:
def test_non_utf8_file_skipped_not_corrupted(self, tmp_path):
"""
File with non-UTF-8 bytes: skip with reason, do not write a corrupted
version. The hash guard passes (we hash raw bytes) this is the
edge case that errors="replace" would silently corrupt.
"""
# Craft bytes that are valid Latin-1 but invalid UTF-8.
raw_bytes = b"line1\nline2 with \xff non-utf8\nline3\n"
sha = _sha256(raw_bytes)
fs = MemFS({str(tmp_path / "doc.md"): raw_bytes})
entry = _entry("doc.md", "delete-range", sha=sha, start=2, end=2)
result = _applier(tmp_path, fs=fs).apply([entry])
assert result["applied"] == []
assert result["skipped"][0]["reason"] == "non-utf8-content"
# Must not have written anything.
assert str(tmp_path / "doc.md") not in fs.writes
# ===========================================================================
# move-to-archive idempotency (source already gone)
# ===========================================================================
class TestMoveToArchiveIdempotent:
def test_source_already_moved_is_success(self, tmp_path):
"""
If source is gone and dest exists, treat as idempotent success.
"""
dest_bytes = b"already archived\n"
sha = _sha256(b"original\n") # sha from check time (source is gone)
fs = MemFS({
# Source NOT in fs (already moved).
str(tmp_path / "archive" / "doc.md"): dest_bytes,
})
git = RecordingGit()
entry = _entry("doc.md", "move-to-archive", sha=sha, start=1, end=1,
extra={"dest_path": "archive/doc.md"})
result = _applier(tmp_path, fs=fs, git=git).apply([entry])
assert result["applied"][0]["kind"] == "move-to-archive"
assert result["failed"] == []
# ===========================================================================
# Exit codes via CLI main()
# ===========================================================================
class TestExitCodes:
def _write_report(self, path: Path, entries: list) -> None:
report = {
"schema_version": "1.0",
"tool_version": "0.1.0",
"generated_at": "2026-06-24T00:00:00+00:00",
"scan": {
"project_root": str(path.parent),
"scope_globs": ["**/*.md"],
"excluded_dirs": [],
"files_scanned": 1,
},
"shortlist": ["doc.md"],
"entries": entries,
}
path.write_text(json.dumps(report), encoding="utf-8")
def test_exit_0_all_applied(self, tmp_path):
content = "line1\nline2\nline3\n"
file_bytes, sha = _make_file(content)
(tmp_path / "doc.md").write_bytes(file_bytes)
report_path = tmp_path / "report.json"
self._write_report(report_path, [
{
"path": "doc.md",
"op": "delete line 2",
"op_type": "deterministic",
"is_destructive": True,
"is_reversible": False,
"safety_tier": "confirm",
"signals": [],
"category": {"class": "stale", "subtype": "contradicted"},
"token_estimate": {"raw_tokens": 10},
"exact_edit": {
"kind": "delete-range",
"anchor": {"start_line": 2, "end_line": 2},
"expected_sha256": sha,
},
}
])
rc = applier_main([
"--report", str(report_path),
"--apply-indices", "0",
"--project-root", str(tmp_path),
])
assert rc == 0
def test_exit_1_partial_skipped(self, tmp_path):
"""Content changed → skip → exit 1."""
actual_bytes = b"different content\n"
stale_sha = _sha256(b"original content\n")
(tmp_path / "doc.md").write_bytes(actual_bytes)
report_path = tmp_path / "report.json"
self._write_report(report_path, [
{
"path": "doc.md",
"op": "delete line 1",
"op_type": "deterministic",
"is_destructive": True,
"is_reversible": False,
"safety_tier": "confirm",
"signals": [],
"category": {"class": "stale", "subtype": "contradicted"},
"token_estimate": {"raw_tokens": 5},
"exact_edit": {
"kind": "delete-range",
"anchor": {"start_line": 1, "end_line": 1},
"expected_sha256": stale_sha,
},
}
])
rc = applier_main([
"--report", str(report_path),
"--apply-indices", "0",
"--project-root", str(tmp_path),
])
assert rc == 1
def test_exit_2_missing_report(self, tmp_path):
rc = applier_main([
"--report", str(tmp_path / "nonexistent.json"),
"--apply-indices", "0",
"--project-root", str(tmp_path),
])
assert rc == 2
def test_exit_2_out_of_range_index(self, tmp_path):
content = "x\n"
(tmp_path / "doc.md").write_bytes(content.encode())
report_path = tmp_path / "report.json"
self._write_report(report_path, []) # 0 entries
rc = applier_main([
"--report", str(report_path),
"--apply-indices", "5",
"--project-root", str(tmp_path),
])
assert rc == 2
def test_exit_2_invalid_indices_string(self, tmp_path):
# Write a minimal valid report.
report_path = tmp_path / "report.json"
report = {
"schema_version": "1.0", "tool_version": "0.1.0",
"generated_at": "2026-06-24T00:00:00+00:00",
"scan": {"project_root": str(tmp_path), "scope_globs": [], "excluded_dirs": [], "files_scanned": 0},
"shortlist": [], "entries": [],
}
report_path.write_text(json.dumps(report))
rc = applier_main([
"--report", str(report_path),
"--apply-indices", "abc",
"--project-root", str(tmp_path),
])
assert rc == 2
# ===========================================================================
# apply_indexed preserves original report indices
# ===========================================================================
class TestApplyIndexed:
def test_entry_index_matches_original_report_index(self, tmp_path):
content = "line1\nline2\nline3\n"
file_bytes, sha = _make_file(content)
fs = MemFS({str(tmp_path / "doc.md"): file_bytes})
entry = _entry("doc.md", "delete-range", sha=sha, start=2, end=2)
# original report index is 7 (not 0).
result = _applier(tmp_path, fs=fs).apply_indexed([(7, entry)])
assert result["applied"][0]["entry_index"] == 7

View File

@ -0,0 +1,281 @@
"""
Tests for scripts/reminder.py
Covers tasks 4.1 and 4.4 of the add-deterministic-core change.
Pins invariants:
#1 Hook only reminds — no mutation except `last_reminded`, only on banner.
#2 Snooze ≤ once per calendar day while stale, keyed on `last_reminded`.
sys.path is patched here (no shared conftest) so the test file is
self-contained, as required by the file-ownership constraints.
"""
from __future__ import annotations
import json
import sys
from datetime import datetime, timezone
from io import StringIO
from pathlib import Path
import pytest
# ---------------------------------------------------------------------------
# Path bootstrap — inject scripts/ so we can import reminder + state_store.
# ---------------------------------------------------------------------------
_SCRIPTS_DIR = Path(__file__).parent.parent / "scripts"
if str(_SCRIPTS_DIR) not in sys.path:
sys.path.insert(0, str(_SCRIPTS_DIR))
import reminder as reminder_mod # noqa: E402
from reminder import ( # noqa: E402
Decision,
Reminder,
ReminderRunner,
build_banner,
main as reminder_main,
)
from state_store import StateStore # noqa: E402
def _utc(y, m, d, hh=0, mm=0):
return datetime(y, m, d, hh, mm, tzinfo=timezone.utc)
class FrozenClock:
"""Injected clock returning a fixed `now`."""
def __init__(self, now: datetime):
self._now = now
def now(self) -> datetime:
return self._now
# ===========================================================================
# Task 4.1 — pure decision function
# ===========================================================================
class TestReminderDecision:
THRESHOLD = 21
def _r(self):
return Reminder(threshold_days=self.THRESHOLD)
def test_fresh_docs_are_silent(self):
"""last_check within threshold → SILENT (no banner)."""
now = _utc(2026, 6, 24)
last_check = _utc(2026, 6, 20) # 4 days ago, < 21
assert self._r().decide(last_check, None, now) is Decision.SILENT
def test_never_checked_is_stale_and_banners(self):
"""last_check unset → stale → BANNER (no prior reminder)."""
now = _utc(2026, 6, 24)
assert self._r().decide(None, None, now) is Decision.BANNER
def test_stale_never_reminded_banners(self):
"""last_check older than threshold, never reminded → BANNER."""
now = _utc(2026, 6, 24)
last_check = _utc(2026, 5, 1) # ~54 days ago
assert self._r().decide(last_check, None, now) is Decision.BANNER
def test_stale_reminded_same_day_is_snoozed(self):
"""Stale + reminded earlier today → SILENT (invariant #2 snooze)."""
now = _utc(2026, 6, 24, 17, 0)
last_check = _utc(2026, 5, 1)
last_reminded = _utc(2026, 6, 24, 9, 0) # earlier same calendar day
assert self._r().decide(last_check, last_reminded, now) is Decision.SILENT
def test_stale_reminded_prior_day_rebanners(self):
"""Stale + reminded a prior calendar day → BANNER again."""
now = _utc(2026, 6, 24, 9, 0)
last_check = _utc(2026, 5, 1)
last_reminded = _utc(2026, 6, 23, 23, 0) # yesterday
assert self._r().decide(last_check, last_reminded, now) is Decision.BANNER
def test_calendar_day_boundary_across_midnight(self):
"""
Discriminating case for calendar-day (not 24h-sliding) snooze:
reminded 23:50 day1, now 00:10 day2 only 20 minutes apart but a
DIFFERENT calendar day must BANNER. A naive 24h window would snooze.
"""
last_reminded = _utc(2026, 6, 23, 23, 50)
now = _utc(2026, 6, 24, 0, 10)
last_check = _utc(2026, 5, 1)
assert self._r().decide(last_check, last_reminded, now) is Decision.BANNER
def test_threshold_boundary_just_inside_is_silent(self):
"""Exactly at the threshold (not strictly greater) → fresh → SILENT."""
r = self._r()
now = _utc(2026, 6, 22)
last_check = _utc(2026, 6, 1) # exactly 21 days
assert r.is_stale(last_check, now) is False
assert r.decide(last_check, None, now) is Decision.SILENT
def test_days_stale_reporting(self):
r = self._r()
assert r.days_stale(None, _utc(2026, 6, 24)) is None
assert r.days_stale(_utc(2026, 6, 20), _utc(2026, 6, 24)) == 4
# ===========================================================================
# Task 4.4 — runner: mutation discipline + I/O (invariants #1, #2)
# ===========================================================================
class TestReminderRunner:
def _store(self, tmp_path) -> StateStore:
# No clock injected on the store; the runner owns the clock.
return StateStore(tmp_path)
def test_fresh_run_is_silent_and_mutates_nothing(self, tmp_path):
now = _utc(2026, 6, 24)
store = self._store(tmp_path)
store.set_timestamp("last_check", _utc(2026, 6, 23)) # fresh
before = store.get_timestamp("last_reminded")
out = StringIO()
runner = ReminderRunner(store, clock=FrozenClock(now), out=out)
decision = runner.run()
assert decision is Decision.SILENT
assert out.getvalue() == "" # no banner emitted
assert store.get_timestamp("last_reminded") == before # unchanged
def test_stale_never_reminded_banners_and_records_last_reminded(self, tmp_path):
now = _utc(2026, 6, 24, 12, 0)
store = self._store(tmp_path)
store.set_timestamp("last_check", _utc(2026, 5, 1)) # stale
assert store.get_timestamp("last_reminded") is None
out = StringIO()
runner = ReminderRunner(store, clock=FrozenClock(now), out=out)
decision = runner.run()
assert decision is Decision.BANNER
payload = json.loads(out.getvalue())
assert "systemMessage" in payload
assert "doc-hygiene" in payload["systemMessage"]
# The ONLY mutation: last_reminded == now.
assert store.get_timestamp("last_reminded") == now
def test_stale_reminded_same_day_is_silent(self, tmp_path):
now = _utc(2026, 6, 24, 17, 0)
store = self._store(tmp_path)
store.set_timestamp("last_check", _utc(2026, 5, 1))
store.set_timestamp("last_reminded", _utc(2026, 6, 24, 9, 0))
before = store.get_timestamp("last_reminded")
out = StringIO()
runner = ReminderRunner(store, clock=FrozenClock(now), out=out)
decision = runner.run()
assert decision is Decision.SILENT
assert out.getvalue() == ""
assert store.get_timestamp("last_reminded") == before # not bumped
def test_stale_reminded_prior_day_rebanners(self, tmp_path):
now = _utc(2026, 6, 24, 9, 0)
store = self._store(tmp_path)
store.set_timestamp("last_check", _utc(2026, 5, 1))
store.set_timestamp("last_reminded", _utc(2026, 6, 23, 8, 0))
out = StringIO()
runner = ReminderRunner(store, clock=FrozenClock(now), out=out)
decision = runner.run()
assert decision is Decision.BANNER
assert "systemMessage" in json.loads(out.getvalue())
assert store.get_timestamp("last_reminded") == now
def test_missing_state_treated_as_never_checked(self, tmp_path):
"""No state.json at all → stale → banner, never blocks."""
now = _utc(2026, 6, 24)
store = self._store(tmp_path) # nothing written
out = StringIO()
runner = ReminderRunner(store, clock=FrozenClock(now), out=out)
decision = runner.run()
assert decision is Decision.BANNER
assert store.get_timestamp("last_reminded") == now
def test_corrupt_state_treated_as_never_checked(self, tmp_path):
"""A corrupt timestamp string must not crash; treated as never-checked."""
now = _utc(2026, 6, 24)
state_dir = tmp_path / ".dochygiene"
state_dir.mkdir(parents=True, exist_ok=True)
(state_dir / "state.json").write_text(
json.dumps({"last_check": "not-a-date", "last_reminded": "garbage"})
)
store = self._store(tmp_path)
out = StringIO()
runner = ReminderRunner(store, clock=FrozenClock(now), out=out)
decision = runner.run()
assert decision is Decision.BANNER # unreadable → stale
assert "systemMessage" in json.loads(out.getvalue())
def test_only_last_reminded_is_mutated(self, tmp_path):
"""On banner, last_check / last_clean are untouched (invariant #1)."""
now = _utc(2026, 6, 24, 12, 0)
store = self._store(tmp_path)
check_ts = _utc(2026, 5, 1)
clean_ts = _utc(2026, 5, 2)
store.set_timestamp("last_check", check_ts)
store.set_timestamp("last_clean", clean_ts)
out = StringIO()
ReminderRunner(store, clock=FrozenClock(now), out=out).run()
assert store.get_timestamp("last_check") == check_ts
assert store.get_timestamp("last_clean") == clean_ts
assert store.get_timestamp("last_reminded") == now
# ===========================================================================
# Entry-point guard — never create .dochygiene/ outside a git project (#3)
# ===========================================================================
class TestGitProjectGuard:
def test_non_git_cwd_stays_silent_and_writes_nothing(self, tmp_path, monkeypatch, capsys):
"""No .git anywhere → resolve falls back to cwd → reminder no-ops.
Guards invariant #3: must NOT create <cwd>/.dochygiene/ in an
arbitrary directory (e.g. ~).
"""
monkeypatch.setattr(reminder_mod.Path, "cwd", staticmethod(lambda: tmp_path))
rc = reminder_main()
assert rc == 0
assert capsys.readouterr().out == "" # no banner
assert not (tmp_path / ".dochygiene").exists() # nothing written
def test_git_project_banners_when_never_checked(self, tmp_path, monkeypatch, capsys):
"""A real git project with no prior check → banner (and creates state)."""
(tmp_path / ".git").mkdir()
monkeypatch.setattr(reminder_mod.Path, "cwd", staticmethod(lambda: tmp_path))
rc = reminder_main()
assert rc == 0
out = capsys.readouterr().out
assert "systemMessage" in json.loads(out)
assert (tmp_path / ".dochygiene" / "state.json").exists()
# ===========================================================================
# Banner copy
# ===========================================================================
class TestBanner:
def test_never_checked_copy(self):
b = build_banner(None)
assert "doc-hygiene" in b
assert "no documentation check has been run yet" in b
def test_pluralisation(self):
assert "1 day ago" in build_banner(1)
assert "4 days ago" in build_banner(4)

View File

@ -0,0 +1,463 @@
"""
Tests for scripts/report_builder.py the model-free finalize pass.
Covers Phase 3 (add-check) report_builder:
- expected_sha256 = sha256 of the WHOLE fixture file bytes (hand-computed)
- (is_destructive, is_reversible) + derived safety_tier per kind, covering
delete-rangeconfirm, move-to-archiveauto, insert-frontmatterauto,
dedupeauto, replace-textauto, generativeconfirm
- raw_tokens populated by the estimator; weighting fields null
- per-entry exact_edit.generated_at = the injected hash instant (distinct
from the envelope generated_at proven with a monotonic clock)
- tool_version read from .claude-plugin/plugin.json
- cleared (zero-signal / no-entry) shortlisted files yield no entries
- malformed proposals are rejected BEFORE any computation
- CRITICAL round-trip: assembled report passes validate_report.py
sys.path is patched here (no shared conftest) so the test file is
self-contained, matching the repo's test style.
"""
from __future__ import annotations
import hashlib
import json
import sys
from datetime import datetime, timedelta, timezone
from pathlib import Path
import pytest
# ---------------------------------------------------------------------------
# Path bootstrap — inject scripts/ so we can import report_builder directly.
# ---------------------------------------------------------------------------
_SCRIPTS_DIR = Path(__file__).parent.parent / "scripts"
if str(_SCRIPTS_DIR) not in sys.path:
sys.path.insert(0, str(_SCRIPTS_DIR))
from report_builder import ( # noqa: E402
ReportBuilder,
MalformedProposalError,
)
from validate_report import ReportValidator # noqa: E402
from token_estimator import default_estimator # noqa: E402
# ---------------------------------------------------------------------------
# Test doubles
# ---------------------------------------------------------------------------
class MonotonicClock:
"""Returns a strictly increasing UTC datetime on each now() call.
This is what makes the generated_at test meaningful: a fixed clock would
make per-entry stamps vacuously equal to the envelope stamp.
"""
def __init__(self, start: datetime, step_seconds: int = 1) -> None:
self._next = start
self._step = timedelta(seconds=step_seconds)
self.calls: list[datetime] = []
def now(self) -> datetime:
value = self._next
self._next = self._next + self._step
self.calls.append(value)
return value
_T0 = datetime(2026, 6, 24, 12, 0, 0, tzinfo=timezone.utc)
# ---------------------------------------------------------------------------
# Fixtures — a small doc tree + a scan artifact
# ---------------------------------------------------------------------------
@pytest.fixture
def doc_tree(tmp_path: Path) -> Path:
"""Create a project root with several markdown files."""
(tmp_path / "docs").mkdir()
(tmp_path / "CLAUDE.md").write_text(
"\n".join(f"line {i}" for i in range(1, 21)) + "\n",
encoding="utf-8",
)
(tmp_path / "docs" / "old-plan.md").write_text(
"# Old plan\n\nbody\n", encoding="utf-8"
)
(tmp_path / "docs" / "setup.md").write_text(
"# Setup\n\nall done\n", encoding="utf-8"
)
(tmp_path / "docs" / "dupe.md").write_text(
"a\nb\nc\nd\ne\n", encoding="utf-8"
)
(tmp_path / "docs" / "links.md").write_text(
"see [x](missing.md)\nmore text\n", encoding="utf-8"
)
(tmp_path / "docs" / "big.md").write_text(
"\n".join(f"para {i}" for i in range(1, 51)) + "\n", encoding="utf-8"
)
(tmp_path / "docs" / "clean.md").write_text(
"# Clean\n\nnothing to fix\n", encoding="utf-8"
)
return tmp_path
@pytest.fixture
def scan_artifact(doc_tree: Path) -> dict:
return {
"project_root": str(doc_tree),
"scope_globs": ["**/*.md"],
"excluded_dirs": ["build", ".dochygiene"],
"files_scanned": 7,
"shortlist": [
"CLAUDE.md",
"docs/old-plan.md",
"docs/setup.md",
"docs/dupe.md",
"docs/links.md",
"docs/big.md",
"docs/clean.md",
],
"signals": {},
}
def _proposals() -> list:
"""One proposal per exact_edit kind + one generative + a gloss."""
return [
{ # delete-range → confirm
"path": "CLAUDE.md",
"category": {"class": "stale", "subtype": "contradicted"},
"signals": [{"name": "broken_reference", "detail": "links a dead path"}],
"op": "Delete contradicted block (lines 5-9).",
"op_type": "deterministic",
"confidence": 0.9,
"gloss": "block contradicts the live config",
"exact_edit": {
"kind": "delete-range",
"anchor": {"start_line": 5, "end_line": 9},
},
},
{ # move-to-archive → auto
"path": "docs/old-plan.md",
"category": {"class": "stale", "subtype": "superseded"},
"signals": [{"name": "stale_name_location", "detail": "named 'old'"}],
"op": "Move to archive/docs/old-plan.md.",
"op_type": "deterministic",
"confidence": 0.8,
"exact_edit": {
"kind": "move-to-archive",
"anchor": {"start_line": 1, "end_line": 3},
"dest_path": "archive/docs/old-plan.md",
},
},
{ # insert-frontmatter → auto (no anchor, no sha256/generated_at)
"path": "docs/setup.md",
"category": {"class": "stale", "subtype": "completed-in-place"},
"signals": [{"name": "archive_to_live_ratio", "detail": "all done"}],
"op": "Freeze with hygiene: frozen.",
"op_type": "deterministic",
"confidence": 0.85,
"exact_edit": {
"kind": "insert-frontmatter",
"key": "hygiene",
"value": "frozen",
},
},
{ # dedupe → auto
"path": "docs/dupe.md",
"category": {"class": "stale", "subtype": "duplicated"},
"signals": [{"name": "broken_reference", "detail": "dup span"}],
"op": "Remove duplicate span (lines 2-4); canonical elsewhere.",
"op_type": "deterministic",
"confidence": 0.7,
"exact_edit": {
"kind": "dedupe",
"anchor": {"start_line": 2, "end_line": 4},
"canonical_ref": "docs/canonical.md#section",
},
},
{ # replace-text → auto
"path": "docs/links.md",
"category": {"class": "stale", "subtype": "contradicted"},
"signals": [{"name": "broken_reference", "detail": "dead link"}],
"op": "Fix the dead link.",
"op_type": "deterministic",
"confidence": 0.9,
"exact_edit": {
"kind": "replace-text",
"anchor": {"start_line": 1, "end_line": 1},
"match": "missing.md",
"replacement": "found.md",
},
},
{ # generative → confirm (no exact_edit; reducible_range)
"path": "docs/big.md",
"category": {"class": "bloat", "subtype": "distill"},
"signals": [{"name": "archive_to_live_ratio", "detail": "long narrative"}],
"op": "Condense resolved-problem sections.",
"op_type": "generative",
"confidence": 0.6,
"reducible_range": {"start_line": 1, "end_line": 50},
},
]
def _build(doc_tree: Path, scan: dict, proposals: list, clock=None):
builder = ReportBuilder(
project_root=doc_tree,
clock=clock or MonotonicClock(_T0),
estimator=default_estimator(), # heuristic fallback; deterministic
)
return builder.build(scan, proposals)
# ===========================================================================
# expected_sha256 = whole-file hash
# ===========================================================================
class TestExpectedSha256:
def test_matches_handcomputed_whole_file_hash(self, doc_tree, scan_artifact):
result = _build(doc_tree, scan_artifact, _proposals())
entries = {e["path"]: e for e in result["machine_report"]["entries"]}
expected = hashlib.sha256(
(doc_tree / "CLAUDE.md").read_bytes()
).hexdigest()
assert entries["CLAUDE.md"]["exact_edit"]["expected_sha256"] == expected
def test_anchorless_insert_frontmatter_has_no_sha256(self, doc_tree, scan_artifact):
result = _build(doc_tree, scan_artifact, _proposals())
entries = {e["path"]: e for e in result["machine_report"]["entries"]}
ee = entries["docs/setup.md"]["exact_edit"]
assert "expected_sha256" not in ee
assert "generated_at" not in ee
def test_generative_has_no_exact_edit(self, doc_tree, scan_artifact):
result = _build(doc_tree, scan_artifact, _proposals())
entries = {e["path"]: e for e in result["machine_report"]["entries"]}
assert "exact_edit" not in entries["docs/big.md"]
# ===========================================================================
# (is_destructive, is_reversible) + derived safety_tier per kind
# ===========================================================================
class TestKindCharacterization:
@pytest.mark.parametrize(
"path,is_destructive,is_reversible,tier",
[
("CLAUDE.md", True, False, "confirm"), # delete-range
("docs/old-plan.md", False, True, "auto"), # move-to-archive
("docs/setup.md", False, True, "auto"), # insert-frontmatter
("docs/dupe.md", False, True, "auto"), # dedupe
("docs/links.md", False, True, "auto"), # replace-text
("docs/big.md", False, True, "confirm"), # generative
],
)
def test_booleans_and_tier(self, doc_tree, scan_artifact, path, is_destructive, is_reversible, tier):
result = _build(doc_tree, scan_artifact, _proposals())
entries = {e["path"]: e for e in result["machine_report"]["entries"]}
e = entries[path]
assert e["is_destructive"] is is_destructive
assert e["is_reversible"] is is_reversible
assert e["safety_tier"] == tier
def test_proposal_cannot_override_guardrail_fields(self, doc_tree, scan_artifact):
"""A lying proposal (is_destructive/safety_tier) is ignored; kind wins."""
proposals = _proposals()
# Inject bogus fields onto the delete-range proposal.
proposals[0]["is_destructive"] = False
proposals[0]["is_reversible"] = True
proposals[0]["safety_tier"] = "auto"
result = _build(doc_tree, scan_artifact, proposals)
e = {x["path"]: x for x in result["machine_report"]["entries"]}["CLAUDE.md"]
assert e["is_destructive"] is True
assert e["safety_tier"] == "confirm"
# ===========================================================================
# token_estimate
# ===========================================================================
class TestTokenEstimate:
def test_raw_tokens_populated_weighting_null(self, doc_tree, scan_artifact):
result = _build(doc_tree, scan_artifact, _proposals())
for e in result["machine_report"]["entries"]:
te = e["token_estimate"]
assert isinstance(te["raw_tokens"], int)
assert te["raw_tokens"] >= 0
assert te["injection_frequency"] is None
assert te["weighted_tokens"] is None
def test_raw_tokens_from_estimator_on_span(self, doc_tree, scan_artifact):
"""delete-range counts only the anchored span (lines 5-9)."""
result = _build(doc_tree, scan_artifact, _proposals())
e = {x["path"]: x for x in result["machine_report"]["entries"]}["CLAUDE.md"]
lines = (doc_tree / "CLAUDE.md").read_text().splitlines()
span = "\n".join(lines[4:9]) # lines 5..9 inclusive
assert e["token_estimate"]["raw_tokens"] == default_estimator().estimate(span)
# ===========================================================================
# per-entry generated_at = injected hash instant (distinct from envelope)
# ===========================================================================
class TestGeneratedAt:
def test_per_entry_generated_at_is_hash_instant(self, doc_tree, scan_artifact):
clock = MonotonicClock(_T0, step_seconds=1)
result = _build(doc_tree, scan_artifact, _proposals(), clock=clock)
machine = result["machine_report"]
# Each anchor-bearing entry's generated_at is one of the clock's instants
# and is NOT equal to the envelope generated_at (proves distinctness).
envelope = machine["generated_at"]
stamps = []
for e in machine["entries"]:
ee = e.get("exact_edit")
if ee and "generated_at" in ee:
stamps.append(ee["generated_at"])
assert ee["generated_at"] != envelope
# All hash instants are distinct from each other (monotonic clock).
assert len(stamps) == len(set(stamps))
# The envelope stamp is the last clock call (after all hashes).
assert envelope == max(clock.calls).isoformat()
# ===========================================================================
# tool_version from plugin.json
# ===========================================================================
class TestEnvelope:
def test_tool_version_from_plugin_json(self, doc_tree, scan_artifact):
plugin_json = _SCRIPTS_DIR.parent / ".claude-plugin" / "plugin.json"
expected = json.loads(plugin_json.read_text())["version"]
result = _build(doc_tree, scan_artifact, _proposals())
assert result["machine_report"]["tool_version"] == expected
def test_schema_version_and_scan_block(self, doc_tree, scan_artifact):
result = _build(doc_tree, scan_artifact, _proposals())
m = result["machine_report"]
assert m["schema_version"] == "1.0"
assert m["scan"]["files_scanned"] == 7
assert m["scan"]["project_root"] == str(doc_tree)
assert m["shortlist"] == scan_artifact["shortlist"] # verbatim
# ===========================================================================
# cleared files yield no entries
# ===========================================================================
class TestClearedFiles:
def test_shortlisted_but_unproposed_file_has_no_entry(self, doc_tree, scan_artifact):
result = _build(doc_tree, scan_artifact, _proposals())
paths = {e["path"] for e in result["machine_report"]["entries"]}
assert "docs/clean.md" not in paths
# but it stays in shortlist
assert "docs/clean.md" in result["machine_report"]["shortlist"]
def test_human_report_counts_cleared(self, doc_tree, scan_artifact):
result = _build(doc_tree, scan_artifact, _proposals())
md = result["human_report"]
# 7 shortlisted, 6 entries → 1 cleared
assert "candidates: 6" in md
assert "cleared: 1" in md
assert "docs/clean.md" in md
# ===========================================================================
# malformed proposals are rejected before compute
# ===========================================================================
class TestMalformedRejection:
def test_missing_path(self, doc_tree, scan_artifact):
bad = [{"category": {"class": "stale", "subtype": "orphaned"}, "op": "x", "op_type": "deterministic"}]
with pytest.raises(MalformedProposalError):
_build(doc_tree, scan_artifact, bad)
def test_unknown_kind(self, doc_tree, scan_artifact):
bad = [{
"path": "CLAUDE.md",
"category": {"class": "stale", "subtype": "orphaned"},
"op": "x", "op_type": "deterministic",
"exact_edit": {"kind": "nuke-it", "anchor": {"start_line": 1, "end_line": 2}},
}]
with pytest.raises(MalformedProposalError):
_build(doc_tree, scan_artifact, bad)
def test_deterministic_without_exact_edit(self, doc_tree, scan_artifact):
bad = [{
"path": "CLAUDE.md",
"category": {"class": "stale", "subtype": "orphaned"},
"op": "x", "op_type": "deterministic",
}]
with pytest.raises(MalformedProposalError):
_build(doc_tree, scan_artifact, bad)
def test_generative_with_exact_edit(self, doc_tree, scan_artifact):
bad = [{
"path": "docs/big.md",
"category": {"class": "bloat", "subtype": "distill"},
"op": "x", "op_type": "generative",
"reducible_range": {"start_line": 1, "end_line": 2},
"exact_edit": {"kind": "delete-range", "anchor": {"start_line": 1, "end_line": 2}},
}]
with pytest.raises(MalformedProposalError):
_build(doc_tree, scan_artifact, bad)
def test_missing_kind_specific_field(self, doc_tree, scan_artifact):
bad = [{
"path": "docs/old-plan.md",
"category": {"class": "stale", "subtype": "superseded"},
"op": "x", "op_type": "deterministic",
"exact_edit": {"kind": "move-to-archive", "anchor": {"start_line": 1, "end_line": 2}},
# dest_path missing
}]
with pytest.raises(MalformedProposalError):
_build(doc_tree, scan_artifact, bad)
def test_bad_subtype(self, doc_tree, scan_artifact):
bad = [{
"path": "CLAUDE.md",
"category": {"class": "stale", "subtype": "not-a-subtype"},
"op": "x", "op_type": "generative",
"reducible_range": {"start_line": 1, "end_line": 2},
}]
with pytest.raises(MalformedProposalError):
_build(doc_tree, scan_artifact, bad)
def test_rejected_before_any_file_read(self, tmp_path, scan_artifact):
"""A nonexistent file path must not be read — malformed structure fails first."""
# path missing op_type entirely → rejected before any IO on the file.
bad = [{"path": "CLAUDE.md", "category": {"class": "stale", "subtype": "orphaned"}, "op": "x"}]
builder = ReportBuilder(project_root=tmp_path, clock=MonotonicClock(_T0))
with pytest.raises(MalformedProposalError):
builder.build(scan_artifact, bad)
# ===========================================================================
# CRITICAL: round-trip through validate_report.py
# ===========================================================================
class TestValidatorRoundTrip:
def test_assembled_report_passes_validator(self, doc_tree, scan_artifact):
result = _build(doc_tree, scan_artifact, _proposals())
machine = result["machine_report"]
violations = ReportValidator(machine).validate()
assert violations == [], f"validator rejected report: {violations}"
def test_empty_proposals_still_valid(self, doc_tree, scan_artifact):
result = _build(doc_tree, scan_artifact, [])
machine = result["machine_report"]
assert machine["entries"] == []
violations = ReportValidator(machine).validate()
assert violations == []

Some files were not shown because too many files have changed in this diff Show More