24 KiB
Lifecycle-aware doc hygiene — spec
Status: Locked design (wayfinder map #31); assembled 2026-07-14 from decision tickets #32–#48. Extended 2026-07-15 by wayfinder map #49 (assessment-inventory persistence + rulebook refinements; tickets #51–#57, #59). Last updated: 2026-07-15
Extends os-doc-hygiene from stale/bloat monitoring to lifecycle management: a
rulebook assigns every managed file a lifetime, :check/:clean gain
delete and extract-then-delete behavior, and a new :calibrate skill learns
rules per project. Validated against calibration project #1 (cc-os) per the
criteria in Calibration.
ADR set: ADR-0038 (rulebook location; amended 2026-07-15 — rules-file scope includes nominations memory + the extract-index keep rule), ADR-0039 (deletion autonomy tiers; amended 2026-07-15 — map #49 tier interactions), ADR-0040 (no ignore-surface propagation), ADR-0041 (determinism-promotion principle).
1. Lifetime taxonomy (#33)
Three lifetimes, plus one modifier:
- keep — indefinite; scanned and reported, never deleted.
- temporary — age-triggered deletion (see Temporary tier).
- delete-once-served — purpose-triggered deletion (see Served signals).
- extract (modifier on deletion, not a fourth lifetime) — distill durable
content before deleting. Extraction reuses existing knowledge routing (#36):
repo-durable residue → ADR/CLAUDE.md/docs; cross-repo lessons → SecondBrain
via
/os-vault:write. No new destinations, no "retired specs" pile.
Extract index (extracted.md) — map #49 #57
When an extract-then-delete sends content to the vault (the case where the
insight physically leaves the repo), the deletion leaves a pointer behind in a
per-directory extracted.md — the index stays where a future reader is
already looking. Repo-durable residue (an ADR, a CLAUDE.md line) is already
discoverable in-repo and gets no index line. Entry format:
- [[vault: tool/graphify-clustering-behavior]] — why HDBSCAN over-merges doc
clusters; read before tuning any graphify clustering params.
(extracted from `graphify-clustering-notes.md`, 2026-07-15)
- The
:cleanextract op owns the entry. The op runs: distill →/os-vault:write(returns the note name) → append the pointer line → delete the original — atomically, in:clean's single reviewable commit, so there is no window where the doc is gone but undiscoverable.os-vault:writestays repo-agnostic. - Self-protection is one global rule:
{ "glob": "**/extracted.md", "lifetime": "keep" }in the global rulebook — the self-describing filename means zero per-project bookkeeping and the index can never become a deletion candidate anywhere. (A genericindex.mdname was rejected: it collides with existing index/README conventions, and a global**/index.md → keepis far too broad.) - Extract-worthy vs plain-keep — three tests, in order:
- Cited → KEEP. Anything repo artifacts link to or derive from stays (deleting breaks the provenance chain).
- Generalizes AND standalone → EXTRACT. The insight would change behavior in a different repo (tool behavior, methodology — the vault routing test) and nothing in-repo links to it.
- Unclear → consult. The existing mandatory verdict; never guess an extract.
- The convention is spec-defined and universal — part of the extract op
itself, not a
conventions.jsonentry (that catalog holds completion-conventions a project opts into via served-signal graduation; the extract index is neither).
Distinct from all lifetimes: an IGNORE surface — paths the scanner never
walks at all (vs keep = walked and reported, never deleted). Seed members:
graphify-out/**, .dochygiene/**. The ignore surface is an explicit list,
never inferred from .gitignore (gitignored ≠ deletable AND gitignored ≠
keepable — #43).
2. Rulebook (#34, #38, #40, #44)
Locations
- Global:
plugins/os-doc-hygiene/rulebook.json, resolved relative to plugin scripts. Ships everywhere. - Per-project override: repo-root
.dochygiene-rules.json, committed (matches the.dochygiene-ignoreprecedent; deliberately NOT in gitignored.cc-os/— ADR-0038).
Both use envelope {"schema_version": 1, "rules": [...]}. The project file
may additionally carry a top-level nominations key (see Nominations
memory below); the v1 loader ignores
unknown top-level keys, so the key is additive to schema_version 1.
Glob dialect
gitignore-style via stdlib glob.translate(recursive=True, include_hidden=True) (Python ≥ 3.13); patterns repo-root-relative, real **;
compiled once at load. Directory rules = patterns covering a subtree.
Precedence (two-axis, source first)
project file-rule > project directory-rule > global file-rule > global
directory-rule; ties broken by longest pattern, then last-defined. File-level
rules override directory rules (#38). The merge is add-only: a project
neutralizes a global rule by shadowing it with lifetime: "keep" — there is
no rule-removal mechanism.
Per-rule fields
{
"glob": "autoresearch/*/**",
"lifetime": "keep|temporary|delete-once-served",
"extract": true,
"served_when": "free text — hint consumed by the LLM classifier, no trigger DSL",
"served_when_path": "openspec/changes/archive/{id}/",
"retain_recent": 3,
"max_age_days": 3,
"confirm": true,
"confirmed_by": "human | <strong-model-id>",
"confirmed_on": "2026-07-14",
"source": "clutter-inventory #41",
"note": "free-text rationale"
}
retain_recentdefault 3;max_age_daysdefault 3 (not null-means-forever) — #43 amendments to the #40 schema.served_when_pathis the deterministic sibling ofserved_when(#43).confirm: trueis an optional always-confirm escape hatch — human-settable only; a model-proposed rule may never set it, only ask a human to (#42/#43).- There is no
propagate_ignorefield. It was reserved in #40 and dropped by #44: a lifecycle rule never writes into another tool's ignore/config surface (ADR-0040). - Unmatched files get NO lifetime — they flow through existing signals
unchanged and become
:calibratecandidates (unmatched = unmanaged).
Scanner consumption
New stdlib rulebook.py loader. A directory-rule match prunes the walk
(files beneath are never opened; one aggregate entry) — this also implements
the IGNORE surface for free. A file-rule match attaches a lifecycle signal
(rule ref + lifetime + served_when) consumed by the classifier as a new signal
class (#37).
Validation
Skip-and-warn per invalid/unconfirmed rule — a rule missing confirmed_by
never acts (#39) but doesn't take scanning down. Hard-fail on unparseable JSON
or unknown schema_version.
.dochygiene-ignore remains a hand-authored, human-only escape hatch,
unmanaged by the rulebook (#44).
Nominations memory (nominations key) — map #49 (#52/#56)
Every judge verdict with an actual answer (keep / temporary /
delete-once-served / ignore) persists as a plain rule in rules (#51 — keep
verdicts are ordinary lifetime: keep rules; matched = managed removes them
from the calibrate pool, and the glob covers future files for free). The
nominations key holds only the two residues that cannot be rules: "the
answer is no" (rejected) and "no answer yet" (consults). It never
touches the file filter — rules alone decide which files are governed.
"nominations": {
"consults": [
{
"glob": "docs/orchestration-audit/*.md",
"question": "Are the dated audit tune-up reports a retained audit trail, or disposable once the tune-up lands?",
"evidence": "4 files, one per audit run; docs/implementation-status.md references only the latest",
"cluster_key": "docs/orchestration-audit::report-#",
"asked_on": "2026-07-15"
}
],
"rejected": [
{
"glob": "docs/research/**",
"lifetime": "temporary",
"why": "findings docs other artifacts link to, not disposable output",
"consider_instead": "an extract-to-vault rule for standalone findings docs",
"rejected_by": "judge",
"judged_on": "2026-07-15"
}
]
}
- Rejection entry:
glob,lifetime,why, optionalconsider_instead(the judge's amend pointer),rejected_by: "judge" | "human",judged_on. Human declines at the §8 rule report also persist as rejections (rejected_by: "human") — otherwise haiku can re-nominate what the human personally declined and the judge won't know. - Consult entry:
glob,question,evidence,cluster_key,asked_on. Deliberately no lifetime — the uncertainty about purpose is the point. Presence inconsults= open; no status field. This fixes consult evaporation (verdicts previously died with the run's scratch dir; the same question was re-derived every run). - Consult exits: (a) a human answer settles purpose → a normal rule is
persisted and the consult entry deleted (the rule supersedes it); (b) "not
rule-worthy" → rewritten into
rejectedwith the human's why; (c) defer → stays, resurfaces next:calibraterun. Consults resurface in:calibrateonly (:check/:cleanunchanged — ADR-0039 boundary) and never filter anything. - What a rejection blocks — exact glob+lifetime repeats only. A variant (different lifetime, narrower glob) flows to the judge normally, carrying the past rejection as context. Stale rejections leave by hand-deletion (removals stay HITL, with recorded reasoning).
- Exact-path singleton keep rules (#51) likewise leave only by hand-deletion — no automated revisit path: an automated revisit would be the one place a machine argues a keep back toward deletion, the exact failure mode the keep tier prevents.
rulebook.pystays nomination-unaware — its contract is "which rule governs this path"; onlycalibrate_helpersreads the key. The calibrate reader warns on unrecognized nomination fields, mirroring the rules array's unknown-field discipline.- Writes land via the same canonical writer as rules (next subsection); new rejections/consults appear in the §8 rule report but are not individually gated — they are memory, not deletion authority; hand-editing is the escape hatch.
Canonical ordering — map #49 (#53)
Writer-enforced, no hook. Every code path that serializes
.dochygiene-rules.json writes it grouped by lifetime tier
(delete-once-served, temporary, keep), sorted by glob within each group;
nominations after rules, consults before rejected (the
pending-action queue reads first), each list glob-sorted. Idempotent; no
drift window between a command finishing and a hook firing; hand edits
re-canonicalize on the next write. (A post-command hook was considered and
rejected as more moving parts for the same result.)
3. Deletion semantics and autonomy tiers (#35, #43 — ADR-0039)
Delete = true git deletion in a dedicated hygiene commit; git history is
the archive. No archive/ dirs or graveyard branches (relocated clutter still
distracts AI/search).
Rule-backed deletes are auto — a rule confirmed per #39 is the standing consent; no per-run prompt. The auto/confirm line is drawn on evidence quality + recoverability, not file type:
| Case | Behavior |
|---|---|
IGNORE surface (graphify-out/**, .dochygiene/**) |
never walked |
lifetime keep |
scanned + reported, never deleted |
| tracked + delete rule + clean worktree | auto |
| tracked + delete rule + DIRTY | confirm (an uncommitted diff dies with the file) |
| untracked + delete rule | confirm (no history to recover from) |
| no rule match | unmanaged; existing signals only, never deleted |
clean verifies tracked+clean at runtime (git ls-files + dirty check);
it never trusts the rule's word for it. No recoverable_via field, no
"regenerate" class.
4. Temporary tier (#43, #48)
retain-recent-N + age, not age alone. Defaults retain_recent: 3,
max_age_days: 3 (both per-rule overridable). The newest 3 entries matching a
rule are always kept regardless of age (they show current trajectory); an
entry ranked 4th or older is deleted once it exceeds max_age_days. 90-day
windows were rejected as far too slow.
Retention unit = the rule's match entry: a file for file rules, a run directory for directory rules ("3 most recent autoresearch runs", not "3 most recent files inside one run").
Age = git commit time, falling back to filesystem mtime for untracked
files. mtime-alone was rejected (clone/branch-switch resets every mtime,
putting the rulebook to sleep on fresh checkouts); the objection doesn't apply
to the fallback, since untracked files don't exist in a fresh clone. No
per-rule age_source field (#48).
Untracked directory entries use the directory inode's own mtime (one stat), NOT a recursive max-mtime walk — the tier's failure mode is self-healing (a spuriously bumped mtime merely delays deletion one round), so the cheap signal suffices (#48).
5. delete-once-served: served-signal split (#43)
Two slots, split by evidence quality:
{ "glob": "openspec/changes/*/", "lifetime": "delete-once-served",
"served_when_path": "openspec/changes/archive/{id}/" }
Scanner proves the served condition → may delete silently under the tier matrix.
{ "glob": "docs/plans/*.md", "lifetime": "delete-once-served",
"served_when": "the effort this plan describes has shipped" }
Classifier judges the condition → always forced to confirm, regardless of tracked status. The LLM may propose; it may never silently destroy on a hunch.
Example of the boundary: autoresearch/*/ can be auto (a concluded run is
provable from the filesystem); PRD.md cannot (purpose-triggered — "did this
ship?" is a judgment; and it is NOT temporary, since age-keying would delete
the PRD of a feature not yet built — #45 Addendum 2).
6. Determinism promotion (#43 item 7, #47 — ADR-0041)
Design principle: hygiene drives projects toward structurally-obvious
maintenance. When a rule's served signal is subjective (classifier-judged),
the tool does not merely downgrade it to confirm — it names the subjectivity
and recommends a concrete structural convention that would graduate the rule
to served_when_path and make it silent. Confirm-fatigue is the incentive to
fix the convention.
Completion-conventions catalog
plugins/os-doc-hygiene/conventions.json — global-only, machine-readable so
the deterministic pipeline can emit nudges without an LLM. No per-project
override: the catalog only recommends; adoption lands in the project's own
rulebook. Each entry: name, what it proves, the served_when_path /
frontmatter template a rule graduates to, and a one-line human pitch.
v1 contents — exactly two conventions:
- archive-bucket — "done" = the file moved into a sibling
archive/dir (docs/plans/x.md→docs/plans/archive/x.md); graduates a rule toserved_when_path: <dir>/archive/{name}. Precedent: openspec changes. - status-frontmatter — "done" = a
status: shipped|donefrontmatter key; file stays put; scanner reads frontmatter.
Successor-artifact checks stay in the fog until a calibration pass demands one.
Nudge surfacing (split by capability)
:checknames promotion candidates in every report (deterministic, recurring — the report gains a promotion-candidates section).:calibratemay go further and DRAFT the adoption — the graduated rule plus the file moves — for human approval. It proposes, never applies unasked.
7. Pipeline integration (#37)
Lifecycle categorization is a new signal class in the existing
scanner/classifier pipeline; delete and extract-then-delete are new op
types in the clean report schema. The only new skill is
os-doc-hygiene:calibrate.
8. The :calibrate protocol (#42, #39, #45)
The learn-new-rules loop, run per project:
- Cluster-and-sample over unmatched files (unmatched = unmanaged = the candidate pool). Clustering exists precisely so rules are written over the cluster, not one member.
- Nomination (cheap model): haiku nominates a bare glob + lifetime per cluster — constrained to produce patterns, never exact-instance globs.
- Nomination intake filter (deterministic —
NominationIntakeFilterincalibrate_helpers.py): a nomination whose glob+lifetime exactly equals arejectedentry (§2 Nominations memory) is dropped before the judge, logged in the run summary. Survivors are annotated with every related rejection — related = the two globs' match sets intersect on the current shortlist (deterministic, from the scan). Annotations plus all open consults enter the judge prompt as a "Nominations memory" input section. - Judgment (strong model): one batched Opus/Fable judge gathers its own evidence and authors final rule entries (#39: weak-model discoveries need strong-model confirmation). Verdicts: confirm / reject / amend / consult — consult is mandatory when an artifact's purpose is unclear (regenerable ≠ removable).
- Rule report to the human — before any rule is persisted. Per proposed
rule, the report shows:
- the glob verbatim, exactly as it would be persisted;
- every path it currently matches (or a capped sample + total count);
- the boundary — near-miss paths it does NOT match (this caught a
real bug during #45:
autoresearch/classic-*/silently missingautoresearch/improve-260710-1057/); - lifetime + behavior tier (auto vs confirm);
- a plain-language why — what the artifact is and why it's clutter. The human reviews patterns and examples, not JSON schema.
- Persistence: project rules land on judge confirmation;
global-rulebook writes are human-gated (a cross-repo write into cc-os).
Rule removals are HITL-only, with recorded reasoning. Every settled
verdict persists as a plain rule — judge
keepverdicts become ordinarylifetime: keeprules, including exact-path singletons (#51). Human declines persist asrejectedentries (rejected_by: "human"); openconsultverdicts persist tonominations.consults, deduped by glob at write time (§2 Nominations memory). - Retest loop: stop at <2 new rules OR <10% unmatched shrink; hard cap 3 rounds.
Seed intake: the #41 clutter-inventory seed candidates enter at judge intake — full intake for every run after calibration pass #1 (see the one-off carve-out below).
Rule-quality tests the report enforces
- The rule is the CLASS, never the PATH. A glob may hardcode a name that
recurs by convention (
PRD.md,HANDOFF-*.md,migration-report.md); it may NOT hardcode an identifier unique to one instance (a run-id, hash, or bare timestamp). A rule matching one file today is fine; a rule that can only EVER match one file is a failed generalization — flag loudly, never silently persist. Keep-tier relaxation (map #49 #51): exact-path/instance globs ARE allowed forlifetime: keepentries only — this test exists to prevent bad DELETION rules, and a singleton keep (e.g.docs/research/clutter-pattern-inventory.md) merely protects. Instance globs stay forbidden fortemporary/delete-once-served. - Glob-breadth tie-breaker: prefer the NARROWER glob. Too-narrow fails safe (leaves clutter; the recurring pass catches it next round — self- healing). Too-broad fails dangerous (deletes a keeper — not self-healing). Readability beats cleverness when the cost is one missed round.
- Enumerate siblings — never widen to the container. When the near-miss
boundary check reveals sibling artifacts a glob misses, the fix is to
ENUMERATE the conventional prefixes as separate rule entries
(
autoresearch/classic-*/+autoresearch/improve-*/), never to widen to the container (autoresearch/*/,autoresearch/**). A container-claiming glob mortgages the directory's entire future: nothing keep-worthy can ever live there without a counter-rule (a futureautoresearch/methodology-notes.mdwould be claimed by a deletion rule). A sibling flavor that can't be named yet fails safe — it gets its own entry next pass, the same self-healing property the narrower-glob tie-breaker relies on. Container globs are justified ONLY when the directory is wholly machine-owned (plugins/*/.pytest_cache/), where nothing keep-worthy can appear inside.
Test coverage for the map-#49 additions (#59)
NominationIntakeFilterand the canonical-writer extension get unit tests only (pure deterministic logic — invariant #6). Fixtures assert exact-repeat drops, match-set-intersection relatedness annotations, and round-trip ordering + unknown-field warnings.- The judge's "Nominations memory" context and the consult resurfacing loop
get no scenario harness — both are human-gated downstream, so no
silent-failure path exists a harness would uniquely catch. Mitigation is
one worked example each (judge.md input section; calibrate SKILL.md), with
production IRL session audits as the next signal; IRL evidence of the
judge ignoring rejection context is what triggers harness design (reading
~/Documents/SecondBrain/howto/running-autoresearch-skill-evals.mdfirst, per eval discipline).
9. Calibration pass #1: cc-os (#45)
Project: cc-os — proves the protocol works before testing whether it generalizes; the self-referential risk is accepted and paid for by the validation criteria carrying the weight.
- Precision hard gate: the pass FAILS if any rule persisted to the
rulebook has a glob matching a protected path — regardless of behavior tier
(a confirm gate is a safety property of the human sitting there, not of the
protocol). Exploration-time consult verdicts on protected paths are FREE.
Protected set (fixed before the pass, human-edited, never revised after):
eval
scenarios//scenarios-reserve//fixture//judge-rubric.md;openspec/specs/;docs/adr/**; mirrored.claude//.codex//.pi/skill dirs;CLAUDE.md; plugin source. - Recall floor: 8 of the 10 cc-os rows of the #41 inventory, with 4
mandatory (missing any fails the pass):
autoresearch/<run-id>/,HANDOFF-*.md,docs/adr/migration-report.md,.dochygiene/report.{json,md}.graphify-out/is void, not a miss (it is IGNORE surface per #43). The recall floor is a grading bar, not runtime behavior — err-toward-keeping comes from the hard gate + consult. - Seed hold-out (one-off for pass #1 ONLY): the cc-os rows of #41 are the sealed answer key and are withheld from judge intake — otherwise the pass is an open-book exam. This deliberately deviates from #42; every later run uses full seed intake. Do not mistake the carve-out for a permanent property.
- Novel matches beyond the answer key are expected and human-spot-checked — a wrong novel match triggers rule adjustment + a retest round, not failure.
- A do-nothing pass cannot pass: the recall floor makes the pass falsifiable in the finding direction.
Out of scope for this design
Shipping the recurring cross-project categorize-and-learn skill (charted as fog on map #31). Ignore-surface propagation into other tools' config (rejected — ADR-0040); if disposable files polluting the knowledge graph later proves painful, the fix belongs to graphify or os-vault:onboard-project.