15 KiB
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.jsonresolved relative to the plugin scripts directory
Scenario: Per-project override is optional and committed
- WHEN a project has no
.dochygiene-rules.jsonat 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.jsoncarries a top-levelnominationskey alongsiderules - THEN
rulebook.pyloads therulesarray 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=Truesemantics 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 omitsretain_recentandmax_age_days - THEN the loader applies
retain_recent = 3andmax_age_days = 3
Scenario: confirm:true may only be set by a human
- WHEN a rule is validated
- THEN a rule with
confirm: trueis 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 setconfirm: 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_ignorefield - 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
:calibratecandidate
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_byand nine valid rules - THEN the loader loads all ten rules, marks the one missing
confirmed_byinactive (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_versionthe 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
.gitignorebut 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 withlifetime: 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.mdmatches a file-rule withlifetime: delete-once-servedand 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
nominationsentry'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_onand 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