242 lines
13 KiB
Markdown
242 lines
13 KiB
Markdown
|
|
# 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.
|