Delete remote branches that belong to a merged PR. Destructive, so it runs as a dry-run first and never deletes without explicit confirmation. Delete rule A branch is deletable iff all hold — encoded in , the single source of truth: - it still exists on the remote, - it has a merged PR, - it has no open PR, - it is not protected . Protected = the default branch + . Never delete these, even if a merged PR points at them. A branch with no PR, or only a closed-unmerged PR, is not deletable (it never "belongs to a merged PR"). Extend the protected set in inside , nowhere else. Steps 1. Dry-run. R…

\\t' read -r branch number title; do\n [[ -z \"$branch\" ]] && continue\n grep -qxF \"$branch\" \u003c\u003c\u003c\"$existing\" || continue # must still exist on remote\n grep -qxF \"$branch\" \u003c\u003c\u003c\"$open_prs\" && continue # skip: has an open PR\n is_protected \"$branch\" && continue # skip: protected name\n printf '%s\\t#%s\\t%s\\n' \"$branch\" \"$number\" \"$title\"\n done \\\n| awk -F'\\t' '!seen[$1]++'\n","content_type":"application/x-sh; charset=utf-8","language":"bash","size":1551,"content_sha256":"2d3bb37e2c618d912d57e5edb01b9dd9f5f8a8c5dab76885db69924d5b53e0ca"},{"filename":"scripts/stale-claims.sh","content":"#!/usr/bin/env bash\n# Read-only planner. NEVER mutates the tracker.\n# Prints in-progress issues whose claim is STALE, one per line: \"#\u003cn>\\t\u003ctitle>\".\n#\n# A claim is stale iff ALL hold:\n# - the issue is open and labeled `in-progress`\n# - it has NO open linked PR (linked = an open PR referencing #N in its\n# title/body, OR whose head branch == the branch named in the claim comment)\n# - the `in-progress` label is older than the threshold (age = the latest\n# `labeled` timeline event for `in-progress`)\n#\n# Usage: stale-claims.sh [threshold_hours]\n# threshold_hours defaults to $STALE_CLAIM_HOURS, else 24.\nset -euo pipefail\nexport LC_ALL=C # deterministic byte-wise sort/compare for ISO-8601 timestamps\n\nlabel=\"in-progress\"\nhours=\"${1:-${STALE_CLAIM_HOURS:-24}}\"\n\n# Cutoff as ISO-8601 UTC. Same-zone ISO strings compare lexicographically ==\n# chronologically, so no per-timestamp epoch math is needed. GNU date first,\n# then BSD/macOS date.\ncutoff=\"$(date -u -d \"$hours hours ago\" +%Y-%m-%dT%H:%M:%SZ 2>/dev/null \\\n || date -u -v-\"${hours}\"H +%Y-%m-%dT%H:%M:%SZ)\"\n\n# Open PRs as \"\u003cnumber>\\t\u003cheadRef>\\t\u003ctitle+body>\" (one @tsv-escaped record/line).\nprs=\"$(gh pr list --state open --limit 1000 --json number,title,body,headRefName \\\n -q '.[] | [.number, .headRefName, (.title + \" \" + (.body // \"\"))] | @tsv')\"\n# Their head branches, one per line, computed once (not in a pipe at the match\n# site — a `cut | grep -q` there would SIGPIPE cut and, under pipefail, silently\n# read as \"no match\").\npr_heads=\"$(cut -f2 \u003c\u003c\u003c\"$prs\")\"\n\n# Open in-progress issues as \"\u003cnumber>\\t\u003ctitle>\".\nissues=\"$(gh issue list --label \"$label\" --state open --limit 1000 --json number,title \\\n -q '.[] | [.number, .title] | @tsv')\"\n\n[[ -z \"$issues\" ]] && exit 0\n\nwhile IFS=

Delete remote branches that belong to a merged PR. Destructive, so it runs as a dry-run first and never deletes without explicit confirmation. Delete rule A branch is deletable iff all hold — encoded in , the single source of truth: - it still exists on the remote, - it has a merged PR, - it has no open PR, - it is not protected . Protected = the default branch + . Never delete these, even if a merged PR points at them. A branch with no PR, or only a closed-unmerged PR, is not deletable (it never "belongs to a merged PR"). Extend the protected set in inside , nowhere else. Steps 1. Dry-run. R…

