Skip to content
228 changes: 228 additions & 0 deletions .claude/scripts/branch-cleanup.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,228 @@
#!/usr/bin/env bash
# Per-tick branch hygiene: delete spent claude/* branches locally and on the remote.
#
# SAFETY CONTRACT (fail-closed β€” every ambiguity resolves to KEEP, every
# infrastructure failure ABORTS the phase that depends on it):
# KEEP - any branch that is the head of an OPEN PR (deleting it would CLOSE the PR)
# KEEP - any branch checked out by a worktree (git refuses anyway; we skip explicitly)
# KEEP - the default branch, and anything not claude/* (never touch codex/* = the sibling's lane)
# LOCAL - delete when not in KEEP (squash-merge means `-d` can't see merges, so `-D` + manifest)
# REMOTE- delete ONLY with positive evidence: an associated MERGED/CLOSED PR whose recorded head
# SHA equals the branch's CURRENT SHA (same incarnation β€” a re-pushed branch invalidates
# old PR evidence). Commit age is NOT evidence (commit time != push time), so no-PR
# branches are REPORTED as candidates, never deleted.
# CAS - every remote delete uses --force-with-lease pinned to the evidence SHA, so a branch a
# concurrent session moves between evidence-gathering and deletion is rejected, and the
# open-PR keep-set is re-fetched immediately before the delete loop.
#
# Every deletion is recorded (branch -> sha) to the manifest BEFORE the delete, and the write is
# verified β€” no restore record, no deletion.
set -uo pipefail

REPO_PATH="$1"; SLUG="$2"; MANIFEST="$3"; MODE="${4:-apply}"
errors=0

cd "$REPO_PATH" || exit 1

# DEFAULT reads the LOCAL origin/HEAD (set at clone) and needs no fresh fetch,
# so the checkout-restoration path can be armed BEFORE fetching.
DEFAULT=$(git symbolic-ref --quiet --short refs/remotes/origin/HEAD 2>/dev/null | sed 's#^origin/##')
DEFAULT="${DEFAULT:-main}"

# Return the checkout to where it belongs, FIRST (and again on any abort, via
# trap): a repo left on its tick's claude/* branch would otherwise keep that
# branch in the worktree keep-set forever, and an abort must not strand the
# checkout. A DETACHED start is preserved as-is β€” an initialized submodule sits
# detached at its superproject pin, and checking out the default branch there
# would silently move it off the pin (superproject drift).
START_BRANCH=$(git branch --show-current 2>/dev/null)
return_to_default() {
local cur
cur=$(git branch --show-current 2>/dev/null)
if [ -z "$START_BRANCH" ]; then
sw="detached start (submodule pin) β€” left as-is"
return
fi
if [ "$cur" != "$DEFAULT" ]; then
git checkout "$DEFAULT" -q 2>/dev/null
Comment thread
devantler marked this conversation as resolved.
local now
now=$(git branch --show-current 2>/dev/null)
if [ "$now" = "$DEFAULT" ]; then sw="-> $DEFAULT";
else sw="FAILED to reach $DEFAULT (on '${now:-detached}')"; errors=$((errors+1)); fi
else sw="already on $DEFAULT"; fi
}
sw=""
# Arm restoration BEFORE the fetch (superseded by the tmpfile trap below once it
# is installed): an attached claude/* checkout whose fetch then fails must still
# be returned to the default branch, or it stays worktree-protected on the next
# sweep and defeats the end-of-tick return-and-reap guarantee.
trap 'return_to_default' EXIT
return_to_default

# Stale refs make every later judgement wrong β€” abort the whole run on fetch
# failure (the EXIT trap above still returns the checkout to the default first).
if ! git fetch origin --prune -q 2>/dev/null; then
echo "$SLUG: ABORT β€” git fetch failed; refusing to act on stale refs" >&2
exit 1
fi

# Verified manifest write: no restore record, no deletion.
manifest_write() {
if ! printf '%s\n' "$1" >>"$MANIFEST" 2>/dev/null; then
echo "$SLUG: ABORT β€” manifest write to '$MANIFEST' failed; refusing to delete without a restore record" >&2
exit 1
fi
}

# Fetch the open-PR head list; ABORT on failure (an empty keep-set on a failed
# query is the catastrophic fail-open: it would delete every open PR's branch).
fetch_open_heads() {
local out
if ! out=$(gh pr list --repo "devantler-tech/$SLUG" --state open --limit 300 \
--json headRefName --jq '.[].headRefName' 2>/dev/null); then
echo "$SLUG: ABORT β€” open-PR query failed; keep-set cannot be trusted" >&2
exit 1
fi
printf '%s\n' "$out"
}

