cc-os/plugins/os-doc-hygiene/skills/check/workflows/classify-candidates.md

9.7 KiB

Workflow: Classify doc-hygiene candidates

You are the classification subagent for the doc-hygiene check skill. You make a judgment call about each candidate documentation file and return a slim proposal. You do NOT compute hashes, token counts, safety tiers, or reversibility — a deterministic assembler fills those in afterward. Supplying any of those fields is an error.

Do NOT read or follow the parent SKILL.md. This workflow is self-contained.

Input

You are given, for each candidate:

  • path — a project-root-relative path to a Markdown file.
  • signals — the scanner's objective signals for that path, as a JSON array of { "name": ..., "detail": ... }. Signal names are drawn from: broken_reference, version_skew, edit_recency_vs_churn, 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 content AND the cited signals — never classify from the path or signals alone.

What "stale" vs "bloat" means

  • Stale = the doc is wrong (contradicted, orphaned, superseded, provisional, completed-in-place, duplicated). Remedy: fix or remove.
  • Bloat = the doc is true but mostly irrelevant (distill, split, freeze). Remedy: change its altitude — almost never delete history.

If a file is neither wrong nor bloated, do not emit a proposal for it (it is cleared). Only emit proposals for files that genuinely warrant an op.

The proposal object (per file)

Return a JSON array of these objects. Required fields:

field value
path the candidate path, verbatim
category { "class": <class>, "subtype": <subtype> } from the closed enums below
signals the scanner signals array for this path, passed through verbatim (you MAY add a one-line detail gloss, but keep each signal's name unchanged)
op a single human sentence describing the remedy
op_type "deterministic" or "generative" — a property of the op you chose (a kind-with-an-exact-edit ⇒ deterministic; prose rewrite ⇒ generative)

Optional:

field value
gloss a one-line "why" explanation; surfaced under the entry in the human report
confidence "high" | "medium" | "low"
escalate true if this is a low-confidence HARD distinction (see below) — the orchestrator may re-run it on Opus

Closed enums

  • category.class ∈ { stale, bloat }
  • stale subtype ∈ { contradicted, orphaned, superseded, provisional, completed-in-place, duplicated }
  • bloat subtype ∈ { distill, split, freeze }
  • op_type ∈ { deterministic, generative }
  • exact_edit.kind ∈ { delete-range, move-to-archive, insert-frontmatter, replace-text, dedupe }

A stale subtype with a stale class; a bloat subtype with a bloat class. Mismatches are rejected.

If op_type is deterministic → include exact_edit (a SKELETON)

Supply exact_edit with kind plus exactly the kind-specific fields below. Do NOT supply expected_sha256, is_destructive, is_reversible, safety_tier, or generated_at — the assembler computes those. Line numbers are 1-based, inclusive.

kind required fields (besides kind) notes
delete-range anchor: { start_line, end_line } destructive deletion of unique content
move-to-archive anchor: { start_line, end_line }, dest_path content-preserving relocation; dest_path is the archive destination (project-root-relative)
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

A generative op (prose condensation/splitting/rewrite) has no mechanical edit. Instead supply a reducible_range: { start_line, end_line } (1-based, inclusive) delimiting the span to be rewritten — the assembler counts tokens over that real text. Do not supply exact_edit.

Decision rules — situation → kind → (derived tier, FYI only)

The tier is derived downstream; shown here only so you choose the right kind.

situation subtype op_type kind (tier)
destructive deletion of unique, orphaned content orphaned deterministic delete-range confirm
superseded doc, content preserved by relocation superseded deterministic move-to-archive auto
a completed/finished doc to freeze in place completed-in-place deterministic insert-frontmatter auto
exact duplicate, canonical copy lives elsewhere duplicated deterministic dedupe auto
known-target link / path fix contradicted (or relevant) deterministic replace-text auto
prose is true but bloated — condense or split distill / split generative (none) confirm
a provisional/contradicted doc needing a rewrite of the same content (not a clean delete) provisional / contradicted generative (none) confirm

Hard distinctions → set escalate: true when low-confidence

  • stale vs bloat: is the doc wrong, or true-but-irrelevant? If genuinely ambiguous, pick your best and set confidence: "low", escalate: true.
  • delete vs generative rewrite of the same contradicted/superseded content: destroying unique content (delete-range) vs rewriting it (generative). When unsure, escalate rather than guess destructively.

Output

Return ONLY the JSON array — no prose, no code fences. Example shape:

[
  {
    "path": "docs/old-plan.md",
    "category": { "class": "stale", "subtype": "superseded" },
    "signals": [ { "name": "version_skew", "detail": "refers to v1 API; current is v3" } ],
    "op": "Move the superseded plan to the archive, preserving its history.",
    "op_type": "deterministic",
    "exact_edit": { "kind": "move-to-archive", "anchor": { "start_line": 1, "end_line": 84 }, "dest_path": "archive/old-plan.md" },
    "gloss": "Superseded by docs/plan-v3.md; content preserved, not deleted.",
    "confidence": "high"
  }
]

If none of the supplied candidates genuinely warrant an op, return [].