---
name: drain-labeled-backlog
description: "This skill should be used when draining a labeled issue backlog (deferred-scope-out, code-review, type/security) in one cleanup PR. Groups by code area and delegates to /soleur:one-shot."
---

> **Dynamic-workflow alternative (opt-in).** A [`Workflow`-tool](https://claude.com/blog/introducing-dynamic-workflows-in-claude-code) port of this skill lives at [`workflows/drain-labeled-backlog.workflow.js`](./workflows/drain-labeled-backlog.workflow.js) — deterministic fan-out, journaled resume, schema-validated output. Run it with `Workflow({ scriptPath: "plugins/soleur/skills/drain-labeled-backlog/workflows/drain-labeled-backlog.workflow.js", args: ... })`. The prose skill below stays the default; the two coexist during calibration. See [`knowledge-base/project/specs/feat-review-workflow-prototype/spec.md`](../../../../knowledge-base/project/specs/feat-review-workflow-prototype/spec.md).

# Drain Labeled Backlog

Drain a labeled-issue backlog by batching issues that touch the same code area into a single focused refactor PR. Defaults to `deferred-scope-out` (the original use case, inspired by PR #2486, which closed `#2467 + #2468 + #2469` in one cleanup). Any other label works via `--label` — e.g., `code-review` drains unresolved review findings, `type/security` drains the security backlog.

## When to use

- A labeled backlog has grown and needs a scheduled drain — `deferred-scope-out` (the original use case), `code-review` (unresolved review findings), `type/security` (open security issues), or any label validated by `gh label list`.
- Multiple open issues carrying the target label reference the same top-level directory (e.g., `apps/web-platform`) and are safe to batch.
- You want one PR to close 3+ issues instead of N separate PRs.

Use `/soleur:review` to file new scope-outs. Use this skill to close existing labeled issues.

<decision_gate>
**API budget.** This skill delegates each selected cluster to `/soleur:one-shot`, which runs a full plan→work→review→ship pipeline (30–90 min wall-clock per cluster; non-trivial Anthropic credit per run scaling with plan complexity and review-cycle count). With `--top-n N`, the cost multiplies by N. The `--dry-run` flag previews scope without delegating. Soleur does not bill or proxy these calls — Anthropic does, against the key in your session. The Soleur LICENSE (BSL 1.1) disclaims warranty for runtime cost; you operate this loop against your own budget.

Confirm cluster scope (size, `--top-n`, milestone) before allowing the skill to fan out.
</decision_gate>

## Prerequisites

- `gh` authenticated, `jq` and `python3` available.
- Current directory is a git worktree (not the bare root).
- At least one cluster of `min-cluster-size` open issues carrying the target label.

## Arguments

<arguments> #$ARGUMENTS </arguments>

Optional flags (any subset):

- `--label <name>` — which GitHub label drives the backlog query. Default: `deferred-scope-out`. Pass `code-review` to drain unresolved review findings; pass any other label for a custom drain. Validated against `gh label list` before querying so an invalid name fails fast with a readable error rather than a silent empty cluster.

  **`meta/machinery` is excluded from every drain whose `--label` is not itself `meta/machinery`.** The exclusion is explicit in the helper rather than implied by the default: machinery findings are out of a `deferred-scope-out` drain today only because nobody has changed that default, and a future change would silently pull hundreds of findings-about-guards into a drain the operator reads as user-facing work. It is applied as a `jq` filter over the labels the query already returns, **not** as a `-label:` search qualifier — measured 2026-09-10, `gh issue list` silently discards `--search` when `--label` is present, so a query-level exclusion here is a no-op that passes every positive test. Naming `--label meta/machinery` outright lifts the exclusion, which is how the weekly machinery cadence drains that ledger deliberately.
- `--milestone "<title>"` — which milestone to drain. Default: `Post-MVP / Later` (where 15+ of the open scope-outs live at plan time). Takes the milestone **title**, never a numeric ID — `gh issue create` rejects numeric milestone IDs with a clear error.
- `--top-n N` — how many clusters to consider. Default: `1`.
- `--min-cluster-size M` — minimum issues in a cluster before the skill will pick it. Default: `3`.
- `--dry-run` — print the selected cluster and the one-shot scope argument that would be built, without delegating.

## Workflow

### 1. Prerequisites check

Verify `gh`, `jq`, `python3` are on PATH. If any is missing, abort with installation guidance. Verify the current directory is a git repository with `git -C . rev-parse --git-dir >/dev/null 2>&1` — `git` errors clearly on non-repo paths (`fatal: not a git repository`), so a fail-fast precheck beats a confusing downstream error.

### 2. Resolve milestone

```bash
MILESTONE="${ARG_MILESTONE:-Post-MVP / Later}"
```

Default is `Post-MVP / Later`: plan-time verification showed 15+ of the 22 open `deferred-scope-out` issues live there. Defaulting to the current phase milestone would return an empty cluster set on first run and make the skill appear broken.

### 3. Query and group issues

Delegate to the helper [group-by-area.sh](./scripts/group-by-area.sh):

```bash
bash ${CLAUDE_PLUGIN_ROOT:-plugins/soleur}/skills/drain-labeled-backlog/scripts/group-by-area.sh \
  --label "${LABEL:-deferred-scope-out}" \
  --milestone "$MILESTONE" \
  --top-n "${N:-1}" \
  --min-cluster-size "${MIN_CLUSTER:-3}"
```

The helper:

- Validates the label exists via `gh label list` and the milestone title exists via `gh api ...milestones` + `grep -Fxq` before querying. Both checks fail fast on invalid input rather than producing a silent empty cluster.
- Uses two-stage piping (`gh --json ... | jq`), never `gh --jq` with `--arg` (learning `2026-04-15-gh-jq-does-not-forward-arg-to-jq`).
- Parses each issue body for file paths matching `(ts|tsx|js|jsx|py|rb|go|md|sh|yml|yaml|sql|tf|njk)` extensions via a non-capturing regex.
- Assigns each issue to an **area** = top two path segments (e.g., `apps/web-platform`, `plugins/soleur`) of its most-referenced file path.
- Reports ALL clusters sorted by size desc; does not pre-select one.
- Exits 0 with "No cleanup cluster available" if no area clears the floor.

### 4. Pick a cluster

- **Interactive:** display the top cluster(s) and confirm before proceeding. Allow the user to override with `--area <name>` or pick a different cluster from the listed output.
- **Headless** (pipeline mode — arguments include a path or `--headless`): auto-pick the first cluster (largest) whose `count >= min-cluster-size`. If none meets the floor, the helper already printed "No cleanup cluster available"; exit 0 — do NOT open a low-value PR.

### 5. Build the one-shot scope argument

For the picked cluster, compose a scope string the `one-shot` skill can consume directly. Mention the originating label so the downstream plan frames the work correctly (e.g., "deferred-scope-out backlog" vs. "code-review findings"):

```text
Drain the <label> backlog for code area <area> by closing
#<A> + #<B> + #<C> in a single focused refactor PR. Each issue names
specific files and proposed fixes; fold them all into one change.

Issues:
  - #<A>: <title>
    Files: <files parsed from body>
    Fix: <proposed-fix section from body>
  - #<B>: ...
  - #<C>: ...

PR body MUST include `Closes #<A>`, `Closes #<B>`, `Closes #<C>`.
Reference PR #2486 as the pattern — one PR, three closures.
```

Pull `## Problem`, `## Proposed Fix`, and `Location:` / file paths from each issue body via `gh issue view <N> --json body`.

### 6. Delegate to one-shot

Use the Skill tool: `skill: soleur:one-shot`, args: `<scope argument built above>`.

`/soleur:one-shot` handles worktree creation, plan, deepen, work, review, QA, compound, and ship. This skill does NOT run any lifecycle phases itself — it only assembles scope.

### 7. Report backlog delta

After `one-shot` returns (PR merged), re-query the milestone using the same label:

```bash
gh issue list --label "${LABEL:-deferred-scope-out}" --state open \
  --milestone "$MILESTONE" --json number --jq 'length'
```

Report: `Before: X, After: Y, Closed: Z` and the per-area drain.

### Closing floor (weekly cadence only)

The weekly machinery cadence carries a **closing floor of 20**: a scheduled run
that closes fewer than 20 FAILS. That floor is what makes the gate net-NEGATIVE
rather than net-zero — per-PR net-zero, perfectly enforced, holds the backlog at
its current size forever.

**The floor is gated on candidate supply, and that arm is not optional.** The run
fails below the floor only when the candidate pool held at least 20 to begin
with. When the pool is smaller, the run closes every candidate and PASSES,
reporting:

```
closed=N of N candidates (floor waived: pool < floor)
```

Without that arm the floor becomes unsatisfiable-by-construction the moment the
backlog is actually drained — a scheduled monitor whose steady state on success
is red, which trains the operator to ignore it. That is the failure this whole
change exists to remove, so reproducing it inside the fix would be self-defeating.

The waiver is **reported, never silent**: a chronically empty pool must be
visible rather than indistinguishable from a healthy run.

Both the floor and the waiver line are cited by the runner and its test from a
single named constant, so the two cannot drift apart.

## Post-merge follow-up — Scheduling

The `/soleur:schedule` skill accepts any soleur skill as `--skill <name>` and generates a standalone `.github/workflows/scheduled-<name>.yml`. After merging the PR that ships this skill, schedule a weekly cleanup:

```text
/soleur:schedule create --name weekly-deferred-scope-out-drain \
  --skill drain-labeled-backlog --cron "0 14 * * 1" --model claude-sonnet-5
```

This turns the skill from a manual cadence tool into a programmatic backlog opener. Tracked as a follow-up issue rather than bundled into this skill, so the skill lands clean.

## Pipeline detection

If `$ARGUMENTS` contains a `RETURN CONTRACT` section (i.e., this skill is being driven by another skill), run headless:

- Skip interactive cluster confirmation — auto-pick the first cluster meeting the floor.
- Skip `--dry-run` prompts.

Follows the same pattern as `plan`, `review`, and `ship` skills.

## Sharp edges

- The helper skips issues whose bodies name zero file paths. That is intentional — area grouping requires at least one path. If an issue has no paths but belongs to a cluster thematically, add a `Location:` line to its body and re-run.
- Sub-grouping by second-level directory is NOT implemented (YAGNI). Current backlogs never exceed 10 issues in a single top-level area. If that changes, track as a follow-up issue before adding the branch; don't build for cases that don't exist.
- `--milestone` takes the title literally (quote it). A numeric ID fails with `milestone 'N' not found` — `gh issue create` rejects numeric milestone IDs with a clear error, so the failure is loud rather than silent.
- Rule `rf-review-finding-default-fix-inline` governs the opposite direction (new findings default to fix-inline); this skill drains existing scope-outs. The two rules are complementary.
- When writing a data-reshape shell script that fetches JSON and groups it, default to a single pure-jq pipeline before reaching for python/awk. Multi-language serialization round-trips add dependencies, silent-fallback error paths, and ~2x the LOC without reshape capability jq already provides.
- `jq scan(...)` returns the **captured group** when the regex contains a capture, otherwise the full match. Alternations inside `scan` MUST be non-capturing: `(?:ts|tsx|js)` not `(ts|tsx|js)`. Otherwise `scan("[A-Za-z_./\\-]+\\.(?:ts|js)\\b")` returns full paths, whereas the capturing form would return just the extension.
- When binding `as` against a **multi-value** jq source (e.g., `.[] | select(...)`), the downstream expression runs once per yielded value, producing multiple top-level JSON outputs. This breaks callers that do `$(jq '.field' <<<"$VAR")` under `[[ -eq 0 ]]`. Collect into an array first: `[ .[] | select(...) ] as $meets | { ... }`.
- Sub-agent confirmation gates (like the second-reviewer gate in `review/SKILL.md`) need a **mechanical first-line output contract** (`CONCUR` / `DISSENT: <reason>`), not free-form prose interpretation. Treat anything other than `CONCUR` as `DISSENT` to fail-safe toward fix-inline.
- Per-issue "top path" ranking is **qualified-over-bare, deepest-first, frequency tie-break**. Review bodies typically cite one fully-qualified path (`apps/web-platform/components/chat/chat-input.tsx:107-127`) and then shorthand the rest (`chat-input.tsx:17-19, 58, 120-124`). Ranking by frequency alone lets shorthand outvote the qualified citation and produces singleton clusters keyed by bare filename. Ranking by depth alone lets a shallow shorthand `server/ws-handler.ts` beat a deep `apps/web-platform/server/rate-limiter.ts`. The combined rule handles both. Regression-tested in `shorthand-refs.json` (T8) and `mixed-depth.json` (T9).
- When the top cluster has **≥7 issues from ≥3 unrelated source PRs**, operator sub-selection beats "pick top cluster, delegate whole thing". The natural coherence heuristic is **originating review PR** — three scope-outs filed from the same review (same `Ref #NNNN` in their bodies) are almost always tight siblings, regardless of which subdirectory each path lives under. First dogfood run (PR #2499) hit this at `apps/web-platform` (9 issues spanning kb/chat/billing/analytics); operator read bodies and delegated a coherent 3-issue subset from the same originating review. See `knowledge-base/project/learnings/2026-04-17-cleanup-scope-outs-sub-cluster-selection.md`.

## Test

Unit tests live at [drain-labeled-backlog.test.sh](../../test/drain-labeled-backlog.test.sh). Run them with:

```bash
bash plugins/soleur/test/drain-labeled-backlog.test.sh
```

Covers: clustered fixtures, dispersed fixtures (no cluster), empty fixtures, JSON output shape, and sort order.
