216 lines
11 KiB
Markdown
216 lines
11 KiB
Markdown
|
|
# Spec: lifecycle-rulebook
|
||
|
|
|
||
|
|
## ADDED 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 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
|
||
|
|
`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` delta 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
|