# 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.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.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.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.
