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