# Spec: lifecycle-rulebook ## Purpose Defines the global + optional per-project rulebook files that declare lifetime rules for known-clutter artifacts: their location and envelope, glob dialect, two-axis merge precedence, per-rule fields, validation behavior, the "unmatched means unmanaged" contract, the explicit IGNORE surface, and how the scanner walk consumes rules for pruning and lifecycle signal attachment. ## Requirements ### Requirement: Rulebook Locations and Envelope The plugin SHALL ship a global rulebook at `plugins/os-doc-hygiene/rulebook.json`, resolved relative to plugin scripts, present in every project. A project MAY additionally provide a committed repo-root `.dochygiene-rules.json` override. Both files SHALL use the envelope `{"schema_version": 1, "rules": [...]}`. The project file MAY additionally carry a top-level `nominations` key (see the Nominations Memory requirement); the loader SHALL ignore unknown top-level keys, so the key is additive to `schema_version` 1. The per-project override SHALL NOT live under gitignored `.cc-os/` — it SHALL be a committed, reviewable dotfile. #### Scenario: Global rulebook is always present - **WHEN** the rulebook loader runs in any project - **THEN** it loads `plugins/os-doc-hygiene/rulebook.json` resolved relative to the plugin scripts directory #### Scenario: Per-project override is optional and committed - **WHEN** a project has no `.dochygiene-rules.json` at its repo root - **THEN** the loader proceeds using only the global rulebook, and when the file is present it is read as a committed, reviewable file, never from `.cc-os/` #### Scenario: Both files share one envelope shape - **WHEN** either the global rulebook or a project override is loaded - **THEN** it is validated against the envelope `{"schema_version": 1, "rules": [...]}` #### Scenario: A nominations key does not disturb the v1 loader - **WHEN** a project `.dochygiene-rules.json` carries a top-level `nominations` key alongside `rules` - **THEN** `rulebook.py` loads the `rules` array exactly as before, ignoring the unknown top-level key without warning or error ### Requirement: Glob Dialect Is glob.translate Rule `glob` patterns SHALL be compiled using stdlib `glob.translate(pattern, recursive=True, include_hidden=True)` (Python ≥ 3.13). Patterns SHALL be interpreted as repo-root-relative, and `**` SHALL match recursively. Each rule's glob SHALL be compiled once at rulebook load time, not per matched path. #### Scenario: Recursive double-star matches subtrees - **WHEN** a rule's glob is `autoresearch/*/**` - **THEN** it matches any file at any depth under any immediate subdirectory of `autoresearch/` #### Scenario: Hidden files are matched when the pattern implies them - **WHEN** a rule's glob targets a dotfile path - **THEN** `include_hidden=True` semantics apply and the dotfile is matchable #### Scenario: Compilation happens once per load - **WHEN** the rulebook loader parses the rules array - **THEN** each rule's glob is compiled to a matcher exactly once, and that compiled matcher is reused for every path checked during the run ### Requirement: Two-Axis Precedence with Add-Only Merge The rulebook loader SHALL merge the project override over the global rulebook using add-only semantics: project rules are appended to, and never replace or delete, global rules. Precedence for a given path SHALL resolve in this order: project file-rule > project directory-rule > global file-rule > global directory-rule. Ties within the same precedence tier SHALL be broken first by longest `glob` pattern, then by last-defined order. A project SHALL neutralize a global rule only by adding a shadowing rule with `lifetime: "keep"` at equal-or-higher precedence; there SHALL be no rule-removal mechanism. #### Scenario: Project file-rule outranks every other tier - **WHEN** a path matches both a project file-rule and a global directory-rule - **THEN** the project file-rule's fields govern #### Scenario: Ties broken by longest pattern then last-defined - **WHEN** two rules in the same precedence tier match the same path with different-length globs - **THEN** the rule with the longer glob pattern governs; if the glob lengths are equal, the rule defined later in its rules array governs #### Scenario: Neutralizing a global rule via keep-shadowing - **WHEN** a project wants to exempt a path from a global delete rule - **THEN** it adds a project rule matching that path with `lifetime: "keep"`, and no mechanism exists to remove or edit the global rule itself #### Scenario: Merge never deletes a global rule - **WHEN** the project override is loaded alongside the global rulebook - **THEN** every global rule remains present and evaluable; the merge only adds project rules on top ### Requirement: Per-Rule Fields A rule SHALL support the fields `glob`, `lifetime` (one of `keep`, `temporary`, `delete-once-served`), `extract` (boolean modifier), `served_when` (free text, classifier hint), `served_when_path` (deterministic sibling of `served_when`), `retain_recent` (default `3`), `max_age_days` (default `3`), `confirm` (boolean, human-settable-only escape hatch), `confirmed_by` (`human` or a strong-model identifier), `confirmed_on`, `source`, and `note`. A rule SHALL NOT support a `propagate_ignore` field in any form. A rule that matches no path yet is undefined behavior only in the sense that unmatched files receive no lifetime at all and flow through existing signals unchanged — unmatched SHALL always mean unmanaged, never an implicit lifetime. #### Scenario: Defaults apply when retain_recent/max_age_days are omitted - **WHEN** a `temporary`-lifetime rule omits `retain_recent` and `max_age_days` - **THEN** the loader applies `retain_recent = 3` and `max_age_days = 3` #### Scenario: confirm:true may only be set by a human - **WHEN** a rule is validated - **THEN** a rule with `confirm: true` is accepted only if it is not proposed by a model in the same validation pass as an unconfirmed state — a model-authored rule proposal SHALL NOT itself set `confirm: true`; it may only recommend that a human set it #### Scenario: propagate_ignore is rejected as an unknown field - **WHEN** a rule in either rulebook file contains a `propagate_ignore` field - **THEN** the loader treats it as an unrecognized field under the rule's validation (skip-and-warn, per the Validation requirement), since no such field is part of the schema #### Scenario: Unmatched files receive no lifetime - **WHEN** a file matches no rule in either rulebook - **THEN** the rulebook query returns no match for that path, and the file flows through the existing (non-lifecycle) scanner signals unchanged, becoming a `:calibrate` candidate ### Requirement: Skip-and-Warn Validation, Hard-Fail Only on Structural Errors The rulebook loader SHALL skip and warn on a per-rule basis for any rule that is invalid or lacks `confirmed_by` — such a rule SHALL be loaded but marked inactive and SHALL never contribute a lifecycle signal, while the rest of the rulebook continues to load and function. The loader SHALL hard-fail (non-zero exit / raised error) only for unparseable JSON or an unrecognized `schema_version`. #### Scenario: A rule missing confirmed_by is skipped, not fatal - **WHEN** the rulebook contains one rule without `confirmed_by` and nine valid rules - **THEN** the loader loads all ten rules, marks the one missing `confirmed_by` inactive (it never emits a signal), and the other nine function normally #### Scenario: Unparseable JSON hard-fails - **WHEN** either rulebook file is not valid JSON - **THEN** the loader raises a hard failure and does not proceed with a partial rulebook #### Scenario: Unknown schema_version hard-fails - **WHEN** a rulebook file declares a `schema_version` the loader does not recognize - **THEN** the loader raises a hard failure ### Requirement: Unmatched Means Unmanaged Files that match no rule in either rulebook SHALL receive no lifecycle signal and SHALL NOT be deleted, extracted, or otherwise treated as lifecycle-managed by any component of this change. They remain visible only through the existing stale/bloat signal pipeline and are the candidate pool for `:calibrate`. #### Scenario: No rule match means no lifecycle behavior - **WHEN** a file matches no rulebook rule - **THEN** no delete or extract-then-delete op is ever proposed for it on lifecycle grounds alone ### Requirement: IGNORE Surface Is an Explicit Seed List, Never Inferred from .gitignore The rulebook SHALL define an explicit IGNORE surface as directory rules with no lifetime (paths never walked at all, distinct from `keep`, which is walked and reported but never deleted). The seed IGNORE members SHALL include `graphify-out/**` and `.dochygiene/**`, plus any entries needed to cover the plugin's actual current state directory (`.cc-os/**`, already covered by the scanner's pre-existing hardcoded self-exclusion — see the `doc-check` spec). The IGNORE surface SHALL NEVER be inferred from `.gitignore` — a gitignored path is neither automatically deletable nor automatically keepable. #### Scenario: graphify-out is never walked - **WHEN** the scanner walks a project containing `graphify-out/` - **THEN** no file beneath `graphify-out/` is opened, and no shortlist or signal entry is produced for it or its contents #### Scenario: .dochygiene legacy state dir is never walked - **WHEN** the scanner encounters `.dochygiene/` in a project that has not migrated to `.cc-os/dochygiene/` - **THEN** the directory is treated as IGNORE surface and never walked #### Scenario: gitignored is not treated as IGNORE surface - **WHEN** a path is listed in `.gitignore` but is not one of the explicit IGNORE seed members - **THEN** the scanner walks it normally per its other rules — being gitignored alone neither excludes it from the walk nor exempts it from deletion ### Requirement: Directory-Rule Walk Pruning When a directory-rule (a lifecycle rule whose glob covers a subtree) matches a directory during the scanner walk, the scanner SHALL prune the walk at that directory: no file beneath it SHALL be opened or read. For directory rules carrying a real lifetime (`temporary` or `delete-once-served`), the scanner SHALL emit exactly one aggregate shortlist/signal entry for the directory path itself, carrying the lifecycle signal (rule reference, lifetime, and `served_when`/`served_when_path`). For IGNORE-surface directory rules (no lifetime), the scanner SHALL emit no entry at all. #### Scenario: A temporary directory rule prunes and emits one aggregate entry - **WHEN** `autoresearch/run-2026-07-01/` matches a directory rule with `lifetime: temporary` - **THEN** the scanner does not open any file inside that directory and emits exactly one shortlist entry for the directory path carrying the lifecycle signal #### Scenario: An IGNORE-surface directory rule prunes with no entry - **WHEN** `graphify-out/` matches the IGNORE-surface rule - **THEN** the scanner does not open any file inside it and produces no shortlist or signal entry for it ### Requirement: Lifecycle Signal Attachment on File-Rule Matches When a file-rule matches a path not caught by a directory-rule prune, the scanner SHALL attach a lifecycle signal (rule reference, lifetime, `served_when`/`served_when_path`) to that file's shortlist entry, alongside any pre-existing objective signals for the same file. The lifecycle signal SHALL be a new signal class consumed by the classification subagent like any other signal. #### Scenario: A file-rule match adds a lifecycle signal without displacing existing signals - **WHEN** `HANDOFF-2026-07-01.md` matches a file-rule with `lifetime: delete-once-served` and also has an existing broken-reference signal - **THEN** its shortlist entry carries both the lifecycle signal and the pre-existing broken-reference signal ### Requirement: Nominations Memory Lives in the Project Rules File The project `.dochygiene-rules.json` MAY carry a top-level `nominations` key holding exactly two lists: `consults` (open questions — entries with `glob`, `question`, `evidence`, `cluster_key`, `asked_on`, and deliberately NO lifetime) and `rejected` (settled "no" answers — entries with `glob`, `lifetime`, `why`, optional `consider_instead`, `rejected_by` (`"judge"` or `"human"`), `judged_on`). The `nominations` key SHALL never affect which files the rulebook governs — only entries in `rules` decide that. `rulebook.py` SHALL remain nomination-unaware; only the calibrate helpers read the key, and the calibrate reader SHALL warn on unrecognized nomination fields, mirroring the rules array's unknown-field discipline. Rejected entries and exact-path singleton keep rules SHALL exit only by hand-deletion (removals stay HITL with recorded reasoning); no automated revisit path SHALL exist. #### Scenario: Nominations never filter files - **WHEN** the scanner or rulebook resolves the governing rule for a path that only a `nominations` entry's glob matches - **THEN** the path is treated as unmatched/unmanaged — nominations carry no lifecycle authority #### Scenario: Consult entries carry no lifetime - **WHEN** a consult entry is written to `nominations.consults` - **THEN** it records `glob`, `question`, `evidence`, `cluster_key`, `asked_on` and no lifetime field — presence in the list means open, with no status field #### Scenario: Unrecognized nomination fields warn in the calibrate reader - **WHEN** the calibrate helpers read a nominations entry containing an unknown field - **THEN** a warning is emitted and the field is preserved, never silently dropped #### Scenario: A rejection leaves only by hand-deletion - **WHEN** a calibration pass runs against a rules file containing a stale rejection - **THEN** no automated path removes or expires the entry; it is removed only by explicit human edit ### Requirement: Canonical Writer-Enforced Ordering Every code path that serializes `.dochygiene-rules.json` SHALL write through one canonical writer that emits: `rules` grouped by lifetime tier in the order delete-once-served, temporary, keep, glob-sorted within each group; `nominations` after `rules`; `consults` before `rejected`, each glob-sorted. The writer SHALL be idempotent (canonicalizing an already-canonical file is a no-op) and SHALL round-trip unknown fields with a warning rather than dropping them. Ordering SHALL NOT be enforced by any hook; hand edits re-canonicalize on the next write. #### Scenario: Writes are grouped and sorted canonically - **WHEN** the writer serializes a rules file containing rules of all three tiers plus nominations - **THEN** the output orders rules delete-once-served → temporary → keep with globs sorted within each group, and nominations follows rules with consults before rejected, each list glob-sorted #### Scenario: Canonicalization is idempotent - **WHEN** the writer serializes a file it previously wrote, unchanged - **THEN** the output is byte-identical #### Scenario: A hand-edited file re-canonicalizes on the next write - **WHEN** a human appends a rule out of tier order and a later calibrate run persists a new entry - **THEN** the whole file is rewritten in canonical order in that write, with no hook involved in the interim