# --- KEEP set -------------------------------------------------------------
keep=$(mktemp); prs=$(mktemp); keep2=$(mktemp)
trap 'rm -f "$keep" "$prs" "$keep2"; return_to_default' EXIT
# Capture the worktree enumeration SEPARATELY and ABORT on failure: the REMOTE
# delete loop trusts this one snapshot for its worktree KEEP rule (the local loop
# re-checks per branch, the remote loop does not), so a silently-empty list here
# could let a checked-out claude/* branch with merged/closed PR evidence have its
# upstream deleted β€” violating the documented worktree KEEP guarantee. A failed
# enumeration means the keep-set cannot be trusted.
if ! wt_branches=$(git worktree list --porcelain 2>/dev/null); then
echo "$SLUG: ABORT β€” worktree enumeration failed; keep-set cannot be trusted" >&2
exit 1
fi
{
fetch_open_heads
printf '%s\n' "$wt_branches" | awk '/^branch /{sub("refs/heads/","",$2); print $2}'
printf '%s\n' "$DEFAULT"
Comment thread
devantler marked this conversation as resolved.
} >>"$keep"

# --- PR evidence map for remote decisions ----------------------------------
# newest-first per branch: state + the head SHA the PR evidence belongs to.
# Bounded evidence is FAIL-CLOSED: a merged PR older than the newest 1000
# results yields "no evidence" => KEEP/candidate, never a deletion.
if ! gh pr list --repo "devantler-tech/$SLUG" --state all --limit 1000 \
--json headRefName,state,headRefOid,headRepositoryOwner \
--jq '.[]|select(.headRepositoryOwner.login=="devantler-tech")|"\(.headRefName)\t\(.state)\t\(.headRefOid)"' 2>/dev/null >"$prs"; then
echo "$SLUG: ABORT β€” PR-state query failed; remote evidence unavailable" >&2
exit 1
fi

is_kept() { grep -Fxq "$1" "$keep"; }

# HANDS-OFF the maintainer's INTERACTIVE Claude work. The maintainer also drives
# Claude Code interactively; those sessions use the harness's random-slug worktree
# branch `claude/<adjective>-<name>-<6hex>` (e.g. claude/unruffled-kepler-f3e922),
# marked HANDS-OFF in AGENTS.md. This routine's OWN branches are `claude/<area>-<desc>`
# and never carry a trailing 6-hex slug, so skip anything that does β€” fail-closed (an
# ambiguous match is KEPT, never reaped), so a merged/closed interactive PR's branch is
# never mistaken for one of this routine's spent per-run worktrees.
is_interactive_slug() {
local last="${1##*-}"
[[ "$last" =~ ^[0-9a-f]{6}$ ]]
}
pr_evidence() { awk -F'\t' -v b="$1" '$1==b{print $2 "\t" $3; exit}' "$prs"; }

l_del=0; r_del=0; l_keep=0; r_keep=0; candidates=0; r_rej=0

# --- LOCAL ----------------------------------------------------------------
while IFS= read -r b; do
[ -z "$b" ] && continue
if is_kept "$b"; then l_keep=$((l_keep+1)); continue; fi
if is_interactive_slug "$b"; then l_keep=$((l_keep+1)); continue; fi
sha=$(git rev-parse "$b" 2>/dev/null) || continue
# Never lose unpushed work: delete only when the tip is reachable from SOME
# remote ref, OR a MERGED/CLOSED PR accounts for this exact sha (a
# squash-merged branch whose remote ref was already pruned is reachable
# from nothing, yet its work is safely in the PR record). Otherwise keep as
# a candidate.
ev=$(pr_evidence "$b"); st="${ev%%$'\t'*}"; ev_sha="${ev#*$'\t'}"
if [ -z "$(git branch -r --contains "$sha" 2>/dev/null | head -1)" ] &&
{ [ "$st" != "MERGED" ] && [ "$st" != "CLOSED" ] || [ "$ev_sha" != "$sha" ]; }; then
candidates=$((candidates+1)); l_keep=$((l_keep+1)); continue
fi
manifest_write "$(printf '%s\tlocal\t%s\t%s' "$SLUG" "$b" "$sha")"
if [ "$MODE" = "apply" ]; then
# update-ref -d BYPASSES the checked-out-branch refusal `git branch -D`
# has, so re-check the live worktree list right before deleting β€” a
# concurrent session may have checked the branch out since the keep-set
# snapshot. Capture the enumeration SEPARATELY and fail CLOSED (keep) if it
# errors: piping the failing command straight into grep would let a
# transient worktree-metadata read error read as "checked out nowhere",
# and update-ref would then delete a branch live in another worktree,
# leaving that worktree on a dangling/unborn HEAD.
if ! wt_list=$(git worktree list --porcelain 2>/dev/null); then
echo "$SLUG: keep '$b' β€” worktree enumeration failed; refusing to delete (fail-closed)" >&2
l_keep=$((l_keep+1)); errors=$((errors+1)); continue
fi
if printf '%s\n' "$wt_list" | grep -Fxq "branch refs/heads/$b"; then
echo "$SLUG: keep '$b' β€” checked out by a worktree since the snapshot" >&2
l_keep=$((l_keep+1)); continue
fi
# CAS delete: update-ref -d with the expected old value refuses if a
# concurrent session re-pointed the ref after evidence-gathering.
if git update-ref -d "refs/heads/$b" "$sha" >/dev/null 2>&1; then l_del=$((l_del+1));
Comment thread
devantler marked this conversation as resolved.
else echo "$SLUG: WARN β€” local delete of '$b' rejected (ref moved) or failed" >&2; errors=$((errors+1)); fi
else l_del=$((l_del+1)); fi
done < <(git branch --list 'claude/*' --format='%(refname:short)')
Comment thread
devantler marked this conversation as resolved.

