diff --git a/.claude/scripts/branch-cleanup.sh b/.claude/scripts/branch-cleanup.sh new file mode 100755 index 00000000..d92223d2 --- /dev/null +++ b/.claude/scripts/branch-cleanup.sh @@ -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 + 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" +} >>"$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/--<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 + +# --- 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)); + 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)') + +# --- 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")" + 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 + 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 + 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 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 930473ed..5b980f7c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 `](.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. + +**🔴 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/--<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