ds-cleanup-worktrees · diff
git:20260907.80606af to git:20260908.1cb28c0
1 added, 1 removed. Audit A to A.
---
name: ds-cleanup-worktrees
description: "Clean up stale git worktrees and local branches in the current repository. Covers both worktree removal and local branch prune - see `content/references/worktree-lifecycle.md` §Branch prune for the ca"
user-invocable: true
---
# /ds-cleanup-worktrees
> Run the Activation preflight from `METHODOLOGY.md` before proceeding. If inactive, no-op and exit.
Clean up stale git worktrees and local branches in the current repository. Covers both worktree removal and local branch prune - see `content/references/worktree-lifecycle.md` §Branch prune for the canonical branch-prune command block.
`bin/ds-cleanup-worktrees` is the executable, machine-invocable form of the classify -> lock/dirty -> merge-evidence -> disposition predicate this command runs in Step 2 below - it delegates classification to `classify_entry` and the LOCKED/DIRTY/branch-evidence decision to `disposition_for` itself (both `bin/tests/worktree_model.py`), the single normative definition of both, so it can no longer diverge from a hand-authored predicate the way a bespoke reimplementation could (a round-2 Skeptic Critical/Major review caught exactly that divergence in an earlier version of this command file and required this delegation). It additionally applies three safety floors a bare classify/disposition read does not: a self-worktree guard (never reaps the worktree the invoking process is running inside), an age floor (default 24h - a worktree can be unlocked yet still belong to a resumable session), and a gitignored-content guard - a worktree can report CLEAN under plain `git status --porcelain` while holding irreplaceable ignored content, e.g. `.agentic/plan.md`. By OPERATOR DECISION (round 3) this guard is a PROTECTED DENYLIST, not a fail-safe allowlist: `docs/planning/**`, `.env*`, and `*.local` block removal; everything else ignored - including generated adapter output (`.kimi/`, `.codex/`, `.claude/skills/`) - is disposable and does not block (a round-2 fail-safe allowlist shipped first and measured `removed=0` against the live checkout). `.agentic/**` is a special case with INVERTED polarity (round-4 correction): protected by default inside `.agentic/` - EXCEPT a small named disposable set (routine telemetry like `events.jsonl`, `wrap/`, `codex-prompt-generation/`, `hud/`, cache dirs). Round 3's blanket `.agentic/**` protection also measured `removed=0`, because this repo dogfoods its own methodology and every worktree that has ever hosted an agent accumulates routine telemetry; `.agentic/**` was only ever meant to protect AUTHORED work (plans, notes, decisions), not session logs. Before any worktree is actually removed, its `.agentic/events.jsonl` (if present) is salvaged into the primary repo's `.agentic/reaped-telemetry/` first and the copy is verified - a failed salvage blocks removal rather than becoming a silent deletion. `--strict-ignored` restores the round-2 allowlist behavior UNCHANGED (including for `.agentic/`, where it still blocks `events.jsonl` exactly like round 2) for an operator who wants the more conservative (and less effective) polarity instead. These floors (self-worktree, age, gitignored-content) can only make the tool remove FEWER worktrees than the bare `disposition_for` predicate alone would, never more. See `bin/ds-cleanup-worktrees`'s own module docstring for the full rationale, disposable-set definitions, and salvage mechanics. Both `ds-base-sync`'s advisory note and the SessionStart worktree-count nudge invoke it in `--count-only` mode (a raw count, zero network, zero per-entry evaluation - not a removal forecast).
**The `SKIP_UNPROVEN` class and `--archive-unproven` (round 5).** A worktree can pass every other gate (clean, unlocked, past the age floor, not self, not protected-content) and STILL never resolve, because its branch carries real, unmerged commits that were never pushed anywhere and have no matching PR - `disposition_for` correctly refuses to call that `ELIGIBLE` (the round-4 measurement against this repo's own live checkout found this to be the dominant remaining blocker once the `.agentic/` correction landed: `skipped-protected-content` dropped to 0, but `removed` stayed 0 because most of the remaining worktrees carry exactly this class of branch - default-named `worktree-agent-<id>` branches and legacy `ds-round8`..`ds-round12` rework branches). Left alone, `SKIP_UNPROVEN` worktrees accumulate indefinitely - nothing ever resolves them. `--archive-unproven` (OPT-IN, NEVER the default) extends this repo's own precedent for the identical problem on BRANCHES - a 2026-08-11 manual one-off operator sweep archived 75 unprovable branches into one verified `git bundle` before deleting them (DS-153, `.agentic/branch-archive/`; `bin/ds-branch-prune` itself does not call `git bundle`) - to WORKTREES, but only to an explicit whitelist within `SKIP_UNPROVEN`, never the whole bucket (round 6 correction): only `SKIP_NOT_PUSHED` and `SKIP_AMBIGUOUS_NO_PR` qualify - `SKIP_PR_OPEN` (a hard safety override) and `SKIP_LS_REMOTE_ERROR` (a transient failure) are NEVER archived, even with the flag set. Separately, a PER-ENTRY `gh pr list` query failure (auth fine, that one call errored/timed out/rate-limited) is reported as its OWN `SKIP_PR_QUERY_ERROR` outcome - never `SKIP_UNPROVEN` at all - so it is never eligible for `--archive-unproven` in the first place, not merely excluded from the whitelist: a query failure is a distinct fact from "no PR exists" and must never be treated as proof of anything (round-7/8 correction; see `bin/ds-cleanup-worktrees`'s own module docstring, Removal predicate gate 9). It also refuses to run at all in degraded gh mode (`--no-gh`, or `gh` unavailable/unauthenticated) - without PR evidence it cannot distinguish a genuinely-unprovable branch from one behind an open PR. For every whitelisted entry, it archives the full branch (every commit unique to it) into a verified `git bundle` under `.agentic/worktree-archive/` (never removing anything if the bundle create or verify fails), salvages telemetry (same guard as the plain removal path - a failed salvage also blocks removal), then removes the worktree and prints the exact restore command. **Compaction (DS-191):** when the resolved base branch's objects can be excluded from the bundle without producing an empty bundle, the exclusion is applied - the result is a small bundle plus a recorded prerequisite commit, useful specifically as a full-disk reclaim path, rather than a bundle that duplicates history already present on the base. This is skipped (a full-history bundle is produced instead, with a `NOTE:` printed to stderr) for any of several causes: the base ref cannot be verified locally, the branch shares no history with it, an explicit `--base` was supplied but does not resolve to the same commit this tool's own auto-resolution would have picked (refused outright, never compacted against an unvalidated caller-supplied ref - see `content/references/worktree-lifecycle.md`), a concurrent `git fetch` advanced the base ref past the branch tip between the pre-create measurement and the actual bundle creation (a TOCTOU race, retried once without compaction rather than left stuck), or the rev-list commit count could not be parsed - a compact bundle also requires that prerequisite commit to remain present in this repo's object store to restore (a full-history bundle has no such dependency). It removes the worktree only, never the branch - branch deletion remains `bin/ds-branch-prune`'s job. `.agentic/worktree-archive/` is gitignored (same `/.agentic/*` umbrella as `.agentic/reaped-telemetry/`, no new carve-out) and grows unbounded - pruning it is the operator's own responsibility, exactly like `.agentic/branch-archive/`. See `bin/ds-cleanup-worktrees`'s own module docstring ("Archiving unproven branches") for the full mechanism.
**Origin-reachability evidence and the activity liveness gate (DS-196).** A worktree's branch can be pushed to `origin` and behind a resolved (non-open) PR yet still read `SKIP_UNPROVEN` forever, because the branch was squash-merged (never an ancestor of the base) or never had its own PR. `origin_reachable` is a new, LENIENT-only `DispositionFacts` evidence source (`_compute_origin_reachable`: `git branch -r --contains <head-sha> --list 'origin/*'`; nonempty stdout = `reachable`) added to `WORKTREE_REMOVAL_EVIDENCE_ORDER = (merge_evidence, content_subsumption, pr_state, origin_reachable, ls_remote_status)` - positioned after `pr_state` so an `SKIP_PR_OPEN` veto always resolves first, and it carries its own safety precondition: it never fires unless `pr_state` has been affirmatively resolved (not `"not_checked"`), so under `--no-gh` or a `gh` query failure the new evidence source is inert and the tool degrades to pre-DS-196 behavior. It never touches the STRICT branch-deletion path (`disposition_for_orphan_branch`, `bin/ds-branch-prune`) - branch deletion still requires the four-layer subsumption predicate. Pair it with the new `_worktree_activity_hours` file-activity liveness gate (recursive mtime over files, not directories, under the worktree root, pruning `.git`/`node_modules`/`.venv`/`dist`/`build`/`.next`/`__pycache__`; unresolvable reads fail closed as "too recent") - a worktree with a file touched inside `--activity-window-hours` (CLI default `3.0`) resolves the new `SKIP_RECENT_ACTIVITY` outcome regardless of any other evidence, checked immediately after the age floor and before dirty/locked, so it can mask (never reveal less than) a `SKIP_DIRTY`/`SKIP_LOCKED` reason in a plain run - same class as `SKIP_TOO_YOUNG`; use `--explain` for the full evidence. `--no-origin-reachable-evidence` is the rollback lever, restoring the pre-DS-196 evidence order exactly.
**The automatic session-start reap (DS-196).** In addition to being run on demand via this command, `bin/ds-cleanup-worktrees` is now also invoked by the conductor's session-start preflight, backgrounded so it never blocks, from the session-start prune prose block in `content/references/worktree-lifecycle.md` (§Session-start prune script) - after `git worktree prune`, before `bin/ds-branch-prune`. Output is appended (never truncated) to `<repo-root>/.agentic/worktree-reap.log`, with a per-run header line (UTC timestamp + pid) so DIFFERENT concurrent sessions interleave identifiably rather than clobbering the only record of a mutating removal - `$$` in a backgrounded subshell expands to the PARENT shell's own pid (verified in both bash and zsh), so a same-session 30-minute-idle preflight re-fire shares this pid across every run and is distinguished by its timestamp alone, not by pid. Suppress it with `AE_WORKTREE_REAP_DISABLE=1`; PATH-absence degrades to a `WARNING:` rather than blocking session start.
**Which project is worst - `--report` (run this FIRST, before any multi-repo removal).** Worktrees accumulate across many project checkouts on one machine, and an operator with several of them has no single command telling them which project is the worst offender - `--dry-run` output is per-repo, so answering "which project should I clean first" meant running it once per checkout by hand and comparing by eye. `ds-cleanup-worktrees --multi-repo --report` answers that directly: it ranks every discovered repo, worst first. Two cost tiers, chosen by whether `--count-only` is ALSO given: `--report --count-only` (FAST/approximate - 2 git calls per repo, zero network; ranks by raw non-root worktree count, tie-broken by the oldest worktree's directory age, older-first - older accumulation ranks worse) is a cheap first look, but a weak proxy - 30 legitimately-active worktrees are not the same as 30 stale ones, and the printed table says so. This tier ALSO never resolves a base branch at all (round-N Minor 2), so a repo whose base cannot be resolved (e.g. no git remotes) still exits 0 and ranks as ordinary, actionable output here, even though the DEEP tier and the real sweep both exit 1 and skip that same repo - do not treat a clean-looking fast-tier table as proof every ranked repo is actually reapable; cross-check with plain `--report` or the sweep's own exit code first. Plain `--report` (DEEP/accurate - a full per-entry evaluation per repo, same cost as `--dry-run --explain`, just never acting on it) ranks by `(eligible desc, oldest worktree age desc)` - the count of entries that would actually resolve `REMOVE`, tie-broken the same way the fast tier is (round-N Minor 3, so the two tables don't reorder ties for no visible reason). Either tier accepts `--json` for machine-readable output (`{"tier": "fast"|"deep", "rows": [{"repo", "nonroot_worktrees", "oldest_age_hours", "eligible", "would_reclaim_kb", "dirty_unreclaimable_kb", "size_unknown_count", "pr_query_error_count"}, ...], "truncated": bool}` - `eligible` is `null` only under the fast tier; the three `--measure-size` fields (DS-216, see below) are always present but `null` unless `--measure-size` was also passed, and `null` unconditionally under the fast tier regardless of `--measure-size` (that tier never computes a disposition to measure against); `truncated` is `true` iff `--max-repos` cut the discovered-and-deduped set). `pr_query_error_count` (DS-217) closes a reporting gap: a repo whose entries were 100% `SKIP_PR_QUERY_ERROR` previously exited 0 and ranked as an ordinary clean sweep, invisible on this surface - it is `null` under the fast tier (never computed - that tier makes zero network calls) and an int (0 or more) under the deep tier UNCONDITIONALLY, no `--measure-size`-style flag required, since the deep tier's per-entry evaluation already produces this count for free; deep tier only, `_run_report` also adds a top-level `"pr_query_error_total"` int (the sum across `rows`, absent under the fast tier) and prints a `NOTE:` naming the affected repos whenever it is nonzero. Deep tier AND `--measure-size` together add two top-level keys: `"totals"` (`{"would_reclaim_kb", "dirty_unreclaimable_kb", "size_unknown_count"}`, each an int sum across `rows`) and `"measure_size_caveat"` (the same working-tree-only/object-store disclosure string the table/summary NOTEs print, since a JSON consumer never sees that stderr/stdout prose) - both absent (not merely `null`) when `--measure-size` was not also passed. Plain `--report --measure-size` WITHOUT `--json` prints the same per-row figures as extra table columns (abbreviated headers `dirty_kb`/`unk` alongside `would_reclaim_kb`), plus a separate `NOTE:` line carrying those three figures summed across every listed repo, plus the same FLOOR/object-store `NOTE:`s the single-repo summary prints when applicable - the deep tier already pays for the full `du -sk` walk regardless of `--json`, so this output was previously silently discarded. `--report` is STRUCTURALLY READ-ONLY - it never calls `git worktree remove`/`unlock`/`prune` under any combination of flags, including `--dry-run` (which is simply irrelevant to it) - and is incompatible with `--archive-unproven` for the same reason. Look before you delete: run `--report` (or its fast `--count-only` form for a quick first pass) before running the sweep below across several repos for the first time.
**Byte reporting: `--measure-size` (DS-216, OPT-IN AT THE BINARY LEVEL, ALWAYS PASSED HERE).** Every removal this tool has ever performed was permanently unmeasured - it deletes worktree directories without ever reporting what they occupied on disk, so "how much space did we recover?" had no answer. `--measure-size` closes that: it measures each REMOVE-bucket entry with `du -sk` BEFORE removal (there is nothing left to measure after) and reports `would_reclaim_kb` under `--dry-run` or `reclaimed_kb` on a live run - NEVER the same label in both, since a projection and bytes actually freed are different claims and conflating them is how "we recovered X" becomes a lie. With `--archive-unproven` also passed, this same figure WIDENS to also include every entry whose FINAL outcome is `ARCHIVED_AND_REMOVED` - an archived-and-removed worktree is removed exactly as permanently as a plain `REMOVE` one, so withholding its bytes would understate what the run actually freed (or, under `--dry-run`, what a real run would free). It also measures every `SKIP_DIRTY` entry into a separate `dirty_unreclaimable_kb` figure - dirty worktrees are refused removal terminally by design and are typically where the real byte mass on a checkout lives, so this total is surfaced but is NEVER folded into either reclaim total (reporting refused bytes as reclaimed would be exactly the lie this flag exists to prevent). Any entry a `du -sk` call cannot measure (failure or a generous 300s timeout - opt-in and operator-invoked, so a large tree is tolerated rather than raced) is EXCLUDED from every total, never defaulted to 0, and counted in `size_unknown_count`; a nonzero count means every total that run reports is a FLOOR, not an exact figure, and the binary prints a `NOTE:` saying so. Deliberately excludes any `reclaimable_by_gc_kb`/`git count-objects` figure - considered and rejected, since `size-garbage` counts only invalid/corrupt loose objects, never unreachable-but-valid objects from a deleted ref (measured `0 bytes` across six repos), so it cannot answer the question it appears to answer. Every byte figure measures the working-tree checkout only, never the shared git object store every worktree of a repo shares - removing a worktree never shrinks it, and deleting a branch reclaims nothing until `git gc` runs. Because measuring means a recursive `du` walk over potentially tens of GB, this flag is NEVER passed by any of the THREE real AUTOMATIC callers - `bin/ds-base-sync`'s advisory note, the SessionStart nudge, and `content/references/worktree-lifecycle.md`'s own session-start reap block (the mutating, backgrounded call site) - their cost profile must not change. **That carve-out covers only callers that fire without the operator asking.** This command's own Step 2 invocation ALWAYS passes `--measure-size`, and the two facts are consistent rather than in tension: the flag is opt-in and operator-invoked by design, so a large tree is tolerated rather than raced, and an operator who typed `/ds-cleanup-worktrees` is exactly the reader for whom "how much space did we recover?" must have an answer - a default run that answered it with silence was reproducing, at its own primary call site, the defect this flag exists to close. To opt out on a very large checkout where the `du` walk is too slow, drop the flag from that one invocation; there is no `--no-measure-size` flag, and a run made without the flag reports the byte figures as NOT MEASURED, never as zero. `--multi-repo --measure-size` (without `--report`) also prints a grand total across every swept repo, and the same FLOOR/object-store NOTEs when applicable. See `bin/ds-cleanup-worktrees`'s own `--measure-size` doc for the full field list under `--report --json`.
**Sweeping multiple repos: `--multi-repo`.** Plain `ds-cleanup-worktrees` operates on exactly one repo per invocation (`--repo <path>`, default cwd) - `--multi-repo` extends the SAME binary to sweep several repos in one call, in-process, sequentially (never parallel - legible output, no concurrent `git`/`gh` rate-limit contention across repos). It discovers a set of repos - explicit `--repo <path>` (now repeatable once `--multi-repo` is given), positional root-directory arguments (scanned to `--depth` levels, default 1, max 3, for a `.git` dir or file), or a `~/.agentic/cleanup-worktrees.json` fallback (`{"roots": [...], "repos": [...]}`), consulted only when neither an explicit `--repo` nor a positional root is given - `ds-cleanup-worktrees --init-config` scaffolds this file for a first-time setup (never overwrites an existing one; prints an empty `{"roots": [], "repos": []}` skeleton and instructions otherwise). `--max-repos N` caps the discovered-and-deduped target list to the first N (discovery order preserved, applied AFTER dedup and BEFORE any per-repo evaluation, so it bounds git-call cost, not just display) - unset is unbounded. It then evaluates and (unless `--report`) sweeps every discovered repo using the exact same predicate and every safety gate described above, per repo, with each repo resolving its OWN base independently (`--base` combined with `--multi-repo` is a usage error - a single global base would silently leak one repo's base into every other repo's evaluation). One repo's or root's failure (bad path, discovery error, nonzero evaluation) never halts the sweep - it is reported and the remaining repos/roots are still attempted; exit 1 when any repo/root errored, exit 2 on a usage error (including zero repos discovered). The standalone `bin/ds-reap-all` subprocess-per-repo wrapper this mode was ported from has been retired - `--multi-repo` is now the sole cross-repo sweep mechanism.
Use proactively when worktrees are accumulating or any time you want to confirm the repo is in a clean state. **Not immediate post-merge cleanup at the default settings:** Step 2's 24h age floor means a worktree from a PR merged minutes or hours ago is reported as `SKIP_TOO_YOUNG`, not removed - the floor is a genuine safety default (an unlocked worktree can still belong to a resumable session), not a bug. Step 2 below documents `--min-age-hours` narrowing, but narrowing this floor never guarantees removal: it is only one of several gates (a worktree may still be skipped for another reason - dirty, locked, unproven branch, an unreadable activity timestamp, etc.), and it removes the protection against reaping a concurrent session's still-in-flight worktree. Also invoke when the user says "prune worktrees", "clean up branches", "tidy the repo", or "remove stale worktrees". Works in any git repo.
## Execution model
Run all steps directly in the conductor session via Bash - do NOT spawn background agents. Worktree cleanup is sequential and fast.
---
## Step 1: Fetch and prune metadata
```bash
git fetch origin 2>/dev/null || true
git worktree prune
# Baseline for Step 4's before -> after delta. Captured AFTER
# `git worktree prune` on purpose: prune only drops registrations whose
# directories were already gone from disk, so those entries reclaim zero
# bytes and were never Step 2's to remove. Counting them in the baseline
# would inflate the delta into an accomplishment this run cannot back
# with a reclaimed_kb figure - the same projection-vs-actual discipline
# `--measure-size` enforces on bytes. The honest baseline is therefore
# "what actually existed on disk and was Step 2's to act on".
BEFORE_WORKTREES=$(( $(git worktree list | wc -l) - 1 ))
BEFORE_BRANCHES=$(( $(git for-each-ref --format='%(refname)' refs/heads/ | wc -l) ))
echo "BEFORE_WORKTREES=$BEFORE_WORKTREES"
echo "BEFORE_BRANCHES=$BEFORE_BRANCHES"
```
`git fetch origin` is non-fatal - repos without a remote (local test repos, offline environments) will fail here and that is fine. Always continue. `git worktree prune` removes entries pointing to directories that no longer exist on disk.
`BEFORE_WORKTREES` counts non-root worktree entries only (total `git worktree list` lines minus the main worktree, which is never removable); `BEFORE_BRANCHES` counts local branches, the population Step 3 prunes. Both are ECHOED rather than left in shell variables for Step 4 to read: each step's Bash block runs in its own shell, so a variable set here is not in scope there. Carry the two printed values forward into Step 4's report - they are the only record of the before-state, and once Steps 2 and 3 have run it cannot be recomputed.
---
## Step 2: Reap worktrees
Resolve `bin/ds-cleanup-worktrees`: check `$REPO_DIR/bin/ds-cleanup-worktrees` first (the `REPO_DIR` env var, when the operator's shell happens to have it set), else fall back to PATH. This is NOT the same mechanism `hooks/session-start-wrap.sh` uses (it resolves `AE_REPO_DIR` via `resolve_ae_repo_dir_with_fallback`, sourced relative to that script's own on-disk location - a trick this inline Bash block, run ad hoc with no stable file location of its own, cannot reproduce) or `bin/ds-base-sync` (which resolves its colocated `$SCRIPT_DIR/ds-cleanup-worktrees` directly, with no PATH fallback at all). `REPO_DIR` is rarely set in an interactive or conductor shell, so this almost always lands on the PATH fallback - which is exactly why every adapter's install script wires `bin/` onto PATH. This single call replaces the entire manual classify (`classify_entry`) -> lock/dirty -> merge-evidence -> disposition (`disposition_for`) walk a hand-authored version of this step used to spell out here: same normative predicate, no second copy to drift.
**Base-branch resolution is no longer this command file's job (round-4 rework).** A prior version of this step hand-derived `--base` here via a grep/awk/sed text-extraction pipeline over `AGENTS.md`; that pipeline produced a fresh defect in three consecutive review rounds (a prose sentence that merely mentioned `BASE_BRANCH:` matching, mishandled quotes, a doubled `origin/` prefix, trailing whitespace surviving normalization, a fence-stripper that missed indented and `~~~` fences) - each fix was an input-variant patch on the last. `bin/ds-cleanup-worktrees` now resolves its own base automatically when `--base` is omitted: an `AGENTS.md` `BASE_BRANCH:` declaration (AUTHORITATIVE - if declared but its ref does not validate, resolution fails outright rather than falling through to a lower tier), then `git symbolic-ref refs/remotes/origin/HEAD`, then a local `develop` branch, then a local `development` branch, then `main` falling back to `master` - each of these lower tiers validated against origin before use, with a diagnostic and fallthrough to the next candidate on a resolved-but-nonexistent value. See `resolve_base_branch`'s docstring in `bin/_lib.py` (it lives there, shared with `bin/ds-branch-prune`, not in `bin/ds-cleanup-worktrees` itself) for the full tier order and its documented, deliberate deviations from `content/rules/conventions.md` §Base branch resolution. Simply omit `--base` below; do not re-derive it here. When resolution fails - either the AGENTS.md declaration was unresolvable, or every lower-tier automatic candidate failed validation - the binary names every candidate tried on stderr and exits 0 having removed nothing (fail-safe, not fatal - the same asymmetry the old inline pipeline had: a WARNING and a skipped reap for the session, never a hard nonzero exit that would abort Steps 3/4 below).
```bash
DS_CLEANUP_BIN=""
if [[ -n "${REPO_DIR:-}" ]] && [[ -x "$REPO_DIR/bin/ds-cleanup-worktrees" ]]; then
DS_CLEANUP_BIN="$REPO_DIR/bin/ds-cleanup-worktrees"
elif command -v ds-cleanup-worktrees >/dev/null 2>&1; then
DS_CLEANUP_BIN="$(command -v ds-cleanup-worktrees)"
fi
if [[ -n "$DS_CLEANUP_BIN" ]]; then
# Default: actually remove, with a full per-entry breakdown. If the
# operator asked for a preview/dry run instead ("show me what would be
# removed", "don't delete anything yet"), add --dry-run - --explain
# stays on either way so the report below has real detail to work from.
# `--explain` alone does NOT imply `--dry-run`: without --dry-run this
# invocation deletes. No --base flag: the binary resolves it itself (see
# above) and prints which candidate it used.
# --measure-size is ALWAYS passed here so the run reports what it
# actually freed (reclaimed_kb live, would_reclaim_kb under --dry-run)
# instead of leaving the removal permanently unmeasured; Step 4 reports
# those figures. There is NO --no-measure-size flag - to opt out on a
# very large checkout where the recursive `du` walk is too slow, drop
# the flag from this one invocation and say so in the Step 4 report.
"$DS_CLEANUP_BIN" --explain --measure-size
DS_CLEANUP_STATUS=$?
if [[ "$DS_CLEANUP_STATUS" -ne 0 ]]; then
echo "ERROR: ds-cleanup-worktrees exited $DS_CLEANUP_STATUS - do NOT treat this as a completed reap. Stop and report the failure output above instead of proceeding to Steps 3/4 as though removal succeeded." >&2
exit "$DS_CLEANUP_STATUS"
fi
else
echo "WARNING: ds-cleanup-worktrees not found (REPO_DIR/bin or PATH) - re-run your harness's DinoStack install script (<repo>/.claude/install.sh for Claude Code, the equivalent script under your adapter directory otherwise) to wire bin/ onto PATH. Worktree reap skipped this session." >&2
fi
```
This removes worktrees only - isolation (`.claude/worktrees/*`), feature/conductor-created (`.agentic/worktrees/*`), and registered-but-out-of-tree entries (`OUT_OF_TREE`), per `classify_entry`'s classification - never a branch. Branch deletion is Step 3's job, below, on its own separate proof: a bare `MERGE_EVIDENCE=merged`/PR-`MERGED` read is sufficient to reclaim a worktree (`git worktree remove` does not destroy commits) but is NOT sufficient evidence for `git branch -D` (DS-153 Amendment B1 - see the Notes section below). An `UNMANAGED` entry (a bare-repo entry, or a path under neither admin directory, e.g. `evals/.worktrees/wt-*`) is always reported, never touched; a registered worktree physically outside this repo's own directory tree is `OUT_OF_TREE`, not `UNMANAGED` - it is evaluated by the same evidence gates as ISOLATION/CONDUCTOR_CREATED and may be REMOVEd via ancestor-of-base evidence, origin-reachable evidence (DS-196), or `--archive-unproven` (which needs no merge evidence at all - see `content/references/worktree-lifecycle.md` for the full three-path disclosure). A genuinely foreign entry (a different repository's worktree) remains `UNMANAGED`. `--repo` must point at a repo's MAIN worktree, never one of its own linked worktrees - see the same reference doc.
**The default 24h age floor.** A worktree younger than `--min-age-hours` (default 24) resolves `SKIP_TOO_YOUNG`, never `REMOVE`, regardless of how otherwise-eligible it is - the binary itself now prints an unconditional `NOTE:` line naming the count whenever that bucket is nonzero, so a `removed=0` run is never left unexplained. Similarly, when at least half of a run's entries resolve `SKIP_UNPROVEN (SKIP_LS_REMOTE_ERROR)` (a `git ls-remote` failure reaching origin, not proof of an unmerged branch), the binary prints its own `NOTE:` line - check network/auth to origin before trusting a low `removed` count from that run. If the operator explicitly wants a specific, just-merged worktree gone right now, pass `--min-age-hours 0` for that invocation, but state the tradeoff when doing so: this narrows only one gate among several, so the worktree may still be skipped for another reason (dirty, locked, unproven branch, an unreadable activity timestamp, etc.), and it removes the protection against reaping a concurrent session's still-in-flight worktree (unlocked does not mean idle - a resumable session can leave its worktree unlocked between tool calls). Otherwise leave every flag but `--explain`/`--dry-run` at its default - `--base` is resolved automatically (see above) and is never operator-variable here; omit it, do not re-derive it.
---
## Step 3: Prune stale local branches
- Run the canonical branch prune from `content/references/worktree-lifecycle.md §Branch prune (stale local branches)` - `bin/ds-branch-prune` (DS-153). It deletes a local branch only when a four-layer, first-match-wins subsumption predicate (ancestry, squash-patch equivalence, tip-subsumption, content-on-main) proves that branch's tip content is on the RESOLVED base branch; absence of proof is always `SKIP_UNPROVEN`, reported for manual review, never force-deleted. It resolves that base itself, through the same shared `resolve_base_branch` Step 2 uses, so omit `--base` here too; when resolution fails it deletes nothing, writes no ledger entry, and exits 0. When `gh` is unavailable or errors, the predicate degrades to ancestry and content-on-main evidence only (L1/L4) - a strict subset, never a superset, of what a full run would delete - and the run names the degradation rather than staying silent.
+ Run the canonical branch prune from `content/references/worktree-lifecycle.md §Branch prune (stale local branches)` - `bin/ds-branch-prune` (DS-153). It deletes a local branch only when a four-layer, first-match-wins subsumption predicate (ancestry, squash-patch equivalence, tip-subsumption, content-on-main) proves that branch's tip content is on the RESOLVED base branch; absence of proof is always `SKIP_UNPROVEN`, reported for manual review, never force-deleted. It resolves that base itself, through the same shared `resolve_base_branch` Step 2 uses, so omit `--base` here too; when resolution fails it deletes nothing, writes no ledger entry, and exits 0. When NO merged-PR window is obtained at all - `gh` absent, unauthenticated, `--no-gh`, an unresolvable repository slug, or the very first page failing - the predicate degrades to ancestry and content-on-main evidence only (L1/L4), the run's `mode=` field gains a `degraded (gh unavailable)` part, and a NOTE naming that degradation is printed. `mode=` composes every axis that held rather than reporting one of them, so that part appears alongside any other (a degraded `--dry-run` reads `mode=degraded (gh unavailable), dry-run`) - read the field for the presence of the part, not as a whole string. A PARTIAL window is a distinct, weaker state and is reported differently: if pagination retrieves some pages and then cannot prove the window complete (a mid-run API error, a `totalCount` mismatch, a GraphQL `errors` array, a per-call or whole-fetch timeout, or exhausting the page bound), the rows already fetched are RETAINED, L2/L3 stay live against them, and the run prints a NOTE naming why completeness could not be proven. Incompleteness is not a degradation and contributes NO part to `mode=`, so an otherwise-plain run still reads `mode=live` (an incomplete `--dry-run` reads `mode=dry-run`); the two NOTE lines are separate. Both states share the one guarantee that matters: a short or absent window can only ever cost deletions, never create one. The operative cause is candidate ABSENCE, not any `pr_state` value: L2 and L3 each iterate the merged-PR candidates matched to the branch, so a branch whose row is missing from the window has no candidate for either layer to fire on, leaving only the window-independent L1/L4 evidence and otherwise falling through to `SKIP_UNPROVEN`. Recording `pr_state=not_checked` rather than the affirmative `NONE` is a separate contract-correctness measure - it withholds a "no merged PR exists" claim the tool has just proven it could not enumerate - and is currently inert on this strict deletion path, where `_check_pr_state_strict` treats the two values alike; it is kept so the guarantee never comes to depend on that coincidence of the present gates. Do not remove the candidate-absence handling on the strength of that downgrade. What a degraded or incomplete run deletes is therefore always a strict subset, never a superset, of a fully-proven run.
---
## Step 4: Final state report
```bash
git worktree prune
git worktree list
# After-state, counted exactly the way Step 1 counted its baseline (same
# two commands, same non-root convention) so the two are comparable.
AFTER_WORKTREES=$(( $(git worktree list | wc -l) - 1 ))
AFTER_BRANCHES=$(( $(git for-each-ref --format='%(refname)' refs/heads/ | wc -l) ))
echo "AFTER_WORKTREES=$AFTER_WORKTREES"
echo "AFTER_BRANCHES=$AFTER_BRANCHES"
```
Report a summary:
- What was removed (worktree path + branch name for each)
- What was skipped (branch name + reason: dirty, PR open, no PR, too young, unknown type)
- If Step 2 printed a `NOTE: N worktree(s) skipped because they are younger than the ... age floor` line, surface it prominently in this summary rather than letting `removed=0` stand unexplained - name the count
- **Worktree entries before -> after, with the delta:** `BEFORE_WORKTREES` (echoed by Step 1) -> `AFTER_WORKTREES` (echoed above). Non-root entries only; the main worktree is never counted and never removable. A bare final count is not a substitute - it cannot answer "what did this run accomplish", which is the whole question an operator runs this command to have answered.
- **Local branches before -> after, with the delta:** `BEFORE_BRANCHES` (Step 1) -> `AFTER_BRANCHES` (above). Step 3 deletes local branches, so a worktree-only report leaves half of what this run did unreported.
- **The bytes Step 2's `--measure-size` produced, under the binary's OWN label:** `reclaimed_kb` on a live run, `would_reclaim_kb` under `--dry-run`. Carry that label through verbatim - never relabel one as the other, and never collapse the two into a single word like "reclaimed": a projection and bytes actually freed are different claims, and conflating them is how "we recovered X" becomes a lie. If Step 2's invocation dropped `--measure-size`, or the binary was not found and the reap was skipped, report the byte figures as NOT MEASURED and say why - an absent measurement is never reported as zero.
- **`dirty_unreclaimable_kb` alongside it, labelled refused-by-design and explicitly NOT part of what was reclaimed.** Dirty worktrees are refused removal terminally and are typically where the real byte mass on a checkout lives, so the figure is worth surfacing - but folding it into either reclaim total would report refused bytes as freed.
- **`--explain` now itemizes what each dirty tree actually holds** (`composition: modified=N untracked=N ...`, plus the raw `git status --porcelain` text), so a `SKIP_DIRTY` entry can be triaged as abandoned build output versus real uncommitted work without opening it. This is a REPORT ONLY: no gate reads it, it changes no outcome, and the tool still refuses to remove a dirty worktree - there is no flag that overrides that refusal. An unmeasurable tree reports `composition: unavailable`, never zeroes.
- **When Step 2 reported a nonzero `size_unknown_count`, state that every byte total that run is a FLOOR, not an exact figure** - entries a `du -sk` call could not measure are excluded from every total, never defaulted to 0.
- **Any `NOTE: N directory/directories ... are NOT registered with git` line, verbatim.** These are directories under `.claude/worktrees/`/`.agentic/worktrees/` that `git worktree list` does not know about, so no gate in Step 2 ever evaluates them and no run can reclaim them - they are invisible to every before/after count above. `--explain` lists the paths. REPORT ONLY: nothing is touched and no removal command is printed, deliberately; disposition is a human decision per path.
- **The standing caveat on every byte figure:** it measures the working-tree checkout only, never the shared git object store every worktree of a repo shares. Removing a worktree does not shrink that store, and deleting a branch in Step 3 reclaims nothing until `git gc` runs - so the branch delta above is a tidiness result, not a disk-space one.
---
## Notes
- **Safety first:** never remove a worktree with uncommitted changes without explicit user confirmation. `disposition_for`'s dirty check in Step 2 is not optional.
- Never remove a feature worktree whose PR is still OPEN. For a live worktree's own removal (Step 2, `disposition_for`), a MERGED PR alone remains sufficient evidence - `git worktree remove` does not destroy commits, so the worst case is already covered by `SKIP_DIRTY`/`SKIP_LOCKED` (DS-153 Amendment B1). This does NOT extend to local branch DELETION: `bin/ds-branch-prune` (Step 3, `disposition_for_orphan_branch`) treats a bare MERGED PR as terminally insufficient (`SKIP_PR_MERGED_UNPROVEN`) and requires the subsumption predicate to prove the tip's content is on the resolved base branch before deleting.
- The main worktree (first entry in `git worktree list`) is always skipped.
- Works on the repository in the current working directory - not project-specific.
- If `gh` is not available, flag feature worktrees for manual review and continue.