# --- REMOTE ---------------------------------------------------------------
# Re-fetch the open-PR keep-set IMMEDIATELY before the delete loop: a PR opened
# while we were working must re-protect its branch (ABORTs on query failure).
fetch_open_heads >"$keep2"
re_kept() { grep -Fxq "$1" "$keep2" || grep -Fxq "$1" "$keep"; }

while IFS= read -r rb; do
b="${rb#origin/}"
[ -z "$b" ] && continue
if re_kept "$b"; then r_keep=$((r_keep+1)); continue; fi
if is_interactive_slug "$b"; then r_keep=$((r_keep+1)); continue; fi
sha=$(git rev-parse "$rb" 2>/dev/null) || { r_keep=$((r_keep+1)); continue; }
ev=$(pr_evidence "$b"); st="${ev%%$'\t'*}"; ev_sha="${ev#*$'\t'}"
if [ "$st" != "MERGED" ] && [ "$st" != "CLOSED" ]; then
# No PR evidence. Commit age is NOT push age β€” a recent push of old commits
# would look stale β€” so no-PR branches are candidates to REPORT, never delete.
candidates=$((candidates+1)); r_keep=$((r_keep+1)); continue
fi
if [ "$ev_sha" != "$sha" ]; then
# The PR evidence belongs to an older incarnation of this branch name; the
# current ref carries commits no PR accounts for. Keep it.
r_keep=$((r_keep+1)); continue
fi
manifest_write "$(printf '%s\tremote\t%s\t%s\t%s' "$SLUG" "$b" "$sha" "$st")"
Comment thread
devantler marked this conversation as resolved.
if [ "$MODE" = "apply" ]; then
# Final per-branch TOCTOU guard: a PR can open between the loop-level
# keep-set refresh and this very deletion. Fail closed on query failure.
# RESIDUAL RISK (accepted, no server-side conditional delete exists): a PR
# opened in the milliseconds between this query and the push below would
# be closed by the deletion; the manifest sha makes that restorable
# (push the sha back, reopen the PR).
open_now=$(gh pr list --repo "devantler-tech/$SLUG" --state open --head "$b" \
--json number --jq 'length' 2>/dev/null)
if [ -z "$open_now" ] || [ "$open_now" != "0" ]; then
echo "$SLUG: keep '$b' β€” open-PR recheck non-empty or failed" >&2
r_keep=$((r_keep+1)); continue
fi
# CAS delete: rejected if the remote ref moved off the evidence SHA.
if git push --force-with-lease="refs/heads/$b:$sha" origin ":refs/heads/$b" >/dev/null 2>&1; then
Comment thread
devantler marked this conversation as resolved.
Comment thread
devantler marked this conversation as resolved.
r_del=$((r_del+1))
else
echo "$SLUG: WARN β€” remote delete of '$b' rejected (ref moved or push failed); kept" >&2
r_rej=$((r_rej+1)); r_keep=$((r_keep+1))
fi
Comment thread
devantler marked this conversation as resolved.
else r_del=$((r_del+1)); fi
done < <(git branch -r --list 'origin/claude/*' --format='%(refname:short)')

printf '%-24s local: -%-4s keep %-3s | remote: -%-3s keep %-3s rej %-2s cand %-3s | %s\n' \
"$SLUG" "$l_del" "$l_keep" "$r_del" "$r_keep" "$r_rej" "$candidates" "$sw"

