os-doc-hygiene: calibrate assessment inventory implemented (map #49)
RulesFileWriter (canonical .dochygiene-rules.json serialization) + NominationIntakeFilter (deterministic Step 3.5) in calibrate_helpers.py; calibrate SKILL.md Steps 3.5/5/6 wired (open-consults exits, keep/decline/ consult persistence through the writer); patch_applier extract-then-delete appends the extracted.md vault pointer fail-closed; clean SKILL.md Step 6.5 supplies extraction_pointer. Suite 434 passed (26 new tests). OpenSpec change: calibrate-assessment-inventory. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LjFRroqLsvkL2WrMJtRBrK
This commit is contained in:
parent
9f11df3e2a
commit
0d303caf43
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -0,0 +1,2 @@
|
|||
schema: spec-driven
|
||||
created: 2026-07-15
|
||||
|
|
@ -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 `<dir>/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.
|
||||
|
|
@ -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`.
|
||||
|
|
@ -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
|
||||
|
|
@ -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 <path>` 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
|
||||
|
|
@ -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
|
||||
|
|
@ -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 `<dir>/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
|
||||
|
|
@ -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", "<no 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": <all open consults, passed through unconditionally>,
|
||||
}
|
||||
"""
|
||||
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),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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: <scan.project_root>
|
||||
|
||||
Nominations to judge (verbatim):
|
||||
<contents of $SCRATCH/nominations.json>
|
||||
Nominations to judge (verbatim, each with related_rejections):
|
||||
<contents of $SCRATCH/intake.json's "survivors">
|
||||
|
||||
Nominations memory — open consults (unconditional, may be empty):
|
||||
<contents of $SCRATCH/intake.json's "consults">
|
||||
|
||||
Seed intake: <see "Seed intake" below — include or omit per pass>
|
||||
|
||||
|
|
@ -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: <glob>
|
||||
Asked: <asked_on> Cluster: <cluster_key>
|
||||
Question: <question>
|
||||
Evidence: <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 `<project-root>/.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).
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
```
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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."""
|
||||
|
||||
|
|
|
|||
|
|
@ -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 == []
|
||||
Loading…
Reference in New Issue