cc-os/plugins/os-doc-hygiene/openspec/changes/lifecycle-aware-doc-hygiene/design.md

24 KiB
Raw Blame History

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 00380041) 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.pyvalidate_report.pypatch_applier.py) without reopening any decision already recorded in the spec or its ADRs.

Goals / Non-Goals

Goals

  • Add a stdlib-only rulebook.py loader (global + per-project override, add-only merge, glob.translate dialect) 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.py to carry delete and extract-then-delete op 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.json and wire promotion-candidate nudging into :check.
  • Add :calibrate as 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_ignore field, 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:write for 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.json and, if present, the project's committed .dochygiene-rules.json, both under the envelope {"schema_version": 1, "rules": [...]}.
  • Compile every rule's glob once at load time via stdlib glob.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 glob string, 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_by is loaded but flagged inactive, 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 unrecognized schema_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_path if 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 with lifetime: "keep"... except keep implies "scanned and reported," which contradicts "never walked." Resolution: IGNORE-surface entries are a rulebook rule with no lifetime field 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 from keep, 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 ordinary temporary/delete-once-served directory 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_type selection (delete / extract-then-delete) in place of, or alongside, the existing stale/bloat vocabulary.

3. Delete / extract-then-delete op flow

report_builder.pyvalidate_report.pypatch_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.py gains two new exact_edit.kind values in its KIND_TABLE: delete and extract-then-delete. Both carry an anchor covering the full file (or, for a directory-rule aggregate entry, the directory path with no anchor) so the biconditional with op_type holds. extract-then-delete additionally carries the extraction destination classification (repo-durable vs cross-repo) as a proposal field the model supplies (it is a judgment about content, not a derived guardrail field) plus, for repo-durable, a target doc reference; cross-repo routes through /os-vault:write at clean time and carries no fixed destination path (spec §1: "no new destinations").
  • validate_report.py's derive_safety_tier remains 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 lifecycle object on the entry (rule_ref, lifetime, served_when_path XOR served_when, git_state placeholder recomputed at runtime — see below)
    • derive_safety_tier gains a lifecycle-aware branch: for op_type in (delete, extract-then-delete), tier is auto only when the entry's lifecycle evidence is scanner-proven (a served_when_path hit, or a temporary-tier age/retain-recent computation) and the file's git state is tracked+clean; every other combination (dirty, untracked, or served_when classifier-judged) forces confirm. This is additive: the existing non-lifecycle branches (stale/bloat ops) are unchanged, and derive_safety_tier is still the one function both report_builder.py and validate_report.py call — no second tier-deriving code path is introduced.
    • Because tracked/dirty state can only be known against the live worktree, report_builder.py calls a runtime git ls-files + dirty check at report-build time to populate the git_state input for tier derivation — but per ADR-0039 this is explicitly re-verified again at apply time by patch_applier.py, because time passes between check and clean. The report's tier is advisory-fresh, not authoritative-forever.
  • patch_applier.py gains handling for delete and extract-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 for auto, 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.
    • delete performs a true git rm <path> (or git rm -r for 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 (no git add -A).
    • extract-then-delete first invokes the generative extraction (repo- durable → written via the same live-read Sonnet-subagent path doc-clean already uses for generative ops, target = ADR/CLAUDE.md/docs per the model's proposal; cross-repo → /os-vault:write invoked 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 it git rm the 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 error doc-clean's existing rollback rules already treat as hard).

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 filesystem mtime only when the path is untracked (no commit history to read). No per-rule age_source override 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.py and the scanner must agree on this: the directory-rule aggregate shortlist entry (§2 above) is the unit retain_recent/max_age_days operate over, computed by grouping sibling directory-rule matches under their common parent glob and ranking by age (newest first), keeping the top retain_recent regardless of age and deleting the rest once they exceed max_age_days.
  • retain_recent defaults to 3, max_age_days defaults to 3, both per-rule overridable in the rulebook JSON.

5. conventions.json and promotion candidates

  • plugins/os-doc-hygiene/conventions.json is 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_when present, no served_when_path), checks whether any catalog convention's served_when_path pattern is not yet satisfied by that project's current structure, and if so emits a promotion_candidates entry naming the convention and the one-line pitch. This is a deterministic string/structure check, not a judgment call, so it belongs in report_builder.py alongside the other guardrail fields the model must not author.
  • The :check report gains a promotion_candidates array section at the top level (sibling to entries), 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:

  1. 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.
  2. 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).
  3. 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), with consult mandatory whenever an artifact's purpose is unclear.
  4. 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.
  5. Persistence: project-rule writes to .dochygiene-rules.json happen 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.
  6. 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_dirs and 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.md should 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-delete spans two subsystems (a generative rewrite via the existing Sonnet-distillation path, then a git 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-schema specs assume throughout (e.g. "every entries[].path SHALL be a member of shortlist" — a directory aggregate entry's path is a directory, not a file, which is a new shape for path that 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 assumes path is 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 running os-doc-hygiene scripts 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.)

  1. 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.
  2. conventions.json file 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 (unlike rulebook.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.
  3. 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_sha256 is 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).
  4. 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 with doc-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.