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:
parent
cebcc1b649
commit
b235e99145
|
|
@ -9,3 +9,5 @@
|
|||
/.vscode/
|
||||
# Graphify build artifacts
|
||||
graphify-out/
|
||||
# doc-hygiene plugin state
|
||||
.dochygiene/
|
||||
|
|
|
|||
32
CLAUDE.md
32
CLAUDE.md
|
|
@ -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
|
||||
- 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`
|
||||
- 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
|
||||
|
|
@ -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.
|
||||
- **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
|
||||
|
||||
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.
|
||||
**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.
|
||||
|
|
|
|||
|
|
@ -576,6 +576,19 @@ _Date: 2026-07-03_
|
|||
- **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).
|
||||
|
||||
## 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)
|
||||
|
||||
| Tool | Why rejected for our use |
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
}
|
||||
|
|
@ -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/
|
||||
|
|
@ -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 3–5 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`.
|
||||
|
|
@ -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.1–2.6 ✓ | done — `scripts/scanner.py`, 56 tests pass |
|
||||
| 3 — state store (`state-store`) | 3.1–3.8 ✓ | done — `scripts/state_store.py`, 30 tests pass |
|
||||
| 4 — reminder hook (`session-reminder`) | 4.1–4.4 | NOT STARTED — needs Group 3 + confirmed hook format (1.3) |
|
||||
| 5 — cross-seam verification | 5.1–5.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)
|
||||
|
|
@ -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.
|
||||
|
|
@ -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.
|
||||
|
|
@ -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)
|
||||
```
|
||||
|
|
@ -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).
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
|
@ -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.
|
||||
|
|
@ -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."
|
||||
}
|
||||
]
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
|
@ -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).
|
||||
|
|
@ -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."
|
||||
}
|
||||
]
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
|
@ -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.
|
||||
|
|
@ -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."
|
||||
}
|
||||
]
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
|
@ -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.
|
||||
|
|
@ -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.
|
||||
|
|
@ -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."
|
||||
}
|
||||
]
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
|
@ -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.
|
||||
|
|
@ -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."
|
||||
}
|
||||
]
|
||||
|
|
@ -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.
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
{
|
||||
"hooks": {
|
||||
"SessionStart": [
|
||||
{
|
||||
"matcher": "startup|resume",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "python3 ${CLAUDE_PLUGIN_ROOT}/scripts/reminder.py",
|
||||
"timeout": 5
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
schema: spec-driven
|
||||
created: 2026-06-18
|
||||
|
|
@ -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.
|
||||
|
|
@ -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).
|
||||
|
|
@ -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.
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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/`
|
||||
|
|
@ -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.
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
schema: spec-driven
|
||||
created: 2026-06-18
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
# add-report-schema
|
||||
|
||||
Freeze the machine report schema as the contract every doc-hygiene component consumes
|
||||
|
|
@ -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.
|
||||
|
|
@ -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.
|
||||
|
|
@ -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
|
||||
|
|
@ -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.
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
schema: spec-driven
|
||||
created: 2026-06-24
|
||||
|
|
@ -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`.
|
||||
|
|
@ -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`. 3–5 cases, each mapping a subtype to a **distinct** `exact_edit.kind` to
|
||||
cover the kind table + tier derivation:
|
||||
|
||||
- `orphaned` → `stale`/`orphaned`/`deterministic`/`delete-range`/`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.
|
||||
|
|
@ -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.
|
||||
|
|
@ -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
|
||||
|
|
@ -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 3–5 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.
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
schema: spec-driven
|
||||
created: 2026-06-24
|
||||
|
|
@ -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.
|
||||
|
|
@ -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).
|
||||
|
|
@ -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.
|
||||
|
|
@ -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 5–15 and lines 10–20)
|
||||
- **THEN** the applier skips the entire file with reason `incompatible-ops-on-file`
|
||||
|
||||
#### Scenario: Non-conflicting entries on a file proceed normally
|
||||
|
||||
- **WHEN** a file has two entries with non-overlapping anchors and no move-to-archive
|
||||
- **THEN** the applier applies both entries via the descending-anchor transaction
|
||||
|
||||
### Requirement: Safety-Tier Gating Before Any Mutation
|
||||
|
||||
The clean skill SHALL partition report entries by safety tier (read from the report —
|
||||
never recomputed) into `auto` entries (applied without prompt) and `confirm` entries
|
||||
(escalated before any mutation). It SHALL present all `confirm`-tier entries as a
|
||||
single batch-confirm list showing path, category, op, token count, and rationale with
|
||||
per-entry opt-out, visually distinguishing irreversible `delete-range` 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
|
||||
|
|
@ -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
|
||||
> T3–T5 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.
|
||||
|
|
@ -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).
|
||||
|
|
@ -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
|
||||
|
||||
|
|
@ -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 5–15 and lines 10–20)
|
||||
- **THEN** the applier skips the entire file with reason `incompatible-ops-on-file`
|
||||
|
||||
#### Scenario: Non-conflicting entries on a file proceed normally
|
||||
|
||||
- **WHEN** a file has two entries with non-overlapping anchors and no move-to-archive
|
||||
- **THEN** the applier applies both entries via the descending-anchor transaction
|
||||
|
||||
### Requirement: Safety-Tier Gating Before Any Mutation
|
||||
|
||||
The clean skill SHALL partition report entries by safety tier (read from the report —
|
||||
never recomputed) into `auto` entries (applied without prompt) and `confirm` entries
|
||||
(escalated before any mutation). It SHALL present all `confirm`-tier entries as a
|
||||
single batch-confirm list showing path, category, op, token count, and rationale with
|
||||
per-entry opt-out, visually distinguishing irreversible `delete-range` 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
|
||||
|
||||
|
|
@ -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
|
||||
|
|
@ -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`)
|
||||
|
|
@ -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())
|
||||
|
|
@ -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 14–30 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())
|
||||
|
|
@ -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())
|
||||
|
|
@ -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())
|
||||
|
|
@ -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.2–3.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
|
||||
|
|
@ -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())
|
||||
|
|
@ -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()
|
||||
|
|
@ -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, ~40–60% 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)
|
||||
|
|
@ -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.
|
||||
|
|
@ -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 `[]`.
|
||||
|
|
@ -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, ~40–60% 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. |
|
||||
|
|
@ -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.
|
||||
|
|
@ -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 40–60% 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.
|
||||
|
|
@ -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.
|
||||
15
plugins/os-doc-hygiene/tests/fixtures/scanner/append_only/changelog_positive.md
vendored
Normal file
15
plugins/os-doc-hygiene/tests/fixtures/scanner/append_only/changelog_positive.md
vendored
Normal 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
|
||||
21
plugins/os-doc-hygiene/tests/fixtures/scanner/append_only/regular_doc_negative.md
vendored
Normal file
21
plugins/os-doc-hygiene/tests/fixtures/scanner/append_only/regular_doc_negative.md
vendored
Normal 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.
|
||||
|
|
@ -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.
|
||||
|
|
@ -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.
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
# Ignored Document
|
||||
|
||||
This document matches a .dochygiene-ignore pattern and should NOT appear in the shortlist.
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
# Normal Doc
|
||||
|
||||
This document is live and should appear in the shortlist.
|
||||
|
|
@ -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
|
||||
|
|
@ -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.
|
||||
|
|
@ -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.
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
---
|
||||
status: draft
|
||||
---
|
||||
|
||||
# Draft Document
|
||||
|
||||
This document is in draft status and should emit a frontmatter_marker signal.
|
||||
|
|
@ -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.
|
||||
|
|
@ -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
|
||||
|
|
@ -0,0 +1 @@
|
|||
# Sample project for state_store tests
|
||||
|
|
@ -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)
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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)
|
||||
|
|
@ -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-range→confirm, move-to-archive→auto, insert-frontmatter→auto,
|
||||
dedupe→auto, replace-text→auto, generative→confirm
|
||||
- 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
Loading…
Reference in New Issue