diff --git a/docs/implementation-status/os-doc-hygiene.md b/docs/implementation-status/os-doc-hygiene.md
index 52dc09d..7b0c539 100644
--- a/docs/implementation-status/os-doc-hygiene.md
+++ b/docs/implementation-status/os-doc-hygiene.md
@@ -43,3 +43,17 @@ _Leaf file of [../implementation-status.md](../implementation-status.md). Read o
all 4 mandatory rows; rule persistence to `.dochygiene-rules.json` is pending human
approval. Results: `plugins/os-doc-hygiene/openspec/changes/lifecycle-aware-doc-hygiene/calibration-pass-1-results.md`.
Design: `plugins/os-doc-hygiene/lifecycle-spec.md`. Suite: 407 passed.
+- **Calibrate assessment inventory implemented (2026-07-15, map #49 / ADR-0038-0039
+ amendments)** — the locked nominations-memory design is now code: `RulesFileWriter`
+ (single canonical serialization path for `.dochygiene-rules.json`; tier-grouped,
+ glob-sorted, idempotent, unknown fields warn-and-round-trip) and `NominationIntakeFilter`
+ (deterministic Step 3.5 — exact glob+lifetime repeats dropped, variants annotated via
+ shortlist match-set intersection, open consults always forwarded) in
+ `calibrate_helpers.py`; calibrate SKILL.md Steps 3.5/5/6 wired (Open-consults report
+ section with the three exits; keep verdicts, human declines, and consults all persist
+ through the writer); `patch_applier.py` extract-then-delete appends the `extracted.md`
+ vault pointer (keyed off `extraction_target: "cross-repo"` + a skill-supplied
+ `extraction_pointer`, fail-closed: no pointer → no delete); clean SKILL.md Step 6.5
+ supplies the pointer metadata. OpenSpec change:
+ `plugins/os-doc-hygiene/openspec/changes/calibrate-assessment-inventory/`. Suite: 434
+ passed (26 new unit tests across writer/intake-filter/applier seams).
diff --git a/plugins/os-doc-hygiene/openspec/changes/calibrate-assessment-inventory/.openspec.yaml b/plugins/os-doc-hygiene/openspec/changes/calibrate-assessment-inventory/.openspec.yaml
new file mode 100644
index 0000000..4f63482
--- /dev/null
+++ b/plugins/os-doc-hygiene/openspec/changes/calibrate-assessment-inventory/.openspec.yaml
@@ -0,0 +1,2 @@
+schema: spec-driven
+created: 2026-07-15
diff --git a/plugins/os-doc-hygiene/openspec/changes/calibrate-assessment-inventory/design.md b/plugins/os-doc-hygiene/openspec/changes/calibrate-assessment-inventory/design.md
new file mode 100644
index 0000000..f17a8c7
--- /dev/null
+++ b/plugins/os-doc-hygiene/openspec/changes/calibrate-assessment-inventory/design.md
@@ -0,0 +1,143 @@
+# Design: calibrate-assessment-inventory
+
+## Context
+
+Wayfinder map #49 produced a fully locked design, recorded in
+`plugins/os-doc-hygiene/lifecycle-spec.md` (§1 extract index, §2 nominations
+memory + canonical ordering, §8 protocol steps 3/6 + test coverage) and the
+2026-07-15 amendments to ADR-0038/0039, committed as `9f11df3`. This change
+implements it. There are no open design questions — where this document
+states a choice, it is restating the locked decision and its rationale, not
+making a new one.
+
+Current state: `:calibrate` persists only confirmed rules; keep verdicts,
+human declines, and open consults evaporate with the run's scratch dir and
+are re-derived every pass. `calibrate_helpers.py` has helper functions but no
+intake filter and no single serialization path for `.dochygiene-rules.json`.
+The `:clean` extract-then-delete op writes to the vault but leaves no in-repo
+pointer.
+
+## Goals / Non-Goals
+
+**Goals:**
+
+- Persist every settled judge verdict: answers as plain rules (keep included),
+ "no" as `nominations.rejected`, "not yet" as `nominations.consults`.
+- Deterministically block exact-repeat re-nominations and hand the judge its
+ history (related rejections + open consults) as context.
+- One canonical writer for `.dochygiene-rules.json` so every write is
+ ordering-stable and reviewable.
+- Leave a discoverable pointer (`extracted.md`) when an extraction physically
+ leaves the repo for the vault.
+
+**Non-Goals:**
+
+- No changes to `rulebook.py` semantics — it stays nomination-unaware
+ (contract: "which rule governs this path"). Only `calibrate_helpers` reads
+ the `nominations` key.
+- No consult resurfacing in `:check`/`:clean` (ADR-0039 boundary — calibrate
+ only).
+- No automated revisit of rejections or singleton keep rules — both exit by
+ hand-deletion only.
+- No new eval harness for the judge-context / consult-loop behavior (#59) —
+ worked examples + production IRL audits instead.
+- No post-write hook for ordering enforcement (rejected in #53).
+
+## Decisions
+
+All decided on map #49; restated with rationale:
+
+1. **Keep verdicts are plain `lifetime: keep` rules, not a separate store**
+ (#51). Matched = managed removes them from the calibrate pool and the glob
+ protects future files for free. Corollary: the class-never-path test is
+ relaxed for the keep tier only — a singleton keep merely protects, so
+ exact-path globs are allowed there and remain forbidden for
+ `temporary`/`delete-once-served`.
+2. **`nominations` holds only the two non-rule residues** (#52): `rejected`
+ ("the answer is no") and `consults` ("no answer yet"). It never touches the
+ file filter. Entry schemas per lifecycle-spec §2; consults deliberately
+ carry no lifetime; presence in `consults` = open, no status field.
+3. **Rejections block exact glob+lifetime repeats only** (#52). Variants flow
+ to the judge annotated with the related rejection — memory, not veto.
+ Relatedness is deterministic: the two globs' match sets intersect on the
+ current shortlist.
+4. **The intake filter is deterministic code, not a model step** (#52,
+ invariant #6): `NominationIntakeFilter` in `calibrate_helpers.py`, run as
+ pipeline Step 3.5 between haiku nomination and the judge; drops are logged
+ in the run summary.
+5. **Canonical ordering is writer-enforced, no hook** (#53). Grouping:
+ `rules` by tier (delete-once-served, temporary, keep), glob-sorted within
+ group; `nominations` after `rules`; `consults` before `rejected` (pending
+ -action queue reads first), each glob-sorted. Idempotent; hand edits
+ re-canonicalize on the next write. Unknown fields warn and round-trip
+ (never dropped).
+6. **Consult exits** (#56): (a) human answer settles purpose → normal rule
+ persisted, consult entry deleted; (b) not rule-worthy → rewritten into
+ `rejected` with `rejected_by: "human"`; (c) defer → stays, resurfaces next
+ run. New rejections/consults appear in the Step 5 report but are not
+ individually gated — they are memory, not deletion authority.
+7. **`extracted.md` append is owned by the extract op** (#57): distill →
+ `/os-vault:write` → append pointer line → `git rm`, all inside the same
+ per-file transaction and single hygiene commit — no window where the doc is
+ gone but undiscoverable. Vault extractions only; repo-durable residue
+ (ADR/CLAUDE.md) gets no index line. Self-protection is the already-shipped
+ global `**/extracted.md → keep` rule.
+8. **Test coverage** (#59): unit tests only, for the two deterministic seams
+ (intake filter; writer). The two model-facing behaviors get worked examples
+ (already landed in judge.md / SKILL.md with the design commit) and IRL
+ audits as the next signal.
+
+## Implementation shape
+
+- `scripts/calibrate_helpers.py`: add `NominationIntakeFilter` (small
+ single-responsibility class, injected shortlist/rules-file data, returns
+ `(survivors_with_annotations, dropped_log)`) and a `RulesFileWriter` (or
+ extend the existing write path) that owns *all* serialization of
+ `.dochygiene-rules.json`, including nominations read/validate/warn.
+- `skills/calibrate/SKILL.md` + workflow files: wire Step 3.5 (run the filter,
+ feed its annotations + open consults into the judge prompt's existing
+ "Nominations memory" section), Step 5 ("Open consults" report section,
+ consult exits a/b/c), Step 6 (persist keep verdicts / rejections / consults
+ through the canonical writer, dedupe consults by glob at write time). The
+ SKILL.md consult-loop worked example loses its "design-level pending
+ wiring" marker.
+- `scripts/patch_applier.py`: extend the `extract-then-delete` path — when the
+ extraction destination is the vault, append the pointer entry (format per
+ lifecycle-spec §1) to `
/extracted.md` (create if absent) and stage it
+ in the same hygiene commit; on extraction failure, neither append nor
+ delete applies (existing skip semantics).
+- `tests/`: extend `test_calibrate_helpers.py` (or add
+ `test_nomination_intake.py` / writer tests) covering exact-repeat drops,
+ the two #52 worked relatedness cases, round-trip canonical ordering, and
+ unknown-field warnings; patch-applier tests cover the append-then-delete
+ atomicity and the no-append-on-repo-durable-extraction case.
+
+## Risks / Trade-offs
+
+- [Nominations grow without bound — stale rejections accumulate] → accepted
+ by design: exits are hand-deletion only (removals stay HITL); the canonical
+ ordering keeps the file reviewable, and entries are small.
+- [Judge treats a related rejection as a veto and under-nominates] → the
+ judge.md "Nominations memory" section states variants-are-not-vetoes with a
+ worked example; IRL session audits are the detection path (#59), and IRL
+ evidence triggers harness design per eval discipline.
+- [Writer round-trips unknown fields but a hand-edit introduces malformed
+ JSON] → existing skip-and-warn loader discipline applies; the writer only
+ runs on an already-parsed structure.
+- [extracted.md append fails after a successful vault write] → the per-file
+ transaction skips the delete too, so the doc is never gone-but-unindexed;
+ the vault note is orphaned-but-harmless and the next run retries.
+
+## Migration Plan
+
+No data migration: `nominations` is additive to `schema_version` 1 and the v1
+loader ignores unknown top-level keys. Existing `.dochygiene-rules.json`
+files re-canonicalize on their first post-change write. Rollback is code-only
+(revert the change; `nominations` keys left in project files are inert).
+After implementation: full test suite green (407 existing tests + new), then
+`bin/refresh-plugins`.
+
+## Open Questions
+
+None — the design is locked (map #49); ambiguities found during
+implementation go back to `lifecycle-spec.md` as the source of truth.
diff --git a/plugins/os-doc-hygiene/openspec/changes/calibrate-assessment-inventory/proposal.md b/plugins/os-doc-hygiene/openspec/changes/calibrate-assessment-inventory/proposal.md
new file mode 100644
index 0000000..26fc427
--- /dev/null
+++ b/plugins/os-doc-hygiene/openspec/changes/calibrate-assessment-inventory/proposal.md
@@ -0,0 +1,34 @@
+# Proposal: calibrate-assessment-inventory
+
+## Why
+
+Wayfinder map #49 locked the design (in `lifecycle-spec.md`, committed 2026-07-15) for persisting the *full* `:calibrate` assessment inventory — today only confirmed rules survive a calibration pass, so keep verdicts, human declines, and open consults evaporate and get re-litigated every pass. The design also fixed two judge-quality defects observed in calibration pass #1 (widening to container globs; no memory of prior rejections). This change implements that locked design; every decision is already made — see tickets #51–#57, #59 on the map.
+
+## What Changes
+
+- **Nominations memory**: the project rules file (`.dochygiene-rules.json`) gains a top-level `nominations` key with `consults` and `rejected` sub-keys (additive to `schema_version` 1). `rulebook.py` stays nomination-unaware.
+- **`NominationIntakeFilter`** (new class in `calibrate_helpers.py`): deterministic Step 3.5 of the calibrate pipeline — drops exact glob+lifetime repeats of recorded rejections, annotates related (match-set-intersecting) history for the judge.
+- **Canonical-writer extension** (`calibrate_helpers.py`): all rules-file writes go through one writer enforcing canonical ordering (rules grouped delete-once-served / temporary / keep, glob-sorted; `nominations` after `rules`, `consults` before `rejected`; unknown fields warned, round-tripped). No hook enforcement.
+- **Pipeline wiring**: Step 3.5 (intake filter) before the judge; Step 5 report gains an "Open consults" section; Step 6 persistence extended — keep verdicts persist as plain keep rules (incl. exact-path singletons, per the keep-tier relaxation), human declines persist as `rejected` entries, consult verdicts dedupe into `consults`.
+- **Extract-op append**: `:clean`'s extract-then-delete op appends a pointer entry to the per-directory `extracted.md` index, atomically within the single hygiene commit (the global `**/extracted.md → keep` rule is already in `rulebook.json`).
+- **Two unit-test suites** (no new eval harnesses, per ticket #59): intake filter (exact-repeat drops, relatedness annotations incl. the two #52 worked cases) and writer (round-trip ordering, unknown-field warnings).
+
+## Capabilities
+
+### New Capabilities
+
+(none — every change extends an existing capability)
+
+### Modified Capabilities
+
+- `lifecycle-rulebook`: envelope gains the optional project-file-only `nominations` key (consults/rejected schemas); writes are writer-enforced into canonical ordering; loader remains nomination-unaware and add-only merge is unchanged.
+- `calibrate`: new deterministic intake-filter requirement (Step 3.5); Persistence Rules by Scope extended to keep verdicts / rejections / consults; judge intake gains nominations-memory context (rejections are variants-not-vetoes, open consults must resurface); Rule-Quality Class-Never-Path gains the keep-tier relaxation and the enumerate-siblings-never-widen-to-container test.
+- `doc-clean`: the extract-then-delete op additionally appends the `extracted.md` pointer entry in the same per-file transaction / single hygiene commit.
+
+## Impact
+
+- **Code**: `scripts/calibrate_helpers.py` (NominationIntakeFilter + canonical writer), `skills/calibrate/SKILL.md` + `workflows/*.md` (Step 3.5/5/6 wiring — judge.md context sections already landed with the design commit), `scripts/patch_applier.py` (extract-op append).
+- **Tests**: `tests/test_calibrate_helpers.py` extended (or split) for the two suites; existing 407-test suite must stay green.
+- **Data**: `.dochygiene-rules.json` files may now carry `nominations`; older readers unaffected (additive, `rulebook.py` ignores it).
+- **Source of truth**: `plugins/os-doc-hygiene/lifecycle-spec.md` §1 (extract index), §2 (nominations memory, canonical ordering), §8 (steps 3/6, test coverage); ADR-0038/0039 amendments 2026-07-15.
+- **Post-implementation**: run `bin/refresh-plugins`.
diff --git a/plugins/os-doc-hygiene/openspec/changes/calibrate-assessment-inventory/specs/calibrate/spec.md b/plugins/os-doc-hygiene/openspec/changes/calibrate-assessment-inventory/specs/calibrate/spec.md
new file mode 100644
index 0000000..63de5bb
--- /dev/null
+++ b/plugins/os-doc-hygiene/openspec/changes/calibrate-assessment-inventory/specs/calibrate/spec.md
@@ -0,0 +1,151 @@
+# Delta: calibrate (calibrate-assessment-inventory)
+
+## ADDED Requirements
+
+### Requirement: Deterministic Nomination Intake Filter
+
+Between cheap-model nomination and strong-model judgment, `:calibrate` SHALL
+run a deterministic `NominationIntakeFilter` (in `calibrate_helpers.py`, no
+model — invariant #6). A nomination whose glob+lifetime exactly equals a
+`rejected` entry SHALL be dropped before the judge and logged in the run
+summary. Surviving nominations SHALL be annotated with every related
+rejection, where related means the two globs' match sets intersect on the
+current shortlist (deterministic, computed from the scan). The annotations
+plus all open consults SHALL enter the judge prompt as its "Nominations
+memory" input section; related rejections are context for the judge, never a
+veto.
+
+#### Scenario: Exact glob+lifetime repeat is dropped before the judge
+
+- **WHEN** haiku nominates `docs/research/** -> temporary` and `nominations.rejected` contains an entry with glob `docs/research/**` and lifetime `temporary`
+- **THEN** the nomination is dropped at intake, never reaches the judge, and the drop is logged in the run summary
+
+#### Scenario: A variant flows through annotated, not blocked
+
+- **WHEN** haiku nominates `docs/research/drafts/** -> temporary` and `nominations.rejected` contains `docs/research/** -> temporary`, and the two globs' match sets intersect on the current shortlist
+- **THEN** the nomination proceeds to the judge carrying the related rejection (its why and consider_instead) as context, and the judge may still confirm it
+
+#### Scenario: Open consults always reach the judge prompt
+
+- **WHEN** `nominations.consults` is non-empty at intake time
+- **THEN** every open consult is included in the judge prompt's "Nominations memory" section, regardless of what haiku nominated this round
+
+### Requirement: Consult Persistence and Resurfacing
+
+Open `consult` verdicts SHALL persist to `nominations.consults` (deduped by
+glob at write time) rather than dying with the run. Consults SHALL resurface
+in `:calibrate` only — `:check` and `:clean` are unchanged — appearing in the
+judge prompt and as an "Open consults" section of the rule report. A consult
+SHALL exit in exactly one of three ways: (a) a human answer settles the
+purpose — a normal rule is persisted and the consult entry deleted; (b) the
+human deems it not rule-worthy — the entry is rewritten into `rejected` with
+`rejected_by: "human"` and the human's why; (c) the human defers — the entry
+stays and resurfaces next run. New rejections and consults SHALL appear in
+the rule report but are not individually gated — they are memory, not
+deletion authority.
+
+#### Scenario: A consult survives the run and resurfaces
+
+- **WHEN** a judge verdict is `consult` and the run ends without a human answer
+- **THEN** the consult is written to `nominations.consults`, and the next `:calibrate` run surfaces it in both the judge prompt and the report's "Open consults" section
+
+#### Scenario: An answered consult becomes a rule and disappears
+
+- **WHEN** the human answers an open consult in a way that settles the artifact's purpose
+- **THEN** a normal rule is persisted through the standard report flow and the consult entry is deleted in the same write
+
+#### Scenario: A declined consult becomes a human rejection
+
+- **WHEN** the human answers that an open consult's artifact class is not rule-worthy
+- **THEN** the consult entry is rewritten into `nominations.rejected` with `rejected_by: "human"` and the stated reason
+
+#### Scenario: Consults never surface outside calibrate
+
+- **WHEN** `:check` or `:clean` runs against a project with open consults
+- **THEN** their behavior is unchanged — consults neither appear in output nor affect any classification
+
+## MODIFIED Requirements
+
+### 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. Every settled verdict SHALL
+persist: judge `keep` verdicts become ordinary `lifetime: keep` rules in
+`rules` (including exact-path singletons, per the keep-tier relaxation);
+human declines at the rule report persist as `rejected` entries with
+`rejected_by: "human"`; open `consult` verdicts persist to
+`nominations.consults`, deduped by glob at write time. All persistence SHALL
+go through the canonical rules-file writer.
+
+#### 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
+
+#### Scenario: A keep verdict persists as a plain keep rule
+
+- **WHEN** the judge's settled verdict for a cluster is that the artifacts must be retained
+- **THEN** an ordinary `lifetime: keep` rule is persisted to `rules` — removing the cluster from future calibrate pools and protecting future matches — even when the glob is an exact-path singleton
+
+#### Scenario: A human decline persists as a rejection
+
+- **WHEN** the human declines a judge-confirmed rule at the rule report
+- **THEN** a `rejected` entry with `rejected_by: "human"` is written, so a later haiku round cannot re-nominate the identical glob+lifetime without the judge knowing
+
+### 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. **Keep-tier relaxation:**
+exact-path/instance globs ARE acceptable for `lifetime: keep` entries only —
+this test exists to prevent bad deletion rules, and a singleton keep merely
+protects; instance globs remain forbidden for `temporary` and
+`delete-once-served`. When the near-miss boundary check reveals sibling
+artifacts a glob misses, the fix SHALL be to enumerate the conventional
+prefixes as separate rule entries, never to widen the glob to the containing
+directory; container-claiming globs are justified ONLY when the directory is
+wholly machine-owned (e.g. `plugins/*/.pytest_cache/`).
+
+#### 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
+
+#### Scenario: A singleton keep passes under the keep-tier relaxation
+
+- **WHEN** a proposed rule is `docs/research/clutter-pattern-inventory.md -> keep`
+- **THEN** it passes despite being an exact-path instance glob, because the keep tier only protects; the same glob with lifetime `temporary` or `delete-once-served` fails
+
+#### Scenario: Missed siblings are enumerated, never widened to the container
+
+- **WHEN** the boundary check shows `autoresearch/classic-*/` misses sibling runs under `autoresearch/improve-*/`
+- **THEN** the fix is a second rule entry `autoresearch/improve-*/` (same cluster), never a widening to `autoresearch/*/` or `autoresearch/**`, so keep-worthy content can still live in the container without a counter-rule
diff --git a/plugins/os-doc-hygiene/openspec/changes/calibrate-assessment-inventory/specs/doc-clean/spec.md b/plugins/os-doc-hygiene/openspec/changes/calibrate-assessment-inventory/specs/doc-clean/spec.md
new file mode 100644
index 0000000..ee12862
--- /dev/null
+++ b/plugins/os-doc-hygiene/openspec/changes/calibrate-assessment-inventory/specs/doc-clean/spec.md
@@ -0,0 +1,66 @@
+# Delta: doc-clean (calibrate-assessment-inventory)
+
+## MODIFIED Requirements
+
+### 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). When the extraction destination is the vault (the content
+physically leaves the repo), the op SHALL additionally append a pointer
+entry to the deleted file's per-directory `extracted.md` index (creating the
+file if absent), in the same atomic sequence — distill → `/os-vault:write` →
+append the pointer line → `git rm` — all staged into the same single hygiene
+commit, so there is no window where the doc is gone but undiscoverable. The
+pointer entry SHALL name the vault note, state why a future reader would
+follow it, and record the source filename and date. Repo-durable extraction
+targets (ADR, CLAUDE.md, docs) SHALL NOT produce an index entry — they are
+already discoverable in-repo.
+
+#### 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
+
+#### Scenario: A vault extraction leaves an extracted.md pointer in the same commit
+
+- **WHEN** an `extract-then-delete` entry extracts to the vault via `/os-vault:write`
+- **THEN** a pointer entry naming the vault note, the reason to follow it, the source filename, and the date is appended to the deleted file's directory `extracted.md` (created if absent), and the append, the deletion, and the index file are all staged into the same single hygiene commit
+
+#### Scenario: A repo-durable extraction leaves no index entry
+
+- **WHEN** an `extract-then-delete` entry extracts into an ADR, CLAUDE.md, or docs target inside the repo
+- **THEN** no `extracted.md` entry is written — the residue is already discoverable in-repo
+
+#### Scenario: A failed pointer append skips the delete
+
+- **WHEN** the vault write succeeds but appending the `extracted.md` pointer fails
+- **THEN** the `git rm` is not applied for that entry and it is reported as skipped, preserving the invariant that a doc is never gone but undiscoverable
diff --git a/plugins/os-doc-hygiene/openspec/changes/calibrate-assessment-inventory/specs/lifecycle-rulebook/spec.md b/plugins/os-doc-hygiene/openspec/changes/calibrate-assessment-inventory/specs/lifecycle-rulebook/spec.md
new file mode 100644
index 0000000..7c8bf3f
--- /dev/null
+++ b/plugins/os-doc-hygiene/openspec/changes/calibrate-assessment-inventory/specs/lifecycle-rulebook/spec.md
@@ -0,0 +1,99 @@
+# Delta: lifecycle-rulebook (calibrate-assessment-inventory)
+
+## MODIFIED Requirements
+
+### Requirement: Rulebook Locations and Envelope
+
+The plugin SHALL ship a global rulebook at
+`plugins/os-doc-hygiene/rulebook.json`, resolved relative to plugin scripts,
+present in every project. A project MAY additionally provide a committed
+repo-root `.dochygiene-rules.json` override. Both files SHALL use the
+envelope `{"schema_version": 1, "rules": [...]}`. The project file MAY
+additionally carry a top-level `nominations` key (see the Nominations Memory
+requirement); the loader SHALL ignore unknown top-level keys, so the key is
+additive to `schema_version` 1. The per-project override SHALL NOT live under
+gitignored `.cc-os/` — it SHALL be a committed, reviewable dotfile.
+
+#### Scenario: Global rulebook is always present
+
+- **WHEN** the rulebook loader runs in any project
+- **THEN** it loads `plugins/os-doc-hygiene/rulebook.json` resolved relative to the plugin scripts directory
+
+#### Scenario: Per-project override is optional and committed
+
+- **WHEN** a project has no `.dochygiene-rules.json` at its repo root
+- **THEN** the loader proceeds using only the global rulebook, and when the file is present it is read as a committed, reviewable file, never from `.cc-os/`
+
+#### Scenario: Both files share one envelope shape
+
+- **WHEN** either the global rulebook or a project override is loaded
+- **THEN** it is validated against the envelope `{"schema_version": 1, "rules": [...]}`
+
+#### Scenario: A nominations key does not disturb the v1 loader
+
+- **WHEN** a project `.dochygiene-rules.json` carries a top-level `nominations` key alongside `rules`
+- **THEN** `rulebook.py` loads the `rules` array exactly as before, ignoring the unknown top-level key without warning or error
+
+## ADDED Requirements
+
+### Requirement: Nominations Memory Lives in the Project Rules File
+
+The project `.dochygiene-rules.json` MAY carry a top-level `nominations` key
+holding exactly two lists: `consults` (open questions — entries with `glob`,
+`question`, `evidence`, `cluster_key`, `asked_on`, and deliberately NO
+lifetime) and `rejected` (settled "no" answers — entries with `glob`,
+`lifetime`, `why`, optional `consider_instead`, `rejected_by` (`"judge"` or
+`"human"`), `judged_on`). The `nominations` key SHALL never affect which
+files the rulebook governs — only entries in `rules` decide that.
+`rulebook.py` SHALL remain nomination-unaware; only the calibrate helpers
+read the key, and the calibrate reader SHALL warn on unrecognized nomination
+fields, mirroring the rules array's unknown-field discipline. Rejected
+entries and exact-path singleton keep rules SHALL exit only by hand-deletion
+(removals stay HITL with recorded reasoning); no automated revisit path
+SHALL exist.
+
+#### Scenario: Nominations never filter files
+
+- **WHEN** the scanner or rulebook resolves the governing rule for a path that only a `nominations` entry's glob matches
+- **THEN** the path is treated as unmatched/unmanaged — nominations carry no lifecycle authority
+
+#### Scenario: Consult entries carry no lifetime
+
+- **WHEN** a consult entry is written to `nominations.consults`
+- **THEN** it records `glob`, `question`, `evidence`, `cluster_key`, `asked_on` and no lifetime field — presence in the list means open, with no status field
+
+#### Scenario: Unrecognized nomination fields warn in the calibrate reader
+
+- **WHEN** the calibrate helpers read a nominations entry containing an unknown field
+- **THEN** a warning is emitted and the field is preserved, never silently dropped
+
+#### Scenario: A rejection leaves only by hand-deletion
+
+- **WHEN** a calibration pass runs against a rules file containing a stale rejection
+- **THEN** no automated path removes or expires the entry; it is removed only by explicit human edit
+
+### Requirement: Canonical Writer-Enforced Ordering
+
+Every code path that serializes `.dochygiene-rules.json` SHALL write through
+one canonical writer that emits: `rules` grouped by lifetime tier in the
+order delete-once-served, temporary, keep, glob-sorted within each group;
+`nominations` after `rules`; `consults` before `rejected`, each glob-sorted.
+The writer SHALL be idempotent (canonicalizing an already-canonical file is a
+no-op) and SHALL round-trip unknown fields with a warning rather than
+dropping them. Ordering SHALL NOT be enforced by any hook; hand edits
+re-canonicalize on the next write.
+
+#### Scenario: Writes are grouped and sorted canonically
+
+- **WHEN** the writer serializes a rules file containing rules of all three tiers plus nominations
+- **THEN** the output orders rules delete-once-served → temporary → keep with globs sorted within each group, and nominations follows rules with consults before rejected, each list glob-sorted
+
+#### Scenario: Canonicalization is idempotent
+
+- **WHEN** the writer serializes a file it previously wrote, unchanged
+- **THEN** the output is byte-identical
+
+#### Scenario: A hand-edited file re-canonicalizes on the next write
+
+- **WHEN** a human appends a rule out of tier order and a later calibrate run persists a new entry
+- **THEN** the whole file is rewritten in canonical order in that write, with no hook involved in the interim
diff --git a/plugins/os-doc-hygiene/openspec/changes/calibrate-assessment-inventory/tasks.md b/plugins/os-doc-hygiene/openspec/changes/calibrate-assessment-inventory/tasks.md
new file mode 100644
index 0000000..d45d741
--- /dev/null
+++ b/plugins/os-doc-hygiene/openspec/changes/calibrate-assessment-inventory/tasks.md
@@ -0,0 +1,33 @@
+# Tasks: calibrate-assessment-inventory
+
+TDD (red-green-refactor) throughout; run the full suite (407 existing tests)
+after each group. Source of truth for behavior: `lifecycle-spec.md` §1/§2/§8.
+
+## 1. Canonical rules-file writer (calibrate_helpers.py)
+
+- [x] 1.1 Write failing tests: canonical ordering (rules grouped delete-once-served → temporary → keep, glob-sorted; nominations after rules; consults before rejected, glob-sorted), idempotent round-trip (byte-identical rewrite), hand-edit re-canonicalization, unknown-field warn-and-round-trip (rules and nomination entries)
+- [x] 1.2 Implement the writer as the single serialization path for `.dochygiene-rules.json`, including the nominations read/validate/warn path (`rulebook.py` untouched)
+- [x] 1.3 Route every existing calibrate persistence write through the writer
+
+## 2. NominationIntakeFilter (calibrate_helpers.py)
+
+- [x] 2.1 Write failing tests: exact glob+lifetime repeat is dropped and logged; the two #52 worked cases (exact repeat of `docs/research/** -> temporary` dropped; variant `docs/research/drafts/** -> temporary` survives annotated); match-set-intersection relatedness computed from an injected shortlist; open consults always passed through to the judge input
+- [x] 2.2 Implement `NominationIntakeFilter` (deterministic, injected rules-file data + shortlist; returns survivors-with-annotations + dropped log)
+
+## 3. Calibrate skill wiring (SKILL.md + workflows)
+
+- [x] 3.1 Wire Step 3.5: run the intake filter between nomination and judgment; feed annotations + open consults into the judge prompt's "Nominations memory" section; surface drops in the run summary
+- [x] 3.2 Wire Step 5: add the "Open consults" report section with the three exits (answer → rule + consult deleted; decline → rejected with `rejected_by: "human"`; defer → stays); new rejections/consults shown but not individually gated
+- [x] 3.3 Wire Step 6: persist keep verdicts as plain keep rules (exact-path singletons allowed — keep-tier relaxation), human declines as rejections, consult verdicts into `nominations.consults` deduped by glob — all through the canonical writer
+- [x] 3.4 Remove the "design-level pending pipeline wiring" marker from the SKILL.md consult-loop worked example; verify judge.md needs no further edits (its map-#49 sections landed with the design commit)
+
+## 4. Extract-op extracted.md append (patch_applier.py)
+
+- [x] 4.1 Write failing tests: vault extraction appends the pointer entry (correct format: vault note name, why, source filename, date) to `/extracted.md`, creating it if absent, staged in the same hygiene commit as the `git rm`; repo-durable extraction (ADR/CLAUDE.md/docs) writes no entry; failed vault write skips append and delete; failed append skips the delete
+- [x] 4.2 Implement the append in the extract-then-delete path (distill → vault write → append → git rm, one per-file transaction)
+
+## 5. Verification and rollout
+
+- [x] 5.1 Full test suite green (existing 407 + new); no informal runs of held-out eval scenarios
+- [x] 5.2 Run `bin/refresh-plugins` and update the SKILL.md/workflow cache-sensitive docs if the refresh surfaces anything
+- [x] 5.3 Record completion per repo convention: build-plan/status index untouched unless design changed; note in `docs/implementation-status/` leaf if warranted
diff --git a/plugins/os-doc-hygiene/scripts/calibrate_helpers.py b/plugins/os-doc-hygiene/scripts/calibrate_helpers.py
index 0e72e21..d609858 100644
--- a/plugins/os-doc-hygiene/scripts/calibrate_helpers.py
+++ b/plugins/os-doc-hygiene/scripts/calibrate_helpers.py
@@ -26,11 +26,14 @@ purely on lists of strings (paths) handed in by the orchestrating skill.
from __future__ import annotations
import fnmatch
+import json
import re
from dataclasses import dataclass, field
-from pathlib import PurePosixPath
+from pathlib import Path, PurePosixPath
from typing import Optional
+import rulebook as _rulebook
+
_DEFAULT_SAMPLE_CAP = 5
# --- shape-class helpers ---------------------------------------------------
@@ -330,3 +333,212 @@ class RuleQualityChecker:
"narrower": narrower,
"match_counts": {glob_a: count_a, glob_b: count_b},
}
+
+
+# --- canonical rules-file writer --------------------------------------------
+
+# Known fields for a rule entry — reuses rulebook.py's schema so the writer
+# and the loader never drift (rulebook.py itself stays nomination-unaware;
+# this is a read-only reference to its field set, design.md Non-Goal 1).
+_RULE_KNOWN_FIELDS = _rulebook._KNOWN_FIELDS
+
+# Nominations entry schemas (lifecycle-spec.md §2). Consult entries
+# deliberately carry no lifetime; rejected entries deliberately carry no
+# status field.
+_CONSULT_KNOWN_FIELDS = {"glob", "question", "evidence", "cluster_key", "asked_on"}
+_REJECTED_KNOWN_FIELDS = {
+ "glob",
+ "lifetime",
+ "why",
+ "consider_instead",
+ "rejected_by",
+ "judged_on",
+}
+
+# Canonical tier order for `rules`: delete-once-served -> temporary -> keep.
+# Anything else (e.g. a missing/unrecognized lifetime) sorts last.
+_LIFETIME_TIER_ORDER = {
+ "delete-once-served": 0,
+ "temporary": 1,
+ "keep": 2,
+}
+
+
+def _warn_unknown_fields(entry: dict, known_fields: set, label: str, warnings: list) -> None:
+ unknown = set(entry.keys()) - known_fields
+ if unknown:
+ glob_pattern = entry.get("glob", "")
+ warnings.append(
+ f"{label} {glob_pattern!r}: unrecognized field(s) {sorted(unknown)} "
+ "— preserved, not dropped"
+ )
+
+
+def _sort_by_glob(entries: list) -> list:
+ return sorted(entries, key=lambda e: e.get("glob", ""))
+
+
+class RulesFileWriter:
+ """The single canonical serialization path for `.dochygiene-rules.json`
+ (design.md Implementation shape). Owns:
+
+ - canonical ordering: `rules` grouped by lifetime tier (delete-once-
+ served -> temporary -> keep), glob-sorted within each group;
+ `nominations` after `rules`, with `consults` before `rejected`, each
+ glob-sorted.
+ - idempotency: canonicalizing an already-canonical structure is a no-op.
+ - unknown-field discipline: unrecognized fields on rules AND nomination
+ entries are warned about and round-tripped, never dropped.
+ - the nominations read/validate/warn path — `rulebook.py` stays
+ nomination-unaware; this is the only code that interprets the
+ `nominations` key.
+
+ No knowledge of git, classification, or judge/report logic — purely a
+ load/canonicalize/write seam over the parsed JSON structure.
+ """
+
+ def canonicalize(self, data: dict) -> "tuple[dict, list]":
+ """Pure in-memory transform: returns (canonical_dict, warnings).
+ Does no I/O."""
+ warnings: list = []
+
+ rules_in = data.get("rules", []) or []
+ rules_out = []
+ for raw in rules_in:
+ rule = dict(raw)
+ _warn_unknown_fields(rule, _RULE_KNOWN_FIELDS, "rule", warnings)
+ rules_out.append(rule)
+
+ rules_out = sorted(
+ rules_out,
+ key=lambda r: (
+ _LIFETIME_TIER_ORDER.get(r.get("lifetime"), 99),
+ r.get("glob", ""),
+ ),
+ )
+
+ canonical: dict = {
+ "schema_version": data.get("schema_version", 1),
+ "rules": rules_out,
+ }
+
+ nominations_in = data.get("nominations") or {}
+ consults_in = nominations_in.get("consults", []) or []
+ rejected_in = nominations_in.get("rejected", []) or []
+
+ consults_out = []
+ for raw in consults_in:
+ entry = dict(raw)
+ _warn_unknown_fields(entry, _CONSULT_KNOWN_FIELDS, "consult entry", warnings)
+ consults_out.append(entry)
+
+ rejected_out = []
+ for raw in rejected_in:
+ entry = dict(raw)
+ _warn_unknown_fields(entry, _REJECTED_KNOWN_FIELDS, "rejected entry", warnings)
+ rejected_out.append(entry)
+
+ consults_out = _sort_by_glob(consults_out)
+ rejected_out = _sort_by_glob(rejected_out)
+
+ if consults_out or rejected_out:
+ canonical["nominations"] = {
+ "consults": consults_out,
+ "rejected": rejected_out,
+ }
+
+ return canonical, warnings
+
+ def load(self, path: Path) -> "tuple[dict, list]":
+ """Reads a rules file from disk and returns (canonical_dict,
+ warnings). A missing file yields the empty envelope, matching
+ rulebook.py's own missing-file behavior."""
+ path = Path(path)
+ try:
+ text = path.read_text(encoding="utf-8")
+ except FileNotFoundError:
+ return self.canonicalize({"schema_version": 1, "rules": []})
+
+ data = json.loads(text)
+ return self.canonicalize(data)
+
+ def write(self, path: Path, data: dict) -> list:
+ """Canonicalizes *data* and writes it to *path* as pretty JSON with
+ a trailing newline. Returns the warnings collected during
+ canonicalization."""
+ path = Path(path)
+ canonical, warnings = self.canonicalize(data)
+ text = json.dumps(canonical, indent=2) + "\n"
+ path.write_text(text, encoding="utf-8")
+ return warnings
+
+
+# --- nomination intake filter ------------------------------------------------
+
+
+class NominationIntakeFilter:
+ """Deterministic, no-model filter run between haiku nomination and the
+ strong-model judge (lifecycle-spec.md §8 step 3; calibrate spec's
+ "Deterministic Nomination Intake Filter" requirement).
+
+ Injected inputs: the rules-file's `rejected` entries and open `consults`
+ (both read via `RulesFileWriter`/the calibrate skill, never by this
+ class), plus, per call, the haiku nominations and the current scanned
+ shortlist. No model, no I/O — pure set/string logic over injected data.
+ """
+
+ def __init__(self, rejected: list, consults: list) -> None:
+ self._rejected = list(rejected)
+ self._consults = list(consults)
+
+ def _matches(self, glob_pattern: str, shortlist: list) -> set:
+ return {p for p in shortlist if _glob_matches(glob_pattern, p)}
+
+ def filter(self, nominations: list, shortlist: list) -> dict:
+ """Returns:
+ {
+ "survivors": [ {**nomination, "related_rejections": [...]}, ... ],
+ "dropped": [ {**nomination, "reason": "..."}, ... ],
+ "consults": ,
+ }
+ """
+ survivors = []
+ dropped = []
+
+ for nomination in nominations:
+ glob_pattern = nomination.get("glob")
+ lifetime = nomination.get("lifetime")
+
+ exact_match = next(
+ (
+ r
+ for r in self._rejected
+ if r.get("glob") == glob_pattern and r.get("lifetime") == lifetime
+ ),
+ None,
+ )
+ if exact_match is not None:
+ dropped.append(
+ {
+ **nomination,
+ "reason": (
+ "exact glob+lifetime repeat of a rejected nomination"
+ ),
+ }
+ )
+ continue
+
+ related_rejections = []
+ nomination_matches = self._matches(glob_pattern, shortlist)
+ for rejection in self._rejected:
+ rejection_matches = self._matches(rejection.get("glob", ""), shortlist)
+ if nomination_matches & rejection_matches:
+ related_rejections.append(dict(rejection))
+
+ survivors.append({**nomination, "related_rejections": related_rejections})
+
+ return {
+ "survivors": survivors,
+ "dropped": dropped,
+ "consults": list(self._consults),
+ }
diff --git a/plugins/os-doc-hygiene/scripts/patch_applier.py b/plugins/os-doc-hygiene/scripts/patch_applier.py
index 2d59f6a..80485c1 100644
--- a/plugins/os-doc-hygiene/scripts/patch_applier.py
+++ b/plugins/os-doc-hygiene/scripts/patch_applier.py
@@ -296,6 +296,52 @@ def _ranges_overlap(a_start: int, a_end: int, b_start: int, b_end: int) -> bool:
# atomic per-path operation, mirroring move-to-archive's special-casing.
_LIFECYCLE_DELETE_KINDS = {"delete", "extract-then-delete"}
+# exact_edit.extraction_target value ("cross-repo") that means the content
+# physically left the repo (vault write via /os-vault:write) and therefore
+# needs a discoverable extracted.md pointer. The other valid value,
+# "repo-durable" (ADR/CLAUDE.md/docs residue), is already discoverable
+# in-repo and gets no index entry (lifecycle-spec §1). This field is part
+# of the frozen machine-report schema (validate_report.py KIND_TABLE
+# required_fields for extract-then-delete) — reused here rather than
+# duplicated under a new name.
+_VAULT_EXTRACTION_TARGET = "cross-repo"
+
+_POINTER_REQUIRED_FIELDS = ("vault_note", "reason", "date")
+
+
+def _extracted_md_relpath(path: str) -> str:
+ """Return the `extracted.md` path in the same directory as *path*."""
+ parent = os.path.dirname(path)
+ return f"{parent}/extracted.md" if parent else "extracted.md"
+
+
+def _valid_extraction_pointer(pointer: Any) -> bool:
+ """True iff *pointer* has non-empty vault_note/reason/date strings."""
+ if not isinstance(pointer, dict):
+ return False
+ for key in _POINTER_REQUIRED_FIELDS:
+ value = pointer.get(key)
+ if not isinstance(value, str) or not value.strip():
+ return False
+ return True
+
+
+def _format_extracted_pointer(pointer: dict, source_filename: str) -> str:
+ """Format the extracted.md pointer line per lifecycle-spec §1:
+
+ - [[vault: tool/graphify-clustering-behavior]] — why HDBSCAN
+ over-merges doc clusters; read before tuning any graphify
+ clustering params. (extracted from `graphify-clustering-notes.md`,
+ 2026-07-15)
+ """
+ vault_note = pointer["vault_note"]
+ reason = pointer["reason"]
+ date = pointer["date"]
+ return (
+ f"- [[vault: {vault_note}]] — {reason} "
+ f"(extracted from `{source_filename}`, {date})"
+ )
+
# ---------------------------------------------------------------------------
# PatchApplier
@@ -668,6 +714,17 @@ class PatchApplier:
extraction step complete via `extraction_complete: true` on the
entry. This applier never performs the generative extraction itself.
+ When `exact_edit.extraction_target == "cross-repo"` (the vault case —
+ content physically left the repo via `/os-vault:write`), the git rm
+ additionally requires a valid top-level `extraction_pointer` dict
+ (`vault_note`, `reason`, `date`, all non-empty strings), and a pointer
+ line is appended to the deleted file's directory `extracted.md`
+ (creating it if absent) BEFORE the git rm, staged into the same
+ commit. `extraction_target == "repo-durable"` (ADR/CLAUDE.md/docs
+ residue) writes no index entry — it is already discoverable in-repo.
+ A failed append (or missing/invalid pointer metadata) skips the
+ delete, same fail-closed semantics as an unconfirmed extraction.
+
Directory-rule aggregate entries (`exact_edit.is_directory: true`)
bypass the content-hash guard entirely (no single file to hash) but
NEVER bypass the git-state re-verification.
@@ -704,6 +761,53 @@ class PatchApplier:
))
return applied, skipped_out, failed_out, staged
+ # extract-then-delete, vault destination: append a discoverable
+ # pointer to the deleted file's directory `extracted.md` BEFORE the
+ # git rm, so there is never a window where the doc is gone but
+ # undiscoverable (lifecycle-spec §1 / design decision #7). Repo-
+ # durable destinations (ADR/CLAUDE.md/docs, or no destination at all)
+ # are already discoverable in-repo and get no index entry.
+ if (
+ kind == "extract-then-delete"
+ and ee.get("extraction_target") == _VAULT_EXTRACTION_TARGET
+ ):
+ pointer = entry.get("extraction_pointer")
+ if not _valid_extraction_pointer(pointer):
+ skipped_out.append(_skipped(
+ path, kind, idx,
+ "extraction-pointer-invalid",
+ "extraction_target is \"cross-repo\" (vault) but "
+ "extraction_pointer is missing or incomplete (needs "
+ "vault_note, reason, date); the delete is withheld so the "
+ "doc is never gone-but-unindexed.",
+ ))
+ return applied, skipped_out, failed_out, staged
+
+ extracted_rel = _extracted_md_relpath(path)
+ extracted_abs = self._root / extracted_rel
+ pointer_line = _format_extracted_pointer(pointer, Path(path).name)
+
+ try:
+ existing = (
+ self._fs.read_bytes(extracted_abs)
+ if self._fs.exists(extracted_abs) else b""
+ )
+ if existing and not existing.endswith(b"\n"):
+ existing += b"\n"
+ self._fs.write_bytes(
+ extracted_abs, existing + pointer_line.encode("utf-8") + b"\n"
+ )
+ except Exception as exc: # noqa: BLE001
+ skipped_out.append(_skipped(
+ path, kind, idx,
+ "extracted-md-append-failed",
+ f"Could not append pointer to {extracted_rel}: {exc}; the "
+ "delete is withheld so the doc is never gone-but-unindexed.",
+ ))
+ return applied, skipped_out, failed_out, staged
+
+ staged.append(extracted_rel)
+
try:
self._git.rm(abs_path, self._root, recursive=is_directory)
except Exception as exc: # noqa: BLE001
diff --git a/plugins/os-doc-hygiene/skills/calibrate/SKILL.md b/plugins/os-doc-hygiene/skills/calibrate/SKILL.md
index a076231..1077604 100644
--- a/plugins/os-doc-hygiene/skills/calibrate/SKILL.md
+++ b/plugins/os-doc-hygiene/skills/calibrate/SKILL.md
@@ -5,10 +5,12 @@ description: Learn new lifecycle rules for a project by clustering unmatched fil
# Hygiene Calibrate Skill
Orchestrates the learn-new-rules loop (lifecycle-spec.md §8): **cluster-and-
-sample → cheap-model nominate → strong-model judge → rule report (human) →
-persist → retest**. It runs over the **unmatched pool** (unmatched = unmanaged
-= not governed by any existing rulebook rule, per `rulebook.py`), and is the
-only new skill this change adds — `check`/`clean` are unchanged in structure
+sample → cheap-model nominate → deterministic intake filter (drop repeat
+rejections, carry related rejections + open consults forward) → strong-
+model judge → rule report (human, including open consults) → persist →
+retest**. It runs over the **unmatched pool** (unmatched = unmanaged = not
+governed by any existing rulebook rule, per `rulebook.py`), and is the only
+new skill this change adds — `check`/`clean` are unchanged in structure
(ADR-0039/-0041, `lifecycle-spec.md` §7).
All scripts live under `${CLAUDE_PLUGIN_ROOT}/scripts/`. Run them with
@@ -161,15 +163,71 @@ haiku at face value.
---
+### Step 3.5 — (D) Nomination intake filter — `calibrate_helpers.NominationIntakeFilter`
+
+Deterministic, no model (lifecycle-spec.md §8 step 3;
+`NominationIntakeFilter` requirement). Reads the project rules file's
+`nominations` key via `RulesFileWriter.load`, then drops any nomination that
+exactly repeats a `rejected` glob+lifetime, annotates survivors with every
+*related* rejection (match-set intersection on the current shortlist), and
+passes ALL open consults through unconditionally — this is the input the
+judge prompt's "Nominations memory" section (Step 4) consumes.
+
+```bash
+python3 -c '
+import json, os, sys
+from pathlib import Path
+sys.path.insert(0, os.environ["CLAUDE_PLUGIN_ROOT"] + "/scripts")
+from calibrate_helpers import RulesFileWriter, NominationIntakeFilter
+
+scan = json.loads(Path(os.environ["SCRATCH"] + "/scan.json").read_text())
+project_rules = Path(scan["project_root"]) / ".dochygiene-rules.json"
+nominations = json.loads(Path(os.environ["SCRATCH"] + "/nominations.json").read_text())
+shortlist = scan.get("shortlist", [])
+
+writer = RulesFileWriter()
+data, load_warnings = writer.load(project_rules)
+memory = data.get("nominations", {})
+
+result = NominationIntakeFilter(
+ rejected=memory.get("rejected", []),
+ consults=memory.get("consults", []),
+).filter(nominations, shortlist)
+
+Path(os.environ["SCRATCH"] + "/intake.json").write_text(json.dumps(result, indent=2))
+print(f"{len(result[\"survivors\"])} survivors, {len(result[\"dropped\"])} dropped, {len(result[\"consults\"])} open consults")
+if result["dropped"]:
+ for d in result["dropped"]:
+ print(f" dropped: {d[\"glob\"]} -> {d[\"lifetime\"]} ({d[\"reason\"]})")
+'
+```
+
+- **Surface every drop in the run summary** shown to the human alongside the
+ Step 5 report — a dropped nomination never reaches the judge, so this is
+ the only place it is visible.
+- `intake.json`'s `survivors` (each nomination plus its `related_rejections`
+ annotation) and `consults` (all open consults, unconditionally) are what
+ gets embedded in Step 4's judge prompt as the "Nominations memory" input
+ section — feed the whole `survivors` array (not the raw
+ `nominations.json`) forward into Step 4, and include `consults` even when
+ empty (an empty array is a valid, meaningful "no open consults" signal).
+
+---
+
### Step 4 — (M) Strong-model batched judgment — **ONE Opus/Fable subagent**
Dispatch a **single batched** strong-model subagent (`model: opus`, or the
-project's configured Fable-tier model) to judge ALL nominations from Step 3
+project's configured Fable-tier model) to judge ALL nominations from Step 3.5
in one call (LOOP-GUARD: point it at `workflows/judge.md`, never this
SKILL.md). The judge gathers its OWN evidence — re-reads matched paths
against the live tree, checks near-miss boundaries — rather than trusting
the haiku nomination's claims.
+Judge on `intake.json`'s `survivors` (each nomination annotated with its
+`related_rejections`), never the raw `nominations.json` — the dropped
+exact-repeats never reach this step. `intake.json`'s `consults` is the
+"Open consults" input regardless of what haiku nominated this round.
+
```
Agent tool parameters:
- subagent_type: "general-purpose"
@@ -181,8 +239,11 @@ Agent tool parameters:
Project root:
- Nominations to judge (verbatim):
-
+ Nominations to judge (verbatim, each with related_rejections):
+
+
+ Nominations memory — open consults (unconditional, may be empty):
+
Seed intake:
@@ -256,29 +317,156 @@ report rather than silently dropping or persisting it; ask the human whether
to have the judge re-amend it (loop back to Step 4 for that one rule) or
drop it from this round.
+#### Open consults section
+
+List every entry from `$SCRATCH/intake.json`'s `consults` — the open
+consults from prior runs (plus any new `consult` verdict this run) — one per
+entry:
+
+```
+Open consult:
+ Asked: Cluster:
+ Question:
+ Evidence:
+```
+
+Each open consult has exactly three exits, all human, none of which is
+gated the way a rule confirmation is:
+
+- **(a) Answer settles the purpose** — the human's answer determines a
+ lifetime; persist a normal rule through this same report/Step 6 flow AND
+ delete the consult entry, in the same write (the rule supersedes it).
+- **(b) Not rule-worthy** — the human decides the artifact class isn't a
+ rule; rewrite the entry into `nominations.rejected` with
+ `rejected_by: "human"` and the human's stated why.
+- **(c) Defer** — no answer this round; the entry stays as-is and resurfaces
+ in the next `:calibrate` run's Step 3.5/4/5.
+
+New rejections and consults produced this run (from judge `consult`
+verdicts, or from exit (b) above) appear in this report for visibility but
+are **not individually gated** — unlike proposed rules, they carry no
+deletion authority, only memory. Only proposed rules go through the "Persist
+these N project rules?" gate below.
+
**No rule is written anywhere until the human has seen this report and
responded.** Ask: "Persist these N project rules? (yes / no / a subset by
-number)". Only proceed to Step 6 for the rules the human approves.
+number)". Separately, resolve each open consult per its three exits above.
+Only proceed to Step 6 for the rules the human approves and the consults the
+human has settled or declined this round.
---
-### Step 6 — (D) Persistence
+### Step 6 — (D) Persistence — canonical writer only
+
+Every settled verdict persists through `calibrate_helpers.RulesFileWriter`
+(load → mutate the parsed dict → write) — no code path serializes
+`.dochygiene-rules.json` by hand. Before running this, translate the
+human's Step 5 responses into four in-memory lists (the orchestrating skill's
+own bookkeeping — there is no scratch file for these, since they come from
+the live conversation, not a subagent):
+
+- `approved_rules` — the judge `confirm`/`amend` rule objects the human said
+ yes to (including `keep`-lifetime rules and Step-5-exit-(a) rules that
+ answer an open consult).
+- `declined` — `(rule, why)` pairs for judge-proposed rules the human said
+ no to at the report.
+- `settled_consult_globs` — globs of open consults the human resolved this
+ run, either via exit (a) (answered, folded into `approved_rules` above) or
+ exit (b) (declined, folded into `new_human_rejections` below); these are
+ removed from `nominations.consults` in the same write.
+- `new_human_rejections` — `(glob, lifetime, why)` for exit-(b) consults
+ ("not rule-worthy"), written with `rejected_by: "human"`.
+- `still_open_consults` — judge `consult` verdicts from THIS run
+ (`{glob, question, evidence, cluster_key}`) that remain unresolved after
+ Step 5 (exit (c), or simply not reached this round).
+
+```bash
+python3 -c '
+import json, os, sys
+from datetime import date
+from pathlib import Path
+sys.path.insert(0, os.environ["CLAUDE_PLUGIN_ROOT"] + "/scripts")
+from calibrate_helpers import RulesFileWriter
+
+scan = json.loads(Path(os.environ["SCRATCH"] + "/scan.json").read_text())
+project_rules = Path(scan["project_root"]) / ".dochygiene-rules.json"
+today = date.today().isoformat()
+
+# approved_rules / declined / settled_consult_globs / new_human_rejections /
+# still_open_consults come from the human'"'"'s Step 5 responses (see above).
+
+writer = RulesFileWriter()
+data, load_warnings = writer.load(project_rules)
+nominations = data.setdefault("nominations", {"consults": [], "rejected": []})
+
+# 1. Judge confirm/amend verdicts the human approved -> plain rules
+# (keep verdicts and exact-path keep singletons allowed -- the keep-tier
+# relaxation). confirmed_by/confirmed_on are set here, never by the judge.
+for rule in approved_rules:
+ rule["confirmed_by"] = "human"
+ rule["confirmed_on"] = today
+ data["rules"].append(rule)
+
+# 2. Human declines at the rule report -> rejected entries, so a later
+# haiku round cannot re-nominate the identical glob+lifetime without the
+# judge knowing.
+for rule, why in declined:
+ nominations["rejected"].append({
+ "glob": rule["glob"], "lifetime": rule["lifetime"],
+ "why": why, "rejected_by": "human", "judged_on": today,
+ })
+
+# 3. Consults the human declined this run (Step 5 exit b) -> rejected
+# entries too, same rejected_by/judged_on convention.
+for glob_pattern, lifetime, why in new_human_rejections:
+ nominations["rejected"].append({
+ "glob": glob_pattern, "lifetime": lifetime,
+ "why": why, "rejected_by": "human", "judged_on": today,
+ })
+
+# 4. Still-open consults from this run -> nominations.consults, deduped by
+# glob (an existing entry with the same glob wins -- never duplicated).
+existing_consult_globs = {c["glob"] for c in nominations["consults"]}
+for consult in still_open_consults:
+ if consult["glob"] not in existing_consult_globs:
+ nominations["consults"].append({**consult, "asked_on": today})
+
+# 5. Consults the human just settled this run (answered or declined) leave
+# nominations.consults -- the rule (step 1) or rejection (step 3)
+# supersedes them.
+nominations["consults"] = [
+ c for c in nominations["consults"] if c["glob"] not in settled_consult_globs
+]
+
+write_warnings = writer.write(project_rules, data)
+'
+```
- **Project rules** (the common case): land in `/.dochygiene-
- rules.json` on judge `confirm`/`amend` PLUS this step's human approval.
- Read-modify-write the envelope (`{"schema_version": 1, "rules": [...]}`,
- creating the file if absent), appending only — never mutating or removing
- an existing rule here.
+ rules.json` on judge `confirm`/`amend` PLUS this step's human approval —
+ including plain `lifetime: keep` rules for judge `keep`-purpose verdicts,
+ exact-path singletons allowed under the keep-tier relaxation.
+- **Human declines** at the rule report persist as `rejected` entries with
+ `rejected_by: "human"` (never silently dropped) — see item 2 above.
+- **Open `consult` verdicts** persist to `nominations.consults`, deduped by
+ glob at write time — see item 4 above. An answered consult (Step 5 exit a)
+ is deleted from `consults` in the same write that persists its superseding
+ rule; a declined consult (exit b) is deleted from `consults` in the same
+ write that adds its `rejected` entry; a deferred consult (exit c) is left
+ untouched and resurfaces next run.
- **Global rulebook writes** (`plugins/os-doc-hygiene/rulebook.json`) require
a SEPARATE, EXPLICIT confirmation beyond the project-rule approval above —
this is a cross-repo write into cc-os itself. Ask distinctly: "This rule
looks like it belongs in the GLOBAL rulebook (applies to every project),
not just this one. Write it to the global rulebook instead/as well? (yes/
- no)". Only write on explicit "yes" to THIS question.
+ no)". Only write on explicit "yes" to THIS question. (Rejections/consults
+ are always project-scoped memory — never written to the global rulebook.)
- **Removals are HITL-only, always**, regardless of scope: only remove a
rule when the human explicitly asks to, with the reasoning recorded in the
rule's own `note` field (or a comment in the calibration run's summary) —
- never as an automatic side effect of a calibration pass.
+ never as an automatic side effect of a calibration pass. New rejections
+ and consults are memory, not deletion authority, and are never gated the
+ way a rule persist/remove is (Step 5).
Each persisted rule gets `confirmed_by` (the human's decision, not the
judge's — a model-proposed rule may never set `confirm: true` on itself,
@@ -289,13 +477,12 @@ it may only ask) and `confirmed_on` (today's date) per the rulebook schema
### Consult loop — worked example (map #49, #56/#59)
-Design-level example of consult persistence (`lifecycle-spec.md` §2
-"Nominations memory"); the pipeline wiring lands with the nominations-memory
-implementation. Run 1: the judge returns `consult` on
-`docs/orchestration-audit/*.md` ("retained audit trail, or disposable once
-the tune-up lands?"). Step 6's writer persists it to `nominations.consults`
-(glob, question, evidence, cluster_key, asked_on — deliberately no
-lifetime). Run 2, weeks later: the deterministic intake filter injects the
+Worked example of consult persistence (`lifecycle-spec.md` §2 "Nominations
+memory"), wired through Steps 3.5/5/6 above. Run 1: the judge returns
+`consult` on `docs/orchestration-audit/*.md` ("retained audit trail, or
+disposable once the tune-up lands?"). Step 6's writer persists it to
+`nominations.consults` (glob, question, evidence, cluster_key, asked_on —
+deliberately no lifetime). Run 2, weeks later: the deterministic intake filter injects the
still-open consult into the judge prompt's "Nominations memory" section, AND
the Step 5 report renders it under "Open consults". Three exits, all human:
(a) the human answers "audit trail" → a `lifetime: keep` rule is persisted
@@ -309,7 +496,8 @@ own.
### Step 7 — (D) Retest loop
-Re-run Steps 1-6 against the shrunk unmatched pool. Stop when:
+Re-run Steps 1-6 (including 3.5) against the shrunk unmatched pool. Stop
+when:
- a round yields **fewer than 2 new persisted rules**, OR
- the unmatched pool shrank by **less than 10%** since the previous round,
@@ -356,8 +544,12 @@ When run is explicitly cc-os pass #1:
## Invariants
-- Steps 1, 2, 5, 6 are deterministic scripts/logic — **no model**.
+- Steps 1, 2, 3.5, 5, 6 are deterministic scripts/logic — **no model**.
- Step 3 = **haiku** (cheap, per-cluster nomination, patterns only).
+- Step 3.5 = the deterministic `NominationIntakeFilter` — exact glob+lifetime
+ repeats of a `rejected` entry are dropped before the judge ever sees them;
+ survivors carry `related_rejections`; all open consults pass through
+ unconditionally.
- Step 4 = **ONE batched Opus/Fable** judge call, never per-cluster.
- **No rule is persisted before the Step 5 report has been shown to the
human** (hard invariant — never skip Step 5, never merge it with Step 6).
diff --git a/plugins/os-doc-hygiene/skills/calibrate/workflows/judge.md b/plugins/os-doc-hygiene/skills/calibrate/workflows/judge.md
index d893110..4c538d9 100644
--- a/plugins/os-doc-hygiene/skills/calibrate/workflows/judge.md
+++ b/plugins/os-doc-hygiene/skills/calibrate/workflows/judge.md
@@ -150,6 +150,9 @@ nomination (nominations with a `null` glob need no entry):
"cluster_key": "docs::status-#",
"verdict": "consult",
"rule": null,
+ "glob": "docs/orchestration-audit/*.md",
+ "question": "Are the dated audit tune-up reports a retained audit trail, or disposable once the tune-up lands?",
+ "evidence": "4 files, one per audit run; docs/implementation-status.md references only the latest",
"reasoning": "Cannot determine from content or naming whether these status snapshots are meant to be retained as an audit trail or are disposable scratch notes -- purpose is genuinely unclear."
}
]
@@ -163,6 +166,15 @@ nomination (nominations with a `null` glob need no entry):
`delete-once-served`). Do NOT include `confirmed_by`/`confirmed_on` — you
are proposing, not confirming; **you may never set `confirm: true` on a
rule yourself, only the human can.**
+- **For `consult`, ALSO include `glob` (echoed from the nomination you're
+ judging — the persisted consult entry needs it and `rule` is `null`),
+ `question` (the specific ambiguity, one sentence, phrased for a human to
+ answer), and `evidence` (what you found — sample size, what references
+ exist, what a representative file's content told you). These three are
+ what `nominations.consults` persists (lifecycle-spec.md §2) — `reasoning`
+ alone is not enough downstream, since the Step 5 "Open consults" section
+ and the persisted entry both need `question` and `evidence` as distinct
+ fields, not one prose blob.
- `reasoning` is shown to the human in the Step 5 rule report's "why" field —
write it in plain language, not JSON-schema-speak.
diff --git a/plugins/os-doc-hygiene/skills/clean/SKILL.md b/plugins/os-doc-hygiene/skills/clean/SKILL.md
index 05d8fd0..df92edc 100644
--- a/plugins/os-doc-hygiene/skills/clean/SKILL.md
+++ b/plugins/os-doc-hygiene/skills/clean/SKILL.md
@@ -337,7 +337,7 @@ For each `approved_det` entry with `exact_edit.kind ==
extraction against a missing file.
2. **Live-read the file contents now** (same freshness rule as Step 8's
generative path — never trust the report's cached text).
-3. **Branch on `exact_edit.extraction_dest`:**
+3. **Branch on `exact_edit.extraction_target`:**
- **`"repo-durable"`** — dispatch the SAME generative Sonnet-subagent
path `doc-clean` already uses (LOOP-GUARD: point it at
`skills/clean/workflows/extract.md`, NEVER this SKILL.md). Give it the
@@ -358,9 +358,15 @@ For each `approved_det` entry with `exact_edit.kind ==
source path, why it's evergreen) for it to choose vault frontmatter.
`/os-vault:write` owns its own destination — this step supplies content
and context only, per spec §1 ("no new destinations" for cross-repo
- extraction).
+ extraction). On success, capture the pointer metadata the applier needs
+ for the `extracted.md` index entry (lifecycle-spec §1): the vault note
+ name `/os-vault:write` reports, a one-line reason a future reader would
+ follow the link, and today's date (`YYYY-MM-DD`).
4. **On extraction success:** record the entry's original report index in
- `extraction_confirmed_indices` for Step 7 below.
+ `extraction_confirmed_indices` for Step 7 below; for `"cross-repo"`
+ entries also record the captured pointer metadata in
+ `extraction_pointers[idx]`. Without it the applier fails that entry
+ closed (`extraction-pointer-invalid`) and skips the deletion.
5. **On extraction failure** (subagent errors, or `/os-vault:write` fails):
do NOT add the index to `extraction_confirmed_indices` — leave the entry
unconfirmed. This is a per-entry fails-closed skip (patch_applier.py will
@@ -379,6 +385,9 @@ from pathlib import Path
report = json.loads(Path(REPORT_PATH).read_text())
for idx in extraction_confirmed_indices:
report["entries"][idx]["extraction_complete"] = True
+for idx, pointer in extraction_pointers.items():
+ # cross-repo entries only: {"vault_note": ..., "reason": ..., "date": ...}
+ report["entries"][idx]["extraction_pointer"] = pointer
Path(SCRATCH, "report_with_extraction.json").write_text(json.dumps(report))
```
diff --git a/plugins/os-doc-hygiene/tests/test_nomination_intake.py b/plugins/os-doc-hygiene/tests/test_nomination_intake.py
new file mode 100644
index 0000000..19def92
--- /dev/null
+++ b/plugins/os-doc-hygiene/tests/test_nomination_intake.py
@@ -0,0 +1,144 @@
+"""
+Tests for `NominationIntakeFilter` in scripts/calibrate_helpers.py.
+
+Covers tasks 2.1/2.2 of the calibrate-assessment-inventory change: the
+deterministic, no-model filter that runs between haiku nomination and the
+strong-model judge (lifecycle-spec.md §8 step 3, calibrate spec's
+"Deterministic Nomination Intake Filter" requirement).
+
+sys.path is patched here (no shared conftest), matching the existing
+tests/test_calibrate_helpers.py pattern.
+"""
+
+from __future__ import annotations
+
+import sys
+from pathlib import Path
+
+_SCRIPTS_DIR = Path(__file__).parent.parent / "scripts"
+if str(_SCRIPTS_DIR) not in sys.path:
+ sys.path.insert(0, str(_SCRIPTS_DIR))
+
+from calibrate_helpers import NominationIntakeFilter # noqa: E402
+
+
+def _rejected(glob, lifetime, why="why", consider_instead=None):
+ d = {
+ "glob": glob,
+ "lifetime": lifetime,
+ "why": why,
+ "rejected_by": "judge",
+ "judged_on": "2026-07-15",
+ }
+ if consider_instead is not None:
+ d["consider_instead"] = consider_instead
+ return d
+
+
+def _consult(glob, question="q?"):
+ return {
+ "glob": glob,
+ "question": question,
+ "evidence": "e",
+ "cluster_key": "k",
+ "asked_on": "2026-07-15",
+ }
+
+
+class TestExactRepeatDrop:
+ def test_exact_glob_and_lifetime_match_is_dropped_and_logged(self):
+ rejected = [_rejected("docs/research/**", "temporary")]
+ filt = NominationIntakeFilter(rejected=rejected, consults=[])
+
+ nominations = [{"glob": "docs/research/**", "lifetime": "temporary"}]
+ shortlist = ["docs/research/notes.md"]
+
+ result = filt.filter(nominations, shortlist)
+
+ assert result["survivors"] == []
+ assert len(result["dropped"]) == 1
+ assert result["dropped"][0]["glob"] == "docs/research/**"
+ assert result["dropped"][0]["lifetime"] == "temporary"
+
+ def test_same_glob_different_lifetime_is_not_dropped(self):
+ rejected = [_rejected("docs/research/**", "temporary")]
+ filt = NominationIntakeFilter(rejected=rejected, consults=[])
+
+ nominations = [{"glob": "docs/research/**", "lifetime": "keep"}]
+ shortlist = ["docs/research/notes.md"]
+
+ result = filt.filter(nominations, shortlist)
+
+ assert len(result["survivors"]) == 1
+ assert result["dropped"] == []
+
+
+class TestVariantAnnotation:
+ def test_variant_survives_annotated_with_related_rejection(self):
+ # #52 worked example: docs/research/** -> temporary was rejected;
+ # docs/research/drafts/** -> temporary is a narrower variant whose
+ # match set intersects the rejected glob's match set on the
+ # shortlist -- it survives, annotated with the rejection.
+ rejected = [
+ _rejected(
+ "docs/research/**",
+ "temporary",
+ why="findings docs other artifacts link to",
+ consider_instead="an extract-to-vault rule",
+ )
+ ]
+ filt = NominationIntakeFilter(rejected=rejected, consults=[])
+
+ nominations = [{"glob": "docs/research/drafts/**", "lifetime": "temporary"}]
+ shortlist = [
+ "docs/research/drafts/outline.md",
+ "docs/research/other-notes.md",
+ ]
+
+ result = filt.filter(nominations, shortlist)
+
+ assert len(result["survivors"]) == 1
+ survivor = result["survivors"][0]
+ assert survivor["glob"] == "docs/research/drafts/**"
+ assert len(survivor["related_rejections"]) == 1
+ related = survivor["related_rejections"][0]
+ assert related["glob"] == "docs/research/**"
+ assert related["why"] == "findings docs other artifacts link to"
+ assert related["consider_instead"] == "an extract-to-vault rule"
+ assert result["dropped"] == []
+
+ def test_no_annotation_when_match_sets_do_not_intersect(self):
+ rejected = [_rejected("docs/research/**", "temporary")]
+ filt = NominationIntakeFilter(rejected=rejected, consults=[])
+
+ nominations = [{"glob": "docs/other-area/**", "lifetime": "temporary"}]
+ # shortlist has no path that both globs could match in common
+ shortlist = ["docs/research/notes.md", "docs/other-area/thing.md"]
+
+ result = filt.filter(nominations, shortlist)
+
+ assert len(result["survivors"]) == 1
+ survivor = result["survivors"][0]
+ assert survivor["related_rejections"] == []
+
+
+class TestOpenConsultsPassThrough:
+ def test_open_consults_pass_through_even_with_no_nominations(self):
+ consults = [_consult("docs/orchestration-audit/*.md")]
+ filt = NominationIntakeFilter(rejected=[], consults=consults)
+
+ result = filt.filter([], [])
+
+ assert result["consults"] == consults
+ assert result["survivors"] == []
+ assert result["dropped"] == []
+
+ def test_open_consults_pass_through_alongside_survivors(self):
+ consults = [_consult("docs/orchestration-audit/*.md")]
+ filt = NominationIntakeFilter(rejected=[], consults=consults)
+
+ nominations = [{"glob": "a/*.md", "lifetime": "keep"}]
+ result = filt.filter(nominations, ["a/x.md"])
+
+ assert result["consults"] == consults
+ assert len(result["survivors"]) == 1
diff --git a/plugins/os-doc-hygiene/tests/test_patch_applier.py b/plugins/os-doc-hygiene/tests/test_patch_applier.py
index 938c103..4012dbd 100644
--- a/plugins/os-doc-hygiene/tests/test_patch_applier.py
+++ b/plugins/os-doc-hygiene/tests/test_patch_applier.py
@@ -873,14 +873,21 @@ def _lifecycle_entry(
is_directory: bool = False,
extraction_complete: bool | None = None,
lifecycle: dict | None = None,
+ extraction_target: str | None = None,
+ extraction_pointer: dict | None = None,
) -> dict:
"""Build a minimal `delete` / `extract-then-delete` report entry.
Deliberately independent of KIND_TABLE (see module docstring above).
+ `extraction_target` (when given) mirrors the pre-existing frozen-schema
+ field validate_report.py already requires on extract-then-delete entries
+ (`"repo-durable"` or `"cross-repo"`, the vault case).
"""
exact_edit: dict[str, Any] = {"kind": kind}
if is_directory:
exact_edit["is_directory"] = True
+ if extraction_target is not None:
+ exact_edit["extraction_target"] = extraction_target
if extraction_complete is not None:
exact_edit["extraction_complete"] = extraction_complete
entry_extraction_complete = extraction_complete
@@ -899,6 +906,8 @@ def _lifecycle_entry(
}
if extraction_complete is not None:
entry["extraction_complete"] = extraction_complete
+ if extraction_pointer is not None:
+ entry["extraction_pointer"] = extraction_pointer
return entry
@@ -1173,6 +1182,211 @@ class TestExtractThenDeleteFailsClosed:
assert git.rm_calls == []
+class WriteFailsFS(MemFS):
+ """MemFS that raises on write_bytes for a chosen path (simulates an
+ unwritable extracted.md — e.g. its directory was removed)."""
+
+ def __init__(self, files: dict[str, bytes] | None = None, fail_path: str | None = None) -> None:
+ super().__init__(files)
+ self._fail_path = fail_path
+
+ def write_bytes(self, path: Path, data: bytes) -> None:
+ if self._fail_path is not None and str(path) == self._fail_path:
+ raise OSError("simulated write failure: directory removed")
+ super().write_bytes(path, data)
+
+
+class TestExtractThenDeleteVaultPointerAppend:
+ """Task 4.2: vault-destination extractions append an extracted.md pointer,
+ staged into the same transaction as the git rm; repo-durable destinations
+ get no index entry; a failed append (or missing/invalid pointer
+ metadata) skips the delete (fail closed, never gone-but-unindexed)."""
+
+ def _pointer(self, **overrides) -> dict:
+ base = {
+ "vault_note": "tool/graphify-clustering-behavior",
+ "reason": "why HDBSCAN over-merges doc clusters; read before "
+ "tuning any graphify clustering params",
+ "date": "2026-07-15",
+ }
+ base.update(overrides)
+ return base
+
+ def test_vault_extraction_appends_pointer_and_creates_extracted_md(self, tmp_path):
+ fs = MemFS()
+ git = RecordingGitRm()
+ git_state = RecordingGitState(tracked=True, dirty=False)
+ entry = _lifecycle_entry(
+ "docs/graphify-clustering-notes.md", "extract-then-delete",
+ safety_tier="auto", extraction_complete=True,
+ extraction_target="cross-repo", extraction_pointer=self._pointer(),
+ )
+
+ result = _applier_ls(tmp_path, fs=fs, git=git, git_state=git_state).apply([entry])
+
+ assert result["applied"][0]["kind"] == "extract-then-delete"
+ assert result["skipped"] == []
+ assert len(git.rm_calls) == 1
+
+ extracted_path = str(tmp_path / "docs" / "extracted.md")
+ assert extracted_path in fs.writes
+ content = fs.writes[extracted_path].decode("utf-8")
+ expected_line = (
+ "- [[vault: tool/graphify-clustering-behavior]] — why HDBSCAN "
+ "over-merges doc clusters; read before tuning any graphify "
+ "clustering params (extracted from `graphify-clustering-notes.md`, "
+ "2026-07-15)"
+ )
+ assert expected_line in content
+ assert "docs/extracted.md" in result["staged_paths"]
+ assert "docs/graphify-clustering-notes.md" in result["staged_paths"]
+
+ def test_vault_extraction_appends_to_existing_extracted_md(self, tmp_path):
+ existing_path = str(tmp_path / "docs" / "extracted.md")
+ existing_content = (
+ "- [[vault: tool/old-note]] — prior entry "
+ "(extracted from `old.md`, 2026-07-01)\n"
+ )
+ fs = MemFS({existing_path: existing_content.encode("utf-8")})
+ git = RecordingGitRm()
+ git_state = RecordingGitState(tracked=True, dirty=False)
+ entry = _lifecycle_entry(
+ "docs/graphify-clustering-notes.md", "extract-then-delete",
+ safety_tier="auto", extraction_complete=True,
+ extraction_target="cross-repo", extraction_pointer=self._pointer(),
+ )
+
+ result = _applier_ls(tmp_path, fs=fs, git=git, git_state=git_state).apply([entry])
+
+ assert result["applied"][0]["kind"] == "extract-then-delete"
+ content = fs.writes[existing_path].decode("utf-8")
+ assert "tool/old-note" in content
+ assert "tool/graphify-clustering-behavior" in content
+ # Prior entry preserved before the newly appended one.
+ assert content.index("tool/old-note") < content.index("tool/graphify-clustering-behavior")
+
+ def test_repo_durable_extraction_writes_no_extracted_md(self, tmp_path):
+ fs = MemFS()
+ git = RecordingGitRm()
+ git_state = RecordingGitState(tracked=True, dirty=False)
+ entry = _lifecycle_entry(
+ "docs/old-guide.md", "extract-then-delete",
+ safety_tier="auto", extraction_complete=True,
+ extraction_target="repo-durable",
+ )
+
+ result = _applier_ls(tmp_path, fs=fs, git=git, git_state=git_state).apply([entry])
+
+ assert result["applied"][0]["kind"] == "extract-then-delete"
+ assert fs.writes == {}
+ assert result["staged_paths"] == ["docs/old-guide.md"]
+
+ def test_no_extraction_target_writes_no_extracted_md(self, tmp_path):
+ """Absence of extraction_target (or any value other than
+ "cross-repo") is treated as repo-durable — no pointer append."""
+ fs = MemFS()
+ git = RecordingGitRm()
+ git_state = RecordingGitState(tracked=True, dirty=False)
+ entry = _lifecycle_entry(
+ "docs/old-guide.md", "extract-then-delete",
+ safety_tier="auto", extraction_complete=True,
+ )
+
+ result = _applier_ls(tmp_path, fs=fs, git=git, git_state=git_state).apply([entry])
+
+ assert result["applied"][0]["kind"] == "extract-then-delete"
+ assert fs.writes == {}
+
+ def test_failed_append_skips_the_delete(self, tmp_path):
+ extracted_path = str(tmp_path / "docs" / "extracted.md")
+ fs = WriteFailsFS(fail_path=extracted_path)
+ git = RecordingGitRm()
+ git_state = RecordingGitState(tracked=True, dirty=False)
+ entry = _lifecycle_entry(
+ "docs/graphify-clustering-notes.md", "extract-then-delete",
+ safety_tier="auto", extraction_complete=True,
+ extraction_target="cross-repo", extraction_pointer=self._pointer(),
+ )
+
+ result = _applier_ls(tmp_path, fs=fs, git=git, git_state=git_state).apply([entry])
+
+ assert result["applied"] == []
+ assert git.rm_calls == []
+ assert result["skipped"][0]["reason"] == "extracted-md-append-failed"
+
+ def test_missing_pointer_metadata_skips_the_delete(self, tmp_path):
+ fs = MemFS()
+ git = RecordingGitRm()
+ git_state = RecordingGitState(tracked=True, dirty=False)
+ entry = _lifecycle_entry(
+ "docs/graphify-clustering-notes.md", "extract-then-delete",
+ safety_tier="auto", extraction_complete=True,
+ extraction_target="cross-repo",
+ # No extraction_pointer at all.
+ )
+
+ result = _applier_ls(tmp_path, fs=fs, git=git, git_state=git_state).apply([entry])
+
+ assert result["applied"] == []
+ assert git.rm_calls == []
+ assert result["skipped"][0]["reason"] == "extraction-pointer-invalid"
+ assert fs.writes == {}
+
+ def test_incomplete_pointer_metadata_skips_the_delete(self, tmp_path):
+ fs = MemFS()
+ git = RecordingGitRm()
+ git_state = RecordingGitState(tracked=True, dirty=False)
+ entry = _lifecycle_entry(
+ "docs/graphify-clustering-notes.md", "extract-then-delete",
+ safety_tier="auto", extraction_complete=True,
+ extraction_target="cross-repo",
+ extraction_pointer={"vault_note": "tool/x"}, # missing reason/date
+ )
+
+ result = _applier_ls(tmp_path, fs=fs, git=git, git_state=git_state).apply([entry])
+
+ assert result["applied"] == []
+ assert git.rm_calls == []
+ assert result["skipped"][0]["reason"] == "extraction-pointer-invalid"
+ assert fs.writes == {}
+
+ def test_extraction_complete_false_skips_before_any_pointer_logic(self, tmp_path):
+ """extraction_complete: false still skips everything, even with a
+ fully-formed vault pointer present."""
+ fs = MemFS()
+ git = RecordingGitRm()
+ git_state = RecordingGitState(tracked=True, dirty=False)
+ entry = _lifecycle_entry(
+ "docs/graphify-clustering-notes.md", "extract-then-delete",
+ safety_tier="auto", extraction_complete=False,
+ extraction_target="cross-repo", extraction_pointer=self._pointer(),
+ )
+
+ result = _applier_ls(tmp_path, fs=fs, git=git, git_state=git_state).apply([entry])
+
+ assert result["applied"] == []
+ assert git.rm_calls == []
+ assert result["skipped"][0]["reason"] == "extraction-not-confirmed"
+ assert fs.writes == {}
+
+ def test_root_level_file_uses_root_extracted_md(self, tmp_path):
+ fs = MemFS()
+ git = RecordingGitRm()
+ git_state = RecordingGitState(tracked=True, dirty=False)
+ entry = _lifecycle_entry(
+ "graphify-clustering-notes.md", "extract-then-delete",
+ safety_tier="auto", extraction_complete=True,
+ extraction_target="cross-repo", extraction_pointer=self._pointer(),
+ )
+
+ result = _applier_ls(tmp_path, fs=fs, git=git, git_state=git_state).apply([entry])
+
+ assert result["applied"][0]["kind"] == "extract-then-delete"
+ extracted_path = str(tmp_path / "extracted.md")
+ assert extracted_path in fs.writes
+ assert "extracted.md" in result["staged_paths"]
+
+
class TestLifecycleConfirmTierGating:
"""Task 4.1(d): confirm-tier lifecycle entries never auto-apply silently."""
diff --git a/plugins/os-doc-hygiene/tests/test_rules_file_writer.py b/plugins/os-doc-hygiene/tests/test_rules_file_writer.py
new file mode 100644
index 0000000..dc8c77e
--- /dev/null
+++ b/plugins/os-doc-hygiene/tests/test_rules_file_writer.py
@@ -0,0 +1,303 @@
+"""
+Tests for `RulesFileWriter` in scripts/calibrate_helpers.py.
+
+Covers tasks 1.1/1.2 of the calibrate-assessment-inventory change: the
+single canonical serialization path for `.dochygiene-rules.json` —
+- canonical ordering: rules grouped delete-once-served -> temporary -> keep,
+ glob-sorted within each group; nominations after rules; consults before
+ rejected, each glob-sorted
+- idempotent round-trip (byte-identical rewrite of an already-canonical file)
+- hand-edit re-canonicalization (out-of-order input is rewritten canonical)
+- unknown-field warn-and-round-trip, for both rules and nomination entries
+
+sys.path is patched here (no shared conftest), matching the existing
+tests/test_calibrate_helpers.py pattern.
+"""
+
+from __future__ import annotations
+
+import json
+import sys
+from pathlib import Path
+
+_SCRIPTS_DIR = Path(__file__).parent.parent / "scripts"
+if str(_SCRIPTS_DIR) not in sys.path:
+ sys.path.insert(0, str(_SCRIPTS_DIR))
+
+from calibrate_helpers import RulesFileWriter # noqa: E402
+
+
+def _rule(glob, lifetime, **extra):
+ d = {"glob": glob, "lifetime": lifetime, "confirmed_by": "human", "confirmed_on": "2026-07-15"}
+ d.update(extra)
+ return d
+
+
+class TestCanonicalOrdering:
+ def test_rules_grouped_by_tier_in_order_and_glob_sorted(self):
+ data = {
+ "schema_version": 1,
+ "rules": [
+ _rule("z/keep.md", "keep"),
+ _rule("a/keep.md", "keep"),
+ _rule("b/temp/*.md", "temporary"),
+ _rule("a/temp/*.md", "temporary"),
+ _rule("z/served.md", "delete-once-served"),
+ _rule("a/served.md", "delete-once-served"),
+ ],
+ }
+ writer = RulesFileWriter()
+ canonical, _warnings = writer.canonicalize(data)
+ globs = [r["glob"] for r in canonical["rules"]]
+ assert globs == [
+ "a/served.md",
+ "z/served.md",
+ "a/temp/*.md",
+ "b/temp/*.md",
+ "a/keep.md",
+ "z/keep.md",
+ ]
+
+ def test_nominations_after_rules_consults_before_rejected_sorted(self):
+ data = {
+ "schema_version": 1,
+ "rules": [_rule("a.md", "keep")],
+ "nominations": {
+ "rejected": [
+ {
+ "glob": "z/reject.md",
+ "lifetime": "temporary",
+ "why": "why z",
+ "rejected_by": "judge",
+ "judged_on": "2026-07-15",
+ },
+ {
+ "glob": "a/reject.md",
+ "lifetime": "temporary",
+ "why": "why a",
+ "rejected_by": "judge",
+ "judged_on": "2026-07-15",
+ },
+ ],
+ "consults": [
+ {
+ "glob": "z/consult.md",
+ "question": "q z",
+ "evidence": "e",
+ "cluster_key": "k",
+ "asked_on": "2026-07-15",
+ },
+ {
+ "glob": "a/consult.md",
+ "question": "q a",
+ "evidence": "e",
+ "cluster_key": "k",
+ "asked_on": "2026-07-15",
+ },
+ ],
+ },
+ }
+ writer = RulesFileWriter()
+ canonical, _warnings = writer.canonicalize(data)
+
+ keys = list(canonical.keys())
+ assert keys.index("rules") < keys.index("nominations")
+
+ nomination_keys = list(canonical["nominations"].keys())
+ assert nomination_keys.index("consults") < nomination_keys.index("rejected")
+
+ assert [c["glob"] for c in canonical["nominations"]["consults"]] == [
+ "a/consult.md",
+ "z/consult.md",
+ ]
+ assert [r["glob"] for r in canonical["nominations"]["rejected"]] == [
+ "a/reject.md",
+ "z/reject.md",
+ ]
+
+ def test_nominations_omitted_when_absent(self):
+ data = {"schema_version": 1, "rules": [_rule("a.md", "keep")]}
+ writer = RulesFileWriter()
+ canonical, _warnings = writer.canonicalize(data)
+ assert "nominations" not in canonical
+
+ def test_nominations_omitted_when_present_but_empty(self):
+ data = {
+ "schema_version": 1,
+ "rules": [_rule("a.md", "keep")],
+ "nominations": {"consults": [], "rejected": []},
+ }
+ writer = RulesFileWriter()
+ canonical, _warnings = writer.canonicalize(data)
+ assert "nominations" not in canonical
+
+
+class TestIdempotentRoundTrip:
+ def test_writing_an_already_canonical_file_is_byte_identical(self, tmp_path):
+ data = {
+ "schema_version": 1,
+ "rules": [
+ _rule("a/served.md", "delete-once-served"),
+ _rule("a/temp/*.md", "temporary"),
+ _rule("a/keep.md", "keep"),
+ ],
+ "nominations": {
+ "consults": [
+ {
+ "glob": "a/consult.md",
+ "question": "q",
+ "evidence": "e",
+ "cluster_key": "k",
+ "asked_on": "2026-07-15",
+ }
+ ],
+ "rejected": [
+ {
+ "glob": "a/reject.md",
+ "lifetime": "temporary",
+ "why": "why",
+ "rejected_by": "judge",
+ "judged_on": "2026-07-15",
+ }
+ ],
+ },
+ }
+ writer = RulesFileWriter()
+ path = tmp_path / ".dochygiene-rules.json"
+ writer.write(path, data)
+ first_bytes = path.read_bytes()
+
+ loaded, _warnings = writer.load(path)
+ writer.write(path, loaded)
+ second_bytes = path.read_bytes()
+
+ assert first_bytes == second_bytes
+
+
+class TestHandEditRecanonicalization:
+ def test_hand_edited_out_of_order_file_recanonicalizes_on_next_write(self, tmp_path):
+ # Hand-authored file: keep rule listed before temporary/served rules,
+ # rejected before consults -- out of canonical order.
+ hand_edited = {
+ "schema_version": 1,
+ "rules": [
+ _rule("a/keep.md", "keep"),
+ _rule("a/served.md", "delete-once-served"),
+ _rule("a/temp/*.md", "temporary"),
+ ],
+ "nominations": {
+ "rejected": [
+ {
+ "glob": "a/reject.md",
+ "lifetime": "temporary",
+ "why": "why",
+ "rejected_by": "judge",
+ "judged_on": "2026-07-15",
+ }
+ ],
+ "consults": [
+ {
+ "glob": "a/consult.md",
+ "question": "q",
+ "evidence": "e",
+ "cluster_key": "k",
+ "asked_on": "2026-07-15",
+ }
+ ],
+ },
+ }
+ path = tmp_path / ".dochygiene-rules.json"
+ path.write_text(json.dumps(hand_edited, indent=2), encoding="utf-8")
+
+ writer = RulesFileWriter()
+ loaded, _warnings = writer.load(path)
+ writer.write(path, loaded)
+
+ on_disk = json.loads(path.read_text(encoding="utf-8"))
+ assert [r["glob"] for r in on_disk["rules"]] == [
+ "a/served.md",
+ "a/temp/*.md",
+ "a/keep.md",
+ ]
+ nomination_keys = list(on_disk["nominations"].keys())
+ assert nomination_keys.index("consults") < nomination_keys.index("rejected")
+
+
+class TestUnknownFieldsWarnAndRoundtrip:
+ def test_unknown_rule_field_warns_and_is_preserved(self):
+ data = {
+ "schema_version": 1,
+ "rules": [_rule("a.md", "keep", mystery_field="surprise")],
+ }
+ writer = RulesFileWriter()
+ canonical, warnings = writer.canonicalize(data)
+ assert canonical["rules"][0]["mystery_field"] == "surprise"
+ assert any("mystery_field" in w for w in warnings)
+
+ def test_unknown_consult_field_warns_and_is_preserved(self):
+ data = {
+ "schema_version": 1,
+ "rules": [],
+ "nominations": {
+ "consults": [
+ {
+ "glob": "a/consult.md",
+ "question": "q",
+ "evidence": "e",
+ "cluster_key": "k",
+ "asked_on": "2026-07-15",
+ "extra_field": "unexpected",
+ }
+ ],
+ "rejected": [],
+ },
+ }
+ writer = RulesFileWriter()
+ canonical, warnings = writer.canonicalize(data)
+ assert canonical["nominations"]["consults"][0]["extra_field"] == "unexpected"
+ assert any("extra_field" in w for w in warnings)
+
+ def test_unknown_rejected_field_warns_and_is_preserved(self):
+ data = {
+ "schema_version": 1,
+ "rules": [],
+ "nominations": {
+ "consults": [],
+ "rejected": [
+ {
+ "glob": "a/reject.md",
+ "lifetime": "temporary",
+ "why": "why",
+ "rejected_by": "judge",
+ "judged_on": "2026-07-15",
+ "surprise_field": "oops",
+ }
+ ],
+ },
+ }
+ writer = RulesFileWriter()
+ canonical, warnings = writer.canonicalize(data)
+ assert canonical["nominations"]["rejected"][0]["surprise_field"] == "oops"
+ assert any("surprise_field" in w for w in warnings)
+
+
+class TestLoad:
+ def test_load_reads_file_and_returns_canonical_data(self, tmp_path):
+ data = {
+ "schema_version": 1,
+ "rules": [_rule("a/keep.md", "keep")],
+ }
+ path = tmp_path / ".dochygiene-rules.json"
+ path.write_text(json.dumps(data), encoding="utf-8")
+ writer = RulesFileWriter()
+ loaded, warnings = writer.load(path)
+ assert loaded["rules"][0]["glob"] == "a/keep.md"
+ assert warnings == []
+
+ def test_load_missing_file_returns_empty_envelope(self, tmp_path):
+ path = tmp_path / "does-not-exist.json"
+ writer = RulesFileWriter()
+ loaded, warnings = writer.load(path)
+ assert loaded["rules"] == []
+ assert "nominations" not in loaded
+ assert warnings == []