From 8adfd16fa7e81db8592c9c7b02954640283042d1 Mon Sep 17 00:00:00 2001 From: Nikolai Emil Damm Date: Thu, 16 Jul 2026 23:33:02 +0200 Subject: [PATCH 1/7] docs(agents): reap spent branches every tick and return to the default branch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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." Every tick's worktree branch survived the tick and nothing reaped it, so the first sweep found ~1,140 spent branches (monorepo alone: 589 local; .github: 35 stale remote). Adds branch-cleanup.sh and makes the sweep an end-of-tick duty. The safety property is the keep-set: deleting a remote branch closes its open PR, so open-PR heads, worktree-held branches, the default branch and non-claude/* branches are never touched. Notably `git branch --merged main` cannot be used here — the portfolio squash-merges, so a merged branch's commits are never in main and the PR state is the only authoritative signal. Verified by running it: 1137 branches reaped across 15 repos, zero open PRs harmed (delete-set asserted to intersect open-PR heads in zero places, and every remote deletion carries MERGED/CLOSED or >14d-no-PR evidence). Co-Authored-By: Claude Opus 4.8 --- .claude/scripts/branch-cleanup.sh | 78 +++++++++++++++++++++++++++++++ AGENTS.md | 21 +++++++++ 2 files changed, 99 insertions(+) create mode 100755 .claude/scripts/branch-cleanup.sh diff --git a/.claude/scripts/branch-cleanup.sh b/.claude/scripts/branch-cleanup.sh new file mode 100755 index 00000000..643e59ae --- /dev/null +++ b/.claude/scripts/branch-cleanup.sh @@ -0,0 +1,78 @@ +#!/usr/bin/env bash +# Per-tick branch hygiene: delete spent claude/* branches locally and on the remote. +# +# SAFETY CONTRACT (fail-closed): +# 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, or age > MAX_AGE_DAYS +# with no PR at all. A recent no-PR branch may be a LIVE session -> left alone. +# +# Every deletion is recorded (branch -> sha) to the manifest so it can be restored from the SHA. +set -uo pipefail + +REPO_PATH="$1"; SLUG="$2"; MANIFEST="$3"; MODE="${4:-apply}" +MAX_AGE_DAYS=14 +NOW=$(date -u +%s) + +cd "$REPO_PATH" || exit 1 +git fetch origin --prune -q 2>/dev/null + +DEFAULT=$(git symbolic-ref --quiet --short refs/remotes/origin/HEAD 2>/dev/null | sed 's#^origin/##') +DEFAULT="${DEFAULT:-main}" + +# --- KEEP set ------------------------------------------------------------- +keep=$(mktemp); trap 'rm -f "$keep" "$prs"' EXIT +{ + gh pr list --repo "devantler-tech/$SLUG" --state open --limit 300 \ + --json headRefName --jq '.[].headRefName' 2>/dev/null + git worktree list --porcelain 2>/dev/null | awk '/^branch /{sub("refs/heads/","",$2); print $2}' + printf '%s\n' "$DEFAULT" +} >>"$keep" + +# --- PR state map for remote decisions ------------------------------------ +prs=$(mktemp) +gh pr list --repo "devantler-tech/$SLUG" --state all --limit 500 \ + --json headRefName,state --jq '.[]|"\(.headRefName)\t\(.state)"' 2>/dev/null >"$prs" + +is_kept() { grep -Fxq "$1" "$keep"; } +pr_state() { awk -F'\t' -v b="$1" '$1==b{print $2; exit}' "$prs"; } + +l_del=0; r_del=0; l_keep=0; r_keep=0 + +# --- LOCAL ---------------------------------------------------------------- +while IFS= read -r b; do + [ -z "$b" ] && continue + if is_kept "$b"; then l_keep=$((l_keep+1)); continue; fi + sha=$(git rev-parse "$b" 2>/dev/null) + printf '%s\tlocal\t%s\t%s\n' "$SLUG" "$b" "$sha" >>"$MANIFEST" + if [ "$MODE" = "apply" ]; then git branch -D "$b" >/dev/null 2>&1 && l_del=$((l_del+1)); else l_del=$((l_del+1)); fi +done < <(git branch --list 'claude/*' --format='%(refname:short)') + +# --- REMOTE --------------------------------------------------------------- +while IFS= read -r rb; do + b="${rb#origin/}" + [ -z "$b" ] && continue + if is_kept "$b"; then r_keep=$((r_keep+1)); continue; fi + st=$(pr_state "$b") + if [ "$st" != "MERGED" ] && [ "$st" != "CLOSED" ]; then + # No PR evidence -> only reap if clearly stale (never nuke a live session's branch). + ts=$(git log -1 --format=%ct "$rb" 2>/dev/null || echo "$NOW") + age=$(( (NOW - ${ts:-$NOW}) / 86400 )) + if [ "$age" -lt "$MAX_AGE_DAYS" ]; then r_keep=$((r_keep+1)); continue; fi + st="NO-PR-stale-${age}d" + fi + sha=$(git rev-parse "$rb" 2>/dev/null) + printf '%s\tremote\t%s\t%s\t%s\n' "$SLUG" "$b" "$sha" "$st" >>"$MANIFEST" + if [ "$MODE" = "apply" ]; then git push origin --delete "$b" >/dev/null 2>&1 && r_del=$((r_del+1)); else r_del=$((r_del+1)); fi +done < <(git branch -r --list 'origin/claude/*' --format='%(refname:short)') + +# --- return to default ---------------------------------------------------- +cur=$(git branch --show-current 2>/dev/null) +if [ "$cur" != "$DEFAULT" ] && [ -n "$cur" ]; then + git checkout "$DEFAULT" -q 2>/dev/null && sw="-> $DEFAULT" || sw="(could not switch: dirty/worktree)" +else sw="already on $DEFAULT"; fi + +printf '%-24s local: -%-4s keep %-3s | remote: -%-3s keep %-3s | %s\n' \ + "$SLUG" "$l_del" "$l_keep" "$r_del" "$r_keep" "$sw" diff --git a/AGENTS.md b/AGENTS.md index 930473ed..a739addb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -913,6 +913,27 @@ 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). Run +[`.claude/scripts/branch-cleanup.sh `](.claude/scripts/branch-cleanup.sh) +for each repo touched, then remove the per-run worktree. + +**🔴 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; 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, or **no PR and + older than 14 days**. A *recent* no-PR remote branch may be a **live session's** → leave it. +- **Write a manifest** (`repo → branch → sha → evidence`) before deleting so any branch is restorable + from its SHA, and **assert the delete-set intersects the open-PR heads in ZERO places** before applying. +- 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 From 6bae508dd6df542ef16e3cde666f2339ce5ed3ef Mon Sep 17 00:00:00 2001 From: Nikolai Emil Damm Date: Fri, 17 Jul 2026 01:07:04 +0200 Subject: [PATCH 2/7] fix(scripts): make branch-cleanup fail-closed on every surface codex flagged Fetch/PR-query/manifest failures now ABORT (an empty keep-set from a failed query would have deleted every open PR's branch); remote deletes require PR evidence at the branch's CURRENT sha and are CAS-guarded with --force-with-lease; the open-PR keep-set is re-fetched just before the delete loop; the no-PR age heuristic is removed entirely (commit time is not push time) in favour of report-only candidates; delete/checkout failures propagate via exit 3. AGENTS.md ordering fixed: remove your own worktree BEFORE the sweep. RED/GREEN: bogus-slug run aborts with 0 deletions; monorepo dry-run keeps all 42 worktree/open-PR branches. Co-Authored-By: Claude Fable 5 --- .claude/scripts/branch-cleanup.sh | 126 ++++++++++++++++++++++-------- AGENTS.md | 17 ++-- 2 files changed, 105 insertions(+), 38 deletions(-) diff --git a/.claude/scripts/branch-cleanup.sh b/.claude/scripts/branch-cleanup.sh index 643e59ae..1d80d0f4 100755 --- a/.claude/scripts/branch-cleanup.sh +++ b/.claude/scripts/branch-cleanup.sh @@ -1,78 +1,138 @@ #!/usr/bin/env bash # Per-tick branch hygiene: delete spent claude/* branches locally and on the remote. # -# SAFETY CONTRACT (fail-closed): +# 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, or age > MAX_AGE_DAYS -# with no PR at all. A recent no-PR branch may be a LIVE session -> left alone. +# 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 so it can be restored from the SHA. +# 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}" -MAX_AGE_DAYS=14 -NOW=$(date -u +%s) +errors=0 cd "$REPO_PATH" || exit 1 -git fetch origin --prune -q 2>/dev/null + +# Stale refs make every later judgement wrong — abort the whole run on fetch failure. +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 DEFAULT=$(git symbolic-ref --quiet --short refs/remotes/origin/HEAD 2>/dev/null | sed 's#^origin/##') DEFAULT="${DEFAULT:-main}" +# 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); trap 'rm -f "$keep" "$prs"' EXIT +keep=$(mktemp); prs=$(mktemp); keep2=$(mktemp) +trap 'rm -f "$keep" "$prs" "$keep2"' EXIT { - gh pr list --repo "devantler-tech/$SLUG" --state open --limit 300 \ - --json headRefName --jq '.[].headRefName' 2>/dev/null + fetch_open_heads git worktree list --porcelain 2>/dev/null | awk '/^branch /{sub("refs/heads/","",$2); print $2}' printf '%s\n' "$DEFAULT" } >>"$keep" -# --- PR state map for remote decisions ------------------------------------ -prs=$(mktemp) -gh pr list --repo "devantler-tech/$SLUG" --state all --limit 500 \ - --json headRefName,state --jq '.[]|"\(.headRefName)\t\(.state)"' 2>/dev/null >"$prs" +# --- PR evidence map for remote decisions ---------------------------------- +# newest-first per branch: state + the head SHA the PR evidence belongs to. +if ! gh pr list --repo "devantler-tech/$SLUG" --state all --limit 500 \ + --json headRefName,state,headRefOid \ + --jq '.[]|"\(.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"; } -pr_state() { awk -F'\t' -v b="$1" '$1==b{print $2; exit}' "$prs"; } +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 +l_del=0; r_del=0; l_keep=0; r_keep=0; candidates=0 # --- LOCAL ---------------------------------------------------------------- while IFS= read -r b; do [ -z "$b" ] && continue if is_kept "$b"; then l_keep=$((l_keep+1)); continue; fi - sha=$(git rev-parse "$b" 2>/dev/null) - printf '%s\tlocal\t%s\t%s\n' "$SLUG" "$b" "$sha" >>"$MANIFEST" - if [ "$MODE" = "apply" ]; then git branch -D "$b" >/dev/null 2>&1 && l_del=$((l_del+1)); else l_del=$((l_del+1)); fi + sha=$(git rev-parse "$b" 2>/dev/null) || continue + manifest_write "$(printf '%s\tlocal\t%s\t%s' "$SLUG" "$b" "$sha")" + if [ "$MODE" = "apply" ]; then + if git branch -D "$b" >/dev/null 2>&1; then l_del=$((l_del+1)); + else echo "$SLUG: WARN — local delete of '$b' failed" >&2; errors=$((errors+1)); fi + else l_del=$((l_del+1)); fi done < <(git branch --list 'claude/*' --format='%(refname:short)') # --- 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 is_kept "$b"; then r_keep=$((r_keep+1)); continue; fi - st=$(pr_state "$b") + if re_kept "$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 -> only reap if clearly stale (never nuke a live session's branch). - ts=$(git log -1 --format=%ct "$rb" 2>/dev/null || echo "$NOW") - age=$(( (NOW - ${ts:-$NOW}) / 86400 )) - if [ "$age" -lt "$MAX_AGE_DAYS" ]; then r_keep=$((r_keep+1)); continue; fi - st="NO-PR-stale-${age}d" + # 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 - sha=$(git rev-parse "$rb" 2>/dev/null) - printf '%s\tremote\t%s\t%s\t%s\n' "$SLUG" "$b" "$sha" "$st" >>"$MANIFEST" - if [ "$MODE" = "apply" ]; then git push origin --delete "$b" >/dev/null 2>&1 && r_del=$((r_del+1)); else r_del=$((r_del+1)); 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")" + if [ "$MODE" = "apply" ]; then + # 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 + r_del=$((r_del+1)) + else + echo "$SLUG: WARN — remote delete of '$b' rejected (ref moved or push failed); kept" >&2 + r_keep=$((r_keep+1)) + fi + else r_del=$((r_del+1)); fi done < <(git branch -r --list 'origin/claude/*' --format='%(refname:short)') # --- return to default ---------------------------------------------------- cur=$(git branch --show-current 2>/dev/null) -if [ "$cur" != "$DEFAULT" ] && [ -n "$cur" ]; then - git checkout "$DEFAULT" -q 2>/dev/null && sw="-> $DEFAULT" || sw="(could not switch: dirty/worktree)" +if [ "$cur" != "$DEFAULT" ]; then + git checkout "$DEFAULT" -q 2>/dev/null + 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 -printf '%-24s local: -%-4s keep %-3s | remote: -%-3s keep %-3s | %s\n' \ - "$SLUG" "$l_del" "$l_keep" "$r_del" "$r_keep" "$sw" +printf '%-24s local: -%-4s keep %-3s | remote: -%-3s keep %-3s cand %-3s | %s\n' \ + "$SLUG" "$l_del" "$l_keep" "$r_del" "$r_keep" "$candidates" "$sw" + +[ "$errors" -gt 0 ] && exit 3 +exit 0 diff --git a/AGENTS.md b/AGENTS.md index a739addb..91f5082c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -917,9 +917,10 @@ a task explicitly calls for it. Leave every checkout/worktree clean when done. (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). Run +local; `.github` had **35** stale remote). **Remove your own per-run worktree FIRST, then run** [`.claude/scripts/branch-cleanup.sh `](.claude/scripts/branch-cleanup.sh) -for each repo touched, then remove the per-run worktree. +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. **🔴 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; and @@ -928,10 +929,16 @@ for each repo touched, then remove the per-run worktree. 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, or **no PR and - older than 14 days**. A *recent* no-PR remote branch may be a **live session's** → leave it. +- **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, and **assert the delete-set intersects the open-PR heads in ZERO places** before applying. + 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 From e1c85c63b82b9a053dd3003a9d5599f0dea7ae91 Mon Sep 17 00:00:00 2001 From: Nikolai Emil Damm Date: Fri, 17 Jul 2026 01:25:49 +0200 Subject: [PATCH 3/7] =?UTF-8?q?fix(scripts):=20close=20codex=20round-2=20P?= =?UTF-8?q?1s=20=E2=80=94=20fork-PR=20evidence,=20unpushed-commit=20loss,?= =?UTF-8?q?=20per-delete=20TOCTOU?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR evidence now binds to devantler-tech-owned heads (a fork PR sharing a branch name can no longer license deleting the origin branch); local -D requires the tip to be reachable from a remote ref (unpushed-only work is kept and reported); and each remote deletion re-checks for a just-opened PR, failing closed on query failure. Co-Authored-By: Claude Fable 5 --- .claude/scripts/branch-cleanup.sh | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/.claude/scripts/branch-cleanup.sh b/.claude/scripts/branch-cleanup.sh index 1d80d0f4..261663ad 100755 --- a/.claude/scripts/branch-cleanup.sh +++ b/.claude/scripts/branch-cleanup.sh @@ -65,8 +65,8 @@ trap 'rm -f "$keep" "$prs" "$keep2"' EXIT # --- PR evidence map for remote decisions ---------------------------------- # newest-first per branch: state + the head SHA the PR evidence belongs to. if ! gh pr list --repo "devantler-tech/$SLUG" --state all --limit 500 \ - --json headRefName,state,headRefOid \ - --jq '.[]|"\(.headRefName)\t\(.state)\t\(.headRefOid)"' 2>/dev/null >"$prs"; then + --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 @@ -81,6 +81,11 @@ while IFS= read -r b; do [ -z "$b" ] && continue if is_kept "$b"; then l_keep=$((l_keep+1)); continue; fi sha=$(git rev-parse "$b" 2>/dev/null) || continue + # Never lose unpushed work: -D only when the tip is reachable from SOME + # remote ref; an unpushed-only branch is kept (reported as a candidate). + if [ -z "$(git branch -r --contains "$sha" 2>/dev/null | head -1)" ]; 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 if git branch -D "$b" >/dev/null 2>&1; then l_del=$((l_del+1)); @@ -112,6 +117,14 @@ while IFS= read -r rb; do fi manifest_write "$(printf '%s\tremote\t%s\t%s\t%s' "$SLUG" "$b" "$sha" "$st")" 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. + 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 r_del=$((r_del+1)) From 5cf5e9eba6cb4aeb1e0852a24abce96ed1b106a8 Mon Sep 17 00:00:00 2001 From: Nikolai Emil Damm Date: Fri, 17 Jul 2026 04:05:31 +0200 Subject: [PATCH 4/7] =?UTF-8?q?fix(scripts):=20branch-cleanup=20round=203?= =?UTF-8?q?=20=E2=80=94=20local=20CAS,=20merged-evidence=20locals,=20trap-?= =?UTF-8?q?based=20return,=20rejection=20counter?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Local deletes now use update-ref -d with the expected sha (CAS) and accept MERGED/CLOSED PR evidence at the exact sha (a squash-merged branch whose remote ref was already pruned is remote-reachable from nothing and would have piled up forever); return-to-default runs first AND on every abort via the EXIT trap, which also un-keeps the tick's own checked-out branch; remote rejections get their own surfaced counter; the evidence bound is 1000 and documented fail-closed; the final-guard residual TOCTOU window is documented as accepted (no server-side conditional delete exists). Co-Authored-By: Claude Fable 5 --- .claude/scripts/branch-cleanup.sh | 61 +++++++++++++++++++++---------- 1 file changed, 41 insertions(+), 20 deletions(-) diff --git a/.claude/scripts/branch-cleanup.sh b/.claude/scripts/branch-cleanup.sh index 261663ad..40cb6b1f 100755 --- a/.claude/scripts/branch-cleanup.sh +++ b/.claude/scripts/branch-cleanup.sh @@ -33,6 +33,23 @@ fi DEFAULT=$(git symbolic-ref --quiet --short refs/remotes/origin/HEAD 2>/dev/null | sed 's#^origin/##') DEFAULT="${DEFAULT:-main}" +# Return to the default branch 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. +return_to_default() { + local cur + cur=$(git branch --show-current 2>/dev/null) + if [ "$cur" != "$DEFAULT" ]; then + git checkout "$DEFAULT" -q 2>/dev/null + 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="" +return_to_default + # Verified manifest write: no restore record, no deletion. manifest_write() { if ! printf '%s\n' "$1" >>"$MANIFEST" 2>/dev/null; then @@ -55,7 +72,7 @@ fetch_open_heads() { # --- KEEP set ------------------------------------------------------------- keep=$(mktemp); prs=$(mktemp); keep2=$(mktemp) -trap 'rm -f "$keep" "$prs" "$keep2"' EXIT +trap 'rm -f "$keep" "$prs" "$keep2"; return_to_default' EXIT { fetch_open_heads git worktree list --porcelain 2>/dev/null | awk '/^branch /{sub("refs/heads/","",$2); print $2}' @@ -64,7 +81,9 @@ trap 'rm -f "$keep" "$prs" "$keep2"' EXIT # --- PR evidence map for remote decisions ---------------------------------- # newest-first per branch: state + the head SHA the PR evidence belongs to. -if ! gh pr list --repo "devantler-tech/$SLUG" --state all --limit 500 \ +# 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 @@ -74,22 +93,29 @@ fi is_kept() { grep -Fxq "$1" "$keep"; } 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 +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 sha=$(git rev-parse "$b" 2>/dev/null) || continue - # Never lose unpushed work: -D only when the tip is reachable from SOME - # remote ref; an unpushed-only branch is kept (reported as a candidate). - if [ -z "$(git branch -r --contains "$sha" 2>/dev/null | head -1)" ]; then + # 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 - if git branch -D "$b" >/dev/null 2>&1; then l_del=$((l_del+1)); - else echo "$SLUG: WARN — local delete of '$b' failed" >&2; errors=$((errors+1)); 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)); + 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)') @@ -119,6 +145,10 @@ while IFS= read -r rb; do 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 @@ -130,22 +160,13 @@ while IFS= read -r rb; do r_del=$((r_del+1)) else echo "$SLUG: WARN — remote delete of '$b' rejected (ref moved or push failed); kept" >&2 - r_keep=$((r_keep+1)) + r_rej=$((r_rej+1)); r_keep=$((r_keep+1)) fi else r_del=$((r_del+1)); fi done < <(git branch -r --list 'origin/claude/*' --format='%(refname:short)') -# --- return to default ---------------------------------------------------- -cur=$(git branch --show-current 2>/dev/null) -if [ "$cur" != "$DEFAULT" ]; then - git checkout "$DEFAULT" -q 2>/dev/null - 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 - -printf '%-24s local: -%-4s keep %-3s | remote: -%-3s keep %-3s cand %-3s | %s\n' \ - "$SLUG" "$l_del" "$l_keep" "$r_del" "$r_keep" "$candidates" "$sw" +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 From 436f673c2c765d6cbb151e04156304224857de38 Mon Sep 17 00:00:00 2001 From: Nikolai Emil Damm Date: Fri, 17 Jul 2026 08:00:01 +0200 Subject: [PATCH 5/7] fix(scripts): preserve detached submodule pins; recheck worktrees before update-ref -d MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A detached start (an initialized submodule at its superproject pin) is now left exactly as-is — checking out the default branch there silently moved the submodule off its pin. And since update-ref -d bypasses the checked-out-branch refusal git branch -D has, each local delete now re-checks the live worktree list first. Co-Authored-By: Claude Fable 5 --- .claude/scripts/branch-cleanup.sh | 22 +++++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/.claude/scripts/branch-cleanup.sh b/.claude/scripts/branch-cleanup.sh index 40cb6b1f..288efebe 100755 --- a/.claude/scripts/branch-cleanup.sh +++ b/.claude/scripts/branch-cleanup.sh @@ -33,12 +33,20 @@ fi DEFAULT=$(git symbolic-ref --quiet --short refs/remotes/origin/HEAD 2>/dev/null | sed 's#^origin/##') DEFAULT="${DEFAULT:-main}" -# Return to the default branch 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. +# 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 local now @@ -112,6 +120,14 @@ while IFS= read -r b; do 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. + if git worktree list --porcelain 2>/dev/null | 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)); From 4b5a72801a833b40ca3128337da4c8f2ec254416 Mon Sep 17 00:00:00 2001 From: Nikolai Emil Damm Date: Sat, 18 Jul 2026 04:37:23 +0200 Subject: [PATCH 6/7] fix(branch-cleanup): fail closed on fetch and worktree-enum errors Address two robustness gaps: (1) capture the pre-delete worktree enumeration separately and keep the branch if it errors, so a transient failure can't be read as "checked out nowhere" and let update-ref delete a branch live in another worktree; (2) arm the checkout-restoration trap before the initial fetch, so a fetch failure still returns the checkout to the default branch instead of stranding it on the spent branch. Co-Authored-By: Claude Opus 4.8 (1M context) --- .claude/scripts/branch-cleanup.sh | 32 +++++++++++++++++++++++-------- 1 file changed, 24 insertions(+), 8 deletions(-) diff --git a/.claude/scripts/branch-cleanup.sh b/.claude/scripts/branch-cleanup.sh index 288efebe..57cabc7b 100755 --- a/.claude/scripts/branch-cleanup.sh +++ b/.claude/scripts/branch-cleanup.sh @@ -24,12 +24,8 @@ errors=0 cd "$REPO_PATH" || exit 1 -# Stale refs make every later judgement wrong — abort the whole run on fetch failure. -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 - +# 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}" @@ -56,8 +52,20 @@ return_to_default() { 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 @@ -123,8 +131,16 @@ while IFS= read -r b; do # 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. - if git worktree list --porcelain 2>/dev/null | grep -Fxq "branch refs/heads/$b"; then + # 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 From 7fd4d763c338de48d9e6a06ac8559c3638dbb36c Mon Sep 17 00:00:00 2001 From: Nikolai Emil Damm Date: Sat, 18 Jul 2026 05:16:01 +0200 Subject: [PATCH 7/7] fix(branch-cleanup): guard keep-set enum, skip interactive branches, wire into run loop MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address three review findings: (1) capture the keep-set worktree enumeration separately and abort on failure — the remote delete loop trusts that snapshot, so a silently-empty list could delete a checked-out branch's upstream; (2) skip the maintainer's interactive random-slug claude/-<6hex> branches (HANDS-OFF) in both loops, fail-closed; (3) wire the reap step into the portfolio-maintenance run-loop skill so the every-run duty actually runs, and sync the AGENTS.md keep-set list. Co-Authored-By: Claude Opus 4.8 (1M context) --- .claude/scripts/branch-cleanup.sh | 26 ++++++++++++++++++- .claude/skills/portfolio-maintenance/SKILL.md | 9 ++++++- AGENTS.md | 4 ++- 3 files changed, 36 insertions(+), 3 deletions(-) diff --git a/.claude/scripts/branch-cleanup.sh b/.claude/scripts/branch-cleanup.sh index 57cabc7b..d92223d2 100755 --- a/.claude/scripts/branch-cleanup.sh +++ b/.claude/scripts/branch-cleanup.sh @@ -89,9 +89,19 @@ fetch_open_heads() { # --- 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 - git worktree list --porcelain 2>/dev/null | awk '/^branch /{sub("refs/heads/","",$2); print $2}' + printf '%s\n' "$wt_branches" | awk '/^branch /{sub("refs/heads/","",$2); print $2}' printf '%s\n' "$DEFAULT" } >>"$keep" @@ -107,6 +117,18 @@ if ! gh pr list --repo "devantler-tech/$SLUG" --state all --limit 1000 \ 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/--<6hex>` (e.g. claude/unruffled-kepler-f3e922), +# marked HANDS-OFF in AGENTS.md. This routine's OWN branches are `claude/-` +# 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 @@ -115,6 +137,7 @@ l_del=0; r_del=0; l_keep=0; r_keep=0; candidates=0; r_rej=0 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 @@ -161,6 +184,7 @@ 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 diff --git a/.claude/skills/portfolio-maintenance/SKILL.md b/.claude/skills/portfolio-maintenance/SKILL.md index 6635f6e6..faccac4a 100644 --- a/.claude/skills/portfolio-maintenance/SKILL.md +++ b/.claude/skills/portfolio-maintenance/SKILL.md @@ -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 worktree remove .claude/worktrees/maint-` (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 `](../../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 diff --git a/AGENTS.md b/AGENTS.md index 91f5082c..5b980f7c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -923,7 +923,9 @@ for each repo touched — a branch still checked out by your own worktree sits i sweep run before the worktree removal silently spares the very branch the tick just spent. **🔴 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; and +- **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/--<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