Scan two independent fronts and recommend cleanup — open GitHub issues (solved-by-PR, inactive, superseded, duplicates) and the on-disk workspace (stale worktrees + local/remote branches, via the shared stale-cleanup.sh). Never auto-closes issues or deletes worktrees/branches without confirmation. Triggers on "pm-clean", "stale issues", "clean backlog", "close stale", "clean worktrees", "stale branches", "prune branches".
Installs into .claude/skills of the current project.
Are you the author of Pm Clean?
Add the live security badge to your README. It updates with every re-scan.
[](https://www.skillsdirectory.com/skills/auerbachb-pm-clean)
---
name: pm-clean
description: Scan two independent fronts and recommend cleanup — open GitHub issues (solved-by-PR, inactive, superseded, duplicates) and the on-disk workspace (stale worktrees + local/remote branches, via the shared stale-cleanup.sh). Never auto-closes issues or deletes worktrees/branches without confirmation. Triggers on "pm-clean", "stale issues", "clean backlog", "close stale", "clean worktrees", "stale branches", "prune branches".
argument-hint: "[days] (optional — default 30; applies to issue inactivity and worktree/branch age) [--worktree-days N] (optional — override worktree/branch age only)"
---
`/pm-clean` is the repo's single janitor. It runs two **independent** staleness scans and presents one recommend-then-confirm report:
1. **Open GitHub issues** — solved-by-merged-PR, inactive, superseded, potential duplicates.
2. **On-disk workspace** — stale worktrees and stale local/remote branches, via `stale-cleanup.sh` (this skill holds that script's only direct invocation — one implementation, no divergence).
The two scans never gate each other: one finding nothing does **not** suppress the other, and the summary always reports both. Nothing is closed or deleted without explicit confirmation.
Resolve both scan engines before Step 0:
```bash
resolve_script() {
local name="$1" candidate
for candidate in \
"$HOME/.claude/skills-worktree/.claude/scripts/$name" \
"$HOME/.claude/scripts/$name" \
".claude/scripts/$name"; do
if [[ -x "$candidate" ]]; then echo "$candidate"; return 0; fi
done
return 1
}
BACKLOG_STALENESS_SH=$(resolve_script backlog-staleness.sh || true)
STALE_CLEANUP_SH=$(resolve_script stale-cleanup.sh || true)
[[ -n "$BACKLOG_STALENESS_SH" ]] || { echo "ERROR: backlog-staleness.sh not found (checked all three paths) — issue scan unavailable" >&2; exit 1; }
[[ -n "$STALE_CLEANUP_SH" ]] || { echo "ERROR: stale-cleanup.sh not found (checked all three paths) — workspace scan unavailable" >&2; exit 1; }
```
> **Invoking-repo scope (issue #687).** Both scans stay in the invoking repo's lane:
> the issue scan (`backlog-staleness.sh`) runs `gh issue list` cwd-repo-scoped, and
> the workspace sweep (`stale-cleanup.sh`) enumerates worktrees/branches via
> `git -C "$ROOT"` where `$ROOT` is *this* repo's root — never another project's
> worktrees. `/pm-clean` therefore never offers to close an issue or delete a
> worktree/branch outside the invoking repo, whether run directly or inline from `/pm`.
## Step 0: Parse arguments
Parse `$ARGUMENTS`:
- **`[days]`** — the first bare (non-flag) token. If it is a positive number, use it as the issue-inactivity threshold **and** the default worktree/branch age. If empty, non-numeric, or non-positive (zero or negative), default to 30 days:
- Warn if non-numeric: "Invalid argument '{value}', defaulting to 30 days"
- Warn if non-positive: "Threshold must be positive, defaulting to 30 days"
- **`--worktree-days N`** — optional flag. If present and a positive integer, it overrides the worktree/branch age **only** (the issue scan still uses `[days]`). If present but invalid (non-numeric or non-positive), warn ("Invalid --worktree-days '{value}', reusing the [days] threshold of {DAYS}") and fall back to `[days]`. If the flag is absent, the worktree/branch age reuses `[days]`.
Record two values for the rest of the run: `DAYS` (issue-inactivity threshold) and `WORKTREE_DAYS` (workspace threshold — equal to `DAYS` unless `--worktree-days` overrode it).
> **Threshold note.** Reusing `[days]` means `/pm-clean` defaults the workspace age to 30 days (its issue default), whereas `stale-cleanup.sh` itself defaults to `STALE_DAYS=7`. That is intentional: this skill owns the *threshold* it passes, while the script owns the *implementation*. Use `--worktree-days 7` for the script's own aggressive default.
## Step 1: Issue staleness scan
> **Authorship guard (issue #733, `safety.md`).** `/pm-clean` performs **no PR writes** — it closes issues (after confirmation) and cleans local worktrees/branches; it never merges, comments on, or closes a PR. Any merged-PR data it reads (e.g. "solved by merged PR") is **read-only context**, and may legitimately reference a collaborator's PR. Workspace cleanup already protects **every** open PR's branch (yours and collaborators') from deletion via `stale-cleanup.sh`, so no author filter is applied there by design.
### Step 1.1: Count the open backlog
```bash
gh issue list --state open --limit 500 --json number | jq 'length'
```
Record the count as `TOTAL_OPEN` for the summary. If it is 0, the issue backlog is clean — note that for Step 3, but **do not stop**: continue to the workspace scan (Step 2). The two scans are independent.
### Step 1.2: Run the shared staleness detection
```bash
"$BACKLOG_STALENESS_SH" --days <DAYS> --json
```
This one call reproduces what was previously four separate inline steps here — gathering open issues, merged PRs, and closed issues, then flagging **solved-by-merged-PR** (closing-keyword regex in merged PR bodies, plus a weaker branch-name signal requiring 2+ shared keywords), **inactive** (the `updatedAt` threshold plus a per-issue comment/open-PR-reference/commit-reference triple check, capped at the 50 oldest candidates), **superseded** (issue-body file/keyword references with 3+ commits each, or 5+ commits on a single file), and **potential-duplicate** (3+ shared significant title words against a *resolved* closed issue, suppressed by explicit cross-references).
Since issue #656 `/pm` runs this entire `/pm-clean` flow inline by default (reversing the #598 count-only design; the only opt-out is `/pm`'s `--no-clean` / `fast` ranking-only flag), so it reaches `backlog-staleness.sh` through this same flow rather than a separate call — both skills share the one `backlog-staleness.sh` detector, so the detection logic cannot diverge between them. Full per-category rules, safeguards (the `pinned`/`do-not-close`/`long-term`/`epic` label-skip, the 50-candidate performance cap), and the JSON record shape are documented by `backlog-staleness.sh --help`.
Each returned record has `number`, `title`, `category` (`solved-by-pr`|`inactive`|`superseded`|`potential-duplicate`), a human-readable `rationale`, plus category-specific evidence fields (`pr`/`merged_at`; `last_activity`/`age_days`; `path`/`commits`; `closed_issue`/`closed_title`/`closed_at`/`keywords`). Group records by `category` for Step 3.
## Step 2: Workspace staleness sweep (worktrees + branches)
This scan delegates **entirely** to `stale-cleanup.sh`, which is the single source of truth for stale worktree/branch detection; this skill holds its only direct invocation (issue #618). **Reuse it as-is: never re-derive worktree/branch detection, thresholds, or safety checks in this skill.** The script's safety contract is the single source of truth — it always skips the main worktree, your current worktree, worktrees with uncommitted tracked changes, open-PR branches, protected branch names, and branches checked out in a worktree. See `stale-cleanup.sh --help` for the full contract.
### Step 2.1: Run the dry-run pass
Always run `--check` first — the script never deletes in this mode. Pass the resolved threshold via `STALE_DAYS`, and capture both the JSON and the exit code:
```bash
RC=0
WORKSPACE_JSON="$(STALE_DAYS="$WORKTREE_DAYS" "$STALE_CLEANUP_SH" --check --json)" || RC=$?
```
The `|| RC=$?` guard is required: `--check` exits **1** when stale items exist (the normal "found something" case), which would otherwise abort this step under `set -e`.
Branch on `RC` (the script's documented exit contract — `stale-cleanup.sh --help`):
- **`RC == 0`** — no stale items. Record "no stale worktrees or branches" for the Step 3 summary. **Still parse `WORKSPACE_JSON` for `orphaned_checkouts[]` and `checkout_scan`** — that class is exit-code neutral by design (issue #1417), so this is the exit code it reports under when it is the *only* finding. Treating `RC == 0` as "nothing to show" is precisely how the backstop would go unseen.
- **`RC == 1`** — stale items exist. Parse `WORKSPACE_JSON` and present them in Step 3.
- **`RC == 3`** — usage error (bad flag or `STALE_DAYS`). Surface the script's `--help` output and skip the workspace section — this indicates a bug in this skill's invocation, not a real-state problem. The issue scan (Step 1) still stands.
- **`RC == 4`** — environment error (no `gh`/`jq`, cannot resolve repo). Surface stderr and skip the workspace section. The issue scan still stands.
- **Any other exit** — treat like an environment error: surface stderr and skip the workspace section (the issue scan still stands). `--check` documents only 0/1/3/4, so this is a defensive catch-all.
`WORKSPACE_JSON` shape (from `--check --json`): `root` (the resolved main-worktree root of the swept repo — cwd-derived, or `--root` when passed; issue #707); `stale_days`; `stale_worktrees[]` (`path`, `branch`, `last_commit_ts`); `stale_local_branches[]` (`branch`, `last_commit_ts`); `stale_remote_branches[]` (`ref`, `last_commit_ts`); `skipped_worktrees[]` / `skipped_local_branches[]` / `skipped_remote_branches[]` (each `{…, reason}`); `orphaned_registrations[]` (`id`, `registration_path`, `worktree_path`, `reason`, `method`) and `skipped_registrations[]` (`id`, `reason`) for orphaned `.git/worktrees/<id>` entries (issue #1402); `orphaned_checkouts[]` (`path`, `gitdir`, `reason`) and `skipped_checkouts[]` (`path`, `reason`) for checkout directories under `.claude/worktrees/` whose registration is gone (issue #1417); and the four scalars `worktree_enumeration` (`"ok"` / `"timed_out"` / `"failed"`), `registration_scan` (`"ok"` / `"unavailable"` / `"none"`), `ref_scan` (`"ok"` / `"timed_out"` / `"failed"` — the branch-classifying `for-each-ref` passes, bounded in issue #1404), and `checkout_scan` (`"ok"` / `"none"` / `"unreadable"`). Every key is always present (empty arrays when nothing matches), so parsing is safe regardless of state.
> **`orphaned_checkouts[]` is exit-code neutral — read it at every `RC`.** This class never moves the exit code, because plain `--apply` can never clear it (removal needs `--remove-orphaned-checkouts`, which `/pm-clean` never runs), so letting it raise exit 1 would pin the sweep's status high forever. The consequence for this skill is that a run whose *only* finding is orphaned checkouts exits **0** — so read `orphaned_checkouts[]` and `checkout_scan` regardless of `RC`, and never let an `RC == 0` short-circuit swallow them. `checkout_scan == "unreadable"` means the scan directory could not be inspected, resolved, or entered: `orphaned_checkouts[]` is then empty because nothing was *classified*, not because nothing is orphaned — report that as an incomplete sweep, never as clean (`"none"` is the clean state, and means provably absent).
> **Read `worktree_enumeration`, `registration_scan`, and `ref_scan` before trusting an empty result.** A `ref_scan` other than `"ok"` means the bounded `for-each-ref` passes did not complete, so `stale_local_branches[]` and `stale_remote_branches[]` are empty for the same "nothing was classified" reason as below, and `--check` exits **1**. If `worktree_enumeration` is anything other than `"ok"`, the bounded `git worktree list` did not complete, so `stale_worktrees[]` and `stale_local_branches[]` are empty because nothing was *classified* — not because nothing is stale. If `registration_scan` is `"unavailable"`, the git common dir would not resolve inside its bound and the registration sweep never ran, so `orphaned_registrations[]` is empty for the same reason. `--check` exits **1** in both states. Report the sweep as incomplete either way: surface any orphaned registrations, clear them with `--apply`, and re-run — never present an empty sweep as clean. `last_commit_ts` is a Unix timestamp — render it as `YYYY-MM-DD` for display (`date -r <ts> +%F` on macOS/BSD, `date -d @<ts> +%F` on GNU — the same portable pair `stale-cleanup.sh` uses internally).
## Step 3: Present combined recommendations
Present **both** scans in one report — issue sections first, then workspace sections. Omit any category that has no items; when an entire scan finds nothing, emit its one-line "clean" note (never stay silent about a scan that ran).
### Issue sections
Group flagged issues by `category` and present them in a scannable format:
```
## Backlog Cleanup Recommendations
Scanned {TOTAL_OPEN} open issues. Found M candidates for closure.
### Solved by Merged PR (K issues)
These issues appear to have been resolved by merged PRs but were not auto-closed:
| Issue | PR | Merged | Recommendation |
|-------|-----|--------|----------------|
| #N — Title | PR #M | date | Close — PR body contains `Closes #N` |
### Inactive (K issues, threshold: X days)
No activity (comments, PR references, or updates) in X+ days:
| Issue | Last Activity | Age | Recommendation |
|-------|--------------|-----|----------------|
| #N — Title | date | X days | Close with comment or reassign |
### Superseded (K issues)
Referenced files/features have been substantially modified since issue creation:
| Issue | Evidence | Recommendation |
|-------|----------|----------------|
| #N — Title | N commits to `path` since creation | Verify resolved, then close |
### Potential Duplicates (K pairs)
Open issues that may duplicate already-closed issues:
| Open Issue | Similar Closed Issue | Shared Keywords |
|-----------|---------------------|-----------------|
| #N — Title | #M — Title (closed date) | word1, word2, word3 |
```
Populate each row directly from the record's fields — `pr`/`merged_at` (Solved by Merged PR), `last_activity`/`age_days` (Inactive), `path`/`commits` (Superseded), `closed_issue`/`closed_title`/`closed_at`/`keywords` (Potential Duplicates). The `Recommendation` column is authored prose (e.g. "Close — PR body contains a closing keyword"), not a script field — `rationale` is the evidence backing it, useful as a one-line justification if a table gets flattened to a list.
If a category has no flagged issues, omit that section entirely. If no issues were flagged across all categories, state: "Issue backlog is clean — no stale or duplicate issues detected." (Do not stop — the workspace section still follows.)
### Workspace sections
Render from `WORKSPACE_JSON` when `RC == 1`, **plus the orphaned-checkout block whenever `orphaned_checkouts[]` is non-empty or `checkout_scan` is not `"ok"`/`"none"` — at any `RC`, including 0** (see the exit-code-neutral note in Step 2). Convert each `last_commit_ts` to a `YYYY-MM-DD` date. Put the item count in each heading, and omit any category with no items:
```
### Stale Worktrees (K)
Idle {WORKTREE_DAYS}+ days. Removing a worktree also deletes its branch when that branch is unprotected and has no open PR.
| Worktree | Branch | Last Commit | Recommendation |
|----------|--------|-------------|----------------|
| `path` | `branch` | date | Remove — idle {WORKTREE_DAYS}+ days |
### Stale Local Branches (K)
| Branch | Last Commit | Recommendation |
|--------|-------------|----------------|
| `branch` | date | Delete — idle {WORKTREE_DAYS}+ days |
### Stale Remote Branches (K)
| Remote Branch | Last Commit | Recommendation |
|---------------|-------------|----------------|
| `origin/branch` | date | Delete — idle {WORKTREE_DAYS}+ days |
### Skipped (safety) (K)
Protected by the script — listed for transparency, NOT offered for deletion:
| Item | Type | Reason |
|------|------|--------|
| `path / branch / ref` | worktree / local branch / remote branch | reason (verbatim from the script) |
```
Draw the **Skipped (safety)** rows from the three `skipped_*` arrays (each item's `reason` verbatim), and render the block whenever any is non-empty while presenting stale items. If the workspace scan found no stale items (`RC == 0`), skip these tables and state: "No stale worktrees or branches detected." — but only after checking `orphaned_checkouts[]` and `checkout_scan`, which report under `RC == 0` too: render the orphaned-checkout block (and any `skipped_checkouts[]` reasons) before that line, and do not claim a clean workspace while either is non-empty or `checkout_scan == "unreadable"`.
**Summarize the tail.** For large sets (e.g. 20+ stale worktrees), list the most relevant items and summarize the remainder ("…and N more, all idle {WORKTREE_DAYS}+ days") — the same tail treatment the issue scan relies on (`backlog-staleness.sh` caps its own deep check at the 50 oldest candidates and notes any remainder on stderr; surface that note when present).
### Combined summary
Close the report with a one-line status for **each** scan so neither is ambiguous — e.g. "Backlog: 3 closure candidates · Workspace: 5 stale worktrees, 2 stale branches", or, on a fully clean repo, "Backlog is clean · No stale worktrees or branches."
## Step 4: Confirm and act — two separate gates
Issue closures and workspace deletions are **separate** confirmations. Declining one never affects the other — surface both asks, act only on what the user confirms.
### Step 4.1: Issue closures
Present via **`AskUserQuestion`** when available (prose fallback in headless runs): `multiSelect: true` with up to 3 closure categories found (mark highest-count category as `(Recommended)`; if >3 categories found, surface 3 with most candidates) plus `"Skip all"`. When the user wants to select specific issue numbers rather than whole categories, fall back to the prose prompt below. See `ask-menu.md` for the full vehicle convention (`Skip all` is skip-wins).
Prose fallback:
```
## Next Steps — Issues
To close recommended issues, confirm which ones to close. I can:
1. Close specific issues with a comment explaining why
2. Close all issues in a category (e.g., all "Solved by Merged PR")
3. Skip — leave the backlog as-is
Which issues should I close? (List numbers, category names, or "all")
```
When the user confirms closures, close each issue with a comment:
```bash
# Replace 42 with the actual issue number and customize the rationale
gh issue close 42 --comment "Closing: [rationale from the recommendation]. Identified by backlog cleanup scan."
```
### Step 4.2: Workspace deletions
Only if the workspace scan surfaced stale items (`RC == 1`) and the user explicitly confirms. Present via **`AskUserQuestion`** when available (options: `"Apply cleanup (Recommended)"`, `"Skip"`; prose fallback `"Apply the stale workspace cleanup above? (worktrees removed, local + remote branches deleted)"` in headless runs). `--apply` removes **every** listed stale item — the script has no per-item selection — so if the user wants to keep any, they decline and handle it manually. See `ask-menu.md` for the full vehicle convention.
On explicit confirmation:
```bash
APPLY_RC=0
STALE_DAYS="$WORKTREE_DAYS" "$STALE_CLEANUP_SH" --apply || APPLY_RC=$?
```
The script re-runs the same detection (state may have changed since the dry-run), re-applies every safety check, then attempts each deletion. Report the `removed:` / `failed:` lines verbatim:
- **`APPLY_RC == 0`** — all deletions succeeded and every category was swept.
- **`APPLY_RC == 1`** — **incomplete sweep, not a failure.** Worktree enumeration or the registration scan did not finish inside its bound, so whole categories were skipped; anything the script *did* reach was still applied (the registration sweep in particular, which is what clears the cause). Report it as incomplete and re-run — never record it as done. Same meaning as `--check`'s exit 1.
- **`APPLY_RC == 2`** — one or more deletions failed; surface the `failed:` lines. Do not retry automatically — some failures (e.g. network errors on remote-branch deletion) need user intervention. Outranks 1.
A `skipped: worktree registration <id> — its worktree reappeared after the scan` line is the removal-time re-check declining to delete an entry that came back to life between the dry-run and the apply. That is the guard working: it is not a failure, does not affect the exit code, and needs no user action.
If the user declines, report: "Workspace cleanup: dry-run only — nothing deleted."
## Rules
- **NEVER auto-close issues.** Always present recommendations and wait for user confirmation.
- **NEVER delete worktrees or branches without explicit confirmation.** `/pm-clean` NEVER runs `stale-cleanup.sh --apply` on its own — consistent with the "never auto-close" rule above. The `--check` dry-run is the user's only chance to spot a false positive before anything is removed.
- **The two scans are independent.** One finding nothing must not suppress the other; the summary reports both, always.
- **Delegate workspace detection — never reimplement it.** `stale-cleanup.sh` is the shared source of truth for worktree/branch staleness and safety (issue #618, mirroring the shared-detection precedent `backlog-staleness.sh` set in #598). This skill holds the only direct invocation of the script, and `/pm` reaches it by running this whole `/pm-clean` flow inline rather than calling the script itself (#656); keep them in sync by changing only the script, never by forking its logic into this skill.
- **Be conservative with "superseded" and "duplicate" flags.** False positives waste the user's time reviewing issues that shouldn't be closed. Only flag when evidence is clear.
- **Include rationale for every recommendation.** The user should be able to evaluate each suggestion without reading the full issue or inspecting the worktree.
- **Handle large backlogs gracefully.** `backlog-staleness.sh` already caps the inactive-candidate deep check at the 50 oldest issues and notes any remainder on stderr — surface that note in the summary when present. Apply the same summarize-the-tail treatment to large workspace result sets.
- **Respect issue labels.** `backlog-staleness.sh` already skips issues labeled `pinned`, `do-not-close`, `long-term`, or `epic` in the inactive, superseded, and duplicate checks (still checking them for solved-by-PR, since that's a factual signal, not a judgment call) — no extra filtering needed here.