`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 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": [...]}`
### Requirement: Glob Dialect Is glob.translate
Rule `glob` patterns SHALL be compiled using stdlib
(`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
`.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