Compare commits
7 Commits
b5bc8813fb
...
3466159f7c
| Author | SHA1 | Date |
|---|---|---|
|
|
3466159f7c | |
|
|
0d303caf43 | |
|
|
9f11df3e2a | |
|
|
807dcc8d90 | |
|
|
fe04297604 | |
|
|
7b9afb1f6a | |
|
|
e1d22d83ba |
|
|
@ -0,0 +1,90 @@
|
|||
{
|
||||
"schema_version": 1,
|
||||
"rules": [
|
||||
{
|
||||
"glob": "autoresearch/classic-*/",
|
||||
"lifetime": "temporary",
|
||||
"retain_recent": 3,
|
||||
"max_age_days": 30,
|
||||
"confirmed_by": "human",
|
||||
"confirmed_on": "2026-07-15",
|
||||
"source": "calibration pass #1 (lifecycle-aware-doc-hygiene); narrowed from autoresearch/*/ 2026-07-15",
|
||||
"note": "Autoresearch run directories are disposable run output; keep the 3 newest, age out the rest. Narrowed to conventional prefixes so autoresearch/ can hold keep-worthy content."
|
||||
},
|
||||
{
|
||||
"glob": "autoresearch/improve-*/",
|
||||
"lifetime": "temporary",
|
||||
"retain_recent": 3,
|
||||
"max_age_days": 30,
|
||||
"confirmed_by": "human",
|
||||
"confirmed_on": "2026-07-15",
|
||||
"source": "calibration pass #1 (lifecycle-aware-doc-hygiene); narrowed from autoresearch/*/ 2026-07-15",
|
||||
"note": "Autoresearch run directories are disposable run output; keep the 3 newest, age out the rest. Narrowed to conventional prefixes so autoresearch/ can hold keep-worthy content."
|
||||
},
|
||||
{
|
||||
"glob": "docs/orchestration-audit/factsheets/*.md",
|
||||
"lifetime": "temporary",
|
||||
"retain_recent": 10,
|
||||
"max_age_days": 90,
|
||||
"confirmed_by": "human",
|
||||
"confirmed_on": "2026-07-15",
|
||||
"source": "calibration pass #1 (lifecycle-aware-doc-hygiene)",
|
||||
"note": "Regenerable precompute for the orchestration IRL audit; the audit skill rebuilds them on each run."
|
||||
},
|
||||
{
|
||||
"glob": "plugins/*/.pytest_cache/",
|
||||
"lifetime": "temporary",
|
||||
"retain_recent": 0,
|
||||
"max_age_days": 7,
|
||||
"confirmed_by": "human",
|
||||
"confirmed_on": "2026-07-15",
|
||||
"source": "calibration pass #1 (lifecycle-aware-doc-hygiene)",
|
||||
"note": "Regenerable pytest cache. Judge noted a .gitignore entry may be preferable long-term."
|
||||
},
|
||||
{
|
||||
"glob": "openspec/specs/**",
|
||||
"lifetime": "keep",
|
||||
"confirmed_by": "human",
|
||||
"confirmed_on": "2026-07-15",
|
||||
"source": "conversation 2026-07-15 (openspec lifecycle rules)",
|
||||
"note": "Live OpenSpec capability specs — source of truth future changes diff against; never age out."
|
||||
},
|
||||
{
|
||||
"glob": "plugins/*/openspec/specs/**",
|
||||
"lifetime": "keep",
|
||||
"confirmed_by": "human",
|
||||
"confirmed_on": "2026-07-15",
|
||||
"source": "conversation 2026-07-15 (openspec lifecycle rules)",
|
||||
"note": "Live OpenSpec capability specs — source of truth future changes diff against; never age out."
|
||||
},
|
||||
{
|
||||
"glob": "openspec/changes/archive/*/",
|
||||
"lifetime": "temporary",
|
||||
"retain_recent": 3,
|
||||
"max_age_days": 90,
|
||||
"confirmed_by": "human",
|
||||
"confirmed_on": "2026-07-15",
|
||||
"source": "conversation 2026-07-15 (openspec lifecycle rules)",
|
||||
"note": "Archived change dirs are redundant once synced into specs/ and stay recoverable from git history; age out after a quarter."
|
||||
},
|
||||
{
|
||||
"glob": "plugins/*/openspec/changes/archive/*/",
|
||||
"lifetime": "temporary",
|
||||
"retain_recent": 3,
|
||||
"max_age_days": 90,
|
||||
"confirmed_by": "human",
|
||||
"confirmed_on": "2026-07-15",
|
||||
"source": "conversation 2026-07-15 (openspec lifecycle rules)",
|
||||
"note": "Archived change dirs are redundant once synced into specs/ and stay recoverable from git history; age out after a quarter."
|
||||
},
|
||||
{
|
||||
"glob": "plugins/*/HANDOFF-*.md",
|
||||
"lifetime": "delete-once-served",
|
||||
"served_when": "The handoff it describes has been picked up and its follow-on work completed in a later session.",
|
||||
"confirmed_by": "human",
|
||||
"confirmed_on": "2026-07-15",
|
||||
"source": "calibration pass #1 (lifecycle-aware-doc-hygiene)",
|
||||
"note": "Classifier-judged served_when — always confirm-tier by ADR-0039; never auto-deleted."
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
---
|
||||
id: "0038"
|
||||
date: 2026-07-14
|
||||
status: Accepted
|
||||
supersedes:
|
||||
superseded-by:
|
||||
affected-paths: [plugins/os-doc-hygiene/rulebook.json, plugins/os-doc-hygiene/lifecycle-spec.md, plugins/os-doc-hygiene/scripts/]
|
||||
affected-components: [os-doc-hygiene]
|
||||
---
|
||||
|
||||
# 0038 — Per-project doc-hygiene rulebook override lives at repo-root .dochygiene-rules.json, not in .cc-os/
|
||||
|
||||
## Context
|
||||
|
||||
The lifecycle-aware doc hygiene design (wayfinder map #31, ticket #40) gives os-doc-hygiene a global rulebook shipped in the plugin (plugins/os-doc-hygiene/rulebook.json) plus a thin per-project override for exceptions (e.g. repos where specs are the product). cc-os also has a gitignored .cc-os/ directory used for local machine config (e.g. tracker keys), which raised the question of where the override belongs. The override participates in deletion decisions, so it must be reviewable, versioned, and shared by every collaborator and agent working the repo.
|
||||
|
||||
## Decision
|
||||
|
||||
The per-project override is a committed repo-root dotfile: .dochygiene-rules.json. It follows the existing .dochygiene-ignore precedent. Envelope {"schema_version": 1, "rules": [...]}; merged add-only over the global rulebook with source-then-specificity precedence (project file-rule > project directory-rule > global file-rule > global directory-rule; ties by longest pattern, then last-defined). A project neutralizes a global rule by shadowing it with lifetime "keep" — no rule-removal mechanism.
|
||||
|
||||
## Consequences
|
||||
|
||||
Rule overrides are visible in review and travel with the repo — a rule that can authorize deletion is never invisible local state. One more root dotfile per opted-in project. Because the merge is add-only, a bad global rule is neutralized by shadowing, not deleted, keeping merge semantics trivially predictable. Full schema: plugins/os-doc-hygiene/lifecycle-spec.md.
|
||||
|
||||
## Alternatives rejected
|
||||
|
||||
(1) .cc-os/dochygiene-rules.json — rejected: .cc-os/ is gitignored local config; deletion-authorizing rules must be committed, reviewable, and shared, not per-machine. (2) Global rulebook only, no override — rejected: repos where specs/plans are the product need exceptions (ticket #34). (3) YAML/TOML with comments — rejected in #40: JSON parses with stdlib in both the Python pipeline and any tooling; a per-rule note field substitutes for comments.
|
||||
|
||||
## Amendment (2026-07-15 — wayfinder map #49)
|
||||
|
||||
The rules file's scope now includes **nominations memory**: an optional second top-level key `nominations` with two sub-keys — `consults` (open judge questions that resurface each `:calibrate` run until answered) and `rejected` (declined nominations, judge- or human-declined, that block exact glob+lifetime repeats at nomination intake and travel to the judge as context for variants). The key is additive to schema_version 1 (the v1 loader ignores unknown top-level keys); `rulebook.py` stays nomination-unaware — only the calibrate pipeline reads it. Serialization of the whole file is writer-enforced canonical ordering (rules grouped delete-once-served → temporary → keep, glob-sorted within groups; `nominations` after `rules`, `consults` before `rejected`) — no hook. The global rulebook additionally carries a `**/extracted.md → keep` rule, protecting the per-directory extract-index convention (map #49 ticket #57) in every project with zero per-project bookkeeping. Detail: plugins/os-doc-hygiene/lifecycle-spec.md (§1 extract index; §2 nominations memory + canonical ordering); decision tickets #51–#53, #56, #57.
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
---
|
||||
id: "0039"
|
||||
date: 2026-07-14
|
||||
status: Accepted
|
||||
supersedes:
|
||||
superseded-by:
|
||||
affected-paths: [plugins/os-doc-hygiene/lifecycle-spec.md, plugins/os-doc-hygiene/scripts/, plugins/os-doc-hygiene/skills/]
|
||||
affected-components: [os-doc-hygiene]
|
||||
---
|
||||
|
||||
# 0039 — Doc-hygiene deletion autonomy is tiered on evidence quality and recoverability, not file type
|
||||
|
||||
## Context
|
||||
|
||||
Lifecycle rules let os-doc-hygiene:clean delete files (true git deletion in a dedicated hygiene commit; git history is the archive — ticket #35). The question (ticket #43) was what deletes silently versus what requires confirmation. Re-confirming every run would reduce the rulebook to an advisory list and strip :calibrate of its payoff; but an LLM judgment must never silently destroy a file, and untracked or dirty files have no history to recover from. /os-adr:find confirmed no existing ADR governed clean's safety tiers.
|
||||
|
||||
## Decision
|
||||
|
||||
Rule-backed deletes are auto — a rule confirmed per the strong-model gate (#39) is the standing consent. The auto/confirm line is drawn on evidence quality plus recoverability: tracked + clean worktree + deterministic signal = auto; tracked-but-dirty or untracked = confirm (verified at runtime via git ls-files + dirty check, never trusting the rule); classifier-judged served_when signals are ALWAYS forced to confirm regardless of tracked status, while scanner-proven served_when_path signals may delete silently. The temporary tier defaults to retain_recent 3 + max_age_days 3, age measured from git commit time falling back to mtime for untracked entries (directory-inode mtime, no recursive walk — #48). graphify-out/** and .dochygiene/** form an explicit IGNORE surface that is never walked — the ignore surface is its own list, never inferred from .gitignore (gitignored ≠ deletable, gitignored ≠ keepable). A per-rule confirm:true escape hatch exists but is human-settable only; a model-proposed rule may only ask a human to set it.
|
||||
|
||||
## Consequences
|
||||
|
||||
Routine hygiene runs unattended on the safe population while every non-recoverable or judgment-based deletion stops at a human. The auto/confirm line moves exactly where evidence quality changes, so improving a rule's evidence (see the determinism-promotion ADR) directly buys silence. 3-day/retain-3 retention is aggressive by design; the newest 3 entries per rule always survive to show current trajectory. Full tier matrix: plugins/os-doc-hygiene/lifecycle-spec.md.
|
||||
|
||||
## Alternatives rejected
|
||||
|
||||
(1) Confirm everything — rejected: rulebook becomes advisory; confirm-fatigue makes the human rubber-stamp. (2) Tier by file type or a recoverable_via/regenerate class — rejected: recoverability is a property of git state at runtime, not of the rule's claim. (3) 90-day retention windows — rejected as far too slow in the age of AI. (4) mtime-only age — rejected: clone/branch-switch resets every mtime, silently putting the whole rulebook to sleep. (5) Model-settable confirm:true — rejected: used too liberally, everything drifts back to always-confirm.
|
||||
|
||||
## Amendment (2026-07-15 — wayfinder map #49)
|
||||
|
||||
Tier interactions decided on map #49: (1) the "class, never path" rule-quality test is relaxed for the **keep tier only** — exact-path singleton keep rules are allowed (the test exists to prevent bad deletion rules; a singleton keep merely protects) and leave the rulebook only by hand-deletion, never an automated revisit: an automated revisit would be the one place a machine argues a keep back toward deletion, the exact failure mode the keep tier prevents. Instance globs stay forbidden for temporary/delete-once-served. (2) Nominations memory (ADR-0038 amendment) is memory, not deletion authority: rejections and consults never filter files and are not individually gated at persistence; rules alone authorize deletion. (3) Consults resurface in `:calibrate` only — `:check`/`:clean` are unchanged. Detail: plugins/os-doc-hygiene/lifecycle-spec.md §2/§8; decision tickets #51, #52, #56.
|
||||
|
|
@ -0,0 +1,27 @@
|
|||
---
|
||||
id: "0040"
|
||||
date: 2026-07-14
|
||||
status: Accepted
|
||||
supersedes:
|
||||
superseded-by:
|
||||
affected-paths: [plugins/os-doc-hygiene/lifecycle-spec.md, plugins/os-vault/, docs/adr/0017-project-graph-onboarding-assesses-the-repo-and-writes-graphifyignore-before-extracting.md]
|
||||
affected-components: [os-doc-hygiene, os-vault, graphify]
|
||||
---
|
||||
|
||||
# 0040 — Doc-hygiene rules never write other tools' ignore surfaces (no propagate_ignore)
|
||||
|
||||
## Context
|
||||
|
||||
Ticket #44 asked whether a lifecycle rule marking files temporary/delete-once-served should also propagate into .graphifyignore (keeping disposable docs out of the session-start knowledge graph) and the scanner's own .dochygiene-ignore. The #40 rulebook schema had reserved a propagate_ignore field pending this decision.
|
||||
|
||||
## Decision
|
||||
|
||||
No propagation, ever: a lifecycle rule never writes into any other tool's ignore/config surface, and the propagate_ignore field is removed from the rulebook schema entirely (not left as an empty slot). .dochygiene-ignore remains a hand-authored, human-only escape hatch unmanaged by the rulebook. os-doc-hygiene stays in its own lane — separation of concerns.
|
||||
|
||||
## Consequences
|
||||
|
||||
The glob-dialect mismatch problem (rulebook glob.translate vs gitignore vs fnmatch) dissolves — no rule glob is ever translated into a foreign dialect. Delete-once-served files leave the knowledge graph at the next rebuild anyway, so the lost payoff was only the narrow temporary-but-not-yet-served window. If disposable files polluting the knowledge graph later proves painful, the fix belongs to graphify or os-vault:onboard-project, not a hygiene rule reaching across the seam. Noted while verifying: ADR-0017's premise that graphify does not honor .gitignore is stale — graphifyy 0.8.31 (detect.py:707-711) falls back to .gitignore per-directory where no .graphifyignore exists; this does not change ADR-0017's decision.
|
||||
|
||||
## Alternatives rejected
|
||||
|
||||
(1) Propagate into .dochygiene-ignore — moot, not rejected on principle: the rulebook itself is already the scanner's skip instruction (directory-rule matches prune the walk), so propagation would create two sources of truth for one fact in a weaker dialect (fnmatch) with no precedence rule. (2) Append to .graphifyignore — rejected on ownership and on ADR-0017: onboard-project generates that file per-project WITH user confirmation, and ADR-0017 explicitly rejected auto-writing it without confirmation; automatic appends would reinstate the rejected alternative and add a second writer with no arbiter. (3) Propagate to memsearch — not applicable: memsearch indexes its own ~/.memsearch/memory/ daily files and has no ignore surface for project docs.
|
||||
|
|
@ -0,0 +1,27 @@
|
|||
---
|
||||
id: "0041"
|
||||
date: 2026-07-14
|
||||
status: Accepted
|
||||
supersedes:
|
||||
superseded-by:
|
||||
affected-paths: [plugins/os-doc-hygiene/conventions.json, plugins/os-doc-hygiene/lifecycle-spec.md, plugins/os-doc-hygiene/skills/]
|
||||
affected-components: [os-doc-hygiene]
|
||||
---
|
||||
|
||||
# 0041 — Determinism promotion: hygiene nudges projects toward structurally-obvious completion conventions
|
||||
|
||||
## Context
|
||||
|
||||
The deletion-autonomy tiers force every classifier-judged served_when signal to confirm (the LLM may propose, never silently destroy). Left there, projects with subjective completion signals would accumulate permanent confirm-fatigue, and the human would rubber-stamp gates. Tickets #43 (item 7) and #47 asked how the tool should respond when a rule's served signal is subjective.
|
||||
|
||||
## Decision
|
||||
|
||||
When a rule's served signal is classifier-judged, the tool does not merely downgrade it to confirm: it names the subjectivity and recommends a concrete structural convention that would graduate the rule to a scanner-proven served_when_path and make it silent. Conventions live in a global-only, machine-readable catalog at plugins/os-doc-hygiene/conventions.json so the deterministic pipeline emits nudges without an LLM; v1 holds exactly two entries: archive-bucket (done = file moved to a sibling archive/ dir, precedent: openspec changes) and status-frontmatter (done = a status: shipped|done frontmatter key). Surfacing splits by capability: :check names promotion candidates in every report; :calibrate may draft the adoption (graduated rule + file moves) for human approval but never applies it unasked.
|
||||
|
||||
## Consequences
|
||||
|
||||
Confirm-fatigue is converted into the incentive to fix the convention rather than a cost to endure — adopting a convention directly buys silent automation. The catalog only recommends; adoption lands in the project's own rulebook, so no per-project catalog override is needed. A two-entry catalog that gets adopted beats a taxonomy; successor-artifact checks stay out until a calibration pass demands one. The :check report schema gains a promotion-candidates section.
|
||||
|
||||
## Alternatives rejected
|
||||
|
||||
(1) Just downgrade subjective rules to confirm and stop — rejected: permanent confirm-fatigue with no exit path. (2) A rich taxonomy of completion conventions — rejected: adoption beats coverage; entries are added when calibration passes demand them. (3) Per-project catalog override — rejected: the catalog only recommends, so there is nothing project-specific to override. (4) :calibrate applies adoptions automatically — rejected: violates the model-proposes/human-disposes constraint from #42.
|
||||
|
|
@ -43,4 +43,9 @@ One file per decision, `NNNN-kebab-title.md`, created via `/os-adr:create`.
|
|||
| 0034 | [Cross-project filing: file-dont-fix with a derived global project index](0034-cross-project-filing-file-dont-fix-with-a-derived-global-project-index.md) | Accepted | 2026-07-13 |
|
||||
| 0035 | [Issue-triggered project AI wakeup via polling, not webhooks](0035-issue-triggered-project-ai-wakeup-via-polling-not-webhooks.md) | Accepted | 2026-07-13 |
|
||||
| 0036 | [tmux session convention for AI-run sessions: cc-<project>-<purpose>](0036-tmux-session-convention-for-ai-run-sessions-cc-project-purpose.md) | Accepted | 2026-07-13 |
|
||||
| 0037 | [os-sdlc lives inside cc-os as a new plugin, not a separate cc-sdlc marketplace](0037-os-sdlc-lives-inside-cc-os-as-a-new-plugin-not-a-separate-cc-sdlc-marketplace.md) | Accepted | 2026-07-14 |
|
||||
| 0038 | [Per-project doc-hygiene rulebook override lives at repo-root .dochygiene-rules.json, not in .cc-os/](0038-per-project-doc-hygiene-rulebook-override-lives-at-repo-root-dochygiene-rules-json-not-in-cc-os.md) | Accepted | 2026-07-14 |
|
||||
| 0039 | [Doc-hygiene deletion autonomy is tiered on evidence quality and recoverability, not file type](0039-doc-hygiene-deletion-autonomy-is-tiered-on-evidence-quality-and-recoverability-not-file-type.md) | Accepted | 2026-07-14 |
|
||||
| 0040 | [Doc-hygiene rules never write other tools' ignore surfaces (no propagate_ignore)](0040-doc-hygiene-rules-never-write-other-tools-ignore-surfaces-no-propagate-ignore.md) | Accepted | 2026-07-14 |
|
||||
| 0041 | [Determinism promotion: hygiene nudges projects toward structurally-obvious completion conventions](0041-determinism-promotion-hygiene-nudges-projects-toward-structurally-obvious-completion-conventions.md) | Accepted | 2026-07-14 |
|
||||
<!-- adr-index:end -->
|
||||
|
|
|
|||
|
|
@ -68,6 +68,14 @@ entry is in the leaf file named.
|
|||
convention + derived global project index (issue #27, ADR-0034); wakeup-polling +
|
||||
tmux-convention spike with dry-run pilot (issue #28, ADR-0035/0036). Detail:
|
||||
os-context / os-doc-hygiene / os-backlog leaves.
|
||||
- **2026-07-14** — os-sdlc plugin scaffolded (ADR-0037): new plugin inside cc-os (not a
|
||||
separate marketplace) for a harness-driven SDLC lifecycle, adapting Matt Pocock's v1.1
|
||||
skill lifecycle and Delta Refinery's multi-level pipeline; registered in local-plugins,
|
||||
no skills/agents/hooks implemented yet. Launching-point doc: `plugins/os-sdlc/OVERVIEW.md`;
|
||||
full design deferred to a follow-up brainstorming session.
|
||||
- **2026-07-15** — os-doc-hygiene lifecycle-aware hygiene shipped (ADR-0038–0041): rulebook
|
||||
layer, lifetime taxonomy + tier matrix, no-ignore-propagation, conventions.json promotion,
|
||||
new `:calibrate` skill; calibration pass #1 (cc-os) passed. Detail: os-doc-hygiene leaf.
|
||||
|
||||
**Remaining optional items:** additional project onboarding (one at a time, per ADR-0013);
|
||||
bulk vault migration; os-adr rollout to pilot projects; os-backlog routing rollout (#14);
|
||||
|
|
|
|||
|
|
@ -29,3 +29,31 @@ _Leaf file of [../implementation-status.md](../implementation-status.md). Read o
|
|||
`check`/`clean`; the `commands/hygiene.md` dispatcher was removed in favor of two new
|
||||
skills (`status`, `sweep`), aligning with the no-`commands/`-directory pattern
|
||||
(2026-07-03).
|
||||
- **Lifecycle-aware hygiene (2026-07-15, ADR-0038–0041)** — adds a rulebook layer on top of
|
||||
the existing stale/bloat scanner: a global `rulebook.json` plus an optional per-project
|
||||
`.dochygiene-rules.json` declare lifetime rules for known-temporary artifacts (ADR-0038).
|
||||
Rules classify matched paths into the lifetime taxonomy and drive a tier matrix from
|
||||
scanner-proven+tracked+clean (`auto`) down to classifier-judged (`confirm`) (ADR-0039); an
|
||||
IGNORE sentinel prunes matched paths with zero emission and does not propagate ignore
|
||||
status to children (ADR-0040). Classifier-judged matches that recur are surfaced
|
||||
deterministically as `promotion_candidates` from `conventions.json`, not model-authored
|
||||
(ADR-0041). New `/os-doc-hygiene:calibrate` skill runs the cluster/nominate/judge/
|
||||
human-report protocol to tune rules against a real project. Calibration pass #1 (cc-os)
|
||||
ran 2026-07-14/15 and passed both the protected-set hard gate and the recall floor across
|
||||
all 4 mandatory rows; rule persistence to `.dochygiene-rules.json` is pending human
|
||||
approval. Results: `plugins/os-doc-hygiene/openspec/changes/lifecycle-aware-doc-hygiene/calibration-pass-1-results.md`.
|
||||
Design: `plugins/os-doc-hygiene/lifecycle-spec.md`. Suite: 407 passed.
|
||||
- **Calibrate assessment inventory implemented (2026-07-15, map #49 / ADR-0038-0039
|
||||
amendments)** — the locked nominations-memory design is now code: `RulesFileWriter`
|
||||
(single canonical serialization path for `.dochygiene-rules.json`; tier-grouped,
|
||||
glob-sorted, idempotent, unknown fields warn-and-round-trip) and `NominationIntakeFilter`
|
||||
(deterministic Step 3.5 — exact glob+lifetime repeats dropped, variants annotated via
|
||||
shortlist match-set intersection, open consults always forwarded) in
|
||||
`calibrate_helpers.py`; calibrate SKILL.md Steps 3.5/5/6 wired (Open-consults report
|
||||
section with the three exits; keep verdicts, human declines, and consults all persist
|
||||
through the writer); `patch_applier.py` extract-then-delete appends the `extracted.md`
|
||||
vault pointer (keyed off `extraction_target: "cross-repo"` + a skill-supplied
|
||||
`extraction_pointer`, fail-closed: no pointer → no delete); clean SKILL.md Step 6.5
|
||||
supplies the pointer metadata. OpenSpec change:
|
||||
`plugins/os-doc-hygiene/openspec/changes/calibrate-assessment-inventory/`. Suite: 434
|
||||
passed (26 new unit tests across writer/intake-filter/applier seams).
|
||||
|
|
|
|||
|
|
@ -7,6 +7,18 @@ zero-token) on `SessionStart`; checks and cleans on demand via skills.
|
|||
> Read `PRD.md` first — it is the source of truth for scope, decisions, and
|
||||
> rationale. This file is the build map.
|
||||
|
||||
> Vault user guide (nuance/mental-model, not a how-to):
|
||||
> `~/Documents/SecondBrain/user-guide/os-doc-hygiene-user-guide.md`.
|
||||
|
||||
## Lifecycle layer (2026-07-15)
|
||||
|
||||
A rulebook layer sits on top of the scan/classify/clean core: global
|
||||
`rulebook.json` + optional per-project `.dochygiene-rules.json` declare
|
||||
lifetime rules for known-temporary artifacts, feeding tier derivation and a
|
||||
`/os-doc-hygiene:calibrate` skill for tuning rules against a real project.
|
||||
Read `lifecycle-spec.md` before touching `rulebook.py`, the scanner's
|
||||
lifecycle signals, or the calibrate skill; decisions are ADR-0038–0041.
|
||||
|
||||
## Core distinction (do not conflate)
|
||||
|
||||
- **Stale** = the doc is *wrong* (contradicted, orphaned, superseded,
|
||||
|
|
@ -22,7 +34,8 @@ Severity scales with **injection frequency** — a stale line in `CLAUDE.md` (au
|
|||
1. The `SessionStart` hook **only reminds** — it never runs analysis or mutates
|
||||
anything, and spends no AI tokens. All mutation is user-invoked.
|
||||
2. Reminder **snoozes** (≤ once/day while stale) via `last_reminded`.
|
||||
3. State lives **in-project** under gitignored `.dochygiene/`. No global index.
|
||||
3. State lives **in-project** under gitignored `.cc-os/dochygiene/` (ADR-0027;
|
||||
legacy `.dochygiene/` is fallback-read only). No global index.
|
||||
4. **Report rollover**: keep only the latest report. The tool must not become
|
||||
the bloat it polices.
|
||||
5. Cleanup is **git-safe**: clean/committed tree required (or auto WIP
|
||||
|
|
|
|||
|
|
@ -0,0 +1,19 @@
|
|||
[
|
||||
{
|
||||
"name": "archive-bucket",
|
||||
"what_it_proves": "The file has been moved into a sibling archive/ directory — a completed doc has physically left the live tree (precedent: openspec changes).",
|
||||
"graduation_template": {
|
||||
"served_when_path": "{dir}/archive/{name}"
|
||||
},
|
||||
"pitch": "Move done docs into a sibling archive/ dir once, and the rule graduates from a confirm-gated guess to a silent, scanner-provable auto-delete."
|
||||
},
|
||||
{
|
||||
"name": "status-frontmatter",
|
||||
"what_it_proves": "The file's own frontmatter carries status: shipped or status: done — the file stays put and the scanner reads the key directly.",
|
||||
"graduation_template": {
|
||||
"frontmatter_key": "status",
|
||||
"allowed_values": ["shipped", "done"]
|
||||
},
|
||||
"pitch": "Add a status: shipped|done frontmatter key once, and every future check reads it deterministically instead of asking a model to judge whether the doc shipped."
|
||||
}
|
||||
]
|
||||
|
|
@ -54,5 +54,6 @@
|
|||
"weighted_tokens": null
|
||||
}
|
||||
}
|
||||
]
|
||||
],
|
||||
"promotion_candidates": []
|
||||
}
|
||||
|
|
|
|||
|
|
@ -55,5 +55,6 @@
|
|||
"weighted_tokens": null
|
||||
}
|
||||
}
|
||||
]
|
||||
],
|
||||
"promotion_candidates": []
|
||||
}
|
||||
|
|
|
|||
|
|
@ -50,5 +50,6 @@
|
|||
"weighted_tokens": null
|
||||
}
|
||||
}
|
||||
]
|
||||
],
|
||||
"promotion_candidates": []
|
||||
}
|
||||
|
|
|
|||
|
|
@ -56,5 +56,6 @@
|
|||
"weighted_tokens": null
|
||||
}
|
||||
}
|
||||
]
|
||||
],
|
||||
"promotion_candidates": []
|
||||
}
|
||||
|
|
|
|||
|
|
@ -45,5 +45,6 @@
|
|||
"weighted_tokens": null
|
||||
}
|
||||
}
|
||||
]
|
||||
],
|
||||
"promotion_candidates": []
|
||||
}
|
||||
|
|
|
|||
|
|
@ -34,5 +34,6 @@
|
|||
"weighted_tokens": null
|
||||
}
|
||||
}
|
||||
]
|
||||
],
|
||||
"promotion_candidates": []
|
||||
}
|
||||
|
|
|
|||
|
|
@ -99,5 +99,6 @@
|
|||
"weighted_tokens": 1200
|
||||
}
|
||||
}
|
||||
]
|
||||
],
|
||||
"promotion_candidates": []
|
||||
}
|
||||
|
|
|
|||
|
|
@ -65,17 +65,20 @@ it.
|
|||
- **Violation looks like:** the banner appears on every resume/compact; the
|
||||
snooze keys off something other than `last_reminded`; a same-day re-fire.
|
||||
|
||||
## 3. State lives in-project under gitignored `.dochygiene/`
|
||||
## 3. State lives in-project under gitignored `.cc-os/dochygiene/`
|
||||
|
||||
- **Invariant:** All state and reports live under a gitignored `.dochygiene/`
|
||||
directory at the resolved project root (git root, fallback cwd). There is no
|
||||
- **Invariant:** All state and reports live under the gitignored
|
||||
`.cc-os/dochygiene/` directory at the resolved project root (git root,
|
||||
fallback cwd), per ADR-0027; the legacy `.dochygiene/` path is read as a
|
||||
backward-compat fallback only (auto-migrated on first write). There is no
|
||||
global, cross-project index, and the tool never silently edits the user's
|
||||
`.gitignore`.
|
||||
- **Why:** A global index would race, corrupt, and itself go stale across
|
||||
projects. Silently editing `.gitignore` is an outward mutation that violates
|
||||
the non-intrusive premise; the dir being tracked would pollute the repo.
|
||||
- **Enforced by:** state store unit test (root resolution + writes confined to
|
||||
`.dochygiene/`); scanner self-exclusion test (`.dochygiene/` never scanned);
|
||||
the state dir); scanner self-exclusion test (`.cc-os/` and legacy
|
||||
`.dochygiene/` never scanned);
|
||||
GAP: needs test that no global path outside the project root is written and
|
||||
that `.gitignore` is only modified on explicit confirmation.
|
||||
- **Violation looks like:** a `~/.dochygiene` or other global index appears;
|
||||
|
|
@ -156,8 +159,9 @@ it.
|
|||
## 9. Frozen / ignored files are never flagged
|
||||
|
||||
- **Invariant:** Files marked `hygiene: frozen` in frontmatter, files matched by
|
||||
`.dochygiene-ignore`, and detected append-only logs are never surfaced as
|
||||
candidates by the scanner.
|
||||
`.dochygiene-ignore`, detected append-only logs, and paths matched by a
|
||||
rulebook IGNORE-sentinel rule (a rule with no `lifetime` — zero-emission
|
||||
prune, ADR-0040) are never surfaced as candidates by the scanner.
|
||||
- **Why:** Re-flagging deliberately-frozen records and append-only logs every
|
||||
week destroys the user's trust in the tool. This is a correctness requirement,
|
||||
not a nicety.
|
||||
|
|
|
|||
|
|
@ -0,0 +1,467 @@
|
|||
# Lifecycle-aware doc hygiene — spec
|
||||
|
||||
_Status: Locked design (wayfinder map [#31](https://forgejo.swansoncloud.com/jared/cc-os/issues/31)); assembled 2026-07-14 from decision tickets #32–#48. Extended 2026-07-15 by wayfinder map [#49](https://forgejo.swansoncloud.com/jared/cc-os/issues/49) (assessment-inventory persistence + rulebook refinements; tickets #51–#57, #59)._
|
||||
_Last updated: 2026-07-15_
|
||||
|
||||
Extends os-doc-hygiene from stale/bloat monitoring to lifecycle management: a
|
||||
rulebook assigns every managed file a **lifetime**, `:check`/`:clean` gain
|
||||
delete and extract-then-delete behavior, and a new `:calibrate` skill learns
|
||||
rules per project. Validated against calibration project #1 (cc-os) per the
|
||||
criteria in [Calibration](#calibration-pass-1-cc-os).
|
||||
|
||||
ADR set: [ADR-0038](../../docs/adr/) (rulebook location; amended 2026-07-15 —
|
||||
rules-file scope includes nominations memory + the extract-index keep rule),
|
||||
ADR-0039 (deletion autonomy tiers; amended 2026-07-15 — map #49 tier
|
||||
interactions), ADR-0040 (no ignore-surface propagation), ADR-0041
|
||||
(determinism-promotion principle).
|
||||
|
||||
## 1. Lifetime taxonomy (#33)
|
||||
|
||||
Three lifetimes, plus one modifier:
|
||||
|
||||
- **keep** — indefinite; scanned and reported, never deleted.
|
||||
- **temporary** — age-triggered deletion (see [Temporary tier](#4-temporary-tier-43-48)).
|
||||
- **delete-once-served** — purpose-triggered deletion (see [Served signals](#5-delete-once-served-served-signal-split-43)).
|
||||
- **extract** (modifier on deletion, not a fourth lifetime) — distill durable
|
||||
content before deleting. Extraction reuses existing knowledge routing (#36):
|
||||
repo-durable residue → ADR/CLAUDE.md/docs; cross-repo lessons → SecondBrain
|
||||
via `/os-vault:write`. No new destinations, no "retired specs" pile.
|
||||
|
||||
### Extract index (`extracted.md`) — map #49 [#57](https://forgejo.swansoncloud.com/jared/cc-os/issues/57)
|
||||
|
||||
When an extract-then-delete sends content to the **vault** (the case where the
|
||||
insight physically leaves the repo), the deletion leaves a pointer behind in a
|
||||
per-directory **`extracted.md`** — the index stays where a future reader is
|
||||
already looking. Repo-durable residue (an ADR, a CLAUDE.md line) is already
|
||||
discoverable in-repo and gets **no** index line. Entry format:
|
||||
|
||||
```markdown
|
||||
- [[vault: tool/graphify-clustering-behavior]] — why HDBSCAN over-merges doc
|
||||
clusters; read before tuning any graphify clustering params.
|
||||
(extracted from `graphify-clustering-notes.md`, 2026-07-15)
|
||||
```
|
||||
|
||||
- **The `:clean` extract op owns the entry.** The op runs: distill →
|
||||
`/os-vault:write` (returns the note name) → append the pointer line →
|
||||
delete the original — atomically, in `:clean`'s single reviewable commit,
|
||||
so there is no window where the doc is gone but undiscoverable.
|
||||
`os-vault:write` stays repo-agnostic.
|
||||
- **Self-protection is one global rule**: `{ "glob": "**/extracted.md",
|
||||
"lifetime": "keep" }` in the global rulebook — the self-describing filename
|
||||
means zero per-project bookkeeping and the index can never become a
|
||||
deletion candidate anywhere. (A generic `index.md` name was rejected: it
|
||||
collides with existing index/README conventions, and a global
|
||||
`**/index.md → keep` is far too broad.)
|
||||
- **Extract-worthy vs plain-keep — three tests, in order:**
|
||||
1. **Cited → KEEP.** Anything repo artifacts link to or derive from stays
|
||||
(deleting breaks the provenance chain).
|
||||
2. **Generalizes AND standalone → EXTRACT.** The insight would change
|
||||
behavior in a *different* repo (tool behavior, methodology — the vault
|
||||
routing test) and nothing in-repo links to it.
|
||||
3. **Unclear → consult.** The existing mandatory verdict; never guess an
|
||||
extract.
|
||||
- The convention is spec-defined and universal — part of the extract op
|
||||
itself, **not** a `conventions.json` entry (that catalog holds
|
||||
completion-conventions a project opts into via served-signal graduation;
|
||||
the extract index is neither).
|
||||
|
||||
Distinct from all lifetimes: an **IGNORE surface** — paths the scanner never
|
||||
walks at all (vs `keep` = walked and reported, never deleted). Seed members:
|
||||
`graphify-out/**`, `.dochygiene/**`. The ignore surface is an explicit list,
|
||||
never inferred from `.gitignore` (gitignored ≠ deletable AND gitignored ≠
|
||||
keepable — #43).
|
||||
|
||||
## 2. Rulebook (#34, #38, #40, #44)
|
||||
|
||||
### Locations
|
||||
|
||||
- **Global:** `plugins/os-doc-hygiene/rulebook.json`, resolved relative to
|
||||
plugin scripts. Ships everywhere.
|
||||
- **Per-project override:** repo-root `.dochygiene-rules.json`, committed
|
||||
(matches the `.dochygiene-ignore` precedent; deliberately NOT in gitignored
|
||||
`.cc-os/` — ADR-0038).
|
||||
|
||||
Both use envelope `{"schema_version": 1, "rules": [...]}`. The project file
|
||||
may additionally carry a top-level `nominations` key (see [Nominations
|
||||
memory](#nominations-memory--map-49-5256) below); the v1 loader ignores
|
||||
unknown top-level keys, so the key is additive to `schema_version` 1.
|
||||
|
||||
### Glob dialect
|
||||
|
||||
gitignore-style via stdlib `glob.translate(recursive=True,
|
||||
include_hidden=True)` (Python ≥ 3.13); patterns repo-root-relative, real `**`;
|
||||
compiled once at load. Directory rules = patterns covering a subtree.
|
||||
|
||||
### Precedence (two-axis, source first)
|
||||
|
||||
project file-rule > project directory-rule > global file-rule > global
|
||||
directory-rule; ties broken by longest pattern, then last-defined. File-level
|
||||
rules override directory rules (#38). The merge is **add-only**: a project
|
||||
neutralizes a global rule by shadowing it with `lifetime: "keep"` — there is
|
||||
no rule-removal mechanism.
|
||||
|
||||
### Per-rule fields
|
||||
|
||||
```json
|
||||
{
|
||||
"glob": "autoresearch/*/**",
|
||||
"lifetime": "keep|temporary|delete-once-served",
|
||||
"extract": true,
|
||||
"served_when": "free text — hint consumed by the LLM classifier, no trigger DSL",
|
||||
"served_when_path": "openspec/changes/archive/{id}/",
|
||||
"retain_recent": 3,
|
||||
"max_age_days": 3,
|
||||
"confirm": true,
|
||||
"confirmed_by": "human | <strong-model-id>",
|
||||
"confirmed_on": "2026-07-14",
|
||||
"source": "clutter-inventory #41",
|
||||
"note": "free-text rationale"
|
||||
}
|
||||
```
|
||||
|
||||
- `retain_recent` default 3; `max_age_days` default **3** (not
|
||||
null-means-forever) — #43 amendments to the #40 schema.
|
||||
- `served_when_path` is the deterministic sibling of `served_when` (#43).
|
||||
- `confirm: true` is an optional always-confirm escape hatch — **human-settable
|
||||
only**; a model-proposed rule may never set it, only ask a human to (#42/#43).
|
||||
- **There is no `propagate_ignore` field.** It was reserved in #40 and dropped
|
||||
by #44: a lifecycle rule never writes into another tool's ignore/config
|
||||
surface (ADR-0040).
|
||||
- Unmatched files get NO lifetime — they flow through existing signals
|
||||
unchanged and become `:calibrate` candidates (**unmatched = unmanaged**).
|
||||
|
||||
### Scanner consumption
|
||||
|
||||
New stdlib `rulebook.py` loader. A directory-rule match **prunes the walk**
|
||||
(files beneath are never opened; one aggregate entry) — this also implements
|
||||
the IGNORE surface for free. A file-rule match attaches a **lifecycle signal**
|
||||
(rule ref + lifetime + served_when) consumed by the classifier as a new signal
|
||||
class (#37).
|
||||
|
||||
### Validation
|
||||
|
||||
Skip-and-warn per invalid/unconfirmed rule — a rule missing `confirmed_by`
|
||||
never acts (#39) but doesn't take scanning down. Hard-fail on unparseable JSON
|
||||
or unknown `schema_version`.
|
||||
|
||||
`.dochygiene-ignore` remains a hand-authored, human-only escape hatch,
|
||||
unmanaged by the rulebook (#44).
|
||||
|
||||
### Nominations memory (`nominations` key) — map #49 (#52/#56)
|
||||
|
||||
Every judge verdict with an actual answer (keep / temporary /
|
||||
delete-once-served / ignore) persists as a plain rule in `rules` (#51 — keep
|
||||
verdicts are ordinary `lifetime: keep` rules; matched = managed removes them
|
||||
from the calibrate pool, and the glob covers future files for free). The
|
||||
`nominations` key holds only the two residues that cannot be rules: "the
|
||||
answer is no" (`rejected`) and "no answer yet" (`consults`). It **never
|
||||
touches the file filter** — rules alone decide which files are governed.
|
||||
|
||||
```json
|
||||
"nominations": {
|
||||
"consults": [
|
||||
{
|
||||
"glob": "docs/orchestration-audit/*.md",
|
||||
"question": "Are the dated audit tune-up reports a retained audit trail, or disposable once the tune-up lands?",
|
||||
"evidence": "4 files, one per audit run; docs/implementation-status.md references only the latest",
|
||||
"cluster_key": "docs/orchestration-audit::report-#",
|
||||
"asked_on": "2026-07-15"
|
||||
}
|
||||
],
|
||||
"rejected": [
|
||||
{
|
||||
"glob": "docs/research/**",
|
||||
"lifetime": "temporary",
|
||||
"why": "findings docs other artifacts link to, not disposable output",
|
||||
"consider_instead": "an extract-to-vault rule for standalone findings docs",
|
||||
"rejected_by": "judge",
|
||||
"judged_on": "2026-07-15"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
- **Rejection entry:** `glob`, `lifetime`, `why`, optional `consider_instead`
|
||||
(the judge's amend pointer), `rejected_by: "judge" | "human"`, `judged_on`.
|
||||
Human declines at the §8 rule report also persist as rejections
|
||||
(`rejected_by: "human"`) — otherwise haiku can re-nominate what the human
|
||||
personally declined and the judge won't know.
|
||||
- **Consult entry:** `glob`, `question`, `evidence`, `cluster_key`,
|
||||
`asked_on`. Deliberately **no lifetime** — the uncertainty about purpose is
|
||||
the point. Presence in `consults` = open; no status field. This fixes
|
||||
consult evaporation (verdicts previously died with the run's scratch dir;
|
||||
the same question was re-derived every run).
|
||||
- **Consult exits:** (a) a human answer settles purpose → a normal rule is
|
||||
persisted and the consult entry deleted (the rule supersedes it); (b) "not
|
||||
rule-worthy" → rewritten into `rejected` with the human's why; (c) defer →
|
||||
stays, resurfaces next `:calibrate` run. Consults resurface in `:calibrate`
|
||||
**only** (`:check`/`:clean` unchanged — ADR-0039 boundary) and never
|
||||
filter anything.
|
||||
- **What a rejection blocks — exact glob+lifetime repeats only.** A variant
|
||||
(different lifetime, narrower glob) flows to the judge normally, carrying
|
||||
the past rejection as context. Stale rejections leave by hand-deletion
|
||||
(removals stay HITL, with recorded reasoning).
|
||||
- Exact-path singleton **keep** rules (#51) likewise leave only by
|
||||
hand-deletion — no automated revisit path: an automated revisit would be
|
||||
the one place a machine argues a keep back toward deletion, the exact
|
||||
failure mode the keep tier prevents.
|
||||
- `rulebook.py` stays **nomination-unaware** — its contract is "which rule
|
||||
governs this path"; only `calibrate_helpers` reads the key. The calibrate
|
||||
reader warns on unrecognized nomination fields, mirroring the rules
|
||||
array's unknown-field discipline.
|
||||
- Writes land via the same canonical writer as rules (next subsection); new
|
||||
rejections/consults appear in the §8 rule report but are not individually
|
||||
gated — they are memory, not deletion authority; hand-editing is the
|
||||
escape hatch.
|
||||
|
||||
### Canonical ordering — map #49 (#53)
|
||||
|
||||
**Writer-enforced, no hook.** Every code path that serializes
|
||||
`.dochygiene-rules.json` writes it grouped by lifetime tier
|
||||
(delete-once-served, temporary, keep), sorted by glob within each group;
|
||||
`nominations` after `rules`, `consults` before `rejected` (the
|
||||
pending-action queue reads first), each list glob-sorted. Idempotent; no
|
||||
drift window between a command finishing and a hook firing; hand edits
|
||||
re-canonicalize on the next write. (A post-command hook was considered and
|
||||
rejected as more moving parts for the same result.)
|
||||
|
||||
## 3. Deletion semantics and autonomy tiers (#35, #43 — ADR-0039)
|
||||
|
||||
Delete = **true git deletion in a dedicated hygiene commit**; git history is
|
||||
the archive. No `archive/` dirs or graveyard branches (relocated clutter still
|
||||
distracts AI/search).
|
||||
|
||||
Rule-backed deletes are **auto** — a rule confirmed per #39 *is* the standing
|
||||
consent; no per-run prompt. The auto/confirm line is drawn on **evidence
|
||||
quality + recoverability**, not file type:
|
||||
|
||||
| Case | Behavior |
|
||||
|---|---|
|
||||
| IGNORE surface (`graphify-out/**`, `.dochygiene/**`) | never walked |
|
||||
| lifetime `keep` | scanned + reported, never deleted |
|
||||
| tracked + delete rule + clean worktree | **auto** |
|
||||
| tracked + delete rule + DIRTY | **confirm** (an uncommitted diff dies with the file) |
|
||||
| untracked + delete rule | **confirm** (no history to recover from) |
|
||||
| no rule match | unmanaged; existing signals only, never deleted |
|
||||
|
||||
`clean` verifies tracked+clean **at runtime** (`git ls-files` + dirty check);
|
||||
it never trusts the rule's word for it. No `recoverable_via` field, no
|
||||
"regenerate" class.
|
||||
|
||||
## 4. Temporary tier (#43, #48)
|
||||
|
||||
**retain-recent-N + age, not age alone.** Defaults `retain_recent: 3`,
|
||||
`max_age_days: 3` (both per-rule overridable). The newest 3 entries matching a
|
||||
rule are always kept regardless of age (they show current trajectory); an
|
||||
entry ranked 4th or older is deleted once it exceeds `max_age_days`. 90-day
|
||||
windows were rejected as far too slow.
|
||||
|
||||
**Retention unit = the rule's match entry**: a file for file rules, a run
|
||||
*directory* for directory rules ("3 most recent autoresearch runs", not "3
|
||||
most recent files inside one run").
|
||||
|
||||
**Age = git commit time, falling back to filesystem mtime for untracked
|
||||
files.** mtime-alone was rejected (clone/branch-switch resets every mtime,
|
||||
putting the rulebook to sleep on fresh checkouts); the objection doesn't apply
|
||||
to the fallback, since untracked files don't exist in a fresh clone. No
|
||||
per-rule `age_source` field (#48).
|
||||
|
||||
**Untracked directory entries** use the directory inode's own mtime (one
|
||||
stat), NOT a recursive max-mtime walk — the tier's failure mode is
|
||||
self-healing (a spuriously bumped mtime merely delays deletion one round), so
|
||||
the cheap signal suffices (#48).
|
||||
|
||||
## 5. delete-once-served: served-signal split (#43)
|
||||
|
||||
Two slots, split by evidence quality:
|
||||
|
||||
```json
|
||||
{ "glob": "openspec/changes/*/", "lifetime": "delete-once-served",
|
||||
"served_when_path": "openspec/changes/archive/{id}/" }
|
||||
```
|
||||
Scanner **proves** the served condition → may delete silently under the tier
|
||||
matrix.
|
||||
|
||||
```json
|
||||
{ "glob": "docs/plans/*.md", "lifetime": "delete-once-served",
|
||||
"served_when": "the effort this plan describes has shipped" }
|
||||
```
|
||||
Classifier **judges** the condition → **always forced to confirm**, regardless
|
||||
of tracked status. The LLM may propose; it may never silently destroy on a
|
||||
hunch.
|
||||
|
||||
Example of the boundary: `autoresearch/*/` can be auto (a concluded run is
|
||||
provable from the filesystem); `PRD.md` cannot (purpose-triggered — "did this
|
||||
ship?" is a judgment; and it is NOT temporary, since age-keying would delete
|
||||
the PRD of a feature not yet built — #45 Addendum 2).
|
||||
|
||||
## 6. Determinism promotion (#43 item 7, #47 — ADR-0041)
|
||||
|
||||
**Design principle: hygiene drives projects toward structurally-obvious
|
||||
maintenance.** When a rule's served signal is subjective (classifier-judged),
|
||||
the tool does not merely downgrade it to confirm — it names the subjectivity
|
||||
and recommends a concrete structural convention that would graduate the rule
|
||||
to `served_when_path` and make it silent. Confirm-fatigue is the incentive to
|
||||
fix the convention.
|
||||
|
||||
### Completion-conventions catalog
|
||||
|
||||
`plugins/os-doc-hygiene/conventions.json` — global-only, machine-readable so
|
||||
the deterministic pipeline can emit nudges without an LLM. No per-project
|
||||
override: the catalog only recommends; adoption lands in the project's own
|
||||
rulebook. Each entry: name, what it proves, the `served_when_path` /
|
||||
frontmatter template a rule graduates to, and a one-line human pitch.
|
||||
|
||||
v1 contents — exactly two conventions:
|
||||
|
||||
- **archive-bucket** — "done" = the file moved into a sibling `archive/` dir
|
||||
(`docs/plans/x.md` → `docs/plans/archive/x.md`); graduates a rule to
|
||||
`served_when_path: <dir>/archive/{name}`. Precedent: openspec changes.
|
||||
- **status-frontmatter** — "done" = a `status: shipped|done` frontmatter key;
|
||||
file stays put; scanner reads frontmatter.
|
||||
|
||||
Successor-artifact checks stay in the fog until a calibration pass demands one.
|
||||
|
||||
### Nudge surfacing (split by capability)
|
||||
|
||||
- **`:check`** names promotion candidates in every report (deterministic,
|
||||
recurring — the report gains a promotion-candidates section).
|
||||
- **`:calibrate`** may go further and DRAFT the adoption — the graduated rule
|
||||
plus the file moves — for human approval. It proposes, never applies unasked.
|
||||
|
||||
## 7. Pipeline integration (#37)
|
||||
|
||||
Lifecycle categorization is a **new signal class** in the existing
|
||||
scanner/classifier pipeline; `delete` and `extract-then-delete` are **new op
|
||||
types** in the clean report schema. The only new skill is
|
||||
**`os-doc-hygiene:calibrate`**.
|
||||
|
||||
## 8. The :calibrate protocol (#42, #39, #45)
|
||||
|
||||
The learn-new-rules loop, run per project:
|
||||
|
||||
1. **Cluster-and-sample** over unmatched files (unmatched = unmanaged = the
|
||||
candidate pool). Clustering exists precisely so rules are written over the
|
||||
cluster, not one member.
|
||||
2. **Nomination (cheap model):** haiku nominates a bare glob + lifetime per
|
||||
cluster — constrained to produce *patterns*, never exact-instance globs.
|
||||
3. **Nomination intake filter (deterministic —
|
||||
`NominationIntakeFilter` in `calibrate_helpers.py`):** a nomination whose
|
||||
glob+lifetime exactly equals a `rejected` entry (§2 Nominations memory)
|
||||
is dropped before the judge, logged in the run summary. Survivors are
|
||||
annotated with every *related* rejection — related = the two globs'
|
||||
match sets intersect on the current shortlist (deterministic, from the
|
||||
scan). Annotations plus all open consults enter the judge prompt as a
|
||||
"Nominations memory" input section.
|
||||
4. **Judgment (strong model):** one batched Opus/Fable judge gathers its own
|
||||
evidence and authors final rule entries (#39: weak-model discoveries need
|
||||
strong-model confirmation). Verdicts: **confirm / reject / amend /
|
||||
consult** — consult is mandatory when an artifact's purpose is unclear
|
||||
(regenerable ≠ removable).
|
||||
5. **Rule report to the human — before any rule is persisted.** Per proposed
|
||||
rule, the report shows:
|
||||
1. the **glob verbatim**, exactly as it would be persisted;
|
||||
2. every path it currently matches (or a capped sample + total count);
|
||||
3. **the boundary — near-miss paths it does NOT match** (this caught a
|
||||
real bug during #45: `autoresearch/classic-*/` silently missing
|
||||
`autoresearch/improve-260710-1057/`);
|
||||
4. lifetime + behavior tier (auto vs confirm);
|
||||
5. a plain-language why — what the artifact is and why it's clutter.
|
||||
The human reviews patterns and examples, not JSON schema.
|
||||
6. **Persistence:** project rules land on judge confirmation;
|
||||
**global-rulebook writes are human-gated** (a cross-repo write into cc-os).
|
||||
Rule removals are HITL-only, with recorded reasoning. Every settled
|
||||
verdict persists as a plain rule — judge `keep` verdicts become ordinary
|
||||
`lifetime: keep` rules, including exact-path singletons (#51). Human
|
||||
declines persist as `rejected` entries (`rejected_by: "human"`); open
|
||||
`consult` verdicts persist to `nominations.consults`, deduped by glob at
|
||||
write time (§2 Nominations memory).
|
||||
7. **Retest loop:** stop at <2 new rules OR <10% unmatched shrink; hard cap 3
|
||||
rounds.
|
||||
|
||||
Seed intake: the #41 clutter-inventory seed candidates enter at judge intake —
|
||||
**full intake for every run after calibration pass #1** (see the one-off
|
||||
carve-out below).
|
||||
|
||||
### Rule-quality tests the report enforces
|
||||
|
||||
- **The rule is the CLASS, never the PATH.** A glob may hardcode a name that
|
||||
*recurs by convention* (`PRD.md`, `HANDOFF-*.md`, `migration-report.md`); it
|
||||
may NOT hardcode an identifier *unique to one instance* (a run-id, hash, or
|
||||
bare timestamp). A rule matching one file today is fine; a rule that can
|
||||
only EVER match one file is a failed generalization — flag loudly, never
|
||||
silently persist.
|
||||
**Keep-tier relaxation (map #49 #51):** exact-path/instance globs ARE
|
||||
allowed for `lifetime: keep` entries only — this test exists to prevent
|
||||
bad DELETION rules, and a singleton keep (e.g.
|
||||
`docs/research/clutter-pattern-inventory.md`) merely protects. Instance
|
||||
globs stay forbidden for `temporary`/`delete-once-served`.
|
||||
- **Glob-breadth tie-breaker: prefer the NARROWER glob.** Too-narrow fails
|
||||
safe (leaves clutter; the recurring pass catches it next round — self-
|
||||
healing). Too-broad fails dangerous (deletes a keeper — not self-healing).
|
||||
Readability beats cleverness when the cost is one missed round.
|
||||
- **Enumerate siblings — never widen to the container.** When the near-miss
|
||||
boundary check reveals sibling artifacts a glob misses, the fix is to
|
||||
ENUMERATE the conventional prefixes as separate rule entries
|
||||
(`autoresearch/classic-*/` + `autoresearch/improve-*/`), never to widen to
|
||||
the container (`autoresearch/*/`, `autoresearch/**`). A container-claiming
|
||||
glob mortgages the directory's entire future: nothing keep-worthy can ever
|
||||
live there without a counter-rule (a future
|
||||
`autoresearch/methodology-notes.md` would be claimed by a deletion rule). A
|
||||
sibling flavor that can't be named yet fails safe — it gets its own entry
|
||||
next pass, the same self-healing property the narrower-glob tie-breaker
|
||||
relies on. Container globs are justified ONLY when the directory is wholly
|
||||
machine-owned (`plugins/*/.pytest_cache/`), where nothing keep-worthy can
|
||||
appear inside.
|
||||
|
||||
### Test coverage for the map-#49 additions (#59)
|
||||
|
||||
- `NominationIntakeFilter` and the canonical-writer extension get **unit
|
||||
tests only** (pure deterministic logic — invariant #6). Fixtures assert
|
||||
exact-repeat drops, match-set-intersection relatedness annotations, and
|
||||
round-trip ordering + unknown-field warnings.
|
||||
- The judge's "Nominations memory" context and the consult resurfacing loop
|
||||
get **no scenario harness** — both are human-gated downstream, so no
|
||||
silent-failure path exists a harness would uniquely catch. Mitigation is
|
||||
one worked example each (judge.md input section; calibrate SKILL.md), with
|
||||
production IRL session audits as the next signal; IRL evidence of the
|
||||
judge ignoring rejection context is what triggers harness design (reading
|
||||
`~/Documents/SecondBrain/howto/running-autoresearch-skill-evals.md` first,
|
||||
per eval discipline).
|
||||
|
||||
## 9. Calibration pass #1: cc-os (#45)
|
||||
|
||||
Project: **cc-os** — proves the protocol *works* before testing whether it
|
||||
*generalizes*; the self-referential risk is accepted and paid for by the
|
||||
validation criteria carrying the weight.
|
||||
|
||||
- **Precision hard gate:** the pass FAILS if any rule persisted to the
|
||||
rulebook has a glob matching a protected path — regardless of behavior tier
|
||||
(a confirm gate is a safety property of the human sitting there, not of the
|
||||
protocol). Exploration-time consult verdicts on protected paths are FREE.
|
||||
Protected set (fixed before the pass, human-edited, never revised after):
|
||||
eval `scenarios/`/`scenarios-reserve/`/`fixture/`/judge-rubric.md;
|
||||
`openspec/specs/`; `docs/adr/**`; mirrored `.claude/`/`.codex/`/`.pi/` skill
|
||||
dirs; `CLAUDE.md`; plugin source.
|
||||
- **Recall floor: 8 of the 10 cc-os rows of the #41 inventory**, with 4
|
||||
mandatory (missing any fails the pass): `autoresearch/<run-id>/`,
|
||||
`HANDOFF-*.md`, `docs/adr/migration-report.md`,
|
||||
`.dochygiene/report.{json,md}`. `graphify-out/` is **void, not a miss** (it
|
||||
is IGNORE surface per #43). The recall floor is a grading bar, not runtime
|
||||
behavior — err-toward-keeping comes from the hard gate + consult.
|
||||
- **Seed hold-out (one-off for pass #1 ONLY):** the cc-os rows of #41 are the
|
||||
sealed answer key and are withheld from judge intake — otherwise the pass is
|
||||
an open-book exam. This deliberately deviates from #42; **every later run
|
||||
uses full seed intake.** Do not mistake the carve-out for a permanent
|
||||
property.
|
||||
- Novel matches beyond the answer key are expected and human-spot-checked —
|
||||
a wrong novel match triggers rule adjustment + a retest round, not failure.
|
||||
- A do-nothing pass cannot pass: the recall floor makes the pass falsifiable
|
||||
in the finding direction.
|
||||
|
||||
## Out of scope for this design
|
||||
|
||||
Shipping the recurring cross-project categorize-and-learn skill (charted as
|
||||
fog on map #31). Ignore-surface propagation into other tools' config
|
||||
(rejected — ADR-0040); if disposable files polluting the knowledge graph later
|
||||
proves painful, the fix belongs to graphify or os-vault:onboard-project.
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
schema: spec-driven
|
||||
created: 2026-07-15
|
||||
|
|
@ -0,0 +1,143 @@
|
|||
# Design: calibrate-assessment-inventory
|
||||
|
||||
## Context
|
||||
|
||||
Wayfinder map #49 produced a fully locked design, recorded in
|
||||
`plugins/os-doc-hygiene/lifecycle-spec.md` (§1 extract index, §2 nominations
|
||||
memory + canonical ordering, §8 protocol steps 3/6 + test coverage) and the
|
||||
2026-07-15 amendments to ADR-0038/0039, committed as `9f11df3`. This change
|
||||
implements it. There are no open design questions — where this document
|
||||
states a choice, it is restating the locked decision and its rationale, not
|
||||
making a new one.
|
||||
|
||||
Current state: `:calibrate` persists only confirmed rules; keep verdicts,
|
||||
human declines, and open consults evaporate with the run's scratch dir and
|
||||
are re-derived every pass. `calibrate_helpers.py` has helper functions but no
|
||||
intake filter and no single serialization path for `.dochygiene-rules.json`.
|
||||
The `:clean` extract-then-delete op writes to the vault but leaves no in-repo
|
||||
pointer.
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
**Goals:**
|
||||
|
||||
- Persist every settled judge verdict: answers as plain rules (keep included),
|
||||
"no" as `nominations.rejected`, "not yet" as `nominations.consults`.
|
||||
- Deterministically block exact-repeat re-nominations and hand the judge its
|
||||
history (related rejections + open consults) as context.
|
||||
- One canonical writer for `.dochygiene-rules.json` so every write is
|
||||
ordering-stable and reviewable.
|
||||
- Leave a discoverable pointer (`extracted.md`) when an extraction physically
|
||||
leaves the repo for the vault.
|
||||
|
||||
**Non-Goals:**
|
||||
|
||||
- No changes to `rulebook.py` semantics — it stays nomination-unaware
|
||||
(contract: "which rule governs this path"). Only `calibrate_helpers` reads
|
||||
the `nominations` key.
|
||||
- No consult resurfacing in `:check`/`:clean` (ADR-0039 boundary — calibrate
|
||||
only).
|
||||
- No automated revisit of rejections or singleton keep rules — both exit by
|
||||
hand-deletion only.
|
||||
- No new eval harness for the judge-context / consult-loop behavior (#59) —
|
||||
worked examples + production IRL audits instead.
|
||||
- No post-write hook for ordering enforcement (rejected in #53).
|
||||
|
||||
## Decisions
|
||||
|
||||
All decided on map #49; restated with rationale:
|
||||
|
||||
1. **Keep verdicts are plain `lifetime: keep` rules, not a separate store**
|
||||
(#51). Matched = managed removes them from the calibrate pool and the glob
|
||||
protects future files for free. Corollary: the class-never-path test is
|
||||
relaxed for the keep tier only — a singleton keep merely protects, so
|
||||
exact-path globs are allowed there and remain forbidden for
|
||||
`temporary`/`delete-once-served`.
|
||||
2. **`nominations` holds only the two non-rule residues** (#52): `rejected`
|
||||
("the answer is no") and `consults` ("no answer yet"). It never touches the
|
||||
file filter. Entry schemas per lifecycle-spec §2; consults deliberately
|
||||
carry no lifetime; presence in `consults` = open, no status field.
|
||||
3. **Rejections block exact glob+lifetime repeats only** (#52). Variants flow
|
||||
to the judge annotated with the related rejection — memory, not veto.
|
||||
Relatedness is deterministic: the two globs' match sets intersect on the
|
||||
current shortlist.
|
||||
4. **The intake filter is deterministic code, not a model step** (#52,
|
||||
invariant #6): `NominationIntakeFilter` in `calibrate_helpers.py`, run as
|
||||
pipeline Step 3.5 between haiku nomination and the judge; drops are logged
|
||||
in the run summary.
|
||||
5. **Canonical ordering is writer-enforced, no hook** (#53). Grouping:
|
||||
`rules` by tier (delete-once-served, temporary, keep), glob-sorted within
|
||||
group; `nominations` after `rules`; `consults` before `rejected` (pending
|
||||
-action queue reads first), each glob-sorted. Idempotent; hand edits
|
||||
re-canonicalize on the next write. Unknown fields warn and round-trip
|
||||
(never dropped).
|
||||
6. **Consult exits** (#56): (a) human answer settles purpose → normal rule
|
||||
persisted, consult entry deleted; (b) not rule-worthy → rewritten into
|
||||
`rejected` with `rejected_by: "human"`; (c) defer → stays, resurfaces next
|
||||
run. New rejections/consults appear in the Step 5 report but are not
|
||||
individually gated — they are memory, not deletion authority.
|
||||
7. **`extracted.md` append is owned by the extract op** (#57): distill →
|
||||
`/os-vault:write` → append pointer line → `git rm`, all inside the same
|
||||
per-file transaction and single hygiene commit — no window where the doc is
|
||||
gone but undiscoverable. Vault extractions only; repo-durable residue
|
||||
(ADR/CLAUDE.md) gets no index line. Self-protection is the already-shipped
|
||||
global `**/extracted.md → keep` rule.
|
||||
8. **Test coverage** (#59): unit tests only, for the two deterministic seams
|
||||
(intake filter; writer). The two model-facing behaviors get worked examples
|
||||
(already landed in judge.md / SKILL.md with the design commit) and IRL
|
||||
audits as the next signal.
|
||||
|
||||
## Implementation shape
|
||||
|
||||
- `scripts/calibrate_helpers.py`: add `NominationIntakeFilter` (small
|
||||
single-responsibility class, injected shortlist/rules-file data, returns
|
||||
`(survivors_with_annotations, dropped_log)`) and a `RulesFileWriter` (or
|
||||
extend the existing write path) that owns *all* serialization of
|
||||
`.dochygiene-rules.json`, including nominations read/validate/warn.
|
||||
- `skills/calibrate/SKILL.md` + workflow files: wire Step 3.5 (run the filter,
|
||||
feed its annotations + open consults into the judge prompt's existing
|
||||
"Nominations memory" section), Step 5 ("Open consults" report section,
|
||||
consult exits a/b/c), Step 6 (persist keep verdicts / rejections / consults
|
||||
through the canonical writer, dedupe consults by glob at write time). The
|
||||
SKILL.md consult-loop worked example loses its "design-level pending
|
||||
wiring" marker.
|
||||
- `scripts/patch_applier.py`: extend the `extract-then-delete` path — when the
|
||||
extraction destination is the vault, append the pointer entry (format per
|
||||
lifecycle-spec §1) to `<dir>/extracted.md` (create if absent) and stage it
|
||||
in the same hygiene commit; on extraction failure, neither append nor
|
||||
delete applies (existing skip semantics).
|
||||
- `tests/`: extend `test_calibrate_helpers.py` (or add
|
||||
`test_nomination_intake.py` / writer tests) covering exact-repeat drops,
|
||||
the two #52 worked relatedness cases, round-trip canonical ordering, and
|
||||
unknown-field warnings; patch-applier tests cover the append-then-delete
|
||||
atomicity and the no-append-on-repo-durable-extraction case.
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
- [Nominations grow without bound — stale rejections accumulate] → accepted
|
||||
by design: exits are hand-deletion only (removals stay HITL); the canonical
|
||||
ordering keeps the file reviewable, and entries are small.
|
||||
- [Judge treats a related rejection as a veto and under-nominates] → the
|
||||
judge.md "Nominations memory" section states variants-are-not-vetoes with a
|
||||
worked example; IRL session audits are the detection path (#59), and IRL
|
||||
evidence triggers harness design per eval discipline.
|
||||
- [Writer round-trips unknown fields but a hand-edit introduces malformed
|
||||
JSON] → existing skip-and-warn loader discipline applies; the writer only
|
||||
runs on an already-parsed structure.
|
||||
- [extracted.md append fails after a successful vault write] → the per-file
|
||||
transaction skips the delete too, so the doc is never gone-but-unindexed;
|
||||
the vault note is orphaned-but-harmless and the next run retries.
|
||||
|
||||
## Migration Plan
|
||||
|
||||
No data migration: `nominations` is additive to `schema_version` 1 and the v1
|
||||
loader ignores unknown top-level keys. Existing `.dochygiene-rules.json`
|
||||
files re-canonicalize on their first post-change write. Rollback is code-only
|
||||
(revert the change; `nominations` keys left in project files are inert).
|
||||
After implementation: full test suite green (407 existing tests + new), then
|
||||
`bin/refresh-plugins`.
|
||||
|
||||
## Open Questions
|
||||
|
||||
None — the design is locked (map #49); ambiguities found during
|
||||
implementation go back to `lifecycle-spec.md` as the source of truth.
|
||||
|
|
@ -0,0 +1,34 @@
|
|||
# Proposal: calibrate-assessment-inventory
|
||||
|
||||
## Why
|
||||
|
||||
Wayfinder map #49 locked the design (in `lifecycle-spec.md`, committed 2026-07-15) for persisting the *full* `:calibrate` assessment inventory — today only confirmed rules survive a calibration pass, so keep verdicts, human declines, and open consults evaporate and get re-litigated every pass. The design also fixed two judge-quality defects observed in calibration pass #1 (widening to container globs; no memory of prior rejections). This change implements that locked design; every decision is already made — see tickets #51–#57, #59 on the map.
|
||||
|
||||
## What Changes
|
||||
|
||||
- **Nominations memory**: the project rules file (`.dochygiene-rules.json`) gains a top-level `nominations` key with `consults` and `rejected` sub-keys (additive to `schema_version` 1). `rulebook.py` stays nomination-unaware.
|
||||
- **`NominationIntakeFilter`** (new class in `calibrate_helpers.py`): deterministic Step 3.5 of the calibrate pipeline — drops exact glob+lifetime repeats of recorded rejections, annotates related (match-set-intersecting) history for the judge.
|
||||
- **Canonical-writer extension** (`calibrate_helpers.py`): all rules-file writes go through one writer enforcing canonical ordering (rules grouped delete-once-served / temporary / keep, glob-sorted; `nominations` after `rules`, `consults` before `rejected`; unknown fields warned, round-tripped). No hook enforcement.
|
||||
- **Pipeline wiring**: Step 3.5 (intake filter) before the judge; Step 5 report gains an "Open consults" section; Step 6 persistence extended — keep verdicts persist as plain keep rules (incl. exact-path singletons, per the keep-tier relaxation), human declines persist as `rejected` entries, consult verdicts dedupe into `consults`.
|
||||
- **Extract-op append**: `:clean`'s extract-then-delete op appends a pointer entry to the per-directory `extracted.md` index, atomically within the single hygiene commit (the global `**/extracted.md → keep` rule is already in `rulebook.json`).
|
||||
- **Two unit-test suites** (no new eval harnesses, per ticket #59): intake filter (exact-repeat drops, relatedness annotations incl. the two #52 worked cases) and writer (round-trip ordering, unknown-field warnings).
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
|
||||
(none — every change extends an existing capability)
|
||||
|
||||
### Modified Capabilities
|
||||
|
||||
- `lifecycle-rulebook`: envelope gains the optional project-file-only `nominations` key (consults/rejected schemas); writes are writer-enforced into canonical ordering; loader remains nomination-unaware and add-only merge is unchanged.
|
||||
- `calibrate`: new deterministic intake-filter requirement (Step 3.5); Persistence Rules by Scope extended to keep verdicts / rejections / consults; judge intake gains nominations-memory context (rejections are variants-not-vetoes, open consults must resurface); Rule-Quality Class-Never-Path gains the keep-tier relaxation and the enumerate-siblings-never-widen-to-container test.
|
||||
- `doc-clean`: the extract-then-delete op additionally appends the `extracted.md` pointer entry in the same per-file transaction / single hygiene commit.
|
||||
|
||||
## Impact
|
||||
|
||||
- **Code**: `scripts/calibrate_helpers.py` (NominationIntakeFilter + canonical writer), `skills/calibrate/SKILL.md` + `workflows/*.md` (Step 3.5/5/6 wiring — judge.md context sections already landed with the design commit), `scripts/patch_applier.py` (extract-op append).
|
||||
- **Tests**: `tests/test_calibrate_helpers.py` extended (or split) for the two suites; existing 407-test suite must stay green.
|
||||
- **Data**: `.dochygiene-rules.json` files may now carry `nominations`; older readers unaffected (additive, `rulebook.py` ignores it).
|
||||
- **Source of truth**: `plugins/os-doc-hygiene/lifecycle-spec.md` §1 (extract index), §2 (nominations memory, canonical ordering), §8 (steps 3/6, test coverage); ADR-0038/0039 amendments 2026-07-15.
|
||||
- **Post-implementation**: run `bin/refresh-plugins`.
|
||||
|
|
@ -0,0 +1,151 @@
|
|||
# Delta: calibrate (calibrate-assessment-inventory)
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Deterministic Nomination Intake Filter
|
||||
|
||||
Between cheap-model nomination and strong-model judgment, `:calibrate` SHALL
|
||||
run a deterministic `NominationIntakeFilter` (in `calibrate_helpers.py`, no
|
||||
model — invariant #6). A nomination whose glob+lifetime exactly equals a
|
||||
`rejected` entry SHALL be dropped before the judge and logged in the run
|
||||
summary. Surviving nominations SHALL be annotated with every related
|
||||
rejection, where related means the two globs' match sets intersect on the
|
||||
current shortlist (deterministic, computed from the scan). The annotations
|
||||
plus all open consults SHALL enter the judge prompt as its "Nominations
|
||||
memory" input section; related rejections are context for the judge, never a
|
||||
veto.
|
||||
|
||||
#### Scenario: Exact glob+lifetime repeat is dropped before the judge
|
||||
|
||||
- **WHEN** haiku nominates `docs/research/** -> temporary` and `nominations.rejected` contains an entry with glob `docs/research/**` and lifetime `temporary`
|
||||
- **THEN** the nomination is dropped at intake, never reaches the judge, and the drop is logged in the run summary
|
||||
|
||||
#### Scenario: A variant flows through annotated, not blocked
|
||||
|
||||
- **WHEN** haiku nominates `docs/research/drafts/** -> temporary` and `nominations.rejected` contains `docs/research/** -> temporary`, and the two globs' match sets intersect on the current shortlist
|
||||
- **THEN** the nomination proceeds to the judge carrying the related rejection (its why and consider_instead) as context, and the judge may still confirm it
|
||||
|
||||
#### Scenario: Open consults always reach the judge prompt
|
||||
|
||||
- **WHEN** `nominations.consults` is non-empty at intake time
|
||||
- **THEN** every open consult is included in the judge prompt's "Nominations memory" section, regardless of what haiku nominated this round
|
||||
|
||||
### Requirement: Consult Persistence and Resurfacing
|
||||
|
||||
Open `consult` verdicts SHALL persist to `nominations.consults` (deduped by
|
||||
glob at write time) rather than dying with the run. Consults SHALL resurface
|
||||
in `:calibrate` only — `:check` and `:clean` are unchanged — appearing in the
|
||||
judge prompt and as an "Open consults" section of the rule report. A consult
|
||||
SHALL exit in exactly one of three ways: (a) a human answer settles the
|
||||
purpose — a normal rule is persisted and the consult entry deleted; (b) the
|
||||
human deems it not rule-worthy — the entry is rewritten into `rejected` with
|
||||
`rejected_by: "human"` and the human's why; (c) the human defers — the entry
|
||||
stays and resurfaces next run. New rejections and consults SHALL appear in
|
||||
the rule report but are not individually gated — they are memory, not
|
||||
deletion authority.
|
||||
|
||||
#### Scenario: A consult survives the run and resurfaces
|
||||
|
||||
- **WHEN** a judge verdict is `consult` and the run ends without a human answer
|
||||
- **THEN** the consult is written to `nominations.consults`, and the next `:calibrate` run surfaces it in both the judge prompt and the report's "Open consults" section
|
||||
|
||||
#### Scenario: An answered consult becomes a rule and disappears
|
||||
|
||||
- **WHEN** the human answers an open consult in a way that settles the artifact's purpose
|
||||
- **THEN** a normal rule is persisted through the standard report flow and the consult entry is deleted in the same write
|
||||
|
||||
#### Scenario: A declined consult becomes a human rejection
|
||||
|
||||
- **WHEN** the human answers that an open consult's artifact class is not rule-worthy
|
||||
- **THEN** the consult entry is rewritten into `nominations.rejected` with `rejected_by: "human"` and the stated reason
|
||||
|
||||
#### Scenario: Consults never surface outside calibrate
|
||||
|
||||
- **WHEN** `:check` or `:clean` runs against a project with open consults
|
||||
- **THEN** their behavior is unchanged — consults neither appear in output nor affect any classification
|
||||
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: Persistence Rules by Scope
|
||||
|
||||
Project-rulebook writes SHALL land on judge confirmation once the human has
|
||||
reviewed the rule report. Global-rulebook writes (writing into
|
||||
`plugins/os-doc-hygiene/rulebook.json`) SHALL additionally require explicit
|
||||
human gating, distinct from project-rule confirmation, since it is a
|
||||
cross-repo write into cc-os. Rule removals SHALL be HITL-only in all cases,
|
||||
with recorded reasoning, regardless of scope. Every settled verdict SHALL
|
||||
persist: judge `keep` verdicts become ordinary `lifetime: keep` rules in
|
||||
`rules` (including exact-path singletons, per the keep-tier relaxation);
|
||||
human declines at the rule report persist as `rejected` entries with
|
||||
`rejected_by: "human"`; open `consult` verdicts persist to
|
||||
`nominations.consults`, deduped by glob at write time. All persistence SHALL
|
||||
go through the canonical rules-file writer.
|
||||
|
||||
#### Scenario: Project rule persists on judge confirmation plus report review
|
||||
|
||||
- **WHEN** the judge verdict is `confirm` for a project-scoped rule and the human has reviewed its rule report
|
||||
- **THEN** the rule is written to the project's `.dochygiene-rules.json`
|
||||
|
||||
#### Scenario: Global rulebook writes require an additional explicit gate
|
||||
|
||||
- **WHEN** a proposed rule would be written to the global `rulebook.json`
|
||||
- **THEN** a distinct human confirmation for the cross-repo write is required, beyond the project-rule confirmation step
|
||||
|
||||
#### Scenario: Rule removal is always HITL-only
|
||||
|
||||
- **WHEN** any rule (project or global) is proposed for removal
|
||||
- **THEN** the removal happens only via explicit human instruction, with the reasoning recorded, never as an automatic side effect of a calibration pass
|
||||
|
||||
#### Scenario: A keep verdict persists as a plain keep rule
|
||||
|
||||
- **WHEN** the judge's settled verdict for a cluster is that the artifacts must be retained
|
||||
- **THEN** an ordinary `lifetime: keep` rule is persisted to `rules` — removing the cluster from future calibrate pools and protecting future matches — even when the glob is an exact-path singleton
|
||||
|
||||
#### Scenario: A human decline persists as a rejection
|
||||
|
||||
- **WHEN** the human declines a judge-confirmed rule at the rule report
|
||||
- **THEN** a `rejected` entry with `rejected_by: "human"` is written, so a later haiku round cannot re-nominate the identical glob+lifetime without the judge knowing
|
||||
|
||||
### Requirement: Rule-Quality Test — Class Never Path
|
||||
|
||||
A proposed rule's glob SHALL name a recurring class of artifact, never an
|
||||
identifier unique to a single instance. A glob that hardcodes a name
|
||||
recurring by convention (e.g. `PRD.md`, `HANDOFF-*.md`,
|
||||
`migration-report.md`) is acceptable. A glob that hardcodes a run-id, hash,
|
||||
or bare timestamp unique to one instance is not acceptable. A rule that
|
||||
currently matches only one file is acceptable; a rule that can, by
|
||||
construction, only ever match one file is a failed generalization and SHALL
|
||||
be flagged loudly rather than silently persisted. **Keep-tier relaxation:**
|
||||
exact-path/instance globs ARE acceptable for `lifetime: keep` entries only —
|
||||
this test exists to prevent bad deletion rules, and a singleton keep merely
|
||||
protects; instance globs remain forbidden for `temporary` and
|
||||
`delete-once-served`. When the near-miss boundary check reveals sibling
|
||||
artifacts a glob misses, the fix SHALL be to enumerate the conventional
|
||||
prefixes as separate rule entries, never to widen the glob to the containing
|
||||
directory; container-claiming globs are justified ONLY when the directory is
|
||||
wholly machine-owned (e.g. `plugins/*/.pytest_cache/`).
|
||||
|
||||
#### Scenario: A convention-recurring name is acceptable
|
||||
|
||||
- **WHEN** a proposed rule's glob is `HANDOFF-*.md`
|
||||
- **THEN** it passes the class-never-path test, since `HANDOFF-*` is a recurring naming convention, not a single instance
|
||||
|
||||
#### Scenario: An instance-unique identifier fails the test
|
||||
|
||||
- **WHEN** a proposed rule's glob hardcodes a specific run-id or hash string that can only ever identify one artifact
|
||||
- **THEN** the rule fails the class-never-path test and is flagged loudly, not silently persisted
|
||||
|
||||
#### Scenario: One current match is fine; one-EVER match is not
|
||||
|
||||
- **WHEN** a proposed rule currently matches exactly one file
|
||||
- **THEN** it is acceptable if the glob's structure could match future similarly-named files; it is flagged as a failed generalization if the glob's structure can never match any file but the one it names today
|
||||
|
||||
#### Scenario: A singleton keep passes under the keep-tier relaxation
|
||||
|
||||
- **WHEN** a proposed rule is `docs/research/clutter-pattern-inventory.md -> keep`
|
||||
- **THEN** it passes despite being an exact-path instance glob, because the keep tier only protects; the same glob with lifetime `temporary` or `delete-once-served` fails
|
||||
|
||||
#### Scenario: Missed siblings are enumerated, never widened to the container
|
||||
|
||||
- **WHEN** the boundary check shows `autoresearch/classic-*/` misses sibling runs under `autoresearch/improve-*/`
|
||||
- **THEN** the fix is a second rule entry `autoresearch/improve-*/` (same cluster), never a widening to `autoresearch/*/` or `autoresearch/**`, so keep-worthy content can still live in the container without a counter-rule
|
||||
|
|
@ -0,0 +1,66 @@
|
|||
# Delta: doc-clean (calibrate-assessment-inventory)
|
||||
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: Applier Applies Delete and Extract-Then-Delete Under the Tier Matrix
|
||||
|
||||
The patch applier SHALL support two new op kinds, `delete` and
|
||||
`extract-then-delete`. For both, immediately before applying, it SHALL
|
||||
re-run `git ls-files <path>` and a dirty check against that specific path —
|
||||
never trusting the cached report tier or rule claim — and SHALL apply the
|
||||
tier matrix from the `lifecycle-deletion` spec to decide whether the entry
|
||||
may proceed as `auto` or must be treated as `confirm` (already gated
|
||||
upstream by the clean skill). `delete` SHALL perform a `git rm` (recursive
|
||||
for a directory-rule aggregate entry) staged into the run's single hygiene
|
||||
commit. `extract-then-delete` SHALL first complete its generative extraction
|
||||
step (repo-durable via the existing live-read Sonnet distillation path
|
||||
writing into an ADR/CLAUDE.md/docs target, or cross-repo via
|
||||
`/os-vault:write`) and SHALL only perform the `git rm` once extraction has
|
||||
succeeded; both steps SHALL land in the same single hygiene commit, or, on
|
||||
extraction failure, neither SHALL be applied for that entry (skip, not a
|
||||
run-level hard failure, unless the failure matches an existing hard-failure
|
||||
trigger). When the extraction destination is the vault (the content
|
||||
physically leaves the repo), the op SHALL additionally append a pointer
|
||||
entry to the deleted file's per-directory `extracted.md` index (creating the
|
||||
file if absent), in the same atomic sequence — distill → `/os-vault:write` →
|
||||
append the pointer line → `git rm` — all staged into the same single hygiene
|
||||
commit, so there is no window where the doc is gone but undiscoverable. The
|
||||
pointer entry SHALL name the vault note, state why a future reader would
|
||||
follow it, and record the source filename and date. Repo-durable extraction
|
||||
targets (ADR, CLAUDE.md, docs) SHALL NOT produce an index entry — they are
|
||||
already discoverable in-repo.
|
||||
|
||||
#### Scenario: delete performs a true git rm at apply time
|
||||
|
||||
- **WHEN** the applier applies a `delete` entry
|
||||
- **THEN** it re-verifies tracked/clean status via `git ls-files` and a dirty check, then performs a `git rm` staged into the single hygiene commit
|
||||
|
||||
#### Scenario: extract-then-delete only deletes after extraction succeeds
|
||||
|
||||
- **WHEN** the applier applies an `extract-then-delete` entry
|
||||
- **THEN** it completes the extraction write (ADR/CLAUDE.md/docs or vault) first, and performs the `git rm` only after that write succeeds
|
||||
|
||||
#### Scenario: A failed extraction skips the delete for that entry
|
||||
|
||||
- **WHEN** the extraction step of an `extract-then-delete` entry fails
|
||||
- **THEN** the delete is not applied for that entry, the entry is reported as skipped, and the run is not treated as a hard failure unless the failure matches an existing hard-failure trigger (applier exit 2, write error)
|
||||
|
||||
#### Scenario: Directory-rule aggregate delete removes the whole directory
|
||||
|
||||
- **WHEN** the applier applies a `delete` entry whose path is a directory-rule aggregate entry
|
||||
- **THEN** it performs a recursive `git rm` removing the entire matched directory in one operation staged into the single hygiene commit
|
||||
|
||||
#### Scenario: A vault extraction leaves an extracted.md pointer in the same commit
|
||||
|
||||
- **WHEN** an `extract-then-delete` entry extracts to the vault via `/os-vault:write`
|
||||
- **THEN** a pointer entry naming the vault note, the reason to follow it, the source filename, and the date is appended to the deleted file's directory `extracted.md` (created if absent), and the append, the deletion, and the index file are all staged into the same single hygiene commit
|
||||
|
||||
#### Scenario: A repo-durable extraction leaves no index entry
|
||||
|
||||
- **WHEN** an `extract-then-delete` entry extracts into an ADR, CLAUDE.md, or docs target inside the repo
|
||||
- **THEN** no `extracted.md` entry is written — the residue is already discoverable in-repo
|
||||
|
||||
#### Scenario: A failed pointer append skips the delete
|
||||
|
||||
- **WHEN** the vault write succeeds but appending the `extracted.md` pointer fails
|
||||
- **THEN** the `git rm` is not applied for that entry and it is reported as skipped, preserving the invariant that a doc is never gone but undiscoverable
|
||||
|
|
@ -0,0 +1,99 @@
|
|||
# Delta: lifecycle-rulebook (calibrate-assessment-inventory)
|
||||
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: Rulebook Locations and Envelope
|
||||
|
||||
The plugin SHALL ship a global rulebook at
|
||||
`plugins/os-doc-hygiene/rulebook.json`, resolved relative to plugin scripts,
|
||||
present in every project. A project MAY additionally provide a committed
|
||||
repo-root `.dochygiene-rules.json` override. Both files SHALL use the
|
||||
envelope `{"schema_version": 1, "rules": [...]}`. The project file MAY
|
||||
additionally carry a top-level `nominations` key (see the Nominations Memory
|
||||
requirement); the loader SHALL ignore unknown top-level keys, so the key is
|
||||
additive to `schema_version` 1. The per-project override SHALL NOT live under
|
||||
gitignored `.cc-os/` — it SHALL be a committed, reviewable dotfile.
|
||||
|
||||
#### Scenario: Global rulebook is always present
|
||||
|
||||
- **WHEN** the rulebook loader runs in any project
|
||||
- **THEN** it loads `plugins/os-doc-hygiene/rulebook.json` resolved relative to the plugin scripts directory
|
||||
|
||||
#### Scenario: Per-project override is optional and committed
|
||||
|
||||
- **WHEN** a project has no `.dochygiene-rules.json` at its repo root
|
||||
- **THEN** the loader proceeds using only the global rulebook, and when the file is present it is read as a committed, reviewable file, never from `.cc-os/`
|
||||
|
||||
#### Scenario: Both files share one envelope shape
|
||||
|
||||
- **WHEN** either the global rulebook or a project override is loaded
|
||||
- **THEN** it is validated against the envelope `{"schema_version": 1, "rules": [...]}`
|
||||
|
||||
#### Scenario: A nominations key does not disturb the v1 loader
|
||||
|
||||
- **WHEN** a project `.dochygiene-rules.json` carries a top-level `nominations` key alongside `rules`
|
||||
- **THEN** `rulebook.py` loads the `rules` array exactly as before, ignoring the unknown top-level key without warning or error
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Nominations Memory Lives in the Project Rules File
|
||||
|
||||
The project `.dochygiene-rules.json` MAY carry a top-level `nominations` key
|
||||
holding exactly two lists: `consults` (open questions — entries with `glob`,
|
||||
`question`, `evidence`, `cluster_key`, `asked_on`, and deliberately NO
|
||||
lifetime) and `rejected` (settled "no" answers — entries with `glob`,
|
||||
`lifetime`, `why`, optional `consider_instead`, `rejected_by` (`"judge"` or
|
||||
`"human"`), `judged_on`). The `nominations` key SHALL never affect which
|
||||
files the rulebook governs — only entries in `rules` decide that.
|
||||
`rulebook.py` SHALL remain nomination-unaware; only the calibrate helpers
|
||||
read the key, and the calibrate reader SHALL warn on unrecognized nomination
|
||||
fields, mirroring the rules array's unknown-field discipline. Rejected
|
||||
entries and exact-path singleton keep rules SHALL exit only by hand-deletion
|
||||
(removals stay HITL with recorded reasoning); no automated revisit path
|
||||
SHALL exist.
|
||||
|
||||
#### Scenario: Nominations never filter files
|
||||
|
||||
- **WHEN** the scanner or rulebook resolves the governing rule for a path that only a `nominations` entry's glob matches
|
||||
- **THEN** the path is treated as unmatched/unmanaged — nominations carry no lifecycle authority
|
||||
|
||||
#### Scenario: Consult entries carry no lifetime
|
||||
|
||||
- **WHEN** a consult entry is written to `nominations.consults`
|
||||
- **THEN** it records `glob`, `question`, `evidence`, `cluster_key`, `asked_on` and no lifetime field — presence in the list means open, with no status field
|
||||
|
||||
#### Scenario: Unrecognized nomination fields warn in the calibrate reader
|
||||
|
||||
- **WHEN** the calibrate helpers read a nominations entry containing an unknown field
|
||||
- **THEN** a warning is emitted and the field is preserved, never silently dropped
|
||||
|
||||
#### Scenario: A rejection leaves only by hand-deletion
|
||||
|
||||
- **WHEN** a calibration pass runs against a rules file containing a stale rejection
|
||||
- **THEN** no automated path removes or expires the entry; it is removed only by explicit human edit
|
||||
|
||||
### Requirement: Canonical Writer-Enforced Ordering
|
||||
|
||||
Every code path that serializes `.dochygiene-rules.json` SHALL write through
|
||||
one canonical writer that emits: `rules` grouped by lifetime tier in the
|
||||
order delete-once-served, temporary, keep, glob-sorted within each group;
|
||||
`nominations` after `rules`; `consults` before `rejected`, each glob-sorted.
|
||||
The writer SHALL be idempotent (canonicalizing an already-canonical file is a
|
||||
no-op) and SHALL round-trip unknown fields with a warning rather than
|
||||
dropping them. Ordering SHALL NOT be enforced by any hook; hand edits
|
||||
re-canonicalize on the next write.
|
||||
|
||||
#### Scenario: Writes are grouped and sorted canonically
|
||||
|
||||
- **WHEN** the writer serializes a rules file containing rules of all three tiers plus nominations
|
||||
- **THEN** the output orders rules delete-once-served → temporary → keep with globs sorted within each group, and nominations follows rules with consults before rejected, each list glob-sorted
|
||||
|
||||
#### Scenario: Canonicalization is idempotent
|
||||
|
||||
- **WHEN** the writer serializes a file it previously wrote, unchanged
|
||||
- **THEN** the output is byte-identical
|
||||
|
||||
#### Scenario: A hand-edited file re-canonicalizes on the next write
|
||||
|
||||
- **WHEN** a human appends a rule out of tier order and a later calibrate run persists a new entry
|
||||
- **THEN** the whole file is rewritten in canonical order in that write, with no hook involved in the interim
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
# Tasks: calibrate-assessment-inventory
|
||||
|
||||
TDD (red-green-refactor) throughout; run the full suite (407 existing tests)
|
||||
after each group. Source of truth for behavior: `lifecycle-spec.md` §1/§2/§8.
|
||||
|
||||
## 1. Canonical rules-file writer (calibrate_helpers.py)
|
||||
|
||||
- [x] 1.1 Write failing tests: canonical ordering (rules grouped delete-once-served → temporary → keep, glob-sorted; nominations after rules; consults before rejected, glob-sorted), idempotent round-trip (byte-identical rewrite), hand-edit re-canonicalization, unknown-field warn-and-round-trip (rules and nomination entries)
|
||||
- [x] 1.2 Implement the writer as the single serialization path for `.dochygiene-rules.json`, including the nominations read/validate/warn path (`rulebook.py` untouched)
|
||||
- [x] 1.3 Route every existing calibrate persistence write through the writer
|
||||
|
||||
## 2. NominationIntakeFilter (calibrate_helpers.py)
|
||||
|
||||
- [x] 2.1 Write failing tests: exact glob+lifetime repeat is dropped and logged; the two #52 worked cases (exact repeat of `docs/research/** -> temporary` dropped; variant `docs/research/drafts/** -> temporary` survives annotated); match-set-intersection relatedness computed from an injected shortlist; open consults always passed through to the judge input
|
||||
- [x] 2.2 Implement `NominationIntakeFilter` (deterministic, injected rules-file data + shortlist; returns survivors-with-annotations + dropped log)
|
||||
|
||||
## 3. Calibrate skill wiring (SKILL.md + workflows)
|
||||
|
||||
- [x] 3.1 Wire Step 3.5: run the intake filter between nomination and judgment; feed annotations + open consults into the judge prompt's "Nominations memory" section; surface drops in the run summary
|
||||
- [x] 3.2 Wire Step 5: add the "Open consults" report section with the three exits (answer → rule + consult deleted; decline → rejected with `rejected_by: "human"`; defer → stays); new rejections/consults shown but not individually gated
|
||||
- [x] 3.3 Wire Step 6: persist keep verdicts as plain keep rules (exact-path singletons allowed — keep-tier relaxation), human declines as rejections, consult verdicts into `nominations.consults` deduped by glob — all through the canonical writer
|
||||
- [x] 3.4 Remove the "design-level pending pipeline wiring" marker from the SKILL.md consult-loop worked example; verify judge.md needs no further edits (its map-#49 sections landed with the design commit)
|
||||
|
||||
## 4. Extract-op extracted.md append (patch_applier.py)
|
||||
|
||||
- [x] 4.1 Write failing tests: vault extraction appends the pointer entry (correct format: vault note name, why, source filename, date) to `<dir>/extracted.md`, creating it if absent, staged in the same hygiene commit as the `git rm`; repo-durable extraction (ADR/CLAUDE.md/docs) writes no entry; failed vault write skips append and delete; failed append skips the delete
|
||||
- [x] 4.2 Implement the append in the extract-then-delete path (distill → vault write → append → git rm, one per-file transaction)
|
||||
|
||||
## 5. Verification and rollout
|
||||
|
||||
- [x] 5.1 Full test suite green (existing 407 + new); no informal runs of held-out eval scenarios
|
||||
- [x] 5.2 Run `bin/refresh-plugins` and update the SKILL.md/workflow cache-sensitive docs if the refresh surfaces anything
|
||||
- [x] 5.3 Record completion per repo convention: build-plan/status index untouched unless design changed; note in `docs/implementation-status/` leaf if warranted
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
schema: spec-driven
|
||||
created: 2026-07-14
|
||||
|
|
@ -0,0 +1,27 @@
|
|||
# Calibration pass #1 (cc-os) — protected set
|
||||
|
||||
_Fixed 2026-07-14, before the pass; per lifecycle-spec §9 this list is
|
||||
human-edited and never revised after the pass starts._
|
||||
|
||||
Hard gate: the pass FAILS if any rule persisted to a rulebook has a glob
|
||||
matching any path below — regardless of behavior tier. Exploration-time
|
||||
consult verdicts on these paths are free.
|
||||
|
||||
Protected globs (repo-root-relative, cc-os):
|
||||
|
||||
- `plugins/*/eval*/scenarios/**`
|
||||
- `plugins/*/eval*/scenarios-reserve/**`
|
||||
- `plugins/*/eval*/fixture/**`
|
||||
- `plugins/*/eval*/judge-rubric.md`
|
||||
- `plugins/os-context/eval/**/scenarios*/**` (defensive; covered by the above where layout matches)
|
||||
- `openspec/specs/**`
|
||||
- `docs/adr/**`
|
||||
- `.claude/skills/**`
|
||||
- `.codex/skills/**`
|
||||
- `.pi/skills/**`
|
||||
- `plugins/*/.claude/skills/**`
|
||||
- `CLAUDE.md` (root and any directory-level `CLAUDE.md`, i.e. `**/CLAUDE.md`)
|
||||
- `plugins/*/skills/**`, `plugins/*/scripts/**`, `plugins/*/hooks/**`,
|
||||
`plugins/*/lib/**`, `plugins/*/.claude-plugin/**` (plugin source)
|
||||
- `plugins/*/rulebook.json`, `plugins/*/conventions.json`, `plugins/*/invariants.md`,
|
||||
`plugins/*/examples/golden/**` (plugin source/reversion protection)
|
||||
|
|
@ -0,0 +1,67 @@
|
|||
# Calibration pass #1 (cc-os) — results
|
||||
|
||||
_Run 2026-07-14/15. Protocol: skills/calibrate/SKILL.md; seed hold-out per
|
||||
lifecycle-spec §9 (one-off). Scratch artifacts: session scratchpad
|
||||
`calib-r1/` (scan.json, clusters.json, nominations.json, verdicts.json,
|
||||
rule_report.json)._
|
||||
|
||||
## Run shape
|
||||
|
||||
- Unmatched pool (round 1): **479 paths**, 435 shape-clusters, batched into 4
|
||||
haiku nominator families (autoresearch, docs, plugins,
|
||||
openspec+assistant-mirrors+root).
|
||||
- Nominations: 19 (15 with globs, 4 explicit keep-declines).
|
||||
- Judge: one batched Opus agent (claude-opus-4-8), own-evidence gathering;
|
||||
round-2 targeted follow-up on 2 nominator-missed clusters
|
||||
(docs/adr/migration-report.md, plugins/*/PRD.md) via the same agent.
|
||||
- Verdict tally (17 judged): 4 amend→persistable, 4 consult, 9 reject.
|
||||
- Retest stop condition: round 2 yielded 0 new persistable rules (<2) → stop.
|
||||
|
||||
## Grading vs spec §9
|
||||
|
||||
- **Protected-set hard gate: PASS** — deterministic glob check of all 4
|
||||
persistable rules against the fixed protected set
|
||||
(calibration-pass-1-protected-set.md): zero overlaps. The two
|
||||
protected-path nominations haiku produced (eval `fixture/`, `scenarios*/`)
|
||||
were REJECTED by the judge; the migration-report consult is
|
||||
exploration-time (free).
|
||||
- **Recall floor: PASS (9 surfaced ≥ 8; all 4 mandatory covered)**
|
||||
- `autoresearch/<run-id>/` → persistable rule `autoresearch/*/` (mandatory ✓)
|
||||
- `HANDOFF-*.md` → persistable rule `plugins/*/HANDOFF-*.md` (mandatory ✓)
|
||||
- `docs/adr/migration-report.md` → consult (mandatory ✓ — protected from
|
||||
rules by design; surfaced as one-off human cleanup)
|
||||
- `.dochygiene/report.{json,md}` → governed by the shipped global IGNORE
|
||||
seed `.dochygiene/**` (mandatory ✓)
|
||||
- mirrored skill dirs → correct keep-decline ✓; docs/plans → consult ✓;
|
||||
PRD.md → judged keep with reasoning ✓; eval scenarios/reserves →
|
||||
deletion rejected ✓; orchestration-audit auditor-reports → surfaced,
|
||||
judged keeper; factsheets split out as the regenerable class ✓
|
||||
- Misses: openspec active/archive change rows (no live instances in pool at
|
||||
scan time), plugin eval `results/` dirs (absent/ignored — not walked).
|
||||
- `graphify-out/` — void, not a miss (IGNORE surface).
|
||||
- **Novel matches spot-check:** `docs/orchestration-audit/factsheets/*.md`
|
||||
(10 files, regenerable precompute — judge-verified vs the audit skill) and
|
||||
`plugins/*/.pytest_cache/` (regenerable cache; judge notes the human may
|
||||
prefer a .gitignore entry instead). Both plausible; human review below.
|
||||
- Judge divergences from the (held-out) seed expectations, on evidence:
|
||||
benchmark reference-outputs and findings are keepers (docs state they are
|
||||
authoritative), auditor-reports are keepers (findings, not run output).
|
||||
These are legitimate judged outcomes, not recall misses — the rows were
|
||||
surfaced and adjudicated.
|
||||
|
||||
## Persistable rules (pending human approval — NOT yet persisted)
|
||||
|
||||
See rule report in the session transcript / `calib-r1/verdicts.json`:
|
||||
|
||||
1. `autoresearch/*/` — temporary, retain_recent 3, max_age_days 30
|
||||
2. `docs/orchestration-audit/factsheets/*.md` — temporary, retain 10, 90d
|
||||
3. `plugins/*/.pytest_cache/` — temporary, retain 0, 7d
|
||||
4. `plugins/*/HANDOFF-*.md` — delete-once-served, classifier-judged
|
||||
served_when (forces confirm; the one instance is ACTIVE and must not be
|
||||
auto-deleted)
|
||||
|
||||
Consults for one-off human decisions (no rules): docs/plans workstream/block
|
||||
docs (ws*/b*/c*), dated plan/audit overview docs, docs/adr/migration-report.md.
|
||||
|
||||
**Verdict: pass CRITERIA MET. Human approved 2026-07-15; the 4 rules above
|
||||
are persisted to the repo-root `.dochygiene-rules.json` (ADR-0038).**
|
||||
|
|
@ -0,0 +1,391 @@
|
|||
# Design: lifecycle-aware-doc-hygiene
|
||||
|
||||
## Context
|
||||
|
||||
os-doc-hygiene today only monitors stale/bloated docs (`doc-check`,
|
||||
`doc-clean`, `report-schema` specs, already shipped). The locked lifecycle
|
||||
design (`plugins/os-doc-hygiene/lifecycle-spec.md`, wayfinder map #31, ADRs
|
||||
0038–0041) extends it to *lifecycle management*: every managed file gets a
|
||||
lifetime (`keep` / `temporary` / `delete-once-served`, plus an `extract`
|
||||
modifier), rules live in a rulebook, and a new `:calibrate` skill learns rules
|
||||
per project. This design translates the locked spec into the concrete seams
|
||||
of the existing pipeline (`scanner.py` → subagent classification →
|
||||
`report_builder.py` → `validate_report.py` → `patch_applier.py`) without
|
||||
reopening any decision already recorded in the spec or its ADRs.
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
**Goals**
|
||||
|
||||
- Add a stdlib-only `rulebook.py` loader (global + per-project override,
|
||||
add-only merge, `glob.translate` dialect) consumed by the scanner.
|
||||
- Give the scanner a lifecycle signal class where a directory-rule match
|
||||
prunes the walk — this is simultaneously the IGNORE-surface implementation
|
||||
(no separate ignore mechanism).
|
||||
- Extend `report_builder.py` / `validate_report.py` / `patch_applier.py` to
|
||||
carry `delete` and `extract-then-delete` op types end-to-end under the
|
||||
ADR-0039 autonomy tier matrix, with all tracked/dirty state verified at
|
||||
application time via git, never trusted from the rule or the report cache.
|
||||
- Implement temporary-tier age computation (git commit time, mtime fallback
|
||||
for untracked files, directory-inode mtime for untracked directory entries)
|
||||
and retain-recent-N semantics.
|
||||
- Add `conventions.json` and wire promotion-candidate nudging into `:check`.
|
||||
- Add `:calibrate` as a skill orchestrating haiku nomination + strong-model
|
||||
judge subagents, per the 6-step protocol in spec §8.
|
||||
- Resolve the state-dir wrinkle (below) so the shipped IGNORE surface
|
||||
actually excludes the real state directory.
|
||||
|
||||
**Non-Goals**
|
||||
|
||||
- No ignore-surface propagation into `.graphifyignore`, `.dochygiene-ignore`,
|
||||
or any other tool's config (ADR-0040 — closed, not revisited here).
|
||||
- No bulk-clean of any project other than cc-os (calibration pass #1 target
|
||||
per spec §9); no other project's rulebook is touched by this change.
|
||||
- No recurring cross-project categorize-and-learn skill — that is charted as
|
||||
fog on map #31 and explicitly out of scope for this design (spec, "Out of
|
||||
scope" section).
|
||||
- No `propagate_ignore` field, in any form (removed entirely per ADR-0040,
|
||||
not left as a documented-but-unused slot).
|
||||
- No new deletion destinations beyond the existing knowledge-routing targets
|
||||
(ADR/CLAUDE.md/docs for repo-durable extraction, SecondBrain vault via
|
||||
`/os-vault:write` for cross-repo extraction).
|
||||
|
||||
## Decisions
|
||||
|
||||
### 1. `rulebook.py` as a new stdlib-only module
|
||||
|
||||
A new `scripts/rulebook.py`, following the existing scripts' conventions
|
||||
(small single-responsibility classes, structured JSON-serializable output, no
|
||||
model, injectable for testing). Responsibilities:
|
||||
|
||||
- Load and parse the global `plugins/os-doc-hygiene/rulebook.json` and, if
|
||||
present, the project's committed `.dochygiene-rules.json`, both under the
|
||||
envelope `{"schema_version": 1, "rules": [...]}`.
|
||||
- Compile every rule's `glob` once at load time via stdlib
|
||||
`glob.translate(pattern, recursive=True, include_hidden=True)` (Python ≥
|
||||
3.13, per spec §2) into a matchable regex; this is a hard runtime
|
||||
requirement on the Python version the plugin scripts run under — no
|
||||
fallback dialect is provided (matches the spec's explicit dialect pin).
|
||||
- Merge add-only: project rules are appended to global rules, never replacing
|
||||
or deleting a global entry. A project "neutralizes" a global rule only by
|
||||
adding a shadowing rule with `lifetime: "keep"` at equal-or-higher
|
||||
precedence (per the two-axis precedence below) — there is no
|
||||
rule-removal mechanism, by design (ADR-0038).
|
||||
- Resolve precedence per path: project file-rule > project directory-rule >
|
||||
global file-rule > global directory-rule; ties within the same tier broken
|
||||
by longest `glob` string, then by last-defined (definition order within the
|
||||
rules array, project-before-global order does not matter once the four
|
||||
buckets above are checked first).
|
||||
- Validate: skip-and-warn per invalid or unconfirmed rule (a rule missing
|
||||
`confirmed_by` is loaded but flagged `inactive`, never contributes a
|
||||
signal, and never blocks the rest of the rulebook from loading). Hard-fail
|
||||
(raise / non-zero exit) only on unparseable JSON or an unrecognized
|
||||
`schema_version`.
|
||||
- Expose a single query surface consumed by the scanner: given a
|
||||
project-root-relative path, return either `None` (unmatched — flows
|
||||
through existing signals unchanged) or the winning rule (with its
|
||||
precedence-resolved fields) plus whether the match was file-level or
|
||||
directory-level.
|
||||
|
||||
`rulebook.py` has no knowledge of git, classification, or the report schema —
|
||||
it only resolves "which rule, if any, governs this path," keeping it testable
|
||||
in complete isolation with fixture rulebook JSON and injected path lists, in
|
||||
line with every other script in `scripts/`.
|
||||
|
||||
### 2. Where the lifecycle signal plugs into `scanner.py`
|
||||
|
||||
A new signal class, `LifecycleSignal` (or equivalent), is attached during the
|
||||
existing scanner walk, alongside the current objective signals (broken refs,
|
||||
version skew, etc.). Concretely:
|
||||
|
||||
- Before recursing into a directory, the scanner asks the loaded rulebook
|
||||
whether a directory-rule matches that directory's path. If so, the walk is
|
||||
**pruned** — the scanner never opens any file beneath it — and the scanner
|
||||
emits **one aggregate shortlist/signal entry** for the directory path
|
||||
itself (not one entry per file inside it), carrying the lifecycle signal
|
||||
(rule ref, lifetime, `served_when`/`served_when_path` if present).
|
||||
- This directory-rule-prune mechanism **is** the IGNORE surface described in
|
||||
spec §1 and §2 — there is no separate ignore-list data structure in the
|
||||
scanner. The IGNORE surface's seed members (`graphify-out/**`,
|
||||
`.dochygiene/**`) are shipped as ordinary directory rules with
|
||||
`lifetime: "keep"`... except `keep` implies "scanned and reported," which
|
||||
contradicts "never walked." Resolution: IGNORE-surface entries are a
|
||||
rulebook rule with no `lifetime` field at all (or a reserved sentinel the
|
||||
scanner recognizes before even considering lifetime) whose sole effect is
|
||||
walk-pruning with **no** shortlist/signal emission at all — distinct from
|
||||
`keep`, which is walked and reported. See "Ambiguities resolved" below;
|
||||
this is the one place the spec's wording ("directory-rule match prunes the
|
||||
walk and emits one aggregate entry — this IS the ignore surface") needed
|
||||
disambiguating against the separate statement that IGNORE-surface members
|
||||
are "never walked" with no entry at all. This design follows the latter
|
||||
(spec §1, §3 table: "IGNORE surface … never walked", no aggregate-entry
|
||||
language attached to it) and reserves the "prunes the walk and emits one
|
||||
aggregate entry" behavior for ordinary `temporary`/`delete-once-served`
|
||||
directory rules (e.g., `autoresearch/<run-id>/`), which need a signal to
|
||||
drive deletion decisions, whereas pure IGNORE members need none.
|
||||
- For files matched by a file-rule (not caught by a directory prune), the
|
||||
scanner attaches the lifecycle signal to that file's existing entry in the
|
||||
shortlist, alongside any pre-existing objective signals — a file can be
|
||||
both lifecycle-tagged and, say, stale-by-broken-ref.
|
||||
- The lifecycle signal is consumed downstream by the classification subagent
|
||||
as a new signal class (per doc-check's existing "signals are passed
|
||||
through verbatim" contract) and ultimately drives `op`/`op_type` selection
|
||||
(`delete` / `extract-then-delete`) in place of, or alongside, the existing
|
||||
stale/bloat vocabulary.
|
||||
|
||||
### 3. Delete / extract-then-delete op flow
|
||||
|
||||
`report_builder.py` → `validate_report.py` → `patch_applier.py`, following
|
||||
the existing division of labor (model proposes judgment fields only;
|
||||
deterministic finalize pass authors the guardrail fields; validator enforces
|
||||
the schema; applier mutates at clean time with runtime re-verification).
|
||||
|
||||
- **`report_builder.py`** gains two new `exact_edit.kind` values in its
|
||||
`KIND_TABLE`: `delete` and `extract-then-delete`. Both carry an `anchor`
|
||||
covering the full file (or, for a directory-rule aggregate entry, the
|
||||
directory path with no anchor) so the biconditional with `op_type` holds.
|
||||
`extract-then-delete` additionally carries the extraction destination
|
||||
classification (`repo-durable` vs `cross-repo`) as a proposal field the
|
||||
model supplies (it is a judgment about content, not a derived guardrail
|
||||
field) plus, for `repo-durable`, a target doc reference; `cross-repo`
|
||||
routes through `/os-vault:write` at clean time and carries no fixed
|
||||
destination path (spec §1: "no new destinations").
|
||||
- **`validate_report.py`**'s `derive_safety_tier` remains the single source
|
||||
of tier derivation (invariant #10) and is extended, not replaced, with the
|
||||
ADR-0039 matrix as additional input dimensions beyond `(op_type,
|
||||
is_destructive, is_reversible)`:
|
||||
- a `lifecycle` object on the entry (`rule_ref`, `lifetime`,
|
||||
`served_when_path` XOR `served_when`, `git_state` placeholder recomputed
|
||||
at runtime — see below)
|
||||
- `derive_safety_tier` gains a lifecycle-aware branch: for `op_type` in
|
||||
(`delete`, `extract-then-delete`), tier is `auto` **only** when the
|
||||
entry's lifecycle evidence is `scanner-proven` (a `served_when_path` hit,
|
||||
or a temporary-tier age/retain-recent computation) **and** the file's
|
||||
git state is tracked+clean; every other combination (dirty, untracked,
|
||||
or `served_when` classifier-judged) forces `confirm`. This is additive:
|
||||
the existing non-lifecycle branches (stale/bloat ops) are unchanged, and
|
||||
`derive_safety_tier` is still the one function both `report_builder.py`
|
||||
and `validate_report.py` call — no second tier-deriving code path is
|
||||
introduced.
|
||||
- Because tracked/dirty state can only be known against the live worktree,
|
||||
`report_builder.py` calls a runtime `git ls-files` + dirty check
|
||||
**at report-build time** to populate the `git_state` input for tier
|
||||
derivation — but per ADR-0039 this is explicitly re-verified again at
|
||||
apply time by `patch_applier.py`, because time passes between check and
|
||||
clean. The report's tier is advisory-fresh, not authoritative-forever.
|
||||
- **`patch_applier.py`** gains handling for `delete` and
|
||||
`extract-then-delete`:
|
||||
- Before applying either, it re-runs `git ls-files <path>` and a dirty
|
||||
check against that specific path (never trusting the report's cached
|
||||
tier or git_state) — if the path is no longer tracked+clean when the
|
||||
rule required that for `auto`, the applier downgrades that entry to
|
||||
skip-and-report (`git-state-changed-since-check`), the same family of
|
||||
guard as the existing content-hash guard, not a silent auto-apply.
|
||||
- `delete` performs a true `git rm <path>` (or `git rm -r` for a
|
||||
directory-rule aggregate entry) staged into the same single hygiene
|
||||
commit the rest of the run produces — no archive directory, no move.
|
||||
This is staged precisely like every other op (no `git add -A`).
|
||||
- `extract-then-delete` first invokes the generative extraction (repo-
|
||||
durable → written via the same live-read Sonnet-subagent path
|
||||
`doc-clean` already uses for generative ops, target = ADR/CLAUDE.md/docs
|
||||
per the model's proposal; cross-repo → `/os-vault:write` invoked by the
|
||||
clean skill, not the applier itself, since vault writes are not a
|
||||
filesystem op the applier owns) and only after that step succeeds does
|
||||
it `git rm` the source path, both landing in the one hygiene commit.
|
||||
If extraction fails, the delete does not proceed for that entry (fails
|
||||
closed, consistent with the existing "hard failure → rollback,
|
||||
partial-guard-skip → no rollback" split: an extraction failure is a
|
||||
per-entry skip, not a run-level hard failure, unless it is the kind of
|
||||
error `doc-clean`'s existing rollback rules already treat as hard).
|
||||
|
||||
### 4. Temporary-tier age computation
|
||||
|
||||
- **Age source:** git commit time of the path's most recent commit touching
|
||||
it, obtained via `git log -1 --format=%cI -- <path>`; falls back to
|
||||
filesystem `mtime` only when the path is untracked (no commit history to
|
||||
read). No per-rule `age_source` override field exists (ADR-0039/#48).
|
||||
- **Untracked directory entries** (a directory-rule match on an untracked
|
||||
directory) use **one `stat()` on the directory inode itself**, not a
|
||||
recursive max-mtime walk over its contents — cheap and self-healing per
|
||||
spec §4/§48 (a spuriously bumped directory mtime only delays deletion by
|
||||
one round, it never causes incorrect *early* deletion).
|
||||
- **Retention unit** is the rule's own match granularity: a file-rule's
|
||||
matches are individual files; a directory-rule's matches are whole
|
||||
directories (e.g. `autoresearch/<run-id>/` — "3 most recent runs," not "3
|
||||
most recent files inside the newest run"). `rulebook.py` and the scanner
|
||||
must agree on this: the directory-rule aggregate shortlist entry (§2
|
||||
above) is the unit `retain_recent`/`max_age_days` operate over, computed
|
||||
by grouping sibling directory-rule matches under their common parent glob
|
||||
and ranking by age (newest first), keeping the top `retain_recent`
|
||||
regardless of age and deleting the rest once they exceed `max_age_days`.
|
||||
- `retain_recent` defaults to 3, `max_age_days` defaults to 3, both per-rule
|
||||
overridable in the rulebook JSON.
|
||||
|
||||
### 5. `conventions.json` and promotion candidates
|
||||
|
||||
- `plugins/os-doc-hygiene/conventions.json` is a global-only, plugin-shipped,
|
||||
machine-readable file (envelope TBD-but-simple: an array of convention
|
||||
objects — name, "what it proves," `served_when_path`/frontmatter template
|
||||
a rule graduates to, one-line human pitch). v1 ships exactly the two
|
||||
entries named in the spec (`archive-bucket`, `status-frontmatter`); no
|
||||
per-project override file exists for it (ADR-0041: "the catalog only
|
||||
recommends; adoption lands in the project's own rulebook").
|
||||
`report_builder.py` (deterministic, no model) reads this file directly —
|
||||
no subagent involvement — and, for every entry whose lifecycle signal is
|
||||
classifier-judged (`served_when` present, no `served_when_path`), checks
|
||||
whether any catalog convention's `served_when_path` pattern is *not yet*
|
||||
satisfied by that project's current structure, and if so emits a
|
||||
`promotion_candidates` entry naming the convention and the one-line pitch.
|
||||
This is a deterministic string/structure check, not a judgment call, so it
|
||||
belongs in `report_builder.py` alongside the other guardrail fields the
|
||||
model must not author.
|
||||
- The `:check` report gains a `promotion_candidates` array section at the
|
||||
top level (sibling to `entries`), populated on every run where at least
|
||||
one classifier-judged lifecycle signal exists and a matching catalog
|
||||
convention applies. `:calibrate` (out of this change's core report path,
|
||||
but sharing the same catalog) may go further and draft the adoption
|
||||
(graduated rule + the file moves that convention implies) for human
|
||||
approval, never applying unasked.
|
||||
|
||||
### 6. `:calibrate` as an orchestrating skill
|
||||
|
||||
New `skills/calibrate/SKILL.md` (the only new skill this change adds),
|
||||
following the existing skill pattern (`check`/`clean`/`sweep`
|
||||
SKILL.md + a `workflows/*.md` LOOP-GUARD subagent pointer where a subagent
|
||||
must not recurse into the top-level skill file). It orchestrates, per spec
|
||||
§8:
|
||||
|
||||
1. A deterministic clustering pass (script, no model) over the current
|
||||
scanner's unmatched shortlist (unmatched = unmanaged = candidate pool),
|
||||
grouping by path-shape similarity and sampling representatives per
|
||||
cluster — this is a new small deterministic helper, not a new pipeline
|
||||
stage in `check`/`clean`, since calibration is a separate on-demand skill.
|
||||
2. A **haiku** subagent per cluster, constrained by prompt contract to
|
||||
nominate a bare glob + candidate lifetime, never an exact-instance path
|
||||
(the rule-quality "class, never path" test is enforced by the strong-
|
||||
model judge in the next step, not trusted from haiku).
|
||||
3. One batched **strong-model** (Opus/Fable) judge subagent that gathers its
|
||||
own evidence (re-reads matched paths, checks near-miss boundaries) and
|
||||
returns verdicts (`confirm` / `reject` / `amend` / `consult`), with
|
||||
`consult` mandatory whenever an artifact's purpose is unclear.
|
||||
4. A deterministic report-assembly step (script) that renders the required
|
||||
5-element rule report (glob verbatim, matches with capped sample + total
|
||||
count, near-miss boundary, tier, plain-language why) to the human **before
|
||||
any persistence call is made** — persistence is a separate, explicit step
|
||||
gated on human review of this report, not something the judge subagent
|
||||
triggers directly.
|
||||
5. Persistence: project-rule writes to `.dochygiene-rules.json` happen on
|
||||
judge confirmation (post human rule-report review); global-rulebook
|
||||
writes are additionally human-gated (a distinct confirmation, since it is
|
||||
a cross-repo write into cc-os); rule removals are HITL-only in all cases.
|
||||
6. A retest loop (re-run clustering against the shrunk unmatched pool) that
|
||||
stops when a round nominates fewer than 2 new rules or shrinks the
|
||||
unmatched pool by less than 10%, hard-capped at 3 rounds regardless.
|
||||
|
||||
### 7. The state-dir wrinkle
|
||||
|
||||
`lifecycle-spec.md`'s IGNORE seed lists `.dochygiene/**`. Since ADR-0027
|
||||
(pre-dating this change), the plugin's actual state directory is
|
||||
`.cc-os/dochygiene/`, with `.dochygiene/` retained only as a legacy read-
|
||||
fallback that `state_store.py` migrates away from on first write; `scanner.py`
|
||||
already self-excludes both `.cc-os/` and `.dochygiene/` by directory name at
|
||||
any depth (per `scripts/CONTEXT.md`'s documented default excludes). This
|
||||
change's rulebook-driven IGNORE surface must therefore ship **both** seed
|
||||
entries — `.dochygiene/**` (matching the spec text verbatim, covering the
|
||||
legacy dir on projects that haven't migrated) and an equivalent rule (or
|
||||
reliance on the scanner's pre-existing hardcoded self-exclusion, which
|
||||
already covers `.cc-os/**`) — so that on a project already migrated to
|
||||
`.cc-os/dochygiene/`, the IGNORE surface still holds. This design does not
|
||||
change the scanner's existing hardcoded self-exclusion; the rulebook's
|
||||
IGNORE rule list is additive to it, not a replacement, so this is satisfied
|
||||
without a code change beyond confirming the shipped `rulebook.json`'s IGNORE
|
||||
entries include `.dochygiene/**` and documenting that `.cc-os/**` is already
|
||||
covered by the pre-existing self-exclusion.
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
- **Two overlapping walk-pruning mechanisms** (the scanner's pre-existing
|
||||
hardcoded `excluded_dirs` and the new rulebook-driven directory-rule
|
||||
prune) risk confusing future maintainers about which one is authoritative
|
||||
for a given path. Mitigated by treating the hardcoded excludes as a
|
||||
distinct, lower-level safety net (self-exclusion of the tool's own state
|
||||
and pre-existing fixture excludes) and the rulebook as the
|
||||
project-configurable lifecycle layer; `scripts/CONTEXT.md` should document
|
||||
both once implemented.
|
||||
- **`git ls-files` / dirty check runs twice** (once at report-build time,
|
||||
once at apply time) for lifecycle deletes — a deliberate, spec-mandated
|
||||
redundancy (ADR-0039: "never trusting the rule … at runtime") rather than
|
||||
an oversight; the cost is one extra git subprocess call per lifecycle
|
||||
entry per run, acceptable given the destructive nature of the operation.
|
||||
- **`extract-then-delete` spans two subsystems** (a generative rewrite via
|
||||
the existing Sonnet-distillation path, then a `git rm`) inside a single
|
||||
op type, and for cross-repo extraction additionally calls into
|
||||
`/os-vault:write`, a different plugin's skill. This is more moving parts
|
||||
in one applied entry than any existing op; a partial failure (extraction
|
||||
succeeds, delete fails, or vice versa) needs care to keep the "one hygiene
|
||||
commit" invariant intact — this design treats extraction-then-delete as
|
||||
atomic per entry (both steps land in the same commit, or neither does),
|
||||
which may need revisiting once implemented against real vault-write
|
||||
latency/failure modes.
|
||||
- **Directory-rule aggregate entries change the meaning of "one entry per
|
||||
file"** that today's `doc-check`/`report-schema` specs assume throughout
|
||||
(e.g. "every `entries[].path` SHALL be a member of `shortlist`" — a
|
||||
directory aggregate entry's path is a directory, not a file, which is a
|
||||
new shape for `path` that existing consumers may not expect). The delta
|
||||
specs below extend rather than replace this requirement, but any code
|
||||
outside this change's touched files that assumes `path` is always a
|
||||
regular file should be audited during implementation.
|
||||
- **`rulebook.py`'s hard requirement on Python ≥3.13** (`glob.translate`) is
|
||||
new relative to the rest of the scripts directory (whose stdlib-only
|
||||
policy has not previously pinned a Python floor this high); if the
|
||||
environment running `os-doc-hygiene` scripts is older, the whole rulebook
|
||||
layer hard-fails to import. This is accepted as a locked design decision
|
||||
(spec §2), not reopened here, but is called out as an operational risk
|
||||
worth a version check with a clear error message at load time.
|
||||
|
||||
## Ambiguities resolved while writing this design
|
||||
|
||||
(Reported per instructions — none silently decided against the spec; these
|
||||
are places the locked documents left an implementation-level gap.)
|
||||
|
||||
1. **IGNORE-surface entries vs. ordinary directory-rule "prune + aggregate
|
||||
entry" behavior.** Spec §1/§2 phrasing could be read as saying the
|
||||
IGNORE surface *itself* produces an aggregate shortlist entry ("a
|
||||
directory-rule match prunes the walk and emits one aggregate entry —
|
||||
this IS the ignore surface"), but spec §1 and the §3 tier table also say
|
||||
IGNORE-surface members are simply "never walked," with no entry
|
||||
language. Resolved by treating "prune + aggregate entry" as the general
|
||||
mechanism for lifecycle directory rules with a real lifetime (temporary /
|
||||
delete-once-served), and reserving true zero-signal, zero-entry pruning
|
||||
for the specific IGNORE seed list (`graphify-out/**`, `.dochygiene/**`).
|
||||
Flagging this because it affects whether IGNORE members ever appear
|
||||
anywhere in a report — this design says they never do.
|
||||
2. **`conventions.json` file shape.** The spec names the file and its v1
|
||||
contents (archive-bucket, status-frontmatter) and per-entry conceptual
|
||||
fields (name, what it proves, template, pitch) but does not give a JSON
|
||||
schema. This design treats it as a flat array of objects with those four
|
||||
conceptual fields, no envelope/schema_version wrapper (unlike
|
||||
`rulebook.json`, which the spec explicitly gives an envelope). Flagging
|
||||
because a schema_version envelope could be added instead if implementation
|
||||
prefers consistency with rulebook.json; nothing in the spec forecloses
|
||||
either choice.
|
||||
3. **Where the `git ls-files`/dirty runtime check physically lives** (spec
|
||||
says "verified at runtime via git ls-files + dirty check, never trusting
|
||||
the rule" but doesn't say whether that's a report-build-time check, an
|
||||
apply-time check, or both). This design does both — advisory at
|
||||
report-build time (so the report's tier reflects current reality when
|
||||
shown to the human), authoritative at apply time (so a stale report
|
||||
never causes an unsafe auto-delete) — matching the existing
|
||||
content-hash-guard pattern (`expected_sha256` is likewise computed at
|
||||
build time and re-verified at apply time). Flagging because the spec's
|
||||
single sentence doesn't explicitly mandate the double-check; it was
|
||||
inferred from ADR-0039's "never trusting the rule" plus the existing
|
||||
applier's re-verification precedent (`patch_applier.py`'s content-hash
|
||||
guard).
|
||||
4. **Extraction-failure handling within `extract-then-delete`** (does a
|
||||
failed extraction hard-fail the whole clean run, or just skip that
|
||||
entry?) is not addressed in the spec. This design treats it as a
|
||||
per-entry skip (consistent with `doc-clean`'s existing partial-success
|
||||
semantics), not a run-level hard failure, unless the failure mode matches
|
||||
one of the existing hard-failure triggers (applier exit 2, write error).
|
||||
Flagging since this determines commit granularity under failure.
|
||||
|
|
@ -0,0 +1,81 @@
|
|||
# Proposal: lifecycle-aware-doc-hygiene
|
||||
|
||||
## Why
|
||||
|
||||
os-doc-hygiene monitors stale/bloated docs but cannot manage document
|
||||
*lifecycle*: disposable artifacts (autoresearch runs, handoff files, served
|
||||
plans) accumulate as clutter that distracts AI navigation and search. The
|
||||
lifecycle-aware design is locked (wayfinder map #31, `lifecycle-spec.md`,
|
||||
ADRs 0038–0041); this change implements it.
|
||||
|
||||
## What Changes
|
||||
|
||||
- New `scripts/rulebook.py` loader: global `rulebook.json` + committed
|
||||
repo-root `.dochygiene-rules.json` override, add-only merge with
|
||||
source-then-specificity precedence, `glob.translate` dialect,
|
||||
skip-and-warn validation (unconfirmed rules never act), hard-fail on
|
||||
unparseable JSON/unknown schema_version.
|
||||
- Scanner gains a **lifecycle signal class**; directory-rule matches prune
|
||||
the walk (which implements the explicit IGNORE surface: `graphify-out/**`,
|
||||
`.dochygiene/**` — never inferred from `.gitignore`).
|
||||
- Report schema + clean applier gain **`delete`** and
|
||||
**`extract-then-delete`** op types with the ADR-0039 autonomy tier matrix
|
||||
(tracked+clean = auto; dirty/untracked = confirm; classifier-judged
|
||||
`served_when` = always confirm), verified at runtime via git, never
|
||||
trusted from the rule.
|
||||
- **Temporary tier**: retain-recent-N (default 3) + max_age_days (default 3),
|
||||
age from git commit time falling back to mtime; retention unit = the rule's
|
||||
match entry (file or run directory).
|
||||
- **delete-once-served**: deterministic `served_when_path` (scanner-proven,
|
||||
may auto-delete) vs free-text `served_when` (classifier-judged, forced
|
||||
confirm).
|
||||
- **Determinism promotion** (ADR-0041): global-only `conventions.json`
|
||||
catalog (v1: archive-bucket, status-frontmatter); `:check` reports gain a
|
||||
promotion-candidates section.
|
||||
- New **`:calibrate` skill** (the ONLY new skill): cluster-and-sample
|
||||
unmatched files → haiku glob nomination → strong-model batched judgment
|
||||
(confirm/reject/amend/consult) → human rule report (glob verbatim, matches,
|
||||
near-miss boundary, tier, plain-language why) → persistence (project rules
|
||||
on judge confirm; global writes human-gated) → retest loop (<2 new rules or
|
||||
<10% shrink, cap 3 rounds).
|
||||
- Extraction reuses existing knowledge routing (ADR route: repo-durable →
|
||||
docs/ADR/CLAUDE.md; cross-repo → vault). No `propagate_ignore` (ADR-0040).
|
||||
- Calibration pass #1 against cc-os per spec §9 (protected-set hard gate,
|
||||
8-of-10 recall floor with 4 mandatory rows, seed hold-out — pass #1 only).
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
|
||||
- `lifecycle-rulebook`: rulebook file format, locations, merge/precedence,
|
||||
glob dialect, validation, and scanner consumption (walk pruning + lifecycle
|
||||
signals + IGNORE surface).
|
||||
- `lifecycle-deletion`: lifetime taxonomy semantics — temporary tier
|
||||
(retain-recent + age), delete-once-served (served_when_path vs served_when),
|
||||
autonomy tier matrix, true-git-deletion in a dedicated hygiene commit,
|
||||
extract-then-delete routing.
|
||||
- `determinism-promotion`: conventions.json catalog and promotion-candidate
|
||||
nudging split across :check (names) and :calibrate (drafts).
|
||||
- `calibrate`: the learn-new-rules protocol, rule-quality tests
|
||||
(class-not-path, prefer-narrower), and calibration-pass validation criteria.
|
||||
|
||||
### Modified Capabilities
|
||||
|
||||
- `doc-check`: scanner walks are pruned by directory rules; lifecycle signals
|
||||
enter classification; reports include promotion candidates.
|
||||
- `doc-clean`: applies `delete`/`extract-then-delete` ops under the tier
|
||||
matrix with runtime git tracked/dirty verification.
|
||||
- `report-schema`: new op types `delete` and `extract-then-delete`, lifecycle
|
||||
signal fields, promotion-candidates section.
|
||||
|
||||
## Impact
|
||||
|
||||
- `plugins/os-doc-hygiene/scripts/`: new `rulebook.py`; changes to
|
||||
`scanner.py`, `report_builder.py`, `validate_report.py`, `patch_applier.py`.
|
||||
- New data files: `rulebook.json`, `conventions.json` (plugin root).
|
||||
- New skill dir `skills/calibrate/SKILL.md` (no `name:` frontmatter; naming
|
||||
per convention doc). Updates to `check`/`clean` SKILL.md.
|
||||
- Tests: TDD (red-green) on every deterministic piece; existing suite (286)
|
||||
must stay green.
|
||||
- Per-project surface: optional committed `.dochygiene-rules.json`.
|
||||
- After source edits: `bin/refresh-plugins`.
|
||||
|
|
@ -0,0 +1,239 @@
|
|||
# Spec: calibrate
|
||||
|
||||
## ADDED 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
|
||||
|
|
@ -0,0 +1,99 @@
|
|||
# Spec: determinism-promotion
|
||||
|
||||
## ADDED 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
|
||||
|
|
@ -0,0 +1,82 @@
|
|||
# Spec: doc-check (delta)
|
||||
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: Check Skill Orchestrates Scan, Classification, and Report Writing
|
||||
|
||||
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 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
|
||||
|
||||
- **WHEN** a shortlisted file carries no scanner signals
|
||||
- **THEN** it remains in the shortlist, produces no entry, and is not read by the classification model
|
||||
|
||||
#### Scenario: Scope and category are recorded and applied
|
||||
|
||||
- **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
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### 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
|
||||
|
|
@ -0,0 +1,146 @@
|
|||
# Spec: doc-clean (delta)
|
||||
|
||||
## MODIFIED Requirements
|
||||
|
||||
### 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`, `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
|
||||
|
||||
- **WHEN** the report contains only auto-tier entries (move-to-archive, insert-frontmatter, replace-text, dedupe)
|
||||
- **THEN** the clean skill applies them without presenting a confirm prompt
|
||||
|
||||
#### Scenario: confirm entries escalate before any mutation
|
||||
|
||||
- **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
|
||||
|
||||
- **WHEN** the user approves some confirm entries and opts out of others
|
||||
- **THEN** the skill applies the approved entries and skips the opted-out entries
|
||||
|
||||
#### Scenario: sweep does not bypass the gate
|
||||
|
||||
- **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, 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, including any lifecycle deletes
|
||||
|
||||
#### Scenario: Dirty tree gets a WIP checkpoint then one cleanup commit
|
||||
|
||||
- **WHEN** the user runs /os-doc-hygiene:clean with unstaged changes in the working tree
|
||||
- **THEN** the skill auto-creates a WIP checkpoint commit of the user's work, then produces exactly one cleanup commit — two commits total, cleanup still exactly one
|
||||
|
||||
#### Scenario: All-skipped produces zero commits
|
||||
|
||||
- **WHEN** all entries are guard-skipped (every file changed since check)
|
||||
- **THEN** the skill produces zero commits and reports all files as skipped with re-analysis recommended
|
||||
|
||||
#### Scenario: Hard failure triggers rollback
|
||||
|
||||
- **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
|
||||
|
||||
- **WHEN** some file batches are guard-skipped (applier exit 1) and others succeed
|
||||
- **THEN** the skill commits the applied edits and reports the skipped files, without rolling back the applied changes
|
||||
|
||||
#### Scenario: move-to-archive is not double-staged
|
||||
|
||||
- **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
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Applier Applies Delete and Extract-Then-Delete Under the Tier Matrix
|
||||
|
||||
The patch applier SHALL support two new op kinds, `delete` and
|
||||
`extract-then-delete`. For both, immediately before applying, it SHALL
|
||||
re-run `git ls-files <path>` and a dirty check against that specific path —
|
||||
never trusting the cached report tier or rule claim — and SHALL apply the
|
||||
tier matrix from the `lifecycle-deletion` spec to decide whether the entry
|
||||
may proceed as `auto` or must be treated as `confirm` (already gated
|
||||
upstream by the clean skill). `delete` SHALL perform a `git rm` (recursive
|
||||
for a directory-rule aggregate entry) staged into the run's single hygiene
|
||||
commit. `extract-then-delete` SHALL first complete its generative extraction
|
||||
step (repo-durable via the existing live-read Sonnet distillation path
|
||||
writing into an ADR/CLAUDE.md/docs target, or cross-repo via
|
||||
`/os-vault:write`) and SHALL only perform the `git rm` once extraction has
|
||||
succeeded; both steps SHALL land in the same single hygiene commit, or, on
|
||||
extraction failure, neither SHALL be applied for that entry (skip, not a
|
||||
run-level hard failure, unless the failure matches an existing hard-failure
|
||||
trigger).
|
||||
|
||||
#### 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
|
||||
|
|
@ -0,0 +1,173 @@
|
|||
# Spec: lifecycle-deletion
|
||||
|
||||
## ADDED 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
|
||||
|
|
@ -0,0 +1,215 @@
|
|||
# Spec: lifecycle-rulebook
|
||||
|
||||
## ADDED 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` delta 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
|
||||
|
|
@ -0,0 +1,203 @@
|
|||
# Spec: report-schema (delta)
|
||||
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: Top-Level Report Envelope
|
||||
|
||||
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, 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`, `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: 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`,
|
||||
`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`).
|
||||
- `move-to-archive` SHALL carry `anchor` (`start_line`, `end_line`) and
|
||||
`dest_path`; it is non-destructive and reversible (derives to `auto`).
|
||||
- `insert-frontmatter` SHALL carry the frontmatter `key` and `value` to inject
|
||||
(for example `hygiene: frozen`); it is non-destructive and reversible (derives
|
||||
to `auto`).
|
||||
- `replace-text` SHALL carry `anchor` (`start_line`, `end_line`), a `match`
|
||||
string, and a `replacement` string; it is non-destructive and reversible
|
||||
(derives to `auto`).
|
||||
- `dedupe` SHALL carry `anchor` (`start_line`, `end_line`) of the removed
|
||||
duplicate span and a `canonical_ref` to the kept location; because the removed
|
||||
span is an exact duplicate preserved verbatim at `canonical_ref`, no information
|
||||
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`.
|
||||
|
||||
#### Scenario: Each kind carries its required fields
|
||||
- **WHEN** an entry has `op_type` = `deterministic` with an `exact_edit` of `kind` = `move-to-archive`
|
||||
- **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`, `delete`, `extract-then-delete`
|
||||
- **THEN** the report is invalid
|
||||
|
||||
#### Scenario: Missing required sub-field is rejected
|
||||
- **WHEN** an `exact_edit` of `kind` = `replace-text` omits its `match` or `replacement` field
|
||||
- **THEN** the report is invalid
|
||||
|
||||
#### Scenario: Kind characterization feeds the safety-tier derivation
|
||||
- **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: 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 `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
|
||||
- **THEN** the derived `safety_tier` is `auto` and the cleaner applies the `exact_edit` mechanically without a prompt
|
||||
|
||||
#### Scenario: Destructive op derives to confirm
|
||||
- **WHEN** an op removes information not preserved elsewhere (`is_destructive` = true, e.g. a `delete-range`)
|
||||
- **THEN** the derived `safety_tier` is `confirm` regardless of `op_type`
|
||||
|
||||
#### Scenario: Generative op derives to confirm
|
||||
- **WHEN** an entry has `op_type` = `generative`
|
||||
- **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** `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`
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### 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
|
||||
|
|
@ -0,0 +1,47 @@
|
|||
# Tasks: lifecycle-aware-doc-hygiene
|
||||
|
||||
## 1. Rulebook loader (TDD)
|
||||
|
||||
- [x] 1.1 Red: tests for `scripts/rulebook.py` — envelope parsing, hard-fail on unparseable JSON/unknown schema_version, skip-and-warn on invalid/unconfirmed rules (missing `confirmed_by` → inactive), glob compilation via `glob.translate(recursive=True, include_hidden=True)` with a Python ≥3.13 version check + clear error
|
||||
- [x] 1.2 Red: tests for add-only merge + two-axis precedence (project file > project dir > global file > global dir; longest pattern, then last-defined), keep-shadowing neutralization, unmatched → None, file-vs-directory match distinction, IGNORE-sentinel rules (no lifetime → zero-emission prune)
|
||||
- [x] 1.3 Green: implement `scripts/rulebook.py` (stdlib-only, single query surface per design §1)
|
||||
- [x] 1.4 Author shipped `rulebook.json` (global): IGNORE seeds `graphify-out/**`, `.dochygiene/**`; envelope `{"schema_version": 1, "rules": [...]}`
|
||||
|
||||
## 2. Scanner lifecycle signals (TDD)
|
||||
|
||||
- [x] 2.1 Red: scanner tests — directory-rule match prunes the walk and emits one aggregate entry with lifecycle signal; IGNORE-surface rules prune with zero emission; file-rule match attaches lifecycle signal alongside existing signals; unmatched files flow through unchanged
|
||||
- [x] 2.2 Red: temporary-tier tests — age from `git log -1 --format=%cI`, mtime fallback for untracked, directory-inode mtime for untracked dirs (no recursive walk); retain-recent-N grouping by rule match entry, newest 3 kept regardless of age, 4th+ deleted past max_age_days
|
||||
- [x] 2.3 Red: delete-once-served tests — `served_when_path` (with `{id}`/`{name}` substitution) proven from filesystem → deterministic served signal; `served_when` free text → classifier-judged marker only
|
||||
- [x] 2.4 Green: implement lifecycle signal class + rulebook consumption + tier computations in `scanner.py`
|
||||
|
||||
## 3. Report schema + tier derivation (TDD)
|
||||
|
||||
- [x] 3.1 Red: `report_builder.py`/`validate_report.py` tests — new op kinds `delete`/`extract-then-delete` in KIND_TABLE; lifecycle entry fields (rule_ref, lifetime, served evidence, git_state); `derive_safety_tier` lifecycle branch (scanner-proven + tracked+clean = auto; dirty/untracked/classifier-judged = confirm; existing branches unchanged); build-time `git ls-files` + dirty check populates git_state
|
||||
- [x] 3.2 Red: promotion-candidates tests — deterministic `promotion_candidates` report section from `conventions.json` for classifier-judged rules (no model)
|
||||
- [x] 3.3 Green: implement report_builder/validate_report extensions
|
||||
- [x] 3.4 Author `conventions.json` (v1: archive-bucket, status-frontmatter exactly)
|
||||
|
||||
## 4. Clean applier deletion ops (TDD)
|
||||
|
||||
- [x] 4.1 Red: `patch_applier.py` tests — apply-time re-verification (git ls-files + dirty per path; downgrade to skip `git-state-changed-since-check` on mismatch); `delete` = `git rm`/`git rm -r` staged into the single hygiene commit; `extract-then-delete` fails closed per entry when extraction fails; confirm-tier gating for lifecycle ops
|
||||
- [x] 4.2 Green: implement applier delete/extract-then-delete handling
|
||||
|
||||
## 5. Skills wiring
|
||||
|
||||
- [x] 5.1 Update `skills/check/SKILL.md` — lifecycle signals into classification, promotion-candidates section in reports
|
||||
- [x] 5.2 Update `skills/clean/SKILL.md` — lifecycle op application, confirm gates, extraction routing (repo-durable → docs/ADR/CLAUDE.md via existing generative path; cross-repo → `/os-vault:write` from the skill, not the applier)
|
||||
- [x] 5.3 Read `~/Documents/SecondBrain/cc-os-plugin-skill-naming-convention.md`, then create `skills/calibrate/SKILL.md` (NO `name:` frontmatter) implementing the 6-step protocol: deterministic cluster-and-sample helper script, haiku nomination, strong-model batched judge (confirm/reject/amend/consult), 5-element human rule report before persistence, persistence rules (project on confirm, global human-gated, removals HITL-only), retest loop (<2 rules or <10% shrink, cap 3)
|
||||
- [x] 5.4 Red-green the deterministic calibrate helpers (clustering/sampling, rule-report assembly, class-not-path + prefer-narrower checks)
|
||||
- [x] 5.5 Full test suite green (existing 286 + new); run `bin/refresh-plugins`
|
||||
|
||||
## 6. Calibration pass #1 (cc-os, spec §9)
|
||||
|
||||
- [x] 6.1 Write the protected set fixed before the pass (eval scenarios/reserve/fixture/judge-rubric, openspec/specs/, docs/adr/**, mirrored .claude/.codex/.pi skill dirs, CLAUDE.md, plugin source)
|
||||
- [x] 6.2 Run :calibrate against cc-os with #41 cc-os seed rows HELD OUT of judge intake (pass #1 one-off)
|
||||
- [x] 6.3 Grade: protected-set hard gate (no persisted rule glob matches a protected path); recall floor 8/10 with 4 mandatory (`autoresearch/<run-id>/`, `HANDOFF-*.md`, `docs/adr/migration-report.md`, `.dochygiene/report.{json,md}`); graphify-out is void-not-miss; spot-check novel matches (wrong novel match → rule adjustment + retest round)
|
||||
- [x] 6.4 Record pass results in the change; persist confirmed cc-os rules to `.dochygiene-rules.json` (repo root, committed) — human-approved and persisted 2026-07-15
|
||||
|
||||
## 7. Records
|
||||
|
||||
- [x] 7.1 One-line headline in `docs/implementation-status.md` + detail in `docs/implementation-status/os-doc-hygiene.md`. (No `docs/memory-system/04-build-plan.md` step covers this effort — tracked by wayfinder map #31, not the memory-system build plan; nothing to mark there.)
|
||||
- [x] 7.2 Updated plugin `CLAUDE.md`/`scripts/CONTEXT.md` for the new lifecycle layer (two prune mechanisms documented). `invariants.md` #3 (`.cc-os/dochygiene/` per ADR-0027, legacy fallback noted) and #9 (rulebook IGNORE-sentinel added to the never-flagged enumeration) corrected with explicit human approval 2026-07-15 per the META-RULE.
|
||||
|
|
@ -0,0 +1,342 @@
|
|||
# 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. Every settled verdict SHALL
|
||||
persist: judge `keep` verdicts become ordinary `lifetime: keep` rules in
|
||||
`rules` (including exact-path singletons, per the keep-tier relaxation);
|
||||
human declines at the rule report persist as `rejected` entries with
|
||||
`rejected_by: "human"`; open `consult` verdicts persist to
|
||||
`nominations.consults`, deduped by glob at write time. All persistence SHALL
|
||||
go through the canonical rules-file writer.
|
||||
|
||||
#### Scenario: Project rule persists on judge confirmation plus report review
|
||||
|
||||
- **WHEN** the judge verdict is `confirm` for a project-scoped rule and the human has reviewed its rule report
|
||||
- **THEN** the rule is written to the project's `.dochygiene-rules.json`
|
||||
|
||||
#### Scenario: Global rulebook writes require an additional explicit gate
|
||||
|
||||
- **WHEN** a proposed rule would be written to the global `rulebook.json`
|
||||
- **THEN** a distinct human confirmation for the cross-repo write is required, beyond the project-rule confirmation step
|
||||
|
||||
#### Scenario: Rule removal is always HITL-only
|
||||
|
||||
- **WHEN** any rule (project or global) is proposed for removal
|
||||
- **THEN** the removal happens only via explicit human instruction, with the reasoning recorded, never as an automatic side effect of a calibration pass
|
||||
|
||||
#### Scenario: A keep verdict persists as a plain keep rule
|
||||
|
||||
- **WHEN** the judge's settled verdict for a cluster is that the artifacts must be retained
|
||||
- **THEN** an ordinary `lifetime: keep` rule is persisted to `rules` — removing the cluster from future calibrate pools and protecting future matches — even when the glob is an exact-path singleton
|
||||
|
||||
#### Scenario: A human decline persists as a rejection
|
||||
|
||||
- **WHEN** the human declines a judge-confirmed rule at the rule report
|
||||
- **THEN** a `rejected` entry with `rejected_by: "human"` is written, so a later haiku round cannot re-nominate the identical glob+lifetime without the judge knowing
|
||||
|
||||
### Requirement: 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. **Keep-tier relaxation:**
|
||||
exact-path/instance globs ARE acceptable for `lifetime: keep` entries only —
|
||||
this test exists to prevent bad deletion rules, and a singleton keep merely
|
||||
protects; instance globs remain forbidden for `temporary` and
|
||||
`delete-once-served`. When the near-miss boundary check reveals sibling
|
||||
artifacts a glob misses, the fix SHALL be to enumerate the conventional
|
||||
prefixes as separate rule entries, never to widen the glob to the containing
|
||||
directory; container-claiming globs are justified ONLY when the directory is
|
||||
wholly machine-owned (e.g. `plugins/*/.pytest_cache/`).
|
||||
|
||||
#### Scenario: A convention-recurring name is acceptable
|
||||
|
||||
- **WHEN** a proposed rule's glob is `HANDOFF-*.md`
|
||||
- **THEN** it passes the class-never-path test, since `HANDOFF-*` is a recurring naming convention, not a single instance
|
||||
|
||||
#### Scenario: An instance-unique identifier fails the test
|
||||
|
||||
- **WHEN** a proposed rule's glob hardcodes a specific run-id or hash string that can only ever identify one artifact
|
||||
- **THEN** the rule fails the class-never-path test and is flagged loudly, not silently persisted
|
||||
|
||||
#### Scenario: One current match is fine; one-EVER match is not
|
||||
|
||||
- **WHEN** a proposed rule currently matches exactly one file
|
||||
- **THEN** it is acceptable if the glob's structure could match future similarly-named files; it is flagged as a failed generalization if the glob's structure can never match any file but the one it names today
|
||||
|
||||
#### Scenario: A singleton keep passes under the keep-tier relaxation
|
||||
|
||||
- **WHEN** a proposed rule is `docs/research/clutter-pattern-inventory.md -> keep`
|
||||
- **THEN** it passes despite being an exact-path instance glob, because the keep tier only protects; the same glob with lifetime `temporary` or `delete-once-served` fails
|
||||
|
||||
#### Scenario: Missed siblings are enumerated, never widened to the container
|
||||
|
||||
- **WHEN** the boundary check shows `autoresearch/classic-*/` misses sibling runs under `autoresearch/improve-*/`
|
||||
- **THEN** the fix is a second rule entry `autoresearch/improve-*/` (same cluster), never a widening to `autoresearch/*/` or `autoresearch/**`, so keep-worthy content can still live in the container without a counter-rule
|
||||
|
||||
### 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
|
||||
|
||||
### Requirement: Deterministic Nomination Intake Filter
|
||||
|
||||
Between cheap-model nomination and strong-model judgment, `:calibrate` SHALL
|
||||
run a deterministic `NominationIntakeFilter` (in `calibrate_helpers.py`, no
|
||||
model — invariant #6). A nomination whose glob+lifetime exactly equals a
|
||||
`rejected` entry SHALL be dropped before the judge and logged in the run
|
||||
summary. Surviving nominations SHALL be annotated with every related
|
||||
rejection, where related means the two globs' match sets intersect on the
|
||||
current shortlist (deterministic, computed from the scan). The annotations
|
||||
plus all open consults SHALL enter the judge prompt as its "Nominations
|
||||
memory" input section; related rejections are context for the judge, never a
|
||||
veto.
|
||||
|
||||
#### Scenario: Exact glob+lifetime repeat is dropped before the judge
|
||||
|
||||
- **WHEN** haiku nominates `docs/research/** -> temporary` and `nominations.rejected` contains an entry with glob `docs/research/**` and lifetime `temporary`
|
||||
- **THEN** the nomination is dropped at intake, never reaches the judge, and the drop is logged in the run summary
|
||||
|
||||
#### Scenario: A variant flows through annotated, not blocked
|
||||
|
||||
- **WHEN** haiku nominates `docs/research/drafts/** -> temporary` and `nominations.rejected` contains `docs/research/** -> temporary`, and the two globs' match sets intersect on the current shortlist
|
||||
- **THEN** the nomination proceeds to the judge carrying the related rejection (its why and consider_instead) as context, and the judge may still confirm it
|
||||
|
||||
#### Scenario: Open consults always reach the judge prompt
|
||||
|
||||
- **WHEN** `nominations.consults` is non-empty at intake time
|
||||
- **THEN** every open consult is included in the judge prompt's "Nominations memory" section, regardless of what haiku nominated this round
|
||||
|
||||
### Requirement: Consult Persistence and Resurfacing
|
||||
|
||||
Open `consult` verdicts SHALL persist to `nominations.consults` (deduped by
|
||||
glob at write time) rather than dying with the run. Consults SHALL resurface
|
||||
in `:calibrate` only — `:check` and `:clean` are unchanged — appearing in the
|
||||
judge prompt and as an "Open consults" section of the rule report. A consult
|
||||
SHALL exit in exactly one of three ways: (a) a human answer settles the
|
||||
purpose — a normal rule is persisted and the consult entry deleted; (b) the
|
||||
human deems it not rule-worthy — the entry is rewritten into `rejected` with
|
||||
`rejected_by: "human"` and the human's why; (c) the human defers — the entry
|
||||
stays and resurfaces next run. New rejections and consults SHALL appear in
|
||||
the rule report but are not individually gated — they are memory, not
|
||||
deletion authority.
|
||||
|
||||
#### Scenario: A consult survives the run and resurfaces
|
||||
|
||||
- **WHEN** a judge verdict is `consult` and the run ends without a human answer
|
||||
- **THEN** the consult is written to `nominations.consults`, and the next `:calibrate` run surfaces it in both the judge prompt and the report's "Open consults" section
|
||||
|
||||
#### Scenario: An answered consult becomes a rule and disappears
|
||||
|
||||
- **WHEN** the human answers an open consult in a way that settles the artifact's purpose
|
||||
- **THEN** a normal rule is persisted through the standard report flow and the consult entry is deleted in the same write
|
||||
|
||||
#### Scenario: A declined consult becomes a human rejection
|
||||
|
||||
- **WHEN** the human answers that an open consult's artifact class is not rule-worthy
|
||||
- **THEN** the consult entry is rewritten into `nominations.rejected` with `rejected_by: "human"` and the stated reason
|
||||
|
||||
#### Scenario: Consults never surface outside calibrate
|
||||
|
||||
- **WHEN** `:check` or `:clean` runs against a project with open consults
|
||||
- **THEN** their behavior is unchanged — consults neither appear in output nor affect any classification
|
||||
|
|
@ -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
|
||||
|
|
@ -1,7 +1,13 @@
|
|||
# doc-check Specification
|
||||
|
||||
## Purpose
|
||||
TBD - created by archiving change add-check. Update Purpose after archive.
|
||||
|
||||
Defines the `/os-doc-hygiene:check` command and the `check` skill it dispatches to:
|
||||
the deterministic scan-classify-finalize-validate pipeline that turns a project's
|
||||
docs into a schema-valid machine + human report pair, including rulebook-driven
|
||||
scanning, judgment-only Sonnet classification, the model-free finalize pass that
|
||||
authors safety-critical fields, and validation-gated report writes.
|
||||
|
||||
## Requirements
|
||||
### Requirement: `/hygiene` Command Surface
|
||||
|
||||
|
|
@ -35,20 +41,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 +71,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
|
||||
|
|
|
|||
|
|
@ -1,7 +1,14 @@
|
|||
# doc-clean Specification
|
||||
|
||||
## Purpose
|
||||
TBD - created by archiving change add-clean. Update Purpose after archive.
|
||||
|
||||
Defines the `clean` skill and patch applier that consume a validated `check`
|
||||
report and apply its findings git-safely: per-file transactional edits guarded
|
||||
by content hashes, safety-tier gating that escalates destructive or generative
|
||||
ops for confirmation, tracked-only true deletion under the lifecycle tier
|
||||
matrix, and a single reviewable commit per run (with `sweep` composing `check`
|
||||
then `clean`).
|
||||
|
||||
## Requirements
|
||||
### Requirement: Per-File Transaction with Content-Hash Guard
|
||||
|
||||
|
|
@ -87,14 +94,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 +115,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 +128,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 +176,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 +189,74 @@ 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). When the extraction destination is the vault (the content
|
||||
physically leaves the repo), the op SHALL additionally append a pointer
|
||||
entry to the deleted file's per-directory `extracted.md` index (creating the
|
||||
file if absent), in the same atomic sequence — distill → `/os-vault:write` →
|
||||
append the pointer line → `git rm` — all staged into the same single hygiene
|
||||
commit, so there is no window where the doc is gone but undiscoverable. The
|
||||
pointer entry SHALL name the vault note, state why a future reader would
|
||||
follow it, and record the source filename and date. Repo-durable extraction
|
||||
targets (ADR, CLAUDE.md, docs) SHALL NOT produce an index entry — they are
|
||||
already discoverable in-repo.
|
||||
|
||||
#### Scenario: delete performs a true git rm at apply time
|
||||
|
||||
- **WHEN** the applier applies a `delete` entry
|
||||
- **THEN** it re-verifies tracked/clean status via `git ls-files` and a dirty check, then performs a `git rm` staged into the single hygiene commit
|
||||
|
||||
#### Scenario: extract-then-delete only deletes after extraction succeeds
|
||||
|
||||
- **WHEN** the applier applies an `extract-then-delete` entry
|
||||
- **THEN** it completes the extraction write (ADR/CLAUDE.md/docs or vault) first, and performs the `git rm` only after that write succeeds
|
||||
|
||||
#### Scenario: A failed extraction skips the delete for that entry
|
||||
|
||||
- **WHEN** the extraction step of an `extract-then-delete` entry fails
|
||||
- **THEN** the delete is not applied for that entry, the entry is reported as skipped, and the run is not treated as a hard failure unless the failure matches an existing hard-failure trigger (applier exit 2, write error)
|
||||
|
||||
#### Scenario: Directory-rule aggregate delete removes the whole directory
|
||||
|
||||
- **WHEN** the applier applies a `delete` entry whose path is a directory-rule aggregate entry
|
||||
- **THEN** it performs a recursive `git rm` removing the entire matched directory in one operation staged into the single hygiene commit
|
||||
|
||||
#### Scenario: A vault extraction leaves an extracted.md pointer in the same commit
|
||||
|
||||
- **WHEN** an `extract-then-delete` entry extracts to the vault via `/os-vault:write`
|
||||
- **THEN** a pointer entry naming the vault note, the reason to follow it, the source filename, and the date is appended to the deleted file's directory `extracted.md` (created if absent), and the append, the deletion, and the index file are all staged into the same single hygiene commit
|
||||
|
||||
#### Scenario: A repo-durable extraction leaves no index entry
|
||||
|
||||
- **WHEN** an `extract-then-delete` entry extracts into an ADR, CLAUDE.md, or docs target inside the repo
|
||||
- **THEN** no `extracted.md` entry is written — the residue is already discoverable in-repo
|
||||
|
||||
#### Scenario: A failed pointer append skips the delete
|
||||
|
||||
- **WHEN** the vault write succeeds but appending the `extracted.md` pointer fails
|
||||
- **THEN** the `git rm` is not applied for that entry and it is reported as skipped, preserving the invariant that a doc is never gone but undiscoverable
|
||||
|
||||
### Requirement: Clean Skill Orchestration
|
||||
|
||||
The `clean` skill SHALL load the current report via `StateStore.read_report`
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -0,0 +1,293 @@
|
|||
# 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 project file MAY
|
||||
additionally carry a top-level `nominations` key (see the Nominations Memory
|
||||
requirement); the loader SHALL ignore unknown top-level keys, so the key is
|
||||
additive to `schema_version` 1. The per-project override SHALL NOT live under
|
||||
gitignored `.cc-os/` — it SHALL be a committed, reviewable dotfile.
|
||||
|
||||
#### Scenario: Global rulebook is always present
|
||||
|
||||
- **WHEN** the rulebook loader runs in any project
|
||||
- **THEN** it loads `plugins/os-doc-hygiene/rulebook.json` resolved relative to the plugin scripts directory
|
||||
|
||||
#### Scenario: Per-project override is optional and committed
|
||||
|
||||
- **WHEN** a project has no `.dochygiene-rules.json` at its repo root
|
||||
- **THEN** the loader proceeds using only the global rulebook, and when the file is present it is read as a committed, reviewable file, never from `.cc-os/`
|
||||
|
||||
#### Scenario: Both files share one envelope shape
|
||||
|
||||
- **WHEN** either the global rulebook or a project override is loaded
|
||||
- **THEN** it is validated against the envelope `{"schema_version": 1, "rules": [...]}`
|
||||
|
||||
#### Scenario: A nominations key does not disturb the v1 loader
|
||||
|
||||
- **WHEN** a project `.dochygiene-rules.json` carries a top-level `nominations` key alongside `rules`
|
||||
- **THEN** `rulebook.py` loads the `rules` array exactly as before, ignoring the unknown top-level key without warning or error
|
||||
|
||||
### 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
|
||||
|
||||
### Requirement: Nominations Memory Lives in the Project Rules File
|
||||
|
||||
The project `.dochygiene-rules.json` MAY carry a top-level `nominations` key
|
||||
holding exactly two lists: `consults` (open questions — entries with `glob`,
|
||||
`question`, `evidence`, `cluster_key`, `asked_on`, and deliberately NO
|
||||
lifetime) and `rejected` (settled "no" answers — entries with `glob`,
|
||||
`lifetime`, `why`, optional `consider_instead`, `rejected_by` (`"judge"` or
|
||||
`"human"`), `judged_on`). The `nominations` key SHALL never affect which
|
||||
files the rulebook governs — only entries in `rules` decide that.
|
||||
`rulebook.py` SHALL remain nomination-unaware; only the calibrate helpers
|
||||
read the key, and the calibrate reader SHALL warn on unrecognized nomination
|
||||
fields, mirroring the rules array's unknown-field discipline. Rejected
|
||||
entries and exact-path singleton keep rules SHALL exit only by hand-deletion
|
||||
(removals stay HITL with recorded reasoning); no automated revisit path
|
||||
SHALL exist.
|
||||
|
||||
#### Scenario: Nominations never filter files
|
||||
|
||||
- **WHEN** the scanner or rulebook resolves the governing rule for a path that only a `nominations` entry's glob matches
|
||||
- **THEN** the path is treated as unmatched/unmanaged — nominations carry no lifecycle authority
|
||||
|
||||
#### Scenario: Consult entries carry no lifetime
|
||||
|
||||
- **WHEN** a consult entry is written to `nominations.consults`
|
||||
- **THEN** it records `glob`, `question`, `evidence`, `cluster_key`, `asked_on` and no lifetime field — presence in the list means open, with no status field
|
||||
|
||||
#### Scenario: Unrecognized nomination fields warn in the calibrate reader
|
||||
|
||||
- **WHEN** the calibrate helpers read a nominations entry containing an unknown field
|
||||
- **THEN** a warning is emitted and the field is preserved, never silently dropped
|
||||
|
||||
#### Scenario: A rejection leaves only by hand-deletion
|
||||
|
||||
- **WHEN** a calibration pass runs against a rules file containing a stale rejection
|
||||
- **THEN** no automated path removes or expires the entry; it is removed only by explicit human edit
|
||||
|
||||
### Requirement: Canonical Writer-Enforced Ordering
|
||||
|
||||
Every code path that serializes `.dochygiene-rules.json` SHALL write through
|
||||
one canonical writer that emits: `rules` grouped by lifetime tier in the
|
||||
order delete-once-served, temporary, keep, glob-sorted within each group;
|
||||
`nominations` after `rules`; `consults` before `rejected`, each glob-sorted.
|
||||
The writer SHALL be idempotent (canonicalizing an already-canonical file is a
|
||||
no-op) and SHALL round-trip unknown fields with a warning rather than
|
||||
dropping them. Ordering SHALL NOT be enforced by any hook; hand edits
|
||||
re-canonicalize on the next write.
|
||||
|
||||
#### Scenario: Writes are grouped and sorted canonically
|
||||
|
||||
- **WHEN** the writer serializes a rules file containing rules of all three tiers plus nominations
|
||||
- **THEN** the output orders rules delete-once-served → temporary → keep with globs sorted within each group, and nominations follows rules with consults before rejected, each list glob-sorted
|
||||
|
||||
#### Scenario: Canonicalization is idempotent
|
||||
|
||||
- **WHEN** the writer serializes a file it previously wrote, unchanged
|
||||
- **THEN** the output is byte-identical
|
||||
|
||||
#### Scenario: A hand-edited file re-canonicalizes on the next write
|
||||
|
||||
- **WHEN** a human appends a rule out of tier order and a later calibrate run persists a new entry
|
||||
- **THEN** the whole file is rewritten in canonical order in that write, with no hook involved in the interim
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -0,0 +1,27 @@
|
|||
{
|
||||
"schema_version": 1,
|
||||
"rules": [
|
||||
{
|
||||
"glob": "graphify-out/**",
|
||||
"confirmed_by": "human",
|
||||
"confirmed_on": "2026-07-14",
|
||||
"source": "lifecycle-spec #43",
|
||||
"note": "IGNORE surface: disposable/rebuildable Graphify graph output, never walked (distinct from lifetime keep, which is walked and reported)."
|
||||
},
|
||||
{
|
||||
"glob": ".dochygiene/**",
|
||||
"confirmed_by": "human",
|
||||
"confirmed_on": "2026-07-14",
|
||||
"source": "lifecycle-spec #43",
|
||||
"note": "IGNORE surface: legacy pre-ADR-0027 state directory, never walked. .cc-os/** (the current state dir) is already covered by scanner.py's pre-existing hardcoded self-exclusion (design.md §7 state-dir wrinkle)."
|
||||
},
|
||||
{
|
||||
"glob": "**/extracted.md",
|
||||
"lifetime": "keep",
|
||||
"confirmed_by": "human",
|
||||
"confirmed_on": "2026-07-15",
|
||||
"source": "map #49 ticket #57 (extract-index convention)",
|
||||
"note": "Per-directory extract index: pointer lines left behind by :clean's extract-then-delete op for vault extractions. The self-describing filename lets one global keep rule protect every project's index; the index can never become a deletion candidate."
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -9,12 +9,14 @@ for isolated testing with injected clocks/filesystems.
|
|||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `scanner.py` | Deterministic doc scanner. Walks scoped files under the resolved project root, applies the ordered short-circuiting exclusion pipeline (dir-prune incl. `.cc-os/` and legacy `.dochygiene/` self-exclusion → `.dochygiene-ignore` match → `hygiene: frozen` frontmatter → append-only-log detection; invariant #9), and computes objective per-path signals (broken refs, version skew, edit-recency vs git churn, location, archive ratio, frontmatter markers). Emits the **intermediate artifact** `{project_root, scope_globs, excluded_dirs, files_scanned, shortlist, signals}` — NOT a machine report (no `entries`/`category`/`op`/`token_estimate`). No model. Injected git-log / clock for testability. **Default excludes** (echoed verbatim in `excluded_dirs`): bare names — `build`, `vendor`, `archive`, `graphify-out`, `.cc-os` (shared cc-os state dir, ADR-027), `.dochygiene` (legacy state dir, invariant self-exclusion), `fixtures` — are matched on the directory *name* segment at any depth (any dir named `archive` is pruned, not just `docs/archive`); plus one **path-aware** entry `examples/golden` (any entry containing `/` is a `parent/child` name pair, pruned only where the child's immediate parent matches `parent`, depth-independent). `fixtures` + `examples/golden` prevent false-positive flagging of test-fixture trees and classifier golden inputs (deliberately-stale docs). `golden` is deliberately path-aware — a bare `golden` name-match would wrongly skip legitimate `golden/` dirs in unrelated projects (doc-hygiene installs globally). The golden-test harness is unaffected: it roots the scanner at `…/examples/golden/classifier/N/input/`, so the `examples→golden` pair sits in the ancestry above the walk root and is never re-encountered as a child. |
|
||||
| `scanner.py` | Deterministic doc scanner. Walks scoped files under the resolved project root, applies the ordered short-circuiting exclusion pipeline (dir-prune incl. `.cc-os/` and legacy `.dochygiene/` self-exclusion → `.dochygiene-ignore` match → `hygiene: frozen` frontmatter → append-only-log detection; invariant #9), and computes objective per-path signals (broken refs, version skew, edit-recency vs git churn, location, archive ratio, frontmatter markers). Emits the **intermediate artifact** `{project_root, scope_globs, excluded_dirs, files_scanned, shortlist, signals}` — NOT a machine report (no `entries`/`category`/`op`/`token_estimate`). No model. Injected git-log / clock for testability. **Default excludes** (echoed verbatim in `excluded_dirs`): bare names — `build`, `vendor`, `archive`, `graphify-out`, `.cc-os` (shared cc-os state dir, ADR-027), `.dochygiene` (legacy state dir, invariant self-exclusion), `fixtures` — are matched on the directory *name* segment at any depth (any dir named `archive` is pruned, not just `docs/archive`); plus one **path-aware** entry `examples/golden` (any entry containing `/` is a `parent/child` name pair, pruned only where the child's immediate parent matches `parent`, depth-independent). `fixtures` + `examples/golden` prevent false-positive flagging of test-fixture trees and classifier golden inputs (deliberately-stale docs). `golden` is deliberately path-aware — a bare `golden` name-match would wrongly skip legitimate `golden/` dirs in unrelated projects (doc-hygiene installs globally). The golden-test harness is unaffected: it roots the scanner at `…/examples/golden/classifier/N/input/`, so the `examples→golden` pair sits in the ancestry above the walk root and is never re-encountered as a child. Additive to this hardcoded-exclude prune, the scanner also consults `rulebook.py` for lifecycle rule matches during the walk (directory-rule matches prune the walk and emit one aggregate entry; IGNORE-surface rules prune with zero emission; file-rule matches attach a lifecycle signal alongside existing signals) — **two independent prune mechanisms**: the hardcoded `DEFAULT_EXCLUDED_DIRS` list is the safety net (fixed, not project-configurable), while the rulebook is the configurable lifecycle layer (global + per-project rules, tiered by lifetime taxonomy per ADR-0039). |
|
||||
| `state_store.py` | State store. `resolve_project_root(start_dir, fs)` (pure: git root, fallback cwd) plus `StateStore` confining all writes to `<root>/.cc-os/dochygiene/` (invariant #3, ADR-027, never edits `.gitignore`). Reads fall back to the legacy `<root>/.dochygiene/` when the canonical dir has no state; the first write migrates legacy files in and removes the legacy dir. Lifecycle timestamps (`last_check`/`last_clean`/`last_reminded`), atomic write-temp→fsync→`os.replace` (D9), and report rollover keeping exactly one `.json`+`.md` pair (invariant #4). Injected clock. |
|
||||
| `reminder.py` | `SessionStart` reminder. Pure `Reminder.decide(last_check, last_reminded, now)` decision (stale-and-not-snoozed ⇒ banner; calendar-day snooze keyed on `last_reminded`, invariants #1/#2). `ReminderRunner` wires it to the `StateStore`, emits a zero-token `systemMessage` JSON banner on stdout, and writes `last_reminded` as the *only* mutation, only on banner. Treats missing/corrupt state as never-checked (stale); always exits 0 — never blocks the session. **No-ops unless cwd is inside a real git project** (`_is_git_project` guard) so the user-scoped hook never creates `.cc-os/dochygiene/` in an arbitrary dir like `~` (invariant #3) — flagged for human review. No scan, no model. `DEFAULT_THRESHOLD_DAYS` is an implementation constant, not a spec'd contract. Invoked by `hooks/hooks.json` via `${CLAUDE_PLUGIN_ROOT}/scripts/reminder.py`. |
|
||||
| `validate_report.py` | Schema-validator for machine-report JSON files. Enforces the frozen report contract: envelope fields, closed category enum, closed `exact_edit.kind` enum with per-kind required sub-fields, `op_type`↔`exact_edit` biconditional (invariant #11), and `safety_tier` derivation match (invariant #10). Collects all violations. Exit 0 = valid, exit 1 = invalid, exit 2 = usage error. |
|
||||
| `report_builder.py` | Deterministic finalize pass (no model). Consumes the scanner artifact + the subagent's slim proposals and assembles a schema-valid machine report plus a human-report skeleton. Fills the four guardrail fields the model must not author: computes each entry's `expected_sha256` over the live file, looks up `is_destructive`/`is_reversible` from the `KIND_TABLE` (keyed on `exact_edit.kind`), derives `safety_tier` via `validate_report`'s `derive_safety_tier` (invariant #10 — single source of truth), and sources `raw_tokens` from `token_estimator` (over the `reducible_range`/anchor text). Rejects malformed proposals with a structured stderr error (`{index, field, message}`, exit 1); `--out-json`/`--out-md` write files and suppress the stdout bundle. An empty proposals array yields a valid empty-entries report. |
|
||||
| `token_estimator.py` | Deterministic local token estimator (no model, no network — invariant #6). Produces the `token_estimate.raw_tokens` count for report entries. Pluggable `TokenEstimator` ABC with two backends: `HeuristicEstimator` (ALWAYS available, zero deps, `ceil(len/4)`) and `TiktokenEstimator` (optional accuracy upgrade, `o200k_base`, `disallowed_special=()`). `default_estimator(cache_dir=…)` selects tiktoken **only** when it is importable AND the vocab is vendored locally (detect-before-fetch: checks the sha1-named cache file exists before loading, so a cold cache never triggers a network download); otherwise returns the heuristic. Never raises; active backend is introspectable via `.name`. `estimate(text)→int`, `estimate_file(path)→int` (utf-8, `errors="replace"`), `estimate_for_report(text)→{raw_tokens, injection_frequency:null, weighted_tokens:null}` (v1 emits only `raw_tokens`; weighting is the v2 bonus). CLI: `python token_estimator.py <path>` prints JSON `{path, backend, token_estimate}`, exit 1 on missing file. **tiktoken vocab is NOT committed** (~2 MB; gitignored at `scripts/tiktoken_cache/`). Pre-warm once with: `TIKTOKEN_CACHE_DIR=scripts/tiktoken_cache python -c "import tiktoken; tiktoken.get_encoding('o200k_base')"`. **Cache-filename gotcha:** tiktoken names the cache file `sha1(blobpath_url)` = `fb374d419588a4632f3f557e76b4b70aebbca790` (NOT the sha256 content hash); a vendored file under any other name never hits and falls back to network. |
|
||||
| `rulebook.py` | Deterministic rulebook loader (no model). Parses the global `rulebook.json` plus an optional per-project `.dochygiene-rules.json` envelope (`{"schema_version": 1, "rules": [...]}`), hard-failing on unparseable JSON/unknown `schema_version` and skip-and-warning on invalid/unconfirmed rules (missing `confirmed_by` → inactive). Compiles globs via `glob.translate(recursive=True, include_hidden=True)` (Python ≥3.13 required, clear error otherwise). Merges add-only across a two-axis precedence — project file > project dir > global file > global dir; within a tier, longest pattern then last-defined — with keep-shadowing neutralization; unmatched paths resolve to `None`; file-vs-directory match are distinguished; IGNORE-sentinel rules (no `lifetime`) prune with zero emission. Single query surface consumed by `scanner.py`'s lifecycle signal pass. |
|
||||
| `calibrate_helpers.py` | Deterministic helpers (no model) backing the `/os-doc-hygiene:calibrate` skill: clustering/sampling of scanner shortlist candidates for haiku nomination, rule-report assembly for the human-approval step, and class-not-path + prefer-narrower checks that keep proposed rules generalized rather than single-path. The skill itself drives the AI nomination/judge steps; this module is the deterministic scaffolding around them. |
|
||||
| `patch_applier.py` | Deterministic patch-applier: consumes the machine report's deterministic entries, applies each `exact_edit.kind` mechanically via a per-file transaction (read-once → verify whole-file sha256 against `expected_sha256` → apply edits descending by line, insert-frontmatter last → write once), skips files on content-guard mismatch or incompatible-op combinations, uses `git mv` for move-to-archive; structured JSON output, no model. CLI: `python patch_applier.py --report <path> --apply-indices <idx,idx,...>` emits `{applied, skipped, failed, staged_paths}` to stdout, exit 0 (partial ok), exit 1 (some applied + skipped), exit 2 (hard failure — rollback recommended). Invariant #8 (mtime guard). |
|
||||
|
||||
## Planned additions (future changes)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,544 @@
|
|||
"""
|
||||
calibrate_helpers.py — deterministic helpers for the `:calibrate` skill
|
||||
(lifecycle-aware-doc-hygiene change, task 5.4).
|
||||
|
||||
Stdlib-only, no model. Three responsibilities, each a small
|
||||
single-responsibility class taking injected inputs and returning
|
||||
JSON-serializable output:
|
||||
|
||||
1. `ClusterSampler` — groups unmatched paths by path-shape (directory
|
||||
prefix + a filename "shape class" where digit runs -> `#` and hex-looking
|
||||
runs -> `~`) and emits capped representative samples per cluster.
|
||||
2. `RuleReportBuilder` — given proposed rules (glob + lifetime) and the
|
||||
repo's file list, assembles the 5-element rule-report data: glob
|
||||
verbatim, matched paths (capped sample + total), near-miss boundary
|
||||
paths, lifetime/tier, and a `why` placeholder the caller fills in.
|
||||
3. `RuleQualityChecker` — deterministic lint: `class_not_path` (flags globs
|
||||
whose filename segment looks instance-unique: long digit runs, hex
|
||||
hashes, bare timestamps, or a wildcard-free glob matching exactly one
|
||||
existing path) and `prefer_narrower` (compares two candidate globs by
|
||||
match-count on the actual tree; the narrower one wins ties).
|
||||
|
||||
None of these classes call an LLM, read history, or touch git. They operate
|
||||
purely on lists of strings (paths) handed in by the orchestrating skill.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import fnmatch
|
||||
import json
|
||||
import re
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path, PurePosixPath
|
||||
from typing import Optional
|
||||
|
||||
import rulebook as _rulebook
|
||||
|
||||
_DEFAULT_SAMPLE_CAP = 5
|
||||
|
||||
# --- shape-class helpers ---------------------------------------------------
|
||||
|
||||
_DIGIT_RUN = re.compile(r"\d+")
|
||||
# hex run: 6+ consecutive hex chars (avoids false-triggering on short words)
|
||||
_HEX_RUN = re.compile(r"(?<![0-9a-fA-F])[0-9a-fA-F]{6,}(?![0-9a-fA-F])")
|
||||
|
||||
# "instance-unique" detectors used by both shape-classing and rule-quality
|
||||
# lints: a long digit run (8+, e.g. a timestamp or run-id), or a hex run
|
||||
# (6+ chars, e.g. a git sha / hash).
|
||||
_LONG_DIGIT_RUN = re.compile(r"\d{6,}")
|
||||
|
||||
|
||||
def _filename_shape(name: str) -> str:
|
||||
"""Collapse a filename into a shape class: digit runs -> '#', hex runs
|
||||
(6+ hex chars) -> '~'. Order matters: hex collapse first (a hex run may
|
||||
contain digits that would otherwise be independently collapsed)."""
|
||||
shaped = _HEX_RUN.sub("~", name)
|
||||
shaped = _DIGIT_RUN.sub("#", shaped)
|
||||
return shaped
|
||||
|
||||
|
||||
def _dir_prefix(path: str) -> str:
|
||||
parts = PurePosixPath(path).parts[:-1]
|
||||
return "/".join(parts)
|
||||
|
||||
|
||||
@dataclass
|
||||
class Cluster:
|
||||
key: str # "<dir_prefix>::<shape>"
|
||||
dir_prefix: str
|
||||
shape: str
|
||||
paths: list # all paths in this cluster, sorted
|
||||
sample: list # capped representative sample
|
||||
|
||||
@property
|
||||
def total(self) -> int:
|
||||
return len(self.paths)
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
"key": self.key,
|
||||
"dir_prefix": self.dir_prefix,
|
||||
"shape": self.shape,
|
||||
"total": self.total,
|
||||
"sample": list(self.sample),
|
||||
}
|
||||
|
||||
|
||||
class ClusterSampler:
|
||||
"""Groups unmatched paths by path-shape (directory prefix + filename
|
||||
shape class) and emits per-cluster representative samples, capped."""
|
||||
|
||||
def __init__(self, sample_cap: int = _DEFAULT_SAMPLE_CAP) -> None:
|
||||
self._cap = sample_cap
|
||||
|
||||
def cluster(self, unmatched_paths: list) -> list:
|
||||
"""Returns a list of Cluster, sorted by cluster key for determinism.
|
||||
Each cluster groups paths sharing the same directory prefix AND the
|
||||
same filename shape class."""
|
||||
groups: dict = {}
|
||||
for path in unmatched_paths:
|
||||
dir_prefix = _dir_prefix(path)
|
||||
filename = PurePosixPath(path).name
|
||||
shape = _filename_shape(filename)
|
||||
key = f"{dir_prefix}::{shape}"
|
||||
groups.setdefault(key, []).append(path)
|
||||
|
||||
clusters = []
|
||||
for key in sorted(groups.keys()):
|
||||
paths = sorted(groups[key])
|
||||
dir_prefix, shape = key.split("::", 1)
|
||||
clusters.append(
|
||||
Cluster(
|
||||
key=key,
|
||||
dir_prefix=dir_prefix,
|
||||
shape=shape,
|
||||
paths=paths,
|
||||
sample=paths[: self._cap],
|
||||
)
|
||||
)
|
||||
return clusters
|
||||
|
||||
def cluster_to_dicts(self, unmatched_paths: list) -> list:
|
||||
return [c.to_dict() for c in self.cluster(unmatched_paths)]
|
||||
|
||||
|
||||
# --- rule report assembly ---------------------------------------------------
|
||||
|
||||
|
||||
def _glob_matches(glob_pattern: str, path: str) -> bool:
|
||||
return fnmatch.fnmatch(path, glob_pattern)
|
||||
|
||||
|
||||
def _relax_glob(glob_pattern: str) -> Optional[str]:
|
||||
"""Produce a strictly broader variant of a glob for near-miss detection.
|
||||
|
||||
Widens every path segment that already contains a wildcard character to
|
||||
a bare '*' (fully generic for that segment), preserving fixed segments
|
||||
and the final segment's extension when present. This surfaces exactly
|
||||
the #45 boundary-bug class: a glob like `autoresearch/classic-*/` that
|
||||
silently misses a sibling `autoresearch/improve-*/` — relaxing the
|
||||
`classic-*` segment to `*` reveals the sibling as a near-miss.
|
||||
|
||||
Returns None if no segment has a wildcard to relax (fully-fixed glob has
|
||||
no meaningful near-miss boundary)."""
|
||||
parts = glob_pattern.split("/")
|
||||
if not parts:
|
||||
return None
|
||||
|
||||
relaxed_parts = list(parts)
|
||||
changed = False
|
||||
for i, part in enumerate(parts):
|
||||
if part in ("*", "**"):
|
||||
continue # already fully generic
|
||||
if any(ch in part for ch in "*?[") :
|
||||
if "." in part:
|
||||
stem, _, ext = part.rpartition(".")
|
||||
relaxed = "*." + ext
|
||||
else:
|
||||
relaxed = "*"
|
||||
if relaxed != part:
|
||||
relaxed_parts[i] = relaxed
|
||||
changed = True
|
||||
|
||||
if not changed:
|
||||
return None
|
||||
return "/".join(relaxed_parts)
|
||||
|
||||
|
||||
@dataclass
|
||||
class RuleReportEntry:
|
||||
glob: str
|
||||
lifetime: str
|
||||
tier: str
|
||||
matched_sample: list
|
||||
matched_total: int
|
||||
near_miss: list
|
||||
why: str = ""
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
"glob": self.glob,
|
||||
"lifetime": self.lifetime,
|
||||
"tier": self.tier,
|
||||
"matches": {
|
||||
"sample": list(self.matched_sample),
|
||||
"total": self.matched_total,
|
||||
},
|
||||
"near_miss": list(self.near_miss),
|
||||
"why": self.why,
|
||||
}
|
||||
|
||||
|
||||
class RuleReportBuilder:
|
||||
"""Given proposed rules and the repo's file list, produces the 5-element
|
||||
rule-report data per proposed rule: glob verbatim, matched paths (capped
|
||||
sample + total), near-miss boundary, lifetime + tier, and a why string
|
||||
(caller-supplied; this class does not author prose)."""
|
||||
|
||||
def __init__(self, sample_cap: int = _DEFAULT_SAMPLE_CAP) -> None:
|
||||
self._cap = sample_cap
|
||||
|
||||
def build(self, proposed_rule: dict, all_paths: list) -> RuleReportEntry:
|
||||
glob_pattern = proposed_rule["glob"]
|
||||
lifetime = proposed_rule.get("lifetime", "keep")
|
||||
tier = proposed_rule.get("tier", "confirm")
|
||||
why = proposed_rule.get("why", "")
|
||||
|
||||
matched = sorted(p for p in all_paths if _glob_matches(glob_pattern, p))
|
||||
|
||||
near_miss: list = []
|
||||
relaxed = _relax_glob(glob_pattern)
|
||||
if relaxed is not None:
|
||||
matched_set = set(matched)
|
||||
near_miss = sorted(
|
||||
p
|
||||
for p in all_paths
|
||||
if p not in matched_set and _glob_matches(relaxed, p)
|
||||
)
|
||||
|
||||
return RuleReportEntry(
|
||||
glob=glob_pattern,
|
||||
lifetime=lifetime,
|
||||
tier=tier,
|
||||
matched_sample=matched[: self._cap],
|
||||
matched_total=len(matched),
|
||||
near_miss=near_miss,
|
||||
why=why,
|
||||
)
|
||||
|
||||
def build_all(self, proposed_rules: list, all_paths: list) -> list:
|
||||
return [self.build(r, all_paths).to_dict() for r in proposed_rules]
|
||||
|
||||
|
||||
# --- rule-quality checks -----------------------------------------------------
|
||||
|
||||
|
||||
@dataclass
|
||||
class QualityFinding:
|
||||
check: str # "class_not_path" | "prefer_narrower"
|
||||
glob: str
|
||||
passed: bool
|
||||
reason: str
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
"check": self.check,
|
||||
"glob": self.glob,
|
||||
"passed": self.passed,
|
||||
"reason": self.reason,
|
||||
}
|
||||
|
||||
|
||||
class RuleQualityChecker:
|
||||
"""Deterministic lints applied to a proposed glob before persistence:
|
||||
|
||||
- `class_not_path`: flags a glob whose final path segment looks
|
||||
instance-unique (a long digit run / bare timestamp, or a hex-looking
|
||||
hash run), OR a glob with zero wildcard characters that currently
|
||||
matches exactly one existing path (a rule that can, by construction,
|
||||
only ever match that one file — a failed generalization).
|
||||
- `prefer_narrower`: given two candidate globs for the same cluster,
|
||||
report which one is narrower by match-count against the actual tree
|
||||
(ties broken by raw pattern length, longer = more specific = narrower).
|
||||
"""
|
||||
|
||||
def class_not_path(self, glob_pattern: str, all_paths: Optional[list] = None) -> QualityFinding:
|
||||
last_segment = glob_pattern.split("/")[-1]
|
||||
|
||||
if _LONG_DIGIT_RUN.search(last_segment):
|
||||
return QualityFinding(
|
||||
check="class_not_path",
|
||||
glob=glob_pattern,
|
||||
passed=False,
|
||||
reason=(
|
||||
"glob's final segment contains a long digit run "
|
||||
"(looks like a run-id or timestamp unique to one instance)"
|
||||
),
|
||||
)
|
||||
|
||||
if _HEX_RUN.search(last_segment):
|
||||
return QualityFinding(
|
||||
check="class_not_path",
|
||||
glob=glob_pattern,
|
||||
passed=False,
|
||||
reason=(
|
||||
"glob's final segment contains a hex-looking run "
|
||||
"(looks like a hash unique to one instance)"
|
||||
),
|
||||
)
|
||||
|
||||
has_wildcard = any(ch in glob_pattern for ch in "*?[")
|
||||
if not has_wildcard and all_paths is not None:
|
||||
matches = [p for p in all_paths if _glob_matches(glob_pattern, p)]
|
||||
if len(matches) <= 1:
|
||||
return QualityFinding(
|
||||
check="class_not_path",
|
||||
glob=glob_pattern,
|
||||
passed=False,
|
||||
reason=(
|
||||
"glob has no wildcard and matches at most one existing "
|
||||
"path — by construction it can never match a future "
|
||||
"file (failed generalization), flagged loudly"
|
||||
),
|
||||
)
|
||||
|
||||
return QualityFinding(
|
||||
check="class_not_path",
|
||||
glob=glob_pattern,
|
||||
passed=True,
|
||||
reason="glob names a recurring class, not a single instance",
|
||||
)
|
||||
|
||||
def prefer_narrower(self, glob_a: str, glob_b: str, all_paths: list) -> dict:
|
||||
"""Returns {"narrower": <glob>, "match_counts": {a: n, b: n}}."""
|
||||
count_a = sum(1 for p in all_paths if _glob_matches(glob_a, p))
|
||||
count_b = sum(1 for p in all_paths if _glob_matches(glob_b, p))
|
||||
|
||||
if count_a != count_b:
|
||||
narrower = glob_a if count_a < count_b else glob_b
|
||||
else:
|
||||
# tie-break on identical match counts: fewer wildcard characters
|
||||
# is more specific/narrower; if that also ties, the longer raw
|
||||
# pattern is treated as more specific.
|
||||
def _wildcard_count(g: str) -> int:
|
||||
return sum(g.count(ch) for ch in "*?[")
|
||||
|
||||
wc_a, wc_b = _wildcard_count(glob_a), _wildcard_count(glob_b)
|
||||
if wc_a != wc_b:
|
||||
narrower = glob_a if wc_a < wc_b else glob_b
|
||||
else:
|
||||
narrower = glob_a if len(glob_a) >= len(glob_b) else glob_b
|
||||
|
||||
return {
|
||||
"narrower": narrower,
|
||||
"match_counts": {glob_a: count_a, glob_b: count_b},
|
||||
}
|
||||
|
||||
|
||||
# --- canonical rules-file writer --------------------------------------------
|
||||
|
||||
# Known fields for a rule entry — reuses rulebook.py's schema so the writer
|
||||
# and the loader never drift (rulebook.py itself stays nomination-unaware;
|
||||
# this is a read-only reference to its field set, design.md Non-Goal 1).
|
||||
_RULE_KNOWN_FIELDS = _rulebook._KNOWN_FIELDS
|
||||
|
||||
# Nominations entry schemas (lifecycle-spec.md §2). Consult entries
|
||||
# deliberately carry no lifetime; rejected entries deliberately carry no
|
||||
# status field.
|
||||
_CONSULT_KNOWN_FIELDS = {"glob", "question", "evidence", "cluster_key", "asked_on"}
|
||||
_REJECTED_KNOWN_FIELDS = {
|
||||
"glob",
|
||||
"lifetime",
|
||||
"why",
|
||||
"consider_instead",
|
||||
"rejected_by",
|
||||
"judged_on",
|
||||
}
|
||||
|
||||
# Canonical tier order for `rules`: delete-once-served -> temporary -> keep.
|
||||
# Anything else (e.g. a missing/unrecognized lifetime) sorts last.
|
||||
_LIFETIME_TIER_ORDER = {
|
||||
"delete-once-served": 0,
|
||||
"temporary": 1,
|
||||
"keep": 2,
|
||||
}
|
||||
|
||||
|
||||
def _warn_unknown_fields(entry: dict, known_fields: set, label: str, warnings: list) -> None:
|
||||
unknown = set(entry.keys()) - known_fields
|
||||
if unknown:
|
||||
glob_pattern = entry.get("glob", "<no glob>")
|
||||
warnings.append(
|
||||
f"{label} {glob_pattern!r}: unrecognized field(s) {sorted(unknown)} "
|
||||
"— preserved, not dropped"
|
||||
)
|
||||
|
||||
|
||||
def _sort_by_glob(entries: list) -> list:
|
||||
return sorted(entries, key=lambda e: e.get("glob", ""))
|
||||
|
||||
|
||||
class RulesFileWriter:
|
||||
"""The single canonical serialization path for `.dochygiene-rules.json`
|
||||
(design.md Implementation shape). Owns:
|
||||
|
||||
- canonical ordering: `rules` grouped by lifetime tier (delete-once-
|
||||
served -> temporary -> keep), glob-sorted within each group;
|
||||
`nominations` after `rules`, with `consults` before `rejected`, each
|
||||
glob-sorted.
|
||||
- idempotency: canonicalizing an already-canonical structure is a no-op.
|
||||
- unknown-field discipline: unrecognized fields on rules AND nomination
|
||||
entries are warned about and round-tripped, never dropped.
|
||||
- the nominations read/validate/warn path — `rulebook.py` stays
|
||||
nomination-unaware; this is the only code that interprets the
|
||||
`nominations` key.
|
||||
|
||||
No knowledge of git, classification, or judge/report logic — purely a
|
||||
load/canonicalize/write seam over the parsed JSON structure.
|
||||
"""
|
||||
|
||||
def canonicalize(self, data: dict) -> "tuple[dict, list]":
|
||||
"""Pure in-memory transform: returns (canonical_dict, warnings).
|
||||
Does no I/O."""
|
||||
warnings: list = []
|
||||
|
||||
rules_in = data.get("rules", []) or []
|
||||
rules_out = []
|
||||
for raw in rules_in:
|
||||
rule = dict(raw)
|
||||
_warn_unknown_fields(rule, _RULE_KNOWN_FIELDS, "rule", warnings)
|
||||
rules_out.append(rule)
|
||||
|
||||
rules_out = sorted(
|
||||
rules_out,
|
||||
key=lambda r: (
|
||||
_LIFETIME_TIER_ORDER.get(r.get("lifetime"), 99),
|
||||
r.get("glob", ""),
|
||||
),
|
||||
)
|
||||
|
||||
canonical: dict = {
|
||||
"schema_version": data.get("schema_version", 1),
|
||||
"rules": rules_out,
|
||||
}
|
||||
|
||||
nominations_in = data.get("nominations") or {}
|
||||
consults_in = nominations_in.get("consults", []) or []
|
||||
rejected_in = nominations_in.get("rejected", []) or []
|
||||
|
||||
consults_out = []
|
||||
for raw in consults_in:
|
||||
entry = dict(raw)
|
||||
_warn_unknown_fields(entry, _CONSULT_KNOWN_FIELDS, "consult entry", warnings)
|
||||
consults_out.append(entry)
|
||||
|
||||
rejected_out = []
|
||||
for raw in rejected_in:
|
||||
entry = dict(raw)
|
||||
_warn_unknown_fields(entry, _REJECTED_KNOWN_FIELDS, "rejected entry", warnings)
|
||||
rejected_out.append(entry)
|
||||
|
||||
consults_out = _sort_by_glob(consults_out)
|
||||
rejected_out = _sort_by_glob(rejected_out)
|
||||
|
||||
if consults_out or rejected_out:
|
||||
canonical["nominations"] = {
|
||||
"consults": consults_out,
|
||||
"rejected": rejected_out,
|
||||
}
|
||||
|
||||
return canonical, warnings
|
||||
|
||||
def load(self, path: Path) -> "tuple[dict, list]":
|
||||
"""Reads a rules file from disk and returns (canonical_dict,
|
||||
warnings). A missing file yields the empty envelope, matching
|
||||
rulebook.py's own missing-file behavior."""
|
||||
path = Path(path)
|
||||
try:
|
||||
text = path.read_text(encoding="utf-8")
|
||||
except FileNotFoundError:
|
||||
return self.canonicalize({"schema_version": 1, "rules": []})
|
||||
|
||||
data = json.loads(text)
|
||||
return self.canonicalize(data)
|
||||
|
||||
def write(self, path: Path, data: dict) -> list:
|
||||
"""Canonicalizes *data* and writes it to *path* as pretty JSON with
|
||||
a trailing newline. Returns the warnings collected during
|
||||
canonicalization."""
|
||||
path = Path(path)
|
||||
canonical, warnings = self.canonicalize(data)
|
||||
text = json.dumps(canonical, indent=2) + "\n"
|
||||
path.write_text(text, encoding="utf-8")
|
||||
return warnings
|
||||
|
||||
|
||||
# --- nomination intake filter ------------------------------------------------
|
||||
|
||||
|
||||
class NominationIntakeFilter:
|
||||
"""Deterministic, no-model filter run between haiku nomination and the
|
||||
strong-model judge (lifecycle-spec.md §8 step 3; calibrate spec's
|
||||
"Deterministic Nomination Intake Filter" requirement).
|
||||
|
||||
Injected inputs: the rules-file's `rejected` entries and open `consults`
|
||||
(both read via `RulesFileWriter`/the calibrate skill, never by this
|
||||
class), plus, per call, the haiku nominations and the current scanned
|
||||
shortlist. No model, no I/O — pure set/string logic over injected data.
|
||||
"""
|
||||
|
||||
def __init__(self, rejected: list, consults: list) -> None:
|
||||
self._rejected = list(rejected)
|
||||
self._consults = list(consults)
|
||||
|
||||
def _matches(self, glob_pattern: str, shortlist: list) -> set:
|
||||
return {p for p in shortlist if _glob_matches(glob_pattern, p)}
|
||||
|
||||
def filter(self, nominations: list, shortlist: list) -> dict:
|
||||
"""Returns:
|
||||
{
|
||||
"survivors": [ {**nomination, "related_rejections": [...]}, ... ],
|
||||
"dropped": [ {**nomination, "reason": "..."}, ... ],
|
||||
"consults": <all open consults, passed through unconditionally>,
|
||||
}
|
||||
"""
|
||||
survivors = []
|
||||
dropped = []
|
||||
|
||||
for nomination in nominations:
|
||||
glob_pattern = nomination.get("glob")
|
||||
lifetime = nomination.get("lifetime")
|
||||
|
||||
exact_match = next(
|
||||
(
|
||||
r
|
||||
for r in self._rejected
|
||||
if r.get("glob") == glob_pattern and r.get("lifetime") == lifetime
|
||||
),
|
||||
None,
|
||||
)
|
||||
if exact_match is not None:
|
||||
dropped.append(
|
||||
{
|
||||
**nomination,
|
||||
"reason": (
|
||||
"exact glob+lifetime repeat of a rejected nomination"
|
||||
),
|
||||
}
|
||||
)
|
||||
continue
|
||||
|
||||
related_rejections = []
|
||||
nomination_matches = self._matches(glob_pattern, shortlist)
|
||||
for rejection in self._rejected:
|
||||
rejection_matches = self._matches(rejection.get("glob", ""), shortlist)
|
||||
if nomination_matches & rejection_matches:
|
||||
related_rejections.append(dict(rejection))
|
||||
|
||||
survivors.append({**nomination, "related_rejections": related_rejections})
|
||||
|
||||
return {
|
||||
"survivors": survivors,
|
||||
"dropped": dropped,
|
||||
"consults": list(self._consults),
|
||||
}
|
||||
|
|
@ -104,10 +104,11 @@ class RealFileSystem:
|
|||
class _Git(Protocol):
|
||||
"""Git surface — injectable for tests."""
|
||||
def mv(self, src: Path, dest: Path, cwd: Path) -> None: ...
|
||||
def rm(self, path: Path, cwd: Path, recursive: bool = False) -> None: ...
|
||||
|
||||
|
||||
class RealGit:
|
||||
"""Production git — runs `git mv` via subprocess."""
|
||||
"""Production git — runs `git mv`/`git rm` via subprocess."""
|
||||
|
||||
def mv(self, src: Path, dest: Path, cwd: Path) -> None:
|
||||
result = subprocess.run(
|
||||
|
|
@ -121,6 +122,56 @@ class RealGit:
|
|||
f"git mv failed ({result.returncode}): {result.stderr.strip()}"
|
||||
)
|
||||
|
||||
def rm(self, path: Path, cwd: Path, recursive: bool = False) -> None:
|
||||
cmd = ["git", "rm"]
|
||||
if recursive:
|
||||
cmd.append("-r")
|
||||
cmd.append(str(path))
|
||||
result = subprocess.run(
|
||||
cmd,
|
||||
cwd=str(cwd),
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
raise RuntimeError(
|
||||
f"git rm failed ({result.returncode}): {result.stderr.strip()}"
|
||||
)
|
||||
|
||||
|
||||
class _GitState(Protocol):
|
||||
"""Runtime git tracked/dirty query surface — injectable for tests.
|
||||
|
||||
Used ONLY to re-verify lifecycle delete/extract-then-delete entries at
|
||||
apply time (ADR-0039): the applier never trusts a report's cached tier
|
||||
or git_state field for these kinds — it re-asks git, per path, right
|
||||
before applying.
|
||||
"""
|
||||
def is_tracked(self, path: str, cwd: Path) -> bool: ...
|
||||
def is_dirty(self, path: str, cwd: Path) -> bool: ...
|
||||
|
||||
|
||||
class RealGitState:
|
||||
"""Production git-state checker — `git ls-files` + `git status --porcelain`."""
|
||||
|
||||
def is_tracked(self, path: str, cwd: Path) -> bool:
|
||||
result = subprocess.run(
|
||||
["git", "ls-files", "--", path],
|
||||
cwd=str(cwd),
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
return bool(result.stdout.strip())
|
||||
|
||||
def is_dirty(self, path: str, cwd: Path) -> bool:
|
||||
result = subprocess.run(
|
||||
["git", "status", "--porcelain", "--", path],
|
||||
cwd=str(cwd),
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
return bool(result.stdout.strip())
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Result records
|
||||
|
|
@ -236,6 +287,62 @@ def _ranges_overlap(a_start: int, a_end: int, b_start: int, b_end: int) -> bool:
|
|||
return a_start <= b_end and b_start <= a_end
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Lifecycle deletion kinds (delete / extract-then-delete)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# These two kinds are NOT looked up in KIND_TABLE (owned by report_builder.py /
|
||||
# validate_report.py) — they carry no anchor and are handled as their own
|
||||
# atomic per-path operation, mirroring move-to-archive's special-casing.
|
||||
_LIFECYCLE_DELETE_KINDS = {"delete", "extract-then-delete"}
|
||||
|
||||
# exact_edit.extraction_target value ("cross-repo") that means the content
|
||||
# physically left the repo (vault write via /os-vault:write) and therefore
|
||||
# needs a discoverable extracted.md pointer. The other valid value,
|
||||
# "repo-durable" (ADR/CLAUDE.md/docs residue), is already discoverable
|
||||
# in-repo and gets no index entry (lifecycle-spec §1). This field is part
|
||||
# of the frozen machine-report schema (validate_report.py KIND_TABLE
|
||||
# required_fields for extract-then-delete) — reused here rather than
|
||||
# duplicated under a new name.
|
||||
_VAULT_EXTRACTION_TARGET = "cross-repo"
|
||||
|
||||
_POINTER_REQUIRED_FIELDS = ("vault_note", "reason", "date")
|
||||
|
||||
|
||||
def _extracted_md_relpath(path: str) -> str:
|
||||
"""Return the `extracted.md` path in the same directory as *path*."""
|
||||
parent = os.path.dirname(path)
|
||||
return f"{parent}/extracted.md" if parent else "extracted.md"
|
||||
|
||||
|
||||
def _valid_extraction_pointer(pointer: Any) -> bool:
|
||||
"""True iff *pointer* has non-empty vault_note/reason/date strings."""
|
||||
if not isinstance(pointer, dict):
|
||||
return False
|
||||
for key in _POINTER_REQUIRED_FIELDS:
|
||||
value = pointer.get(key)
|
||||
if not isinstance(value, str) or not value.strip():
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _format_extracted_pointer(pointer: dict, source_filename: str) -> str:
|
||||
"""Format the extracted.md pointer line per lifecycle-spec §1:
|
||||
|
||||
- [[vault: tool/graphify-clustering-behavior]] — why HDBSCAN
|
||||
over-merges doc clusters; read before tuning any graphify
|
||||
clustering params. (extracted from `graphify-clustering-notes.md`,
|
||||
2026-07-15)
|
||||
"""
|
||||
vault_note = pointer["vault_note"]
|
||||
reason = pointer["reason"]
|
||||
date = pointer["date"]
|
||||
return (
|
||||
f"- [[vault: {vault_note}]] — {reason} "
|
||||
f"(extracted from `{source_filename}`, {date})"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# PatchApplier
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
@ -258,10 +365,12 @@ class PatchApplier:
|
|||
project_root: Path,
|
||||
fs: Optional[_FileSystem] = None,
|
||||
git: Optional[_Git] = None,
|
||||
git_state: Optional[_GitState] = None,
|
||||
) -> None:
|
||||
self._root = Path(project_root)
|
||||
self._fs = fs or RealFileSystem()
|
||||
self._git = git or RealGit()
|
||||
self._git_state = git_state or RealGitState()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Public API
|
||||
|
|
@ -341,6 +450,9 @@ class PatchApplier:
|
|||
continue
|
||||
ee = entry.get("exact_edit", {})
|
||||
kind = ee.get("kind", "")
|
||||
if kind in _LIFECYCLE_DELETE_KINDS:
|
||||
# Handled by _apply_lifecycle_delete below, not KIND_TABLE.
|
||||
continue
|
||||
if kind not in KIND_TABLE:
|
||||
step0_failures.append(_skipped(
|
||||
path, kind or "(unknown)", idx,
|
||||
|
|
@ -365,6 +477,26 @@ class PatchApplier:
|
|||
))
|
||||
return applied, skipped_out, failed_out, staged
|
||||
|
||||
# Step 0b: lifecycle delete / extract-then-delete — handled as their
|
||||
# own atomic per-path operation (no anchor, no KIND_TABLE lookup).
|
||||
lifecycle_batch = [
|
||||
(i, e) for i, e in batch
|
||||
if e.get("exact_edit", {}).get("kind") in _LIFECYCLE_DELETE_KINDS
|
||||
]
|
||||
if lifecycle_batch:
|
||||
if len(batch) > 1:
|
||||
for idx, entry in batch:
|
||||
kind = entry.get("exact_edit", {}).get("kind", "(unknown)")
|
||||
skipped_out.append(_skipped(
|
||||
path, kind, idx,
|
||||
"incompatible-ops-on-file",
|
||||
"delete/extract-then-delete cannot be combined with "
|
||||
"other ops on the same path; re-run check to re-classify.",
|
||||
))
|
||||
return applied, skipped_out, failed_out, staged
|
||||
idx, entry = lifecycle_batch[0]
|
||||
return self._apply_lifecycle_delete(path, idx, entry)
|
||||
|
||||
# Step 1: Incompatible-ops detection (structural — before any IO).
|
||||
incompatibility = self._check_incompatible_ops(path, batch)
|
||||
if incompatibility:
|
||||
|
|
@ -561,6 +693,131 @@ class PatchApplier:
|
|||
|
||||
return applied, skipped_out, failed_out, staged
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Lifecycle delete / extract-then-delete (ADR-0039)
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _apply_lifecycle_delete(
|
||||
self, path: str, idx: int, entry: dict
|
||||
) -> tuple[list, list, list, list]:
|
||||
"""Apply a single `delete` or `extract-then-delete` entry.
|
||||
|
||||
Re-verifies live git tracked/clean state at apply time whenever the
|
||||
entry's cached `safety_tier` is `auto` — never trusting the report's
|
||||
cached tier or `lifecycle.git_state` (ADR-0039). A `confirm`-tier
|
||||
entry was already gated through explicit human approval upstream
|
||||
(doc-clean's batch-confirm), so its tracked/dirty state is expected
|
||||
and is NOT re-verified here — only auto verdicts get downgraded.
|
||||
|
||||
`extract-then-delete` additionally fails closed per-entry: the git rm
|
||||
only happens once the caller (clean skill / subagent) has marked the
|
||||
extraction step complete via `extraction_complete: true` on the
|
||||
entry. This applier never performs the generative extraction itself.
|
||||
|
||||
When `exact_edit.extraction_target == "cross-repo"` (the vault case —
|
||||
content physically left the repo via `/os-vault:write`), the git rm
|
||||
additionally requires a valid top-level `extraction_pointer` dict
|
||||
(`vault_note`, `reason`, `date`, all non-empty strings), and a pointer
|
||||
line is appended to the deleted file's directory `extracted.md`
|
||||
(creating it if absent) BEFORE the git rm, staged into the same
|
||||
commit. `extraction_target == "repo-durable"` (ADR/CLAUDE.md/docs
|
||||
residue) writes no index entry — it is already discoverable in-repo.
|
||||
A failed append (or missing/invalid pointer metadata) skips the
|
||||
delete, same fail-closed semantics as an unconfirmed extraction.
|
||||
|
||||
Directory-rule aggregate entries (`exact_edit.is_directory: true`)
|
||||
bypass the content-hash guard entirely (no single file to hash) but
|
||||
NEVER bypass the git-state re-verification.
|
||||
"""
|
||||
applied: list[dict] = []
|
||||
skipped_out: list[dict] = []
|
||||
failed_out: list[dict] = []
|
||||
staged: list[str] = []
|
||||
|
||||
ee = entry.get("exact_edit", {})
|
||||
kind = ee.get("kind", "")
|
||||
is_directory = bool(ee.get("is_directory", False))
|
||||
abs_path = self._root / path
|
||||
tier = entry.get("safety_tier")
|
||||
|
||||
if tier == "auto":
|
||||
tracked = self._git_state.is_tracked(path, self._root)
|
||||
dirty = self._git_state.is_dirty(path, self._root) if tracked else False
|
||||
if not tracked or dirty:
|
||||
skipped_out.append(_skipped(
|
||||
path, kind, idx,
|
||||
"git-state-changed-since-check",
|
||||
"Path is no longer tracked+clean since the check ran; "
|
||||
"re-run hygiene check to re-verify before deleting.",
|
||||
))
|
||||
return applied, skipped_out, failed_out, staged
|
||||
|
||||
if kind == "extract-then-delete" and not entry.get("extraction_complete", False):
|
||||
skipped_out.append(_skipped(
|
||||
path, kind, idx,
|
||||
"extraction-not-confirmed",
|
||||
"Extraction step has not been marked complete; the delete "
|
||||
"is withheld for this entry (fails closed, not a hard failure).",
|
||||
))
|
||||
return applied, skipped_out, failed_out, staged
|
||||
|
||||
# extract-then-delete, vault destination: append a discoverable
|
||||
# pointer to the deleted file's directory `extracted.md` BEFORE the
|
||||
# git rm, so there is never a window where the doc is gone but
|
||||
# undiscoverable (lifecycle-spec §1 / design decision #7). Repo-
|
||||
# durable destinations (ADR/CLAUDE.md/docs, or no destination at all)
|
||||
# are already discoverable in-repo and get no index entry.
|
||||
if (
|
||||
kind == "extract-then-delete"
|
||||
and ee.get("extraction_target") == _VAULT_EXTRACTION_TARGET
|
||||
):
|
||||
pointer = entry.get("extraction_pointer")
|
||||
if not _valid_extraction_pointer(pointer):
|
||||
skipped_out.append(_skipped(
|
||||
path, kind, idx,
|
||||
"extraction-pointer-invalid",
|
||||
"extraction_target is \"cross-repo\" (vault) but "
|
||||
"extraction_pointer is missing or incomplete (needs "
|
||||
"vault_note, reason, date); the delete is withheld so the "
|
||||
"doc is never gone-but-unindexed.",
|
||||
))
|
||||
return applied, skipped_out, failed_out, staged
|
||||
|
||||
extracted_rel = _extracted_md_relpath(path)
|
||||
extracted_abs = self._root / extracted_rel
|
||||
pointer_line = _format_extracted_pointer(pointer, Path(path).name)
|
||||
|
||||
try:
|
||||
existing = (
|
||||
self._fs.read_bytes(extracted_abs)
|
||||
if self._fs.exists(extracted_abs) else b""
|
||||
)
|
||||
if existing and not existing.endswith(b"\n"):
|
||||
existing += b"\n"
|
||||
self._fs.write_bytes(
|
||||
extracted_abs, existing + pointer_line.encode("utf-8") + b"\n"
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
skipped_out.append(_skipped(
|
||||
path, kind, idx,
|
||||
"extracted-md-append-failed",
|
||||
f"Could not append pointer to {extracted_rel}: {exc}; the "
|
||||
"delete is withheld so the doc is never gone-but-unindexed.",
|
||||
))
|
||||
return applied, skipped_out, failed_out, staged
|
||||
|
||||
staged.append(extracted_rel)
|
||||
|
||||
try:
|
||||
self._git.rm(abs_path, self._root, recursive=is_directory)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
failed_out.append(_failed(path, kind, idx, str(exc)))
|
||||
return applied, skipped_out, failed_out, staged
|
||||
|
||||
applied.append(_applied(path, kind, idx))
|
||||
staged.append(path)
|
||||
return applied, skipped_out, failed_out, staged
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Incompatible-ops detection
|
||||
# ------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -49,6 +49,7 @@ from __future__ import annotations
|
|||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
|
@ -68,6 +69,7 @@ from token_estimator import default_estimator, TokenEstimator # noqa: E402
|
|||
SCHEMA_VERSION = "1.0"
|
||||
|
||||
_PLUGIN_JSON = _SCRIPTS_DIR.parent / ".claude-plugin" / "plugin.json"
|
||||
_CONVENTIONS_JSON = _SCRIPTS_DIR.parent / "conventions.json"
|
||||
|
||||
_VALID_OP_TYPES = ("deterministic", "generative")
|
||||
_STALE_SUBTYPES = {"contradicted", "orphaned", "superseded", "provisional", "completed-in-place", "duplicated"}
|
||||
|
|
@ -121,6 +123,48 @@ class RealFileReader:
|
|||
return Path(path).read_text(encoding="utf-8", errors="replace")
|
||||
|
||||
|
||||
class _GitStateProvider(Protocol):
|
||||
def state(self, root: Path, rel_path: str) -> dict: ...
|
||||
|
||||
|
||||
class RealGitStateProvider:
|
||||
"""Production git-state provider — `git ls-files` (tracked) + `git status
|
||||
--porcelain` (dirty), scoped to a single path. Advisory-fresh at
|
||||
report-build time; `patch_applier.py` re-verifies at apply time (design
|
||||
Decision 3 / ADR-0039 — never trust the rule's own claim)."""
|
||||
|
||||
def state(self, root: Path, rel_path: str) -> dict:
|
||||
try:
|
||||
ls = subprocess.run(
|
||||
["git", "ls-files", "--error-unmatch", "--", rel_path],
|
||||
cwd=str(root),
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
tracked = ls.returncode == 0 and bool(ls.stdout.strip())
|
||||
except OSError:
|
||||
tracked = False
|
||||
|
||||
if not tracked:
|
||||
return {"tracked": False, "clean": False}
|
||||
|
||||
try:
|
||||
status = subprocess.run(
|
||||
["git", "status", "--porcelain", "--", rel_path],
|
||||
cwd=str(root),
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
clean = status.returncode == 0 and status.stdout.strip() == ""
|
||||
except OSError:
|
||||
clean = False
|
||||
|
||||
return {"tracked": True, "clean": clean}
|
||||
|
||||
|
||||
_LIFECYCLE_OP_KINDS = ("delete", "extract-then-delete")
|
||||
|
||||
|
||||
def _iso(dt: datetime) -> str:
|
||||
"""Serialise a datetime as ISO-8601 UTC."""
|
||||
if dt.tzinfo is None:
|
||||
|
|
@ -172,11 +216,17 @@ class ReportBuilder:
|
|||
reader: Optional[_FileReader] = None,
|
||||
estimator: Optional[TokenEstimator] = None,
|
||||
tool_version: Optional[str] = None,
|
||||
git_state_provider: Optional[_GitStateProvider] = None,
|
||||
conventions_path: Optional[Path] = None,
|
||||
) -> None:
|
||||
self._root = Path(project_root)
|
||||
self._clock = clock or RealClock()
|
||||
self._reader = reader or RealFileReader()
|
||||
self._estimator = estimator or default_estimator()
|
||||
self._git_state_provider = git_state_provider or RealGitStateProvider()
|
||||
self._conventions_path = (
|
||||
Path(conventions_path) if conventions_path is not None else _CONVENTIONS_JSON
|
||||
)
|
||||
if tool_version is not None:
|
||||
self._tool_version = tool_version
|
||||
self._tool_version_fell_back = False
|
||||
|
|
@ -223,6 +273,7 @@ class ReportBuilder:
|
|||
"scan": scan_block,
|
||||
"shortlist": shortlist,
|
||||
"entries": entries,
|
||||
"promotion_candidates": self._build_promotion_candidates(entries),
|
||||
}
|
||||
|
||||
human_report = self._build_human_report(
|
||||
|
|
@ -347,8 +398,13 @@ class ReportBuilder:
|
|||
entry["safety_tier"] = derive_safety_tier(
|
||||
op_type, spec.is_destructive, spec.is_reversible
|
||||
)
|
||||
exact_edit = self._build_exact_edit(proposal["exact_edit"], spec, abs_path)
|
||||
exact_edit = self._build_exact_edit(proposal["exact_edit"], spec, abs_path, path)
|
||||
entry["exact_edit"] = exact_edit
|
||||
if kind in _LIFECYCLE_OP_KINDS:
|
||||
entry["safety_tier"] = derive_safety_tier(
|
||||
op_type, spec.is_destructive, spec.is_reversible,
|
||||
lifecycle=exact_edit.get("lifecycle"),
|
||||
)
|
||||
span_text = self._extract_span(proposal["exact_edit"], kind, abs_path)
|
||||
else: # generative
|
||||
# No exact_edit; (is_destructive, is_reversible) characterize the op.
|
||||
|
|
@ -362,7 +418,7 @@ class ReportBuilder:
|
|||
entry["token_estimate"] = self._estimator.estimate_for_report(span_text)
|
||||
return entry
|
||||
|
||||
def _build_exact_edit(self, skeleton: dict, spec, abs_path: Path) -> dict:
|
||||
def _build_exact_edit(self, skeleton: dict, spec, abs_path: Path, rel_path: str) -> dict:
|
||||
"""Copy the skeleton and stamp the deterministic fields."""
|
||||
kind = skeleton["kind"]
|
||||
exact_edit: dict[str, Any] = {"kind": kind}
|
||||
|
|
@ -379,6 +435,16 @@ class ReportBuilder:
|
|||
file_bytes = self._reader.read_bytes(abs_path)
|
||||
exact_edit["expected_sha256"] = hashlib.sha256(file_bytes).hexdigest()
|
||||
exact_edit["generated_at"] = _iso(hash_instant)
|
||||
|
||||
if kind in _LIFECYCLE_OP_KINDS:
|
||||
# git_state is a guardrail field (like expected_sha256): NEVER
|
||||
# trust a proposal-supplied value — always recomputed against the
|
||||
# live worktree at build time. Advisory-fresh; patch_applier.py
|
||||
# re-verifies authoritatively at apply time (ADR-0039).
|
||||
lifecycle = dict(exact_edit.get("lifecycle") or {})
|
||||
lifecycle["git_state"] = self._git_state_provider.state(self._root, rel_path)
|
||||
exact_edit["lifecycle"] = lifecycle
|
||||
|
||||
return exact_edit
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
|
|
@ -395,6 +461,10 @@ class ReportBuilder:
|
|||
# NOTE: a documented deviation — the design did not specify a span
|
||||
# for insert-frontmatter.
|
||||
return self._reader.read_text(abs_path)
|
||||
if kind in _LIFECYCLE_OP_KINDS:
|
||||
# delete / extract-then-delete removes the whole file → count the
|
||||
# whole file, same rationale as move-to-archive.
|
||||
return self._reader.read_text(abs_path)
|
||||
# delete-range / replace-text / dedupe — count the anchored span.
|
||||
return self._read_range(abs_path, skeleton["anchor"])
|
||||
|
||||
|
|
@ -408,6 +478,57 @@ class ReportBuilder:
|
|||
return ""
|
||||
return "\n".join(lines[start - 1:end])
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Promotion candidates (ADR-0041, determinism-promotion — no model)
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _load_conventions(self) -> list:
|
||||
try:
|
||||
data = json.loads(self._conventions_path.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError):
|
||||
return []
|
||||
return data if isinstance(data, list) else []
|
||||
|
||||
def _entry_lifecycle(self, entry: dict) -> Optional[dict]:
|
||||
"""Return the entry's lifecycle object, whether it lives on the entry
|
||||
itself (a signal-only, non-deleting match) or nested under a
|
||||
delete/extract-then-delete exact_edit."""
|
||||
if isinstance(entry.get("lifecycle"), dict):
|
||||
return entry["lifecycle"]
|
||||
exact_edit = entry.get("exact_edit")
|
||||
if isinstance(exact_edit, dict) and isinstance(exact_edit.get("lifecycle"), dict):
|
||||
return exact_edit["lifecycle"]
|
||||
return None
|
||||
|
||||
def _build_promotion_candidates(self, entries: list) -> list:
|
||||
"""Deterministic, model-free: for every entry whose lifecycle
|
||||
evidence is classifier-judged (served_when present, no
|
||||
served_when_path — which by construction means "not yet adopted",
|
||||
since adoption IS graduating to served_when_path), name every
|
||||
catalog convention as a promotion candidate."""
|
||||
conventions = self._load_conventions()
|
||||
if not conventions:
|
||||
return []
|
||||
|
||||
candidates: list = []
|
||||
for entry in entries:
|
||||
lifecycle = self._entry_lifecycle(entry)
|
||||
if lifecycle is None:
|
||||
continue
|
||||
has_served_when = bool(lifecycle.get("served_when"))
|
||||
has_served_when_path = bool(lifecycle.get("served_when_path"))
|
||||
if not has_served_when or has_served_when_path:
|
||||
continue
|
||||
for conv in conventions:
|
||||
if not isinstance(conv, dict):
|
||||
continue
|
||||
name = conv.get("name")
|
||||
pitch = conv.get("pitch")
|
||||
if not name or not pitch:
|
||||
continue
|
||||
candidates.append({"path": entry["path"], "name": name, "pitch": pitch})
|
||||
return candidates
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Scan block
|
||||
# ------------------------------------------------------------------
|
||||
|
|
@ -451,8 +572,8 @@ class ReportBuilder:
|
|||
lines.append(f"- generated_at: {generated_at}")
|
||||
lines.append(f"- scope: {', '.join(scope_globs) if scope_globs else '(default)'}")
|
||||
lines.append(f"- files scanned: {scan.get('files_scanned', 0)}")
|
||||
lines.append(f"- candidates: {len(entries)}")
|
||||
lines.append(f"- cleared: {len(cleared)}")
|
||||
lines.append(f"- findings: {len(entries)}")
|
||||
lines.append(f"- cleared: {len(cleared)} of {len(shortlist)} evaluated entries")
|
||||
lines.append("")
|
||||
|
||||
stale = [e for e in entries if e["category"].get("class") == "stale"]
|
||||
|
|
@ -470,6 +591,10 @@ class ReportBuilder:
|
|||
lines.append("- (none)")
|
||||
lines.append("")
|
||||
|
||||
lines.extend(
|
||||
self._render_promotion_candidates(machine_report.get("promotion_candidates", []))
|
||||
)
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
def _render_group(self, title: str, group: list, gloss_by_path: dict) -> list:
|
||||
|
|
@ -492,6 +617,30 @@ class ReportBuilder:
|
|||
gloss = gloss_by_path.get(e["path"])
|
||||
if gloss:
|
||||
lines.append(f" - {gloss}")
|
||||
lifecycle = self._entry_lifecycle(e)
|
||||
if lifecycle:
|
||||
rule_ref = lifecycle.get("rule_ref", "?")
|
||||
lifetime = lifecycle.get("lifetime", "?")
|
||||
served = (
|
||||
f"served_when_path: {lifecycle['served_when_path']}"
|
||||
if lifecycle.get("served_when_path")
|
||||
else f"served_when: {lifecycle.get('served_when', '?')}"
|
||||
)
|
||||
lines.append(f" - lifecycle: rule={rule_ref} · lifetime={lifetime} · {served}")
|
||||
lines.append("")
|
||||
return lines
|
||||
|
||||
def _render_promotion_candidates(self, candidates: list) -> list:
|
||||
"""Renders the deterministic promotion_candidates section (ADR-0041)
|
||||
into the human report — always present, even when empty, per the
|
||||
determinism-promotion spec."""
|
||||
lines = ["## Promotion Candidates", ""]
|
||||
if not candidates:
|
||||
lines.append("- (none)")
|
||||
lines.append("")
|
||||
return lines
|
||||
for c in candidates:
|
||||
lines.append(f"- {c['path']} → adopt `{c['name']}`: {c['pitch']}")
|
||||
lines.append("")
|
||||
return lines
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,336 @@
|
|||
"""
|
||||
rulebook.py — lifecycle rulebook loader for os-doc-hygiene.
|
||||
|
||||
Stdlib-only. Loads the global rulebook (`plugins/os-doc-hygiene/rulebook.json`)
|
||||
and an optional per-project override (`<repo-root>/.dochygiene-rules.json`),
|
||||
both under the envelope `{"schema_version": 1, "rules": [...]}`. Merges
|
||||
add-only (project rules are appended to, never replace, global rules) and
|
||||
exposes a single query surface: given a repo-root-relative path (and whether
|
||||
it is a directory), return `None` (unmatched) or the winning `RuleMatch`.
|
||||
|
||||
No knowledge of git, classification, or the report schema — this module only
|
||||
resolves "which rule, if any, governs this path" (design.md Decision 1).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import glob
|
||||
import json
|
||||
import sys
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any, Optional
|
||||
|
||||
_MIN_PYTHON = (3, 13)
|
||||
|
||||
_VALID_LIFETIMES = {"keep", "temporary", "delete-once-served"}
|
||||
|
||||
# Fields recognized by the schema (lifecycle-spec.md §2). Any field on a
|
||||
# rule outside this set (e.g. the removed `propagate_ignore`) is rejected.
|
||||
_KNOWN_FIELDS = {
|
||||
"glob",
|
||||
"lifetime",
|
||||
"extract",
|
||||
"served_when",
|
||||
"served_when_path",
|
||||
"retain_recent",
|
||||
"max_age_days",
|
||||
"confirm",
|
||||
"confirmed_by",
|
||||
"confirmed_on",
|
||||
"source",
|
||||
"note",
|
||||
}
|
||||
|
||||
_DEFAULT_RETAIN_RECENT = 3
|
||||
_DEFAULT_MAX_AGE_DAYS = 3
|
||||
|
||||
_SUPPORTED_SCHEMA_VERSIONS = {1}
|
||||
|
||||
|
||||
class RulebookLoadError(Exception):
|
||||
"""Hard-fail: unparseable JSON, unknown schema_version, or unsupported
|
||||
Python version for the mandated glob dialect."""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RuleMatch:
|
||||
"""The winning rule for a queried path."""
|
||||
|
||||
rule: dict
|
||||
level: str # "file" | "directory"
|
||||
is_ignore: bool
|
||||
origin: str # "project" | "global"
|
||||
|
||||
|
||||
@dataclass
|
||||
class _CompiledRule:
|
||||
raw: dict
|
||||
origin: str # "project" | "global"
|
||||
level: str # "file" | "directory"
|
||||
is_ignore: bool
|
||||
pattern_for_matching: str
|
||||
matcher: Any # compiled regex (re.Pattern) — exact match on this level
|
||||
subtree_matcher: Optional[Any] # directory rules only: matches paths
|
||||
# anywhere beneath the directory (files or nested dirs), used when a
|
||||
# directory-rule competes for precedence over a *file* path.
|
||||
define_order: int
|
||||
|
||||
|
||||
def _check_python_version() -> None:
|
||||
if sys.version_info[:2] < _MIN_PYTHON:
|
||||
raise RulebookLoadError(
|
||||
"rulebook.py requires Python >= 3.13 for glob.translate("
|
||||
"recursive=True, include_hidden=True); running under "
|
||||
f"{sys.version_info[0]}.{sys.version_info[1]}."
|
||||
)
|
||||
|
||||
|
||||
def _load_json_envelope(path: Path) -> dict:
|
||||
try:
|
||||
text = path.read_text(encoding="utf-8")
|
||||
except FileNotFoundError:
|
||||
return {"schema_version": 1, "rules": []}
|
||||
try:
|
||||
data = json.loads(text)
|
||||
except json.JSONDecodeError as exc:
|
||||
raise RulebookLoadError(f"{path}: unparseable JSON: {exc}") from exc
|
||||
|
||||
if not isinstance(data, dict) or "schema_version" not in data:
|
||||
raise RulebookLoadError(
|
||||
f"{path}: missing or malformed envelope (expected "
|
||||
'{"schema_version": 1, "rules": [...]})'
|
||||
)
|
||||
|
||||
schema_version = data.get("schema_version")
|
||||
if schema_version not in _SUPPORTED_SCHEMA_VERSIONS:
|
||||
raise RulebookLoadError(
|
||||
f"{path}: unrecognized schema_version {schema_version!r} "
|
||||
f"(supported: {sorted(_SUPPORTED_SCHEMA_VERSIONS)})"
|
||||
)
|
||||
|
||||
rules = data.get("rules", [])
|
||||
if not isinstance(rules, list):
|
||||
raise RulebookLoadError(f"{path}: 'rules' must be a list")
|
||||
|
||||
return data
|
||||
|
||||
|
||||
def _is_directory_glob(pattern: str) -> bool:
|
||||
return pattern.endswith("/**") or pattern.endswith("/")
|
||||
|
||||
|
||||
def _base_pattern_for_directory(pattern: str) -> str:
|
||||
if pattern.endswith("/**"):
|
||||
return pattern[: -len("/**")]
|
||||
if pattern.endswith("/"):
|
||||
return pattern[: -len("/")]
|
||||
return pattern
|
||||
|
||||
|
||||
def _validate_and_compile_rule(
|
||||
raw: dict, origin: str, define_order: int, warnings: list
|
||||
) -> Optional[_CompiledRule]:
|
||||
label = raw.get("glob", "<no glob>")
|
||||
|
||||
unknown_fields = set(raw.keys()) - _KNOWN_FIELDS
|
||||
if unknown_fields:
|
||||
warnings.append(
|
||||
f"rule {label!r} ({origin}): unrecognized field(s) "
|
||||
f"{sorted(unknown_fields)} — rule skipped and marked inactive"
|
||||
)
|
||||
return None
|
||||
|
||||
glob_pattern = raw.get("glob")
|
||||
if not glob_pattern or not isinstance(glob_pattern, str):
|
||||
warnings.append(
|
||||
f"rule {label!r} ({origin}): missing or invalid 'glob' field — "
|
||||
"rule skipped and marked inactive"
|
||||
)
|
||||
return None
|
||||
|
||||
lifetime = raw.get("lifetime")
|
||||
is_ignore = lifetime is None
|
||||
if lifetime is not None and lifetime not in _VALID_LIFETIMES:
|
||||
warnings.append(
|
||||
f"rule {glob_pattern!r} ({origin}): invalid lifetime "
|
||||
f"{lifetime!r} — rule skipped and marked inactive"
|
||||
)
|
||||
return None
|
||||
|
||||
if not raw.get("confirmed_by"):
|
||||
warnings.append(
|
||||
f"rule {glob_pattern!r} ({origin}): missing 'confirmed_by' — "
|
||||
"rule loaded but marked inactive, never contributes a match"
|
||||
)
|
||||
return None
|
||||
|
||||
level = "directory" if _is_directory_glob(glob_pattern) else "file"
|
||||
match_pattern = (
|
||||
_base_pattern_for_directory(glob_pattern)
|
||||
if level == "directory"
|
||||
else glob_pattern
|
||||
)
|
||||
|
||||
import re
|
||||
|
||||
try:
|
||||
regex = glob.translate(
|
||||
match_pattern, recursive=True, include_hidden=True
|
||||
)
|
||||
matcher = re.compile(regex)
|
||||
except Exception as exc: # pragma: no cover - defensive
|
||||
warnings.append(
|
||||
f"rule {glob_pattern!r} ({origin}): failed to compile glob: "
|
||||
f"{exc} — rule skipped and marked inactive"
|
||||
)
|
||||
return None
|
||||
|
||||
subtree_matcher = None
|
||||
if level == "directory":
|
||||
# The un-stripped pattern (e.g. "notes/**") matches paths *beneath*
|
||||
# the directory too — needed so this directory-rule can compete for
|
||||
# precedence against file-rules on a file path (design.md Decision 1
|
||||
# two-axis precedence is path-based, not query-level-based: a file
|
||||
# path can be governed by a directory-rule that covers it).
|
||||
try:
|
||||
subtree_regex = glob.translate(
|
||||
glob_pattern, recursive=True, include_hidden=True
|
||||
)
|
||||
subtree_matcher = re.compile(subtree_regex)
|
||||
except Exception as exc: # pragma: no cover - defensive
|
||||
warnings.append(
|
||||
f"rule {glob_pattern!r} ({origin}): failed to compile "
|
||||
f"subtree glob: {exc} — rule skipped and marked inactive"
|
||||
)
|
||||
return None
|
||||
|
||||
resolved = dict(raw)
|
||||
if lifetime == "temporary":
|
||||
resolved.setdefault("retain_recent", _DEFAULT_RETAIN_RECENT)
|
||||
resolved.setdefault("max_age_days", _DEFAULT_MAX_AGE_DAYS)
|
||||
|
||||
return _CompiledRule(
|
||||
raw=resolved,
|
||||
origin=origin,
|
||||
level=level,
|
||||
is_ignore=is_ignore,
|
||||
pattern_for_matching=match_pattern,
|
||||
matcher=matcher,
|
||||
subtree_matcher=subtree_matcher,
|
||||
define_order=define_order,
|
||||
)
|
||||
|
||||
|
||||
# Precedence tiers, lowest wins first (spec: project file > project dir >
|
||||
# global file > global dir).
|
||||
_TIER = {
|
||||
("project", "file"): 0,
|
||||
("project", "directory"): 1,
|
||||
("global", "file"): 2,
|
||||
("global", "directory"): 3,
|
||||
}
|
||||
|
||||
|
||||
class Rulebook:
|
||||
"""Merged, compiled rulebook exposing a single query surface."""
|
||||
|
||||
def __init__(self, compiled_rules: list, warnings: list):
|
||||
self._rules = compiled_rules
|
||||
self.warnings = warnings
|
||||
|
||||
def query(self, path: str, is_dir: bool = False) -> Optional[RuleMatch]:
|
||||
"""Return the winning rule for *path*, or None if unmatched.
|
||||
|
||||
Precedence is resolved across *all* rules that cover this path,
|
||||
regardless of whether the winning rule is a file-rule or a
|
||||
directory-rule: a directory-rule's subtree covers files beneath it,
|
||||
so it can compete for (and win, per its tier) precedence on a file
|
||||
path just as a file-rule can. The returned RuleMatch.level reflects
|
||||
the winning rule's own declared type, not the caller's *is_dir*.
|
||||
"""
|
||||
norm_path = path.strip("/")
|
||||
|
||||
candidates = []
|
||||
for cr in self._rules:
|
||||
if cr.level == "file":
|
||||
# A file-rule's pattern is matched exactly; it only ever
|
||||
# governs the exact path it names.
|
||||
if cr.matcher.fullmatch(norm_path):
|
||||
candidates.append(cr)
|
||||
else: # directory-rule
|
||||
if is_dir:
|
||||
# Directory query: does this rule's own subtree glob
|
||||
# match the directory path itself?
|
||||
if cr.matcher.fullmatch(norm_path):
|
||||
candidates.append(cr)
|
||||
else:
|
||||
# File query: does this directory-rule's subtree cover
|
||||
# this file path?
|
||||
if cr.subtree_matcher.fullmatch(norm_path):
|
||||
candidates.append(cr)
|
||||
|
||||
if not candidates:
|
||||
return None
|
||||
|
||||
def sort_key(cr: _CompiledRule):
|
||||
tier = _TIER[(cr.origin, cr.level)]
|
||||
# Longer pattern wins ties -> negate for ascending sort.
|
||||
# Last-defined wins ties -> negate define_order.
|
||||
return (tier, -len(cr.pattern_for_matching), -cr.define_order)
|
||||
|
||||
winner = min(candidates, key=sort_key)
|
||||
return RuleMatch(
|
||||
rule=winner.raw,
|
||||
level=winner.level,
|
||||
is_ignore=winner.is_ignore,
|
||||
origin=winner.origin,
|
||||
)
|
||||
|
||||
|
||||
def default_global_rulebook_path() -> Path:
|
||||
"""The global rulebook path resolved relative to this script's own
|
||||
location: `<plugin-root>/rulebook.json` (this file lives in
|
||||
`<plugin-root>/scripts/`)."""
|
||||
return Path(__file__).resolve().parent.parent / "rulebook.json"
|
||||
|
||||
|
||||
def load_rulebook(
|
||||
global_path: Optional[Path] = None, project_path: Optional[Path] = None
|
||||
) -> Rulebook:
|
||||
"""Load and merge the global rulebook with an optional project override.
|
||||
|
||||
Hard-fails (raises RulebookLoadError) on unparseable JSON, an
|
||||
unrecognized schema_version, or an unsupported Python version.
|
||||
Skips and warns on a per-rule basis for invalid rules or rules missing
|
||||
`confirmed_by`.
|
||||
"""
|
||||
_check_python_version()
|
||||
|
||||
global_path = Path(global_path) if global_path is not None else default_global_rulebook_path()
|
||||
global_data = _load_json_envelope(global_path)
|
||||
|
||||
project_data: dict
|
||||
if project_path is not None:
|
||||
project_path = Path(project_path)
|
||||
project_data = _load_json_envelope(project_path)
|
||||
else:
|
||||
project_data = {"schema_version": 1, "rules": []}
|
||||
|
||||
warnings: list = []
|
||||
compiled: list = []
|
||||
define_order = 0
|
||||
|
||||
for raw in global_data.get("rules", []):
|
||||
cr = _validate_and_compile_rule(raw, "global", define_order, warnings)
|
||||
define_order += 1
|
||||
if cr is not None:
|
||||
compiled.append(cr)
|
||||
|
||||
for raw in project_data.get("rules", []):
|
||||
cr = _validate_and_compile_rule(raw, "project", define_order, warnings)
|
||||
define_order += 1
|
||||
if cr is not None:
|
||||
compiled.append(cr)
|
||||
|
||||
return Rulebook(compiled_rules=compiled, warnings=warnings)
|
||||
|
|
@ -22,11 +22,20 @@ import os
|
|||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Callable, Iterator, List, Optional
|
||||
from typing import Any, Callable, Iterator, List, Optional
|
||||
|
||||
from token_estimator import TokenEstimator, default_estimator
|
||||
|
||||
try: # rulebook.py is optional-at-import (requires Python >= 3.13 for its
|
||||
# own glob.translate usage); the scanner itself has no hard dependency
|
||||
# on it — a Scanner constructed without a `rulebook=` argument behaves
|
||||
# exactly as before this change (design.md Decision 2).
|
||||
from rulebook import RuleMatch # noqa: F401
|
||||
except Exception: # pragma: no cover - defensive, see comment above
|
||||
RuleMatch = object # type: ignore
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Glob matching (Python 3.14-compatible)
|
||||
|
|
@ -544,6 +553,161 @@ def _git_log_real(path: Path) -> List[str]:
|
|||
return []
|
||||
|
||||
|
||||
def _git_commit_time_real(path: Path) -> Optional[str]:
|
||||
"""Return the ISO-8601 committer time of *path*'s most recent commit.
|
||||
|
||||
Returns ``None`` when the path is untracked (no commit history) or on
|
||||
any subprocess failure — callers fall back to filesystem mtime.
|
||||
"""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["git", "log", "-1", "--format=%cI", "--", str(path)],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=10,
|
||||
)
|
||||
out = result.stdout.strip()
|
||||
return out or None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Lifecycle signal computation (design.md Decision 2 / 4)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_LIFECYCLE_SIGNAL_NAME = "lifecycle"
|
||||
|
||||
|
||||
def _substitute_served_when_path(pattern: str, basename: str) -> str:
|
||||
"""Substitute ``{id}``/``{name}`` in a served_when_path pattern with the
|
||||
matched entry's basename."""
|
||||
return pattern.replace("{id}", basename).replace("{name}", basename)
|
||||
|
||||
|
||||
class LifecycleSignalBuilder:
|
||||
"""Builds lifecycle signal dicts from rulebook matches.
|
||||
|
||||
Injected dependencies mirror ``SignalComputer``: a git-commit-time
|
||||
provider and a clock, both fakeable for tests. Age/retention are the
|
||||
only stateful parts — retention ranking requires all matches for a rule
|
||||
to be known first, so ``rank_temporary_matches`` is a separate pass run
|
||||
once the whole walk has completed.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
root: Path,
|
||||
git_commit_time_fn: Optional[Callable[[Path], Optional[str]]] = None,
|
||||
now_fn: Optional[Callable[[], float]] = None,
|
||||
) -> None:
|
||||
self._root = root
|
||||
self._git_commit_time_fn = git_commit_time_fn
|
||||
self._now_fn = now_fn or (lambda: __import__("time").time())
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
def age_days(self, rel_path: str, is_dir: bool) -> Optional[float]:
|
||||
"""Age in days: git commit time, falling back to mtime for
|
||||
untracked paths. Untracked directories use one stat() on the
|
||||
directory inode itself (never a recursive max-mtime walk)."""
|
||||
abs_path = self._root / rel_path
|
||||
|
||||
commit_iso: Optional[str] = None
|
||||
if self._git_commit_time_fn is not None:
|
||||
try:
|
||||
commit_iso = self._git_commit_time_fn(abs_path)
|
||||
except Exception:
|
||||
commit_iso = None
|
||||
|
||||
if commit_iso:
|
||||
try:
|
||||
commit_dt = datetime.fromisoformat(commit_iso)
|
||||
commit_ts = commit_dt.timestamp()
|
||||
return (self._now_fn() - commit_ts) / 86400.0
|
||||
except ValueError:
|
||||
pass # fall through to mtime
|
||||
|
||||
# Untracked (or unparseable commit time): filesystem mtime. For a
|
||||
# directory this is a single stat() on the directory inode itself —
|
||||
# never a recursive walk over its contents (design.md Decision 4).
|
||||
try:
|
||||
stat = abs_path.stat()
|
||||
except OSError:
|
||||
return None
|
||||
return (self._now_fn() - stat.st_mtime) / 86400.0
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
def build(self, rel_path: str, match: "RuleMatch", is_dir: bool) -> dict:
|
||||
"""Build the lifecycle signal dict for a matched (non-ignore) rule."""
|
||||
rule = match.rule
|
||||
lifetime = rule.get("lifetime")
|
||||
basename = Path(rel_path).name
|
||||
|
||||
sig: dict = {
|
||||
"name": _LIFECYCLE_SIGNAL_NAME,
|
||||
"rule_ref": rule.get("glob"),
|
||||
"lifetime": lifetime,
|
||||
"detail": f"matched lifecycle rule {rule.get('glob')!r} (lifetime={lifetime})",
|
||||
}
|
||||
if rule.get("extract"):
|
||||
sig["extract"] = True
|
||||
|
||||
served_when_path = rule.get("served_when_path")
|
||||
served_when = rule.get("served_when")
|
||||
if served_when_path:
|
||||
resolved = _substitute_served_when_path(served_when_path, basename)
|
||||
satisfied = (self._root / resolved).exists()
|
||||
sig["served"] = {
|
||||
"kind": "scanner-proven",
|
||||
"served_when_path": served_when_path,
|
||||
"resolved_path": resolved,
|
||||
"satisfied": satisfied,
|
||||
}
|
||||
elif served_when:
|
||||
sig["served"] = {
|
||||
"kind": "classifier-judged",
|
||||
"served_when": served_when,
|
||||
}
|
||||
|
||||
if lifetime == "temporary":
|
||||
sig["age_days"] = self.age_days(rel_path, is_dir)
|
||||
sig["retain_recent"] = rule.get("retain_recent", 3)
|
||||
sig["max_age_days"] = rule.get("max_age_days", 3)
|
||||
|
||||
return sig
|
||||
|
||||
|
||||
def rank_temporary_matches(entries: List[dict]) -> None:
|
||||
"""Mutate *entries*' lifecycle signal dicts in-place with retention info.
|
||||
|
||||
*entries* is a list of ``{"rule": <rule dict>, "signal": <sig dict>}``
|
||||
covering every temporary-lifetime match produced during a single scan.
|
||||
Grouped by rule identity (the same rule dict object is shared across all
|
||||
its matches within one Rulebook), ranked newest-first by age_days: the
|
||||
top ``retain_recent`` are always kept; rank ``retain_recent + 1`` or
|
||||
older is flagged deletable once it exceeds ``max_age_days``.
|
||||
"""
|
||||
groups: dict = {}
|
||||
for entry in entries:
|
||||
groups.setdefault(id(entry["rule"]), []).append(entry)
|
||||
|
||||
for group in groups.values():
|
||||
# Newest first: smallest age_days first. None age sorts last.
|
||||
group.sort(key=lambda e: (e["signal"].get("age_days") is None, e["signal"].get("age_days") or 0.0))
|
||||
retain_recent = group[0]["signal"].get("retain_recent", 3) if group else 3
|
||||
max_age_days = group[0]["signal"].get("max_age_days", 3) if group else 3
|
||||
for rank, entry in enumerate(group, start=1):
|
||||
sig = entry["signal"]
|
||||
age = sig.get("age_days")
|
||||
kept_by_rank = rank <= retain_recent
|
||||
deletable = (not kept_by_rank) and (age is not None) and (age > max_age_days)
|
||||
sig["retention"] = {
|
||||
"rank": rank,
|
||||
"kept": kept_by_rank,
|
||||
"deletable": deletable,
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Scanner
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
@ -586,6 +750,8 @@ class Scanner:
|
|||
max_lines: int = DEFAULT_MAX_LINES,
|
||||
max_tokens: int = DEFAULT_MAX_TOKENS,
|
||||
token_estimator: Optional[TokenEstimator] = None,
|
||||
rulebook: Optional["Any"] = None,
|
||||
git_commit_time_fn: Optional[Callable[[Path], Optional[str]]] = None,
|
||||
) -> None:
|
||||
self._root = root.resolve()
|
||||
self._scope_globs = scope_globs if scope_globs is not None else DEFAULT_SCOPE_GLOBS
|
||||
|
|
@ -623,6 +789,15 @@ class Scanner:
|
|||
self._max_tokens = max_tokens
|
||||
self._token_estimator = token_estimator
|
||||
self._ignore_patterns: List[str] = _load_ignore_patterns(self._root)
|
||||
self._rulebook = rulebook
|
||||
self._lifecycle_builder = LifecycleSignalBuilder(
|
||||
root=self._root,
|
||||
git_commit_time_fn=git_commit_time_fn,
|
||||
now_fn=now_fn,
|
||||
)
|
||||
# Directory-rule aggregate entries pruned during the walk (design.md
|
||||
# Decision 2): populated by _walk_scoped, consumed by run().
|
||||
self._pruned_dir_matches: List[tuple] = [] # (rel_path, RuleMatch)
|
||||
|
||||
def _is_excluded_child(self, parent_dirpath: str, child_name: str) -> bool:
|
||||
"""Return True if walking would prune *child_name* under *parent_dirpath*.
|
||||
|
|
@ -658,6 +833,10 @@ class Scanner:
|
|||
files_scanned = 0
|
||||
shortlist: List[str] = []
|
||||
signals_map: dict = {}
|
||||
# Collected for the post-walk retention-ranking pass (design.md
|
||||
# Decision 4): every temporary-lifetime match, dir or file, in one
|
||||
# flat list so rank_temporary_matches can group by rule identity.
|
||||
temporary_entries: List[dict] = []
|
||||
|
||||
for path in self._walk_scoped():
|
||||
files_scanned += 1
|
||||
|
|
@ -677,10 +856,46 @@ class Scanner:
|
|||
|
||||
# Survived exclusions — compute signals
|
||||
sigs = signal_computer.compute(path)
|
||||
|
||||
# Rulebook file-rule lifecycle signal attachment (design.md
|
||||
# Decision 2): alongside any pre-existing objective signals.
|
||||
if self._rulebook is not None:
|
||||
match = self._rulebook.query(rel, is_dir=False)
|
||||
if match is not None and not match.is_ignore:
|
||||
lifecycle_sig = self._lifecycle_builder.build(
|
||||
rel, match, is_dir=False
|
||||
)
|
||||
sigs = list(sigs) + [lifecycle_sig]
|
||||
if lifecycle_sig.get("lifetime") == "temporary":
|
||||
temporary_entries.append(
|
||||
{"rule": match.rule, "signal": lifecycle_sig}
|
||||
)
|
||||
|
||||
shortlist.append(rel)
|
||||
if sigs:
|
||||
signals_map[rel] = sigs
|
||||
|
||||
# Directory-rule aggregate entries pruned during the walk: exactly
|
||||
# one shortlist/signal entry per matched directory (design.md
|
||||
# Decision 2) — never one entry per file beneath it (those files
|
||||
# were never opened).
|
||||
for dir_rel, match in self._pruned_dir_matches:
|
||||
lifecycle_sig = self._lifecycle_builder.build(
|
||||
dir_rel, match, is_dir=True
|
||||
)
|
||||
shortlist.append(dir_rel)
|
||||
signals_map[dir_rel] = [lifecycle_sig]
|
||||
if lifecycle_sig.get("lifetime") == "temporary":
|
||||
temporary_entries.append(
|
||||
{"rule": match.rule, "signal": lifecycle_sig}
|
||||
)
|
||||
|
||||
# Retention ranking requires every match for a rule to be known
|
||||
# first, so this runs once, after the full walk (design.md
|
||||
# Decision 4).
|
||||
if temporary_entries:
|
||||
rank_temporary_matches(temporary_entries)
|
||||
|
||||
return {
|
||||
"project_root": str(self._root),
|
||||
"scope_globs": self._scope_globs,
|
||||
|
|
@ -696,6 +911,7 @@ class Scanner:
|
|||
|
||||
def _walk_scoped(self) -> Iterator[Path]:
|
||||
"""Yield files matching scope_globs, with excluded dirs pruned at walk time."""
|
||||
self._pruned_dir_matches = []
|
||||
for dirpath, dirnames, filenames in os.walk(str(self._root)):
|
||||
# Prune excluded dirs IN-PLACE so os.walk never descends them (D7).
|
||||
# Path-aware pairs (e.g. examples/golden) are matched against the
|
||||
|
|
@ -704,6 +920,25 @@ class Scanner:
|
|||
d for d in dirnames if not self._is_excluded_child(dirpath, d)
|
||||
]
|
||||
|
||||
# Rulebook-driven directory-rule pruning (design.md Decision 2).
|
||||
# Checked BEFORE opening any file beneath a matched directory —
|
||||
# a directory-rule match (including IGNORE-surface entries) is
|
||||
# pruned from dirnames so os.walk never descends into it.
|
||||
if self._rulebook is not None:
|
||||
survivors: List[str] = []
|
||||
for d in dirnames:
|
||||
dir_abs = Path(dirpath) / d
|
||||
dir_rel = str(dir_abs.relative_to(self._root))
|
||||
match = self._rulebook.query(dir_rel, is_dir=True)
|
||||
if match is None:
|
||||
survivors.append(d)
|
||||
continue
|
||||
# Matched: prune regardless of is_ignore.
|
||||
if not match.is_ignore:
|
||||
self._pruned_dir_matches.append((dir_rel, match))
|
||||
# IGNORE-surface matches: pruned with zero emission.
|
||||
dirnames[:] = survivors
|
||||
|
||||
for filename in filenames:
|
||||
filepath = Path(dirpath) / filename
|
||||
rel = filepath.relative_to(self._root)
|
||||
|
|
@ -771,6 +1006,7 @@ def main(argv: Optional[List[str]] = None) -> int:
|
|||
scope_globs=args.globs,
|
||||
excluded_dirs=args.excluded_dirs,
|
||||
git_log_fn=_git_log_real,
|
||||
git_commit_time_fn=_git_commit_time_real,
|
||||
max_lines=args.max_lines,
|
||||
max_tokens=args.max_tokens,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -18,17 +18,52 @@ Output: structured JSON on stdout with keys:
|
|||
import json
|
||||
import sys
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
from typing import Any, Optional
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Safety-tier derivation (invariant #10)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def derive_safety_tier(op_type: str, is_destructive: bool, is_reversible: bool) -> str:
|
||||
def _lifecycle_is_scanner_proven(lifecycle: dict) -> bool:
|
||||
"""True when the lifecycle evidence is a filesystem-provable fact.
|
||||
|
||||
A `temporary`-lifetime match is scanner-computed (retain-recent/age); a
|
||||
`served_when_path` hit is scanner-verified against the filesystem. A
|
||||
`served_when` (free text) is always classifier-judged, never proven.
|
||||
"""
|
||||
if lifecycle.get("lifetime") == "temporary":
|
||||
return True
|
||||
if lifecycle.get("served_when_path"):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def derive_safety_tier(
|
||||
op_type: str,
|
||||
is_destructive: bool,
|
||||
is_reversible: bool,
|
||||
lifecycle: "dict | None" = None,
|
||||
) -> str:
|
||||
"""
|
||||
Deterministic derivation of safety_tier from op characterisation.
|
||||
Never returns 'auto' for a generative, destructive, or irreversible op.
|
||||
|
||||
ADR-0039 lifecycle branch: when `lifecycle` is supplied (i.e. this is a
|
||||
`delete`/`extract-then-delete` op), the tier is derived from evidence
|
||||
quality + runtime git state instead of (is_destructive, is_reversible) —
|
||||
`auto` only when the lifecycle evidence is scanner-proven AND the path is
|
||||
tracked AND clean; every other combination (dirty, untracked, or
|
||||
classifier-judged `served_when`) forces `confirm`, regardless of the
|
||||
is_destructive/is_reversible inputs. This is additive: the existing
|
||||
non-lifecycle branches below are unchanged.
|
||||
"""
|
||||
if lifecycle is not None:
|
||||
git_state = lifecycle.get("git_state") or {}
|
||||
tracked = bool(git_state.get("tracked"))
|
||||
clean = bool(git_state.get("clean"))
|
||||
if _lifecycle_is_scanner_proven(lifecycle) and tracked and clean:
|
||||
return "auto"
|
||||
return "confirm"
|
||||
if op_type == "generative":
|
||||
return "confirm"
|
||||
if is_destructive:
|
||||
|
|
@ -81,11 +116,32 @@ KIND_TABLE: dict[str, KindSpec] = {
|
|||
required_fields=["anchor", "canonical_ref"],
|
||||
has_anchor=True,
|
||||
),
|
||||
"delete": KindSpec(
|
||||
# is_destructive is fixed (git history is the only recovery path);
|
||||
# is_reversible is a nominal True here — its REAL characterisation is
|
||||
# git-history-dependent and is resolved by the lifecycle branch of
|
||||
# derive_safety_tier, not by this fixed table value (report-schema
|
||||
# spec, "Exact-Edit Kind Is a Closed Enum").
|
||||
is_destructive=True,
|
||||
is_reversible=True,
|
||||
required_fields=["anchor", "lifecycle"],
|
||||
has_anchor=True,
|
||||
),
|
||||
"extract-then-delete": KindSpec(
|
||||
is_destructive=True,
|
||||
is_reversible=True,
|
||||
required_fields=["anchor", "lifecycle", "extraction_target"],
|
||||
has_anchor=True,
|
||||
),
|
||||
}
|
||||
|
||||
STALE_SUBTYPES = {"contradicted", "orphaned", "superseded", "provisional", "completed-in-place", "duplicated"}
|
||||
BLOAT_SUBTYPES = {"distill", "split", "freeze"}
|
||||
|
||||
_LIFECYCLE_LIFETIMES = {"keep", "temporary", "delete-once-served"}
|
||||
_LIFECYCLE_OP_KINDS = {"delete", "extract-then-delete"}
|
||||
_EXTRACTION_TARGETS = {"repo-durable", "cross-repo"}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Validator
|
||||
|
|
@ -130,10 +186,17 @@ class ReportValidator:
|
|||
else:
|
||||
self._add(f"entries[{i}]", "Entry must be a JSON object")
|
||||
|
||||
# Rule: promotion_candidates section (report-schema delta spec).
|
||||
if "promotion_candidates" in r:
|
||||
self._check_promotion_candidates(r["promotion_candidates"])
|
||||
|
||||
return self._violations
|
||||
|
||||
def _check_envelope(self, r: dict) -> None:
|
||||
for key in ("schema_version", "tool_version", "generated_at", "scan", "shortlist", "entries"):
|
||||
for key in (
|
||||
"schema_version", "tool_version", "generated_at", "scan",
|
||||
"shortlist", "entries", "promotion_candidates",
|
||||
):
|
||||
if key not in r:
|
||||
self._add(key, f"Required top-level field '{key}' is missing")
|
||||
|
||||
|
|
@ -185,9 +248,17 @@ class ReportValidator:
|
|||
if is_reversible is not None and not isinstance(is_reversible, bool):
|
||||
self._add(f"{pfx}.is_reversible", "is_reversible must be a boolean")
|
||||
|
||||
# entry-level lifecycle signal (report-schema: "Lifecycle Signal
|
||||
# Fields on Shortlist and Entries") — optional; validated when present,
|
||||
# regardless of op kind (a `keep`/`temporary` entry may carry it with
|
||||
# no exact_edit at all).
|
||||
if "lifecycle" in entry:
|
||||
self._check_lifecycle(entry["lifecycle"], f"{pfx}.lifecycle", require_git_state=False)
|
||||
|
||||
# Rules 6, 7, 8, 9: exact_edit checks (only if op_type valid)
|
||||
entry_lifecycle_for_tier = None
|
||||
if op_type in ("deterministic", "generative"):
|
||||
self._check_exact_edit_biconditional(entry, pfx, op_type)
|
||||
entry_lifecycle_for_tier = self._check_exact_edit_biconditional(entry, pfx, op_type)
|
||||
|
||||
# Rule 9: safety_tier derivation match (only when all inputs are valid)
|
||||
if (
|
||||
|
|
@ -196,12 +267,15 @@ class ReportValidator:
|
|||
and isinstance(is_reversible, bool)
|
||||
and safety_tier in ("auto", "confirm")
|
||||
):
|
||||
expected_tier = derive_safety_tier(op_type, is_destructive, is_reversible)
|
||||
expected_tier = derive_safety_tier(
|
||||
op_type, is_destructive, is_reversible, lifecycle=entry_lifecycle_for_tier
|
||||
)
|
||||
if safety_tier != expected_tier:
|
||||
self._add(
|
||||
f"{pfx}.safety_tier",
|
||||
f"safety_tier mismatch: recorded '{safety_tier}' but derivation yields '{expected_tier}' "
|
||||
f"for (op_type={op_type}, is_destructive={is_destructive}, is_reversible={is_reversible})",
|
||||
f"for (op_type={op_type}, is_destructive={is_destructive}, is_reversible={is_reversible}, "
|
||||
f"lifecycle={'present' if entry_lifecycle_for_tier is not None else 'none'})",
|
||||
)
|
||||
|
||||
# Rule 10: token_estimate.raw_tokens required
|
||||
|
|
@ -209,6 +283,78 @@ class ReportValidator:
|
|||
if token_estimate is not None:
|
||||
self._check_token_estimate(token_estimate, pfx)
|
||||
|
||||
def _check_promotion_candidates(self, candidates: Any) -> None:
|
||||
if not isinstance(candidates, list):
|
||||
self._add("promotion_candidates", "promotion_candidates must be an array")
|
||||
return
|
||||
for i, cand in enumerate(candidates):
|
||||
pfx = f"promotion_candidates[{i}]"
|
||||
if not isinstance(cand, dict):
|
||||
self._add(pfx, "candidate must be a JSON object")
|
||||
continue
|
||||
for field_name in ("path", "name", "pitch"):
|
||||
value = cand.get(field_name)
|
||||
if not isinstance(value, str) or not value:
|
||||
self._add(f"{pfx}.{field_name}", f"candidate '{field_name}' must be a non-empty string")
|
||||
|
||||
def _check_lifecycle(
|
||||
self, lifecycle: Any, pfx: str, *, require_git_state: bool
|
||||
) -> Optional[dict]:
|
||||
"""Validate a lifecycle object's shape (rule_ref, lifetime, exactly
|
||||
one served field per lifetime, optional/required git_state).
|
||||
|
||||
Returns the lifecycle dict when it is well-formed enough to feed
|
||||
`derive_safety_tier` (or None if too malformed to trust).
|
||||
"""
|
||||
if not isinstance(lifecycle, dict):
|
||||
self._add(pfx, "lifecycle must be a JSON object")
|
||||
return None
|
||||
|
||||
rule_ref = lifecycle.get("rule_ref")
|
||||
if not isinstance(rule_ref, str) or not rule_ref:
|
||||
self._add(f"{pfx}.rule_ref", "lifecycle.rule_ref is required and must be a non-empty string")
|
||||
|
||||
lifetime = lifecycle.get("lifetime")
|
||||
if lifetime not in _LIFECYCLE_LIFETIMES:
|
||||
self._add(
|
||||
f"{pfx}.lifetime",
|
||||
f"lifecycle.lifetime must be one of {sorted(_LIFECYCLE_LIFETIMES)}, got {lifetime!r}",
|
||||
)
|
||||
|
||||
has_served_when_path = "served_when_path" in lifecycle and lifecycle["served_when_path"] is not None
|
||||
has_served_when = "served_when" in lifecycle and lifecycle["served_when"] is not None
|
||||
|
||||
if lifetime == "delete-once-served":
|
||||
if has_served_when_path and has_served_when:
|
||||
self._add(
|
||||
f"{pfx}",
|
||||
"lifecycle must carry exactly one of served_when_path or served_when, not both",
|
||||
)
|
||||
elif not has_served_when_path and not has_served_when:
|
||||
self._add(
|
||||
f"{pfx}",
|
||||
"lifecycle with lifetime 'delete-once-served' requires exactly one of "
|
||||
"served_when_path or served_when",
|
||||
)
|
||||
elif lifetime in ("keep", "temporary"):
|
||||
if has_served_when_path or has_served_when:
|
||||
self._add(
|
||||
f"{pfx}",
|
||||
f"lifecycle with lifetime {lifetime!r} must not carry served_when_path or served_when "
|
||||
"(age/keep entries are not served-keyed)",
|
||||
)
|
||||
|
||||
if require_git_state:
|
||||
git_state = lifecycle.get("git_state")
|
||||
if not isinstance(git_state, dict):
|
||||
self._add(f"{pfx}.git_state", "lifecycle.git_state is required for delete/extract-then-delete")
|
||||
else:
|
||||
for k in ("tracked", "clean"):
|
||||
if not isinstance(git_state.get(k), bool):
|
||||
self._add(f"{pfx}.git_state.{k}", f"lifecycle.git_state.{k} must be a boolean")
|
||||
|
||||
return lifecycle
|
||||
|
||||
def _check_category(self, category: Any, pfx: str) -> None:
|
||||
if not isinstance(category, dict):
|
||||
self._add(f"{pfx}.category", "category must be a JSON object with 'class' and 'subtype'")
|
||||
|
|
@ -235,8 +381,13 @@ class ReportValidator:
|
|||
f"For class 'bloat', subtype must be one of {sorted(BLOAT_SUBTYPES)}, got '{subtype}'",
|
||||
)
|
||||
|
||||
def _check_exact_edit_biconditional(self, entry: dict, pfx: str, op_type: str) -> None:
|
||||
"""Rule 6: exact_edit present IFF op_type == deterministic."""
|
||||
def _check_exact_edit_biconditional(self, entry: dict, pfx: str, op_type: str) -> Optional[dict]:
|
||||
"""Rule 6: exact_edit present IFF op_type == deterministic.
|
||||
|
||||
Returns the exact_edit's validated `lifecycle` dict (for delete /
|
||||
extract-then-delete kinds) so the caller can feed it to
|
||||
`derive_safety_tier`; None otherwise.
|
||||
"""
|
||||
has_exact_edit = "exact_edit" in entry
|
||||
exact_edit = entry.get("exact_edit")
|
||||
|
||||
|
|
@ -245,35 +396,36 @@ class ReportValidator:
|
|||
f"{pfx}.exact_edit",
|
||||
"generative entry must NOT carry exact_edit (invariant #11)",
|
||||
)
|
||||
return # No point validating the structure of an edit that shouldn't exist
|
||||
return None # No point validating the structure of an edit that shouldn't exist
|
||||
|
||||
if op_type == "deterministic" and not has_exact_edit:
|
||||
self._add(
|
||||
f"{pfx}.exact_edit",
|
||||
"deterministic entry MUST carry exact_edit (invariant #11)",
|
||||
)
|
||||
return
|
||||
return None
|
||||
|
||||
if op_type == "deterministic" and has_exact_edit:
|
||||
self._check_exact_edit(exact_edit, pfx, entry)
|
||||
return self._check_exact_edit(exact_edit, pfx, entry)
|
||||
return None
|
||||
|
||||
def _check_exact_edit(self, exact_edit: Any, pfx: str, entry: dict) -> None:
|
||||
def _check_exact_edit(self, exact_edit: Any, pfx: str, entry: dict) -> Optional[dict]:
|
||||
"""Rules 7 and 8: kind enum, per-kind required fields, characterisation match."""
|
||||
if not isinstance(exact_edit, dict):
|
||||
self._add(f"{pfx}.exact_edit", "exact_edit must be a JSON object")
|
||||
return
|
||||
return None
|
||||
|
||||
kind = exact_edit.get("kind")
|
||||
if kind is None:
|
||||
self._add(f"{pfx}.exact_edit.kind", "exact_edit.kind is required")
|
||||
return
|
||||
return None
|
||||
|
||||
if kind not in KIND_TABLE:
|
||||
self._add(
|
||||
f"{pfx}.exact_edit.kind",
|
||||
f"Unknown exact_edit.kind '{kind}'; valid kinds: {sorted(KIND_TABLE)}",
|
||||
)
|
||||
return
|
||||
return None
|
||||
|
||||
spec = KIND_TABLE[kind]
|
||||
|
||||
|
|
@ -285,6 +437,15 @@ class ReportValidator:
|
|||
f"exact_edit of kind '{kind}' requires field '{req_field}'",
|
||||
)
|
||||
|
||||
lifecycle_for_tier: Optional[dict] = None
|
||||
if kind in _LIFECYCLE_OP_KINDS:
|
||||
if "lifecycle" in exact_edit:
|
||||
lifecycle_for_tier = self._check_lifecycle(
|
||||
exact_edit["lifecycle"], f"{pfx}.exact_edit.lifecycle", require_git_state=True
|
||||
)
|
||||
if kind == "extract-then-delete":
|
||||
self._check_extraction_target(exact_edit, pfx)
|
||||
|
||||
# Anchor must have start_line and end_line
|
||||
if spec.has_anchor and "anchor" in exact_edit:
|
||||
anchor = exact_edit["anchor"]
|
||||
|
|
@ -318,6 +479,24 @@ class ReportValidator:
|
|||
f"kind='{kind}' requires is_reversible={spec.is_reversible}, got {entry_is_reversible}",
|
||||
)
|
||||
|
||||
return lifecycle_for_tier
|
||||
|
||||
def _check_extraction_target(self, exact_edit: dict, pfx: str) -> None:
|
||||
target = exact_edit.get("extraction_target")
|
||||
if target not in _EXTRACTION_TARGETS:
|
||||
self._add(
|
||||
f"{pfx}.exact_edit.extraction_target",
|
||||
f"extraction_target must be one of {sorted(_EXTRACTION_TARGETS)}, got {target!r}",
|
||||
)
|
||||
return
|
||||
if target == "repo-durable":
|
||||
dest = exact_edit.get("extraction_dest")
|
||||
if not isinstance(dest, str) or not dest:
|
||||
self._add(
|
||||
f"{pfx}.exact_edit.extraction_dest",
|
||||
"extraction_target 'repo-durable' requires a non-empty 'extraction_dest'",
|
||||
)
|
||||
|
||||
def _check_token_estimate(self, token_estimate: Any, pfx: str) -> None:
|
||||
if not isinstance(token_estimate, dict):
|
||||
self._add(f"{pfx}.token_estimate", "token_estimate must be a JSON object")
|
||||
|
|
|
|||
|
|
@ -0,0 +1,568 @@
|
|||
---
|
||||
description: Learn new lifecycle rules for a project by clustering unmatched files, nominating candidate globs (cheap model), and having a strong model judge and confirm/reject/amend them, with a mandatory rule report to the human before any persistence. Invoked by `/os-doc-hygiene:calibrate`.
|
||||
---
|
||||
|
||||
# Hygiene Calibrate Skill
|
||||
|
||||
Orchestrates the learn-new-rules loop (lifecycle-spec.md §8): **cluster-and-
|
||||
sample → cheap-model nominate → deterministic intake filter (drop repeat
|
||||
rejections, carry related rejections + open consults forward) → strong-
|
||||
model judge → rule report (human, including open consults) → persist →
|
||||
retest**. It runs over the **unmatched pool** (unmatched = unmanaged = not
|
||||
governed by any existing rulebook rule, per `rulebook.py`), and is the only
|
||||
new skill this change adds — `check`/`clean` are unchanged in structure
|
||||
(ADR-0039/-0041, `lifecycle-spec.md` §7).
|
||||
|
||||
All scripts live under `${CLAUDE_PLUGIN_ROOT}/scripts/`. Run them with
|
||||
`python3` from the user's project directory (`cwd`). Use the session
|
||||
scratchpad directory for all intermediate artifacts.
|
||||
|
||||
> **Precondition:** requires `CLAUDE_PLUGIN_ROOT`. Every script path resolves
|
||||
> against it; abort rather than guessing a path if it is unset.
|
||||
|
||||
> Pick a scratch dir once and reuse it: `SCRATCH="$(mktemp -d)"`.
|
||||
|
||||
## What this skill NEVER does
|
||||
|
||||
- It never applies a rule without the human having seen the Step 4 rule
|
||||
report first (spec: "no rule shall be persisted before this report has
|
||||
been shown").
|
||||
- It never removes a rule automatically — removals are HITL-only in all
|
||||
cases, with recorded reasoning (spec §5).
|
||||
- It never writes to the global `plugins/os-doc-hygiene/rulebook.json`
|
||||
without an explicit, distinct confirmation beyond project-rule
|
||||
confirmation (cross-repo write into cc-os).
|
||||
- It never applies a drafted convention adoption (§6 below) without explicit
|
||||
human confirmation.
|
||||
|
||||
---
|
||||
|
||||
## Workflow
|
||||
|
||||
### Step 1 — (D) Load the rulebook and scan for the unmatched pool
|
||||
|
||||
Same rulebook-load pattern as `check`'s Step 0.5, but here the candidate pool
|
||||
is the **unmatched** paths — files the current rulebook leaves ungoverned —
|
||||
not the signal-bearing shortlist.
|
||||
|
||||
```bash
|
||||
export SCRATCH
|
||||
python3 -c '
|
||||
import json, os, sys
|
||||
from pathlib import Path
|
||||
sys.path.insert(0, os.environ["CLAUDE_PLUGIN_ROOT"] + "/scripts")
|
||||
from rulebook import load_rulebook, RulebookLoadError
|
||||
from scanner import Scanner, _resolve_project_root, _git_log_real, _git_commit_time_real
|
||||
|
||||
root = _resolve_project_root(Path.cwd())
|
||||
project_rules = root / ".dochygiene-rules.json"
|
||||
try:
|
||||
rulebook = load_rulebook(project_path=project_rules if project_rules.is_file() else None)
|
||||
except RulebookLoadError as exc:
|
||||
print(json.dumps({"error": "rulebook-load-failed", "detail": str(exc)}))
|
||||
sys.exit(2)
|
||||
|
||||
scanner = Scanner(
|
||||
root=root, rulebook=rulebook,
|
||||
git_log_fn=_git_log_real, git_commit_time_fn=_git_commit_time_real,
|
||||
)
|
||||
artifact = scanner.run()
|
||||
|
||||
# The unmatched pool is every file the scan encountered that carries no
|
||||
# lifecycle signal AND is not itself an IGNORE-pruned/directory-rule
|
||||
# aggregate entry -- i.e. shortlist entries with no rulebook governance.
|
||||
signals = artifact.get("signals", {})
|
||||
unmatched = [p for p in artifact.get("shortlist", []) if p not in signals]
|
||||
|
||||
Path(os.environ["SCRATCH"] + "/scan.json").write_text(json.dumps(artifact, indent=2))
|
||||
Path(os.environ["SCRATCH"] + "/unmatched.json").write_text(json.dumps(unmatched, indent=2))
|
||||
print(f"unmatched pool: {len(unmatched)} paths")
|
||||
'
|
||||
```
|
||||
|
||||
- Exit `2` / rulebook load failure → hard STOP, same as `check` Step 0.5. Do
|
||||
not proceed with a silently-empty rulebook.
|
||||
- Note: `signals` here means ANY signal (stale/bloat/lifecycle) — a file with
|
||||
a stale/bloat signal but no lifecycle rule match is still "unmatched" with
|
||||
respect to the rulebook, and belongs in the pool. Filter precisely on
|
||||
`lifecycle`-named signals if the project has files carrying only
|
||||
non-lifecycle signals that should stay in the pool:
|
||||
|
||||
```python
|
||||
unmatched = [
|
||||
p for p in artifact["shortlist"]
|
||||
if not any(s.get("name") == "lifecycle" for s in artifact.get("signals", {}).get(p, []))
|
||||
]
|
||||
```
|
||||
|
||||
Use this refined filter, not the simpler one above, when `signals` may carry
|
||||
non-lifecycle entries for shortlisted paths.
|
||||
|
||||
If `unmatched` is empty → report "Nothing to calibrate — every shortlisted
|
||||
file is already governed by a rulebook rule." **STOP.**
|
||||
|
||||
---
|
||||
|
||||
### Step 2 — (D) Cluster and sample — `calibrate_helpers.ClusterSampler`
|
||||
|
||||
Deterministic, no model. Groups unmatched paths by path-shape (directory
|
||||
prefix + filename shape class — digit runs collapse to `#`, hex-looking runs
|
||||
collapse to `~`) and samples representatives per cluster, capped.
|
||||
|
||||
```bash
|
||||
python3 -c '
|
||||
import json, os, sys
|
||||
from pathlib import Path
|
||||
sys.path.insert(0, os.environ["CLAUDE_PLUGIN_ROOT"] + "/scripts")
|
||||
from calibrate_helpers import ClusterSampler
|
||||
|
||||
unmatched = json.loads(Path(os.environ["SCRATCH"] + "/unmatched.json").read_text())
|
||||
clusters = ClusterSampler().cluster_to_dicts(unmatched)
|
||||
Path(os.environ["SCRATCH"] + "/clusters.json").write_text(json.dumps(clusters, indent=2))
|
||||
print(f"{len(clusters)} clusters")
|
||||
'
|
||||
```
|
||||
|
||||
Each cluster is `{key, dir_prefix, shape, total, sample}`. Rules are always
|
||||
proposed against a cluster, never a single instance in isolation (spec: "the
|
||||
nomination is derived from a cluster of similar unmatched paths").
|
||||
|
||||
---
|
||||
|
||||
### Step 3 — (M) Cheap-model nomination — **haiku subagent, one per cluster**
|
||||
|
||||
For each cluster, dispatch a **haiku** subagent (LOOP-GUARD: point it at
|
||||
`workflows/nominate.md`, never this SKILL.md) constrained to nominate a bare
|
||||
glob pattern + candidate lifetime — patterns only, never an exact-instance
|
||||
glob (a run-id, hash, or bare timestamp hardcoded into the glob).
|
||||
|
||||
```
|
||||
Agent tool parameters:
|
||||
- subagent_type: "general-purpose"
|
||||
- model: haiku
|
||||
- description: "Nominate lifecycle rule for cluster: <cluster.key>"
|
||||
- prompt: |
|
||||
Read and follow the workflow at:
|
||||
${CLAUDE_PLUGIN_ROOT}/skills/calibrate/workflows/nominate.md
|
||||
|
||||
Cluster: <cluster.dir_prefix> / shape <cluster.shape>
|
||||
Total matching paths: <cluster.total>
|
||||
Sample paths:
|
||||
<cluster.sample, one per line>
|
||||
|
||||
Return ONLY the JSON object specified in the workflow.
|
||||
```
|
||||
|
||||
Collect all nominations into `"$SCRATCH/nominations.json"` (array, one per
|
||||
cluster, tagged with the originating `cluster.key`).
|
||||
|
||||
**Do not trust a haiku nomination as final.** The "class, never path"
|
||||
rule-quality test is enforced by the strong-model judge (Step 4) plus the
|
||||
deterministic `RuleQualityChecker` (Step 5's report), never accepted from
|
||||
haiku at face value.
|
||||
|
||||
---
|
||||
|
||||
### Step 3.5 — (D) Nomination intake filter — `calibrate_helpers.NominationIntakeFilter`
|
||||
|
||||
Deterministic, no model (lifecycle-spec.md §8 step 3;
|
||||
`NominationIntakeFilter` requirement). Reads the project rules file's
|
||||
`nominations` key via `RulesFileWriter.load`, then drops any nomination that
|
||||
exactly repeats a `rejected` glob+lifetime, annotates survivors with every
|
||||
*related* rejection (match-set intersection on the current shortlist), and
|
||||
passes ALL open consults through unconditionally — this is the input the
|
||||
judge prompt's "Nominations memory" section (Step 4) consumes.
|
||||
|
||||
```bash
|
||||
python3 -c '
|
||||
import json, os, sys
|
||||
from pathlib import Path
|
||||
sys.path.insert(0, os.environ["CLAUDE_PLUGIN_ROOT"] + "/scripts")
|
||||
from calibrate_helpers import RulesFileWriter, NominationIntakeFilter
|
||||
|
||||
scan = json.loads(Path(os.environ["SCRATCH"] + "/scan.json").read_text())
|
||||
project_rules = Path(scan["project_root"]) / ".dochygiene-rules.json"
|
||||
nominations = json.loads(Path(os.environ["SCRATCH"] + "/nominations.json").read_text())
|
||||
shortlist = scan.get("shortlist", [])
|
||||
|
||||
writer = RulesFileWriter()
|
||||
data, load_warnings = writer.load(project_rules)
|
||||
memory = data.get("nominations", {})
|
||||
|
||||
result = NominationIntakeFilter(
|
||||
rejected=memory.get("rejected", []),
|
||||
consults=memory.get("consults", []),
|
||||
).filter(nominations, shortlist)
|
||||
|
||||
Path(os.environ["SCRATCH"] + "/intake.json").write_text(json.dumps(result, indent=2))
|
||||
print(f"{len(result[\"survivors\"])} survivors, {len(result[\"dropped\"])} dropped, {len(result[\"consults\"])} open consults")
|
||||
if result["dropped"]:
|
||||
for d in result["dropped"]:
|
||||
print(f" dropped: {d[\"glob\"]} -> {d[\"lifetime\"]} ({d[\"reason\"]})")
|
||||
'
|
||||
```
|
||||
|
||||
- **Surface every drop in the run summary** shown to the human alongside the
|
||||
Step 5 report — a dropped nomination never reaches the judge, so this is
|
||||
the only place it is visible.
|
||||
- `intake.json`'s `survivors` (each nomination plus its `related_rejections`
|
||||
annotation) and `consults` (all open consults, unconditionally) are what
|
||||
gets embedded in Step 4's judge prompt as the "Nominations memory" input
|
||||
section — feed the whole `survivors` array (not the raw
|
||||
`nominations.json`) forward into Step 4, and include `consults` even when
|
||||
empty (an empty array is a valid, meaningful "no open consults" signal).
|
||||
|
||||
---
|
||||
|
||||
### Step 4 — (M) Strong-model batched judgment — **ONE Opus/Fable subagent**
|
||||
|
||||
Dispatch a **single batched** strong-model subagent (`model: opus`, or the
|
||||
project's configured Fable-tier model) to judge ALL nominations from Step 3.5
|
||||
in one call (LOOP-GUARD: point it at `workflows/judge.md`, never this
|
||||
SKILL.md). The judge gathers its OWN evidence — re-reads matched paths
|
||||
against the live tree, checks near-miss boundaries — rather than trusting
|
||||
the haiku nomination's claims.
|
||||
|
||||
Judge on `intake.json`'s `survivors` (each nomination annotated with its
|
||||
`related_rejections`), never the raw `nominations.json` — the dropped
|
||||
exact-repeats never reach this step. `intake.json`'s `consults` is the
|
||||
"Open consults" input regardless of what haiku nominated this round.
|
||||
|
||||
```
|
||||
Agent tool parameters:
|
||||
- subagent_type: "general-purpose"
|
||||
- model: opus
|
||||
- description: "Judge doc-hygiene calibration nominations"
|
||||
- prompt: |
|
||||
Read and follow the workflow at:
|
||||
${CLAUDE_PLUGIN_ROOT}/skills/calibrate/workflows/judge.md
|
||||
|
||||
Project root: <scan.project_root>
|
||||
|
||||
Nominations to judge (verbatim, each with related_rejections):
|
||||
<contents of $SCRATCH/intake.json's "survivors">
|
||||
|
||||
Nominations memory — open consults (unconditional, may be empty):
|
||||
<contents of $SCRATCH/intake.json's "consults">
|
||||
|
||||
Seed intake: <see "Seed intake" below — include or omit per pass>
|
||||
|
||||
Return ONLY the JSON array of verdicts specified in the workflow.
|
||||
```
|
||||
|
||||
**Verdicts** are exactly one of `confirm` / `reject` / `amend` / `consult`.
|
||||
`consult` is MANDATORY whenever the judge cannot determine if an artifact is
|
||||
regenerable or must be retained — never resolved to `confirm` or `reject` in
|
||||
that case. Write the judge's verdict array to `"$SCRATCH/verdicts.json"`.
|
||||
|
||||
**Seed intake:** the #41 clutter-inventory seed candidates enter at THIS
|
||||
step (judge intake), for every calibration run **except** cc-os calibration
|
||||
pass #1, which withholds them as a sealed answer key (one-off carve-out, see
|
||||
`lifecycle-spec.md` §9). If this run IS cc-os pass #1, do NOT include seed
|
||||
candidates in the judge prompt. Every other run (including later cc-os runs)
|
||||
includes full seed intake.
|
||||
|
||||
---
|
||||
|
||||
### Step 5 — (D) Rule report to the human — BEFORE any persistence
|
||||
|
||||
Deterministic, no model — `calibrate_helpers.RuleReportBuilder` plus
|
||||
`RuleQualityChecker`. For every judge verdict of `confirm` or `amend` (never
|
||||
for `reject`/`consult` — those are not proposed for persistence), assemble
|
||||
the 5-element report and run the quality lints:
|
||||
|
||||
```bash
|
||||
python3 -c '
|
||||
import json, os, sys
|
||||
from pathlib import Path
|
||||
sys.path.insert(0, os.environ["CLAUDE_PLUGIN_ROOT"] + "/scripts")
|
||||
from calibrate_helpers import RuleReportBuilder, RuleQualityChecker
|
||||
|
||||
scan = json.loads(Path(os.environ["SCRATCH"] + "/scan.json").read_text())
|
||||
verdicts = json.loads(Path(os.environ["SCRATCH"] + "/verdicts.json").read_text())
|
||||
all_paths = scan.get("shortlist", [])
|
||||
|
||||
proposed = [v["rule"] for v in verdicts if v["verdict"] in ("confirm", "amend")]
|
||||
builder = RuleReportBuilder()
|
||||
checker = RuleQualityChecker()
|
||||
|
||||
report = []
|
||||
for rule in proposed:
|
||||
entry = builder.build(rule, all_paths).to_dict()
|
||||
entry["quality"] = {
|
||||
"class_not_path": checker.class_not_path(rule["glob"], all_paths).to_dict(),
|
||||
}
|
||||
report.append(entry)
|
||||
|
||||
Path(os.environ["SCRATCH"] + "/rule_report.json").write_text(json.dumps(report, indent=2))
|
||||
print(json.dumps(report, indent=2))
|
||||
'
|
||||
```
|
||||
|
||||
Render this to the human as **patterns and examples, not JSON schema** — per
|
||||
rule:
|
||||
|
||||
```
|
||||
Proposed rule: <glob verbatim>
|
||||
Lifetime: <lifetime> Tier: <auto|confirm>
|
||||
Matches (<total>): <sample paths, one per line> [+ N more]
|
||||
Near-miss (does NOT match, but looks similar): <near-miss paths, or "(none)">
|
||||
Why: <plain-language "what this is and why it's clutter">
|
||||
Quality check: <PASS, or the flagged reason if class_not_path failed>
|
||||
```
|
||||
|
||||
**If `class_not_path` failed** (a glob that can, by construction, only ever
|
||||
match one file — a failed generalization), flag it LOUDLY in the rendered
|
||||
report rather than silently dropping or persisting it; ask the human whether
|
||||
to have the judge re-amend it (loop back to Step 4 for that one rule) or
|
||||
drop it from this round.
|
||||
|
||||
#### Open consults section
|
||||
|
||||
List every entry from `$SCRATCH/intake.json`'s `consults` — the open
|
||||
consults from prior runs (plus any new `consult` verdict this run) — one per
|
||||
entry:
|
||||
|
||||
```
|
||||
Open consult: <glob>
|
||||
Asked: <asked_on> Cluster: <cluster_key>
|
||||
Question: <question>
|
||||
Evidence: <evidence>
|
||||
```
|
||||
|
||||
Each open consult has exactly three exits, all human, none of which is
|
||||
gated the way a rule confirmation is:
|
||||
|
||||
- **(a) Answer settles the purpose** — the human's answer determines a
|
||||
lifetime; persist a normal rule through this same report/Step 6 flow AND
|
||||
delete the consult entry, in the same write (the rule supersedes it).
|
||||
- **(b) Not rule-worthy** — the human decides the artifact class isn't a
|
||||
rule; rewrite the entry into `nominations.rejected` with
|
||||
`rejected_by: "human"` and the human's stated why.
|
||||
- **(c) Defer** — no answer this round; the entry stays as-is and resurfaces
|
||||
in the next `:calibrate` run's Step 3.5/4/5.
|
||||
|
||||
New rejections and consults produced this run (from judge `consult`
|
||||
verdicts, or from exit (b) above) appear in this report for visibility but
|
||||
are **not individually gated** — unlike proposed rules, they carry no
|
||||
deletion authority, only memory. Only proposed rules go through the "Persist
|
||||
these N project rules?" gate below.
|
||||
|
||||
**No rule is written anywhere until the human has seen this report and
|
||||
responded.** Ask: "Persist these N project rules? (yes / no / a subset by
|
||||
number)". Separately, resolve each open consult per its three exits above.
|
||||
Only proceed to Step 6 for the rules the human approves and the consults the
|
||||
human has settled or declined this round.
|
||||
|
||||
---
|
||||
|
||||
### Step 6 — (D) Persistence — canonical writer only
|
||||
|
||||
Every settled verdict persists through `calibrate_helpers.RulesFileWriter`
|
||||
(load → mutate the parsed dict → write) — no code path serializes
|
||||
`.dochygiene-rules.json` by hand. Before running this, translate the
|
||||
human's Step 5 responses into four in-memory lists (the orchestrating skill's
|
||||
own bookkeeping — there is no scratch file for these, since they come from
|
||||
the live conversation, not a subagent):
|
||||
|
||||
- `approved_rules` — the judge `confirm`/`amend` rule objects the human said
|
||||
yes to (including `keep`-lifetime rules and Step-5-exit-(a) rules that
|
||||
answer an open consult).
|
||||
- `declined` — `(rule, why)` pairs for judge-proposed rules the human said
|
||||
no to at the report.
|
||||
- `settled_consult_globs` — globs of open consults the human resolved this
|
||||
run, either via exit (a) (answered, folded into `approved_rules` above) or
|
||||
exit (b) (declined, folded into `new_human_rejections` below); these are
|
||||
removed from `nominations.consults` in the same write.
|
||||
- `new_human_rejections` — `(glob, lifetime, why)` for exit-(b) consults
|
||||
("not rule-worthy"), written with `rejected_by: "human"`.
|
||||
- `still_open_consults` — judge `consult` verdicts from THIS run
|
||||
(`{glob, question, evidence, cluster_key}`) that remain unresolved after
|
||||
Step 5 (exit (c), or simply not reached this round).
|
||||
|
||||
```bash
|
||||
python3 -c '
|
||||
import json, os, sys
|
||||
from datetime import date
|
||||
from pathlib import Path
|
||||
sys.path.insert(0, os.environ["CLAUDE_PLUGIN_ROOT"] + "/scripts")
|
||||
from calibrate_helpers import RulesFileWriter
|
||||
|
||||
scan = json.loads(Path(os.environ["SCRATCH"] + "/scan.json").read_text())
|
||||
project_rules = Path(scan["project_root"]) / ".dochygiene-rules.json"
|
||||
today = date.today().isoformat()
|
||||
|
||||
# approved_rules / declined / settled_consult_globs / new_human_rejections /
|
||||
# still_open_consults come from the human'"'"'s Step 5 responses (see above).
|
||||
|
||||
writer = RulesFileWriter()
|
||||
data, load_warnings = writer.load(project_rules)
|
||||
nominations = data.setdefault("nominations", {"consults": [], "rejected": []})
|
||||
|
||||
# 1. Judge confirm/amend verdicts the human approved -> plain rules
|
||||
# (keep verdicts and exact-path keep singletons allowed -- the keep-tier
|
||||
# relaxation). confirmed_by/confirmed_on are set here, never by the judge.
|
||||
for rule in approved_rules:
|
||||
rule["confirmed_by"] = "human"
|
||||
rule["confirmed_on"] = today
|
||||
data["rules"].append(rule)
|
||||
|
||||
# 2. Human declines at the rule report -> rejected entries, so a later
|
||||
# haiku round cannot re-nominate the identical glob+lifetime without the
|
||||
# judge knowing.
|
||||
for rule, why in declined:
|
||||
nominations["rejected"].append({
|
||||
"glob": rule["glob"], "lifetime": rule["lifetime"],
|
||||
"why": why, "rejected_by": "human", "judged_on": today,
|
||||
})
|
||||
|
||||
# 3. Consults the human declined this run (Step 5 exit b) -> rejected
|
||||
# entries too, same rejected_by/judged_on convention.
|
||||
for glob_pattern, lifetime, why in new_human_rejections:
|
||||
nominations["rejected"].append({
|
||||
"glob": glob_pattern, "lifetime": lifetime,
|
||||
"why": why, "rejected_by": "human", "judged_on": today,
|
||||
})
|
||||
|
||||
# 4. Still-open consults from this run -> nominations.consults, deduped by
|
||||
# glob (an existing entry with the same glob wins -- never duplicated).
|
||||
existing_consult_globs = {c["glob"] for c in nominations["consults"]}
|
||||
for consult in still_open_consults:
|
||||
if consult["glob"] not in existing_consult_globs:
|
||||
nominations["consults"].append({**consult, "asked_on": today})
|
||||
|
||||
# 5. Consults the human just settled this run (answered or declined) leave
|
||||
# nominations.consults -- the rule (step 1) or rejection (step 3)
|
||||
# supersedes them.
|
||||
nominations["consults"] = [
|
||||
c for c in nominations["consults"] if c["glob"] not in settled_consult_globs
|
||||
]
|
||||
|
||||
write_warnings = writer.write(project_rules, data)
|
||||
'
|
||||
```
|
||||
|
||||
- **Project rules** (the common case): land in `<project-root>/.dochygiene-
|
||||
rules.json` on judge `confirm`/`amend` PLUS this step's human approval —
|
||||
including plain `lifetime: keep` rules for judge `keep`-purpose verdicts,
|
||||
exact-path singletons allowed under the keep-tier relaxation.
|
||||
- **Human declines** at the rule report persist as `rejected` entries with
|
||||
`rejected_by: "human"` (never silently dropped) — see item 2 above.
|
||||
- **Open `consult` verdicts** persist to `nominations.consults`, deduped by
|
||||
glob at write time — see item 4 above. An answered consult (Step 5 exit a)
|
||||
is deleted from `consults` in the same write that persists its superseding
|
||||
rule; a declined consult (exit b) is deleted from `consults` in the same
|
||||
write that adds its `rejected` entry; a deferred consult (exit c) is left
|
||||
untouched and resurfaces next run.
|
||||
- **Global rulebook writes** (`plugins/os-doc-hygiene/rulebook.json`) require
|
||||
a SEPARATE, EXPLICIT confirmation beyond the project-rule approval above —
|
||||
this is a cross-repo write into cc-os itself. Ask distinctly: "This rule
|
||||
looks like it belongs in the GLOBAL rulebook (applies to every project),
|
||||
not just this one. Write it to the global rulebook instead/as well? (yes/
|
||||
no)". Only write on explicit "yes" to THIS question. (Rejections/consults
|
||||
are always project-scoped memory — never written to the global rulebook.)
|
||||
- **Removals are HITL-only, always**, regardless of scope: only remove a
|
||||
rule when the human explicitly asks to, with the reasoning recorded in the
|
||||
rule's own `note` field (or a comment in the calibration run's summary) —
|
||||
never as an automatic side effect of a calibration pass. New rejections
|
||||
and consults are memory, not deletion authority, and are never gated the
|
||||
way a rule persist/remove is (Step 5).
|
||||
|
||||
Each persisted rule gets `confirmed_by` (the human's decision, not the
|
||||
judge's — a model-proposed rule may never set `confirm: true` on itself,
|
||||
it may only ask) and `confirmed_on` (today's date) per the rulebook schema
|
||||
(`rulebook.py`'s `_KNOWN_FIELDS`).
|
||||
|
||||
---
|
||||
|
||||
### Consult loop — worked example (map #49, #56/#59)
|
||||
|
||||
Worked example of consult persistence (`lifecycle-spec.md` §2 "Nominations
|
||||
memory"), wired through Steps 3.5/5/6 above. Run 1: the judge returns
|
||||
`consult` on `docs/orchestration-audit/*.md` ("retained audit trail, or
|
||||
disposable once the tune-up lands?"). Step 6's writer persists it to
|
||||
`nominations.consults` (glob, question, evidence, cluster_key, asked_on —
|
||||
deliberately no lifetime). Run 2, weeks later: the deterministic intake filter injects the
|
||||
still-open consult into the judge prompt's "Nominations memory" section, AND
|
||||
the Step 5 report renders it under "Open consults". Three exits, all human:
|
||||
(a) the human answers "audit trail" → a `lifetime: keep` rule is persisted
|
||||
and the consult entry deleted (the rule supersedes it); (b) "not
|
||||
rule-worthy" → the entry is rewritten into `nominations.rejected` with the
|
||||
human's why (`rejected_by: "human"`); (c) no answer → the entry stays and
|
||||
resurfaces on run 3. A consult never filters files and never expires on its
|
||||
own.
|
||||
|
||||
---
|
||||
|
||||
### Step 7 — (D) Retest loop
|
||||
|
||||
Re-run Steps 1-6 (including 3.5) against the shrunk unmatched pool. Stop
|
||||
when:
|
||||
|
||||
- a round yields **fewer than 2 new persisted rules**, OR
|
||||
- the unmatched pool shrank by **less than 10%** since the previous round,
|
||||
- **hard cap: 3 rounds**, regardless of shrink rate.
|
||||
|
||||
Track round count and the unmatched-pool size at the start of each round in
|
||||
the scratch dir (`$SCRATCH/round_N_unmatched_count.txt`) to compute shrink %.
|
||||
|
||||
---
|
||||
|
||||
## §6 (design.md) — draft convention adoption, never apply unasked
|
||||
|
||||
While reviewing the unmatched pool, `:calibrate` MAY notice a pattern that
|
||||
would benefit from a `conventions.json` convention (`archive-bucket` or
|
||||
`status-frontmatter`) rather than a plain rule. If so, it MAY draft the
|
||||
adoption — the graduated rule (e.g. `served_when_path: <dir>/archive/{name}`)
|
||||
PLUS the concrete file moves or frontmatter additions the convention
|
||||
implies — and present it to the human alongside the Step 5 rule report, for
|
||||
approval. **It never applies a drafted adoption without explicit
|
||||
confirmation** — no rulebook write and no file move happens until the human
|
||||
confirms.
|
||||
|
||||
---
|
||||
|
||||
## Calibration pass #1 (cc-os) — special-case reminders
|
||||
|
||||
See `lifecycle-spec.md` §9 and openspec task group 6 for the full protocol.
|
||||
When run is explicitly cc-os pass #1:
|
||||
|
||||
- Withhold the #41 seed candidates from judge intake (Step 4) — do NOT paste
|
||||
them into the judge prompt.
|
||||
- The **protected set is a hard gate**: if ANY rule proposed for persistence
|
||||
(Step 5/6) has a glob matching a path in the protected set (eval
|
||||
`scenarios/`/`scenarios-reserve/`/`fixture/`/`judge-rubric.md`;
|
||||
`openspec/specs/`; `docs/adr/**`; mirrored `.claude/`/`.codex/`/`.pi/`
|
||||
skill dirs; `CLAUDE.md`; plugin source), REFUSE to persist that rule and
|
||||
flag it loudly — regardless of the tier the judge assigned it. A `consult`
|
||||
verdict touching a protected path during exploration is free (does not
|
||||
fail the pass) as long as it is never persisted.
|
||||
- Do not treat this carve-out as a permanent behavior — every later run
|
||||
(including future cc-os runs) uses full seed intake.
|
||||
|
||||
---
|
||||
|
||||
## Invariants
|
||||
|
||||
- Steps 1, 2, 3.5, 5, 6 are deterministic scripts/logic — **no model**.
|
||||
- Step 3 = **haiku** (cheap, per-cluster nomination, patterns only).
|
||||
- Step 3.5 = the deterministic `NominationIntakeFilter` — exact glob+lifetime
|
||||
repeats of a `rejected` entry are dropped before the judge ever sees them;
|
||||
survivors carry `related_rejections`; all open consults pass through
|
||||
unconditionally.
|
||||
- Step 4 = **ONE batched Opus/Fable** judge call, never per-cluster.
|
||||
- **No rule is persisted before the Step 5 report has been shown to the
|
||||
human** (hard invariant — never skip Step 5, never merge it with Step 6).
|
||||
- **Global-rulebook writes require a SEPARATE explicit confirmation** beyond
|
||||
project-rule approval.
|
||||
- **Removals are HITL-only in all cases**, with recorded reasoning.
|
||||
- **A model-proposed rule may never self-set `confirm: true`** — only the
|
||||
human's Step 5/6 response does.
|
||||
- Retest loop stops at <2 new rules OR <10% shrink; hard cap 3 rounds.
|
||||
- **LOOP GUARD:** the nominate subagent prompt MUST point to
|
||||
`workflows/nominate.md`; the judge subagent prompt MUST point to
|
||||
`workflows/judge.md`. Neither ever points to this SKILL.md.
|
||||
- **SUBAGENT AUTHORIZATION:** both subagents are executors — authorization is
|
||||
terminal. Neither re-asks for approval; if either objects, REPORT-AND-EXIT
|
||||
and let the orchestrator (this skill, or ultimately the human at Step 5/6)
|
||||
adjudicate.
|
||||
|
|
@ -0,0 +1,185 @@
|
|||
# Workflow: Judge lifecycle rule nominations (strong model, batched)
|
||||
|
||||
You are the ONE batched strong-model judge for the doc-hygiene `calibrate`
|
||||
skill. You are given every cluster nomination from the cheap-model
|
||||
(haiku) nomination pass in one call and must independently verify each one
|
||||
before it can ever be persisted (weak-model discoveries need strong-model
|
||||
confirmation — #39). You do NOT trust a nomination's claims — you gather
|
||||
your OWN evidence.
|
||||
|
||||
> Do NOT read or follow the parent `SKILL.md`. This workflow is self-contained.
|
||||
|
||||
---
|
||||
|
||||
## Input
|
||||
|
||||
- **Project root.**
|
||||
- **Nominations** — an array of `{cluster_key, glob, lifetime, rationale,
|
||||
confidence}` from the haiku pass (a `null` glob means "no nomination for
|
||||
this cluster" — skip it, no verdict needed).
|
||||
- **Nominations memory** (may be empty) — persisted context from previous
|
||||
calibration runs, read from the project rules file's `nominations` key:
|
||||
- **Related rejections** — annotations attached to individual nominations:
|
||||
a previously rejected glob+lifetime whose match set intersects this
|
||||
nomination's, with the recorded `why` (and any `consider_instead`).
|
||||
Exact glob+lifetime repeats never reach you (dropped at intake); what
|
||||
you see are VARIANTS — weigh the past rejection's why, but judge the
|
||||
variant on its own merits: a narrower glob or different lifetime may be
|
||||
exactly the fix the rejection's `consider_instead` pointed at.
|
||||
- **Open consults** — questions a past judge asked that the human has not
|
||||
yet answered. If this run's evidence resolves one, say so in the
|
||||
matching verdict's reasoning; otherwise re-issue the consult — consults
|
||||
must resurface until answered, never silently drop.
|
||||
|
||||
Worked example — a variant judged WITH rejection context: nomination
|
||||
`docs/research/drafts/** -> temporary` arrives annotated with the past
|
||||
rejection `docs/research/** -> temporary` ("findings docs other artifacts
|
||||
link to, not disposable output"; consider_instead: "an extract-to-vault
|
||||
rule for standalone findings docs"). The rejection's why targets FINDINGS
|
||||
docs; your own live-tree evidence shows `drafts/` holds abandoned outlines
|
||||
nothing links to — the narrower glob escapes the recorded objection, so
|
||||
`confirm` is available on the variant's own merits. The rejection is
|
||||
context, not a veto.
|
||||
|
||||
- **Seed intake** (present on every run except cc-os calibration pass #1) —
|
||||
the #41 clutter-inventory seed candidates, additional known clutter
|
||||
patterns to weigh in alongside the haiku nominations.
|
||||
|
||||
---
|
||||
|
||||
## Your job: gather your own evidence, then verdict
|
||||
|
||||
For EACH nomination (and each seed candidate, when present):
|
||||
|
||||
1. **Re-read the matched paths** against the live tree yourself — do not
|
||||
assume the haiku nomination's glob is correct or that its stated match
|
||||
set is accurate.
|
||||
2. **Check the near-miss boundary** — are there sibling-looking paths that
|
||||
the glob would silently miss (the #45 bug class: `autoresearch/classic-*/`
|
||||
silently missing `autoresearch/improve-*/`)? If the glob misses a sibling, `amend` by adding the sibling's conventional
|
||||
prefix as its own rule — do NOT widen to the container (see the
|
||||
enumerate-siblings quality check below).
|
||||
3. **Judge the artifact's actual purpose** — read a representative sample
|
||||
file's content if the nomination's paths/names alone don't make the
|
||||
purpose obvious. Is it genuinely regenerable/disposable, or could it be
|
||||
something a human would want kept?
|
||||
4. **Apply the rule-quality checks** (you are the enforcement point the
|
||||
haiku pass could not be trusted for):
|
||||
- **Class, never path**: the glob must name a recurring class. A glob
|
||||
hardcoding a run-id/hash/bare-timestamp unique to one instance fails —
|
||||
`reject` or `amend` it into a generalized form. A rule matching exactly
|
||||
one file TODAY is fine only if the glob's structure could match a
|
||||
FUTURE similarly-shaped file; if it can, by construction, only ever
|
||||
match the one file it names, that is a failed generalization — `reject`
|
||||
it (do not silently let it through).
|
||||
- **Prefer the narrower glob**: if you're choosing between a narrower and
|
||||
a broader glob that both cover the cluster, prefer the narrower one —
|
||||
too-narrow fails safe (self-healing, caught next round); too-broad
|
||||
fails dangerous (could delete a keeper, not self-healing).
|
||||
- **Enumerate siblings, never widen to the container**: when the near-miss
|
||||
check (step 2) reveals sibling artifacts, `amend` by ENUMERATING the
|
||||
conventional prefixes as separate rules (`autoresearch/classic-*/` plus
|
||||
`autoresearch/improve-*/`), NOT by widening to the container
|
||||
(`autoresearch/*/`). A container glob claims the directory's entire
|
||||
future — a later keep-worthy file (e.g.
|
||||
`autoresearch/methodology-notes.md`) would sit under a deletion rule. A
|
||||
sibling flavor you cannot yet name fails safe: it gets its own rule next
|
||||
round. Widening to a container is allowed ONLY when the directory is
|
||||
wholly machine-owned (`plugins/*/.pytest_cache/`) so nothing keep-worthy
|
||||
can ever appear inside it.
|
||||
|
||||
## Verdicts — exactly one of four
|
||||
|
||||
- **`confirm`** — the nomination (as-is) is sound: correct glob, correct
|
||||
lifetime, passes the quality checks, purpose is clearly regenerable/
|
||||
disposable.
|
||||
- **`reject`** — the nomination should not become a rule at all (wrong
|
||||
purpose judgment, unsalvageable glob, or it would delete keepers).
|
||||
- **`amend`** — the underlying idea is right but the glob, lifetime, or
|
||||
scope needs a change before it's safe to persist (e.g. widen/narrow the
|
||||
glob, change `temporary` to `delete-once-served`). Return the amended
|
||||
rule, not the original.
|
||||
- **`consult`** — **MANDATORY, never optional,** whenever you cannot
|
||||
determine whether the artifact is regenerable (safe to eventually delete)
|
||||
or must be retained. Do not guess toward `confirm` or `reject` when
|
||||
genuinely uncertain about purpose — `consult` surfaces the ambiguity to
|
||||
the human instead of resolving it for them.
|
||||
|
||||
---
|
||||
|
||||
## Output
|
||||
|
||||
Return ONLY a JSON array, no prose, no code fences — one entry per judged
|
||||
nomination (nominations with a `null` glob need no entry):
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"cluster_key": "autoresearch::run-#",
|
||||
"verdict": "amend",
|
||||
"rule": {
|
||||
"glob": "autoresearch/improve-*/",
|
||||
"lifetime": "temporary",
|
||||
"retain_recent": 3,
|
||||
"max_age_days": 3
|
||||
},
|
||||
"reasoning": "Haiku's glob (autoresearch/run-*/) named a prefix that does not occur; the live tree has autoresearch/classic-*/ and autoresearch/improve-*/ runs. Enumerated each conventional prefix as its own rule rather than widening to autoresearch/*/, which would claim the whole directory's future."
|
||||
},
|
||||
{
|
||||
"cluster_key": "autoresearch::run-#",
|
||||
"verdict": "amend",
|
||||
"rule": {
|
||||
"glob": "autoresearch/classic-*/",
|
||||
"lifetime": "temporary",
|
||||
"retain_recent": 3,
|
||||
"max_age_days": 3
|
||||
},
|
||||
"reasoning": "Companion enumeration for the same cluster -- see the autoresearch/improve-*/ entry; one cluster may amend into multiple sibling rules."
|
||||
},
|
||||
{
|
||||
"cluster_key": "docs::HANDOFF-#",
|
||||
"verdict": "confirm",
|
||||
"rule": {
|
||||
"glob": "HANDOFF-*.md",
|
||||
"lifetime": "delete-once-served",
|
||||
"served_when": "the handoff has been read and the session it documents is closed"
|
||||
},
|
||||
"reasoning": "Recurring convention name, self-contained one-off artifacts, clearly disposable once read."
|
||||
},
|
||||
{
|
||||
"cluster_key": "docs::status-#",
|
||||
"verdict": "consult",
|
||||
"rule": null,
|
||||
"glob": "docs/orchestration-audit/*.md",
|
||||
"question": "Are the dated audit tune-up reports a retained audit trail, or disposable once the tune-up lands?",
|
||||
"evidence": "4 files, one per audit run; docs/implementation-status.md references only the latest",
|
||||
"reasoning": "Cannot determine from content or naming whether these status snapshots are meant to be retained as an audit trail or are disposable scratch notes -- purpose is genuinely unclear."
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
- `rule` is `null` for `reject` and `consult` verdicts (nothing to persist).
|
||||
- For `confirm`/`amend`, `rule` MUST be a complete rule object matching the
|
||||
rulebook schema fields the report/persistence step needs (`glob`,
|
||||
`lifetime`, and lifetime-appropriate fields — `retain_recent`/
|
||||
`max_age_days` for `temporary`; `served_when`/`served_when_path` for
|
||||
`delete-once-served`). Do NOT include `confirmed_by`/`confirmed_on` — you
|
||||
are proposing, not confirming; **you may never set `confirm: true` on a
|
||||
rule yourself, only the human can.**
|
||||
- **For `consult`, ALSO include `glob` (echoed from the nomination you're
|
||||
judging — the persisted consult entry needs it and `rule` is `null`),
|
||||
`question` (the specific ambiguity, one sentence, phrased for a human to
|
||||
answer), and `evidence` (what you found — sample size, what references
|
||||
exist, what a representative file's content told you). These three are
|
||||
what `nominations.consults` persists (lifecycle-spec.md §2) — `reasoning`
|
||||
alone is not enough downstream, since the Step 5 "Open consults" section
|
||||
and the persisted entry both need `question` and `evidence` as distinct
|
||||
fields, not one prose blob.
|
||||
- `reasoning` is shown to the human in the Step 5 rule report's "why" field —
|
||||
write it in plain language, not JSON-schema-speak.
|
||||
|
||||
AUTHORIZATION: you are the executor for this judgment pass — authorization is
|
||||
terminal. Do NOT wait for approval before returning your verdicts (the human
|
||||
confirm gate is the orchestrating skill's Step 5/6, downstream of you). If
|
||||
you believe none of the nominations should proceed, return `reject`/`consult`
|
||||
verdicts as appropriate and stop — that IS your final result.
|
||||
|
|
@ -0,0 +1,86 @@
|
|||
# Workflow: Nominate a lifecycle rule for a cluster (cheap model)
|
||||
|
||||
You are the nomination subagent for the doc-hygiene `calibrate` skill. You are
|
||||
given ONE cluster of similar unmatched file paths (files the project's
|
||||
lifecycle rulebook does not currently govern) and must nominate a candidate
|
||||
rule: a bare glob pattern plus a lifetime. You are a cheap first pass — a
|
||||
strong-model judge will re-check your work independently before anything is
|
||||
persisted, so favor a clear, generalizable proposal over hedging.
|
||||
|
||||
> Do NOT read or follow the parent `SKILL.md`. This workflow is self-contained.
|
||||
|
||||
---
|
||||
|
||||
## Input
|
||||
|
||||
- **Directory prefix** and **shape class** the cluster was grouped by.
|
||||
- **Total** matching paths in this cluster.
|
||||
- **Sample paths** — a capped representative sample from the cluster.
|
||||
|
||||
---
|
||||
|
||||
## Your one job: propose a CLASS, never a PATH
|
||||
|
||||
The single most important constraint: your glob must describe a **recurring
|
||||
class** of artifact, not one specific instance.
|
||||
|
||||
- **Acceptable:** a glob that generalizes the cluster's shared shape — e.g.
|
||||
given `autoresearch/run-20260710/`, `autoresearch/run-20260711/`, propose
|
||||
`autoresearch/run-*/`, not a glob naming one specific run.
|
||||
- **Acceptable:** a glob that hardcodes a name recurring *by convention*
|
||||
across the sample (e.g. all samples are named `HANDOFF-<date>.md` →
|
||||
`HANDOFF-*.md` is fine; a bare `PRD.md` appearing at the same relative
|
||||
location across the sample is fine as `**/PRD.md` or similar).
|
||||
- **NOT acceptable:** hardcoding a run-id, hash, or bare timestamp unique to
|
||||
ONE of the sample paths into the glob (e.g. don't propose
|
||||
`autoresearch/run-20260710/` — that only ever matches that one run).
|
||||
|
||||
If the sample paths don't share an obvious generalizable pattern, it is
|
||||
legitimate to decline (see "no proposal" below) rather than force one.
|
||||
|
||||
---
|
||||
|
||||
## Choosing a lifetime
|
||||
|
||||
Pick the lifetime that best fits what this class of artifact IS, based on
|
||||
the paths and any content you can infer from names/locations:
|
||||
|
||||
- `"temporary"` — self-healing, exists for a bounded window (logs, run
|
||||
outputs, scratch artifacts); a `retain_recent`/`max_age_days` policy makes
|
||||
sense.
|
||||
- `"delete-once-served"` — exists until some condition is met, then should
|
||||
go (a plan doc that should be archived once shipped, a checklist that
|
||||
should go once fully checked off).
|
||||
- `"keep"` — actually should NOT be deleted; if every sample looks like this,
|
||||
decline to nominate (see below) rather than proposing a rule that would
|
||||
delete keepers.
|
||||
|
||||
---
|
||||
|
||||
## Output
|
||||
|
||||
Return ONLY a JSON object, no prose, no code fences:
|
||||
|
||||
```json
|
||||
{
|
||||
"glob": "autoresearch/run-*/",
|
||||
"lifetime": "temporary",
|
||||
"rationale": "One-off autoresearch run directories; self-healing, safe to age out.",
|
||||
"confidence": "high"
|
||||
}
|
||||
```
|
||||
|
||||
If you cannot responsibly generalize this cluster (the samples don't share a
|
||||
clean pattern, or they look like keepers, not clutter), return instead:
|
||||
|
||||
```json
|
||||
{
|
||||
"glob": null,
|
||||
"lifetime": null,
|
||||
"rationale": "Samples do not share a generalizable naming/location pattern, or look like content that should be kept.",
|
||||
"confidence": "low"
|
||||
}
|
||||
```
|
||||
|
||||
A `null` glob is a legitimate, expected outcome — the judge step treats it as
|
||||
"no nomination for this cluster," not an error.
|
||||
|
|
@ -4,10 +4,29 @@ description: Scan the project for stale and bloated documentation and write a hy
|
|||
|
||||
# Hygiene Check Skill
|
||||
|
||||
Orchestrates one documentation-hygiene check: **scan → classify → finalize →
|
||||
validate → write → stamp**. The scan, finalize, validation, write, and stamp are
|
||||
deterministic scripts (invariant #6 — no model). Only the per-file
|
||||
classification is a model step, dispatched to a **Sonnet** subagent.
|
||||
Orchestrates one documentation-hygiene check: **load rulebook → scan → classify
|
||||
→ finalize → validate → write → stamp**. The rulebook load, scan, finalize,
|
||||
validation, write, and stamp are deterministic scripts (invariant #6 — no
|
||||
model). Only the per-file classification is a model step, dispatched to a
|
||||
**Sonnet** subagent.
|
||||
|
||||
**Lifecycle awareness (ADR-0039/-0041):** the scanner now consumes the
|
||||
lifecycle rulebook (global `${CLAUDE_PLUGIN_ROOT}/rulebook.json` plus any
|
||||
project `.dochygiene-rules.json` override, per `rulebook.py`) while it walks.
|
||||
A directory-rule match prunes the walk and emits one aggregate shortlist entry
|
||||
for that directory; a file-rule match attaches a `lifecycle` signal
|
||||
(`rule_ref`, `lifetime`, `served`/`served_when`/`served_when_path`, age/tier
|
||||
fields) to that file's existing signals. Lifecycle signals flow into the
|
||||
classification subagent as a **new signal class alongside stale/bloat** — the
|
||||
classifier judges free-text `served_when` conditions and MAY propose `delete`
|
||||
or `extract-then-delete` ops (with an `extraction_dest` classification:
|
||||
`repo-durable` vs `cross-repo`). The classifier **never authors** `git_state`
|
||||
or `safety_tier` for a lifecycle entry — same as every other guardrail field,
|
||||
those stay deterministic, owned by `report_builder.py`/`validate_report.py`.
|
||||
The finalize pass also computes the report's `promotion_candidates` section
|
||||
(deterministic, from `conventions.json` — no model call) for every
|
||||
classifier-judged lifecycle entry with an applicable, not-yet-adopted
|
||||
convention (`archive-bucket`, `status-frontmatter`).
|
||||
|
||||
All scripts live under `${CLAUDE_PLUGIN_ROOT}/scripts/`. Run them with `python3`
|
||||
from the user's project directory (`cwd`), which is where the project root is
|
||||
|
|
@ -77,18 +96,63 @@ Three cases:
|
|||
|
||||
> **Do NOT append without explicit user confirmation.** (Invariant #3.)
|
||||
|
||||
### Step 1 — (D) Scan
|
||||
### Step 0.5 — (D) Load the lifecycle rulebook, then scan
|
||||
|
||||
Run the scanner, capturing its stdout artifact to the scratch dir:
|
||||
The plain `scanner.py` CLI does not wire up rulebook consumption on its own
|
||||
(`Scanner(rulebook=...)` is an optional constructor argument) — load the
|
||||
rulebook and construct the scanner in one `python3 -c`, mirroring the
|
||||
`state_store` invocation pattern used at Steps 6/7:
|
||||
|
||||
```bash
|
||||
python3 "${CLAUDE_PLUGIN_ROOT}/scripts/scanner.py" [--globs <glob> ...] > "$SCRATCH/scan.json"
|
||||
export SCRATCH
|
||||
python3 -c '
|
||||
import json, os, sys
|
||||
from pathlib import Path
|
||||
sys.path.insert(0, os.environ["CLAUDE_PLUGIN_ROOT"] + "/scripts")
|
||||
from rulebook import load_rulebook, RulebookLoadError
|
||||
from scanner import Scanner, _resolve_project_root, _git_log_real, _git_commit_time_real
|
||||
|
||||
root = _resolve_project_root(Path.cwd())
|
||||
project_rules = root / ".dochygiene-rules.json"
|
||||
try:
|
||||
rulebook = load_rulebook(project_path=project_rules if project_rules.is_file() else None)
|
||||
except RulebookLoadError as exc:
|
||||
print(json.dumps({"error": "rulebook-load-failed", "detail": str(exc)}))
|
||||
sys.exit(2)
|
||||
|
||||
scanner = Scanner(
|
||||
root=root,
|
||||
rulebook=rulebook,
|
||||
git_log_fn=_git_log_real,
|
||||
git_commit_time_fn=_git_commit_time_real,
|
||||
)
|
||||
artifact = scanner.run()
|
||||
Path(os.environ["SCRATCH"] + "/scan.json").write_text(json.dumps(artifact, indent=2))
|
||||
print("scan written")
|
||||
'
|
||||
```
|
||||
|
||||
- Omit `--globs` when there is no `--scope`.
|
||||
- Exit `2` / `{"error": "rulebook-load-failed", ...}` — **hard failure, per
|
||||
spec**: unparseable JSON or an unknown `schema_version` in either rulebook
|
||||
file. STOP here and report the rulebook error to the user before running
|
||||
the scanner at all — do NOT proceed with lifecycle signals silently
|
||||
disabled.
|
||||
- Per-rule warnings (an invalid rule, or one missing `confirmed_by`) are
|
||||
skip-and-warn, not a hard failure — `load_rulebook` already drops those
|
||||
rules; nothing further to do here.
|
||||
- If `--scope` maps to explicit `--globs`, pass `scope_globs=[...]` to the
|
||||
`Scanner(...)` constructor above instead of the CLI's `--globs` flag (same
|
||||
scope semantics as before — just via the constructor since this step
|
||||
no longer shells out to the bare CLI).
|
||||
- The scanner auto-resolves the project root from `cwd` and applies default
|
||||
excludes (incl. `.cc-os/` and legacy `.dochygiene/`). Do not pass `--root`.
|
||||
|
||||
### Step 1 — (D) Scan
|
||||
|
||||
The scan already ran as part of Step 0.5 (rulebook load and scan are one
|
||||
script invocation so the rulebook is never stale relative to the walk). The
|
||||
artifact is at `"$SCRATCH/scan.json"`; proceed to Step 2.
|
||||
|
||||
The artifact is `{ project_root, scope_globs, excluded_dirs, files_scanned,
|
||||
shortlist, signals }`. `signals` is an object keyed by project-root-relative path:
|
||||
`{ "<path>": [ { "name": "<signal>", "detail": "<text>" }, ... ] }`.
|
||||
|
|
@ -271,7 +335,12 @@ Run /os-doc-hygiene:clean to act on these (Phase 4), or /os-doc-hygiene:status f
|
|||
|
||||
The human report header renders `scope_globs` but has no category field (the
|
||||
frozen `report_builder.py` does not take one), so surface the active `--category`
|
||||
here in the skill output rather than expecting it in the report.
|
||||
here in the skill output rather than expecting it in the report. The human
|
||||
report itself now always includes a **Promotion Candidates** section (a
|
||||
`## Promotion Candidates` heading, `(none)` when empty), and any entry
|
||||
carrying lifecycle evidence shows a `lifecycle: rule=... · lifetime=... ·
|
||||
served_when(_path)=...` line beneath it — both rendered deterministically by
|
||||
`report_builder.py`, not authored by the classification subagent.
|
||||
|
||||
## Closed enums (for reference — the subagent enforces them)
|
||||
|
||||
|
|
@ -281,17 +350,34 @@ here in the skill output rather than expecting it in the report.
|
|||
- bloat `subtype` ∈ { `distill`, `split`, `freeze` }
|
||||
- `op_type` ∈ { `deterministic`, `generative` }
|
||||
- `exact_edit.kind` ∈ { `delete-range`, `move-to-archive`, `insert-frontmatter`,
|
||||
`replace-text`, `dedupe` }
|
||||
`replace-text`, `dedupe`, `delete`, `extract-then-delete` } — the last two
|
||||
are lifecycle ops (ADR-0039): the classifier may propose them for a
|
||||
candidate carrying a lifecycle signal, additionally supplying an
|
||||
`extraction_dest` (`repo-durable` \| `cross-repo`) for
|
||||
`extract-then-delete`. The classifier NEVER authors `git_state` or
|
||||
`safety_tier` for these — `report_builder.py`/`validate_report.py` derive
|
||||
them per the lifecycle tier matrix (scanner-proven + tracked+clean ⇒ auto;
|
||||
everything else, including any classifier-judged `served_when`, ⇒ confirm).
|
||||
|
||||
## Invariants
|
||||
|
||||
- Step 0 check is deterministic (`git check-ignore`); the offer/confirm is a user gate (M-GATE). The append is deterministic and runs only on explicit confirmation.
|
||||
- Steps 1, 2, 4, 5, 6, 7 are deterministic scripts — **no model** (invariant #6).
|
||||
- Step 0.5 (rulebook load + scan), Steps 2, 4, 5, 6, 7 are deterministic
|
||||
scripts — **no model** (invariant #6). A rulebook load failure is a hard
|
||||
failure (exit 2) — the check stops before scanning, it never proceeds with
|
||||
lifecycle signals silently disabled.
|
||||
- Classification = **Sonnet**; single-file Opus escalation only on low confidence
|
||||
for hard distinctions.
|
||||
- The subagent supplies judgment only. It never authors `expected_sha256`,
|
||||
`safety_tier`, `is_destructive`, `is_reversible`, or `raw_tokens` — those are
|
||||
owned by `report_builder.py`.
|
||||
`safety_tier`, `is_destructive`, `is_reversible`, `raw_tokens`, or (for
|
||||
lifecycle entries) `git_state` — those are owned by `report_builder.py`. For
|
||||
a lifecycle-signal candidate the subagent MAY judge the free-text
|
||||
`served_when` condition and propose `delete`/`extract-then-delete` plus
|
||||
`extraction_dest`, but the resulting `safety_tier` is still computed
|
||||
downstream, never asserted by the subagent.
|
||||
- `promotion_candidates` is computed by `report_builder.py` from
|
||||
`conventions.json` with **no model call** — it is not something the
|
||||
classification subagent produces or is asked about.
|
||||
- **Validate on a scratch path BEFORE `write_report`** (write_report is
|
||||
destructive-first; invariant #4). Never write an invalid report.
|
||||
- `last_check` = the validated report's envelope `generated_at` (same run
|
||||
|
|
|
|||
|
|
@ -16,7 +16,51 @@ You are given, for each candidate:
|
|||
- `signals` — the scanner's objective signals for that path, as a JSON array of
|
||||
`{ "name": ..., "detail": ... }`. Signal `name`s are drawn from:
|
||||
`broken_reference`, `version_skew`, `edit_recency_vs_churn`,
|
||||
`stale_name_location`, `archive_to_live_ratio`, `frontmatter_marker`.
|
||||
`stale_name_location`, `archive_to_live_ratio`, `frontmatter_marker`, and a
|
||||
new class — `lifecycle` — carrying `{ rule_ref, lifetime, extract,
|
||||
served: {kind, ...}, served_when / served_when_path, age_days,
|
||||
retain_recent, max_age_days, retention: {rank, kept, deletable} }`. A
|
||||
`lifecycle` signal means the path matched a rulebook rule (see
|
||||
`rulebook.py` / `lifecycle-spec.md`).
|
||||
|
||||
## Lifecycle candidates — a fourth op family (delete / extract-then-delete)
|
||||
|
||||
A candidate carrying a `lifecycle` signal is judged differently from a
|
||||
stale/bloat candidate:
|
||||
|
||||
- If `served.kind` is `"scanner-proven"` (a `served_when_path` hit, or a
|
||||
temporary-tier age/retain-recent computation already resolved the
|
||||
deletion question deterministically), the deletion decision is **already
|
||||
made** — you still classify the file (so the entry exists in the report),
|
||||
but you MUST NOT re-litigate whether it should be deleted. Propose `op_type:
|
||||
"deterministic"`, `exact_edit.kind: "delete"` (or, for a directory-rule
|
||||
aggregate entry, `"delete"` with the directory path — the finalize pass
|
||||
sets `is_directory` from the scan artifact, not from you).
|
||||
- If `served.kind` is `"classifier-judged"` (the rule's `served_when` is
|
||||
free text — e.g. "the effort this plan describes has shipped" — and no
|
||||
filesystem-provable `served_when_path` exists), **you** are the judgment:
|
||||
read the file and any evidence available and decide whether the free-text
|
||||
condition currently holds. If it does, propose `exact_edit.kind: "delete"`
|
||||
or `"extract-then-delete"` (see below). If it does not (or you cannot
|
||||
tell), do not propose a lifecycle op for this candidate — emit no
|
||||
proposal, or a stale/bloat proposal instead if independently warranted.
|
||||
- **`extract-then-delete`** is for content worth preserving elsewhere before
|
||||
the source is deleted (the rule's `extract` field, if present, hints this).
|
||||
Supply `exact_edit.extraction_dest`: `"repo-durable"` (content belongs in
|
||||
an ADR/CLAUDE.md/docs file in this repo) or `"cross-repo"` (content
|
||||
belongs in the SecondBrain vault, written via `/os-vault:write` at clean
|
||||
time). For `repo-durable`, also name a target doc reference if you can
|
||||
infer one (e.g. `docs/adr/` or a specific `CLAUDE.md` section) — the clean
|
||||
skill's generative extraction step uses this as a starting point, not a
|
||||
binding contract.
|
||||
|
||||
**You NEVER author `git_state` or `safety_tier` on a lifecycle proposal** —
|
||||
same rule as every other guardrail field. Your job is judgment content only:
|
||||
whether `served_when` currently holds, which `exact_edit.kind` fits, and (for
|
||||
`extract-then-delete`) the extraction destination classification. The
|
||||
deterministic finalize pass derives the tier from your `served`/`kind`
|
||||
evidence plus a live git-state check — a `classifier-judged` verdict can
|
||||
never resolve to `auto`, regardless of how confident you are.
|
||||
|
||||
You are also given the project root. **Read each candidate file** (root + path)
|
||||
before classifying it. Your judgment must be grounded in the file's actual
|
||||
|
|
@ -79,6 +123,8 @@ Supply `exact_edit` with `kind` plus exactly the kind-specific fields below.
|
|||
| `replace-text` | `anchor: { start_line, end_line }`, `match`, `replacement` | known-target fix, e.g. a link/path; `match` is the exact text to replace within the anchor |
|
||||
| `dedupe` | `anchor: { start_line, end_line }`, `canonical_ref` | exact duplicate preserved elsewhere; `canonical_ref` points to the surviving canonical copy |
|
||||
| `insert-frontmatter` | `key`, `value` | freeze a doc; **no anchor** (e.g. `key: "hygiene"`, `value: "frozen"`) |
|
||||
| `delete` | **no anchor for directory-rule aggregate entries; full-file anchor otherwise** | lifecycle-rule deletion (a `lifecycle` signal was present); do NOT supply `git_state` or `safety_tier` |
|
||||
| `extract-then-delete` | same anchor rule as `delete`, plus `extraction_dest: "repo-durable" \| "cross-repo"` | lifecycle deletion where content is worth preserving first; see the lifecycle section above |
|
||||
|
||||
## If `op_type` is `generative` → include `reducible_range`, NO `exact_edit`
|
||||
|
||||
|
|
|
|||
|
|
@ -5,10 +5,26 @@ description: Apply documented hygiene findings to project docs. Loads the curren
|
|||
# Hygiene Clean Skill
|
||||
|
||||
Orchestrates one documentation-hygiene cleanup run: **load → validate → filter →
|
||||
gate → preflight → apply → commit → stamp**. The load, validate, filter,
|
||||
gate → preflight → extract → apply → commit → stamp**. The load, validate, filter,
|
||||
partition, git ops, stage, commit, and stamp are deterministic (invariant #6 — no
|
||||
model). Only generative distillation is a model step, dispatched to a **Sonnet**
|
||||
subagent per the LOOP-GUARD pattern.
|
||||
model). Only generative distillation and lifecycle extraction are model steps,
|
||||
dispatched to a **Sonnet** subagent (or `/os-vault:write` for cross-repo
|
||||
extraction) per the LOOP-GUARD pattern.
|
||||
|
||||
**Lifecycle ops (ADR-0039):** `delete` and `extract-then-delete` are two more
|
||||
`op_type: "deterministic"` kinds alongside the existing five. They partition
|
||||
into the SAME `(safety_tier, op_type)` buckets as every other deterministic
|
||||
op (Step 4) — `auto` applies without prompt, `confirm` escalates — **but their
|
||||
tier was derived with lifecycle-aware rules** (`validate_report.py`'s
|
||||
`derive_safety_tier`): `auto` only when the lifecycle evidence was
|
||||
`scanner-proven` (a `served_when_path` hit or a temporary-tier age/
|
||||
retain-recent computation) **and** the file was tracked+clean at report-build
|
||||
time. A `classifier-judged` (`served_when` free text) verdict, or any
|
||||
dirty/untracked file, is `confirm` — no exception. Because time passes
|
||||
between `check` and `clean`, `patch_applier.py` **re-verifies git state at
|
||||
apply time** for every `auto`-tier lifecycle entry — even one applied
|
||||
silently here may be downgraded to skip (`git-state-changed-since-check`) if
|
||||
the path is no longer tracked+clean.
|
||||
|
||||
All scripts live under `${CLAUDE_PLUGIN_ROOT}/scripts/`. Run them from the user's
|
||||
project directory (`cwd`).
|
||||
|
|
@ -193,12 +209,15 @@ Hygiene clean — confirm required before any changes are made:
|
|||
The following entries require your approval. Auto-tier entries will be
|
||||
applied silently after you respond.
|
||||
|
||||
⚠ IRREVERSIBLE DELETES (delete-range — content will be permanently removed):
|
||||
⚠ IRREVERSIBLE DELETES (delete-range/delete/extract-then-delete — content
|
||||
will be permanently removed or removed after extraction):
|
||||
[1] docs/stale-notes.md (orphaned / delete-range · confirm) — ~120 tokens
|
||||
"Orphaned after 2025 refactor; no references remain."
|
||||
[2] docs/plans/old-plan.md (delete-once-served / delete · confirm) — ~80 tokens
|
||||
"served_when: classifier-judged — always confirm regardless of age/rule."
|
||||
|
||||
Reversible confirm entries:
|
||||
[2] CHANGELOG.md (distill / generative-distill · confirm) — ~400 tokens
|
||||
[3] CHANGELOG.md (distill / generative-distill · confirm) — ~400 tokens
|
||||
"Changelog is true but bloated; condense into summary."
|
||||
|
||||
Enter the numbers to approve (e.g. "1,2"), "all", or "none" / leave blank to skip all:
|
||||
|
|
@ -207,10 +226,17 @@ Enter the numbers to approve (e.g. "1,2"), "all", or "none" / leave blank to ski
|
|||
Rules for display:
|
||||
- List every `confirm_det` and `confirm_gen` entry (not `auto_det` — those apply
|
||||
silently).
|
||||
- Visually distinguish `delete-range` entries (mark `⚠ IRREVERSIBLE`); group
|
||||
them under a warning header.
|
||||
- Visually distinguish `delete-range`, `delete`, and `extract-then-delete`
|
||||
entries (mark `⚠ IRREVERSIBLE`); group them under a warning header —
|
||||
`delete`/`extract-then-delete` land here at `confirm` tier precisely
|
||||
because either the lifecycle evidence was classifier-judged (a
|
||||
`served_when` verdict is NEVER auto, no matter how confident) or the file
|
||||
was dirty/untracked at report-build time. A `scanner-proven` +
|
||||
tracked+clean lifecycle delete is `auto` and is never shown here at all.
|
||||
- Show: path, `category.subtype`, `op_type`/`kind`, `token_estimate.raw_tokens`,
|
||||
and `gloss` (or `op` if `gloss` is absent).
|
||||
and `gloss` (or `op` if `gloss` is absent). For lifecycle entries, surface
|
||||
the `lifecycle.served_when` or `served_when_path` text so the human can see
|
||||
what was (or wasn't) proven.
|
||||
- Per-entry opt-out: the user may approve a subset by number, "all", or "none".
|
||||
|
||||
After the user responds:
|
||||
|
|
@ -290,6 +316,87 @@ Report them at Step 11 with reason "untracked — add the file to git first."
|
|||
|
||||
---
|
||||
|
||||
### Step 6.5 — (M) Lifecycle extraction — orchestrated BEFORE the applier runs
|
||||
|
||||
Skip this step entirely if no `approved_det` entry has
|
||||
`exact_edit.kind == "extract-then-delete"`.
|
||||
|
||||
`patch_applier.py` never performs extraction itself — it only checks
|
||||
`entry.extraction_complete` and fails closed (skip,
|
||||
`extraction-not-confirmed`) when it is not `true` (design.md Decision 3).
|
||||
This skill is the one that runs extraction, and it MUST do so before
|
||||
invoking the applier for these entries, so the source deletion and the
|
||||
extraction land in the **same single hygiene commit**.
|
||||
|
||||
For each `approved_det` entry with `exact_edit.kind ==
|
||||
"extract-then-delete"` (in order):
|
||||
|
||||
1. **Confirm the file still exists** (a preceding op in this run may have
|
||||
already moved/deleted it). If gone, record as skipped
|
||||
(`source-already-gone`) and continue to the next entry — do not attempt
|
||||
extraction against a missing file.
|
||||
2. **Live-read the file contents now** (same freshness rule as Step 8's
|
||||
generative path — never trust the report's cached text).
|
||||
3. **Branch on `exact_edit.extraction_target`:**
|
||||
- **`"repo-durable"`** — dispatch the SAME generative Sonnet-subagent
|
||||
path `doc-clean` already uses (LOOP-GUARD: point it at
|
||||
`skills/clean/workflows/extract.md`, NEVER this SKILL.md). Give it the
|
||||
live file contents, the entry's `op`/`gloss`/lifecycle fields, and any
|
||||
target doc hint the classifier supplied. It returns one of
|
||||
`{"status": "extracted", "target_path", "target_action", "content"}`,
|
||||
`{"status": "nothing-to-extract", "reason"}`, or
|
||||
`{"status": "error", "reason"}`. For `"extracted"`, write/append
|
||||
`content` to `target_path` per `target_action` (an ADR file under
|
||||
`docs/adr/`, `CLAUDE.md`, or a `docs/` file — the model's proposal, not
|
||||
a fixed contract) and `git add -- "$PROJECT_ROOT/<target_path>"`
|
||||
immediately. `"nothing-to-extract"` is treated as extraction success
|
||||
(the judgment call was made; there's simply nothing durable to write) —
|
||||
proceed to step 4 below. `"error"` is treated as extraction failure —
|
||||
proceed to step 5 below.
|
||||
- **`"cross-repo"`** — invoke `/os-vault:write` (the skill, not the
|
||||
applier) with the extracted content and enough context (project name,
|
||||
source path, why it's evergreen) for it to choose vault frontmatter.
|
||||
`/os-vault:write` owns its own destination — this step supplies content
|
||||
and context only, per spec §1 ("no new destinations" for cross-repo
|
||||
extraction). On success, capture the pointer metadata the applier needs
|
||||
for the `extracted.md` index entry (lifecycle-spec §1): the vault note
|
||||
name `/os-vault:write` reports, a one-line reason a future reader would
|
||||
follow the link, and today's date (`YYYY-MM-DD`).
|
||||
4. **On extraction success:** record the entry's original report index in
|
||||
`extraction_confirmed_indices` for Step 7 below; for `"cross-repo"`
|
||||
entries also record the captured pointer metadata in
|
||||
`extraction_pointers[idx]`. Without it the applier fails that entry
|
||||
closed (`extraction-pointer-invalid`) and skips the deletion.
|
||||
5. **On extraction failure** (subagent errors, or `/os-vault:write` fails):
|
||||
do NOT add the index to `extraction_confirmed_indices` — leave the entry
|
||||
unconfirmed. This is a per-entry fails-closed skip (patch_applier.py will
|
||||
report `extraction-not-confirmed`), not a hard failure — continue
|
||||
processing remaining entries. Only an `OSError` on write or a `git add`
|
||||
failure is a hard failure → rollback per Step 7's Trap E procedure.
|
||||
|
||||
Build the scratch report copy the applier will read, marking confirmed
|
||||
entries `extraction_complete: true` (the canonical `$REPORT_PATH` on disk is
|
||||
NEVER mutated by this skill — only this scratch copy):
|
||||
|
||||
```python
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
report = json.loads(Path(REPORT_PATH).read_text())
|
||||
for idx in extraction_confirmed_indices:
|
||||
report["entries"][idx]["extraction_complete"] = True
|
||||
for idx, pointer in extraction_pointers.items():
|
||||
# cross-repo entries only: {"vault_note": ..., "reason": ..., "date": ...}
|
||||
report["entries"][idx]["extraction_pointer"] = pointer
|
||||
|
||||
Path(SCRATCH, "report_with_extraction.json").write_text(json.dumps(report))
|
||||
```
|
||||
|
||||
If this step was skipped (no `extract-then-delete` entries at all), Step 7
|
||||
below reads `$REPORT_PATH` directly instead of the scratch copy.
|
||||
|
||||
---
|
||||
|
||||
### Step 7 — (D) Apply approved deterministic entries via `patch_applier.py`
|
||||
|
||||
Build the comma-separated index list from `approved_det` original report indices:
|
||||
|
|
@ -298,11 +405,19 @@ Build the comma-separated index list from `approved_det` original report indices
|
|||
det_indices_str = ",".join(str(i) for i, _ in approved_det)
|
||||
```
|
||||
|
||||
Use `$SCRATCH/report_with_extraction.json` as `--report` if Step 6.5 ran
|
||||
(any `extraction_complete` markings need to reach the applier); otherwise use
|
||||
`$REPORT_PATH` unchanged. Report indices are unaffected either way — the
|
||||
scratch copy carries the same `entries[]` array positions as the original.
|
||||
|
||||
If `approved_det` is empty, skip this step.
|
||||
|
||||
```bash
|
||||
APPLIER_REPORT="$REPORT_PATH"
|
||||
[ -f "$SCRATCH/report_with_extraction.json" ] && APPLIER_REPORT="$SCRATCH/report_with_extraction.json"
|
||||
|
||||
python3 "${CLAUDE_PLUGIN_ROOT}/scripts/patch_applier.py" \
|
||||
--report "$REPORT_PATH" \
|
||||
--report "$APPLIER_REPORT" \
|
||||
--apply-indices "$DET_INDICES" \
|
||||
> "$SCRATCH/applier_result.json"
|
||||
APPLIER_EXIT=$?
|
||||
|
|
@ -570,7 +685,10 @@ Run /os-doc-hygiene:check to refresh the report and pick up any remaining issues
|
|||
| Write/git error in Step 8 | Rollback to baseline, abort. |
|
||||
| Applier exit 1, `failed[]` empty (Step 7) | Commit what applied; report skipped with re-analysis note. |
|
||||
| Subagent error in Step 8 | Skip that generative entry; continue. |
|
||||
| All entries skipped (Steps 7+8) | No commit, no stamp. Report all-skipped. |
|
||||
| Extraction subagent/`os-vault:write` error in Step 6.5 | Per-entry fails-closed skip — do NOT mark `extraction_complete`; applier reports `extraction-not-confirmed` for that index at Step 7. Continue processing other entries. NOT a hard failure. |
|
||||
| Write/git-add error during extraction (Step 6.5) | Rollback to baseline, abort (same as Step 8's write-error rule — an `OSError`/git failure is hard, a judgment error is not). |
|
||||
| `git-state-changed-since-check` skip (Step 7, applier) | Not a hard failure — the applier already downgraded this `auto`-tier lifecycle entry to skip because the path is no longer tracked+clean since `check` ran. Report with re-analysis note (re-run `/os-doc-hygiene:check`). |
|
||||
| All entries skipped (Steps 6.5+7+8) | No commit, no stamp. Report all-skipped. |
|
||||
| Commit failure (Step 9) | Rollback to baseline, abort. No stamp. |
|
||||
|
||||
Rollback procedure (used by multiple failure paths):
|
||||
|
|
@ -586,19 +704,31 @@ echo "Rolled back to $(git rev-parse --short HEAD). No commit was created."
|
|||
- **#4** — no new state artifacts; `last_clean` is the only new state entry.
|
||||
- **#5** — exactly one cleanup commit per run (plus an optional WIP checkpoint
|
||||
if the tree was dirty).
|
||||
- **#6** — only Steps 8 (generative distillation) and 5 (the confirm prompt
|
||||
rendered to the user) involve a model. All other steps are deterministic
|
||||
scripts or logic.
|
||||
- **#7** — confirm gate precedes every mutation, including the git preflight.
|
||||
- **#6** — only Steps 6.5 (lifecycle extraction), 8 (generative distillation),
|
||||
and 5 (the confirm prompt rendered to the user) involve a model. All other
|
||||
steps are deterministic scripts or logic.
|
||||
- **#7** — confirm gate precedes every mutation, including the git preflight
|
||||
AND lifecycle extraction (Step 6.5 only ever touches entries already in
|
||||
`approved_det`, i.e. already past the Step 5 gate).
|
||||
- **#8** — the applier enforces the content-hash guard; the skill trusts
|
||||
`failed[]` / `skipped[]` in the result.
|
||||
`failed[]` / `skipped[]` in the result. Lifecycle deletes additionally
|
||||
re-verify git tracked/clean state at apply time (never the report's cached
|
||||
`git_state`) — an entry cached as `auto` can still be downgraded to skip at
|
||||
apply time (ADR-0039).
|
||||
- **extraction fails closed** — `extract-then-delete` only proceeds to
|
||||
delete the source once `extraction_complete: true` is set on a per-entry
|
||||
basis by Step 6.5; a failed or skipped extraction withholds the delete for
|
||||
that entry only, never blocks the rest of the run.
|
||||
|
||||
## LOOP GUARD
|
||||
|
||||
The generative subagent prompt MUST point to
|
||||
`skills/clean/workflows/distill.md`, NEVER to this SKILL.md (prevents
|
||||
recursive skill invocation).
|
||||
recursive skill invocation). The `repo-durable` extraction subagent prompt
|
||||
MUST point to `skills/clean/workflows/extract.md`, likewise never this
|
||||
SKILL.md.
|
||||
|
||||
**SUBAGENT AUTHORIZATION:** the distill subagent is the executor; it MUST NOT
|
||||
block waiting for approval. If it objects, REPORT-AND-EXIT — the orchestrator
|
||||
adjudicates. The confirm gate (Step 5) never lives inside the subagent.
|
||||
**SUBAGENT AUTHORIZATION:** the distill and extract subagents are the
|
||||
executor; they MUST NOT block waiting for approval. If either objects,
|
||||
REPORT-AND-EXIT — the orchestrator adjudicates. The confirm gate (Step 5)
|
||||
never lives inside a subagent.
|
||||
|
|
|
|||
|
|
@ -0,0 +1,113 @@
|
|||
# Workflow: Extract doc-hygiene lifecycle entry (repo-durable)
|
||||
|
||||
You are the extraction subagent for the doc-hygiene `clean` skill's
|
||||
`extract-then-delete` lifecycle op (ADR-0039), for the **`repo-durable`**
|
||||
destination class only (`cross-repo` extraction is handled by `/os-vault:write`
|
||||
directly, not by this workflow). You preserve content worth keeping from a
|
||||
file that is about to be deleted, by writing it into a durable in-repo target
|
||||
(an ADR, `CLAUDE.md`, or a `docs/` file). You work entirely from the **live
|
||||
file contents provided to you** — you do not read files from disk and you do
|
||||
not write files to disk yourself. The skill that dispatched you handles the
|
||||
actual write and git staging, based on the structured result you return.
|
||||
|
||||
> Do NOT read or follow the parent `SKILL.md`. This workflow is self-contained.
|
||||
> Do NOT call any file-write or git tools. Return your result as structured text.
|
||||
|
||||
---
|
||||
|
||||
## Input
|
||||
|
||||
You receive, in your prompt:
|
||||
|
||||
- **File path** — the project-root-relative path of the file about to be
|
||||
deleted (the source of extraction).
|
||||
- **Lifecycle info** — `rule_ref`, `lifetime`, and the `served_when` /
|
||||
`served_when_path` evidence that justified deletion.
|
||||
- **Op / gloss** — the classifier's judgment about what this file is and why
|
||||
it's being deleted.
|
||||
- **Target hint** (optional) — a target doc reference the classifier
|
||||
proposed (e.g. "docs/adr/" or a specific `CLAUDE.md` section). This is a
|
||||
starting point, not a binding contract — use your judgment about the best
|
||||
durable home if the hint doesn't fit.
|
||||
- **Live file contents** — the full current text of the file, provided
|
||||
inline. Do NOT re-read it from disk.
|
||||
|
||||
---
|
||||
|
||||
## What "extract" means here
|
||||
|
||||
The source file is being deleted because its lifecycle has ended (e.g. a
|
||||
completed plan, a served one-off artifact). Before it goes, decide whether
|
||||
anything in it is worth a permanent, durable trace in the repo:
|
||||
|
||||
- A **decision** that was made → belongs in a new or amended ADR
|
||||
(`docs/adr/`) if the project has an ADR system; follow that project's ADR
|
||||
conventions if visible in the live contents or target hint.
|
||||
- A **standing fact or convention** future contributors need → belongs in
|
||||
`CLAUDE.md` or the most relevant `docs/` file.
|
||||
- **Ephemeral working detail with no lasting value** (a status update, a
|
||||
scratch note, a checklist that's now fully checked off) → there may be
|
||||
nothing worth extracting. It is legitimate to conclude "nothing durable
|
||||
here" — do not manufacture content to justify the op.
|
||||
|
||||
Be conservative about volume: extract the load-bearing decision or fact, not
|
||||
the whole file. A one-paragraph durable summary beats copy-pasting the
|
||||
source verbatim into the target.
|
||||
|
||||
---
|
||||
|
||||
## Output
|
||||
|
||||
Return ONLY a JSON object, no prose, no code fences:
|
||||
|
||||
**If there is durable content to preserve:**
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "extracted",
|
||||
"target_path": "docs/adr/0042-example-decision.md",
|
||||
"target_action": "create",
|
||||
"content": "<the durable markdown content to write>"
|
||||
}
|
||||
```
|
||||
|
||||
- `target_path` — project-root-relative path of the durable target. Use your
|
||||
judgment (informed by the target hint) — a new ADR file, an existing
|
||||
`CLAUDE.md`, or an existing/new `docs/` file.
|
||||
- `target_action` — `"create"` (target does not exist; skill creates it with
|
||||
exactly `content`) or `"append"` (target exists; skill appends `content` to
|
||||
it, e.g. adding a new ADR-numbered entry is still "create" for a fresh ADR
|
||||
file, but adding one paragraph to an existing `CLAUDE.md` is "append").
|
||||
- `content` — the durable markdown to write/append. Self-contained (correct
|
||||
heading level, no dangling references to the file about to be deleted).
|
||||
|
||||
**If nothing is worth extracting:**
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "nothing-to-extract",
|
||||
"reason": "The file is a fully-served checklist with no standing decision or convention left to preserve."
|
||||
}
|
||||
```
|
||||
|
||||
This is a legitimate, successful outcome — the skill treats it the same as
|
||||
`"extracted"` for the purpose of proceeding to delete the source (extraction
|
||||
"succeeded" in the sense that the judgment call was made; there is simply
|
||||
nothing to write). Only use `{"status": "error", ...}` (see below) when you
|
||||
cannot make the judgment call at all.
|
||||
|
||||
**On error / unable to proceed:**
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "error",
|
||||
"reason": "<why you could not extract>"
|
||||
}
|
||||
```
|
||||
|
||||
An error result is a per-entry fails-closed skip upstream (the delete does
|
||||
NOT proceed for this entry) — it is never a hard failure. AUTHORIZATION: you
|
||||
are the executor — the human confirm gate ran upstream in the orchestrating
|
||||
skill; do NOT re-ask for approval or wait for confirmation that cannot
|
||||
arrive. If you believe you should not proceed, return your objection via the
|
||||
`"error"` status and stop immediately (REPORT-AND-EXIT).
|
||||
|
|
@ -0,0 +1,262 @@
|
|||
"""
|
||||
Tests for scripts/calibrate_helpers.py
|
||||
|
||||
Covers task 5.4 of the lifecycle-aware-doc-hygiene change:
|
||||
- ClusterSampler: group unmatched paths by path-shape, capped sample per
|
||||
cluster
|
||||
- RuleReportBuilder: 5-element rule-report data (glob verbatim, matched
|
||||
sample+total, near-miss boundary, lifetime+tier, why)
|
||||
- RuleQualityChecker: class-not-path lint (instance-unique identifiers /
|
||||
no-wildcard-single-match) and prefer-the-narrower-glob tie-break
|
||||
|
||||
sys.path is patched here (no shared conftest), matching the existing
|
||||
tests/test_rulebook.py pattern.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
_SCRIPTS_DIR = Path(__file__).parent.parent / "scripts"
|
||||
if str(_SCRIPTS_DIR) not in sys.path:
|
||||
sys.path.insert(0, str(_SCRIPTS_DIR))
|
||||
|
||||
from calibrate_helpers import ( # noqa: E402
|
||||
ClusterSampler,
|
||||
RuleQualityChecker,
|
||||
RuleReportBuilder,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ClusterSampler
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestClusterSampler:
|
||||
def test_groups_by_directory_and_filename_shape(self):
|
||||
paths = [
|
||||
"autoresearch/run-12345678/notes.md",
|
||||
"autoresearch/run-87654321/notes.md",
|
||||
"autoresearch/run-11112222/notes.md",
|
||||
"docs/HANDOFF-2026-07-01.md",
|
||||
"docs/HANDOFF-2026-07-02.md",
|
||||
]
|
||||
sampler = ClusterSampler()
|
||||
clusters = sampler.cluster(paths)
|
||||
|
||||
# autoresearch/run-# dirs form one cluster (same dir-shape prefix
|
||||
# pattern doesn't collapse dir segments, but filenames do) —
|
||||
# here dir segments differ (run-12345678 vs run-87654321), so they
|
||||
# are distinct directories; but the *filenames* are identical
|
||||
# ("notes.md") which is itself a valid single-file-per-dir cluster
|
||||
# test. Assert basic invariants instead of exact grouping count.
|
||||
assert len(clusters) >= 1
|
||||
total_paths = sum(c.total for c in [c for c in clusters])
|
||||
# every path is accounted for
|
||||
seen = set()
|
||||
for c in clusters:
|
||||
seen.update(c.paths)
|
||||
assert seen == set(paths)
|
||||
|
||||
def test_same_dir_and_shape_groups_together(self):
|
||||
paths = [
|
||||
"logs/log-00000001.txt",
|
||||
"logs/log-00000002.txt",
|
||||
"logs/log-00000003.txt",
|
||||
]
|
||||
sampler = ClusterSampler()
|
||||
clusters = sampler.cluster(paths)
|
||||
assert len(clusters) == 1
|
||||
assert clusters[0].total == 3
|
||||
assert set(clusters[0].paths) == set(paths)
|
||||
|
||||
def test_sample_is_capped(self):
|
||||
paths = [f"logs/log-{i:08d}.txt" for i in range(20)]
|
||||
sampler = ClusterSampler(sample_cap=5)
|
||||
clusters = sampler.cluster(paths)
|
||||
assert len(clusters) == 1
|
||||
assert clusters[0].total == 20
|
||||
assert len(clusters[0].sample) == 5
|
||||
|
||||
def test_different_directories_are_different_clusters(self):
|
||||
paths = ["a/file.md", "b/file.md"]
|
||||
sampler = ClusterSampler()
|
||||
clusters = sampler.cluster(paths)
|
||||
assert len(clusters) == 2
|
||||
|
||||
def test_hex_run_collapses_to_shape_class(self):
|
||||
paths = [
|
||||
"cache/deadbeefcafe.json",
|
||||
"cache/0123456789ab.json",
|
||||
]
|
||||
sampler = ClusterSampler()
|
||||
clusters = sampler.cluster(paths)
|
||||
# both filenames collapse to the same hex-shape class -> one cluster
|
||||
assert len(clusters) == 1
|
||||
assert clusters[0].total == 2
|
||||
|
||||
def test_to_dict_is_json_serializable(self):
|
||||
import json
|
||||
|
||||
paths = ["docs/PRD.md"]
|
||||
sampler = ClusterSampler()
|
||||
dicts = sampler.cluster_to_dicts(paths)
|
||||
json.dumps(dicts) # must not raise
|
||||
assert dicts[0]["total"] == 1
|
||||
assert dicts[0]["sample"] == ["docs/PRD.md"]
|
||||
|
||||
def test_empty_input_yields_no_clusters(self):
|
||||
sampler = ClusterSampler()
|
||||
assert sampler.cluster([]) == []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# RuleReportBuilder
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestRuleReportBuilder:
|
||||
def test_glob_is_verbatim(self):
|
||||
all_paths = ["docs/HANDOFF-1.md", "docs/HANDOFF-2.md"]
|
||||
rule = {"glob": "docs/HANDOFF-*.md", "lifetime": "temporary", "tier": "auto"}
|
||||
entry = RuleReportBuilder().build(rule, all_paths)
|
||||
assert entry.glob == "docs/HANDOFF-*.md"
|
||||
|
||||
def test_matches_sample_and_total(self):
|
||||
all_paths = [f"docs/HANDOFF-{i}.md" for i in range(10)]
|
||||
rule = {"glob": "docs/HANDOFF-*.md", "lifetime": "temporary", "tier": "auto"}
|
||||
entry = RuleReportBuilder(sample_cap=3).build(rule, all_paths)
|
||||
assert entry.matched_total == 10
|
||||
assert len(entry.matched_sample) == 3
|
||||
|
||||
def test_near_miss_boundary_detects_sibling_paths(self):
|
||||
# A too-narrow glob silently misses a sibling path under the same
|
||||
# parent directory — this is exactly the #45 boundary bug
|
||||
# (autoresearch/classic-*/ missing autoresearch/improve-*/).
|
||||
all_paths = [
|
||||
"autoresearch/classic-260710-1057/report.md",
|
||||
"autoresearch/improve-260710-1057/report.md",
|
||||
]
|
||||
rule = {"glob": "autoresearch/classic-*/report.md", "lifetime": "temporary", "tier": "confirm"}
|
||||
entry = RuleReportBuilder().build(rule, all_paths)
|
||||
assert entry.matched_total == 1
|
||||
assert "autoresearch/improve-260710-1057/report.md" in entry.near_miss
|
||||
|
||||
def test_no_near_miss_when_glob_covers_all_similar_paths(self):
|
||||
all_paths = [
|
||||
"autoresearch/classic-1/report.md",
|
||||
"autoresearch/classic-2/report.md",
|
||||
]
|
||||
rule = {"glob": "autoresearch/*/report.md", "lifetime": "temporary", "tier": "confirm"}
|
||||
entry = RuleReportBuilder().build(rule, all_paths)
|
||||
assert entry.matched_total == 2
|
||||
assert entry.near_miss == []
|
||||
|
||||
def test_lifetime_and_tier_carried_through(self):
|
||||
rule = {"glob": "x/*.md", "lifetime": "delete-once-served", "tier": "confirm"}
|
||||
entry = RuleReportBuilder().build(rule, ["x/a.md"])
|
||||
assert entry.lifetime == "delete-once-served"
|
||||
assert entry.tier == "confirm"
|
||||
|
||||
def test_build_all_is_json_serializable(self):
|
||||
import json
|
||||
|
||||
rules = [
|
||||
{"glob": "docs/HANDOFF-*.md", "lifetime": "temporary", "tier": "auto"},
|
||||
]
|
||||
dicts = RuleReportBuilder().build_all(rules, ["docs/HANDOFF-1.md"])
|
||||
json.dumps(dicts)
|
||||
assert dicts[0]["matches"]["total"] == 1
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# RuleQualityChecker
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestRuleQualityCheckerClassNotPath:
|
||||
def test_convention_recurring_name_passes(self):
|
||||
checker = RuleQualityChecker()
|
||||
finding = checker.class_not_path("docs/HANDOFF-*.md")
|
||||
assert finding.passed is True
|
||||
|
||||
def test_recurring_filename_glob_with_wildcard_passes(self):
|
||||
# "**/PRD.md" is structurally able to match PRD.md in any directory
|
||||
# -- the wildcard makes it a class, not a single-instance path, even
|
||||
# though the literal basename "PRD.md" is itself a fixed name.
|
||||
checker = RuleQualityChecker()
|
||||
finding = checker.class_not_path(
|
||||
"**/PRD.md", all_paths=["docs/PRD.md", "other/PRD.md"]
|
||||
)
|
||||
assert finding.passed is True
|
||||
|
||||
def test_long_digit_run_fails(self):
|
||||
checker = RuleQualityChecker()
|
||||
finding = checker.class_not_path("logs/log-20260714153045.txt")
|
||||
assert finding.passed is False
|
||||
assert "digit" in finding.reason.lower()
|
||||
|
||||
def test_hex_hash_fails(self):
|
||||
checker = RuleQualityChecker()
|
||||
finding = checker.class_not_path("cache/deadbeefcafe1234.bin")
|
||||
assert finding.passed is False
|
||||
assert "hash" in finding.reason.lower() or "hex" in finding.reason.lower()
|
||||
|
||||
def test_one_current_match_is_fine_if_glob_has_wildcard(self):
|
||||
checker = RuleQualityChecker()
|
||||
finding = checker.class_not_path("docs/HANDOFF-*.md", all_paths=["docs/HANDOFF-1.md"])
|
||||
assert finding.passed is True
|
||||
|
||||
def test_wildcard_free_glob_matching_only_one_path_ever_fails(self):
|
||||
checker = RuleQualityChecker()
|
||||
finding = checker.class_not_path(
|
||||
"docs/migration-report.md", all_paths=["docs/migration-report.md"]
|
||||
)
|
||||
assert finding.passed is False
|
||||
assert "generalization" in finding.reason.lower()
|
||||
|
||||
def test_wildcard_free_glob_with_multiple_matches_passes(self):
|
||||
# a bare name matching >1 existing path is a recurring convention
|
||||
# even with no wildcard char (unusual but should not be flagged)
|
||||
checker = RuleQualityChecker()
|
||||
finding = checker.class_not_path(
|
||||
"PRD.md", all_paths=["a/PRD.md"]
|
||||
)
|
||||
# single match with a bare literal glob (no wildcard) still fails,
|
||||
# since "PRD.md" as an exact-path glob wouldn't even match "a/PRD.md"
|
||||
# (fnmatch requires the full string) -- so total matches = 0 -> not
|
||||
# flagged for "matches <=1 existing path" since it's 0 (never seen).
|
||||
# This asserts the checker doesn't crash and returns a finding.
|
||||
assert finding.check == "class_not_path"
|
||||
|
||||
|
||||
class TestRuleQualityCheckerPreferNarrower:
|
||||
def test_narrower_glob_by_match_count_wins(self):
|
||||
checker = RuleQualityChecker()
|
||||
all_paths = [
|
||||
"autoresearch/classic-1/report.md",
|
||||
"autoresearch/classic-2/report.md",
|
||||
"autoresearch/improve-1/report.md",
|
||||
]
|
||||
result = checker.prefer_narrower(
|
||||
"autoresearch/classic-*/report.md", "autoresearch/*/report.md", all_paths
|
||||
)
|
||||
assert result["narrower"] == "autoresearch/classic-*/report.md"
|
||||
assert result["match_counts"]["autoresearch/classic-*/report.md"] == 2
|
||||
assert result["match_counts"]["autoresearch/*/report.md"] == 3
|
||||
|
||||
def test_tie_break_prefers_longer_pattern(self):
|
||||
checker = RuleQualityChecker()
|
||||
all_paths = ["a/b.md"]
|
||||
result = checker.prefer_narrower("a/*.md", "a/b.md", all_paths)
|
||||
# both match exactly 1 path -> tie -> longer raw pattern wins
|
||||
assert result["narrower"] == "a/b.md"
|
||||
|
||||
def test_result_is_json_serializable(self):
|
||||
import json
|
||||
|
||||
checker = RuleQualityChecker()
|
||||
result = checker.prefer_narrower("a/*.md", "a/*.txt", ["a/b.md"])
|
||||
json.dumps(result)
|
||||
|
|
@ -0,0 +1,144 @@
|
|||
"""
|
||||
Tests for `NominationIntakeFilter` in scripts/calibrate_helpers.py.
|
||||
|
||||
Covers tasks 2.1/2.2 of the calibrate-assessment-inventory change: the
|
||||
deterministic, no-model filter that runs between haiku nomination and the
|
||||
strong-model judge (lifecycle-spec.md §8 step 3, calibrate spec's
|
||||
"Deterministic Nomination Intake Filter" requirement).
|
||||
|
||||
sys.path is patched here (no shared conftest), matching the existing
|
||||
tests/test_calibrate_helpers.py pattern.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
_SCRIPTS_DIR = Path(__file__).parent.parent / "scripts"
|
||||
if str(_SCRIPTS_DIR) not in sys.path:
|
||||
sys.path.insert(0, str(_SCRIPTS_DIR))
|
||||
|
||||
from calibrate_helpers import NominationIntakeFilter # noqa: E402
|
||||
|
||||
|
||||
def _rejected(glob, lifetime, why="why", consider_instead=None):
|
||||
d = {
|
||||
"glob": glob,
|
||||
"lifetime": lifetime,
|
||||
"why": why,
|
||||
"rejected_by": "judge",
|
||||
"judged_on": "2026-07-15",
|
||||
}
|
||||
if consider_instead is not None:
|
||||
d["consider_instead"] = consider_instead
|
||||
return d
|
||||
|
||||
|
||||
def _consult(glob, question="q?"):
|
||||
return {
|
||||
"glob": glob,
|
||||
"question": question,
|
||||
"evidence": "e",
|
||||
"cluster_key": "k",
|
||||
"asked_on": "2026-07-15",
|
||||
}
|
||||
|
||||
|
||||
class TestExactRepeatDrop:
|
||||
def test_exact_glob_and_lifetime_match_is_dropped_and_logged(self):
|
||||
rejected = [_rejected("docs/research/**", "temporary")]
|
||||
filt = NominationIntakeFilter(rejected=rejected, consults=[])
|
||||
|
||||
nominations = [{"glob": "docs/research/**", "lifetime": "temporary"}]
|
||||
shortlist = ["docs/research/notes.md"]
|
||||
|
||||
result = filt.filter(nominations, shortlist)
|
||||
|
||||
assert result["survivors"] == []
|
||||
assert len(result["dropped"]) == 1
|
||||
assert result["dropped"][0]["glob"] == "docs/research/**"
|
||||
assert result["dropped"][0]["lifetime"] == "temporary"
|
||||
|
||||
def test_same_glob_different_lifetime_is_not_dropped(self):
|
||||
rejected = [_rejected("docs/research/**", "temporary")]
|
||||
filt = NominationIntakeFilter(rejected=rejected, consults=[])
|
||||
|
||||
nominations = [{"glob": "docs/research/**", "lifetime": "keep"}]
|
||||
shortlist = ["docs/research/notes.md"]
|
||||
|
||||
result = filt.filter(nominations, shortlist)
|
||||
|
||||
assert len(result["survivors"]) == 1
|
||||
assert result["dropped"] == []
|
||||
|
||||
|
||||
class TestVariantAnnotation:
|
||||
def test_variant_survives_annotated_with_related_rejection(self):
|
||||
# #52 worked example: docs/research/** -> temporary was rejected;
|
||||
# docs/research/drafts/** -> temporary is a narrower variant whose
|
||||
# match set intersects the rejected glob's match set on the
|
||||
# shortlist -- it survives, annotated with the rejection.
|
||||
rejected = [
|
||||
_rejected(
|
||||
"docs/research/**",
|
||||
"temporary",
|
||||
why="findings docs other artifacts link to",
|
||||
consider_instead="an extract-to-vault rule",
|
||||
)
|
||||
]
|
||||
filt = NominationIntakeFilter(rejected=rejected, consults=[])
|
||||
|
||||
nominations = [{"glob": "docs/research/drafts/**", "lifetime": "temporary"}]
|
||||
shortlist = [
|
||||
"docs/research/drafts/outline.md",
|
||||
"docs/research/other-notes.md",
|
||||
]
|
||||
|
||||
result = filt.filter(nominations, shortlist)
|
||||
|
||||
assert len(result["survivors"]) == 1
|
||||
survivor = result["survivors"][0]
|
||||
assert survivor["glob"] == "docs/research/drafts/**"
|
||||
assert len(survivor["related_rejections"]) == 1
|
||||
related = survivor["related_rejections"][0]
|
||||
assert related["glob"] == "docs/research/**"
|
||||
assert related["why"] == "findings docs other artifacts link to"
|
||||
assert related["consider_instead"] == "an extract-to-vault rule"
|
||||
assert result["dropped"] == []
|
||||
|
||||
def test_no_annotation_when_match_sets_do_not_intersect(self):
|
||||
rejected = [_rejected("docs/research/**", "temporary")]
|
||||
filt = NominationIntakeFilter(rejected=rejected, consults=[])
|
||||
|
||||
nominations = [{"glob": "docs/other-area/**", "lifetime": "temporary"}]
|
||||
# shortlist has no path that both globs could match in common
|
||||
shortlist = ["docs/research/notes.md", "docs/other-area/thing.md"]
|
||||
|
||||
result = filt.filter(nominations, shortlist)
|
||||
|
||||
assert len(result["survivors"]) == 1
|
||||
survivor = result["survivors"][0]
|
||||
assert survivor["related_rejections"] == []
|
||||
|
||||
|
||||
class TestOpenConsultsPassThrough:
|
||||
def test_open_consults_pass_through_even_with_no_nominations(self):
|
||||
consults = [_consult("docs/orchestration-audit/*.md")]
|
||||
filt = NominationIntakeFilter(rejected=[], consults=consults)
|
||||
|
||||
result = filt.filter([], [])
|
||||
|
||||
assert result["consults"] == consults
|
||||
assert result["survivors"] == []
|
||||
assert result["dropped"] == []
|
||||
|
||||
def test_open_consults_pass_through_alongside_survivors(self):
|
||||
consults = [_consult("docs/orchestration-audit/*.md")]
|
||||
filt = NominationIntakeFilter(rejected=[], consults=consults)
|
||||
|
||||
nominations = [{"glob": "a/*.md", "lifetime": "keep"}]
|
||||
result = filt.filter(nominations, ["a/x.md"])
|
||||
|
||||
assert result["consults"] == consults
|
||||
assert len(result["survivors"]) == 1
|
||||
|
|
@ -838,3 +838,643 @@ class TestApplyIndexed:
|
|||
result = _applier(tmp_path, fs=fs).apply_indexed([(7, entry)])
|
||||
|
||||
assert result["applied"][0]["entry_index"] == 7
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# Lifecycle deletion ops: `delete` and `extract-then-delete` (ADR-0039)
|
||||
# ===========================================================================
|
||||
#
|
||||
# These two kinds are NOT drawn from KIND_TABLE (owned by report_builder.py /
|
||||
# validate_report.py, group 3's file) — they are constructed directly here so
|
||||
# these tests do not depend on that concurrent work landing first.
|
||||
|
||||
class RecordingGitState:
|
||||
"""Injectable git-state double: fixed tracked/dirty answers per test."""
|
||||
|
||||
def __init__(self, tracked: bool = True, dirty: bool = False) -> None:
|
||||
self.tracked = tracked
|
||||
self.dirty = dirty
|
||||
self.is_tracked_calls: list[tuple[str, str]] = []
|
||||
self.is_dirty_calls: list[tuple[str, str]] = []
|
||||
|
||||
def is_tracked(self, path: str, cwd: Path) -> bool:
|
||||
self.is_tracked_calls.append((path, str(cwd)))
|
||||
return self.tracked
|
||||
|
||||
def is_dirty(self, path: str, cwd: Path) -> bool:
|
||||
self.is_dirty_calls.append((path, str(cwd)))
|
||||
return self.dirty
|
||||
|
||||
|
||||
def _lifecycle_entry(
|
||||
path: str,
|
||||
kind: str,
|
||||
safety_tier: str = "auto",
|
||||
is_directory: bool = False,
|
||||
extraction_complete: bool | None = None,
|
||||
lifecycle: dict | None = None,
|
||||
extraction_target: str | None = None,
|
||||
extraction_pointer: dict | None = None,
|
||||
) -> dict:
|
||||
"""Build a minimal `delete` / `extract-then-delete` report entry.
|
||||
|
||||
Deliberately independent of KIND_TABLE (see module docstring above).
|
||||
`extraction_target` (when given) mirrors the pre-existing frozen-schema
|
||||
field validate_report.py already requires on extract-then-delete entries
|
||||
(`"repo-durable"` or `"cross-repo"`, the vault case).
|
||||
"""
|
||||
exact_edit: dict[str, Any] = {"kind": kind}
|
||||
if is_directory:
|
||||
exact_edit["is_directory"] = True
|
||||
if extraction_target is not None:
|
||||
exact_edit["extraction_target"] = extraction_target
|
||||
if extraction_complete is not None:
|
||||
exact_edit["extraction_complete"] = extraction_complete
|
||||
entry_extraction_complete = extraction_complete
|
||||
entry: dict[str, Any] = {
|
||||
"path": path,
|
||||
"op": f"test op {kind}",
|
||||
"op_type": "deterministic",
|
||||
"is_destructive": True,
|
||||
"is_reversible": False,
|
||||
"safety_tier": safety_tier,
|
||||
"exact_edit": exact_edit,
|
||||
"lifecycle": lifecycle or {
|
||||
"rule_ref": "temporary/test-rule",
|
||||
"lifetime": "temporary",
|
||||
},
|
||||
}
|
||||
if extraction_complete is not None:
|
||||
entry["extraction_complete"] = extraction_complete
|
||||
if extraction_pointer is not None:
|
||||
entry["extraction_pointer"] = extraction_pointer
|
||||
return entry
|
||||
|
||||
|
||||
def _applier_ls(root: Path, fs=None, git=None, git_state=None) -> PatchApplier:
|
||||
return PatchApplier(project_root=root, fs=fs, git=git, git_state=git_state)
|
||||
|
||||
|
||||
class RecordingGitRm:
|
||||
"""Records git rm calls (and optionally mv, unused here)."""
|
||||
|
||||
def __init__(self, fail: bool = False) -> None:
|
||||
self.rm_calls: list[tuple[str, str, bool]] = []
|
||||
self.mv_calls: list[tuple[str, str]] = []
|
||||
self._fail = fail
|
||||
|
||||
def mv(self, src: Path, dest: Path, cwd: Path) -> None:
|
||||
self.mv_calls.append((str(src), str(dest)))
|
||||
|
||||
def rm(self, path: Path, cwd: Path, recursive: bool = False) -> None:
|
||||
if self._fail:
|
||||
raise RuntimeError("git rm simulated failure")
|
||||
self.rm_calls.append((str(path), str(cwd), recursive))
|
||||
|
||||
|
||||
class TestLifecycleDeleteApplyTimeReverification:
|
||||
"""Task 4.1(a): apply-time re-verification — never trust cached tier."""
|
||||
|
||||
def test_auto_tier_tracked_clean_applies_via_git_rm(self, tmp_path):
|
||||
fs = MemFS()
|
||||
git = RecordingGitRm()
|
||||
git_state = RecordingGitState(tracked=True, dirty=False)
|
||||
entry = _lifecycle_entry("docs/old.md", "delete", safety_tier="auto")
|
||||
|
||||
result = _applier_ls(tmp_path, fs=fs, git=git, git_state=git_state).apply([entry])
|
||||
|
||||
assert result["applied"][0]["kind"] == "delete"
|
||||
assert result["skipped"] == []
|
||||
assert len(git.rm_calls) == 1
|
||||
rm_path, rm_cwd, recursive = git.rm_calls[0]
|
||||
assert rm_path == str(tmp_path / "docs" / "old.md")
|
||||
assert recursive is False
|
||||
assert "docs/old.md" in result["staged_paths"]
|
||||
# Re-verification actually consulted live git state for this path.
|
||||
assert git_state.is_tracked_calls == [("docs/old.md", str(tmp_path))]
|
||||
|
||||
def test_auto_tier_downgraded_to_skip_when_untracked_since_check(self, tmp_path):
|
||||
fs = MemFS()
|
||||
git = RecordingGitRm()
|
||||
git_state = RecordingGitState(tracked=False)
|
||||
entry = _lifecycle_entry("docs/old.md", "delete", safety_tier="auto")
|
||||
|
||||
result = _applier_ls(tmp_path, fs=fs, git=git, git_state=git_state).apply([entry])
|
||||
|
||||
assert result["applied"] == []
|
||||
assert result["skipped"][0]["reason"] == "git-state-changed-since-check"
|
||||
assert git.rm_calls == []
|
||||
|
||||
def test_auto_tier_downgraded_to_skip_when_dirty_since_check(self, tmp_path):
|
||||
fs = MemFS()
|
||||
git = RecordingGitRm()
|
||||
git_state = RecordingGitState(tracked=True, dirty=True)
|
||||
entry = _lifecycle_entry("docs/old.md", "delete", safety_tier="auto")
|
||||
|
||||
result = _applier_ls(tmp_path, fs=fs, git=git, git_state=git_state).apply([entry])
|
||||
|
||||
assert result["applied"] == []
|
||||
assert result["skipped"][0]["reason"] == "git-state-changed-since-check"
|
||||
assert git.rm_calls == []
|
||||
|
||||
def test_other_entries_on_other_paths_continue_after_a_skip(self, tmp_path):
|
||||
"""One entry's guard-skip must not block a sibling path's delete."""
|
||||
fs = MemFS()
|
||||
git = RecordingGitRm()
|
||||
git_state = RecordingGitState(tracked=False) # everything looks untracked
|
||||
entries = [
|
||||
_lifecycle_entry("docs/a.md", "delete", safety_tier="auto"),
|
||||
_lifecycle_entry("docs/b.md", "delete", safety_tier="auto"),
|
||||
]
|
||||
|
||||
result = _applier_ls(tmp_path, fs=fs, git=git, git_state=git_state).apply(entries)
|
||||
|
||||
assert result["applied"] == []
|
||||
assert len(result["skipped"]) == 2
|
||||
reasons = {s["reason"] for s in result["skipped"]}
|
||||
assert reasons == {"git-state-changed-since-check"}
|
||||
|
||||
def test_confirm_tier_lifecycle_delete_is_not_re_verified(self, tmp_path):
|
||||
"""
|
||||
A confirm-tier entry was already gated through explicit human
|
||||
approval upstream (batch-confirm). The applier must not silently
|
||||
downgrade/skip it just because the path is untracked or dirty —
|
||||
that dirty/untracked state is precisely why it was confirm-tier.
|
||||
"""
|
||||
fs = MemFS()
|
||||
git = RecordingGitRm()
|
||||
git_state = RecordingGitState(tracked=False, dirty=True)
|
||||
entry = _lifecycle_entry("docs/old.md", "delete", safety_tier="confirm")
|
||||
|
||||
result = _applier_ls(tmp_path, fs=fs, git=git, git_state=git_state).apply([entry])
|
||||
|
||||
assert result["applied"][0]["kind"] == "delete"
|
||||
assert len(git.rm_calls) == 1
|
||||
# No git-state query needed at all for an already-confirmed entry.
|
||||
assert git_state.is_tracked_calls == []
|
||||
|
||||
|
||||
class TestLifecycleDeleteGitRm:
|
||||
"""Task 4.1(b): `delete` performs true git rm, staged precisely."""
|
||||
|
||||
def test_directory_aggregate_entry_uses_recursive_git_rm(self, tmp_path):
|
||||
fs = MemFS()
|
||||
git = RecordingGitRm()
|
||||
git_state = RecordingGitState(tracked=True, dirty=False)
|
||||
entry = _lifecycle_entry(
|
||||
"autoresearch/run-042", "delete", safety_tier="auto", is_directory=True
|
||||
)
|
||||
|
||||
result = _applier_ls(tmp_path, fs=fs, git=git, git_state=git_state).apply([entry])
|
||||
|
||||
assert result["applied"][0]["kind"] == "delete"
|
||||
rm_path, rm_cwd, recursive = git.rm_calls[0]
|
||||
assert rm_path == str(tmp_path / "autoresearch" / "run-042")
|
||||
assert recursive is True
|
||||
|
||||
def test_directory_delete_bypasses_content_hash_guard(self, tmp_path):
|
||||
"""
|
||||
A directory aggregate entry carries no expected_sha256 (no single
|
||||
file to hash) — it must still apply cleanly via git rm, never
|
||||
rejected for "missing" hash fields.
|
||||
"""
|
||||
fs = MemFS()
|
||||
git = RecordingGitRm()
|
||||
git_state = RecordingGitState(tracked=True, dirty=False)
|
||||
entry = _lifecycle_entry(
|
||||
"autoresearch/run-001", "delete", safety_tier="auto", is_directory=True
|
||||
)
|
||||
assert "expected_sha256" not in entry["exact_edit"]
|
||||
|
||||
result = _applier_ls(tmp_path, fs=fs, git=git, git_state=git_state).apply([entry])
|
||||
|
||||
assert result["applied"][0]["kind"] == "delete"
|
||||
assert result["failed"] == []
|
||||
|
||||
def test_git_rm_failure_reported_as_failed(self, tmp_path):
|
||||
fs = MemFS()
|
||||
git = RecordingGitRm(fail=True)
|
||||
git_state = RecordingGitState(tracked=True, dirty=False)
|
||||
entry = _lifecycle_entry("docs/old.md", "delete", safety_tier="auto")
|
||||
|
||||
result = _applier_ls(tmp_path, fs=fs, git=git, git_state=git_state).apply([entry])
|
||||
|
||||
assert result["applied"] == []
|
||||
assert result["failed"][0]["kind"] == "delete"
|
||||
assert "git rm simulated failure" in result["failed"][0]["error"]
|
||||
|
||||
def test_real_git_rm_stages_deletion(self, tmp_path):
|
||||
"""Integration: real git rm in a real repo removes + stages the file."""
|
||||
repo = _init_git_repo(tmp_path)
|
||||
target = repo / "old.md"
|
||||
target.write_bytes(b"stale content\n")
|
||||
subprocess.run(["git", "add", "old.md"], cwd=str(repo), check=True, capture_output=True)
|
||||
subprocess.run(
|
||||
["git", "commit", "-m", "add file"],
|
||||
cwd=str(repo), check=True, capture_output=True,
|
||||
)
|
||||
|
||||
entry = _lifecycle_entry("old.md", "delete", safety_tier="auto")
|
||||
result = PatchApplier(project_root=repo).apply([entry])
|
||||
|
||||
assert result["applied"][0]["kind"] == "delete"
|
||||
assert not target.exists(), "file should be gone after git rm"
|
||||
|
||||
status = subprocess.run(
|
||||
["git", "status", "--porcelain"],
|
||||
cwd=str(repo), capture_output=True, text=True,
|
||||
).stdout
|
||||
# Staged deletion shows as "D old.md" (space, not "D " in worktree column).
|
||||
assert "D old.md" in status
|
||||
|
||||
|
||||
class TestExtractThenDeleteFailsClosed:
|
||||
"""Task 4.1(c): extract-then-delete only deletes once extraction is done."""
|
||||
|
||||
def test_skipped_when_extraction_not_marked_complete(self, tmp_path):
|
||||
fs = MemFS()
|
||||
git = RecordingGitRm()
|
||||
git_state = RecordingGitState(tracked=True, dirty=False)
|
||||
entry = _lifecycle_entry(
|
||||
"docs/old-guide.md", "extract-then-delete", safety_tier="auto",
|
||||
extraction_complete=False,
|
||||
)
|
||||
|
||||
result = _applier_ls(tmp_path, fs=fs, git=git, git_state=git_state).apply([entry])
|
||||
|
||||
assert result["applied"] == []
|
||||
assert result["skipped"][0]["reason"] == "extraction-not-confirmed"
|
||||
assert git.rm_calls == []
|
||||
|
||||
def test_skipped_when_extraction_flag_absent(self, tmp_path):
|
||||
"""No extraction_complete field at all → treated as not confirmed."""
|
||||
fs = MemFS()
|
||||
git = RecordingGitRm()
|
||||
git_state = RecordingGitState(tracked=True, dirty=False)
|
||||
entry = _lifecycle_entry(
|
||||
"docs/old-guide.md", "extract-then-delete", safety_tier="auto",
|
||||
)
|
||||
|
||||
result = _applier_ls(tmp_path, fs=fs, git=git, git_state=git_state).apply([entry])
|
||||
|
||||
assert result["applied"] == []
|
||||
assert result["skipped"][0]["reason"] == "extraction-not-confirmed"
|
||||
assert git.rm_calls == []
|
||||
|
||||
def test_applies_git_rm_once_extraction_confirmed(self, tmp_path):
|
||||
fs = MemFS()
|
||||
git = RecordingGitRm()
|
||||
git_state = RecordingGitState(tracked=True, dirty=False)
|
||||
entry = _lifecycle_entry(
|
||||
"docs/old-guide.md", "extract-then-delete", safety_tier="auto",
|
||||
extraction_complete=True,
|
||||
)
|
||||
|
||||
result = _applier_ls(tmp_path, fs=fs, git=git, git_state=git_state).apply([entry])
|
||||
|
||||
assert result["applied"][0]["kind"] == "extract-then-delete"
|
||||
assert len(git.rm_calls) == 1
|
||||
|
||||
def test_failed_extraction_is_per_entry_skip_not_run_level_failure(self, tmp_path):
|
||||
"""
|
||||
A failed extraction for one entry must not prevent a sibling path's
|
||||
entry (independent extraction) from applying in the same run.
|
||||
"""
|
||||
fs = MemFS()
|
||||
git = RecordingGitRm()
|
||||
git_state = RecordingGitState(tracked=True, dirty=False)
|
||||
entries = [
|
||||
_lifecycle_entry(
|
||||
"docs/not-extracted.md", "extract-then-delete", safety_tier="auto",
|
||||
extraction_complete=False,
|
||||
),
|
||||
_lifecycle_entry(
|
||||
"docs/extracted.md", "extract-then-delete", safety_tier="auto",
|
||||
extraction_complete=True,
|
||||
),
|
||||
]
|
||||
|
||||
result = _applier_ls(tmp_path, fs=fs, git=git, git_state=git_state).apply(entries)
|
||||
|
||||
assert len(result["applied"]) == 1
|
||||
assert result["applied"][0]["path"] == "docs/extracted.md"
|
||||
assert len(result["skipped"]) == 1
|
||||
assert result["skipped"][0]["reason"] == "extraction-not-confirmed"
|
||||
|
||||
def test_confirm_tier_extract_then_delete_still_fails_closed_on_extraction(self, tmp_path):
|
||||
"""
|
||||
Even for an already-approved confirm-tier entry, extraction
|
||||
confirmation is unconditional — the applier itself never performs
|
||||
the generative extraction, so it cannot proceed without the flag.
|
||||
"""
|
||||
fs = MemFS()
|
||||
git = RecordingGitRm()
|
||||
git_state = RecordingGitState(tracked=False, dirty=True)
|
||||
entry = _lifecycle_entry(
|
||||
"docs/old-guide.md", "extract-then-delete", safety_tier="confirm",
|
||||
extraction_complete=False,
|
||||
)
|
||||
|
||||
result = _applier_ls(tmp_path, fs=fs, git=git, git_state=git_state).apply([entry])
|
||||
|
||||
assert result["applied"] == []
|
||||
assert result["skipped"][0]["reason"] == "extraction-not-confirmed"
|
||||
assert git.rm_calls == []
|
||||
|
||||
|
||||
class WriteFailsFS(MemFS):
|
||||
"""MemFS that raises on write_bytes for a chosen path (simulates an
|
||||
unwritable extracted.md — e.g. its directory was removed)."""
|
||||
|
||||
def __init__(self, files: dict[str, bytes] | None = None, fail_path: str | None = None) -> None:
|
||||
super().__init__(files)
|
||||
self._fail_path = fail_path
|
||||
|
||||
def write_bytes(self, path: Path, data: bytes) -> None:
|
||||
if self._fail_path is not None and str(path) == self._fail_path:
|
||||
raise OSError("simulated write failure: directory removed")
|
||||
super().write_bytes(path, data)
|
||||
|
||||
|
||||
class TestExtractThenDeleteVaultPointerAppend:
|
||||
"""Task 4.2: vault-destination extractions append an extracted.md pointer,
|
||||
staged into the same transaction as the git rm; repo-durable destinations
|
||||
get no index entry; a failed append (or missing/invalid pointer
|
||||
metadata) skips the delete (fail closed, never gone-but-unindexed)."""
|
||||
|
||||
def _pointer(self, **overrides) -> dict:
|
||||
base = {
|
||||
"vault_note": "tool/graphify-clustering-behavior",
|
||||
"reason": "why HDBSCAN over-merges doc clusters; read before "
|
||||
"tuning any graphify clustering params",
|
||||
"date": "2026-07-15",
|
||||
}
|
||||
base.update(overrides)
|
||||
return base
|
||||
|
||||
def test_vault_extraction_appends_pointer_and_creates_extracted_md(self, tmp_path):
|
||||
fs = MemFS()
|
||||
git = RecordingGitRm()
|
||||
git_state = RecordingGitState(tracked=True, dirty=False)
|
||||
entry = _lifecycle_entry(
|
||||
"docs/graphify-clustering-notes.md", "extract-then-delete",
|
||||
safety_tier="auto", extraction_complete=True,
|
||||
extraction_target="cross-repo", extraction_pointer=self._pointer(),
|
||||
)
|
||||
|
||||
result = _applier_ls(tmp_path, fs=fs, git=git, git_state=git_state).apply([entry])
|
||||
|
||||
assert result["applied"][0]["kind"] == "extract-then-delete"
|
||||
assert result["skipped"] == []
|
||||
assert len(git.rm_calls) == 1
|
||||
|
||||
extracted_path = str(tmp_path / "docs" / "extracted.md")
|
||||
assert extracted_path in fs.writes
|
||||
content = fs.writes[extracted_path].decode("utf-8")
|
||||
expected_line = (
|
||||
"- [[vault: tool/graphify-clustering-behavior]] — why HDBSCAN "
|
||||
"over-merges doc clusters; read before tuning any graphify "
|
||||
"clustering params (extracted from `graphify-clustering-notes.md`, "
|
||||
"2026-07-15)"
|
||||
)
|
||||
assert expected_line in content
|
||||
assert "docs/extracted.md" in result["staged_paths"]
|
||||
assert "docs/graphify-clustering-notes.md" in result["staged_paths"]
|
||||
|
||||
def test_vault_extraction_appends_to_existing_extracted_md(self, tmp_path):
|
||||
existing_path = str(tmp_path / "docs" / "extracted.md")
|
||||
existing_content = (
|
||||
"- [[vault: tool/old-note]] — prior entry "
|
||||
"(extracted from `old.md`, 2026-07-01)\n"
|
||||
)
|
||||
fs = MemFS({existing_path: existing_content.encode("utf-8")})
|
||||
git = RecordingGitRm()
|
||||
git_state = RecordingGitState(tracked=True, dirty=False)
|
||||
entry = _lifecycle_entry(
|
||||
"docs/graphify-clustering-notes.md", "extract-then-delete",
|
||||
safety_tier="auto", extraction_complete=True,
|
||||
extraction_target="cross-repo", extraction_pointer=self._pointer(),
|
||||
)
|
||||
|
||||
result = _applier_ls(tmp_path, fs=fs, git=git, git_state=git_state).apply([entry])
|
||||
|
||||
assert result["applied"][0]["kind"] == "extract-then-delete"
|
||||
content = fs.writes[existing_path].decode("utf-8")
|
||||
assert "tool/old-note" in content
|
||||
assert "tool/graphify-clustering-behavior" in content
|
||||
# Prior entry preserved before the newly appended one.
|
||||
assert content.index("tool/old-note") < content.index("tool/graphify-clustering-behavior")
|
||||
|
||||
def test_repo_durable_extraction_writes_no_extracted_md(self, tmp_path):
|
||||
fs = MemFS()
|
||||
git = RecordingGitRm()
|
||||
git_state = RecordingGitState(tracked=True, dirty=False)
|
||||
entry = _lifecycle_entry(
|
||||
"docs/old-guide.md", "extract-then-delete",
|
||||
safety_tier="auto", extraction_complete=True,
|
||||
extraction_target="repo-durable",
|
||||
)
|
||||
|
||||
result = _applier_ls(tmp_path, fs=fs, git=git, git_state=git_state).apply([entry])
|
||||
|
||||
assert result["applied"][0]["kind"] == "extract-then-delete"
|
||||
assert fs.writes == {}
|
||||
assert result["staged_paths"] == ["docs/old-guide.md"]
|
||||
|
||||
def test_no_extraction_target_writes_no_extracted_md(self, tmp_path):
|
||||
"""Absence of extraction_target (or any value other than
|
||||
"cross-repo") is treated as repo-durable — no pointer append."""
|
||||
fs = MemFS()
|
||||
git = RecordingGitRm()
|
||||
git_state = RecordingGitState(tracked=True, dirty=False)
|
||||
entry = _lifecycle_entry(
|
||||
"docs/old-guide.md", "extract-then-delete",
|
||||
safety_tier="auto", extraction_complete=True,
|
||||
)
|
||||
|
||||
result = _applier_ls(tmp_path, fs=fs, git=git, git_state=git_state).apply([entry])
|
||||
|
||||
assert result["applied"][0]["kind"] == "extract-then-delete"
|
||||
assert fs.writes == {}
|
||||
|
||||
def test_failed_append_skips_the_delete(self, tmp_path):
|
||||
extracted_path = str(tmp_path / "docs" / "extracted.md")
|
||||
fs = WriteFailsFS(fail_path=extracted_path)
|
||||
git = RecordingGitRm()
|
||||
git_state = RecordingGitState(tracked=True, dirty=False)
|
||||
entry = _lifecycle_entry(
|
||||
"docs/graphify-clustering-notes.md", "extract-then-delete",
|
||||
safety_tier="auto", extraction_complete=True,
|
||||
extraction_target="cross-repo", extraction_pointer=self._pointer(),
|
||||
)
|
||||
|
||||
result = _applier_ls(tmp_path, fs=fs, git=git, git_state=git_state).apply([entry])
|
||||
|
||||
assert result["applied"] == []
|
||||
assert git.rm_calls == []
|
||||
assert result["skipped"][0]["reason"] == "extracted-md-append-failed"
|
||||
|
||||
def test_missing_pointer_metadata_skips_the_delete(self, tmp_path):
|
||||
fs = MemFS()
|
||||
git = RecordingGitRm()
|
||||
git_state = RecordingGitState(tracked=True, dirty=False)
|
||||
entry = _lifecycle_entry(
|
||||
"docs/graphify-clustering-notes.md", "extract-then-delete",
|
||||
safety_tier="auto", extraction_complete=True,
|
||||
extraction_target="cross-repo",
|
||||
# No extraction_pointer at all.
|
||||
)
|
||||
|
||||
result = _applier_ls(tmp_path, fs=fs, git=git, git_state=git_state).apply([entry])
|
||||
|
||||
assert result["applied"] == []
|
||||
assert git.rm_calls == []
|
||||
assert result["skipped"][0]["reason"] == "extraction-pointer-invalid"
|
||||
assert fs.writes == {}
|
||||
|
||||
def test_incomplete_pointer_metadata_skips_the_delete(self, tmp_path):
|
||||
fs = MemFS()
|
||||
git = RecordingGitRm()
|
||||
git_state = RecordingGitState(tracked=True, dirty=False)
|
||||
entry = _lifecycle_entry(
|
||||
"docs/graphify-clustering-notes.md", "extract-then-delete",
|
||||
safety_tier="auto", extraction_complete=True,
|
||||
extraction_target="cross-repo",
|
||||
extraction_pointer={"vault_note": "tool/x"}, # missing reason/date
|
||||
)
|
||||
|
||||
result = _applier_ls(tmp_path, fs=fs, git=git, git_state=git_state).apply([entry])
|
||||
|
||||
assert result["applied"] == []
|
||||
assert git.rm_calls == []
|
||||
assert result["skipped"][0]["reason"] == "extraction-pointer-invalid"
|
||||
assert fs.writes == {}
|
||||
|
||||
def test_extraction_complete_false_skips_before_any_pointer_logic(self, tmp_path):
|
||||
"""extraction_complete: false still skips everything, even with a
|
||||
fully-formed vault pointer present."""
|
||||
fs = MemFS()
|
||||
git = RecordingGitRm()
|
||||
git_state = RecordingGitState(tracked=True, dirty=False)
|
||||
entry = _lifecycle_entry(
|
||||
"docs/graphify-clustering-notes.md", "extract-then-delete",
|
||||
safety_tier="auto", extraction_complete=False,
|
||||
extraction_target="cross-repo", extraction_pointer=self._pointer(),
|
||||
)
|
||||
|
||||
result = _applier_ls(tmp_path, fs=fs, git=git, git_state=git_state).apply([entry])
|
||||
|
||||
assert result["applied"] == []
|
||||
assert git.rm_calls == []
|
||||
assert result["skipped"][0]["reason"] == "extraction-not-confirmed"
|
||||
assert fs.writes == {}
|
||||
|
||||
def test_root_level_file_uses_root_extracted_md(self, tmp_path):
|
||||
fs = MemFS()
|
||||
git = RecordingGitRm()
|
||||
git_state = RecordingGitState(tracked=True, dirty=False)
|
||||
entry = _lifecycle_entry(
|
||||
"graphify-clustering-notes.md", "extract-then-delete",
|
||||
safety_tier="auto", extraction_complete=True,
|
||||
extraction_target="cross-repo", extraction_pointer=self._pointer(),
|
||||
)
|
||||
|
||||
result = _applier_ls(tmp_path, fs=fs, git=git, git_state=git_state).apply([entry])
|
||||
|
||||
assert result["applied"][0]["kind"] == "extract-then-delete"
|
||||
extracted_path = str(tmp_path / "extracted.md")
|
||||
assert extracted_path in fs.writes
|
||||
assert "extracted.md" in result["staged_paths"]
|
||||
|
||||
|
||||
class TestLifecycleConfirmTierGating:
|
||||
"""Task 4.1(d): confirm-tier lifecycle entries never auto-apply silently."""
|
||||
|
||||
def test_confirm_tier_entry_only_applies_when_explicitly_indexed(self, tmp_path):
|
||||
"""
|
||||
Mirrors the existing --apply-indices gating convention: the applier
|
||||
only ever touches entries the caller explicitly selected. A
|
||||
confirm-tier lifecycle entry sitting in report.entries but NOT
|
||||
passed via --apply-indices must never be applied.
|
||||
"""
|
||||
content = "line1\n"
|
||||
file_bytes, sha = _make_file(content)
|
||||
fs = MemFS({str(tmp_path / "doc.md"): file_bytes})
|
||||
git = RecordingGitRm()
|
||||
git_state = RecordingGitState(tracked=True, dirty=False)
|
||||
|
||||
report_entries = [
|
||||
_entry("doc.md", "delete-range", sha=sha, start=1, end=1), # index 0, auto
|
||||
_lifecycle_entry("docs/risky.md", "delete", safety_tier="confirm"), # index 1
|
||||
]
|
||||
report = {
|
||||
"schema_version": "1.0", "tool_version": "0.1.0",
|
||||
"generated_at": "2026-07-14T00:00:00+00:00",
|
||||
"scan": {"project_root": str(tmp_path), "scope_globs": [], "excluded_dirs": [], "files_scanned": 0},
|
||||
"shortlist": [], "entries": report_entries,
|
||||
}
|
||||
report_path = tmp_path / "report.json"
|
||||
report_path.write_text(json.dumps(report))
|
||||
|
||||
# Caller (skill) only approved index 0 — the confirm-tier delete was
|
||||
# never approved and must not be passed.
|
||||
applier = _applier_ls(tmp_path, fs=fs, git=git, git_state=git_state)
|
||||
indexed = [(0, report_entries[0])]
|
||||
result = applier.apply_indexed(indexed)
|
||||
|
||||
assert result["applied"][0]["entry_index"] == 0
|
||||
assert git.rm_calls == [] # confirm-tier delete never touched
|
||||
|
||||
def test_confirm_tier_entry_applies_once_explicitly_approved_and_indexed(self, tmp_path):
|
||||
fs = MemFS()
|
||||
git = RecordingGitRm()
|
||||
git_state = RecordingGitState(tracked=True, dirty=False)
|
||||
entry = _lifecycle_entry("docs/risky.md", "delete", safety_tier="confirm")
|
||||
|
||||
# Caller explicitly included this index after the user approved it
|
||||
# in the batch-confirm prompt.
|
||||
result = _applier_ls(tmp_path, fs=fs, git=git, git_state=git_state).apply_indexed(
|
||||
[(1, entry)]
|
||||
)
|
||||
|
||||
assert result["applied"][0]["entry_index"] == 1
|
||||
assert result["applied"][0]["kind"] == "delete"
|
||||
assert len(git.rm_calls) == 1
|
||||
|
||||
|
||||
class TestLifecycleDeleteIncompatibleWithOtherOps:
|
||||
"""A lifecycle delete cannot be combined with any other op on the same path."""
|
||||
|
||||
def test_delete_combined_with_another_op_on_same_path_is_incompatible(self, tmp_path):
|
||||
content = "line1\n"
|
||||
file_bytes, sha = _make_file(content)
|
||||
fs = MemFS({str(tmp_path / "doc.md"): file_bytes})
|
||||
git = RecordingGitRm()
|
||||
git_state = RecordingGitState(tracked=True, dirty=False)
|
||||
|
||||
entries = [
|
||||
_lifecycle_entry("doc.md", "delete", safety_tier="auto"),
|
||||
_entry("doc.md", "insert-frontmatter", extra={"key": "hygiene", "value": "frozen"}),
|
||||
]
|
||||
result = _applier_ls(tmp_path, fs=fs, git=git, git_state=git_state).apply(entries)
|
||||
|
||||
assert result["applied"] == []
|
||||
reasons = {s["reason"] for s in result["skipped"]}
|
||||
assert reasons == {"incompatible-ops-on-file"}
|
||||
assert git.rm_calls == []
|
||||
|
||||
|
||||
class TestUnknownKindStillRejectedAlongsideLifecycleKinds:
|
||||
"""Regression: adding the lifecycle carve-out must not swallow real unknowns."""
|
||||
|
||||
def test_truly_unknown_kind_still_reported(self, tmp_path):
|
||||
fs = MemFS()
|
||||
entry = {
|
||||
"path": "doc.md",
|
||||
"op": "nuke it",
|
||||
"op_type": "deterministic",
|
||||
"exact_edit": {"kind": "nuke-it", "anchor": {"start_line": 1, "end_line": 1}},
|
||||
}
|
||||
result = _applier(tmp_path, fs=fs).apply([entry])
|
||||
|
||||
assert result["skipped"][0]["reason"] == "unknown-kind"
|
||||
|
|
|
|||
|
|
@ -367,8 +367,8 @@ class TestClearedFiles:
|
|||
result = _build(doc_tree, scan_artifact, _proposals())
|
||||
md = result["human_report"]
|
||||
# 7 shortlisted, 6 entries → 1 cleared
|
||||
assert "candidates: 6" in md
|
||||
assert "cleared: 1" in md
|
||||
assert "findings: 6" in md
|
||||
assert "cleared: 1 of 7 evaluated entries" in md
|
||||
assert "docs/clean.md" in md
|
||||
|
||||
|
||||
|
|
@ -447,6 +447,314 @@ class TestMalformedRejection:
|
|||
# CRITICAL: round-trip through validate_report.py
|
||||
# ===========================================================================
|
||||
|
||||
class FakeGitStateProvider:
|
||||
"""Injected git-state provider — returns canned {tracked, clean} per
|
||||
rel_path, matching the report_builder.py convention of an injectable
|
||||
real-vs-fake seam (same shape as scanner.py's git_log_fn)."""
|
||||
|
||||
def __init__(self, states: dict) -> None:
|
||||
self._states = states
|
||||
|
||||
def state(self, root: Path, rel_path: str) -> dict:
|
||||
return self._states.get(rel_path, {"tracked": False, "clean": False})
|
||||
|
||||
|
||||
def _lifecycle_delete_proposal(**lifecycle_overrides) -> dict:
|
||||
lifecycle = {"rule_ref": "autoresearch/*/**", "lifetime": "temporary"}
|
||||
lifecycle.update(lifecycle_overrides)
|
||||
return {
|
||||
"path": "docs/clean.md",
|
||||
"category": {"class": "stale", "subtype": "orphaned"},
|
||||
"signals": [{"name": "temporary_expired", "detail": "past retain-recent window"}],
|
||||
"op": "Delete expired temporary entry.",
|
||||
"op_type": "deterministic",
|
||||
"confidence": 0.9,
|
||||
"exact_edit": {
|
||||
"kind": "delete",
|
||||
"anchor": {"start_line": 1, "end_line": 3},
|
||||
"lifecycle": lifecycle,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
class TestLifecycleDeleteKind:
|
||||
"""Group 3 (task 3.1/3.3): delete/extract-then-delete assembly —
|
||||
git_state population (guardrail, never trusted from the proposal) and
|
||||
the lifecycle-aware safety_tier branch."""
|
||||
|
||||
def test_git_state_is_populated_from_provider_not_proposal(self, doc_tree, scan_artifact):
|
||||
proposal = _lifecycle_delete_proposal()
|
||||
# Proposal lies about git_state; report_builder must ignore it.
|
||||
proposal["exact_edit"]["lifecycle"]["git_state"] = {"tracked": False, "clean": False}
|
||||
provider = FakeGitStateProvider({"docs/clean.md": {"tracked": True, "clean": True}})
|
||||
builder = ReportBuilder(
|
||||
project_root=doc_tree, clock=MonotonicClock(_T0),
|
||||
estimator=default_estimator(), git_state_provider=provider,
|
||||
)
|
||||
result = builder.build(scan_artifact, [proposal])
|
||||
entry = result["machine_report"]["entries"][0]
|
||||
assert entry["exact_edit"]["lifecycle"]["git_state"] == {"tracked": True, "clean": True}
|
||||
|
||||
def test_scanner_proven_tracked_clean_yields_auto(self, doc_tree, scan_artifact):
|
||||
provider = FakeGitStateProvider({"docs/clean.md": {"tracked": True, "clean": True}})
|
||||
builder = ReportBuilder(
|
||||
project_root=doc_tree, clock=MonotonicClock(_T0),
|
||||
estimator=default_estimator(), git_state_provider=provider,
|
||||
)
|
||||
result = builder.build(scan_artifact, [_lifecycle_delete_proposal()])
|
||||
entry = result["machine_report"]["entries"][0]
|
||||
assert entry["safety_tier"] == "auto"
|
||||
|
||||
def test_dirty_worktree_forces_confirm(self, doc_tree, scan_artifact):
|
||||
provider = FakeGitStateProvider({"docs/clean.md": {"tracked": True, "clean": False}})
|
||||
builder = ReportBuilder(
|
||||
project_root=doc_tree, clock=MonotonicClock(_T0),
|
||||
estimator=default_estimator(), git_state_provider=provider,
|
||||
)
|
||||
result = builder.build(scan_artifact, [_lifecycle_delete_proposal()])
|
||||
entry = result["machine_report"]["entries"][0]
|
||||
assert entry["safety_tier"] == "confirm"
|
||||
|
||||
def test_untracked_forces_confirm(self, doc_tree, scan_artifact):
|
||||
provider = FakeGitStateProvider({}) # unknown path -> untracked
|
||||
builder = ReportBuilder(
|
||||
project_root=doc_tree, clock=MonotonicClock(_T0),
|
||||
estimator=default_estimator(), git_state_provider=provider,
|
||||
)
|
||||
result = builder.build(scan_artifact, [_lifecycle_delete_proposal()])
|
||||
entry = result["machine_report"]["entries"][0]
|
||||
assert entry["safety_tier"] == "confirm"
|
||||
|
||||
def test_classifier_judged_served_when_always_confirm(self, doc_tree, scan_artifact):
|
||||
provider = FakeGitStateProvider({"docs/clean.md": {"tracked": True, "clean": True}})
|
||||
proposal = _lifecycle_delete_proposal(
|
||||
lifetime="delete-once-served",
|
||||
served_when="the effort this plan describes has shipped",
|
||||
)
|
||||
builder = ReportBuilder(
|
||||
project_root=doc_tree, clock=MonotonicClock(_T0),
|
||||
estimator=default_estimator(), git_state_provider=provider,
|
||||
)
|
||||
result = builder.build(scan_artifact, [proposal])
|
||||
entry = result["machine_report"]["entries"][0]
|
||||
assert entry["safety_tier"] == "confirm"
|
||||
|
||||
def test_served_when_path_tracked_clean_yields_auto(self, doc_tree, scan_artifact):
|
||||
provider = FakeGitStateProvider({"docs/clean.md": {"tracked": True, "clean": True}})
|
||||
proposal = _lifecycle_delete_proposal(
|
||||
lifetime="delete-once-served",
|
||||
served_when_path="openspec/changes/archive/{id}/",
|
||||
)
|
||||
builder = ReportBuilder(
|
||||
project_root=doc_tree, clock=MonotonicClock(_T0),
|
||||
estimator=default_estimator(), git_state_provider=provider,
|
||||
)
|
||||
result = builder.build(scan_artifact, [proposal])
|
||||
entry = result["machine_report"]["entries"][0]
|
||||
assert entry["safety_tier"] == "auto"
|
||||
|
||||
def test_delete_entry_is_destructive_true(self, doc_tree, scan_artifact):
|
||||
provider = FakeGitStateProvider({"docs/clean.md": {"tracked": True, "clean": True}})
|
||||
builder = ReportBuilder(
|
||||
project_root=doc_tree, clock=MonotonicClock(_T0),
|
||||
estimator=default_estimator(), git_state_provider=provider,
|
||||
)
|
||||
result = builder.build(scan_artifact, [_lifecycle_delete_proposal()])
|
||||
entry = result["machine_report"]["entries"][0]
|
||||
assert entry["is_destructive"] is True
|
||||
|
||||
def test_delete_entry_passes_validator(self, doc_tree, scan_artifact):
|
||||
provider = FakeGitStateProvider({"docs/clean.md": {"tracked": True, "clean": True}})
|
||||
builder = ReportBuilder(
|
||||
project_root=doc_tree, clock=MonotonicClock(_T0),
|
||||
estimator=default_estimator(), git_state_provider=provider,
|
||||
)
|
||||
result = builder.build(scan_artifact, [_lifecycle_delete_proposal()])
|
||||
violations = ReportValidator(result["machine_report"]).validate()
|
||||
assert violations == []
|
||||
|
||||
def test_extract_then_delete_repo_durable_passes_validator(self, doc_tree, scan_artifact):
|
||||
provider = FakeGitStateProvider({"docs/clean.md": {"tracked": True, "clean": True}})
|
||||
proposal = _lifecycle_delete_proposal()
|
||||
proposal["exact_edit"]["kind"] = "extract-then-delete"
|
||||
proposal["exact_edit"]["extraction_target"] = "repo-durable"
|
||||
proposal["exact_edit"]["extraction_dest"] = "CLAUDE.md"
|
||||
builder = ReportBuilder(
|
||||
project_root=doc_tree, clock=MonotonicClock(_T0),
|
||||
estimator=default_estimator(), git_state_provider=provider,
|
||||
)
|
||||
result = builder.build(scan_artifact, [proposal])
|
||||
violations = ReportValidator(result["machine_report"]).validate()
|
||||
assert violations == []
|
||||
|
||||
def test_missing_extraction_target_is_malformed_proposal(self, doc_tree, scan_artifact):
|
||||
proposal = _lifecycle_delete_proposal()
|
||||
proposal["exact_edit"]["kind"] = "extract-then-delete"
|
||||
# extraction_target omitted -> report_builder rejects before compute
|
||||
builder = ReportBuilder(project_root=doc_tree, clock=MonotonicClock(_T0))
|
||||
with pytest.raises(MalformedProposalError):
|
||||
builder.build(scan_artifact, [proposal])
|
||||
|
||||
def test_whole_file_span_counted_for_delete(self, doc_tree, scan_artifact):
|
||||
provider = FakeGitStateProvider({"docs/clean.md": {"tracked": True, "clean": True}})
|
||||
builder = ReportBuilder(
|
||||
project_root=doc_tree, clock=MonotonicClock(_T0),
|
||||
estimator=default_estimator(), git_state_provider=provider,
|
||||
)
|
||||
result = builder.build(scan_artifact, [_lifecycle_delete_proposal()])
|
||||
entry = result["machine_report"]["entries"][0]
|
||||
whole = default_estimator().estimate((doc_tree / "docs" / "clean.md").read_text())
|
||||
assert entry["token_estimate"]["raw_tokens"] == whole
|
||||
|
||||
|
||||
class TestPromotionCandidates:
|
||||
"""Group 3 (task 3.2): deterministic, model-free promotion_candidates."""
|
||||
|
||||
def _builder(self, doc_tree, conventions_path=None, **kw):
|
||||
return ReportBuilder(
|
||||
project_root=doc_tree, clock=MonotonicClock(_T0),
|
||||
estimator=default_estimator(), conventions_path=conventions_path, **kw
|
||||
)
|
||||
|
||||
def _write_conventions(self, tmp_path: Path) -> Path:
|
||||
path = tmp_path / "conventions.json"
|
||||
path.write_text(json.dumps([
|
||||
{"name": "archive-bucket", "what_it_proves": "moved to archive/", "pitch": "Move it once."},
|
||||
{"name": "status-frontmatter", "what_it_proves": "status: shipped", "pitch": "Stamp status once."},
|
||||
]))
|
||||
return path
|
||||
|
||||
def test_classifier_judged_entry_yields_candidates(self, doc_tree, scan_artifact, tmp_path):
|
||||
conventions_path = self._write_conventions(tmp_path)
|
||||
provider = FakeGitStateProvider({"docs/clean.md": {"tracked": True, "clean": True}})
|
||||
proposal = _lifecycle_delete_proposal(
|
||||
lifetime="delete-once-served",
|
||||
served_when="the effort this plan describes has shipped",
|
||||
)
|
||||
builder = self._builder(doc_tree, conventions_path=conventions_path, git_state_provider=provider)
|
||||
result = builder.build(scan_artifact, [proposal])
|
||||
candidates = result["machine_report"]["promotion_candidates"]
|
||||
names = {c["name"] for c in candidates}
|
||||
assert names == {"archive-bucket", "status-frontmatter"}
|
||||
assert all(c["path"] == "docs/clean.md" for c in candidates)
|
||||
assert all(c["pitch"] for c in candidates)
|
||||
|
||||
def test_scanner_proven_served_when_path_yields_no_candidate(self, doc_tree, scan_artifact, tmp_path):
|
||||
conventions_path = self._write_conventions(tmp_path)
|
||||
provider = FakeGitStateProvider({"docs/clean.md": {"tracked": True, "clean": True}})
|
||||
proposal = _lifecycle_delete_proposal(
|
||||
lifetime="delete-once-served",
|
||||
served_when_path="openspec/changes/archive/{id}/",
|
||||
)
|
||||
builder = self._builder(doc_tree, conventions_path=conventions_path, git_state_provider=provider)
|
||||
result = builder.build(scan_artifact, [proposal])
|
||||
assert result["machine_report"]["promotion_candidates"] == []
|
||||
|
||||
def test_no_lifecycle_entries_yields_empty_candidates(self, doc_tree, scan_artifact, tmp_path):
|
||||
conventions_path = self._write_conventions(tmp_path)
|
||||
builder = self._builder(doc_tree, conventions_path=conventions_path)
|
||||
result = builder.build(scan_artifact, _proposals())
|
||||
assert result["machine_report"]["promotion_candidates"] == []
|
||||
|
||||
def test_promotion_candidates_always_present_even_empty(self, doc_tree, scan_artifact):
|
||||
result = _build(doc_tree, scan_artifact, [])
|
||||
assert result["machine_report"]["promotion_candidates"] == []
|
||||
|
||||
def test_missing_conventions_file_yields_empty_candidates_no_crash(self, doc_tree, scan_artifact, tmp_path):
|
||||
missing = tmp_path / "does-not-exist.json"
|
||||
provider = FakeGitStateProvider({"docs/clean.md": {"tracked": True, "clean": True}})
|
||||
proposal = _lifecycle_delete_proposal(
|
||||
lifetime="delete-once-served",
|
||||
served_when="shipped",
|
||||
)
|
||||
builder = self._builder(doc_tree, conventions_path=missing, git_state_provider=provider)
|
||||
result = builder.build(scan_artifact, [proposal])
|
||||
assert result["machine_report"]["promotion_candidates"] == []
|
||||
|
||||
def test_shipped_conventions_json_has_exactly_two_entries(self):
|
||||
real_conventions = _SCRIPTS_DIR.parent / "conventions.json"
|
||||
data = json.loads(real_conventions.read_text())
|
||||
assert isinstance(data, list)
|
||||
names = {c["name"] for c in data}
|
||||
assert names == {"archive-bucket", "status-frontmatter"}
|
||||
for c in data:
|
||||
assert c.get("name")
|
||||
assert c.get("what_it_proves")
|
||||
assert c.get("pitch")
|
||||
|
||||
def test_default_conventions_path_is_the_shipped_catalog(self, doc_tree, scan_artifact):
|
||||
"""No conventions_path override -> reads the real, shipped catalog."""
|
||||
provider = FakeGitStateProvider({"docs/clean.md": {"tracked": True, "clean": True}})
|
||||
proposal = _lifecycle_delete_proposal(
|
||||
lifetime="delete-once-served",
|
||||
served_when="shipped",
|
||||
)
|
||||
builder = ReportBuilder(
|
||||
project_root=doc_tree, clock=MonotonicClock(_T0),
|
||||
estimator=default_estimator(), git_state_provider=provider,
|
||||
)
|
||||
result = builder.build(scan_artifact, [proposal])
|
||||
names = {c["name"] for c in result["machine_report"]["promotion_candidates"]}
|
||||
assert names == {"archive-bucket", "status-frontmatter"}
|
||||
|
||||
|
||||
class TestHumanReportLifecycleAndPromotionRendering:
|
||||
"""Task 5.1: the human report renders lifecycle findings + the
|
||||
Promotion Candidates section (doc-check spec: 'both the machine report's
|
||||
promotion_candidates array and the human report show the candidate')."""
|
||||
|
||||
def _builder(self, doc_tree, conventions_path=None, **kw):
|
||||
return ReportBuilder(
|
||||
project_root=doc_tree, clock=MonotonicClock(_T0),
|
||||
estimator=default_estimator(), conventions_path=conventions_path, **kw
|
||||
)
|
||||
|
||||
def _write_conventions(self, tmp_path: Path) -> Path:
|
||||
path = tmp_path / "conventions.json"
|
||||
path.write_text(json.dumps([
|
||||
{"name": "archive-bucket", "what_it_proves": "moved to archive/", "pitch": "Move it once."},
|
||||
]))
|
||||
return path
|
||||
|
||||
def test_promotion_candidates_section_present_when_empty(self, doc_tree, scan_artifact):
|
||||
result = _build(doc_tree, scan_artifact, [])
|
||||
assert "## Promotion Candidates" in result["human_report"]
|
||||
assert "(none)" in result["human_report"]
|
||||
|
||||
def test_promotion_candidates_named_with_pitch_in_human_report(
|
||||
self, doc_tree, scan_artifact, tmp_path
|
||||
):
|
||||
conventions_path = self._write_conventions(tmp_path)
|
||||
provider = FakeGitStateProvider({"docs/clean.md": {"tracked": True, "clean": True}})
|
||||
proposal = _lifecycle_delete_proposal(
|
||||
lifetime="delete-once-served",
|
||||
served_when="the effort this plan describes has shipped",
|
||||
)
|
||||
builder = self._builder(doc_tree, conventions_path=conventions_path, git_state_provider=provider)
|
||||
result = builder.build(scan_artifact, [proposal])
|
||||
human = result["human_report"]
|
||||
assert "## Promotion Candidates" in human
|
||||
assert "archive-bucket" in human
|
||||
assert "Move it once." in human
|
||||
|
||||
def test_lifecycle_entry_line_renders_rule_ref_and_lifetime(
|
||||
self, doc_tree, scan_artifact
|
||||
):
|
||||
provider = FakeGitStateProvider({"docs/clean.md": {"tracked": True, "clean": True}})
|
||||
proposal = _lifecycle_delete_proposal(
|
||||
lifetime="delete-once-served",
|
||||
served_when_path="openspec/changes/archive/{id}/",
|
||||
)
|
||||
builder = ReportBuilder(
|
||||
project_root=doc_tree, clock=MonotonicClock(_T0),
|
||||
estimator=default_estimator(), git_state_provider=provider,
|
||||
)
|
||||
result = builder.build(scan_artifact, [proposal])
|
||||
human = result["human_report"]
|
||||
assert "lifecycle:" in human
|
||||
assert "delete-once-served" in human
|
||||
|
||||
|
||||
class TestValidatorRoundTrip:
|
||||
|
||||
def test_assembled_report_passes_validator(self, doc_tree, scan_artifact):
|
||||
|
|
|
|||
|
|
@ -0,0 +1,370 @@
|
|||
"""
|
||||
Tests for scripts/rulebook.py
|
||||
|
||||
Covers tasks 1.1-1.2 of the lifecycle-aware-doc-hygiene change:
|
||||
- envelope parsing, hard-fail on unparseable JSON / unknown schema_version
|
||||
- skip-and-warn on invalid rules and rules missing confirmed_by
|
||||
- glob compilation via glob.translate(recursive=True, include_hidden=True),
|
||||
with a Python >=3.13 version check
|
||||
- add-only merge + two-axis precedence (project file > project dir >
|
||||
global file > global dir; ties by longest glob, then last-defined)
|
||||
- keep-shadowing neutralization
|
||||
- unmatched path -> None
|
||||
- file-level vs directory-level match distinction
|
||||
- IGNORE-sentinel rules (no lifetime field -> zero-emission prune)
|
||||
|
||||
sys.path is patched here (no shared conftest) so the test file is
|
||||
self-contained, as required by the file-ownership constraints.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
_SCRIPTS_DIR = Path(__file__).parent.parent / "scripts"
|
||||
if str(_SCRIPTS_DIR) not in sys.path:
|
||||
sys.path.insert(0, str(_SCRIPTS_DIR))
|
||||
|
||||
from rulebook import ( # noqa: E402
|
||||
Rulebook,
|
||||
RulebookLoadError,
|
||||
load_rulebook,
|
||||
)
|
||||
|
||||
|
||||
def _write(tmp_path: Path, name: str, payload: dict) -> Path:
|
||||
p = tmp_path / name
|
||||
p.write_text(json.dumps(payload), encoding="utf-8")
|
||||
return p
|
||||
|
||||
|
||||
def _envelope(*rules: dict) -> dict:
|
||||
return {"schema_version": 1, "rules": list(rules)}
|
||||
|
||||
|
||||
def _rule(glob, lifetime="keep", confirmed_by="human", **extra) -> dict:
|
||||
r = {
|
||||
"glob": glob,
|
||||
"confirmed_by": confirmed_by,
|
||||
"confirmed_on": "2026-07-14",
|
||||
"source": "test",
|
||||
}
|
||||
if lifetime is not None:
|
||||
r["lifetime"] = lifetime
|
||||
r.update(extra)
|
||||
return r
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# Task 1.1 — envelope parsing / hard-fail / skip-and-warn / glob compilation
|
||||
# ===========================================================================
|
||||
|
||||
class TestEnvelopeParsing:
|
||||
def test_loads_valid_global_only(self, tmp_path):
|
||||
global_path = _write(
|
||||
tmp_path, "rulebook.json", _envelope(_rule("foo.md"))
|
||||
)
|
||||
rb = load_rulebook(global_path=global_path, project_path=None)
|
||||
assert isinstance(rb, Rulebook)
|
||||
|
||||
def test_missing_project_file_is_fine(self, tmp_path):
|
||||
global_path = _write(
|
||||
tmp_path, "rulebook.json", _envelope(_rule("foo.md"))
|
||||
)
|
||||
project_path = tmp_path / "does-not-exist.json"
|
||||
rb = load_rulebook(global_path=global_path, project_path=project_path)
|
||||
assert isinstance(rb, Rulebook)
|
||||
|
||||
def test_unparseable_json_hard_fails(self, tmp_path):
|
||||
global_path = tmp_path / "rulebook.json"
|
||||
global_path.write_text("{not valid json", encoding="utf-8")
|
||||
with pytest.raises(RulebookLoadError):
|
||||
load_rulebook(global_path=global_path, project_path=None)
|
||||
|
||||
def test_unknown_schema_version_hard_fails(self, tmp_path):
|
||||
global_path = _write(
|
||||
tmp_path,
|
||||
"rulebook.json",
|
||||
{"schema_version": 99, "rules": [_rule("foo.md")]},
|
||||
)
|
||||
with pytest.raises(RulebookLoadError):
|
||||
load_rulebook(global_path=global_path, project_path=None)
|
||||
|
||||
def test_project_file_unparseable_json_hard_fails(self, tmp_path):
|
||||
global_path = _write(
|
||||
tmp_path, "rulebook.json", _envelope(_rule("foo.md"))
|
||||
)
|
||||
project_path = tmp_path / "proj.json"
|
||||
project_path.write_text("nope not json", encoding="utf-8")
|
||||
with pytest.raises(RulebookLoadError):
|
||||
load_rulebook(global_path=global_path, project_path=project_path)
|
||||
|
||||
|
||||
class TestSkipAndWarnValidation:
|
||||
def test_rule_missing_confirmed_by_is_skipped_not_fatal(self, tmp_path):
|
||||
bad = {
|
||||
"glob": "unconfirmed.md",
|
||||
"lifetime": "keep",
|
||||
"confirmed_on": "2026-07-14",
|
||||
"source": "test",
|
||||
}
|
||||
assert "confirmed_by" not in bad
|
||||
good_rules = [_rule(f"good-{i}.md") for i in range(9)]
|
||||
global_path = _write(
|
||||
tmp_path, "rulebook.json", _envelope(bad, *good_rules)
|
||||
)
|
||||
rb = load_rulebook(global_path=global_path, project_path=None)
|
||||
|
||||
# the invalid rule never contributes a match
|
||||
assert rb.query("unconfirmed.md", is_dir=False) is None
|
||||
# a warning is surfaced
|
||||
assert any("confirmed_by" in w for w in rb.warnings)
|
||||
# the other nine rules still function
|
||||
for i in range(9):
|
||||
match = rb.query(f"good-{i}.md", is_dir=False)
|
||||
assert match is not None
|
||||
|
||||
def test_invalid_lifetime_value_is_skipped(self, tmp_path):
|
||||
bad = _rule("bad.md", lifetime="not-a-real-lifetime")
|
||||
global_path = _write(tmp_path, "rulebook.json", _envelope(bad))
|
||||
rb = load_rulebook(global_path=global_path, project_path=None)
|
||||
assert rb.query("bad.md", is_dir=False) is None
|
||||
assert rb.warnings
|
||||
|
||||
def test_rule_missing_glob_is_skipped(self, tmp_path):
|
||||
bad = {
|
||||
"lifetime": "keep",
|
||||
"confirmed_by": "human",
|
||||
"confirmed_on": "2026-07-14",
|
||||
"source": "test",
|
||||
}
|
||||
global_path = _write(tmp_path, "rulebook.json", _envelope(bad))
|
||||
rb = load_rulebook(global_path=global_path, project_path=None)
|
||||
assert rb.warnings
|
||||
|
||||
def test_propagate_ignore_field_is_rejected(self, tmp_path):
|
||||
bad = _rule("bad.md", propagate_ignore=True)
|
||||
global_path = _write(tmp_path, "rulebook.json", _envelope(bad))
|
||||
rb = load_rulebook(global_path=global_path, project_path=None)
|
||||
assert rb.query("bad.md", is_dir=False) is None
|
||||
assert any("propagate_ignore" in w for w in rb.warnings)
|
||||
|
||||
def test_defaults_retain_recent_and_max_age_days(self, tmp_path):
|
||||
rule = _rule("stuff/**", lifetime="temporary")
|
||||
global_path = _write(tmp_path, "rulebook.json", _envelope(rule))
|
||||
rb = load_rulebook(global_path=global_path, project_path=None)
|
||||
match = rb.query("stuff", is_dir=True)
|
||||
assert match is not None
|
||||
assert match.rule["retain_recent"] == 3
|
||||
assert match.rule["max_age_days"] == 3
|
||||
|
||||
def test_overridden_retain_recent_and_max_age_days(self, tmp_path):
|
||||
rule = _rule(
|
||||
"stuff/**", lifetime="temporary", retain_recent=5, max_age_days=10
|
||||
)
|
||||
global_path = _write(tmp_path, "rulebook.json", _envelope(rule))
|
||||
rb = load_rulebook(global_path=global_path, project_path=None)
|
||||
match = rb.query("stuff", is_dir=True)
|
||||
assert match.rule["retain_recent"] == 5
|
||||
assert match.rule["max_age_days"] == 10
|
||||
|
||||
|
||||
class TestGlobCompilationVersionCheck:
|
||||
def test_hard_fails_clearly_below_python_313(self, tmp_path, monkeypatch):
|
||||
import rulebook as rb_module
|
||||
|
||||
monkeypatch.setattr(rb_module.sys, "version_info", (3, 12, 0))
|
||||
global_path = _write(
|
||||
tmp_path, "rulebook.json", _envelope(_rule("foo.md"))
|
||||
)
|
||||
with pytest.raises(RulebookLoadError, match=r"(?i)python.*3\.13"):
|
||||
load_rulebook(global_path=global_path, project_path=None)
|
||||
|
||||
def test_compiles_recursive_double_star(self, tmp_path):
|
||||
rule = _rule("autoresearch/*/**", lifetime="temporary")
|
||||
global_path = _write(tmp_path, "rulebook.json", _envelope(rule))
|
||||
rb = load_rulebook(global_path=global_path, project_path=None)
|
||||
match = rb.query("autoresearch/run-2026-07-01", is_dir=True)
|
||||
assert match is not None
|
||||
assert match.level == "directory"
|
||||
|
||||
def test_matches_hidden_dotfile_paths(self, tmp_path):
|
||||
rule = _rule(".dochygiene/**")
|
||||
global_path = _write(tmp_path, "rulebook.json", _envelope(rule))
|
||||
rb = load_rulebook(global_path=global_path, project_path=None)
|
||||
match = rb.query(".dochygiene", is_dir=True)
|
||||
assert match is not None
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# Task 1.2 — add-only merge, two-axis precedence, keep-shadowing,
|
||||
# unmatched -> None, file-vs-directory distinction, IGNORE sentinels
|
||||
# ===========================================================================
|
||||
|
||||
class TestPrecedenceAndMerge:
|
||||
def test_unmatched_path_returns_none(self, tmp_path):
|
||||
global_path = _write(
|
||||
tmp_path, "rulebook.json", _envelope(_rule("foo.md"))
|
||||
)
|
||||
rb = load_rulebook(global_path=global_path, project_path=None)
|
||||
assert rb.query("totally/unrelated/path.md", is_dir=False) is None
|
||||
|
||||
def test_file_level_vs_directory_level_distinction(self, tmp_path):
|
||||
rules = [
|
||||
_rule("HANDOFF-2026-07-01.md", lifetime="delete-once-served"),
|
||||
_rule("autoresearch/*/**", lifetime="temporary"),
|
||||
]
|
||||
global_path = _write(tmp_path, "rulebook.json", _envelope(*rules))
|
||||
rb = load_rulebook(global_path=global_path, project_path=None)
|
||||
|
||||
file_match = rb.query("HANDOFF-2026-07-01.md", is_dir=False)
|
||||
assert file_match.level == "file"
|
||||
|
||||
dir_match = rb.query("autoresearch/run-1", is_dir=True)
|
||||
assert dir_match.level == "directory"
|
||||
|
||||
def test_project_file_rule_outranks_global_directory_rule(self, tmp_path):
|
||||
global_path = _write(
|
||||
tmp_path,
|
||||
"rulebook.json",
|
||||
_envelope(_rule("notes/**", lifetime="temporary")),
|
||||
)
|
||||
project_path = _write(
|
||||
tmp_path,
|
||||
"proj.json",
|
||||
_envelope(_rule("notes/keep-me.md", lifetime="keep")),
|
||||
)
|
||||
rb = load_rulebook(global_path=global_path, project_path=project_path)
|
||||
match = rb.query("notes/keep-me.md", is_dir=False)
|
||||
assert match is not None
|
||||
assert match.rule["lifetime"] == "keep"
|
||||
assert match.level == "file"
|
||||
|
||||
def test_project_directory_rule_outranks_global_file_rule(self, tmp_path):
|
||||
# Same file path matched by BOTH a global file-rule and a project
|
||||
# directory-rule whose subtree covers it; project-dir (tier 1)
|
||||
# beats global-file (tier 2) per the two-axis precedence order.
|
||||
global_path = _write(
|
||||
tmp_path,
|
||||
"rulebook.json",
|
||||
_envelope(_rule("notes/x.md", lifetime="delete-once-served")),
|
||||
)
|
||||
project_path = _write(
|
||||
tmp_path,
|
||||
"proj.json",
|
||||
_envelope(_rule("notes/**", lifetime="temporary")),
|
||||
)
|
||||
rb = load_rulebook(global_path=global_path, project_path=project_path)
|
||||
match = rb.query("notes/x.md", is_dir=False)
|
||||
assert match is not None
|
||||
assert match.rule["lifetime"] == "temporary"
|
||||
assert match.origin == "project"
|
||||
assert match.level == "directory"
|
||||
|
||||
def test_project_file_rule_outranks_matching_global_directory_rule(
|
||||
self, tmp_path
|
||||
):
|
||||
# A project file-rule and a global directory-rule both cover the
|
||||
# same file path; project-file (tier 0) must win over
|
||||
# global-directory (tier 3) even though the directory-rule's
|
||||
# subtree also matches.
|
||||
global_path = _write(
|
||||
tmp_path,
|
||||
"rulebook.json",
|
||||
_envelope(_rule("notes/**", lifetime="temporary")),
|
||||
)
|
||||
project_path = _write(
|
||||
tmp_path,
|
||||
"proj.json",
|
||||
_envelope(_rule("notes/keep-me.md", lifetime="keep")),
|
||||
)
|
||||
rb = load_rulebook(global_path=global_path, project_path=project_path)
|
||||
match = rb.query("notes/keep-me.md", is_dir=False)
|
||||
assert match is not None
|
||||
assert match.rule["lifetime"] == "keep"
|
||||
assert match.origin == "project"
|
||||
assert match.level == "file"
|
||||
|
||||
def test_ties_broken_by_longest_glob_then_last_defined(self, tmp_path):
|
||||
# Both directory-rules' subtrees genuinely cover "a/b/c.md" (real
|
||||
# tie within the same (global, directory) tier) — the longer
|
||||
# pattern "a/b/**" must win over "a/**".
|
||||
rules = [
|
||||
_rule("a/**", lifetime="temporary", note="short"),
|
||||
_rule("a/b/**", lifetime="keep", note="longer"),
|
||||
]
|
||||
global_path = _write(tmp_path, "rulebook.json", _envelope(*rules))
|
||||
rb = load_rulebook(global_path=global_path, project_path=None)
|
||||
match = rb.query("a/b/c.md", is_dir=False)
|
||||
assert match is not None
|
||||
assert match.rule["note"] == "longer"
|
||||
|
||||
def test_ties_equal_length_last_defined_wins(self, tmp_path):
|
||||
rules = [
|
||||
_rule("dup/**", lifetime="temporary", note="first"),
|
||||
_rule("dup/**", lifetime="keep", note="second"),
|
||||
]
|
||||
global_path = _write(tmp_path, "rulebook.json", _envelope(*rules))
|
||||
rb = load_rulebook(global_path=global_path, project_path=None)
|
||||
match = rb.query("dup", is_dir=True)
|
||||
assert match.rule["note"] == "second"
|
||||
|
||||
def test_add_only_merge_never_deletes_global_rules(self, tmp_path):
|
||||
global_path = _write(
|
||||
tmp_path,
|
||||
"rulebook.json",
|
||||
_envelope(_rule("a.md"), _rule("b.md")),
|
||||
)
|
||||
project_path = _write(
|
||||
tmp_path, "proj.json", _envelope(_rule("c.md"))
|
||||
)
|
||||
rb = load_rulebook(global_path=global_path, project_path=project_path)
|
||||
assert rb.query("a.md", is_dir=False) is not None
|
||||
assert rb.query("b.md", is_dir=False) is not None
|
||||
assert rb.query("c.md", is_dir=False) is not None
|
||||
|
||||
def test_keep_shadowing_neutralizes_global_delete_rule(self, tmp_path):
|
||||
global_path = _write(
|
||||
tmp_path,
|
||||
"rulebook.json",
|
||||
_envelope(_rule("autoresearch/*/**", lifetime="temporary")),
|
||||
)
|
||||
project_path = _write(
|
||||
tmp_path,
|
||||
"proj.json",
|
||||
_envelope(_rule("autoresearch/special/**", lifetime="keep")),
|
||||
)
|
||||
rb = load_rulebook(global_path=global_path, project_path=project_path)
|
||||
shadowed = rb.query("autoresearch/special", is_dir=True)
|
||||
assert shadowed.rule["lifetime"] == "keep"
|
||||
# an unrelated run dir under the same global rule is still temporary
|
||||
other = rb.query("autoresearch/other-run", is_dir=True)
|
||||
assert other.rule["lifetime"] == "temporary"
|
||||
|
||||
|
||||
class TestIgnoreSentinel:
|
||||
def test_ignore_rule_has_no_lifetime_and_is_flagged(self, tmp_path):
|
||||
ignore_rule = _rule("graphify-out/**", lifetime=None)
|
||||
global_path = _write(tmp_path, "rulebook.json", _envelope(ignore_rule))
|
||||
rb = load_rulebook(global_path=global_path, project_path=None)
|
||||
match = rb.query("graphify-out", is_dir=True)
|
||||
assert match is not None
|
||||
assert match.is_ignore is True
|
||||
assert "lifetime" not in match.rule or match.rule.get("lifetime") is None
|
||||
|
||||
def test_ignore_rule_distinct_from_keep(self, tmp_path):
|
||||
rules = [
|
||||
_rule("graphify-out/**", lifetime=None),
|
||||
_rule(".dochygiene/**", lifetime="keep"),
|
||||
]
|
||||
global_path = _write(tmp_path, "rulebook.json", _envelope(*rules))
|
||||
rb = load_rulebook(global_path=global_path, project_path=None)
|
||||
ignore_match = rb.query("graphify-out", is_dir=True)
|
||||
keep_match = rb.query(".dochygiene", is_dir=True)
|
||||
assert ignore_match.is_ignore is True
|
||||
assert keep_match.is_ignore is False
|
||||
assert keep_match.rule["lifetime"] == "keep"
|
||||
|
|
@ -0,0 +1,303 @@
|
|||
"""
|
||||
Tests for `RulesFileWriter` in scripts/calibrate_helpers.py.
|
||||
|
||||
Covers tasks 1.1/1.2 of the calibrate-assessment-inventory change: the
|
||||
single canonical serialization path for `.dochygiene-rules.json` —
|
||||
- canonical ordering: rules grouped delete-once-served -> temporary -> keep,
|
||||
glob-sorted within each group; nominations after rules; consults before
|
||||
rejected, each glob-sorted
|
||||
- idempotent round-trip (byte-identical rewrite of an already-canonical file)
|
||||
- hand-edit re-canonicalization (out-of-order input is rewritten canonical)
|
||||
- unknown-field warn-and-round-trip, for both rules and nomination entries
|
||||
|
||||
sys.path is patched here (no shared conftest), matching the existing
|
||||
tests/test_calibrate_helpers.py pattern.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
_SCRIPTS_DIR = Path(__file__).parent.parent / "scripts"
|
||||
if str(_SCRIPTS_DIR) not in sys.path:
|
||||
sys.path.insert(0, str(_SCRIPTS_DIR))
|
||||
|
||||
from calibrate_helpers import RulesFileWriter # noqa: E402
|
||||
|
||||
|
||||
def _rule(glob, lifetime, **extra):
|
||||
d = {"glob": glob, "lifetime": lifetime, "confirmed_by": "human", "confirmed_on": "2026-07-15"}
|
||||
d.update(extra)
|
||||
return d
|
||||
|
||||
|
||||
class TestCanonicalOrdering:
|
||||
def test_rules_grouped_by_tier_in_order_and_glob_sorted(self):
|
||||
data = {
|
||||
"schema_version": 1,
|
||||
"rules": [
|
||||
_rule("z/keep.md", "keep"),
|
||||
_rule("a/keep.md", "keep"),
|
||||
_rule("b/temp/*.md", "temporary"),
|
||||
_rule("a/temp/*.md", "temporary"),
|
||||
_rule("z/served.md", "delete-once-served"),
|
||||
_rule("a/served.md", "delete-once-served"),
|
||||
],
|
||||
}
|
||||
writer = RulesFileWriter()
|
||||
canonical, _warnings = writer.canonicalize(data)
|
||||
globs = [r["glob"] for r in canonical["rules"]]
|
||||
assert globs == [
|
||||
"a/served.md",
|
||||
"z/served.md",
|
||||
"a/temp/*.md",
|
||||
"b/temp/*.md",
|
||||
"a/keep.md",
|
||||
"z/keep.md",
|
||||
]
|
||||
|
||||
def test_nominations_after_rules_consults_before_rejected_sorted(self):
|
||||
data = {
|
||||
"schema_version": 1,
|
||||
"rules": [_rule("a.md", "keep")],
|
||||
"nominations": {
|
||||
"rejected": [
|
||||
{
|
||||
"glob": "z/reject.md",
|
||||
"lifetime": "temporary",
|
||||
"why": "why z",
|
||||
"rejected_by": "judge",
|
||||
"judged_on": "2026-07-15",
|
||||
},
|
||||
{
|
||||
"glob": "a/reject.md",
|
||||
"lifetime": "temporary",
|
||||
"why": "why a",
|
||||
"rejected_by": "judge",
|
||||
"judged_on": "2026-07-15",
|
||||
},
|
||||
],
|
||||
"consults": [
|
||||
{
|
||||
"glob": "z/consult.md",
|
||||
"question": "q z",
|
||||
"evidence": "e",
|
||||
"cluster_key": "k",
|
||||
"asked_on": "2026-07-15",
|
||||
},
|
||||
{
|
||||
"glob": "a/consult.md",
|
||||
"question": "q a",
|
||||
"evidence": "e",
|
||||
"cluster_key": "k",
|
||||
"asked_on": "2026-07-15",
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
writer = RulesFileWriter()
|
||||
canonical, _warnings = writer.canonicalize(data)
|
||||
|
||||
keys = list(canonical.keys())
|
||||
assert keys.index("rules") < keys.index("nominations")
|
||||
|
||||
nomination_keys = list(canonical["nominations"].keys())
|
||||
assert nomination_keys.index("consults") < nomination_keys.index("rejected")
|
||||
|
||||
assert [c["glob"] for c in canonical["nominations"]["consults"]] == [
|
||||
"a/consult.md",
|
||||
"z/consult.md",
|
||||
]
|
||||
assert [r["glob"] for r in canonical["nominations"]["rejected"]] == [
|
||||
"a/reject.md",
|
||||
"z/reject.md",
|
||||
]
|
||||
|
||||
def test_nominations_omitted_when_absent(self):
|
||||
data = {"schema_version": 1, "rules": [_rule("a.md", "keep")]}
|
||||
writer = RulesFileWriter()
|
||||
canonical, _warnings = writer.canonicalize(data)
|
||||
assert "nominations" not in canonical
|
||||
|
||||
def test_nominations_omitted_when_present_but_empty(self):
|
||||
data = {
|
||||
"schema_version": 1,
|
||||
"rules": [_rule("a.md", "keep")],
|
||||
"nominations": {"consults": [], "rejected": []},
|
||||
}
|
||||
writer = RulesFileWriter()
|
||||
canonical, _warnings = writer.canonicalize(data)
|
||||
assert "nominations" not in canonical
|
||||
|
||||
|
||||
class TestIdempotentRoundTrip:
|
||||
def test_writing_an_already_canonical_file_is_byte_identical(self, tmp_path):
|
||||
data = {
|
||||
"schema_version": 1,
|
||||
"rules": [
|
||||
_rule("a/served.md", "delete-once-served"),
|
||||
_rule("a/temp/*.md", "temporary"),
|
||||
_rule("a/keep.md", "keep"),
|
||||
],
|
||||
"nominations": {
|
||||
"consults": [
|
||||
{
|
||||
"glob": "a/consult.md",
|
||||
"question": "q",
|
||||
"evidence": "e",
|
||||
"cluster_key": "k",
|
||||
"asked_on": "2026-07-15",
|
||||
}
|
||||
],
|
||||
"rejected": [
|
||||
{
|
||||
"glob": "a/reject.md",
|
||||
"lifetime": "temporary",
|
||||
"why": "why",
|
||||
"rejected_by": "judge",
|
||||
"judged_on": "2026-07-15",
|
||||
}
|
||||
],
|
||||
},
|
||||
}
|
||||
writer = RulesFileWriter()
|
||||
path = tmp_path / ".dochygiene-rules.json"
|
||||
writer.write(path, data)
|
||||
first_bytes = path.read_bytes()
|
||||
|
||||
loaded, _warnings = writer.load(path)
|
||||
writer.write(path, loaded)
|
||||
second_bytes = path.read_bytes()
|
||||
|
||||
assert first_bytes == second_bytes
|
||||
|
||||
|
||||
class TestHandEditRecanonicalization:
|
||||
def test_hand_edited_out_of_order_file_recanonicalizes_on_next_write(self, tmp_path):
|
||||
# Hand-authored file: keep rule listed before temporary/served rules,
|
||||
# rejected before consults -- out of canonical order.
|
||||
hand_edited = {
|
||||
"schema_version": 1,
|
||||
"rules": [
|
||||
_rule("a/keep.md", "keep"),
|
||||
_rule("a/served.md", "delete-once-served"),
|
||||
_rule("a/temp/*.md", "temporary"),
|
||||
],
|
||||
"nominations": {
|
||||
"rejected": [
|
||||
{
|
||||
"glob": "a/reject.md",
|
||||
"lifetime": "temporary",
|
||||
"why": "why",
|
||||
"rejected_by": "judge",
|
||||
"judged_on": "2026-07-15",
|
||||
}
|
||||
],
|
||||
"consults": [
|
||||
{
|
||||
"glob": "a/consult.md",
|
||||
"question": "q",
|
||||
"evidence": "e",
|
||||
"cluster_key": "k",
|
||||
"asked_on": "2026-07-15",
|
||||
}
|
||||
],
|
||||
},
|
||||
}
|
||||
path = tmp_path / ".dochygiene-rules.json"
|
||||
path.write_text(json.dumps(hand_edited, indent=2), encoding="utf-8")
|
||||
|
||||
writer = RulesFileWriter()
|
||||
loaded, _warnings = writer.load(path)
|
||||
writer.write(path, loaded)
|
||||
|
||||
on_disk = json.loads(path.read_text(encoding="utf-8"))
|
||||
assert [r["glob"] for r in on_disk["rules"]] == [
|
||||
"a/served.md",
|
||||
"a/temp/*.md",
|
||||
"a/keep.md",
|
||||
]
|
||||
nomination_keys = list(on_disk["nominations"].keys())
|
||||
assert nomination_keys.index("consults") < nomination_keys.index("rejected")
|
||||
|
||||
|
||||
class TestUnknownFieldsWarnAndRoundtrip:
|
||||
def test_unknown_rule_field_warns_and_is_preserved(self):
|
||||
data = {
|
||||
"schema_version": 1,
|
||||
"rules": [_rule("a.md", "keep", mystery_field="surprise")],
|
||||
}
|
||||
writer = RulesFileWriter()
|
||||
canonical, warnings = writer.canonicalize(data)
|
||||
assert canonical["rules"][0]["mystery_field"] == "surprise"
|
||||
assert any("mystery_field" in w for w in warnings)
|
||||
|
||||
def test_unknown_consult_field_warns_and_is_preserved(self):
|
||||
data = {
|
||||
"schema_version": 1,
|
||||
"rules": [],
|
||||
"nominations": {
|
||||
"consults": [
|
||||
{
|
||||
"glob": "a/consult.md",
|
||||
"question": "q",
|
||||
"evidence": "e",
|
||||
"cluster_key": "k",
|
||||
"asked_on": "2026-07-15",
|
||||
"extra_field": "unexpected",
|
||||
}
|
||||
],
|
||||
"rejected": [],
|
||||
},
|
||||
}
|
||||
writer = RulesFileWriter()
|
||||
canonical, warnings = writer.canonicalize(data)
|
||||
assert canonical["nominations"]["consults"][0]["extra_field"] == "unexpected"
|
||||
assert any("extra_field" in w for w in warnings)
|
||||
|
||||
def test_unknown_rejected_field_warns_and_is_preserved(self):
|
||||
data = {
|
||||
"schema_version": 1,
|
||||
"rules": [],
|
||||
"nominations": {
|
||||
"consults": [],
|
||||
"rejected": [
|
||||
{
|
||||
"glob": "a/reject.md",
|
||||
"lifetime": "temporary",
|
||||
"why": "why",
|
||||
"rejected_by": "judge",
|
||||
"judged_on": "2026-07-15",
|
||||
"surprise_field": "oops",
|
||||
}
|
||||
],
|
||||
},
|
||||
}
|
||||
writer = RulesFileWriter()
|
||||
canonical, warnings = writer.canonicalize(data)
|
||||
assert canonical["nominations"]["rejected"][0]["surprise_field"] == "oops"
|
||||
assert any("surprise_field" in w for w in warnings)
|
||||
|
||||
|
||||
class TestLoad:
|
||||
def test_load_reads_file_and_returns_canonical_data(self, tmp_path):
|
||||
data = {
|
||||
"schema_version": 1,
|
||||
"rules": [_rule("a/keep.md", "keep")],
|
||||
}
|
||||
path = tmp_path / ".dochygiene-rules.json"
|
||||
path.write_text(json.dumps(data), encoding="utf-8")
|
||||
writer = RulesFileWriter()
|
||||
loaded, warnings = writer.load(path)
|
||||
assert loaded["rules"][0]["glob"] == "a/keep.md"
|
||||
assert warnings == []
|
||||
|
||||
def test_load_missing_file_returns_empty_envelope(self, tmp_path):
|
||||
path = tmp_path / "does-not-exist.json"
|
||||
writer = RulesFileWriter()
|
||||
loaded, warnings = writer.load(path)
|
||||
assert loaded["rules"] == []
|
||||
assert "nominations" not in loaded
|
||||
assert warnings == []
|
||||
|
|
@ -0,0 +1,421 @@
|
|||
"""
|
||||
Tests for scanner lifecycle signals (tasks 2.1-2.3 of lifecycle-aware-doc-hygiene).
|
||||
|
||||
Covers:
|
||||
- 2.1: directory-rule match prunes the walk + emits one aggregate entry;
|
||||
IGNORE-surface match prunes with zero emission; file-rule match attaches
|
||||
a lifecycle signal alongside existing objective signals; unmatched files
|
||||
flow through unchanged.
|
||||
- 2.2: temporary-tier age (git commit time, mtime fallback for untracked,
|
||||
one-stat directory-inode mtime for untracked dirs); retain-recent-N
|
||||
grouping/ranking by rule match entry.
|
||||
- 2.3: delete-once-served — served_when_path substitution + filesystem
|
||||
check (deterministic, scanner-proven); served_when free text is
|
||||
classifier-judged only, scanner asserts nothing about satisfaction.
|
||||
|
||||
sys.path manipulation is intentional per file-ownership constraints (no
|
||||
shared conftest).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Prepend scripts/ to sys.path so we can import scanner/rulebook without a package
|
||||
_SCRIPTS_DIR = Path(__file__).parent.parent / "scripts"
|
||||
if str(_SCRIPTS_DIR) not in sys.path:
|
||||
sys.path.insert(0, str(_SCRIPTS_DIR))
|
||||
|
||||
from rulebook import RuleMatch # noqa: E402
|
||||
from scanner import Scanner # noqa: E402
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _make_tree(tmp: Path, files: dict) -> None:
|
||||
for rel, content in files.items():
|
||||
p = tmp / rel
|
||||
p.parent.mkdir(parents=True, exist_ok=True)
|
||||
p.write_text(content, encoding="utf-8")
|
||||
|
||||
|
||||
def _rule(glob, lifetime="temporary", origin="global", **extra) -> dict:
|
||||
r = {"glob": glob, "confirmed_by": "human", "confirmed_on": "2026-07-14", "source": "test"}
|
||||
if lifetime is not None:
|
||||
r["lifetime"] = lifetime
|
||||
r.update(extra)
|
||||
return r
|
||||
|
||||
|
||||
class _FakeRulebook:
|
||||
"""A minimal rulebook stand-in: one canned RuleMatch per glob->config.
|
||||
|
||||
Each registered entry is `(is_dir_only, matcher_fn, level, is_ignore, rule)`.
|
||||
`matcher_fn(path, is_dir)` decides whether the entry applies. This keeps
|
||||
scanner tests fully isolated from rulebook.py's glob-compilation
|
||||
internals (already covered by test_rulebook.py) while exercising the
|
||||
scanner's *consumption* of RuleMatch objects.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self._entries = [] # list of (matcher_fn, level, is_ignore, rule, origin)
|
||||
|
||||
def register(self, matcher_fn, level, rule, is_ignore=False, origin="global"):
|
||||
self._entries.append((matcher_fn, level, is_ignore, rule, origin))
|
||||
|
||||
def query(self, path, is_dir=False):
|
||||
norm = path.strip("/")
|
||||
for matcher_fn, level, is_ignore, rule, origin in self._entries:
|
||||
if matcher_fn(norm, is_dir):
|
||||
return RuleMatch(rule=rule, level=level, is_ignore=is_ignore, origin=origin)
|
||||
return None
|
||||
|
||||
|
||||
def _run(root: Path, rulebook=None, git_commit_time_fn=None, now_fn=None, **kwargs) -> dict:
|
||||
return Scanner(
|
||||
root=root,
|
||||
git_log_fn=lambda p: [],
|
||||
now_fn=now_fn or (lambda: 0.0),
|
||||
rulebook=rulebook,
|
||||
git_commit_time_fn=git_commit_time_fn,
|
||||
**kwargs,
|
||||
).run()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 2.1: directory-rule prune + aggregate entry
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestDirectoryRulePrune:
|
||||
def test_directory_rule_prunes_walk_and_emits_one_aggregate_entry(self, tmp_path):
|
||||
_make_tree(tmp_path, {
|
||||
"autoresearch/run-2026-07-01/a.md": "content a",
|
||||
"autoresearch/run-2026-07-01/b.md": "content b",
|
||||
"autoresearch/run-2026-07-01/nested/c.md": "content c",
|
||||
"docs/live.md": "live content",
|
||||
})
|
||||
rule = _rule("autoresearch/*/**", lifetime="temporary")
|
||||
rb = _FakeRulebook()
|
||||
rb.register(
|
||||
lambda p, is_dir: is_dir and p == "autoresearch/run-2026-07-01",
|
||||
"directory",
|
||||
rule,
|
||||
)
|
||||
|
||||
result = _run(tmp_path, rulebook=rb)
|
||||
|
||||
# No file beneath the pruned directory is opened/shortlisted.
|
||||
for path in result["shortlist"]:
|
||||
assert not path.startswith("autoresearch/run-2026-07-01/"), path
|
||||
|
||||
# Exactly one aggregate entry for the directory path itself.
|
||||
assert "autoresearch/run-2026-07-01" in result["shortlist"]
|
||||
assert result["shortlist"].count("autoresearch/run-2026-07-01") == 1
|
||||
|
||||
sigs = result["signals"]["autoresearch/run-2026-07-01"]
|
||||
lifecycle_sigs = [s for s in sigs if s["name"] == "lifecycle"]
|
||||
assert len(lifecycle_sigs) == 1
|
||||
assert lifecycle_sigs[0]["lifetime"] == "temporary"
|
||||
assert lifecycle_sigs[0]["rule_ref"] == "autoresearch/*/**"
|
||||
|
||||
# Unrelated file flows through unchanged.
|
||||
assert "docs/live.md" in result["shortlist"]
|
||||
assert "docs/live.md" not in result["signals"] or all(
|
||||
s["name"] != "lifecycle" for s in result["signals"]["docs/live.md"]
|
||||
)
|
||||
|
||||
def test_ignore_surface_prunes_with_zero_emission(self, tmp_path):
|
||||
_make_tree(tmp_path, {
|
||||
"graphify-out/graph.json": "{}",
|
||||
"graphify-out/nested/index.md": "index",
|
||||
"docs/live.md": "live content",
|
||||
})
|
||||
ignore_rule = _rule("graphify-out/**", lifetime=None)
|
||||
rb = _FakeRulebook()
|
||||
rb.register(
|
||||
lambda p, is_dir: is_dir and p == "graphify-out",
|
||||
"directory",
|
||||
ignore_rule,
|
||||
is_ignore=True,
|
||||
)
|
||||
|
||||
result = _run(tmp_path, rulebook=rb)
|
||||
|
||||
for path in result["shortlist"]:
|
||||
assert not path.startswith("graphify-out"), path
|
||||
assert "graphify-out" not in result["shortlist"]
|
||||
assert "graphify-out" not in result["signals"]
|
||||
|
||||
def test_file_rule_attaches_lifecycle_signal_alongside_existing_signals(self, tmp_path):
|
||||
_make_tree(tmp_path, {
|
||||
"HANDOFF-2026-07-01.md": "[broken](./missing.md)",
|
||||
})
|
||||
rule = _rule(
|
||||
"HANDOFF-2026-07-01.md", lifetime="delete-once-served",
|
||||
served_when="the handoff has been read and actioned",
|
||||
)
|
||||
rb = _FakeRulebook()
|
||||
rb.register(
|
||||
lambda p, is_dir: (not is_dir) and p == "HANDOFF-2026-07-01.md",
|
||||
"file",
|
||||
rule,
|
||||
)
|
||||
|
||||
result = _run(tmp_path, rulebook=rb)
|
||||
|
||||
sigs = result["signals"]["HANDOFF-2026-07-01.md"]
|
||||
names = {s["name"] for s in sigs}
|
||||
assert "lifecycle" in names
|
||||
assert "broken_reference" in names
|
||||
|
||||
def test_unmatched_files_flow_through_unchanged(self, tmp_path):
|
||||
_make_tree(tmp_path, {"docs/plain.md": "plain content, nothing special"})
|
||||
rb = _FakeRulebook() # no registered rules -> always None
|
||||
|
||||
result = _run(tmp_path, rulebook=rb)
|
||||
|
||||
assert "docs/plain.md" in result["shortlist"]
|
||||
assert "docs/plain.md" not in result["signals"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 2.2: temporary-tier age + retain-recent-N
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestTemporaryTierAge:
|
||||
def test_tracked_file_age_from_git_commit_time(self, tmp_path):
|
||||
_make_tree(tmp_path, {"tmp/a.md": "content"})
|
||||
rule = _rule("tmp/a.md", lifetime="temporary", max_age_days=3, retain_recent=3)
|
||||
rb = _FakeRulebook()
|
||||
rb.register(lambda p, is_dir: (not is_dir) and p == "tmp/a.md", "file", rule)
|
||||
|
||||
# now = 10 days after commit -> age_days == 10
|
||||
commit_time = "2026-06-24T00:00:00+00:00"
|
||||
now_ts = __import__("datetime").datetime.fromisoformat(commit_time).timestamp() + 10 * 86400
|
||||
|
||||
result = _run(
|
||||
tmp_path,
|
||||
rulebook=rb,
|
||||
git_commit_time_fn=lambda p: commit_time,
|
||||
now_fn=lambda: now_ts,
|
||||
)
|
||||
sig = [s for s in result["signals"]["tmp/a.md"] if s["name"] == "lifecycle"][0]
|
||||
assert abs(sig["age_days"] - 10.0) < 0.01
|
||||
|
||||
def test_untracked_file_age_falls_back_to_mtime(self, tmp_path):
|
||||
_make_tree(tmp_path, {"tmp/b.md": "content"})
|
||||
rule = _rule("tmp/b.md", lifetime="temporary")
|
||||
rb = _FakeRulebook()
|
||||
rb.register(lambda p, is_dir: (not is_dir) and p == "tmp/b.md", "file", rule)
|
||||
|
||||
result = _run(
|
||||
tmp_path,
|
||||
rulebook=rb,
|
||||
git_commit_time_fn=lambda p: None, # untracked
|
||||
now_fn=lambda: (tmp_path / "tmp/b.md").stat().st_mtime + 5 * 86400,
|
||||
)
|
||||
sig = [s for s in result["signals"]["tmp/b.md"] if s["name"] == "lifecycle"][0]
|
||||
assert abs(sig["age_days"] - 5.0) < 0.01
|
||||
|
||||
def test_untracked_directory_age_is_one_stat_not_recursive(self, tmp_path):
|
||||
_make_tree(tmp_path, {
|
||||
"autoresearch/run-x/old_nested_file.md": "old",
|
||||
})
|
||||
# Make the nested file's mtime much older than the directory's own
|
||||
# mtime, to prove the directory's own inode mtime is what's used.
|
||||
import os
|
||||
nested = tmp_path / "autoresearch/run-x/old_nested_file.md"
|
||||
old_time = 1_000_000.0
|
||||
os.utime(nested, (old_time, old_time))
|
||||
|
||||
dir_path = tmp_path / "autoresearch/run-x"
|
||||
dir_mtime = dir_path.stat().st_mtime
|
||||
|
||||
rule = _rule("autoresearch/*/**", lifetime="temporary")
|
||||
rb = _FakeRulebook()
|
||||
rb.register(
|
||||
lambda p, is_dir: is_dir and p == "autoresearch/run-x", "directory", rule
|
||||
)
|
||||
|
||||
result = _run(
|
||||
tmp_path,
|
||||
rulebook=rb,
|
||||
git_commit_time_fn=lambda p: None,
|
||||
now_fn=lambda: dir_mtime + 2 * 86400,
|
||||
)
|
||||
sig = [s for s in result["signals"]["autoresearch/run-x"] if s["name"] == "lifecycle"][0]
|
||||
# Age reflects the directory's own mtime (~2 days), not the ancient
|
||||
# nested file's mtime (which would be a huge age if it were used).
|
||||
assert abs(sig["age_days"] - 2.0) < 0.01
|
||||
|
||||
def test_retain_recent_n_keeps_newest_3_regardless_of_age(self, tmp_path):
|
||||
_make_tree(tmp_path, {
|
||||
"autoresearch/run-1/x.md": "1",
|
||||
"autoresearch/run-2/x.md": "2",
|
||||
"autoresearch/run-3/x.md": "3",
|
||||
"autoresearch/run-4/x.md": "4",
|
||||
"autoresearch/run-5/x.md": "5",
|
||||
})
|
||||
rule = _rule("autoresearch/*/**", lifetime="temporary", retain_recent=3, max_age_days=3)
|
||||
rb = _FakeRulebook()
|
||||
|
||||
def matcher(p, is_dir):
|
||||
return is_dir and p.startswith("autoresearch/run-")
|
||||
|
||||
rb.register(matcher, "directory", rule)
|
||||
|
||||
# Ages: run-1 oldest (10d) ... run-5 newest (1d).
|
||||
ages = {
|
||||
"autoresearch/run-1": 10.0,
|
||||
"autoresearch/run-2": 8.0,
|
||||
"autoresearch/run-3": 6.0,
|
||||
"autoresearch/run-4": 4.0,
|
||||
"autoresearch/run-5": 1.0,
|
||||
}
|
||||
|
||||
def commit_time_fn(abs_path):
|
||||
return None # force mtime fallback, patched via now_fn/mtime below
|
||||
|
||||
# Use mtime fallback: set directory mtimes so that now - mtime == age.
|
||||
import os
|
||||
now_ts = 2_000_000.0
|
||||
for rel, age in ages.items():
|
||||
os.utime(tmp_path / rel, (now_ts - age * 86400, now_ts - age * 86400))
|
||||
|
||||
result = _run(
|
||||
tmp_path,
|
||||
rulebook=rb,
|
||||
git_commit_time_fn=commit_time_fn,
|
||||
now_fn=lambda: now_ts,
|
||||
)
|
||||
|
||||
retention_by_dir = {
|
||||
path: [s for s in sigs if s["name"] == "lifecycle"][0]["retention"]
|
||||
for path, sigs in result["signals"].items()
|
||||
if path.startswith("autoresearch/run-")
|
||||
}
|
||||
|
||||
# Newest 3 (run-3, run-4, run-5) always kept regardless of age.
|
||||
assert retention_by_dir["autoresearch/run-5"]["kept"] is True
|
||||
assert retention_by_dir["autoresearch/run-4"]["kept"] is True
|
||||
assert retention_by_dir["autoresearch/run-3"]["kept"] is True
|
||||
|
||||
# Ranked 4th and 5th (run-2, run-1) are older than max_age_days=3 ->
|
||||
# deletable.
|
||||
assert retention_by_dir["autoresearch/run-2"]["kept"] is False
|
||||
assert retention_by_dir["autoresearch/run-2"]["deletable"] is True
|
||||
assert retention_by_dir["autoresearch/run-1"]["kept"] is False
|
||||
assert retention_by_dir["autoresearch/run-1"]["deletable"] is True
|
||||
|
||||
def test_4th_ranked_entry_younger_than_max_age_is_not_deletable(self, tmp_path):
|
||||
_make_tree(tmp_path, {
|
||||
"autoresearch/run-1/x.md": "1",
|
||||
"autoresearch/run-2/x.md": "2",
|
||||
"autoresearch/run-3/x.md": "3",
|
||||
"autoresearch/run-4/x.md": "4",
|
||||
})
|
||||
rule = _rule("autoresearch/*/**", lifetime="temporary", retain_recent=3, max_age_days=3)
|
||||
rb = _FakeRulebook()
|
||||
rb.register(lambda p, is_dir: is_dir and p.startswith("autoresearch/run-"), "directory", rule)
|
||||
|
||||
import os
|
||||
now_ts = 2_000_000.0
|
||||
ages = {
|
||||
"autoresearch/run-1": 1.0, # 4th-ranked (oldest), but younger than max_age_days
|
||||
"autoresearch/run-2": 0.8,
|
||||
"autoresearch/run-3": 0.5,
|
||||
"autoresearch/run-4": 0.1,
|
||||
}
|
||||
for rel, age in ages.items():
|
||||
os.utime(tmp_path / rel, (now_ts - age * 86400, now_ts - age * 86400))
|
||||
|
||||
result = _run(
|
||||
tmp_path, rulebook=rb, git_commit_time_fn=lambda p: None, now_fn=lambda: now_ts
|
||||
)
|
||||
retention_by_dir = {
|
||||
path: [s for s in sigs if s["name"] == "lifecycle"][0]["retention"]
|
||||
for path, sigs in result["signals"].items()
|
||||
if path.startswith("autoresearch/run-")
|
||||
}
|
||||
assert retention_by_dir["autoresearch/run-1"]["kept"] is False
|
||||
assert retention_by_dir["autoresearch/run-1"]["deletable"] is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 2.3: delete-once-served
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestDeleteOnceServed:
|
||||
def test_served_when_path_substitution_and_satisfied(self, tmp_path):
|
||||
_make_tree(tmp_path, {
|
||||
"autoresearch/run-2026-07-01/report.md": "report",
|
||||
"autoresearch/run-2026-07-01/archive/report.md": "archived copy",
|
||||
})
|
||||
rule = _rule(
|
||||
"autoresearch/*/report.md",
|
||||
lifetime="delete-once-served",
|
||||
served_when_path="autoresearch/run-2026-07-01/archive/{name}",
|
||||
)
|
||||
rb = _FakeRulebook()
|
||||
rb.register(
|
||||
lambda p, is_dir: (not is_dir) and p == "autoresearch/run-2026-07-01/report.md",
|
||||
"file",
|
||||
rule,
|
||||
)
|
||||
|
||||
result = _run(tmp_path, rulebook=rb)
|
||||
sig = [
|
||||
s for s in result["signals"]["autoresearch/run-2026-07-01/report.md"]
|
||||
if s["name"] == "lifecycle"
|
||||
][0]
|
||||
assert sig["served"]["kind"] == "scanner-proven"
|
||||
assert sig["served"]["resolved_path"] == "autoresearch/run-2026-07-01/archive/report.md"
|
||||
assert sig["served"]["satisfied"] is True
|
||||
|
||||
def test_served_when_path_not_satisfied(self, tmp_path):
|
||||
_make_tree(tmp_path, {
|
||||
"autoresearch/run-2026-07-01/report.md": "report",
|
||||
})
|
||||
rule = _rule(
|
||||
"autoresearch/*/report.md",
|
||||
lifetime="delete-once-served",
|
||||
served_when_path="autoresearch/run-2026-07-01/archive/{name}",
|
||||
)
|
||||
rb = _FakeRulebook()
|
||||
rb.register(
|
||||
lambda p, is_dir: (not is_dir) and p == "autoresearch/run-2026-07-01/report.md",
|
||||
"file",
|
||||
rule,
|
||||
)
|
||||
|
||||
result = _run(tmp_path, rulebook=rb)
|
||||
sig = [
|
||||
s for s in result["signals"]["autoresearch/run-2026-07-01/report.md"]
|
||||
if s["name"] == "lifecycle"
|
||||
][0]
|
||||
assert sig["served"]["kind"] == "scanner-proven"
|
||||
assert sig["served"]["satisfied"] is False
|
||||
|
||||
def test_served_when_free_text_is_classifier_judged_only(self, tmp_path):
|
||||
_make_tree(tmp_path, {"HANDOFF-2026-07-01.md": "handoff content"})
|
||||
rule = _rule(
|
||||
"HANDOFF-2026-07-01.md",
|
||||
lifetime="delete-once-served",
|
||||
served_when="the handoff has been read and actioned by the team",
|
||||
)
|
||||
rb = _FakeRulebook()
|
||||
rb.register(
|
||||
lambda p, is_dir: (not is_dir) and p == "HANDOFF-2026-07-01.md", "file", rule
|
||||
)
|
||||
|
||||
result = _run(tmp_path, rulebook=rb)
|
||||
sig = [
|
||||
s for s in result["signals"]["HANDOFF-2026-07-01.md"] if s["name"] == "lifecycle"
|
||||
][0]
|
||||
assert sig["served"]["kind"] == "classifier-judged"
|
||||
assert sig["served"]["served_when"] == "the handoff has been read and actioned by the team"
|
||||
# Scanner asserts nothing about satisfaction for classifier-judged signals.
|
||||
assert "satisfied" not in sig["served"]
|
||||
|
|
@ -129,15 +129,26 @@ class TestWriteConfinement:
|
|||
f"Write escaped .cc-os/dochygiene/: {p}"
|
||||
)
|
||||
|
||||
def test_no_global_index_created(self, tmp_path):
|
||||
"""Operating on one project must not create anything in ~/ or /tmp."""
|
||||
(tmp_path / ".git").mkdir()
|
||||
store = StateStore(project_root=tmp_path)
|
||||
def test_no_global_index_created(self, tmp_path, monkeypatch):
|
||||
"""Operating on one project must not create anything in ~/ or /tmp.
|
||||
|
||||
HOME is redirected to an empty temp dir: asserting on the user's real
|
||||
home is flaky — other tools (e.g. os-backlog's projects registry)
|
||||
legitimately create ~/.cc-os outside this plugin's control.
|
||||
"""
|
||||
fake_home = tmp_path / "home"
|
||||
fake_home.mkdir()
|
||||
monkeypatch.setenv("HOME", str(fake_home))
|
||||
|
||||
project = tmp_path / "project"
|
||||
project.mkdir()
|
||||
(project / ".git").mkdir()
|
||||
store = StateStore(project_root=project)
|
||||
store.set_last_check()
|
||||
store.write_report("{}", "md")
|
||||
|
||||
home_dh = Path.home() / ".dochygiene"
|
||||
home_cc_os = Path.home() / ".cc-os"
|
||||
home_dh = fake_home / ".dochygiene"
|
||||
home_cc_os = fake_home / ".cc-os"
|
||||
assert not home_dh.exists(), "Global ~/.dochygiene must not be created"
|
||||
assert not home_cc_os.exists(), "Global ~/.cc-os must not be created"
|
||||
|
||||
|
|
|
|||
|
|
@ -67,6 +67,7 @@ def _minimal_valid_report() -> dict:
|
|||
"token_estimate": {"raw_tokens": 100},
|
||||
}
|
||||
],
|
||||
"promotion_candidates": [],
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -227,6 +228,214 @@ class TestCategoryEnum:
|
|||
assert any(v["field"].endswith("category.subtype") for v in violations)
|
||||
|
||||
|
||||
class TestLifecycleKinds:
|
||||
"""Group 3 (task 3.1): delete/extract-then-delete kinds + the lifecycle
|
||||
object + the ADR-0039 tier branch in derive_safety_tier."""
|
||||
|
||||
def _lifecycle_delete_entry(self, **lifecycle_overrides) -> dict:
|
||||
lifecycle = {
|
||||
"rule_ref": "autoresearch/*/**",
|
||||
"lifetime": "temporary",
|
||||
"git_state": {"tracked": True, "clean": True},
|
||||
}
|
||||
lifecycle.update(lifecycle_overrides)
|
||||
return {
|
||||
"path": "autoresearch/run-1/notes.md",
|
||||
"category": {"class": "stale", "subtype": "orphaned"},
|
||||
"signals": [],
|
||||
"op": "Delete expired temporary run.",
|
||||
"op_type": "deterministic",
|
||||
"is_destructive": True,
|
||||
"is_reversible": True,
|
||||
"safety_tier": "auto",
|
||||
"exact_edit": {
|
||||
"kind": "delete",
|
||||
"anchor": {"start_line": 1, "end_line": 10},
|
||||
"expected_sha256": "a" * 64,
|
||||
"lifecycle": lifecycle,
|
||||
},
|
||||
"token_estimate": {"raw_tokens": 10},
|
||||
}
|
||||
|
||||
def _base_report(self, entry: dict) -> dict:
|
||||
r = _minimal_valid_report()
|
||||
r["shortlist"] = [entry["path"]]
|
||||
r["entries"] = [entry]
|
||||
return r
|
||||
|
||||
# -- kind enum + required sub-fields -----------------------------------
|
||||
|
||||
def test_delete_kind_is_accepted(self):
|
||||
r = self._base_report(self._lifecycle_delete_entry())
|
||||
assert _validate(r) == []
|
||||
|
||||
def test_extract_then_delete_requires_extraction_target(self):
|
||||
entry = self._lifecycle_delete_entry()
|
||||
entry["exact_edit"]["kind"] = "extract-then-delete"
|
||||
r = self._base_report(entry)
|
||||
violations = _validate(r)
|
||||
assert any(v["field"].endswith("extraction_target") for v in violations)
|
||||
|
||||
def test_extract_then_delete_repo_durable_requires_dest(self):
|
||||
entry = self._lifecycle_delete_entry()
|
||||
entry["exact_edit"]["kind"] = "extract-then-delete"
|
||||
entry["exact_edit"]["extraction_target"] = "repo-durable"
|
||||
r = self._base_report(entry)
|
||||
violations = _validate(r)
|
||||
assert any(v["field"].endswith("extraction_dest") for v in violations)
|
||||
|
||||
def test_extract_then_delete_repo_durable_with_dest_passes(self):
|
||||
entry = self._lifecycle_delete_entry()
|
||||
entry["exact_edit"]["kind"] = "extract-then-delete"
|
||||
entry["exact_edit"]["extraction_target"] = "repo-durable"
|
||||
entry["exact_edit"]["extraction_dest"] = "docs/adr/0099-extracted.md"
|
||||
r = self._base_report(entry)
|
||||
assert _validate(r) == []
|
||||
|
||||
def test_extract_then_delete_cross_repo_needs_no_dest(self):
|
||||
entry = self._lifecycle_delete_entry()
|
||||
entry["exact_edit"]["kind"] = "extract-then-delete"
|
||||
entry["exact_edit"]["extraction_target"] = "cross-repo"
|
||||
r = self._base_report(entry)
|
||||
assert _validate(r) == []
|
||||
|
||||
def test_missing_lifecycle_object_is_rejected(self):
|
||||
entry = self._lifecycle_delete_entry()
|
||||
del entry["exact_edit"]["lifecycle"]
|
||||
r = self._base_report(entry)
|
||||
violations = _validate(r)
|
||||
assert any(v["field"].endswith("exact_edit.lifecycle") for v in violations)
|
||||
|
||||
def test_both_served_fields_is_rejected(self):
|
||||
entry = self._lifecycle_delete_entry(
|
||||
lifetime="delete-once-served",
|
||||
served_when_path="docs/plans/archive/{name}",
|
||||
served_when="the plan shipped",
|
||||
)
|
||||
entry["exact_edit"]["safety_tier_note"] = None # no-op, keep shape
|
||||
r = self._base_report(entry)
|
||||
violations = _validate(r)
|
||||
assert any("exactly one" in v["message"] for v in violations)
|
||||
|
||||
def test_neither_served_field_on_delete_once_served_is_rejected(self):
|
||||
entry = self._lifecycle_delete_entry(lifetime="delete-once-served")
|
||||
r = self._base_report(entry)
|
||||
violations = _validate(r)
|
||||
assert any("exactly one" in v["message"] for v in violations)
|
||||
|
||||
def test_temporary_with_served_field_is_rejected(self):
|
||||
entry = self._lifecycle_delete_entry(served_when_path="x/archive/{name}")
|
||||
r = self._base_report(entry)
|
||||
violations = _validate(r)
|
||||
assert any("not carry served_when_path" in v["message"] for v in violations)
|
||||
|
||||
def test_missing_git_state_is_rejected(self):
|
||||
entry = self._lifecycle_delete_entry()
|
||||
del entry["exact_edit"]["lifecycle"]["git_state"]
|
||||
r = self._base_report(entry)
|
||||
violations = _validate(r)
|
||||
assert any(v["field"].endswith("git_state") for v in violations)
|
||||
|
||||
# -- tier derivation branch ----------------------------------------------
|
||||
|
||||
def test_scanner_proven_tracked_clean_derives_auto(self):
|
||||
entry = self._lifecycle_delete_entry() # temporary + tracked+clean
|
||||
r = self._base_report(entry)
|
||||
assert _validate(r) == []
|
||||
assert entry["safety_tier"] == "auto"
|
||||
|
||||
def test_scanner_proven_dirty_forces_confirm(self):
|
||||
entry = self._lifecycle_delete_entry(git_state={"tracked": True, "clean": False})
|
||||
entry["safety_tier"] = "auto" # wrong: dirty forces confirm
|
||||
r = self._base_report(entry)
|
||||
violations = _validate(r)
|
||||
assert any(v["field"].endswith("safety_tier") and "mismatch" in v["message"] for v in violations)
|
||||
|
||||
def test_scanner_proven_untracked_forces_confirm(self):
|
||||
entry = self._lifecycle_delete_entry(git_state={"tracked": False, "clean": False})
|
||||
entry["safety_tier"] = "auto"
|
||||
r = self._base_report(entry)
|
||||
violations = _validate(r)
|
||||
assert any(v["field"].endswith("safety_tier") and "mismatch" in v["message"] for v in violations)
|
||||
|
||||
def test_classifier_judged_served_when_always_confirm_even_tracked_clean(self):
|
||||
entry = self._lifecycle_delete_entry(
|
||||
lifetime="delete-once-served",
|
||||
served_when="the effort this plan describes has shipped",
|
||||
git_state={"tracked": True, "clean": True},
|
||||
)
|
||||
entry["safety_tier"] = "auto" # wrong: classifier-judged always confirm
|
||||
r = self._base_report(entry)
|
||||
violations = _validate(r)
|
||||
assert any(v["field"].endswith("safety_tier") and "mismatch" in v["message"] for v in violations)
|
||||
|
||||
def test_classifier_judged_served_when_confirm_is_accepted(self):
|
||||
entry = self._lifecycle_delete_entry(
|
||||
lifetime="delete-once-served",
|
||||
served_when="the effort this plan describes has shipped",
|
||||
git_state={"tracked": True, "clean": True},
|
||||
)
|
||||
entry["safety_tier"] = "confirm"
|
||||
r = self._base_report(entry)
|
||||
assert _validate(r) == []
|
||||
|
||||
def test_served_when_path_tracked_clean_derives_auto(self):
|
||||
entry = self._lifecycle_delete_entry(
|
||||
lifetime="delete-once-served",
|
||||
served_when_path="openspec/changes/archive/{id}/",
|
||||
git_state={"tracked": True, "clean": True},
|
||||
)
|
||||
r = self._base_report(entry)
|
||||
assert _validate(r) == []
|
||||
assert entry["safety_tier"] == "auto"
|
||||
|
||||
def test_non_lifecycle_branches_unchanged(self):
|
||||
"""The pre-existing non-lifecycle truth table is untouched."""
|
||||
assert derive_safety_tier("generative", False, True) == "confirm"
|
||||
assert derive_safety_tier("deterministic", True, True) == "confirm"
|
||||
assert derive_safety_tier("deterministic", False, False) == "confirm"
|
||||
assert derive_safety_tier("deterministic", False, True) == "auto"
|
||||
|
||||
def test_derive_safety_tier_signature_accepts_lifecycle_kw(self):
|
||||
assert derive_safety_tier(
|
||||
"deterministic", True, True,
|
||||
lifecycle={"lifetime": "temporary", "git_state": {"tracked": True, "clean": True}},
|
||||
) == "auto"
|
||||
|
||||
|
||||
class TestPromotionCandidatesShape:
|
||||
|
||||
def test_envelope_requires_promotion_candidates(self):
|
||||
r = _minimal_valid_report()
|
||||
del r["promotion_candidates"]
|
||||
violations = _validate(r)
|
||||
assert any(v["field"] == "promotion_candidates" for v in violations)
|
||||
|
||||
def test_empty_promotion_candidates_is_valid(self):
|
||||
r = _minimal_valid_report()
|
||||
r["promotion_candidates"] = []
|
||||
assert _validate(r) == []
|
||||
|
||||
def test_well_formed_candidate_is_valid(self):
|
||||
r = _minimal_valid_report()
|
||||
r["promotion_candidates"] = [
|
||||
{"path": "docs/plans/x.md", "name": "archive-bucket", "pitch": "Move it to archive/ once."}
|
||||
]
|
||||
assert _validate(r) == []
|
||||
|
||||
def test_candidate_missing_field_is_rejected(self):
|
||||
r = _minimal_valid_report()
|
||||
r["promotion_candidates"] = [{"path": "docs/plans/x.md", "name": "archive-bucket"}]
|
||||
violations = _validate(r)
|
||||
assert any(v["field"].endswith(".pitch") for v in violations)
|
||||
|
||||
def test_promotion_candidates_must_be_a_list(self):
|
||||
r = _minimal_valid_report()
|
||||
r["promotion_candidates"] = {"not": "a list"}
|
||||
violations = _validate(r)
|
||||
assert any(v["field"] == "promotion_candidates" for v in violations)
|
||||
|
||||
|
||||
class TestEnvelope:
|
||||
|
||||
def test_missing_top_level_field_rejected(self):
|
||||
|
|
|
|||
Loading…
Reference in New Issue