diff --git a/plugins/os-doc-hygiene/openspec/changes/lifecycle-aware-doc-hygiene/.openspec.yaml b/plugins/os-doc-hygiene/openspec/changes/archive/2026-07-15-lifecycle-aware-doc-hygiene/.openspec.yaml
similarity index 100%
rename from plugins/os-doc-hygiene/openspec/changes/lifecycle-aware-doc-hygiene/.openspec.yaml
rename to plugins/os-doc-hygiene/openspec/changes/archive/2026-07-15-lifecycle-aware-doc-hygiene/.openspec.yaml
diff --git a/plugins/os-doc-hygiene/openspec/changes/lifecycle-aware-doc-hygiene/calibration-pass-1-protected-set.md b/plugins/os-doc-hygiene/openspec/changes/archive/2026-07-15-lifecycle-aware-doc-hygiene/calibration-pass-1-protected-set.md
similarity index 100%
rename from plugins/os-doc-hygiene/openspec/changes/lifecycle-aware-doc-hygiene/calibration-pass-1-protected-set.md
rename to plugins/os-doc-hygiene/openspec/changes/archive/2026-07-15-lifecycle-aware-doc-hygiene/calibration-pass-1-protected-set.md
diff --git a/plugins/os-doc-hygiene/openspec/changes/lifecycle-aware-doc-hygiene/calibration-pass-1-results.md b/plugins/os-doc-hygiene/openspec/changes/archive/2026-07-15-lifecycle-aware-doc-hygiene/calibration-pass-1-results.md
similarity index 100%
rename from plugins/os-doc-hygiene/openspec/changes/lifecycle-aware-doc-hygiene/calibration-pass-1-results.md
rename to plugins/os-doc-hygiene/openspec/changes/archive/2026-07-15-lifecycle-aware-doc-hygiene/calibration-pass-1-results.md
diff --git a/plugins/os-doc-hygiene/openspec/changes/lifecycle-aware-doc-hygiene/design.md b/plugins/os-doc-hygiene/openspec/changes/archive/2026-07-15-lifecycle-aware-doc-hygiene/design.md
similarity index 100%
rename from plugins/os-doc-hygiene/openspec/changes/lifecycle-aware-doc-hygiene/design.md
rename to plugins/os-doc-hygiene/openspec/changes/archive/2026-07-15-lifecycle-aware-doc-hygiene/design.md
diff --git a/plugins/os-doc-hygiene/openspec/changes/lifecycle-aware-doc-hygiene/proposal.md b/plugins/os-doc-hygiene/openspec/changes/archive/2026-07-15-lifecycle-aware-doc-hygiene/proposal.md
similarity index 100%
rename from plugins/os-doc-hygiene/openspec/changes/lifecycle-aware-doc-hygiene/proposal.md
rename to plugins/os-doc-hygiene/openspec/changes/archive/2026-07-15-lifecycle-aware-doc-hygiene/proposal.md
diff --git a/plugins/os-doc-hygiene/openspec/changes/lifecycle-aware-doc-hygiene/specs/calibrate/spec.md b/plugins/os-doc-hygiene/openspec/changes/archive/2026-07-15-lifecycle-aware-doc-hygiene/specs/calibrate/spec.md
similarity index 100%
rename from plugins/os-doc-hygiene/openspec/changes/lifecycle-aware-doc-hygiene/specs/calibrate/spec.md
rename to plugins/os-doc-hygiene/openspec/changes/archive/2026-07-15-lifecycle-aware-doc-hygiene/specs/calibrate/spec.md
diff --git a/plugins/os-doc-hygiene/openspec/changes/lifecycle-aware-doc-hygiene/specs/determinism-promotion/spec.md b/plugins/os-doc-hygiene/openspec/changes/archive/2026-07-15-lifecycle-aware-doc-hygiene/specs/determinism-promotion/spec.md
similarity index 100%
rename from plugins/os-doc-hygiene/openspec/changes/lifecycle-aware-doc-hygiene/specs/determinism-promotion/spec.md
rename to plugins/os-doc-hygiene/openspec/changes/archive/2026-07-15-lifecycle-aware-doc-hygiene/specs/determinism-promotion/spec.md
diff --git a/plugins/os-doc-hygiene/openspec/changes/lifecycle-aware-doc-hygiene/specs/doc-check/spec.md b/plugins/os-doc-hygiene/openspec/changes/archive/2026-07-15-lifecycle-aware-doc-hygiene/specs/doc-check/spec.md
similarity index 100%
rename from plugins/os-doc-hygiene/openspec/changes/lifecycle-aware-doc-hygiene/specs/doc-check/spec.md
rename to plugins/os-doc-hygiene/openspec/changes/archive/2026-07-15-lifecycle-aware-doc-hygiene/specs/doc-check/spec.md
diff --git a/plugins/os-doc-hygiene/openspec/changes/lifecycle-aware-doc-hygiene/specs/doc-clean/spec.md b/plugins/os-doc-hygiene/openspec/changes/archive/2026-07-15-lifecycle-aware-doc-hygiene/specs/doc-clean/spec.md
similarity index 100%
rename from plugins/os-doc-hygiene/openspec/changes/lifecycle-aware-doc-hygiene/specs/doc-clean/spec.md
rename to plugins/os-doc-hygiene/openspec/changes/archive/2026-07-15-lifecycle-aware-doc-hygiene/specs/doc-clean/spec.md
diff --git a/plugins/os-doc-hygiene/openspec/changes/lifecycle-aware-doc-hygiene/specs/lifecycle-deletion/spec.md b/plugins/os-doc-hygiene/openspec/changes/archive/2026-07-15-lifecycle-aware-doc-hygiene/specs/lifecycle-deletion/spec.md
similarity index 100%
rename from plugins/os-doc-hygiene/openspec/changes/lifecycle-aware-doc-hygiene/specs/lifecycle-deletion/spec.md
rename to plugins/os-doc-hygiene/openspec/changes/archive/2026-07-15-lifecycle-aware-doc-hygiene/specs/lifecycle-deletion/spec.md
diff --git a/plugins/os-doc-hygiene/openspec/changes/lifecycle-aware-doc-hygiene/specs/lifecycle-rulebook/spec.md b/plugins/os-doc-hygiene/openspec/changes/archive/2026-07-15-lifecycle-aware-doc-hygiene/specs/lifecycle-rulebook/spec.md
similarity index 100%
rename from plugins/os-doc-hygiene/openspec/changes/lifecycle-aware-doc-hygiene/specs/lifecycle-rulebook/spec.md
rename to plugins/os-doc-hygiene/openspec/changes/archive/2026-07-15-lifecycle-aware-doc-hygiene/specs/lifecycle-rulebook/spec.md
diff --git a/plugins/os-doc-hygiene/openspec/changes/lifecycle-aware-doc-hygiene/specs/report-schema/spec.md b/plugins/os-doc-hygiene/openspec/changes/archive/2026-07-15-lifecycle-aware-doc-hygiene/specs/report-schema/spec.md
similarity index 100%
rename from plugins/os-doc-hygiene/openspec/changes/lifecycle-aware-doc-hygiene/specs/report-schema/spec.md
rename to plugins/os-doc-hygiene/openspec/changes/archive/2026-07-15-lifecycle-aware-doc-hygiene/specs/report-schema/spec.md
diff --git a/plugins/os-doc-hygiene/openspec/changes/lifecycle-aware-doc-hygiene/tasks.md b/plugins/os-doc-hygiene/openspec/changes/archive/2026-07-15-lifecycle-aware-doc-hygiene/tasks.md
similarity index 100%
rename from plugins/os-doc-hygiene/openspec/changes/lifecycle-aware-doc-hygiene/tasks.md
rename to plugins/os-doc-hygiene/openspec/changes/archive/2026-07-15-lifecycle-aware-doc-hygiene/tasks.md
diff --git a/plugins/os-doc-hygiene/openspec/specs/calibrate/spec.md b/plugins/os-doc-hygiene/openspec/specs/calibrate/spec.md
new file mode 100644
index 0000000..b6d553c
--- /dev/null
+++ b/plugins/os-doc-hygiene/openspec/specs/calibrate/spec.md
@@ -0,0 +1,246 @@
+# Spec: calibrate
+
+## Purpose
+
+Defines the `:calibrate` skill, which proposes new lifecycle rulebook rules by
+clustering the pool of files the rulebook currently leaves unmatched, having a
+cheap model nominate generalizable glob patterns, and having a strong model
+judge and report those nominations to a human before any persistence.
+
+## Requirements
+
+### Requirement: Calibrate Clusters and Samples Over Unmatched Files
+
+The `:calibrate` skill SHALL run over the unmatched-files pool (unmatched =
+unmanaged, per `lifecycle-rulebook`) as its candidate pool. It SHALL cluster
+unmatched paths by shape before nominating any rule, so that a proposed rule
+is authored against a cluster of similar paths rather than a single file.
+
+#### Scenario: Calibrate operates only on the unmatched pool
+
+- **WHEN** `:calibrate` runs
+- **THEN** its candidate pool is exactly the set of files the rulebook currently leaves unmatched
+
+#### Scenario: Rules are proposed against clusters, not single files
+
+- **WHEN** `:calibrate` nominates a candidate rule
+- **THEN** the nomination is derived from a cluster of similar unmatched paths, not from one instance in isolation
+
+### Requirement: Cheap-Model Nomination Produces Patterns, Never Exact-Instance Globs
+
+For each sampled cluster, `:calibrate` SHALL dispatch a haiku subagent
+constrained to nominate a bare glob pattern plus a candidate lifetime. The
+haiku nomination SHALL be constrained to produce generalizable patterns; it
+SHALL NOT be accepted as final if it hardcodes an identifier unique to a
+single instance (a run-id, hash, or bare timestamp).
+
+#### Scenario: Haiku nominates a glob and lifetime per cluster
+
+- **WHEN** a cluster of unmatched paths is sampled
+- **THEN** the haiku subagent returns a bare glob pattern and a candidate lifetime for that cluster
+
+#### Scenario: Instance-unique nominations are not accepted as final
+
+- **WHEN** a haiku nomination's glob hardcodes a run-id, hash, or bare timestamp unique to one file
+- **THEN** it is not persisted as-is; it must be caught and generalized before the strong-model judgment step, per the rule-quality tests below
+
+### Requirement: Strong-Model Batched Judgment with Confirm/Reject/Amend/Consult
+
+`:calibrate` SHALL dispatch one batched strong-model (Opus or Fable) judge
+subagent that gathers its own evidence (re-reading matched paths and
+checking near-miss boundaries) and authors final rule entries. The judge
+SHALL return one of four verdicts per nominated rule: `confirm`, `reject`,
+`amend`, or `consult`. `consult` SHALL be mandatory whenever an artifact's
+purpose is unclear (i.e., the judge cannot determine whether the artifact is
+regenerable or must be retained).
+
+#### Scenario: Judge gathers its own evidence rather than trusting the nomination
+
+- **WHEN** the strong-model judge evaluates a haiku nomination
+- **THEN** it independently re-reads matched paths and checks near-miss boundaries rather than accepting the nomination's claims at face value
+
+#### Scenario: Four verdicts are the only possible outcomes
+
+- **WHEN** the judge evaluates a nominated rule
+- **THEN** its verdict is exactly one of `confirm`, `reject`, `amend`, or `consult`
+
+#### Scenario: Consult is mandatory when purpose is unclear
+
+- **WHEN** the judge cannot determine whether a clustered artifact type is regenerable or must be retained
+- **THEN** the verdict is `consult`, never `confirm` or `reject`
+
+### Requirement: Rule Report to the Human Before Persistence
+
+Before any proposed rule is persisted, `:calibrate` SHALL present a rule
+report to the human containing, per proposed rule: the glob verbatim exactly
+as it would be persisted; every path it currently matches (or a capped
+sample plus a total count); the near-miss boundary — paths that do NOT match
+despite looking similar; the lifetime and behavior tier (auto vs confirm);
+and a plain-language explanation of what the artifact is and why it is
+clutter. No rule SHALL be persisted before this report has been shown.
+
+#### Scenario: The report shows the exact persisted glob
+
+- **WHEN** a rule report is generated for a proposed rule
+- **THEN** the glob shown is character-for-character identical to what would be written to the rulebook file
+
+#### Scenario: The report shows current matches with a capped sample
+
+- **WHEN** a proposed rule matches more paths than the display cap
+- **THEN** the report shows a capped sample of matched paths plus the total count of all matches
+
+#### Scenario: The report shows the near-miss boundary
+
+- **WHEN** a proposed rule's glob narrowly excludes similar-looking paths
+- **THEN** the report explicitly lists those near-miss non-matching paths, so a boundary bug (e.g. a glob silently missing a sibling path) is visible before persistence
+
+#### Scenario: No rule is persisted before the report is shown
+
+- **WHEN** `:calibrate` has generated proposed rules
+- **THEN** it does not write any rule to a rulebook file until the human has seen the rule report for it
+
+### Requirement: Persistence Rules by Scope
+
+Project-rulebook writes SHALL land on judge confirmation once the human has
+reviewed the rule report. Global-rulebook writes (writing into
+`plugins/os-doc-hygiene/rulebook.json`) SHALL additionally require explicit
+human gating, distinct from project-rule confirmation, since it is a
+cross-repo write into cc-os. Rule removals SHALL be HITL-only in all cases,
+with recorded reasoning, regardless of scope.
+
+#### Scenario: Project rule persists on judge confirmation plus report review
+
+- **WHEN** the judge verdict is `confirm` for a project-scoped rule and the human has reviewed its rule report
+- **THEN** the rule is written to the project's `.dochygiene-rules.json`
+
+#### Scenario: Global rulebook writes require an additional explicit gate
+
+- **WHEN** a proposed rule would be written to the global `rulebook.json`
+- **THEN** a distinct human confirmation for the cross-repo write is required, beyond the project-rule confirmation step
+
+#### Scenario: Rule removal is always HITL-only
+
+- **WHEN** any rule (project or global) is proposed for removal
+- **THEN** the removal happens only via explicit human instruction, with the reasoning recorded, never as an automatic side effect of a calibration pass
+
+### Requirement: Retest Loop with Stop Conditions and Hard Cap
+
+`:calibrate` SHALL re-run its clustering pass against the shrunk unmatched
+pool after each round of persisted rules. It SHALL stop when a round yields
+fewer than 2 new rules OR shrinks the unmatched pool by less than 10%. It
+SHALL hard-cap at 3 rounds regardless of shrink rate.
+
+#### Scenario: Stops on fewer than 2 new rules
+
+- **WHEN** a retest round yields only 1 new confirmed rule
+- **THEN** the retest loop stops after that round
+
+#### Scenario: Stops on less than 10% shrink
+
+- **WHEN** a retest round shrinks the unmatched pool by less than 10%
+- **THEN** the retest loop stops after that round, even if 2 or more rules were confirmed
+
+#### Scenario: Hard cap of 3 rounds regardless of shrink
+
+- **WHEN** three retest rounds have run and each still meets the continuation criteria (≥2 new rules and ≥10% shrink)
+- **THEN** the retest loop stops after the third round regardless
+
+### Requirement: Seed Intake at Judge Intake
+
+The clutter-inventory seed candidates SHALL enter the calibration protocol at
+judge intake. Full seed intake SHALL apply to every calibration run after
+calibration pass #1; pass #1 uses the one-off seed hold-out described in the
+`Calibration Pass Validation Criteria` requirement below.
+
+#### Scenario: Seed candidates feed the judge step
+
+- **WHEN** a calibration run (other than pass #1) begins
+- **THEN** the clutter-inventory seed candidates are included as judge-intake evidence
+
+### Requirement: Rule-Quality Test — Class Never Path
+
+A proposed rule's glob SHALL name a recurring class of artifact, never an
+identifier unique to a single instance. A glob that hardcodes a name
+recurring by convention (e.g. `PRD.md`, `HANDOFF-*.md`,
+`migration-report.md`) is acceptable. A glob that hardcodes a run-id, hash,
+or bare timestamp unique to one instance is not acceptable. A rule that
+currently matches only one file is acceptable; a rule that can, by
+construction, only ever match one file is a failed generalization and SHALL
+be flagged loudly rather than silently persisted.
+
+#### Scenario: A convention-recurring name is acceptable
+
+- **WHEN** a proposed rule's glob is `HANDOFF-*.md`
+- **THEN** it passes the class-never-path test, since `HANDOFF-*` is a recurring naming convention, not a single instance
+
+#### Scenario: An instance-unique identifier fails the test
+
+- **WHEN** a proposed rule's glob hardcodes a specific run-id or hash string that can only ever identify one artifact
+- **THEN** the rule fails the class-never-path test and is flagged loudly, not silently persisted
+
+#### Scenario: One current match is fine; one-EVER match is not
+
+- **WHEN** a proposed rule currently matches exactly one file
+- **THEN** it is acceptable if the glob's structure could match future similarly-named files; it is flagged as a failed generalization if the glob's structure can never match any file but the one it names today
+
+### Requirement: Rule-Quality Tie-Breaker — Prefer the Narrower Glob
+
+When choosing between candidate globs of differing breadth for the same
+cluster, `:calibrate` SHALL prefer the narrower glob. Too-narrow failure is
+self-healing (clutter is merely left for a later round to catch); too-broad
+failure is dangerous (a keeper file may be deleted and is not
+self-healing).
+
+#### Scenario: Narrower glob is chosen when both would satisfy the cluster
+
+- **WHEN** two candidate globs both cover the sampled cluster, one narrower and one broader
+- **THEN** the narrower glob is chosen
+
+#### Scenario: Rationale is evidence quality, not readability alone
+
+- **WHEN** justifying the narrower-glob preference
+- **THEN** the reasoning cited is that too-narrow fails safe (self-healing) while too-broad fails dangerous (not self-healing), not merely stylistic preference
+
+### Requirement: Calibration Pass Validation Criteria
+
+A calibration pass SHALL be judged against: a precision hard gate (the pass
+FAILS if any persisted rule's glob matches a protected path, regardless of
+behavior tier — exploration-time `consult` verdicts on protected paths are
+free and do not fail the pass); a recall floor of 8 of the 10 rows of the
+project's clutter inventory, with 4 specific rows mandatory (missing any
+mandatory row fails the pass); a one-off seed hold-out for calibration pass
+#1 only (the sealed answer key is withheld from judge intake for pass #1;
+every later run uses full seed intake); treatment of IGNORE-surface paths as
+void, not a miss, against the recall floor; and the requirement that a
+do-nothing pass cannot pass (the recall floor makes the pass falsifiable in
+the finding direction).
+
+#### Scenario: A rule matching a protected path fails the pass
+
+- **WHEN** any persisted rule's glob matches a path in the fixed protected set
+- **THEN** the calibration pass fails, regardless of whether that rule's tier was auto or confirm
+
+#### Scenario: Consult verdicts on protected paths do not fail the pass
+
+- **WHEN** the judge issues a `consult` verdict during exploration for a candidate touching a protected path, and that candidate is not persisted
+- **THEN** the pass is not failed by that consult verdict
+
+#### Scenario: Recall floor requires 8 of 10 with 4 mandatory
+
+- **WHEN** a calibration pass is graded against the clutter inventory
+- **THEN** it must recall at least 8 of the 10 inventory rows, and all 4 mandatory rows must be among them, or the pass fails
+
+#### Scenario: Pass #1 seed hold-out is a one-off
+
+- **WHEN** calibration pass #1 runs
+- **THEN** the sealed answer key (the project's clutter-inventory rows) is withheld from judge intake; every subsequent calibration run instead uses full seed intake
+
+#### Scenario: IGNORE-surface rows are void, not a miss
+
+- **WHEN** grading recall against the clutter inventory and a row corresponds to an IGNORE-surface path
+- **THEN** that row is excluded from the recall calculation entirely (void), not counted as a miss
+
+#### Scenario: A do-nothing pass cannot pass
+
+- **WHEN** a calibration pass persists zero rules
+- **THEN** it fails the recall floor and therefore cannot pass
diff --git a/plugins/os-doc-hygiene/openspec/specs/determinism-promotion/spec.md b/plugins/os-doc-hygiene/openspec/specs/determinism-promotion/spec.md
new file mode 100644
index 0000000..fb502d3
--- /dev/null
+++ b/plugins/os-doc-hygiene/openspec/specs/determinism-promotion/spec.md
@@ -0,0 +1,106 @@
+# Spec: determinism-promotion
+
+## Purpose
+
+Defines the global, machine-readable completion-conventions catalog and the
+deterministic pipeline steps that use it to recommend graduating a
+classifier-judged (`served_when`) lifecycle rule to a scanner-provable
+(`served_when_path`) one, without ever applying an adoption unasked.
+
+## Requirements
+
+### Requirement: Conventions Catalog Is Global-Only and Machine-Readable
+
+The plugin SHALL ship a global-only, machine-readable completion-conventions
+catalog at `plugins/os-doc-hygiene/conventions.json`. There SHALL be no
+per-project override of this catalog — it only recommends; adoption of a
+convention lands in the project's own rulebook, not in a project-specific
+copy of the catalog.
+
+#### Scenario: The catalog is read from a single global location
+
+- **WHEN** the deterministic pipeline consults the conventions catalog
+- **THEN** it reads `plugins/os-doc-hygiene/conventions.json` and no project-level override file for it exists or is consulted
+
+#### Scenario: Adoption is a rulebook write, not a catalog write
+
+- **WHEN** a project adopts a convention
+- **THEN** the adoption is expressed as a graduated rule in the project's `.dochygiene-rules.json`, and the global catalog itself is unchanged
+
+### Requirement: Convention Entry Shape
+
+Each entry in the conventions catalog SHALL record: a name, a description of
+what the convention proves (i.e., what filesystem-observable condition
+counts as "done"), the `served_when_path` pattern or frontmatter template a
+rule graduates to when the convention is adopted, and a one-line human pitch
+for why adopting it is worthwhile.
+
+#### Scenario: An entry carries all required fields
+
+- **WHEN** a convention entry is read from the catalog
+- **THEN** it includes a name, a "what it proves" description, the graduation target (`served_when_path` pattern or frontmatter template), and a one-line pitch
+
+### Requirement: v1 Catalog Contains Exactly Two Conventions
+
+The v1 conventions catalog SHALL contain exactly two entries: `archive-bucket`
+(done = the file moved into a sibling `archive/` directory, graduating a rule
+to `served_when_path:
/archive/{name}`) and `status-frontmatter` (done =
+a `status: shipped|done` frontmatter key is present, with the file staying in
+place and the scanner reading the frontmatter). No other conventions SHALL be
+present in v1.
+
+#### Scenario: Exactly two conventions ship in v1
+
+- **WHEN** the v1 conventions catalog is loaded
+- **THEN** it contains exactly the `archive-bucket` and `status-frontmatter` entries and no others
+
+#### Scenario: archive-bucket graduates to a served_when_path
+
+- **WHEN** a project adopts `archive-bucket` for a rule
+- **THEN** the rule graduates from a classifier-judged `served_when` to a scanner-provable `served_when_path` pointing at the sibling `archive/` directory
+
+#### Scenario: status-frontmatter graduates via a frontmatter key
+
+- **WHEN** a project adopts `status-frontmatter` for a rule
+- **THEN** the rule graduates to a condition the scanner can check deterministically by reading a `status: shipped|done` frontmatter key, with the file remaining at its original path
+
+### Requirement: Check Names Promotion Candidates Deterministically in Every Report
+
+The `:check` skill's deterministic finalize pass SHALL, for every entry whose
+lifecycle signal is classifier-judged (`served_when` present, no
+`served_when_path`), check the conventions catalog for an applicable
+not-yet-adopted convention and, when one applies, emit an entry in the
+report's `promotion_candidates` section naming the convention and its
+one-line pitch. This check SHALL run without any model call.
+
+#### Scenario: A classifier-judged entry with an applicable convention is named
+
+- **WHEN** an entry uses `served_when` and the `archive-bucket` convention is not yet adopted for that rule
+- **THEN** the report's `promotion_candidates` section names `archive-bucket` for that entry with its one-line pitch
+
+#### Scenario: Promotion-candidate naming requires no model call
+
+- **WHEN** `report_builder.py` computes `promotion_candidates`
+- **THEN** it does so by reading `conventions.json` and the project's rulebook state directly, with no subagent dispatch
+
+#### Scenario: An already-adopted convention is not re-named
+
+- **WHEN** a rule has already graduated to a `served_when_path` matching a catalog convention
+- **THEN** that rule does not reappear in `promotion_candidates`
+
+### Requirement: Calibrate May Draft Adoption but Never Applies Unasked
+
+The `:calibrate` skill MAY draft the adoption of a catalog convention — the
+graduated rule plus the file moves or frontmatter additions the convention
+implies — and present it to the human for approval. It SHALL NEVER apply an
+adoption without explicit human confirmation.
+
+#### Scenario: Calibrate drafts a convention adoption for review
+
+- **WHEN** `:calibrate` identifies an unmatched pattern that would benefit from `archive-bucket`
+- **THEN** it drafts the graduated rule and the implied file moves and presents them to the human before any change is made
+
+#### Scenario: No adoption is applied without confirmation
+
+- **WHEN** a human has not yet confirmed a drafted adoption
+- **THEN** no rulebook write and no file move occurs
diff --git a/plugins/os-doc-hygiene/openspec/specs/doc-check/spec.md b/plugins/os-doc-hygiene/openspec/specs/doc-check/spec.md
index 972b655..d5f8af4 100644
--- a/plugins/os-doc-hygiene/openspec/specs/doc-check/spec.md
+++ b/plugins/os-doc-hygiene/openspec/specs/doc-check/spec.md
@@ -35,20 +35,25 @@ arguments or unknown arguments SHALL print usage plus the current status.
### Requirement: Check Skill Orchestrates Scan, Classification, and Report Writing
-The `check` skill SHALL orchestrate the check pipeline: run the deterministic
-scanner, dispatch a Sonnet subagent for judgment-only classification of the
-signal-bearing candidates, run the deterministic finalize pass, validate, write the
-report pair, and stamp `last_check`. The skill SHALL run all non-judgment steps as
-deterministic scripts with no model (invariant #6). Zero-signal shortlisted files
-SHALL be treated as presumptively cleared: they SHALL remain in the shortlist,
-produce no entries, and SHALL NOT be read by the model. A `--scope` argument SHALL
-narrow the scanner; a `--category` argument SHALL filter which entries are produced
-after classification; both SHALL be recorded in the human-report header.
+The `check` skill SHALL orchestrate the check pipeline: load the lifecycle
+rulebook (global plus any project override), run the deterministic scanner
+(consuming the rulebook so directory-rule matches prune the walk and
+lifecycle signals are attached per the `lifecycle-rulebook` spec), dispatch
+a Sonnet subagent for judgment-only classification of the signal-bearing
+candidates, run the deterministic finalize pass (which also computes
+`promotion_candidates` from `conventions.json`), validate, write the report
+pair, and stamp `last_check`. The skill SHALL run all non-judgment steps as
+deterministic scripts with no model (invariant #6). Zero-signal shortlisted
+files SHALL be treated as presumptively cleared: they SHALL remain in the
+shortlist, produce no entries, and SHALL NOT be read by the model. A
+`--scope` argument SHALL narrow the scanner; a `--category` argument SHALL
+filter which entries are produced after classification; both SHALL be
+recorded in the human-report header.
#### Scenario: Skill runs the full pipeline
- **WHEN** the `check` skill runs
-- **THEN** it scans (deterministic), classifies signal-bearing candidates (Sonnet), finalizes (deterministic), validates (deterministic), writes the report pair (deterministic), and stamps `last_check` (deterministic)
+- **THEN** it loads the rulebook, scans (deterministic, rulebook-aware), classifies signal-bearing candidates (Sonnet), finalizes (deterministic, including promotion candidates), validates (deterministic), writes the report pair (deterministic), and stamps `last_check` (deterministic)
#### Scenario: Zero-signal files are not read by the model
@@ -60,6 +65,51 @@ after classification; both SHALL be recorded in the human-report header.
- **WHEN** the user passes `--scope docs/**/*.md` and `--category bloat`
- **THEN** the scanner is narrowed by the scope, only `bloat` entries are produced after classification, and both the scope and the category are recorded in the human-report header
+#### Scenario: Rulebook load failure is a hard failure, not a silent skip
+
+- **WHEN** the rulebook loader hard-fails (unparseable JSON or unknown `schema_version` in either rulebook file)
+- **THEN** the check skill stops and reports the rulebook error before running the scanner, rather than proceeding with lifecycle signals silently disabled
+
+### Requirement: Scanner Consumes the Rulebook for Pruning and Lifecycle Signals
+
+The deterministic scanner SHALL consult the loaded rulebook during its walk.
+A directory-rule match (including IGNORE-surface entries) SHALL prune the
+walk beneath that directory per the `lifecycle-rulebook` spec. A file-rule
+match SHALL attach a lifecycle signal to that file's shortlist entry. These
+lifecycle signals SHALL flow into the classification subagent as a new
+signal class alongside the pre-existing stale/bloat signals, and MAY drive
+`op`/`op_type` selection toward `delete` or `extract-then-delete` per the
+`lifecycle-deletion` spec.
+
+#### Scenario: A directory-rule prune is reflected in the scan artifact
+
+- **WHEN** the scanner encounters a directory matching a directory rule
+- **THEN** the scan artifact reflects the prune (no files beneath it are in `files_scanned`), and, for non-IGNORE directory rules, exactly one aggregate shortlist entry appears for that directory
+
+#### Scenario: A file-rule lifecycle signal reaches the classifier
+
+- **WHEN** a file matches a file-rule with `lifetime: delete-once-served`
+- **THEN** the classification subagent receives the lifecycle signal (rule reference, lifetime, served_when/served_when_path) as part of that file's signals, verbatim, per the existing "signals are passed through verbatim" contract
+
+### Requirement: Report Gains a Promotion-Candidates Section
+
+The machine and human reports produced by `:check` SHALL include a
+`promotion_candidates` section (top-level, sibling to `entries`), populated
+deterministically by the finalize pass from `conventions.json` for every
+classifier-judged lifecycle entry with an applicable, not-yet-adopted
+convention. This section SHALL be present (possibly empty) on every run,
+including runs with no lifecycle entries.
+
+#### Scenario: A run with an applicable convention names it in both reports
+
+- **WHEN** a classifier-judged entry has an applicable, unadopted convention
+- **THEN** both the machine report's `promotion_candidates` array and the human report show the candidate with its one-line pitch
+
+#### Scenario: A run with no applicable conventions still has the section, empty
+
+- **WHEN** no classifier-judged entry has an applicable unadopted convention
+- **THEN** `promotion_candidates` is present as an empty array/section rather than omitted
+
### Requirement: Classification Subagent Returns Judgment-Only Proposals
The Sonnet classification subagent SHALL return, per signal-bearing candidate, a slim
diff --git a/plugins/os-doc-hygiene/openspec/specs/doc-clean/spec.md b/plugins/os-doc-hygiene/openspec/specs/doc-clean/spec.md
index 0d7c668..764bcc6 100644
--- a/plugins/os-doc-hygiene/openspec/specs/doc-clean/spec.md
+++ b/plugins/os-doc-hygiene/openspec/specs/doc-clean/spec.md
@@ -87,14 +87,19 @@ SHALL NOT attempt to compose or sequence incompatible ops in v1.
### Requirement: Safety-Tier Gating Before Any Mutation
-The clean skill SHALL partition report entries by safety tier (read from the report —
-never recomputed) into `auto` entries (applied without prompt) and `confirm` entries
-(escalated before any mutation). It SHALL present all `confirm`-tier entries as a
-single batch-confirm list showing path, category, op, token count, and rationale with
-per-entry opt-out, visually distinguishing irreversible `delete-range` entries from
-reversible entries. The approved set SHALL be all `auto` entries plus any
-user-approved `confirm` entries. The gate SHALL run identically under `sweep` — the
-`/os-doc-hygiene:sweep` convenience path SHALL NOT bypass invariant #7.
+The clean skill SHALL partition report entries by safety tier (read from the
+report — never recomputed) into `auto` entries (applied without prompt) and
+`confirm` entries (escalated before any mutation). It SHALL present all
+`confirm`-tier entries as a single batch-confirm list showing path, category,
+op, token count, and rationale with per-entry opt-out, visually
+distinguishing irreversible `delete-range`, `delete`, and
+`extract-then-delete` entries from reversible entries. The approved set
+SHALL be all `auto` entries plus any user-approved `confirm` entries. The
+gate SHALL run identically under `sweep` — the `/os-doc-hygiene:sweep`
+convenience path SHALL NOT bypass invariant #7. Regardless of the tier
+recorded in the report, any `delete` or `extract-then-delete` entry SHALL be
+re-verified at apply time per the `lifecycle-deletion` tier matrix (tracked
++ clean required for auto) before being applied without a prompt.
#### Scenario: auto entries apply without prompt
@@ -103,7 +108,7 @@ user-approved `confirm` entries. The gate SHALL run identically under `sweep`
#### Scenario: confirm entries escalate before any mutation
-- **WHEN** the report contains confirm-tier entries (delete-range or any generative op)
+- **WHEN** the report contains confirm-tier entries (delete-range, delete, extract-then-delete, or any generative op)
- **THEN** the clean skill presents a batch-confirm list before any file is modified, and applies only user-approved entries
#### Scenario: per-entry opt-out is respected
@@ -116,29 +121,41 @@ user-approved `confirm` entries. The gate SHALL run identically under `sweep`
- **WHEN** the user runs /os-doc-hygiene:sweep and the report contains confirm-tier entries
- **THEN** the confirm gate runs identically to a standalone /os-doc-hygiene:clean
+#### Scenario: A report-tier auto delete is downgraded if runtime state has changed
+
+- **WHEN** a `delete` entry was tiered `auto` in the report but the applier's runtime check finds the file is now dirty or untracked
+- **THEN** the applier does not apply it silently; it is skipped and reported for re-analysis, never trusted from the cached tier
+
### Requirement: Git-Safe Single Commit
-The clean skill SHALL produce exactly one git commit per run. Before any mutation it
-SHALL resolve the project root via `StateStore`, run `git status --porcelain`, and
-if the tree is dirty, SHALL automatically create a WIP checkpoint commit of the
-user's work before proceeding, so the cleanup commit remains exactly one. The cleanup
-commit SHALL be created via `git-context commit-apply --message-stdin` with a
-generated message summarizing auto/confirmed/skipped counts and op breakdown.
-Staging SHALL be precise: for non-move ops the skill calls `git add `
-from the applier result; for `move-to-archive` the applier calls `git mv` (staging
-both sides) and the skill SHALL NOT `git add` the destination path again. The skill
-SHALL NEVER use `git add -A` or `git add .`. If a hard failure occurs (applier
-exit 2, `git mv` fail, or write error), the skill SHALL roll back via
-`git restore`/`reset` to the pre-run baseline and abort with a structured error.
-Partial success (some file batches guard-skipped) SHALL NOT trigger rollback — the
-skill SHALL commit what applied and report skipped files. Untracked candidate docs
-SHALL be skipped and reported (tracked-files-only). `last_clean` SHALL be stamped
-to the commit instant, not the run-start instant.
+The clean skill SHALL produce exactly one git commit per run. Before any
+mutation it SHALL resolve the project root via `StateStore`, run
+`git status --porcelain`, and if the tree is dirty, SHALL automatically
+create a WIP checkpoint commit of the user's work before proceeding, so the
+cleanup commit remains exactly one. The cleanup commit SHALL be created via
+`git-context commit-apply --message-stdin` with a generated message
+summarizing auto/confirmed/skipped counts and op breakdown, including
+lifecycle delete/extract-then-delete counts when present. Staging SHALL be
+precise: for non-move, non-delete ops the skill calls `git add
+` from the applier result; for `move-to-archive` the applier
+calls `git mv` (staging both sides) and the skill SHALL NOT `git add` the
+destination path again; for `delete` and the delete half of
+`extract-then-delete` the applier calls `git rm` (staging the removal
+itself) and the skill SHALL NOT separately stage the removed path. The skill
+SHALL NEVER use `git add -A` or `git add .`. If a hard failure occurs
+(applier exit 2, `git mv`/`git rm` fail, or write error), the skill SHALL
+roll back via `git restore`/`reset` to the pre-run baseline and abort with a
+structured error. Partial success (some file batches guard-skipped) SHALL
+NOT trigger rollback — the skill SHALL commit what applied and report
+skipped files. Untracked candidate docs SHALL be skipped and reported
+(tracked-files-only) except where a lifecycle rule explicitly escalates an
+untracked delete to confirm and the user approves it. `last_clean` SHALL be
+stamped to the commit instant, not the run-start instant.
#### Scenario: Clean tree produces exactly one commit
- **WHEN** the user runs /os-doc-hygiene:clean with a clean git tree
-- **THEN** the run produces exactly one git commit containing all applied edits
+- **THEN** the run produces exactly one git commit containing all applied edits, including any lifecycle deletes
#### Scenario: Dirty tree gets a WIP checkpoint then one cleanup commit
@@ -152,7 +169,7 @@ to the commit instant, not the run-start instant.
#### Scenario: Hard failure triggers rollback
-- **WHEN** a write error or applier exit 2 occurs mid-run
+- **WHEN** a write error, applier exit 2, or a `git rm` failure occurs mid-run
- **THEN** the skill rolls back to the pre-run baseline and aborts with a structured error; no partial commit is created
#### Scenario: Partial success commits what applied
@@ -165,6 +182,50 @@ to the commit instant, not the run-start instant.
- **WHEN** the applier stages a move-to-archive via git mv (both source and dest)
- **THEN** the skill does not call git add on the destination path again
+#### Scenario: A lifecycle delete is not double-staged
+
+- **WHEN** the applier stages a `delete` via `git rm`
+- **THEN** the skill does not separately call `git add` or any other staging command on the removed path
+
+### Requirement: Applier Applies Delete and Extract-Then-Delete Under the Tier Matrix
+
+The patch applier SHALL support two new op kinds, `delete` and
+`extract-then-delete`. For both, immediately before applying, it SHALL
+re-run `git ls-files ` and a dirty check against that specific path —
+never trusting the cached report tier or rule claim — and SHALL apply the
+tier matrix from the `lifecycle-deletion` spec to decide whether the entry
+may proceed as `auto` or must be treated as `confirm` (already gated
+upstream by the clean skill). `delete` SHALL perform a `git rm` (recursive
+for a directory-rule aggregate entry) staged into the run's single hygiene
+commit. `extract-then-delete` SHALL first complete its generative extraction
+step (repo-durable via the existing live-read Sonnet distillation path
+writing into an ADR/CLAUDE.md/docs target, or cross-repo via
+`/os-vault:write`) and SHALL only perform the `git rm` once extraction has
+succeeded; both steps SHALL land in the same single hygiene commit, or, on
+extraction failure, neither SHALL be applied for that entry (skip, not a
+run-level hard failure, unless the failure matches an existing hard-failure
+trigger).
+
+#### Scenario: delete performs a true git rm at apply time
+
+- **WHEN** the applier applies a `delete` entry
+- **THEN** it re-verifies tracked/clean status via `git ls-files` and a dirty check, then performs a `git rm` staged into the single hygiene commit
+
+#### Scenario: extract-then-delete only deletes after extraction succeeds
+
+- **WHEN** the applier applies an `extract-then-delete` entry
+- **THEN** it completes the extraction write (ADR/CLAUDE.md/docs or vault) first, and performs the `git rm` only after that write succeeds
+
+#### Scenario: A failed extraction skips the delete for that entry
+
+- **WHEN** the extraction step of an `extract-then-delete` entry fails
+- **THEN** the delete is not applied for that entry, the entry is reported as skipped, and the run is not treated as a hard failure unless the failure matches an existing hard-failure trigger (applier exit 2, write error)
+
+#### Scenario: Directory-rule aggregate delete removes the whole directory
+
+- **WHEN** the applier applies a `delete` entry whose path is a directory-rule aggregate entry
+- **THEN** it performs a recursive `git rm` removing the entire matched directory in one operation staged into the single hygiene commit
+
### Requirement: Clean Skill Orchestration
The `clean` skill SHALL load the current report via `StateStore.read_report`
diff --git a/plugins/os-doc-hygiene/openspec/specs/lifecycle-deletion/spec.md b/plugins/os-doc-hygiene/openspec/specs/lifecycle-deletion/spec.md
new file mode 100644
index 0000000..2f0398e
--- /dev/null
+++ b/plugins/os-doc-hygiene/openspec/specs/lifecycle-deletion/spec.md
@@ -0,0 +1,181 @@
+# Spec: lifecycle-deletion
+
+## Purpose
+
+Defines true git deletion for lifecycle-managed artifacts: the deletion
+autonomy tier matrix (evidence quality and recoverability, not file type),
+temporary-tier retention semantics, age computation, the split between
+scanner-provable and classifier-judged served signals, and the extract
+modifier's routing through existing knowledge destinations.
+
+## Requirements
+
+### Requirement: Delete Is True Git Deletion in a Dedicated Hygiene Commit
+
+A lifecycle `delete` op SHALL perform a true `git rm` (file or, for a
+directory-rule aggregate entry, recursive directory removal) staged into the
+same single hygiene commit the run produces. There SHALL be no archive
+directory, graveyard branch, or other relocation of the deleted content —
+git history SHALL be the sole archive.
+
+#### Scenario: A deleted file is git-rm'd, not moved
+
+- **WHEN** a `delete` op is applied to a tracked file
+- **THEN** the file is removed via `git rm` and staged into the run's single hygiene commit, with no copy relocated anywhere in the working tree
+
+#### Scenario: A deleted directory-rule entry is removed recursively
+
+- **WHEN** a `delete` op targets a directory-rule aggregate entry
+- **THEN** the entire directory is removed via a recursive `git rm` staged into the same commit
+
+### Requirement: Deletion Autonomy Tier Matrix
+
+Deletion autonomy SHALL be determined by evidence quality and recoverability,
+not file type, per the following matrix:
+
+| Case | Behavior |
+|---|---|
+| IGNORE surface | never walked, never a delete candidate |
+| lifetime `keep` | scanned + reported, never deleted |
+| tracked + delete rule + clean worktree | auto |
+| tracked + delete rule + dirty worktree | confirm |
+| untracked + delete rule | confirm |
+| no rule match | unmanaged, never deleted |
+
+Tracked/clean status SHALL be verified at runtime via `git ls-files` plus a
+dirty check, and SHALL NEVER be trusted from the rule's own claim or from a
+cached report field.
+
+#### Scenario: Tracked and clean deletes automatically
+
+- **WHEN** a file matches a delete rule, is tracked, and the worktree for that path is clean
+- **THEN** the deletion proceeds without a confirmation prompt
+
+#### Scenario: Tracked but dirty requires confirmation
+
+- **WHEN** a file matches a delete rule, is tracked, but has uncommitted changes
+- **THEN** the deletion is escalated to confirm — an uncommitted diff would otherwise be lost with the file
+
+#### Scenario: Untracked requires confirmation
+
+- **WHEN** a file matches a delete rule but is untracked
+- **THEN** the deletion is escalated to confirm — there is no git history to recover it from
+
+#### Scenario: Runtime verification never trusts the rule
+
+- **WHEN** a delete op is about to be applied
+- **THEN** the applier re-checks tracked/clean status via `git ls-files` and a dirty check at that moment, regardless of what the report or rule previously claimed
+
+#### Scenario: No rule match is never deleted
+
+- **WHEN** a file has no lifecycle rule match
+- **THEN** it is never a candidate for deletion on lifecycle grounds
+
+### Requirement: Temporary Tier Retention Semantics
+
+The `temporary` lifetime SHALL use retain-recent-N plus age, not age alone.
+`retain_recent` (default 3) SHALL always keep the N most recent matching
+entries for a rule regardless of age. An entry ranked `retain_recent + 1` or
+older SHALL become eligible for deletion once it exceeds `max_age_days`
+(default 3). The retention unit SHALL be the rule's own match granularity: a
+file-rule's unit is the individual file; a directory-rule's unit is the
+matched directory as a whole (e.g., a run directory), never files nested
+inside one such matched directory.
+
+#### Scenario: The 3 newest entries are always kept regardless of age
+
+- **WHEN** a temporary rule has `retain_recent: 3` and five matching entries, the three newest exceeding `max_age_days`
+- **THEN** the three newest are kept and only the two oldest (ranked 4th and 5th) are eligible for deletion
+
+#### Scenario: An entry younger than max_age_days is kept even if not in the top N
+
+- **WHEN** a temporary rule's 4th-ranked entry is younger than `max_age_days`
+- **THEN** it is not deleted this run
+
+#### Scenario: Directory-rule retention unit is the whole directory
+
+- **WHEN** a directory rule matches `autoresearch//` entries
+- **THEN** retain-recent-N and age are computed per matched run directory as a whole, not per file within the newest run
+
+### Requirement: Temporary Tier Age Source
+
+Age for the temporary tier SHALL be computed from the git commit time of the
+path's most recent commit, falling back to filesystem mtime only when the
+path is untracked. There SHALL be no per-rule `age_source` override field.
+
+#### Scenario: Tracked file age comes from git commit time
+
+- **WHEN** age is computed for a tracked file matching a temporary rule
+- **THEN** the age is derived from that file's most recent commit time, not its filesystem mtime
+
+#### Scenario: Untracked file age falls back to mtime
+
+- **WHEN** age is computed for an untracked file matching a temporary rule
+- **THEN** the age is derived from the file's filesystem mtime
+
+#### Scenario: No per-rule age_source field exists
+
+- **WHEN** a rule in the rulebook is inspected
+- **THEN** it has no `age_source` field — the git-commit-time-with-mtime-fallback behavior is fixed, not configurable per rule
+
+### Requirement: Untracked Directory Entry Age Uses Directory Inode mtime
+
+For an untracked directory matched by a directory rule, age SHALL be
+computed from a single `stat()` of the directory inode itself, not a
+recursive walk computing the maximum mtime of its contents.
+
+#### Scenario: Directory age is one stat call, not a recursive scan
+
+- **WHEN** age is computed for an untracked directory matching a directory rule
+- **THEN** the computation reads only the directory inode's own mtime and does not recurse into or stat any file inside it
+
+### Requirement: delete-once-served Split by Evidence Quality
+
+The `delete-once-served` lifetime SHALL support two mutually exclusive served
+signals per rule: `served_when_path`, a deterministic path pattern the
+scanner itself can prove satisfied (e.g. a sibling archive directory
+existing), and `served_when`, free text describing a condition the
+classifier must judge. A rule with `served_when_path` satisfied by the
+scanner MAY be deleted under the autonomy tier matrix (i.e., auto when
+tracked+clean). A rule relying on `served_when` SHALL ALWAYS be forced to
+confirm, regardless of tracked/clean status, because it depends on a model
+judgment rather than a provable filesystem fact.
+
+#### Scenario: Scanner-proven served_when_path may auto-delete
+
+- **WHEN** a rule's `served_when_path` condition is satisfied by the filesystem and the matched path is tracked and clean
+- **THEN** the deletion may proceed automatically under the tier matrix
+
+#### Scenario: Classifier-judged served_when always forces confirm
+
+- **WHEN** a rule uses `served_when` (free text) and the classifier judges the condition met
+- **THEN** the deletion is always escalated to confirm, even if the matched path is tracked and clean
+
+#### Scenario: The LLM may propose but never silently destroy on served_when
+
+- **WHEN** the classifier judges a `served_when` condition satisfied
+- **THEN** it produces a proposal for a human to confirm; it never causes an unattended deletion
+
+### Requirement: Extract Modifier Routes Through Existing Knowledge Destinations Only
+
+The `extract` modifier on a deletion SHALL distill durable content before
+deleting, routing exclusively through the existing knowledge-routing
+destinations: repo-durable residue SHALL be written into an ADR, `CLAUDE.md`,
+or a `docs/` file; cross-repo lessons SHALL be written to the SecondBrain
+vault via `/os-vault:write`. No new destination (e.g., a "retired specs"
+directory) SHALL be introduced.
+
+#### Scenario: Repo-durable extraction targets ADR/CLAUDE.md/docs
+
+- **WHEN** an `extract-then-delete` op is classified as repo-durable
+- **THEN** the extracted content is written into an ADR, `CLAUDE.md`, or a `docs/` file, never a new bespoke location
+
+#### Scenario: Cross-repo extraction routes through /os-vault:write
+
+- **WHEN** an `extract-then-delete` op is classified as cross-repo
+- **THEN** the extracted content is written to the SecondBrain vault via `/os-vault:write`, and no other cross-repo destination is used
+
+#### Scenario: No new destination is introduced
+
+- **WHEN** extraction routing is implemented
+- **THEN** it reuses only the destinations named above; it does not create a new "retired" or "archive" content store
diff --git a/plugins/os-doc-hygiene/openspec/specs/lifecycle-rulebook/spec.md b/plugins/os-doc-hygiene/openspec/specs/lifecycle-rulebook/spec.md
new file mode 100644
index 0000000..37505ed
--- /dev/null
+++ b/plugins/os-doc-hygiene/openspec/specs/lifecycle-rulebook/spec.md
@@ -0,0 +1,224 @@
+# 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 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` 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
diff --git a/plugins/os-doc-hygiene/openspec/specs/report-schema/spec.md b/plugins/os-doc-hygiene/openspec/specs/report-schema/spec.md
index d9202c4..37b1e3d 100644
--- a/plugins/os-doc-hygiene/openspec/specs/report-schema/spec.md
+++ b/plugins/os-doc-hygiene/openspec/specs/report-schema/spec.md
@@ -14,19 +14,26 @@ explicit human approval.
The machine report SHALL be a single JSON object containing `schema_version`,
`tool_version`, `generated_at` (ISO-8601 UTC), a `scan` metadata object, a
-`shortlist` array, and an `entries` array. The `scan` object SHALL include
-`project_root`, `scope_globs`, `excluded_dirs`, and `files_scanned`. The
-`generated_at` timestamp SHALL be the check time that the clean step uses for
-its mtime guard.
+`shortlist` array, an `entries` array, and a `promotion_candidates` array.
+The `scan` object SHALL include `project_root`, `scope_globs`,
+`excluded_dirs`, and `files_scanned`. The `generated_at` timestamp SHALL be
+the check time that the clean step uses for its mtime guard. The
+`promotion_candidates` array SHALL be present (possibly empty) on every
+report and SHALL list, per candidate, the classifier-judged entry it applies
+to, the recommended `conventions.json` entry name, and its one-line pitch.
#### Scenario: Check writes a well-formed report
- **WHEN** the `check` skill completes a scan and classification pass
-- **THEN** it writes one JSON object with `schema_version`, `tool_version`, `generated_at`, `scan`, `shortlist`, and `entries`
+- **THEN** it writes one JSON object with `schema_version`, `tool_version`, `generated_at`, `scan`, `shortlist`, `entries`, and `promotion_candidates`
#### Scenario: Clean reads the check timestamp
- **WHEN** the `clean` skill loads a report
- **THEN** it reads `generated_at` and uses it as the reference time for the per-op mtime guard
+#### Scenario: promotion_candidates is always present, possibly empty
+- **WHEN** a check run has no classifier-judged entry with an applicable unadopted convention
+- **THEN** `promotion_candidates` is written as an empty array, not omitted
+
### Requirement: Shortlist Precedes Entries
The `shortlist` SHALL contain the project-root-relative paths the deterministic
@@ -108,17 +115,38 @@ validated deterministically; an entry that violates it is invalid.
### Requirement: Safety-Tier Is Derived Deterministically
-`deterministic` ops SHALL be exact edits the check pre-computes and the cleaner
-applies with no model. `generative` ops SHALL be prose transformations requiring
-a model at clean time. The `safety_tier` SHALL be **computed** by a deterministic
-script function `safety_tier(op_type, is_destructive, is_reversible)` and SHALL
-NOT be assigned by the model; the report records the computed value. The function
-SHALL return `confirm` when `op_type` is `generative`, OR when `is_destructive`
-is true, OR when `is_reversible` is false; and SHALL return `auto` only when the
-op is `deterministic` AND non-destructive AND reversible (hence objective). The
-function SHALL NEVER return `auto` for a `generative` op or for any destructive
-or irreversible op, so the model cannot violate invariant #7. `auto`-tier ops
-SHALL run without a prompt; `confirm`-tier ops SHALL be escalated for approval.
+`deterministic` ops SHALL be exact edits the check pre-computes and the
+cleaner applies with no model. `generative` ops SHALL be prose
+transformations requiring a model at clean time. The `safety_tier` SHALL be
+**computed** by a deterministic script function `derive_safety_tier(op_type,
+is_destructive, is_reversible, lifecycle=None)` and SHALL NOT be assigned by
+the model; the report records the computed value. This function remains the
+single source of truth for tier derivation across the whole report schema,
+including lifecycle ops — no second tier-deriving function or code path is
+introduced.
+
+For non-lifecycle ops, the function SHALL return `confirm` when `op_type` is
+`generative`, OR when `is_destructive` is true, OR when `is_reversible` is
+false; and SHALL return `auto` only when the op is `deterministic` AND
+non-destructive AND reversible (hence objective).
+
+For `delete` and `extract-then-delete` ops, the function SHALL additionally
+consult the `lifecycle` argument and SHALL return `auto` only when ALL of the
+following hold: the lifecycle evidence is scanner-proven (a satisfied
+`served_when_path`, or a temporary-tier retain-recent/age computation) AND
+the path is tracked AND the worktree at that path is clean at the time of
+derivation. Every other combination for a lifecycle op — a classifier-judged
+`served_when`, a dirty worktree, or an untracked path — SHALL derive to
+`confirm`, regardless of `is_destructive`/`is_reversible` inputs. The
+function SHALL NEVER return `auto` for a `generative` op, for any
+non-lifecycle destructive or irreversible op, or for any lifecycle op whose
+evidence is classifier-judged or whose git state is not tracked+clean, so
+the model cannot violate invariant #7.
+
+`auto`-tier ops SHALL run without a prompt; `confirm`-tier ops SHALL be
+escalated for approval; `delete`/`extract-then-delete` `auto` verdicts SHALL
+additionally be re-verified against live git state at apply time per the
+`lifecycle-deletion` spec before being applied without a prompt.
#### Scenario: Deterministic reversible op derives to auto
- **WHEN** an entry has `op_type` = `deterministic`, `is_destructive` = false, and `is_reversible` = true
@@ -133,9 +161,64 @@ SHALL run without a prompt; `confirm`-tier ops SHALL be escalated for approval.
- **THEN** the derived `safety_tier` is `confirm` and the op is delegated to a Sonnet subagent at clean time
#### Scenario: Function can never emit auto for a generative or destructive op
-- **WHEN** `safety_tier(op_type, is_destructive, is_reversible)` is evaluated for any input where `op_type` = `generative`, or `is_destructive` = true, or `is_reversible` = false
+- **WHEN** `derive_safety_tier(op_type, is_destructive, is_reversible, lifecycle)` is evaluated for any input where `op_type` = `generative`, or (for a non-lifecycle op) `is_destructive` = true, or `is_reversible` = false
- **THEN** the result is `confirm`, never `auto`
+#### Scenario: A lifecycle delete with scanner-proven evidence and tracked+clean state derives to auto
+- **WHEN** a `delete` entry's `lifecycle` argument shows a satisfied `served_when_path` and the path is tracked and clean
+- **THEN** the derived `safety_tier` is `auto`
+
+#### Scenario: A lifecycle delete with classifier-judged evidence always derives to confirm
+- **WHEN** a `delete` or `extract-then-delete` entry's `lifecycle` argument carries `served_when` (classifier-judged)
+- **THEN** the derived `safety_tier` is `confirm`, regardless of tracked/clean state
+
+#### Scenario: A lifecycle delete on a dirty or untracked path derives to confirm
+- **WHEN** a `delete` entry's evidence is scanner-proven but the path is dirty or untracked
+- **THEN** the derived `safety_tier` is `confirm`
+
+### Requirement: Lifecycle Signal Fields on Shortlist and Entries
+
+A shortlist entry or report entry carrying a lifecycle signal SHALL include a
+`lifecycle` object with `rule_ref` (identifying which rulebook rule matched),
+`lifetime` (`keep`, `temporary`, or `delete-once-served`), and exactly one of
+`served_when_path` (deterministic) or `served_when` (classifier-judged, free
+text), mirroring the rulebook rule's own fields. A directory-rule aggregate
+entry SHALL carry the same `lifecycle` object shape as a file entry, with its
+`path` set to the matched directory.
+
+#### Scenario: A file-rule match carries the lifecycle object
+
+- **WHEN** a file matches a `temporary`-lifetime file rule
+- **THEN** its shortlist/report entry includes a `lifecycle` object with `rule_ref`, `lifetime: "temporary"`, and no `served_when`/`served_when_path` (temporary entries are keyed on age, not a served signal)
+
+#### Scenario: A delete-once-served match carries exactly one served field
+
+- **WHEN** a file matches a `delete-once-served` rule using `served_when_path`
+- **THEN** its `lifecycle` object includes `served_when_path` and omits `served_when`, never both
+
+#### Scenario: A directory-rule aggregate entry carries the same shape
+
+- **WHEN** a directory rule produces one aggregate shortlist entry
+- **THEN** that entry's `lifecycle` object has the same fields as a file entry's, with `path` set to the directory
+
+### Requirement: Promotion-Candidates Section Schema
+
+The top-level `promotion_candidates` array SHALL contain, per candidate, the
+`path` of the classifier-judged entry it applies to, the recommended
+`conventions.json` entry `name`, and the convention's one-line `pitch`. This
+array SHALL be computed only by the deterministic finalize pass (never the
+model) and SHALL be empty, not omitted, when no candidate applies.
+
+#### Scenario: A promotion candidate references its source entry and convention
+
+- **WHEN** an entry with `served_when` has an applicable unadopted convention
+- **THEN** the `promotion_candidates` array includes an object with that entry's `path`, the convention `name`, and its `pitch`
+
+#### Scenario: promotion_candidates is model-free
+
+- **WHEN** the finalize pass computes `promotion_candidates`
+- **THEN** it does so without any subagent dispatch, using only `conventions.json` and the report's own lifecycle entries
+
### Requirement: Exact-Edit Presence Tied to Op-Type
An entry SHALL include an `exact_edit` object when, and only when, `op_type` is
@@ -159,11 +242,14 @@ refuse to apply it to a file changed since `generated_at`. Entries with
### Requirement: Exact-Edit Kind Is a Closed Enum
Every `exact_edit` SHALL carry a `kind` drawn from the closed set
-`delete-range`, `move-to-archive`, `insert-frontmatter`, `replace-text`, and
-`dedupe` — one per deterministic op family the PRD names. No other `kind` is
-permitted. Each `kind` SHALL carry its required sub-fields and SHALL have a fixed
-inherent `(is_destructive, is_reversible)` characterization that feeds the
-`safety_tier` derivation:
+`delete-range`, `move-to-archive`, `insert-frontmatter`, `replace-text`,
+`dedupe`, `delete`, and `extract-then-delete` — one per deterministic op
+family the PRD and the lifecycle-aware design name. No other `kind` is
+permitted. Each `kind` SHALL carry its required sub-fields and SHALL have a
+fixed inherent `(is_destructive, is_reversible)` characterization that feeds
+the `safety_tier` derivation, except where noted below that lifecycle kinds'
+tier also depends on runtime git state and evidence quality (see the
+Safety-Tier Is Derived Deterministically requirement):
- `delete-range` SHALL carry `anchor` with `start_line` and `end_line`; it is
destructive and irreversible (derives to `confirm`).
@@ -181,6 +267,17 @@ inherent `(is_destructive, is_reversible)` characterization that feeds the
is lost, so it is non-destructive and reversible and derives to `auto`. (Contrast
`delete-range`, which removes content kept nowhere else, is destructive, and
derives to `confirm`.)
+- `delete` SHALL carry a full-file (or, for a directory-rule aggregate entry,
+ whole-directory) `anchor` and a `lifecycle` object (`rule_ref`, `lifetime`,
+ and exactly one of `served_when_path` or `served_when`); it is destructive
+ (git history is the only recovery path) and its `is_reversible`
+ characterization is inherently git-history-dependent rather than fixed —
+ see the Safety-Tier requirement for how its tier is actually derived.
+- `extract-then-delete` SHALL carry the same `lifecycle` object as `delete`
+ plus an `extraction_target` classification (`repo-durable` or `cross-repo`)
+ and, for `repo-durable`, a target document reference; it has the same
+ destructive/tier characterization as `delete`, gated additionally on the
+ extraction step succeeding before the delete half applies.
A validator SHALL reject an `exact_edit` whose `kind` is outside the closed set
or that omits a required sub-field for its `kind`.
@@ -190,7 +287,7 @@ or that omits a required sub-field for its `kind`.
- **THEN** the `exact_edit` includes `anchor` (`start_line`, `end_line`) and `dest_path`, and the entry is valid
#### Scenario: Unknown kind is rejected
-- **WHEN** an `exact_edit` has a `kind` outside the closed set `delete-range`, `move-to-archive`, `insert-frontmatter`, `replace-text`, `dedupe`
+- **WHEN** an `exact_edit` has a `kind` outside the closed set `delete-range`, `move-to-archive`, `insert-frontmatter`, `replace-text`, `dedupe`, `delete`, `extract-then-delete`
- **THEN** the report is invalid
#### Scenario: Missing required sub-field is rejected
@@ -201,6 +298,15 @@ or that omits a required sub-field for its `kind`.
- **WHEN** an `exact_edit` has `kind` = `delete-range`
- **THEN** the entry's `is_destructive` is true and `is_reversible` is false, so the derived `safety_tier` is `confirm`
+#### Scenario: delete and extract-then-delete require the lifecycle object
+- **WHEN** an `exact_edit` has `kind` = `delete` or `kind` = `extract-then-delete`
+- **THEN** it includes a `lifecycle` object with `rule_ref`, `lifetime`, and exactly one of `served_when_path` or `served_when`; omitting the `lifecycle` object or supplying both `served_when_path` and `served_when` makes the report invalid
+
+#### Scenario: extract-then-delete requires an extraction_target
+
+- **WHEN** an `exact_edit` has `kind` = `extract-then-delete`
+- **THEN** it includes `extraction_target` set to `repo-durable` or `cross-repo`, with a target document reference required when `repo-durable`
+
### Requirement: Per-Entry Token Estimate
Each entry SHALL include a `token_estimate`. In v1, only `raw_tokens` (a