24 KiB
Design: lifecycle-aware-doc-hygiene
Context
os-doc-hygiene today only monitors stale/bloated docs (doc-check,
doc-clean, report-schema specs, already shipped). The locked lifecycle
design (plugins/os-doc-hygiene/lifecycle-spec.md, wayfinder map #31, ADRs
0038–0041) extends it to lifecycle management: every managed file gets a
lifetime (keep / temporary / delete-once-served, plus an extract
modifier), rules live in a rulebook, and a new :calibrate skill learns rules
per project. This design translates the locked spec into the concrete seams
of the existing pipeline (scanner.py → subagent classification →
report_builder.py → validate_report.py → patch_applier.py) without
reopening any decision already recorded in the spec or its ADRs.
Goals / Non-Goals
Goals
- Add a stdlib-only
rulebook.pyloader (global + per-project override, add-only merge,glob.translatedialect) consumed by the scanner. - Give the scanner a lifecycle signal class where a directory-rule match prunes the walk — this is simultaneously the IGNORE-surface implementation (no separate ignore mechanism).
- Extend
report_builder.py/validate_report.py/patch_applier.pyto carrydeleteandextract-then-deleteop types end-to-end under the ADR-0039 autonomy tier matrix, with all tracked/dirty state verified at application time via git, never trusted from the rule or the report cache. - Implement temporary-tier age computation (git commit time, mtime fallback for untracked files, directory-inode mtime for untracked directory entries) and retain-recent-N semantics.
- Add
conventions.jsonand wire promotion-candidate nudging into:check. - Add
:calibrateas a skill orchestrating haiku nomination + strong-model judge subagents, per the 6-step protocol in spec §8. - Resolve the state-dir wrinkle (below) so the shipped IGNORE surface actually excludes the real state directory.
Non-Goals
- No ignore-surface propagation into
.graphifyignore,.dochygiene-ignore, or any other tool's config (ADR-0040 — closed, not revisited here). - No bulk-clean of any project other than cc-os (calibration pass #1 target per spec §9); no other project's rulebook is touched by this change.
- No recurring cross-project categorize-and-learn skill — that is charted as fog on map #31 and explicitly out of scope for this design (spec, "Out of scope" section).
- No
propagate_ignorefield, in any form (removed entirely per ADR-0040, not left as a documented-but-unused slot). - No new deletion destinations beyond the existing knowledge-routing targets
(ADR/CLAUDE.md/docs for repo-durable extraction, SecondBrain vault via
/os-vault:writefor cross-repo extraction).
Decisions
1. rulebook.py as a new stdlib-only module
A new scripts/rulebook.py, following the existing scripts' conventions
(small single-responsibility classes, structured JSON-serializable output, no
model, injectable for testing). Responsibilities:
- Load and parse the global
plugins/os-doc-hygiene/rulebook.jsonand, if present, the project's committed.dochygiene-rules.json, both under the envelope{"schema_version": 1, "rules": [...]}. - Compile every rule's
globonce at load time via stdlibglob.translate(pattern, recursive=True, include_hidden=True)(Python ≥ 3.13, per spec §2) into a matchable regex; this is a hard runtime requirement on the Python version the plugin scripts run under — no fallback dialect is provided (matches the spec's explicit dialect pin). - Merge add-only: project rules are appended to global rules, never replacing
or deleting a global entry. A project "neutralizes" a global rule only by
adding a shadowing rule with
lifetime: "keep"at equal-or-higher precedence (per the two-axis precedence below) — there is no rule-removal mechanism, by design (ADR-0038). - Resolve precedence per path: project file-rule > project directory-rule >
global file-rule > global directory-rule; ties within the same tier broken
by longest
globstring, then by last-defined (definition order within the rules array, project-before-global order does not matter once the four buckets above are checked first). - Validate: skip-and-warn per invalid or unconfirmed rule (a rule missing
confirmed_byis loaded but flaggedinactive, never contributes a signal, and never blocks the rest of the rulebook from loading). Hard-fail (raise / non-zero exit) only on unparseable JSON or an unrecognizedschema_version. - Expose a single query surface consumed by the scanner: given a
project-root-relative path, return either
None(unmatched — flows through existing signals unchanged) or the winning rule (with its precedence-resolved fields) plus whether the match was file-level or directory-level.
rulebook.py has no knowledge of git, classification, or the report schema —
it only resolves "which rule, if any, governs this path," keeping it testable
in complete isolation with fixture rulebook JSON and injected path lists, in
line with every other script in scripts/.
2. Where the lifecycle signal plugs into scanner.py
A new signal class, LifecycleSignal (or equivalent), is attached during the
existing scanner walk, alongside the current objective signals (broken refs,
version skew, etc.). Concretely:
- Before recursing into a directory, the scanner asks the loaded rulebook
whether a directory-rule matches that directory's path. If so, the walk is
pruned — the scanner never opens any file beneath it — and the scanner
emits one aggregate shortlist/signal entry for the directory path
itself (not one entry per file inside it), carrying the lifecycle signal
(rule ref, lifetime,
served_when/served_when_pathif present). - This directory-rule-prune mechanism is the IGNORE surface described in
spec §1 and §2 — there is no separate ignore-list data structure in the
scanner. The IGNORE surface's seed members (
graphify-out/**,.dochygiene/**) are shipped as ordinary directory rules withlifetime: "keep"... exceptkeepimplies "scanned and reported," which contradicts "never walked." Resolution: IGNORE-surface entries are a rulebook rule with nolifetimefield at all (or a reserved sentinel the scanner recognizes before even considering lifetime) whose sole effect is walk-pruning with no shortlist/signal emission at all — distinct fromkeep, which is walked and reported. See "Ambiguities resolved" below; this is the one place the spec's wording ("directory-rule match prunes the walk and emits one aggregate entry — this IS the ignore surface") needed disambiguating against the separate statement that IGNORE-surface members are "never walked" with no entry at all. This design follows the latter (spec §1, §3 table: "IGNORE surface … never walked", no aggregate-entry language attached to it) and reserves the "prunes the walk and emits one aggregate entry" behavior for ordinarytemporary/delete-once-serveddirectory rules (e.g.,autoresearch/<run-id>/), which need a signal to drive deletion decisions, whereas pure IGNORE members need none. - For files matched by a file-rule (not caught by a directory prune), the scanner attaches the lifecycle signal to that file's existing entry in the shortlist, alongside any pre-existing objective signals — a file can be both lifecycle-tagged and, say, stale-by-broken-ref.
- The lifecycle signal is consumed downstream by the classification subagent
as a new signal class (per doc-check's existing "signals are passed
through verbatim" contract) and ultimately drives
op/op_typeselection (delete/extract-then-delete) in place of, or alongside, the existing stale/bloat vocabulary.
3. Delete / extract-then-delete op flow
report_builder.py → validate_report.py → patch_applier.py, following
the existing division of labor (model proposes judgment fields only;
deterministic finalize pass authors the guardrail fields; validator enforces
the schema; applier mutates at clean time with runtime re-verification).
report_builder.pygains two newexact_edit.kindvalues in itsKIND_TABLE:deleteandextract-then-delete. Both carry ananchorcovering the full file (or, for a directory-rule aggregate entry, the directory path with no anchor) so the biconditional withop_typeholds.extract-then-deleteadditionally carries the extraction destination classification (repo-durablevscross-repo) as a proposal field the model supplies (it is a judgment about content, not a derived guardrail field) plus, forrepo-durable, a target doc reference;cross-reporoutes through/os-vault:writeat clean time and carries no fixed destination path (spec §1: "no new destinations").validate_report.py'sderive_safety_tierremains the single source of tier derivation (invariant #10) and is extended, not replaced, with the ADR-0039 matrix as additional input dimensions beyond(op_type, is_destructive, is_reversible):- a
lifecycleobject on the entry (rule_ref,lifetime,served_when_pathXORserved_when,git_stateplaceholder recomputed at runtime — see below) derive_safety_tiergains a lifecycle-aware branch: forop_typein (delete,extract-then-delete), tier isautoonly when the entry's lifecycle evidence isscanner-proven(aserved_when_pathhit, or a temporary-tier age/retain-recent computation) and the file's git state is tracked+clean; every other combination (dirty, untracked, orserved_whenclassifier-judged) forcesconfirm. This is additive: the existing non-lifecycle branches (stale/bloat ops) are unchanged, andderive_safety_tieris still the one function bothreport_builder.pyandvalidate_report.pycall — no second tier-deriving code path is introduced.- Because tracked/dirty state can only be known against the live worktree,
report_builder.pycalls a runtimegit ls-files+ dirty check at report-build time to populate thegit_stateinput for tier derivation — but per ADR-0039 this is explicitly re-verified again at apply time bypatch_applier.py, because time passes between check and clean. The report's tier is advisory-fresh, not authoritative-forever.
- a
patch_applier.pygains handling fordeleteandextract-then-delete:- Before applying either, it re-runs
git ls-files <path>and a dirty check against that specific path (never trusting the report's cached tier or git_state) — if the path is no longer tracked+clean when the rule required that forauto, the applier downgrades that entry to skip-and-report (git-state-changed-since-check), the same family of guard as the existing content-hash guard, not a silent auto-apply. deleteperforms a truegit rm <path>(orgit rm -rfor a directory-rule aggregate entry) staged into the same single hygiene commit the rest of the run produces — no archive directory, no move. This is staged precisely like every other op (nogit add -A).extract-then-deletefirst invokes the generative extraction (repo- durable → written via the same live-read Sonnet-subagent pathdoc-cleanalready uses for generative ops, target = ADR/CLAUDE.md/docs per the model's proposal; cross-repo →/os-vault:writeinvoked by the clean skill, not the applier itself, since vault writes are not a filesystem op the applier owns) and only after that step succeeds does itgit rmthe source path, both landing in the one hygiene commit. If extraction fails, the delete does not proceed for that entry (fails closed, consistent with the existing "hard failure → rollback, partial-guard-skip → no rollback" split: an extraction failure is a per-entry skip, not a run-level hard failure, unless it is the kind of errordoc-clean's existing rollback rules already treat as hard).
- Before applying either, it re-runs
4. Temporary-tier age computation
- Age source: git commit time of the path's most recent commit touching
it, obtained via
git log -1 --format=%cI -- <path>; falls back to filesystemmtimeonly when the path is untracked (no commit history to read). No per-ruleage_sourceoverride field exists (ADR-0039/#48). - Untracked directory entries (a directory-rule match on an untracked
directory) use one
stat()on the directory inode itself, not a recursive max-mtime walk over its contents — cheap and self-healing per spec §4/§48 (a spuriously bumped directory mtime only delays deletion by one round, it never causes incorrect early deletion). - Retention unit is the rule's own match granularity: a file-rule's
matches are individual files; a directory-rule's matches are whole
directories (e.g.
autoresearch/<run-id>/— "3 most recent runs," not "3 most recent files inside the newest run").rulebook.pyand the scanner must agree on this: the directory-rule aggregate shortlist entry (§2 above) is the unitretain_recent/max_age_daysoperate over, computed by grouping sibling directory-rule matches under their common parent glob and ranking by age (newest first), keeping the topretain_recentregardless of age and deleting the rest once they exceedmax_age_days. retain_recentdefaults to 3,max_age_daysdefaults to 3, both per-rule overridable in the rulebook JSON.
5. conventions.json and promotion candidates
plugins/os-doc-hygiene/conventions.jsonis a global-only, plugin-shipped, machine-readable file (envelope TBD-but-simple: an array of convention objects — name, "what it proves,"served_when_path/frontmatter template a rule graduates to, one-line human pitch). v1 ships exactly the two entries named in the spec (archive-bucket,status-frontmatter); no per-project override file exists for it (ADR-0041: "the catalog only recommends; adoption lands in the project's own rulebook").report_builder.py(deterministic, no model) reads this file directly — no subagent involvement — and, for every entry whose lifecycle signal is classifier-judged (served_whenpresent, noserved_when_path), checks whether any catalog convention'sserved_when_pathpattern is not yet satisfied by that project's current structure, and if so emits apromotion_candidatesentry naming the convention and the one-line pitch. This is a deterministic string/structure check, not a judgment call, so it belongs inreport_builder.pyalongside the other guardrail fields the model must not author.- The
:checkreport gains apromotion_candidatesarray section at the top level (sibling toentries), populated on every run where at least one classifier-judged lifecycle signal exists and a matching catalog convention applies.:calibrate(out of this change's core report path, but sharing the same catalog) may go further and draft the adoption (graduated rule + the file moves that convention implies) for human approval, never applying unasked.
6. :calibrate as an orchestrating skill
New skills/calibrate/SKILL.md (the only new skill this change adds),
following the existing skill pattern (check/clean/sweep
SKILL.md + a workflows/*.md LOOP-GUARD subagent pointer where a subagent
must not recurse into the top-level skill file). It orchestrates, per spec
§8:
- A deterministic clustering pass (script, no model) over the current
scanner's unmatched shortlist (unmatched = unmanaged = candidate pool),
grouping by path-shape similarity and sampling representatives per
cluster — this is a new small deterministic helper, not a new pipeline
stage in
check/clean, since calibration is a separate on-demand skill. - A haiku subagent per cluster, constrained by prompt contract to nominate a bare glob + candidate lifetime, never an exact-instance path (the rule-quality "class, never path" test is enforced by the strong- model judge in the next step, not trusted from haiku).
- One batched strong-model (Opus/Fable) judge subagent that gathers its
own evidence (re-reads matched paths, checks near-miss boundaries) and
returns verdicts (
confirm/reject/amend/consult), withconsultmandatory whenever an artifact's purpose is unclear. - A deterministic report-assembly step (script) that renders the required 5-element rule report (glob verbatim, matches with capped sample + total count, near-miss boundary, tier, plain-language why) to the human before any persistence call is made — persistence is a separate, explicit step gated on human review of this report, not something the judge subagent triggers directly.
- Persistence: project-rule writes to
.dochygiene-rules.jsonhappen on judge confirmation (post human rule-report review); global-rulebook writes are additionally human-gated (a distinct confirmation, since it is a cross-repo write into cc-os); rule removals are HITL-only in all cases. - A retest loop (re-run clustering against the shrunk unmatched pool) that stops when a round nominates fewer than 2 new rules or shrinks the unmatched pool by less than 10%, hard-capped at 3 rounds regardless.
7. The state-dir wrinkle
lifecycle-spec.md's IGNORE seed lists .dochygiene/**. Since ADR-0027
(pre-dating this change), the plugin's actual state directory is
.cc-os/dochygiene/, with .dochygiene/ retained only as a legacy read-
fallback that state_store.py migrates away from on first write; scanner.py
already self-excludes both .cc-os/ and .dochygiene/ by directory name at
any depth (per scripts/CONTEXT.md's documented default excludes). This
change's rulebook-driven IGNORE surface must therefore ship both seed
entries — .dochygiene/** (matching the spec text verbatim, covering the
legacy dir on projects that haven't migrated) and an equivalent rule (or
reliance on the scanner's pre-existing hardcoded self-exclusion, which
already covers .cc-os/**) — so that on a project already migrated to
.cc-os/dochygiene/, the IGNORE surface still holds. This design does not
change the scanner's existing hardcoded self-exclusion; the rulebook's
IGNORE rule list is additive to it, not a replacement, so this is satisfied
without a code change beyond confirming the shipped rulebook.json's IGNORE
entries include .dochygiene/** and documenting that .cc-os/** is already
covered by the pre-existing self-exclusion.
Risks / Trade-offs
- Two overlapping walk-pruning mechanisms (the scanner's pre-existing
hardcoded
excluded_dirsand the new rulebook-driven directory-rule prune) risk confusing future maintainers about which one is authoritative for a given path. Mitigated by treating the hardcoded excludes as a distinct, lower-level safety net (self-exclusion of the tool's own state and pre-existing fixture excludes) and the rulebook as the project-configurable lifecycle layer;scripts/CONTEXT.mdshould document both once implemented. git ls-files/ dirty check runs twice (once at report-build time, once at apply time) for lifecycle deletes — a deliberate, spec-mandated redundancy (ADR-0039: "never trusting the rule … at runtime") rather than an oversight; the cost is one extra git subprocess call per lifecycle entry per run, acceptable given the destructive nature of the operation.extract-then-deletespans two subsystems (a generative rewrite via the existing Sonnet-distillation path, then agit rm) inside a single op type, and for cross-repo extraction additionally calls into/os-vault:write, a different plugin's skill. This is more moving parts in one applied entry than any existing op; a partial failure (extraction succeeds, delete fails, or vice versa) needs care to keep the "one hygiene commit" invariant intact — this design treats extraction-then-delete as atomic per entry (both steps land in the same commit, or neither does), which may need revisiting once implemented against real vault-write latency/failure modes.- Directory-rule aggregate entries change the meaning of "one entry per
file" that today's
doc-check/report-schemaspecs assume throughout (e.g. "everyentries[].pathSHALL be a member ofshortlist" — a directory aggregate entry's path is a directory, not a file, which is a new shape forpaththat existing consumers may not expect). The delta specs below extend rather than replace this requirement, but any code outside this change's touched files that assumespathis always a regular file should be audited during implementation. rulebook.py's hard requirement on Python ≥3.13 (glob.translate) is new relative to the rest of the scripts directory (whose stdlib-only policy has not previously pinned a Python floor this high); if the environment runningos-doc-hygienescripts is older, the whole rulebook layer hard-fails to import. This is accepted as a locked design decision (spec §2), not reopened here, but is called out as an operational risk worth a version check with a clear error message at load time.
Ambiguities resolved while writing this design
(Reported per instructions — none silently decided against the spec; these are places the locked documents left an implementation-level gap.)
- IGNORE-surface entries vs. ordinary directory-rule "prune + aggregate
entry" behavior. Spec §1/§2 phrasing could be read as saying the
IGNORE surface itself produces an aggregate shortlist entry ("a
directory-rule match prunes the walk and emits one aggregate entry —
this IS the ignore surface"), but spec §1 and the §3 tier table also say
IGNORE-surface members are simply "never walked," with no entry
language. Resolved by treating "prune + aggregate entry" as the general
mechanism for lifecycle directory rules with a real lifetime (temporary /
delete-once-served), and reserving true zero-signal, zero-entry pruning
for the specific IGNORE seed list (
graphify-out/**,.dochygiene/**). Flagging this because it affects whether IGNORE members ever appear anywhere in a report — this design says they never do. conventions.jsonfile shape. The spec names the file and its v1 contents (archive-bucket, status-frontmatter) and per-entry conceptual fields (name, what it proves, template, pitch) but does not give a JSON schema. This design treats it as a flat array of objects with those four conceptual fields, no envelope/schema_version wrapper (unlikerulebook.json, which the spec explicitly gives an envelope). Flagging because a schema_version envelope could be added instead if implementation prefers consistency with rulebook.json; nothing in the spec forecloses either choice.- Where the
git ls-files/dirty runtime check physically lives (spec says "verified at runtime via git ls-files + dirty check, never trusting the rule" but doesn't say whether that's a report-build-time check, an apply-time check, or both). This design does both — advisory at report-build time (so the report's tier reflects current reality when shown to the human), authoritative at apply time (so a stale report never causes an unsafe auto-delete) — matching the existing content-hash-guard pattern (expected_sha256is likewise computed at build time and re-verified at apply time). Flagging because the spec's single sentence doesn't explicitly mandate the double-check; it was inferred from ADR-0039's "never trusting the rule" plus the existing applier's re-verification precedent (patch_applier.py's content-hash guard). - Extraction-failure handling within
extract-then-delete(does a failed extraction hard-fail the whole clean run, or just skip that entry?) is not addressed in the spec. This design treats it as a per-entry skip (consistent withdoc-clean's existing partial-success semantics), not a run-level hard failure, unless the failure mode matches one of the existing hard-failure triggers (applier exit 2, write error). Flagging since this determines commit granularity under failure.