[ "$errors" -gt 0 ] && exit 3
exit 0
9 changes: 8 additions & 1 deletion .claude/skills/portfolio-maintenance/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -427,7 +427,14 @@ For each selected product:
is forbidden unless the current interactive conversation first clears the professional-work
boundary for that named repo; creating an upstream artifact then still needs ask-tool approval.
4. **Clean up:** `git -C <path> worktree remove .claude/worktrees/maint-<runid>` (and prune). Leave
no worktree or dirty state behind.
no worktree or dirty state behind. **Then reap spent branches EVERY run** (contract *End-of-tick
branch hygiene*): with the worktree already removed (a branch still checked out sits in the keep-set),
run [`.claude/scripts/branch-cleanup.sh <repo_path> <slug> <manifest>`](../../scripts/branch-cleanup.sh)
for each repo touched. It restores the default-branch checkout and deletes only spent `claude/*`
branches β€” KEEPING open-PR heads, worktree-checked-out branches, and the maintainer's interactive
random-slug branches, and deleting a remote branch only on MERGED/CLOSED PR evidence (a restore
manifest is written before each delete). This step is what makes the *reap EVERY run* duty actually
run in a scheduled tick β€” the paragraph alone does not.

## 4. Always: update native memory + one consolidated report
- **Native memory** (the single source of truth β€” your runtime's memory tool; never costs a PR): write
Expand Down
30 changes: 30 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -913,6 +913,36 @@ Never `git reset --hard`, `git stash`, force-push, or discard changes you did no
`git add -A` / `git add .` β€” stage only files you edited. Never stage submodule-pointer bumps unless
a task explicitly calls for it. Leave every checkout/worktree clean when done.

**End-of-tick branch hygiene β€” reap spent branches and return to the default branch, EVERY run**
(maintainer direction 2026-07-16: *"You never clean up old branches locally or on the remote. I expect
you to always clean up and switch back to the default branch after a tick."*). Left unswept, every run's
worktree branch survives it: the first sweep found **~1,140 spent branches** (monorepo alone had **589**
local; `.github` had **35** stale remote). **Remove your own per-run worktree FIRST, then run**
[`.claude/scripts/branch-cleanup.sh <repo_path> <slug> <manifest>`](.claude/scripts/branch-cleanup.sh)
for each repo touched β€” a branch still checked out by your own worktree sits in the keep-set, so a
sweep run before the worktree removal silently spares the very branch the tick just spent.
Comment thread
devantler marked this conversation as resolved.

**πŸ”΄ Deleting a remote branch CLOSES its open PR β€” so the keep-set is the whole safety property:**
- **KEEP:** the head of an **OPEN PR**; any branch **checked out by a worktree**; the default branch;
the maintainer's **interactive random-slug** branches `claude/<adjective>-<name>-<6hex>` (HANDS-OFF β€”
never reaped even with a merged/closed PR, since they were never this routine's per-run worktree); and
anything not `claude/*` (**never touch `codex/*` β€” the sibling's lane**).
- **`git branch --merged main` is USELESS here** β€” the portfolio **squash-merges**, so a merged branch's
commits are never in `main`. For the same reason `commits-not-in-main > 0` does **NOT** mean unmerged
work. **The PR state is the only authoritative signal** β€” never infer merge status from the commit graph.
- **Local:** delete anything outside the keep-set (`-D`; `-d` cannot see squash-merges).
- **Remote:** delete only on **positive evidence** β€” an associated **MERGED/CLOSED PR whose recorded
head SHA equals the branch's CURRENT SHA** (a re-pushed branch is a new incarnation the old PR does
not account for β†’ keep). **No-PR branches are never deleted, only reported as candidates** β€” commit
time is NOT push time, so "old commits" can be a live session that just pushed; age alone is not
evidence. Deletes are **CAS-guarded** (`--force-with-lease` pinned to the evidence SHA) and the
open-PR keep-set is **re-fetched immediately before the delete loop**.
- **Fail closed on infrastructure:** a failed `git fetch`, open-PR query, or manifest write ABORTS the
sweep β€” an empty keep-set from a failed query would otherwise delete every open PR's branch.
- **Write a manifest** (`repo β†’ branch β†’ sha β†’ evidence`) before deleting so any branch is restorable
from its SHA; the write is verified β€” no restore record, no deletion.
- Reap only **your own** per-run worktree β€” another session's worktree directory may be live.

**Two-writer branches β€” another instance may be on the same PR right now.** More than one agent
instance sweeps the same PR dashboard (and instances can overlap inside one hour), so any shared
branch (`claude/*`, a bot branch you push fixes to) β€” and even a not-yet-opened artifact like a
Expand Down
Loading