\\t' read -r n title; do\n [[ -z \"$n\" ]] && continue\n\n # Label age: newest `labeled` event for `in-progress`. Empty (no record) →\n # treat as not-yet-stale and skip, so we never reap on missing data.\n labeled_at=\"$(gh api \"repos/{owner}/{repo}/issues/$n/timeline\" --paginate \\\n -q \".[] | select(.event==\\\"labeled\\\" and .label.name==\\\"$label\\\") | .created_at\" \\\n 2>/dev/null | sort | tail -n1 || true)\"\n [[ -z \"$labeled_at\" ]] && continue\n [[ \"$labeled_at\" > \"$cutoff\" ]] && continue # claim still fresh\n\n # Linked open PR by #N reference (word-bounded, so #10 != #104)?\n if [[ -n \"$prs\" ]] && grep -qE \"(^|[^0-9])#$n([^0-9]|$)\" \u003c\u003c\u003c\"$prs\"; then\n continue\n fi\n\n # Linked open PR by the branch named in the claim comment?\n branch=\"$(gh issue view \"$n\" --json comments -q '.comments[].body' 2>/dev/null \\\n | sed -nE 's/.*in-progress[[:space:]]+—[[:space:]]+([^[:space:]]+).*/\\1/p' \\\n | tail -n1 || true)\"\n if [[ -n \"$branch\" ]] && grep -qxF \"$branch\" \u003c\u003c\u003c\"$pr_heads\"; then\n continue\n fi\n\n printf '#%s\\t%s\\n' \"$n\" \"$title\"\ndone \u003c\u003c\u003c\"$issues\"\n","content_type":"application/x-sh; charset=utf-8","language":"bash","size":2837,"content_sha256":"7a239c3a05e968d32b964f5e44f6f863d432101578b04152ede808e1995b3f8a"},{"filename":"scripts/stale-claims.test.sh","content":"#!/usr/bin/env bash\n# Fixture test for stale-claims.sh. Stubs `gh` on PATH and asserts the planner\n# prints exactly the stale claims — nothing else. Run: bash stale-claims.test.sh\n#\n# Scenarios (issue → expected):\n# 101 stale one old label, no linked PR → PRINTED\n# 102 has ref pr old label, open PR body says \"#102\" → skip (ref link)\n# 103 fresh claim label newer than threshold → skip (too fresh)\n# 104 branch linked old label, open PR head == claim branch → skip (branch link)\n# 105 early branch old label, claim branch matches an → skip (branch link)\n# EARLY line of a LARGE PR list — the\n# regression guard for the `cut | grep -q`\n# SIGPIPE-under-pipefail bug: a small or\n# last-line match does not exercise it.\n# 10 boundary old label, no link; #102/#104/#105 must → PRINTED\n# not false-match #10 (word-boundary check)\nset -euo pipefail\nhere=\"$(cd \"$(dirname \"$0\")\" && pwd)\"\ntmp=\"$(mktemp -d)\"\ntrap 'rm -rf \"$tmp\"' EXIT\n\ncat >\"$tmp/gh\" \u003c\u003c'FAKE'\n#!/usr/bin/env bash\ncmd=\"${1:-}\"; sub=\"${2:-}\"\nif [[ \"$cmd\" == pr && \"$sub\" == list ]]; then\n pad=\"$(printf 'x%.0s' {1..300})\" # fat rows so the list far\n printf '600\\tfeature/early-105\\t%s\\n' \"$pad\" # exceeds the pipe buffer and\n for i in $(seq 1 2000); do # the match sits on row 1 —\n printf '%d\\tfiller/br-%d\\t%s\\n' \"$((700+i))\" \"$i\" \"$pad\" # forces SIGPIPE\n done # on the buggy `cut | grep -q`\n printf '500\\tfeature/fix-x\\tsome title closes #102 here\\n'\n printf '501\\tfeature/x-104\\tunrelated pr body\\n'\n exit 0\nfi\nif [[ \"$cmd\" == issue && \"$sub\" == list ]]; then\n printf '101\\tstale one\\n'\n printf '102\\thas ref pr\\n'\n printf '103\\tfresh claim\\n'\n printf '104\\tbranch linked\\n'\n printf '105\\tearly branch match\\n'\n printf '10\\tboundary\\n'\n exit 0\nfi\nif [[ \"$cmd\" == api ]]; then\n n=\"$(printf '%s' \"$2\" | sed -E 's#.*/issues/([0-9]+)/timeline#\\1#')\"\n case \"$n\" in\n 103) echo \"2099-01-01T00:00:00Z\" ;; # fresh (future) → not stale\n *) echo \"2000-01-01T00:00:00Z\" ;; # ancient → past any threshold\n esac\n exit 0\nfi\nif [[ \"$cmd\" == issue && \"$sub\" == view ]]; then\n case \"$3\" in\n 101) echo \"🤖 in-progress — feature/a-101 (s1) 2000-01-01\" ;;\n 104) echo \"🤖 in-progress — feature/x-104 (s2) 2000-01-01\" ;;\n 105) echo \"🤖 in-progress — feature/early-105 (s4) 2000-01-01\" ;;\n 10) echo \"🤖 in-progress — feature/b-10 (s3) 2000-01-01\" ;;\n esac\n exit 0\nfi\necho \"fake gh: unhandled args: $*\" >&2\nexit 1\nFAKE\nchmod +x \"$tmp/gh\"\n\ngot=\"$(PATH=\"$tmp:$PATH\" bash \"$here/stale-claims.sh\")\"\nwant=\"$(printf '#101\\tstale one\\n#10\\tboundary')\"\n\nif [[ \"$got\" == \"$want\" ]]; then\n echo \"PASS stale-claims.sh\"\nelse\n echo \"FAIL stale-claims.sh\"\n echo \"--- want ---\"; printf '%s\\n' \"$want\"\n echo \"--- got ---\"; printf '%s\\n' \"$got\"\n exit 1\nfi\n","content_type":"application/x-sh; charset=utf-8","language":"bash","size":3033,"content_sha256":"2b7b5d5b2a3e43a5c63acb0f7efdc512c6b7d277e55bda47768ca87dd8c7e5c8"}],"content_json":{"type":"doc","content":[{"type":"paragraph","content":[{"text":"Delete remote branches that belong to a ","type":"text"},{"text":"merged","type":"text","marks":[{"type":"strong"}]},{"text":" PR. Destructive, so it runs as a ","type":"text"},{"text":"dry-run","type":"text","marks":[{"type":"strong"}]},{"text":" first and never deletes without explicit confirmation.","type":"text"}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"Delete rule","type":"text"}]},{"type":"paragraph","content":[{"text":"A branch is deletable iff ","type":"text"},{"text":"all","type":"text","marks":[{"type":"strong"}]},{"text":" hold — encoded in ","type":"text"},{"text":"scripts/plan.sh","type":"text","marks":[{"type":"code_inline"}]},{"text":", the single source of truth:","type":"text"}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"it still exists on the remote,","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"it has a merged PR,","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"it has no open PR,","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"it is not ","type":"text"},{"text":"protected","type":"text","marks":[{"type":"strong"}]},{"text":".","type":"text"}]}]}]},{"type":"paragraph","content":[{"text":"Protected","type":"text","marks":[{"type":"strong"}]},{"text":" = the default branch + ","type":"text"},{"text":"staging","type":"text","marks":[{"type":"code_inline"}]},{"text":". Never delete these, even if a merged PR points at them. A branch with no PR, or only a closed-unmerged PR, is not deletable (it never \"belongs to a merged PR\"). Extend the protected set in ","type":"text"},{"text":"is_protected()","type":"text","marks":[{"type":"code_inline"}]},{"text":" inside ","type":"text"},{"text":"scripts/plan.sh","type":"text","marks":[{"type":"code_inline"}]},{"text":", nowhere else.","type":"text"}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"Steps","type":"text"}]},{"type":"ordered_list","attrs":{"order":1,"listStyle":"number"},"content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Dry-run.","type":"text","marks":[{"type":"strong"}]},{"text":" Run ","type":"text"},{"text":"bash scripts/plan.sh","type":"text","marks":[{"type":"code_inline"}]},{"text":" (pass a remote name as ","type":"text"},{"text":"$1","type":"text","marks":[{"type":"code_inline"}]},{"text":" if not ","type":"text"},{"text":"origin","type":"text","marks":[{"type":"code_inline"}]},{"text":"). It prints deletable branches as ","type":"text"},{"text":"\u003cbranch> #\u003cpr> \u003ctitle>","type":"text","marks":[{"type":"code_inline"}]},{"text":", and deletes nothing. Done when you have the list. Empty output → report \"nothing to clean up\" and stop.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Show + confirm.","type":"text","marks":[{"type":"strong"}]},{"text":" Show the full list to the user and ask for explicit go. Do not delete on a vague reply — only on a clear yes. This gate is mandatory every run.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Delete.","type":"text","marks":[{"type":"strong"}]},{"text":" For each confirmed branch: ","type":"text"},{"text":"git push origin --delete \u003cbranch>","type":"text","marks":[{"type":"code_inline"}]},{"text":". Nothing outside the plan's list may be deleted.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Report.","type":"text","marks":[{"type":"strong"}]},{"text":" List what was deleted; note any push that failed.","type":"text"}]}]}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"Also: stale claims (the reaper)","type":"text"}]},{"type":"paragraph","content":[{"text":"Releases ","type":"text"},{"text":"abandoned","type":"text","marks":[{"type":"em"}]},{"text":" ","type":"text"},{"text":"in-progress","type":"text","marks":[{"type":"code_inline"}]},{"text":" claims (agent crashed / session died / no PR ever opened) so a dead claim doesn't pin an issue as \"taken\" forever. See ","type":"text"},{"text":"docs/agents/triage-labels.md","type":"text","marks":[{"type":"code_inline"}]},{"text":" for the label; ","type":"text"},{"text":"/implement","type":"text","marks":[{"type":"code_inline"}]},{"text":" sets it, PR-open removes it, this reaps the leftovers.","type":"text"}]},{"type":"paragraph","content":[{"text":"Stale rule.","type":"text","marks":[{"type":"strong"}]},{"text":" An ","type":"text"},{"text":"in-progress","type":"text","marks":[{"type":"code_inline"}]},{"text":" issue is stale iff ","type":"text"},{"text":"all","type":"text","marks":[{"type":"strong"}]},{"text":" hold — encoded in ","type":"text"},{"text":"scripts/stale-claims.sh","type":"text","marks":[{"type":"code_inline"}]},{"text":", the single source of truth:","type":"text"}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"it is open and labeled ","type":"text"},{"text":"in-progress","type":"text","marks":[{"type":"code_inline"}]},{"text":",","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"it has ","type":"text"},{"text":"no open linked PR","type":"text","marks":[{"type":"strong"}]},{"text":" (linked = an open PR referencing ","type":"text"},{"text":"#N","type":"text","marks":[{"type":"code_inline"}]},{"text":" in title/body, or whose head branch == the branch named in the claim comment), and","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"the ","type":"text"},{"text":"in-progress","type":"text","marks":[{"type":"code_inline"}]},{"text":" label is older than the threshold (latest ","type":"text"},{"text":"labeled","type":"text","marks":[{"type":"code_inline"}]},{"text":" timeline event): default ","type":"text"},{"text":"24h","type":"text","marks":[{"type":"strong"}]},{"text":", override via ","type":"text"},{"text":"$1","type":"text","marks":[{"type":"code_inline"}]},{"text":"/","type":"text"},{"text":"$STALE_CLAIM_HOURS","type":"text","marks":[{"type":"code_inline"}]},{"text":".","type":"text"}]}]}]},{"type":"paragraph","content":[{"text":"Steps","type":"text","marks":[{"type":"strong"}]},{"text":" (fold into the same run, on the same confirm gate as branch deletion):","type":"text"}]},{"type":"ordered_list","attrs":{"order":1,"listStyle":"number"},"content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Dry-run.","type":"text","marks":[{"type":"strong"}]},{"text":" Run ","type":"text"},{"text":"bash scripts/stale-claims.sh [hours]","type":"text","marks":[{"type":"code_inline"}]},{"text":". It prints stale claims as ","type":"text"},{"text":"#\u003cn> \u003ctitle>","type":"text","marks":[{"type":"code_inline"}]},{"text":" and changes nothing. Empty → nothing to reap. (Logic proof: ","type":"text"},{"text":"bash scripts/stale-claims.test.sh","type":"text","marks":[{"type":"code_inline"}]},{"text":".)","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Show + confirm.","type":"text","marks":[{"type":"strong"}]},{"text":" Show the list; reap only on explicit go. Same mandatory gate.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Reap.","type":"text","marks":[{"type":"strong"}]},{"text":" For each confirmed issue: ","type":"text"},{"text":"gh issue edit N --remove-label in-progress --add-label ready-for-agent","type":"text","marks":[{"type":"code_inline"}]},{"text":" (abandoned work re-enters the free queue). Nothing outside the printed list is touched.","type":"text"}]}]}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"Also: stale-open issues","type":"text"}]},{"type":"paragraph","content":[{"text":"Closes #N","type":"text","marks":[{"type":"code_inline"}]},{"text":" only auto-closes on merge into the ","type":"text"},{"text":"default","type":"text","marks":[{"type":"strong"}]},{"text":" branch — a PR merged into an integration branch (e.g. ","type":"text"},{"text":"rebuild/v1","type":"text","marks":[{"type":"code_inline"}]},{"text":") leaves #N open forever. For each merged PR you clean up here, check its linked issue: if the work is on the default branch but the issue is still open, hand-close it (","type":"text"},{"text":"gh issue close #N --reason completed --comment \"merged via #\u003cpr>\"","type":"text","marks":[{"type":"code_inline"}]},{"text":"). Same misfire hides \"done\" issues as open — this is where to catch them.","type":"text"}]}]},"metadata":{"date":"2026-11-02","name":"cleanup-merged-branches","author":"@skillopedia","source":{"stars":1,"repo_name":"skilly","origin_url":"https://github.com/timschoch/skilly/blob/HEAD/.agents/skills/cleanup-merged-branches/SKILL.md","repo_owner":"timschoch","body_sha256":"3cc06561ff0ce4266eac4d28edfa2a07ccf68268b6919934b17e1d9b3b8f0f08","cluster_key":"bd92c1446595a1447bc7ba676095b1a417cc3476d1cfbfcf88945a7d150cbbe4","clean_bundle":{"format":"clean-skill-bundle-v1","source":"timschoch/skilly/.agents/skills/cleanup-merged-branches/SKILL.md","text_bytes":7421,"source_commit":"f2d9853374d8ce9352bd2a4f3ea2ab24e54b8a12","skills_sh_rank":15538,"attachment_count":3,"text_attachments":3,"attachment_storage":"skill_attachments","binary_attachments":0},"cluster_size":1,"skill_md_path":".agents/skills/cleanup-merged-branches/SKILL.md","import_metadata":{"date":"2026-11-02","author":"@skillopedia","version":"v1","category":"devops-infrastructure","category_label":"DevOps"},"exact_dupes_collapsed_into_this":0},"version":"v1","category":"devops-infrastructure","import_tag":"skills-sh-top20k-2026-11","description":"Delete remote branches whose PR was merged. Read-only dry-run, then confirm, then delete.","disable-model-invocation":true}},"renderedAt":1793671888019}

Delete remote branches that belong to a merged PR. Destructive, so it runs as a dry-run first and never deletes without explicit confirmation. Delete rule A branch is deletable iff all hold — encoded in , the single source of truth: - it still exists on the remote, - it has a merged PR, - it has no open PR, - it is not protected . Protected = the default branch + . Never delete these, even if a merged PR points at them. A branch with no PR, or only a closed-unmerged PR, is not deletable (it never "belongs to a merged PR"). Extend the protected set in inside , nowhere else. Steps 1. Dry-run. R…