os-doc-hygiene: sync lifecycle delta specs to main + archive change

Main specs gain calibrate, determinism-promotion, lifecycle-deletion,
lifecycle-rulebook (new) and rulebook-aware updates to doc-check,
doc-clean, report-schema. Change archived to
openspec/changes/archive/2026-07-15-lifecycle-aware-doc-hygiene.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LAw7YxP71Fk6K9AHNaYPj2
This commit is contained in:
jared 2026-07-15 08:51:21 -04:00
parent 7b9afb1f6a
commit fe04297604
20 changed files with 1034 additions and 60 deletions

View File

@ -0,0 +1,246 @@
# Spec: calibrate
## Purpose
Defines the `:calibrate` skill, which proposes new lifecycle rulebook rules by
clustering the pool of files the rulebook currently leaves unmatched, having a
cheap model nominate generalizable glob patterns, and having a strong model
judge and report those nominations to a human before any persistence.
## Requirements
### Requirement: Calibrate Clusters and Samples Over Unmatched Files
The `:calibrate` skill SHALL run over the unmatched-files pool (unmatched =
unmanaged, per `lifecycle-rulebook`) as its candidate pool. It SHALL cluster
unmatched paths by shape before nominating any rule, so that a proposed rule
is authored against a cluster of similar paths rather than a single file.
#### Scenario: Calibrate operates only on the unmatched pool
- **WHEN** `:calibrate` runs
- **THEN** its candidate pool is exactly the set of files the rulebook currently leaves unmatched
#### Scenario: Rules are proposed against clusters, not single files
- **WHEN** `:calibrate` nominates a candidate rule
- **THEN** the nomination is derived from a cluster of similar unmatched paths, not from one instance in isolation
### Requirement: Cheap-Model Nomination Produces Patterns, Never Exact-Instance Globs
For each sampled cluster, `:calibrate` SHALL dispatch a haiku subagent
constrained to nominate a bare glob pattern plus a candidate lifetime. The
haiku nomination SHALL be constrained to produce generalizable patterns; it
SHALL NOT be accepted as final if it hardcodes an identifier unique to a
single instance (a run-id, hash, or bare timestamp).
#### Scenario: Haiku nominates a glob and lifetime per cluster
- **WHEN** a cluster of unmatched paths is sampled
- **THEN** the haiku subagent returns a bare glob pattern and a candidate lifetime for that cluster
#### Scenario: Instance-unique nominations are not accepted as final
- **WHEN** a haiku nomination's glob hardcodes a run-id, hash, or bare timestamp unique to one file
- **THEN** it is not persisted as-is; it must be caught and generalized before the strong-model judgment step, per the rule-quality tests below
### Requirement: Strong-Model Batched Judgment with Confirm/Reject/Amend/Consult
`:calibrate` SHALL dispatch one batched strong-model (Opus or Fable) judge
subagent that gathers its own evidence (re-reading matched paths and
checking near-miss boundaries) and authors final rule entries. The judge
SHALL return one of four verdicts per nominated rule: `confirm`, `reject`,
`amend`, or `consult`. `consult` SHALL be mandatory whenever an artifact's
purpose is unclear (i.e., the judge cannot determine whether the artifact is
regenerable or must be retained).
#### Scenario: Judge gathers its own evidence rather than trusting the nomination
- **WHEN** the strong-model judge evaluates a haiku nomination
- **THEN** it independently re-reads matched paths and checks near-miss boundaries rather than accepting the nomination's claims at face value
#### Scenario: Four verdicts are the only possible outcomes
- **WHEN** the judge evaluates a nominated rule
- **THEN** its verdict is exactly one of `confirm`, `reject`, `amend`, or `consult`
#### Scenario: Consult is mandatory when purpose is unclear
- **WHEN** the judge cannot determine whether a clustered artifact type is regenerable or must be retained
- **THEN** the verdict is `consult`, never `confirm` or `reject`
### Requirement: Rule Report to the Human Before Persistence
Before any proposed rule is persisted, `:calibrate` SHALL present a rule
report to the human containing, per proposed rule: the glob verbatim exactly
as it would be persisted; every path it currently matches (or a capped
sample plus a total count); the near-miss boundary — paths that do NOT match
despite looking similar; the lifetime and behavior tier (auto vs confirm);
and a plain-language explanation of what the artifact is and why it is
clutter. No rule SHALL be persisted before this report has been shown.
#### Scenario: The report shows the exact persisted glob
- **WHEN** a rule report is generated for a proposed rule
- **THEN** the glob shown is character-for-character identical to what would be written to the rulebook file
#### Scenario: The report shows current matches with a capped sample
- **WHEN** a proposed rule matches more paths than the display cap
- **THEN** the report shows a capped sample of matched paths plus the total count of all matches
#### Scenario: The report shows the near-miss boundary
- **WHEN** a proposed rule's glob narrowly excludes similar-looking paths
- **THEN** the report explicitly lists those near-miss non-matching paths, so a boundary bug (e.g. a glob silently missing a sibling path) is visible before persistence
#### Scenario: No rule is persisted before the report is shown
- **WHEN** `:calibrate` has generated proposed rules
- **THEN** it does not write any rule to a rulebook file until the human has seen the rule report for it
### Requirement: Persistence Rules by Scope
Project-rulebook writes SHALL land on judge confirmation once the human has
reviewed the rule report. Global-rulebook writes (writing into
`plugins/os-doc-hygiene/rulebook.json`) SHALL additionally require explicit
human gating, distinct from project-rule confirmation, since it is a
cross-repo write into cc-os. Rule removals SHALL be HITL-only in all cases,
with recorded reasoning, regardless of scope.
#### Scenario: Project rule persists on judge confirmation plus report review
- **WHEN** the judge verdict is `confirm` for a project-scoped rule and the human has reviewed its rule report
- **THEN** the rule is written to the project's `.dochygiene-rules.json`
#### Scenario: Global rulebook writes require an additional explicit gate
- **WHEN** a proposed rule would be written to the global `rulebook.json`
- **THEN** a distinct human confirmation for the cross-repo write is required, beyond the project-rule confirmation step
#### Scenario: Rule removal is always HITL-only
- **WHEN** any rule (project or global) is proposed for removal
- **THEN** the removal happens only via explicit human instruction, with the reasoning recorded, never as an automatic side effect of a calibration pass
### Requirement: Retest Loop with Stop Conditions and Hard Cap
`:calibrate` SHALL re-run its clustering pass against the shrunk unmatched
pool after each round of persisted rules. It SHALL stop when a round yields
fewer than 2 new rules OR shrinks the unmatched pool by less than 10%. It
SHALL hard-cap at 3 rounds regardless of shrink rate.
#### Scenario: Stops on fewer than 2 new rules
- **WHEN** a retest round yields only 1 new confirmed rule
- **THEN** the retest loop stops after that round
#### Scenario: Stops on less than 10% shrink
- **WHEN** a retest round shrinks the unmatched pool by less than 10%
- **THEN** the retest loop stops after that round, even if 2 or more rules were confirmed
#### Scenario: Hard cap of 3 rounds regardless of shrink
- **WHEN** three retest rounds have run and each still meets the continuation criteria (≥2 new rules and ≥10% shrink)
- **THEN** the retest loop stops after the third round regardless
### Requirement: Seed Intake at Judge Intake
The clutter-inventory seed candidates SHALL enter the calibration protocol at
judge intake. Full seed intake SHALL apply to every calibration run after
calibration pass #1; pass #1 uses the one-off seed hold-out described in the
`Calibration Pass Validation Criteria` requirement below.
#### Scenario: Seed candidates feed the judge step
- **WHEN** a calibration run (other than pass #1) begins
- **THEN** the clutter-inventory seed candidates are included as judge-intake evidence
### Requirement: Rule-Quality Test — Class Never Path
A proposed rule's glob SHALL name a recurring class of artifact, never an
identifier unique to a single instance. A glob that hardcodes a name
recurring by convention (e.g. `PRD.md`, `HANDOFF-*.md`,
`migration-report.md`) is acceptable. A glob that hardcodes a run-id, hash,
or bare timestamp unique to one instance is not acceptable. A rule that
currently matches only one file is acceptable; a rule that can, by
construction, only ever match one file is a failed generalization and SHALL
be flagged loudly rather than silently persisted.
#### Scenario: A convention-recurring name is acceptable
- **WHEN** a proposed rule's glob is `HANDOFF-*.md`
- **THEN** it passes the class-never-path test, since `HANDOFF-*` is a recurring naming convention, not a single instance
#### Scenario: An instance-unique identifier fails the test
- **WHEN** a proposed rule's glob hardcodes a specific run-id or hash string that can only ever identify one artifact
- **THEN** the rule fails the class-never-path test and is flagged loudly, not silently persisted
#### Scenario: One current match is fine; one-EVER match is not
- **WHEN** a proposed rule currently matches exactly one file
- **THEN** it is acceptable if the glob's structure could match future similarly-named files; it is flagged as a failed generalization if the glob's structure can never match any file but the one it names today
### Requirement: Rule-Quality Tie-Breaker — Prefer the Narrower Glob
When choosing between candidate globs of differing breadth for the same
cluster, `:calibrate` SHALL prefer the narrower glob. Too-narrow failure is
self-healing (clutter is merely left for a later round to catch); too-broad
failure is dangerous (a keeper file may be deleted and is not
self-healing).
#### Scenario: Narrower glob is chosen when both would satisfy the cluster
- **WHEN** two candidate globs both cover the sampled cluster, one narrower and one broader
- **THEN** the narrower glob is chosen
#### Scenario: Rationale is evidence quality, not readability alone
- **WHEN** justifying the narrower-glob preference
- **THEN** the reasoning cited is that too-narrow fails safe (self-healing) while too-broad fails dangerous (not self-healing), not merely stylistic preference
### Requirement: Calibration Pass Validation Criteria
A calibration pass SHALL be judged against: a precision hard gate (the pass
FAILS if any persisted rule's glob matches a protected path, regardless of
behavior tier — exploration-time `consult` verdicts on protected paths are
free and do not fail the pass); a recall floor of 8 of the 10 rows of the
project's clutter inventory, with 4 specific rows mandatory (missing any
mandatory row fails the pass); a one-off seed hold-out for calibration pass
#1 only (the sealed answer key is withheld from judge intake for pass #1;
every later run uses full seed intake); treatment of IGNORE-surface paths as
void, not a miss, against the recall floor; and the requirement that a
do-nothing pass cannot pass (the recall floor makes the pass falsifiable in
the finding direction).
#### Scenario: A rule matching a protected path fails the pass
- **WHEN** any persisted rule's glob matches a path in the fixed protected set
- **THEN** the calibration pass fails, regardless of whether that rule's tier was auto or confirm
#### Scenario: Consult verdicts on protected paths do not fail the pass
- **WHEN** the judge issues a `consult` verdict during exploration for a candidate touching a protected path, and that candidate is not persisted
- **THEN** the pass is not failed by that consult verdict
#### Scenario: Recall floor requires 8 of 10 with 4 mandatory
- **WHEN** a calibration pass is graded against the clutter inventory
- **THEN** it must recall at least 8 of the 10 inventory rows, and all 4 mandatory rows must be among them, or the pass fails
#### Scenario: Pass #1 seed hold-out is a one-off
- **WHEN** calibration pass #1 runs
- **THEN** the sealed answer key (the project's clutter-inventory rows) is withheld from judge intake; every subsequent calibration run instead uses full seed intake
#### Scenario: IGNORE-surface rows are void, not a miss
- **WHEN** grading recall against the clutter inventory and a row corresponds to an IGNORE-surface path
- **THEN** that row is excluded from the recall calculation entirely (void), not counted as a miss
#### Scenario: A do-nothing pass cannot pass
- **WHEN** a calibration pass persists zero rules
- **THEN** it fails the recall floor and therefore cannot pass

View File

@ -0,0 +1,106 @@
# Spec: determinism-promotion
## Purpose
Defines the global, machine-readable completion-conventions catalog and the
deterministic pipeline steps that use it to recommend graduating a
classifier-judged (`served_when`) lifecycle rule to a scanner-provable
(`served_when_path`) one, without ever applying an adoption unasked.
## Requirements
### Requirement: Conventions Catalog Is Global-Only and Machine-Readable
The plugin SHALL ship a global-only, machine-readable completion-conventions
catalog at `plugins/os-doc-hygiene/conventions.json`. There SHALL be no
per-project override of this catalog — it only recommends; adoption of a
convention lands in the project's own rulebook, not in a project-specific
copy of the catalog.
#### Scenario: The catalog is read from a single global location
- **WHEN** the deterministic pipeline consults the conventions catalog
- **THEN** it reads `plugins/os-doc-hygiene/conventions.json` and no project-level override file for it exists or is consulted
#### Scenario: Adoption is a rulebook write, not a catalog write
- **WHEN** a project adopts a convention
- **THEN** the adoption is expressed as a graduated rule in the project's `.dochygiene-rules.json`, and the global catalog itself is unchanged
### Requirement: Convention Entry Shape
Each entry in the conventions catalog SHALL record: a name, a description of
what the convention proves (i.e., what filesystem-observable condition
counts as "done"), the `served_when_path` pattern or frontmatter template a
rule graduates to when the convention is adopted, and a one-line human pitch
for why adopting it is worthwhile.
#### Scenario: An entry carries all required fields
- **WHEN** a convention entry is read from the catalog
- **THEN** it includes a name, a "what it proves" description, the graduation target (`served_when_path` pattern or frontmatter template), and a one-line pitch
### Requirement: v1 Catalog Contains Exactly Two Conventions
The v1 conventions catalog SHALL contain exactly two entries: `archive-bucket`
(done = the file moved into a sibling `archive/` directory, graduating a rule
to `served_when_path: <dir>/archive/{name}`) and `status-frontmatter` (done =
a `status: shipped|done` frontmatter key is present, with the file staying in
place and the scanner reading the frontmatter). No other conventions SHALL be
present in v1.
#### Scenario: Exactly two conventions ship in v1
- **WHEN** the v1 conventions catalog is loaded
- **THEN** it contains exactly the `archive-bucket` and `status-frontmatter` entries and no others
#### Scenario: archive-bucket graduates to a served_when_path
- **WHEN** a project adopts `archive-bucket` for a rule
- **THEN** the rule graduates from a classifier-judged `served_when` to a scanner-provable `served_when_path` pointing at the sibling `archive/` directory
#### Scenario: status-frontmatter graduates via a frontmatter key
- **WHEN** a project adopts `status-frontmatter` for a rule
- **THEN** the rule graduates to a condition the scanner can check deterministically by reading a `status: shipped|done` frontmatter key, with the file remaining at its original path
### Requirement: Check Names Promotion Candidates Deterministically in Every Report
The `:check` skill's deterministic finalize pass SHALL, for every entry whose
lifecycle signal is classifier-judged (`served_when` present, no
`served_when_path`), check the conventions catalog for an applicable
not-yet-adopted convention and, when one applies, emit an entry in the
report's `promotion_candidates` section naming the convention and its
one-line pitch. This check SHALL run without any model call.
#### Scenario: A classifier-judged entry with an applicable convention is named
- **WHEN** an entry uses `served_when` and the `archive-bucket` convention is not yet adopted for that rule
- **THEN** the report's `promotion_candidates` section names `archive-bucket` for that entry with its one-line pitch
#### Scenario: Promotion-candidate naming requires no model call
- **WHEN** `report_builder.py` computes `promotion_candidates`
- **THEN** it does so by reading `conventions.json` and the project's rulebook state directly, with no subagent dispatch
#### Scenario: An already-adopted convention is not re-named
- **WHEN** a rule has already graduated to a `served_when_path` matching a catalog convention
- **THEN** that rule does not reappear in `promotion_candidates`
### Requirement: Calibrate May Draft Adoption but Never Applies Unasked
The `:calibrate` skill MAY draft the adoption of a catalog convention — the
graduated rule plus the file moves or frontmatter additions the convention
implies — and present it to the human for approval. It SHALL NEVER apply an
adoption without explicit human confirmation.
#### Scenario: Calibrate drafts a convention adoption for review
- **WHEN** `:calibrate` identifies an unmatched pattern that would benefit from `archive-bucket`
- **THEN** it drafts the graduated rule and the implied file moves and presents them to the human before any change is made
#### Scenario: No adoption is applied without confirmation
- **WHEN** a human has not yet confirmed a drafted adoption
- **THEN** no rulebook write and no file move occurs

View File

@ -35,20 +35,25 @@ arguments or unknown arguments SHALL print usage plus the current status.
### Requirement: Check Skill Orchestrates Scan, Classification, and Report Writing
The `check` skill SHALL orchestrate the check pipeline: run the deterministic
scanner, dispatch a Sonnet subagent for judgment-only classification of the
signal-bearing candidates, run the deterministic finalize pass, validate, write the
report pair, and stamp `last_check`. The skill SHALL run all non-judgment steps as
deterministic scripts with no model (invariant #6). Zero-signal shortlisted files
SHALL be treated as presumptively cleared: they SHALL remain in the shortlist,
produce no entries, and SHALL NOT be read by the model. A `--scope` argument SHALL
narrow the scanner; a `--category` argument SHALL filter which entries are produced
after classification; both SHALL be recorded in the human-report header.
The `check` skill SHALL orchestrate the check pipeline: load the lifecycle
rulebook (global plus any project override), run the deterministic scanner
(consuming the rulebook so directory-rule matches prune the walk and
lifecycle signals are attached per the `lifecycle-rulebook` spec), dispatch
a Sonnet subagent for judgment-only classification of the signal-bearing
candidates, run the deterministic finalize pass (which also computes
`promotion_candidates` from `conventions.json`), validate, write the report
pair, and stamp `last_check`. The skill SHALL run all non-judgment steps as
deterministic scripts with no model (invariant #6). Zero-signal shortlisted
files SHALL be treated as presumptively cleared: they SHALL remain in the
shortlist, produce no entries, and SHALL NOT be read by the model. A
`--scope` argument SHALL narrow the scanner; a `--category` argument SHALL
filter which entries are produced after classification; both SHALL be
recorded in the human-report header.
#### Scenario: Skill runs the full pipeline
- **WHEN** the `check` skill runs
- **THEN** it scans (deterministic), classifies signal-bearing candidates (Sonnet), finalizes (deterministic), validates (deterministic), writes the report pair (deterministic), and stamps `last_check` (deterministic)
- **THEN** it loads the rulebook, scans (deterministic, rulebook-aware), classifies signal-bearing candidates (Sonnet), finalizes (deterministic, including promotion candidates), validates (deterministic), writes the report pair (deterministic), and stamps `last_check` (deterministic)
#### Scenario: Zero-signal files are not read by the model
@ -60,6 +65,51 @@ after classification; both SHALL be recorded in the human-report header.
- **WHEN** the user passes `--scope docs/**/*.md` and `--category bloat`
- **THEN** the scanner is narrowed by the scope, only `bloat` entries are produced after classification, and both the scope and the category are recorded in the human-report header
#### Scenario: Rulebook load failure is a hard failure, not a silent skip
- **WHEN** the rulebook loader hard-fails (unparseable JSON or unknown `schema_version` in either rulebook file)
- **THEN** the check skill stops and reports the rulebook error before running the scanner, rather than proceeding with lifecycle signals silently disabled
### Requirement: Scanner Consumes the Rulebook for Pruning and Lifecycle Signals
The deterministic scanner SHALL consult the loaded rulebook during its walk.
A directory-rule match (including IGNORE-surface entries) SHALL prune the
walk beneath that directory per the `lifecycle-rulebook` spec. A file-rule
match SHALL attach a lifecycle signal to that file's shortlist entry. These
lifecycle signals SHALL flow into the classification subagent as a new
signal class alongside the pre-existing stale/bloat signals, and MAY drive
`op`/`op_type` selection toward `delete` or `extract-then-delete` per the
`lifecycle-deletion` spec.
#### Scenario: A directory-rule prune is reflected in the scan artifact
- **WHEN** the scanner encounters a directory matching a directory rule
- **THEN** the scan artifact reflects the prune (no files beneath it are in `files_scanned`), and, for non-IGNORE directory rules, exactly one aggregate shortlist entry appears for that directory
#### Scenario: A file-rule lifecycle signal reaches the classifier
- **WHEN** a file matches a file-rule with `lifetime: delete-once-served`
- **THEN** the classification subagent receives the lifecycle signal (rule reference, lifetime, served_when/served_when_path) as part of that file's signals, verbatim, per the existing "signals are passed through verbatim" contract
### Requirement: Report Gains a Promotion-Candidates Section
The machine and human reports produced by `:check` SHALL include a
`promotion_candidates` section (top-level, sibling to `entries`), populated
deterministically by the finalize pass from `conventions.json` for every
classifier-judged lifecycle entry with an applicable, not-yet-adopted
convention. This section SHALL be present (possibly empty) on every run,
including runs with no lifecycle entries.
#### Scenario: A run with an applicable convention names it in both reports
- **WHEN** a classifier-judged entry has an applicable, unadopted convention
- **THEN** both the machine report's `promotion_candidates` array and the human report show the candidate with its one-line pitch
#### Scenario: A run with no applicable conventions still has the section, empty
- **WHEN** no classifier-judged entry has an applicable unadopted convention
- **THEN** `promotion_candidates` is present as an empty array/section rather than omitted
### Requirement: Classification Subagent Returns Judgment-Only Proposals
The Sonnet classification subagent SHALL return, per signal-bearing candidate, a slim

View File

@ -87,14 +87,19 @@ SHALL NOT attempt to compose or sequence incompatible ops in v1.
### Requirement: Safety-Tier Gating Before Any Mutation
The clean skill SHALL partition report entries by safety tier (read from the report —
never recomputed) into `auto` entries (applied without prompt) and `confirm` entries
(escalated before any mutation). It SHALL present all `confirm`-tier entries as a
single batch-confirm list showing path, category, op, token count, and rationale with
per-entry opt-out, visually distinguishing irreversible `delete-range` entries from
reversible entries. The approved set SHALL be all `auto` entries plus any
user-approved `confirm` entries. The gate SHALL run identically under `sweep` — the
`/os-doc-hygiene:sweep` convenience path SHALL NOT bypass invariant #7.
The clean skill SHALL partition report entries by safety tier (read from the
report — never recomputed) into `auto` entries (applied without prompt) and
`confirm` entries (escalated before any mutation). It SHALL present all
`confirm`-tier entries as a single batch-confirm list showing path, category,
op, token count, and rationale with per-entry opt-out, visually
distinguishing irreversible `delete-range`, `delete`, and
`extract-then-delete` entries from reversible entries. The approved set
SHALL be all `auto` entries plus any user-approved `confirm` entries. The
gate SHALL run identically under `sweep` — the `/os-doc-hygiene:sweep`
convenience path SHALL NOT bypass invariant #7. Regardless of the tier
recorded in the report, any `delete` or `extract-then-delete` entry SHALL be
re-verified at apply time per the `lifecycle-deletion` tier matrix (tracked
+ clean required for auto) before being applied without a prompt.
#### Scenario: auto entries apply without prompt
@ -103,7 +108,7 @@ user-approved `confirm` entries. The gate SHALL run identically under `sweep`
#### Scenario: confirm entries escalate before any mutation
- **WHEN** the report contains confirm-tier entries (delete-range or any generative op)
- **WHEN** the report contains confirm-tier entries (delete-range, delete, extract-then-delete, or any generative op)
- **THEN** the clean skill presents a batch-confirm list before any file is modified, and applies only user-approved entries
#### Scenario: per-entry opt-out is respected
@ -116,29 +121,41 @@ user-approved `confirm` entries. The gate SHALL run identically under `sweep`
- **WHEN** the user runs /os-doc-hygiene:sweep and the report contains confirm-tier entries
- **THEN** the confirm gate runs identically to a standalone /os-doc-hygiene:clean
#### Scenario: A report-tier auto delete is downgraded if runtime state has changed
- **WHEN** a `delete` entry was tiered `auto` in the report but the applier's runtime check finds the file is now dirty or untracked
- **THEN** the applier does not apply it silently; it is skipped and reported for re-analysis, never trusted from the cached tier
### Requirement: Git-Safe Single Commit
The clean skill SHALL produce exactly one git commit per run. Before any mutation it
SHALL resolve the project root via `StateStore`, run `git status --porcelain`, and
if the tree is dirty, SHALL automatically create a WIP checkpoint commit of the
user's work before proceeding, so the cleanup commit remains exactly one. The cleanup
commit SHALL be created via `git-context commit-apply --message-stdin` with a
generated message summarizing auto/confirmed/skipped counts and op breakdown.
Staging SHALL be precise: for non-move ops the skill calls `git add <staged_paths>`
from the applier result; for `move-to-archive` the applier calls `git mv` (staging
both sides) and the skill SHALL NOT `git add` the destination path again. The skill
SHALL NEVER use `git add -A` or `git add .`. If a hard failure occurs (applier
exit 2, `git mv` fail, or write error), the skill SHALL roll back via
`git restore`/`reset` to the pre-run baseline and abort with a structured error.
Partial success (some file batches guard-skipped) SHALL NOT trigger rollback — the
skill SHALL commit what applied and report skipped files. Untracked candidate docs
SHALL be skipped and reported (tracked-files-only). `last_clean` SHALL be stamped
to the commit instant, not the run-start instant.
The clean skill SHALL produce exactly one git commit per run. Before any
mutation it SHALL resolve the project root via `StateStore`, run
`git status --porcelain`, and if the tree is dirty, SHALL automatically
create a WIP checkpoint commit of the user's work before proceeding, so the
cleanup commit remains exactly one. The cleanup commit SHALL be created via
`git-context commit-apply --message-stdin` with a generated message
summarizing auto/confirmed/skipped counts and op breakdown, including
lifecycle delete/extract-then-delete counts when present. Staging SHALL be
precise: for non-move, non-delete ops the skill calls `git add
<staged_paths>` from the applier result; for `move-to-archive` the applier
calls `git mv` (staging both sides) and the skill SHALL NOT `git add` the
destination path again; for `delete` and the delete half of
`extract-then-delete` the applier calls `git rm` (staging the removal
itself) and the skill SHALL NOT separately stage the removed path. The skill
SHALL NEVER use `git add -A` or `git add .`. If a hard failure occurs
(applier exit 2, `git mv`/`git rm` fail, or write error), the skill SHALL
roll back via `git restore`/`reset` to the pre-run baseline and abort with a
structured error. Partial success (some file batches guard-skipped) SHALL
NOT trigger rollback — the skill SHALL commit what applied and report
skipped files. Untracked candidate docs SHALL be skipped and reported
(tracked-files-only) except where a lifecycle rule explicitly escalates an
untracked delete to confirm and the user approves it. `last_clean` SHALL be
stamped to the commit instant, not the run-start instant.
#### Scenario: Clean tree produces exactly one commit
- **WHEN** the user runs /os-doc-hygiene:clean with a clean git tree
- **THEN** the run produces exactly one git commit containing all applied edits
- **THEN** the run produces exactly one git commit containing all applied edits, including any lifecycle deletes
#### Scenario: Dirty tree gets a WIP checkpoint then one cleanup commit
@ -152,7 +169,7 @@ to the commit instant, not the run-start instant.
#### Scenario: Hard failure triggers rollback
- **WHEN** a write error or applier exit 2 occurs mid-run
- **WHEN** a write error, applier exit 2, or a `git rm` failure occurs mid-run
- **THEN** the skill rolls back to the pre-run baseline and aborts with a structured error; no partial commit is created
#### Scenario: Partial success commits what applied
@ -165,6 +182,50 @@ to the commit instant, not the run-start instant.
- **WHEN** the applier stages a move-to-archive via git mv (both source and dest)
- **THEN** the skill does not call git add on the destination path again
#### Scenario: A lifecycle delete is not double-staged
- **WHEN** the applier stages a `delete` via `git rm`
- **THEN** the skill does not separately call `git add` or any other staging command on the removed path
### Requirement: Applier Applies Delete and Extract-Then-Delete Under the Tier Matrix
The patch applier SHALL support two new op kinds, `delete` and
`extract-then-delete`. For both, immediately before applying, it SHALL
re-run `git ls-files <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).
#### Scenario: delete performs a true git rm at apply time
- **WHEN** the applier applies a `delete` entry
- **THEN** it re-verifies tracked/clean status via `git ls-files` and a dirty check, then performs a `git rm` staged into the single hygiene commit
#### Scenario: extract-then-delete only deletes after extraction succeeds
- **WHEN** the applier applies an `extract-then-delete` entry
- **THEN** it completes the extraction write (ADR/CLAUDE.md/docs or vault) first, and performs the `git rm` only after that write succeeds
#### Scenario: A failed extraction skips the delete for that entry
- **WHEN** the extraction step of an `extract-then-delete` entry fails
- **THEN** the delete is not applied for that entry, the entry is reported as skipped, and the run is not treated as a hard failure unless the failure matches an existing hard-failure trigger (applier exit 2, write error)
#### Scenario: Directory-rule aggregate delete removes the whole directory
- **WHEN** the applier applies a `delete` entry whose path is a directory-rule aggregate entry
- **THEN** it performs a recursive `git rm` removing the entire matched directory in one operation staged into the single hygiene commit
### Requirement: Clean Skill Orchestration
The `clean` skill SHALL load the current report via `StateStore.read_report`

View File

@ -0,0 +1,181 @@
# Spec: lifecycle-deletion
## Purpose
Defines true git deletion for lifecycle-managed artifacts: the deletion
autonomy tier matrix (evidence quality and recoverability, not file type),
temporary-tier retention semantics, age computation, the split between
scanner-provable and classifier-judged served signals, and the extract
modifier's routing through existing knowledge destinations.
## Requirements
### Requirement: Delete Is True Git Deletion in a Dedicated Hygiene Commit
A lifecycle `delete` op SHALL perform a true `git rm` (file or, for a
directory-rule aggregate entry, recursive directory removal) staged into the
same single hygiene commit the run produces. There SHALL be no archive
directory, graveyard branch, or other relocation of the deleted content —
git history SHALL be the sole archive.
#### Scenario: A deleted file is git-rm'd, not moved
- **WHEN** a `delete` op is applied to a tracked file
- **THEN** the file is removed via `git rm` and staged into the run's single hygiene commit, with no copy relocated anywhere in the working tree
#### Scenario: A deleted directory-rule entry is removed recursively
- **WHEN** a `delete` op targets a directory-rule aggregate entry
- **THEN** the entire directory is removed via a recursive `git rm` staged into the same commit
### Requirement: Deletion Autonomy Tier Matrix
Deletion autonomy SHALL be determined by evidence quality and recoverability,
not file type, per the following matrix:
| Case | Behavior |
|---|---|
| IGNORE surface | never walked, never a delete candidate |
| lifetime `keep` | scanned + reported, never deleted |
| tracked + delete rule + clean worktree | auto |
| tracked + delete rule + dirty worktree | confirm |
| untracked + delete rule | confirm |
| no rule match | unmanaged, never deleted |
Tracked/clean status SHALL be verified at runtime via `git ls-files` plus a
dirty check, and SHALL NEVER be trusted from the rule's own claim or from a
cached report field.
#### Scenario: Tracked and clean deletes automatically
- **WHEN** a file matches a delete rule, is tracked, and the worktree for that path is clean
- **THEN** the deletion proceeds without a confirmation prompt
#### Scenario: Tracked but dirty requires confirmation
- **WHEN** a file matches a delete rule, is tracked, but has uncommitted changes
- **THEN** the deletion is escalated to confirm — an uncommitted diff would otherwise be lost with the file
#### Scenario: Untracked requires confirmation
- **WHEN** a file matches a delete rule but is untracked
- **THEN** the deletion is escalated to confirm — there is no git history to recover it from
#### Scenario: Runtime verification never trusts the rule
- **WHEN** a delete op is about to be applied
- **THEN** the applier re-checks tracked/clean status via `git ls-files` and a dirty check at that moment, regardless of what the report or rule previously claimed
#### Scenario: No rule match is never deleted
- **WHEN** a file has no lifecycle rule match
- **THEN** it is never a candidate for deletion on lifecycle grounds
### Requirement: Temporary Tier Retention Semantics
The `temporary` lifetime SHALL use retain-recent-N plus age, not age alone.
`retain_recent` (default 3) SHALL always keep the N most recent matching
entries for a rule regardless of age. An entry ranked `retain_recent + 1` or
older SHALL become eligible for deletion once it exceeds `max_age_days`
(default 3). The retention unit SHALL be the rule's own match granularity: a
file-rule's unit is the individual file; a directory-rule's unit is the
matched directory as a whole (e.g., a run directory), never files nested
inside one such matched directory.
#### Scenario: The 3 newest entries are always kept regardless of age
- **WHEN** a temporary rule has `retain_recent: 3` and five matching entries, the three newest exceeding `max_age_days`
- **THEN** the three newest are kept and only the two oldest (ranked 4th and 5th) are eligible for deletion
#### Scenario: An entry younger than max_age_days is kept even if not in the top N
- **WHEN** a temporary rule's 4th-ranked entry is younger than `max_age_days`
- **THEN** it is not deleted this run
#### Scenario: Directory-rule retention unit is the whole directory
- **WHEN** a directory rule matches `autoresearch/<run-id>/` entries
- **THEN** retain-recent-N and age are computed per matched run directory as a whole, not per file within the newest run
### Requirement: Temporary Tier Age Source
Age for the temporary tier SHALL be computed from the git commit time of the
path's most recent commit, falling back to filesystem mtime only when the
path is untracked. There SHALL be no per-rule `age_source` override field.
#### Scenario: Tracked file age comes from git commit time
- **WHEN** age is computed for a tracked file matching a temporary rule
- **THEN** the age is derived from that file's most recent commit time, not its filesystem mtime
#### Scenario: Untracked file age falls back to mtime
- **WHEN** age is computed for an untracked file matching a temporary rule
- **THEN** the age is derived from the file's filesystem mtime
#### Scenario: No per-rule age_source field exists
- **WHEN** a rule in the rulebook is inspected
- **THEN** it has no `age_source` field — the git-commit-time-with-mtime-fallback behavior is fixed, not configurable per rule
### Requirement: Untracked Directory Entry Age Uses Directory Inode mtime
For an untracked directory matched by a directory rule, age SHALL be
computed from a single `stat()` of the directory inode itself, not a
recursive walk computing the maximum mtime of its contents.
#### Scenario: Directory age is one stat call, not a recursive scan
- **WHEN** age is computed for an untracked directory matching a directory rule
- **THEN** the computation reads only the directory inode's own mtime and does not recurse into or stat any file inside it
### Requirement: delete-once-served Split by Evidence Quality
The `delete-once-served` lifetime SHALL support two mutually exclusive served
signals per rule: `served_when_path`, a deterministic path pattern the
scanner itself can prove satisfied (e.g. a sibling archive directory
existing), and `served_when`, free text describing a condition the
classifier must judge. A rule with `served_when_path` satisfied by the
scanner MAY be deleted under the autonomy tier matrix (i.e., auto when
tracked+clean). A rule relying on `served_when` SHALL ALWAYS be forced to
confirm, regardless of tracked/clean status, because it depends on a model
judgment rather than a provable filesystem fact.
#### Scenario: Scanner-proven served_when_path may auto-delete
- **WHEN** a rule's `served_when_path` condition is satisfied by the filesystem and the matched path is tracked and clean
- **THEN** the deletion may proceed automatically under the tier matrix
#### Scenario: Classifier-judged served_when always forces confirm
- **WHEN** a rule uses `served_when` (free text) and the classifier judges the condition met
- **THEN** the deletion is always escalated to confirm, even if the matched path is tracked and clean
#### Scenario: The LLM may propose but never silently destroy on served_when
- **WHEN** the classifier judges a `served_when` condition satisfied
- **THEN** it produces a proposal for a human to confirm; it never causes an unattended deletion
### Requirement: Extract Modifier Routes Through Existing Knowledge Destinations Only
The `extract` modifier on a deletion SHALL distill durable content before
deleting, routing exclusively through the existing knowledge-routing
destinations: repo-durable residue SHALL be written into an ADR, `CLAUDE.md`,
or a `docs/` file; cross-repo lessons SHALL be written to the SecondBrain
vault via `/os-vault:write`. No new destination (e.g., a "retired specs"
directory) SHALL be introduced.
#### Scenario: Repo-durable extraction targets ADR/CLAUDE.md/docs
- **WHEN** an `extract-then-delete` op is classified as repo-durable
- **THEN** the extracted content is written into an ADR, `CLAUDE.md`, or a `docs/` file, never a new bespoke location
#### Scenario: Cross-repo extraction routes through /os-vault:write
- **WHEN** an `extract-then-delete` op is classified as cross-repo
- **THEN** the extracted content is written to the SecondBrain vault via `/os-vault:write`, and no other cross-repo destination is used
#### Scenario: No new destination is introduced
- **WHEN** extraction routing is implemented
- **THEN** it reuses only the destinations named above; it does not create a new "retired" or "archive" content store

View File

@ -0,0 +1,224 @@
# Spec: lifecycle-rulebook
## Purpose
Defines the global + optional per-project rulebook files that declare
lifetime rules for known-clutter artifacts: their location and envelope,
glob dialect, two-axis merge precedence, per-rule fields, validation
behavior, the "unmatched means unmanaged" contract, the explicit IGNORE
surface, and how the scanner walk consumes rules for pruning and lifecycle
signal attachment.
## Requirements
### Requirement: Rulebook Locations and Envelope
The plugin SHALL ship a global rulebook at
`plugins/os-doc-hygiene/rulebook.json`, resolved relative to plugin scripts,
present in every project. A project MAY additionally provide a committed
repo-root `.dochygiene-rules.json` override. Both files SHALL use the
envelope `{"schema_version": 1, "rules": [...]}`. The per-project override
SHALL NOT live under gitignored `.cc-os/` — it SHALL be a committed,
reviewable dotfile.
#### Scenario: Global rulebook is always present
- **WHEN** the rulebook loader runs in any project
- **THEN** it loads `plugins/os-doc-hygiene/rulebook.json` resolved relative to the plugin scripts directory
#### Scenario: Per-project override is optional and committed
- **WHEN** a project has no `.dochygiene-rules.json` at its repo root
- **THEN** the loader proceeds using only the global rulebook, and when the file is present it is read as a committed, reviewable file, never from `.cc-os/`
#### Scenario: Both files share one envelope shape
- **WHEN** either the global rulebook or a project override is loaded
- **THEN** it is validated against the envelope `{"schema_version": 1, "rules": [...]}`
### Requirement: Glob Dialect Is glob.translate
Rule `glob` patterns SHALL be compiled using stdlib
`glob.translate(pattern, recursive=True, include_hidden=True)` (Python ≥
3.13). Patterns SHALL be interpreted as repo-root-relative, and `**` SHALL
match recursively. Each rule's glob SHALL be compiled once at rulebook load
time, not per matched path.
#### Scenario: Recursive double-star matches subtrees
- **WHEN** a rule's glob is `autoresearch/*/**`
- **THEN** it matches any file at any depth under any immediate subdirectory of `autoresearch/`
#### Scenario: Hidden files are matched when the pattern implies them
- **WHEN** a rule's glob targets a dotfile path
- **THEN** `include_hidden=True` semantics apply and the dotfile is matchable
#### Scenario: Compilation happens once per load
- **WHEN** the rulebook loader parses the rules array
- **THEN** each rule's glob is compiled to a matcher exactly once, and that compiled matcher is reused for every path checked during the run
### Requirement: Two-Axis Precedence with Add-Only Merge
The rulebook loader SHALL merge the project override over the global
rulebook using add-only semantics: project rules are appended to, and never
replace or delete, global rules. Precedence for a given path SHALL resolve
in this order: project file-rule > project directory-rule > global
file-rule > global directory-rule. Ties within the same precedence tier
SHALL be broken first by longest `glob` pattern, then by last-defined order.
A project SHALL neutralize a global rule only by adding a shadowing rule
with `lifetime: "keep"` at equal-or-higher precedence; there SHALL be no
rule-removal mechanism.
#### Scenario: Project file-rule outranks every other tier
- **WHEN** a path matches both a project file-rule and a global directory-rule
- **THEN** the project file-rule's fields govern
#### Scenario: Ties broken by longest pattern then last-defined
- **WHEN** two rules in the same precedence tier match the same path with different-length globs
- **THEN** the rule with the longer glob pattern governs; if the glob lengths are equal, the rule defined later in its rules array governs
#### Scenario: Neutralizing a global rule via keep-shadowing
- **WHEN** a project wants to exempt a path from a global delete rule
- **THEN** it adds a project rule matching that path with `lifetime: "keep"`, and no mechanism exists to remove or edit the global rule itself
#### Scenario: Merge never deletes a global rule
- **WHEN** the project override is loaded alongside the global rulebook
- **THEN** every global rule remains present and evaluable; the merge only adds project rules on top
### Requirement: Per-Rule Fields
A rule SHALL support the fields `glob`, `lifetime` (one of `keep`,
`temporary`, `delete-once-served`), `extract` (boolean modifier), `served_when`
(free text, classifier hint), `served_when_path` (deterministic sibling of
`served_when`), `retain_recent` (default `3`), `max_age_days` (default `3`),
`confirm` (boolean, human-settable-only escape hatch), `confirmed_by`
(`human` or a strong-model identifier), `confirmed_on`, `source`, and `note`.
A rule SHALL NOT support a `propagate_ignore` field in any form. A rule that
matches no path yet is undefined behavior only in the sense that unmatched
files receive no lifetime at all and flow through existing signals unchanged
— unmatched SHALL always mean unmanaged, never an implicit lifetime.
#### Scenario: Defaults apply when retain_recent/max_age_days are omitted
- **WHEN** a `temporary`-lifetime rule omits `retain_recent` and `max_age_days`
- **THEN** the loader applies `retain_recent = 3` and `max_age_days = 3`
#### Scenario: confirm:true may only be set by a human
- **WHEN** a rule is validated
- **THEN** a rule with `confirm: true` is accepted only if it is not proposed by a model in the same validation pass as an unconfirmed state — a model-authored rule proposal SHALL NOT itself set `confirm: true`; it may only recommend that a human set it
#### Scenario: propagate_ignore is rejected as an unknown field
- **WHEN** a rule in either rulebook file contains a `propagate_ignore` field
- **THEN** the loader treats it as an unrecognized field under the rule's validation (skip-and-warn, per the Validation requirement), since no such field is part of the schema
#### Scenario: Unmatched files receive no lifetime
- **WHEN** a file matches no rule in either rulebook
- **THEN** the rulebook query returns no match for that path, and the file flows through the existing (non-lifecycle) scanner signals unchanged, becoming a `:calibrate` candidate
### Requirement: Skip-and-Warn Validation, Hard-Fail Only on Structural Errors
The rulebook loader SHALL skip and warn on a per-rule basis for any rule that
is invalid or lacks `confirmed_by` — such a rule SHALL be loaded but marked
inactive and SHALL never contribute a lifecycle signal, while the rest of
the rulebook continues to load and function. The loader SHALL hard-fail
(non-zero exit / raised error) only for unparseable JSON or an unrecognized
`schema_version`.
#### Scenario: A rule missing confirmed_by is skipped, not fatal
- **WHEN** the rulebook contains one rule without `confirmed_by` and nine valid rules
- **THEN** the loader loads all ten rules, marks the one missing `confirmed_by` inactive (it never emits a signal), and the other nine function normally
#### Scenario: Unparseable JSON hard-fails
- **WHEN** either rulebook file is not valid JSON
- **THEN** the loader raises a hard failure and does not proceed with a partial rulebook
#### Scenario: Unknown schema_version hard-fails
- **WHEN** a rulebook file declares a `schema_version` the loader does not recognize
- **THEN** the loader raises a hard failure
### Requirement: Unmatched Means Unmanaged
Files that match no rule in either rulebook SHALL receive no lifecycle
signal and SHALL NOT be deleted, extracted, or otherwise treated as
lifecycle-managed by any component of this change. They remain visible only
through the existing stale/bloat signal pipeline and are the candidate pool
for `:calibrate`.
#### Scenario: No rule match means no lifecycle behavior
- **WHEN** a file matches no rulebook rule
- **THEN** no delete or extract-then-delete op is ever proposed for it on lifecycle grounds alone
### Requirement: IGNORE Surface Is an Explicit Seed List, Never Inferred from .gitignore
The rulebook SHALL define an explicit IGNORE surface as directory rules with
no lifetime (paths never walked at all, distinct from `keep`, which is
walked and reported but never deleted). The seed IGNORE members SHALL
include `graphify-out/**` and `.dochygiene/**`, plus any entries needed to
cover the plugin's actual current state directory (`.cc-os/**`, already
covered by the scanner's pre-existing hardcoded self-exclusion — see the
`doc-check` spec). The IGNORE surface SHALL NEVER be inferred from
`.gitignore` — a gitignored path is neither automatically deletable nor
automatically keepable.
#### Scenario: graphify-out is never walked
- **WHEN** the scanner walks a project containing `graphify-out/`
- **THEN** no file beneath `graphify-out/` is opened, and no shortlist or signal entry is produced for it or its contents
#### Scenario: .dochygiene legacy state dir is never walked
- **WHEN** the scanner encounters `.dochygiene/` in a project that has not migrated to `.cc-os/dochygiene/`
- **THEN** the directory is treated as IGNORE surface and never walked
#### Scenario: gitignored is not treated as IGNORE surface
- **WHEN** a path is listed in `.gitignore` but is not one of the explicit IGNORE seed members
- **THEN** the scanner walks it normally per its other rules — being gitignored alone neither excludes it from the walk nor exempts it from deletion
### Requirement: Directory-Rule Walk Pruning
When a directory-rule (a lifecycle rule whose glob covers a subtree) matches
a directory during the scanner walk, the scanner SHALL prune the walk at
that directory: no file beneath it SHALL be opened or read. For directory
rules carrying a real lifetime (`temporary` or `delete-once-served`), the
scanner SHALL emit exactly one aggregate shortlist/signal entry for the
directory path itself, carrying the lifecycle signal (rule reference,
lifetime, and `served_when`/`served_when_path`). For IGNORE-surface
directory rules (no lifetime), the scanner SHALL emit no entry at all.
#### Scenario: A temporary directory rule prunes and emits one aggregate entry
- **WHEN** `autoresearch/run-2026-07-01/` matches a directory rule with `lifetime: temporary`
- **THEN** the scanner does not open any file inside that directory and emits exactly one shortlist entry for the directory path carrying the lifecycle signal
#### Scenario: An IGNORE-surface directory rule prunes with no entry
- **WHEN** `graphify-out/` matches the IGNORE-surface rule
- **THEN** the scanner does not open any file inside it and produces no shortlist or signal entry for it
### Requirement: Lifecycle Signal Attachment on File-Rule Matches
When a file-rule matches a path not caught by a directory-rule prune, the
scanner SHALL attach a lifecycle signal (rule reference, lifetime,
`served_when`/`served_when_path`) to that file's shortlist entry, alongside
any pre-existing objective signals for the same file. The lifecycle signal
SHALL be a new signal class consumed by the classification subagent like any
other signal.
#### Scenario: A file-rule match adds a lifecycle signal without displacing existing signals
- **WHEN** `HANDOFF-2026-07-01.md` matches a file-rule with `lifetime: delete-once-served` and also has an existing broken-reference signal
- **THEN** its shortlist entry carries both the lifecycle signal and the pre-existing broken-reference signal

View File

@ -14,19 +14,26 @@ explicit human approval.
The machine report SHALL be a single JSON object containing `schema_version`,
`tool_version`, `generated_at` (ISO-8601 UTC), a `scan` metadata object, a
`shortlist` array, and an `entries` array. The `scan` object SHALL include
`project_root`, `scope_globs`, `excluded_dirs`, and `files_scanned`. The
`generated_at` timestamp SHALL be the check time that the clean step uses for
its mtime guard.
`shortlist` array, an `entries` array, and a `promotion_candidates` array.
The `scan` object SHALL include `project_root`, `scope_globs`,
`excluded_dirs`, and `files_scanned`. The `generated_at` timestamp SHALL be
the check time that the clean step uses for its mtime guard. The
`promotion_candidates` array SHALL be present (possibly empty) on every
report and SHALL list, per candidate, the classifier-judged entry it applies
to, the recommended `conventions.json` entry name, and its one-line pitch.
#### Scenario: Check writes a well-formed report
- **WHEN** the `check` skill completes a scan and classification pass
- **THEN** it writes one JSON object with `schema_version`, `tool_version`, `generated_at`, `scan`, `shortlist`, and `entries`
- **THEN** it writes one JSON object with `schema_version`, `tool_version`, `generated_at`, `scan`, `shortlist`, `entries`, and `promotion_candidates`
#### Scenario: Clean reads the check timestamp
- **WHEN** the `clean` skill loads a report
- **THEN** it reads `generated_at` and uses it as the reference time for the per-op mtime guard
#### Scenario: promotion_candidates is always present, possibly empty
- **WHEN** a check run has no classifier-judged entry with an applicable unadopted convention
- **THEN** `promotion_candidates` is written as an empty array, not omitted
### Requirement: Shortlist Precedes Entries
The `shortlist` SHALL contain the project-root-relative paths the deterministic
@ -108,17 +115,38 @@ validated deterministically; an entry that violates it is invalid.
### Requirement: Safety-Tier Is Derived Deterministically
`deterministic` ops SHALL be exact edits the check pre-computes and the cleaner
applies with no model. `generative` ops SHALL be prose transformations requiring
a model at clean time. The `safety_tier` SHALL be **computed** by a deterministic
script function `safety_tier(op_type, is_destructive, is_reversible)` and SHALL
NOT be assigned by the model; the report records the computed value. The function
SHALL return `confirm` when `op_type` is `generative`, OR when `is_destructive`
is true, OR when `is_reversible` is false; and SHALL return `auto` only when the
op is `deterministic` AND non-destructive AND reversible (hence objective). The
function SHALL NEVER return `auto` for a `generative` op or for any destructive
or irreversible op, so the model cannot violate invariant #7. `auto`-tier ops
SHALL run without a prompt; `confirm`-tier ops SHALL be escalated for approval.
`deterministic` ops SHALL be exact edits the check pre-computes and the
cleaner applies with no model. `generative` ops SHALL be prose
transformations requiring a model at clean time. The `safety_tier` SHALL be
**computed** by a deterministic script function `derive_safety_tier(op_type,
is_destructive, is_reversible, lifecycle=None)` and SHALL NOT be assigned by
the model; the report records the computed value. This function remains the
single source of truth for tier derivation across the whole report schema,
including lifecycle ops — no second tier-deriving function or code path is
introduced.
For non-lifecycle ops, the function SHALL return `confirm` when `op_type` is
`generative`, OR when `is_destructive` is true, OR when `is_reversible` is
false; and SHALL return `auto` only when the op is `deterministic` AND
non-destructive AND reversible (hence objective).
For `delete` and `extract-then-delete` ops, the function SHALL additionally
consult the `lifecycle` argument and SHALL return `auto` only when ALL of the
following hold: the lifecycle evidence is scanner-proven (a satisfied
`served_when_path`, or a temporary-tier retain-recent/age computation) AND
the path is tracked AND the worktree at that path is clean at the time of
derivation. Every other combination for a lifecycle op — a classifier-judged
`served_when`, a dirty worktree, or an untracked path — SHALL derive to
`confirm`, regardless of `is_destructive`/`is_reversible` inputs. The
function SHALL NEVER return `auto` for a `generative` op, for any
non-lifecycle destructive or irreversible op, or for any lifecycle op whose
evidence is classifier-judged or whose git state is not tracked+clean, so
the model cannot violate invariant #7.
`auto`-tier ops SHALL run without a prompt; `confirm`-tier ops SHALL be
escalated for approval; `delete`/`extract-then-delete` `auto` verdicts SHALL
additionally be re-verified against live git state at apply time per the
`lifecycle-deletion` spec before being applied without a prompt.
#### Scenario: Deterministic reversible op derives to auto
- **WHEN** an entry has `op_type` = `deterministic`, `is_destructive` = false, and `is_reversible` = true
@ -133,9 +161,64 @@ SHALL run without a prompt; `confirm`-tier ops SHALL be escalated for approval.
- **THEN** the derived `safety_tier` is `confirm` and the op is delegated to a Sonnet subagent at clean time
#### Scenario: Function can never emit auto for a generative or destructive op
- **WHEN** `safety_tier(op_type, is_destructive, is_reversible)` is evaluated for any input where `op_type` = `generative`, or `is_destructive` = true, or `is_reversible` = false
- **WHEN** `derive_safety_tier(op_type, is_destructive, is_reversible, lifecycle)` is evaluated for any input where `op_type` = `generative`, or (for a non-lifecycle op) `is_destructive` = true, or `is_reversible` = false
- **THEN** the result is `confirm`, never `auto`
#### Scenario: A lifecycle delete with scanner-proven evidence and tracked+clean state derives to auto
- **WHEN** a `delete` entry's `lifecycle` argument shows a satisfied `served_when_path` and the path is tracked and clean
- **THEN** the derived `safety_tier` is `auto`
#### Scenario: A lifecycle delete with classifier-judged evidence always derives to confirm
- **WHEN** a `delete` or `extract-then-delete` entry's `lifecycle` argument carries `served_when` (classifier-judged)
- **THEN** the derived `safety_tier` is `confirm`, regardless of tracked/clean state
#### Scenario: A lifecycle delete on a dirty or untracked path derives to confirm
- **WHEN** a `delete` entry's evidence is scanner-proven but the path is dirty or untracked
- **THEN** the derived `safety_tier` is `confirm`
### Requirement: Lifecycle Signal Fields on Shortlist and Entries
A shortlist entry or report entry carrying a lifecycle signal SHALL include a
`lifecycle` object with `rule_ref` (identifying which rulebook rule matched),
`lifetime` (`keep`, `temporary`, or `delete-once-served`), and exactly one of
`served_when_path` (deterministic) or `served_when` (classifier-judged, free
text), mirroring the rulebook rule's own fields. A directory-rule aggregate
entry SHALL carry the same `lifecycle` object shape as a file entry, with its
`path` set to the matched directory.
#### Scenario: A file-rule match carries the lifecycle object
- **WHEN** a file matches a `temporary`-lifetime file rule
- **THEN** its shortlist/report entry includes a `lifecycle` object with `rule_ref`, `lifetime: "temporary"`, and no `served_when`/`served_when_path` (temporary entries are keyed on age, not a served signal)
#### Scenario: A delete-once-served match carries exactly one served field
- **WHEN** a file matches a `delete-once-served` rule using `served_when_path`
- **THEN** its `lifecycle` object includes `served_when_path` and omits `served_when`, never both
#### Scenario: A directory-rule aggregate entry carries the same shape
- **WHEN** a directory rule produces one aggregate shortlist entry
- **THEN** that entry's `lifecycle` object has the same fields as a file entry's, with `path` set to the directory
### Requirement: Promotion-Candidates Section Schema
The top-level `promotion_candidates` array SHALL contain, per candidate, the
`path` of the classifier-judged entry it applies to, the recommended
`conventions.json` entry `name`, and the convention's one-line `pitch`. This
array SHALL be computed only by the deterministic finalize pass (never the
model) and SHALL be empty, not omitted, when no candidate applies.
#### Scenario: A promotion candidate references its source entry and convention
- **WHEN** an entry with `served_when` has an applicable unadopted convention
- **THEN** the `promotion_candidates` array includes an object with that entry's `path`, the convention `name`, and its `pitch`
#### Scenario: promotion_candidates is model-free
- **WHEN** the finalize pass computes `promotion_candidates`
- **THEN** it does so without any subagent dispatch, using only `conventions.json` and the report's own lifecycle entries
### Requirement: Exact-Edit Presence Tied to Op-Type
An entry SHALL include an `exact_edit` object when, and only when, `op_type` is
@ -159,11 +242,14 @@ refuse to apply it to a file changed since `generated_at`. Entries with
### Requirement: Exact-Edit Kind Is a Closed Enum
Every `exact_edit` SHALL carry a `kind` drawn from the closed set
`delete-range`, `move-to-archive`, `insert-frontmatter`, `replace-text`, and
`dedupe` — one per deterministic op family the PRD names. No other `kind` is
permitted. Each `kind` SHALL carry its required sub-fields and SHALL have a fixed
inherent `(is_destructive, is_reversible)` characterization that feeds the
`safety_tier` derivation:
`delete-range`, `move-to-archive`, `insert-frontmatter`, `replace-text`,
`dedupe`, `delete`, and `extract-then-delete` — one per deterministic op
family the PRD and the lifecycle-aware design name. No other `kind` is
permitted. Each `kind` SHALL carry its required sub-fields and SHALL have a
fixed inherent `(is_destructive, is_reversible)` characterization that feeds
the `safety_tier` derivation, except where noted below that lifecycle kinds'
tier also depends on runtime git state and evidence quality (see the
Safety-Tier Is Derived Deterministically requirement):
- `delete-range` SHALL carry `anchor` with `start_line` and `end_line`; it is
destructive and irreversible (derives to `confirm`).
@ -181,6 +267,17 @@ inherent `(is_destructive, is_reversible)` characterization that feeds the
is lost, so it is non-destructive and reversible and derives to `auto`. (Contrast
`delete-range`, which removes content kept nowhere else, is destructive, and
derives to `confirm`.)
- `delete` SHALL carry a full-file (or, for a directory-rule aggregate entry,
whole-directory) `anchor` and a `lifecycle` object (`rule_ref`, `lifetime`,
and exactly one of `served_when_path` or `served_when`); it is destructive
(git history is the only recovery path) and its `is_reversible`
characterization is inherently git-history-dependent rather than fixed —
see the Safety-Tier requirement for how its tier is actually derived.
- `extract-then-delete` SHALL carry the same `lifecycle` object as `delete`
plus an `extraction_target` classification (`repo-durable` or `cross-repo`)
and, for `repo-durable`, a target document reference; it has the same
destructive/tier characterization as `delete`, gated additionally on the
extraction step succeeding before the delete half applies.
A validator SHALL reject an `exact_edit` whose `kind` is outside the closed set
or that omits a required sub-field for its `kind`.
@ -190,7 +287,7 @@ or that omits a required sub-field for its `kind`.
- **THEN** the `exact_edit` includes `anchor` (`start_line`, `end_line`) and `dest_path`, and the entry is valid
#### Scenario: Unknown kind is rejected
- **WHEN** an `exact_edit` has a `kind` outside the closed set `delete-range`, `move-to-archive`, `insert-frontmatter`, `replace-text`, `dedupe`
- **WHEN** an `exact_edit` has a `kind` outside the closed set `delete-range`, `move-to-archive`, `insert-frontmatter`, `replace-text`, `dedupe`, `delete`, `extract-then-delete`
- **THEN** the report is invalid
#### Scenario: Missing required sub-field is rejected
@ -201,6 +298,15 @@ or that omits a required sub-field for its `kind`.
- **WHEN** an `exact_edit` has `kind` = `delete-range`
- **THEN** the entry's `is_destructive` is true and `is_reversible` is false, so the derived `safety_tier` is `confirm`
#### Scenario: delete and extract-then-delete require the lifecycle object
- **WHEN** an `exact_edit` has `kind` = `delete` or `kind` = `extract-then-delete`
- **THEN** it includes a `lifecycle` object with `rule_ref`, `lifetime`, and exactly one of `served_when_path` or `served_when`; omitting the `lifecycle` object or supplying both `served_when_path` and `served_when` makes the report invalid
#### Scenario: extract-then-delete requires an extraction_target
- **WHEN** an `exact_edit` has `kind` = `extract-then-delete`
- **THEN** it includes `extraction_target` set to `repo-durable` or `cross-repo`, with a target document reference required when `repo-durable`
### Requirement: Per-Entry Token Estimate
Each entry SHALL include a `token_estimate`. In v1, only `raw_tokens` (a