diff --git a/.github/scripts/test_routing_and_cleanup_surface.py b/.github/scripts/test_routing_and_cleanup_surface.py
index 904d191a..2bee9fce 100644
--- a/.github/scripts/test_routing_and_cleanup_surface.py
+++ b/.github/scripts/test_routing_and_cleanup_surface.py
@@ -154,8 +154,18 @@ def test_the_skill_carries_the_issue_308_acceptance_gates(self):
rel = f"{plugin}/{routine}/SKILL.md"
with self.subTest(rel=rel):
text = read(rel)
- # Ancestry proven against the FETCHED default, not `: gone]` state.
- self.assertRegex(text, r"(?i)ancestor of the fetched default")
+ # #586: containment proven against the FETCHED default, not bare
+ # `: gone]` state -- and NOT SHA-ancestry alone. This repo
+ # squash-merges by default, so ancestry fails for every ordinary
+ # landing; the squash case is proven instead via the merged PR
+ # record (a MERGED PR whose headRefOid equals local HEAD). Both
+ # phrases are pinned together: dropping either one -- reverting
+ # to the old ancestry-only gate, or losing the squash-proof
+ # instrument -- must fail this test.
+ self.assertRegex(text, r"(?i)contained\s+in\s+the\s+fetched\s+default")
+ self.assertRegex(text, r"(?i)PR-record\s+squash\s+proof")
+ self.assertRegex(text, r"(?i)\bMERGED\b")
+ self.assertRegex(text, r"(?i)headRefOid\s*==\s*HEAD")
# Three-way artifact classification, with uncertainty biased to unique.
for cls in ("unique", "redundant", "superseded"):
self.assertIn(cls, text.lower())
@@ -163,9 +173,14 @@ def test_the_skill_carries_the_issue_308_acceptance_gates(self):
self.assertRegex(text, r"(?i)without an explicit\s+confirmation naming that item")
# Fast-forward only; no merge commit, no rebase, no reset.
self.assertIn("--ff-only", text)
- # Safety-checked delete only, and never the remote branch.
- self.assertRegex(text, r"(?i)MUST use `git branch -d`, never `-D`")
- self.assertRegex(text, r"(?i)MUST NOT delete a remote branch")
+ # Safety-checked delete by default. `-D` is forbidden everywhere
+ # EXCEPT the #586-scoped exception -- this run's PR-record squash
+ # proof plus a restated, named confirmation -- never as a blanket
+ # allowance alongside `-d`.
+ self.assertRegex(text, r"(?i)MUST\s+use\s+`git branch -d`")
+ self.assertRegex(text, r"(?i)MAY\s+use\s+`-D`\s+ONLY\s+when")
+ self.assertRegex(text, r"(?i)Everywhere\s+else\s+`-D`\s+is\s+forbidden")
+ self.assertRegex(text, r"(?i)MUST\s+NOT\s+delete\s+a\s+remote\s+branch")
# The point of the whole exercise: this never needs an override.
self.assertRegex(text, r"(?i)MUST NOT route to `.{0,20}override.{0,20}` when blocked")
diff --git a/CHANGELOG.md b/CHANGELOG.md
index f7cca054..fd308804 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -12,6 +12,28 @@ predate the plugin rewrite and are grouped by date.
## [Unreleased]
+## [2.11.3] — 2026-08-04
+
+### Fixed
+
+- `post-merge-cleanup` (#586): Phase 1's containment proof no longer relies on
+ SHA-ancestry alone, which fails for every ordinary squash merge in this
+ repo's default merge mode. A squash merge is now proven by the merged PR
+ record (`headRefOid` == HEAD), reported alongside the ancestry check's
+ negative result as a fact rather than a failure. Phase 3's blocked-checkout
+ STOP now distinguishes a genuinely conflicting kept artifact from a stale
+ local default ref. Phase 4 fast-forwards the local default ref before
+ checking it out, so a stale ref can no longer be misattributed to a kept
+ artifact. Phase 5 documents why `git branch -d` can already accept a
+ squash-merged branch via its upstream, and sanctions `git branch -D` only
+ when this run's Phase 1 proof was the PR-record squash proof and `-d`
+ refused.
+- `standup` (#596): stale worktrees can now be confirmed for removal as one
+ explicitly enumerated group naming every member, instead of forcing one
+ confirmation per worktree; declining the group falls back to per-item
+ confirmation. Branch pruning documents the matching squash-merge `-D`
+ exception.
+
## [2.11.2] — 2026-08-04
### Fixed
diff --git a/README.md b/README.md
index 57a9ddcf..3a921ca8 100644
--- a/README.md
+++ b/README.md
@@ -10,7 +10,7 @@ project context. You decide. codeArbiter enforces.
-
+
@@ -119,7 +119,7 @@ Approve the normal plugin trust prompt, open the target repository, and continue
### Codex CLI
-The public GitHub-slug flow is **available now**. The repository currently ships `ca-codex 0.4.1`;
+The public GitHub-slug flow is **available now**. The repository currently ships `ca-codex 0.4.2`;
the dated end-to-end public-install record discovered `ca-codex 0.2.4` from release `v2.8.13`.
Current packaging and shared-core parity are continuously verified, while that dated live-install
record stays labeled rather than being silently promoted to evidence for a newer adapter:
diff --git a/core/surface/commands/cleanup.md b/core/surface/commands/cleanup.md
index 0a8f11da..4b18e2bd 100644
--- a/core/surface/commands/cleanup.md
+++ b/core/surface/commands/cleanup.md
@@ -1,5 +1,5 @@
---
-description: Finish an already-merged branch — classify the leftover artifacts, return to a fast-forwarded default checkout, and delete the merged local branch. Every discard confirmed per item; ancestry proven, never assumed.
+description: Finish an already-merged branch — classify the leftover artifacts, return to a fast-forwarded default checkout, and delete the merged local branch. Every discard confirmed per item; containment proven, never assumed.
argument-hint: (none)
---
@@ -20,7 +20,7 @@ coverage. This is that coverage.
## Routes to
The `post-merge-cleanup` skill (`{{PLUGIN_ROOT}}/skills/post-merge-cleanup/SKILL.md`),
-which owns the ancestry proof, the artifact classification, and the per-item
+which owns the containment proof, the artifact classification, and the per-item
confirmations.
## When NOT to use
@@ -35,9 +35,10 @@ confirmations.
## Hard gate
-- MUST prove the current branch is an ancestor of the **fetched** default branch
- before anything is deleted. An unproven or unfetched ancestry STOPs — a branch
- that only *looks* merged is not merged.
+- MUST prove the current branch is contained in the **fetched** default branch —
+ SHA-ancestry, or a squash-merge proven by the merged PR record (`headRefOid`
+ == HEAD) — before anything is deleted. An unproven or unfetched comparison
+ STOPs — a branch that only *looks* merged is not merged.
- MUST classify every dirty or untracked artifact as **unique**, **redundant**,
or **superseded**, and MUST NOT discard a unique or unclassifiable one without
explicit per-item confirmation naming it.
@@ -45,7 +46,9 @@ confirmations.
- MUST reach the default branch with a clean working tree, and MUST fast-forward
with `--ff-only` only — never a merge commit, never a rebase, never a reset
that discards work.
-- MUST NOT force-delete a branch, force-push, delete a remote branch, or write to
- the default branch.
+- MUST use `git branch -d`, and MAY use `-D` only when the squash-merge PR-record
+ proof held this run and `-d` refused, with that proof restated and the branch
+ named in the confirmation. MUST NOT otherwise force-delete a branch, force-push,
+ delete a remote branch, or write to the default branch.
- MUST NOT require `{{CMD:override}}`. This is ordinary lifecycle work; if it
cannot proceed, the reason is a stated gate, not a bypass.
diff --git a/core/surface/commands/standup.md b/core/surface/commands/standup.md
index 02225be5..711bd808 100644
--- a/core/surface/commands/standup.md
+++ b/core/surface/commands/standup.md
@@ -15,7 +15,10 @@ Arbiter gathers and proposes; you decide every mutation.
The orchestrator reads the current repo state (reusing the briefing's read-only
computation — branch, ahead/behind, dirty tree, stashes, prune-candidate branches,
stale worktrees) and presents it, then offers each applicable action in turn. Skip
-an action that has no candidates; never bundle confirmations.
+an action that has no candidates; never bundle confirmations across different
+actions or items — branch deletions always stay per-item, and the sole exception
+is step 3's explicitly enumerated worktree group, which is still one confirmation
+that names every member, never an implied yes.
1. **Fetch + fast-forward pull** — kick `git fetch`, then offer a **`--ff-only`**
pull of the current branch. Eligibility is the briefing summary's
@@ -28,9 +31,21 @@ an action that has no candidates; never bundle confirmations.
(the `: gone]` upstream set), excluding the current branch and the default
(`main`). Delete a listed branch only after an explicit per-branch confirmation;
declining leaves it in place.
+
+ A `: gone]` branch that was squash-merged will typically **refuse** plain
+ `git branch -d` — its upstream is already pruned, so `-d` has nothing to test
+ reachability against. Before reporting that as a stop, check for a merged PR
+ record: `gh pr list --head --state merged --json headRefOid`. When a
+ `MERGED` PR's `headRefOid` equals that branch's local tip, containment is
+ proven and `git branch -D` is permitted — with the proof stated and the
+ branch named in the confirmation — mirroring the `post-merge-cleanup` Phase 5
+ contract. Without that proof, a refusal stays a report-and-stop; never guess.
3. **Remove stale worktrees** — list stale/merged worktrees (branch gone-or-merged,
- or path missing on disk), never the main worktree. Remove one only after explicit
- per-item confirmation; declining leaves it intact.
+ or path missing on disk), never the main worktree. Present the full stale list
+ together with each item's evidence, then offer removal as an explicitly
+ **enumerated group** — one confirmation that names every member — as well as
+ per-item confirmation for anyone who wants to keep some. Declining the group
+ falls back to per-item confirmation, and declining any item leaves it intact.
4. **Surface stashes / dirty / un-pushed** — list stashes, uncommitted changes, and
un-pushed commits, each with a suggested next step (`{{CMD:commit}}`, `git push`,
`git stash show`). Report-and-route only: never discard a stash, reset, or push.
@@ -84,8 +99,11 @@ Present a one-line summary of what was done and what was declined.
commit, never on a dirty tree, never a rebase.
- MUST exclude the current branch and the default branch from branch pruning, and
the main worktree from worktree cleanup.
-- MUST confirm each destructive action (branch delete, worktree remove)
- individually before performing it — no batched or implied yes.
+- MUST confirm branch deletions individually — no batched or implied yes.
+ Worktree removals MAY be confirmed as one explicitly enumerated group (naming
+ every member) or individually; either way there is no implied yes, and
+ declining the group falls back to per-item confirmation rather than removing
+ anything.
- MUST treat stash / dirty / un-pushed state as report-and-route only — never
discard, reset, force, or push on the user's behalf.
- MUST NOT write to or force-push the default branch.
diff --git a/core/surface/skills/INDEX.md b/core/surface/skills/INDEX.md
index c132b5e3..730cdcd8 100644
--- a/core/surface/skills/INDEX.md
+++ b/core/surface/skills/INDEX.md
@@ -28,5 +28,5 @@ Skill bodies load on routing only. This index is the surface scan; never bulk-re
| [release](release/SKILL.md) | `/release` | Lean SemVer release, three phases: derive the bump from Conventional-Commits since the last tag + roll `CHANGELOG.md`; cut the annotated tag + report; publish (push tag + `gh release create`) on explicit authorization. No tag on a red suite; no direct-to-main/force-push; nothing published unbidden; a release commit routes through `commit-gate`. |
| [security-architecture](security-architecture/SKILL.md) | `/threat-model` (optional) | OPTIONAL lightweight STRIDE pass for sensitive features: attack surface → STRIDE → findings, governed by `security-controls.md`. MAY dispatch `security-reviewer` / `auth-crypto-reviewer`. Not a routine gate; hard-STOPs only on a critical unmitigated threat. |
| [context-check](context-check/SKILL.md) | `/context-check` | OPTIONAL manual drift audit: load `.codearbiter/.provenance/` via `load_provenance_dir` + `compute_drift`, report stale docs, then per stale doc: re-scout / re-baseline (`rebaseline`) / defer. Not the daily loop — commit-gate auto-heal owns routine maintenance. |
-| [post-merge-cleanup](post-merge-cleanup/SKILL.md) | `{{CMD:cleanup}}` | The already-merged branch transition: fetch, prove `HEAD` is an ancestor of the fetched default, classify every dirty/untracked artifact as unique/redundant/superseded, resolve each under its own confirmation, `--ff-only` onto the default branch, then `branch -d` the merged local branch. Unique or unclassifiable artifacts are never discarded unbidden; the remote branch is never touched; never routes to `{{CMD:override}}`. |
+| [post-merge-cleanup](post-merge-cleanup/SKILL.md) | `{{CMD:cleanup}}` | The already-merged branch transition: fetch, prove `HEAD` is contained in the fetched default (SHA-ancestry, or a squash-merge proven by the merged PR record), classify every dirty/untracked artifact as unique/redundant/superseded, resolve each under its own confirmation, fast-forward the local default ref then `--ff-only` onto it, then `branch -d` the merged local branch. Unique or unclassifiable artifacts are never discarded unbidden; the remote branch is never touched; never routes to `{{CMD:override}}`. |
| [tribunal](tribunal/SKILL.md) | `{{CMD:tribunal}}` | The deep, rarely-convened whole-codebase audit lane: seven gated phases across eleven specialist lenses, each finding persisted to its own file (plus append-only triage/run logs) under `.codearbiter/reports//`, resumable from disk, filing approved findings as GitHub issues; never a required gate. |
diff --git a/core/surface/skills/post-merge-cleanup/SKILL.md b/core/surface/skills/post-merge-cleanup/SKILL.md
index 9dc22fa8..1d9d8483 100644
--- a/core/surface/skills/post-merge-cleanup/SKILL.md
+++ b/core/surface/skills/post-merge-cleanup/SKILL.md
@@ -1,6 +1,6 @@
---
name: post-merge-cleanup
-description: Finish an already-merged branch. Proves the branch is an ancestor of the fetched default, classifies leftover artifacts as unique / redundant / superseded, returns to a clean --ff-only default checkout, and deletes the merged local branch — every discard confirmed per item. Routed to by {{CMD:cleanup}}.
+description: Finish an already-merged branch. Proves the branch is contained in the fetched default, classifies leftover artifacts as unique / redundant / superseded, returns to a clean --ff-only default checkout, and deletes the merged local branch — every discard confirmed per item. Routed to by {{CMD:cleanup}}.
---
# post-merge-cleanup
@@ -31,23 +31,51 @@ dirty state read-only and exit; branch pruning across *other* branches belongs t
## Phase 1 — Prove the merge · gate: BLOCK
A branch that looks merged is not merged. Establish it against the network, not
-against a stale local ref:
+against a stale local ref. This repo squash-merges by default, so SHA-ancestry
+alone is the wrong instrument for most landings: it holds for a fast-forward or
+a merge-commit landing, but a squash merge writes a new commit with no SHA
+lineage back to the branch, and `--is-ancestor` will report non-zero for a
+branch that landed cleanly. The gate is **content-containment**, proven by
+whichever instrument fits the landing, always reported as a fact:
1. `git fetch` the remote holding the default branch. A fetch that fails STOPs —
an unfetched comparison proves nothing.
-2. Confirm the current branch is an **ancestor** of the fetched default
- (`git merge-base --is-ancestor HEAD origin/`). This is the test that
- matters, and it is deliberately not `: gone]` upstream state: a squash-merged
- branch whose remote still exists is fully contained in the default branch and
- is safe to delete, while a `: gone]` branch whose commits never landed is not.
-3. Report the proof as a fact — the default branch, the fetched SHA, and that
- HEAD is contained in it.
-
-If HEAD is **not** an ancestor, STOP. Name the un-landed commits and route to
-`{{CMD:pr}}`; nothing is deleted here.
-
-Gate: the remote is fetched and HEAD is proven an ancestor of the fetched default
-branch — or the skill has stopped.
+2. Prove containment with two primary instruments plus a fallback, tried in
+ order:
+ 1. **Ancestry** — `git merge-base --is-ancestor HEAD origin/`. Holds
+ for a fast-forward or merge-commit landing. If it holds, that is the
+ proof: report it and move on.
+ 2. **Squash-merge proof via the PR record** — if ancestry fails, run
+ `gh pr list --head "$(git branch --show-current)" --state merged --json number,state,headRefOid,mergeCommit`.
+ A `MERGED` PR whose `headRefOid` equals local `git rev-parse HEAD` proves
+ every commit on this branch rode that PR's squash into the default
+ branch. Report the PR number, that `headRefOid == HEAD`, the merge
+ commit (`mergeCommit.oid` in that JSON), **and** the ancestry check's
+ negative result — all as facts, not as a failure. Corroborate with
+ `git diff --quiet origin/ HEAD` when it happens to hold; a
+ **non-empty** diff alongside a valid PR proof is normal (the default
+ branch advanced since the merge landed) and is reported as a fact, never
+ treated as a failure. The PR-record identity — `MERGED` plus
+ `headRefOid == HEAD` — is the load-bearing proof; the diff is
+ corroboration only when it happens to be fresh, never a requirement.
+ 3. **Fallback when `gh` is unavailable or no PR record exists** —
+ `git diff --quiet origin/ HEAD` (the tree is byte-identical to
+ the fetched default) is an acceptable fallback proof. Report it as such.
+3. Report whichever instrument held as a fact — the default branch, the fetched
+ SHA, and which of the three proofs established containment.
+
+If none of the three hold, STOP exactly as today. Name the un-landed commits
+and route to `{{CMD:pr}}`; nothing is deleted here.
+
+> Deliberate deviation from issue #586's suggested contract: that suggestion
+> required the squash-merge proof's diff to be empty. That requirement
+> re-breaks this gate the moment any later PR merges to the default branch —
+> the common state, not an edge case. The PR-record identity is what proves
+> containment; the diff is corroboration, demoted from requirement to fact.
+
+Gate: the remote is fetched and HEAD is proven contained in the fetched default
+branch — by ancestry, by the PR-record squash proof, or by the byte-identical
+fallback — or the skill has stopped.
## Phase 2 — Classify the residue · gate: BLOCK
@@ -84,42 +112,94 @@ A stash is never dropped here. Stashes are reported with `git stash show` as the
suggested next step, the same report-and-route contract `{{CMD:standup}}` holds.
If anything the user chose to keep would block the checkout, STOP and say so
-rather than removing it anyway. A blocked checkout is a fine outcome; a silent
-discard is not.
+rather than removing it anyway — but distinguish the two causes before naming
+the artifact as the blocker:
+
+- **The kept artifact genuinely conflicts** — its content collides with what
+ checking out the default branch would need to change or remove. This is the
+ user's to resolve, as today.
+- **The local default ref lags the fetched one** — git refuses a checkout when
+ a locally-modified tracked file differs between HEAD and the target ref, and
+ a stale local `` makes that difference larger than reality: content
+ the kept artifact never actually touches can still collide with what a
+ *current* default branch would carry. That gap is not the user's problem and
+ not the kept artifact's fault. It is resolved by Phase 4's fast-forward-first
+ step, not by discarding anything here. Issue #586 recorded the observed
+ failure mode: a stale local `main` — 513 lines behind on
+ `.codearbiter/gate-events.log` alone — made this STOP name a correctly-kept
+ 38-line artifact as the blocker, which invites exactly the silent discard
+ this phase exists to prevent.
Gate: every item is resolved by an explicit per-item decision, and the working
tree is clean enough to check out the default branch — or the skill has stopped
-with the blocker named.
+with the blocker named, correctly attributed to a genuine conflict or a stale
+local ref.
## Phase 4 — Transition · gate: BLOCK
Only after Phase 3 leaves the tree safe:
-1. Check out the default branch, then **verify the checkout actually happened**
+1. **Fast-forward the local default ref BEFORE checking it out:**
+ `git fetch origin :`. Git refuses a non-fast-forward
+ update of a ref that is not currently checked out, so the `--ff-only`
+ guarantee is preserved by the instrument itself — this cannot silently
+ rewrite the local default ref, only advance it or refuse. This is what
+ keeps a stale local default from blocking (or worse, misattributing) the
+ checkout below. This step can itself be refused for the same reason a
+ checkout can be — `` checked out in another worktree — in which
+ case report that as the (correctly attributed) blocker, per Phase 3, and
+ stop; do not treat the refusal as license to skip ahead.
+2. Check out the default branch, then **verify the checkout actually happened**
(`git branch --show-current`). A checkout silently fails when another worktree
holds the branch, and every step after this one would otherwise run against
the wrong branch.
-2. Fast-forward with `--ff-only`. A divergence means the default branch moved in
- a way this skill will not reconcile: report it and stop. Never a merge commit,
- never a rebase, never a reset.
+3. Fast-forward with `--ff-only` (belt over the braces of step 1 — a no-op when
+ the pre-checkout fetch already brought the local ref current). A divergence
+ means the default branch moved in a way this skill will not reconcile:
+ report it and stop. Never a merge commit, never a rebase, never a reset.
-Gate: HEAD is confirmed on the default branch by re-read, and the fast-forward
-either succeeded or was reported as a refused divergence.
+Gate: the local default ref was fast-forwarded before the checkout — or the
+fast-forward's refusal was itself reported as a fact and the skill stopped —
+HEAD is confirmed on the default branch by re-read, and the belt-and-braces
+`--ff-only` pull either succeeded (typically a no-op) or was reported as a
+refused divergence.
## Phase 5 — Delete the merged local branch · gate: STOP
-Offer deletion of the now-merged local branch, with the Phase 1 ancestry proof
-restated. One confirmation, naming the branch.
-
-- `git branch -d` only. Never `-D`: the safety check is the point, and if `-d`
- refuses, the ancestry proof and the refusal disagree — report both and stop.
+Offer deletion of the now-merged local branch, with the Phase 1 containment
+proof restated. One confirmation, naming the branch.
+
+- `git branch -d` first, always. Never reach for `-D` on the assumption that a
+ refusal means `-d` can't handle a squash merge — it often can. `git branch
+ -d`'s safety check accepts a branch merged into its **upstream**, not only
+ into HEAD: with an upstream still configured and equal to the tip, `-d`
+ succeeds (with a warning) even for a squash merge that `--is-ancestor` alone
+ would call unmerged. Do not conclude a refusal here means `-D` is required —
+ check the reason first.
+- **The sanctioned `-D` path.** `-d` typically refuses once the remote branch
+ has been auto-deleted and pruned, because there is then no upstream left for
+ `-d` to test reachability against. When that happens, `git branch -D` is
+ permitted, but ONLY when both hold:
+ 1. Phase 1's proof, established this run, was the PR-record squash proof
+ (`MERGED` and `headRefOid == HEAD`) — not a bare ancestry pass, not a
+ stale or assumed proof.
+ 2. The confirmation restates that Phase-1 proof and explicitly names the
+ branch.
+ Frame this precisely: `-d`'s safety check tests SHA-reachability, and the
+ squash proof has already shown that instrument is the wrong one for this
+ repo's merge mode. The PR-record proof plus a named confirmation **replaces**
+ the check that `-d` can no longer run — it does not bypass it. Everywhere
+ else, `-D` stays forbidden exactly as before: if `-d` refuses and Phase 1's
+ proof was anything other than the PR-record squash proof, report both and
+ stop.
- The **remote** branch is never touched. If the user wants it gone, that is
theirs to do or the platform's auto-delete-on-merge to do.
Declining leaves the branch in place. That is a normal outcome, not a failure.
Gate: the branch is deleted only after an explicit confirmation naming it, via
-`-d`, with the remote untouched.
+`-d`, or via `-D` restricted to the sanctioned path above with the proof
+restated, with the remote untouched.
## Phase 6 — Receipt
@@ -129,18 +209,25 @@ correct outcome reported plainly, not an error.
## Hard rules
-- MUST fetch and prove `HEAD` is an ancestor of the fetched default branch before
- any deletion. MUST NOT infer merge state from a `: gone]` upstream alone.
+- MUST fetch and prove `HEAD` is **contained** in the fetched default branch
+ before any deletion — via ancestry, via the PR-record squash proof (`MERGED`
+ and `headRefOid == HEAD`), or, without `gh`, via a byte-identical tree diff.
+ MUST NOT infer merge state from a `: gone]` upstream alone.
- MUST classify every artifact, and MUST treat anything not provably redundant or
superseded as unique.
- MUST NOT discard a unique or unclassifiable artifact without an explicit
confirmation naming that item.
- MUST confirm every removal and the branch deletion individually — no batched or
implied yes.
-- MUST re-read the current branch after checkout before acting on it.
+- MUST fast-forward the local default ref (`git fetch origin :`)
+ before checking it out, or report the fast-forward's own refusal (e.g.
+ `` checked out in another worktree) as the blocker and stop. MUST
+ re-read the current branch after checkout before acting on it.
- MUST use `--ff-only`, and MUST NOT merge, rebase, or reset to reach the default
branch.
-- MUST use `git branch -d`, never `-D`; MUST NOT delete a remote branch,
- force-push, or write to the default branch.
+- MUST use `git branch -d`, and MAY use `-D` ONLY when the Phase-1 squash-merge
+ proof held this run and `-d` refused, with that proof restated and the branch
+ named in the confirmation. Everywhere else `-D` is forbidden. MUST NOT delete a
+ remote branch, force-push, or write to the default branch.
- MUST NOT drop a stash — report and route, as `{{CMD:standup}}` does.
- MUST NOT route to `{{CMD:override}}` when blocked. Name the gate instead.
diff --git a/package.json b/package.json
index 8172951b..a9c2e9db 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "ca-pi",
- "version": "0.2.1",
+ "version": "0.2.2",
"private": true,
"license": "AGPL-3.0-only",
"engines": {
diff --git a/plugins/ca-codex/.codex-plugin/plugin.json b/plugins/ca-codex/.codex-plugin/plugin.json
index f81d8b19..10d67034 100644
--- a/plugins/ca-codex/.codex-plugin/plugin.json
+++ b/plugins/ca-codex/.codex-plugin/plugin.json
@@ -1,7 +1,7 @@
{
"name": "ca-codex",
"description": "Governance kernel for OpenAI Codex CLI: the full codeArbiter surface — 37 ca-prefixed governance skills (spec-driven /feature pipeline, nine-gate commit gate, ADRs, audits) plus enforcement hooks (persona injection, blocking pre-exec and pre-write gates, append-only audit trail) — sharing one .codearbiter/ store with the Claude Code sibling plugin. Standalone: opt a repo in with ca-init; enforcement stays dormant until .codearbiter/CONTEXT.md carries 'arbiter: enabled'. Requires Python 3 and Codex >= 0.143.0. CI continuously verifies, through a real Codex host at 0.143.0 and 0.145.0, that the plugin installs, reads back enabled, and ships every hook script it declares; an advisory lane tracks npm latest for upstream drift. Hook FIRING - live persona injection and live blocks inside a turn - is verified by hand per release against docs/codex-parity-testing.md, because a turn needs a model and a provider credential cannot gate fork pull requests.",
- "version": "0.4.1",
+ "version": "0.4.2",
"author": {
"name": "arbiterForge"
},
diff --git a/plugins/ca-codex/routines/INDEX.md b/plugins/ca-codex/routines/INDEX.md
index 7b589016..ee207129 100644
--- a/plugins/ca-codex/routines/INDEX.md
+++ b/plugins/ca-codex/routines/INDEX.md
@@ -28,5 +28,5 @@ Skill bodies load on routing only. This index is the surface scan; never bulk-re
| [release](release/SKILL.md) | `/release` | Lean SemVer release, three phases: derive the bump from Conventional-Commits since the last tag + roll `CHANGELOG.md`; cut the annotated tag + report; publish (push tag + `gh release create`) on explicit authorization. No tag on a red suite; no direct-to-main/force-push; nothing published unbidden; a release commit routes through `commit-gate`. |
| [security-architecture](security-architecture/SKILL.md) | `/threat-model` (optional) | OPTIONAL lightweight STRIDE pass for sensitive features: attack surface → STRIDE → findings, governed by `security-controls.md`. MAY dispatch `security-reviewer` / `auth-crypto-reviewer`. Not a routine gate; hard-STOPs only on a critical unmitigated threat. |
| [context-check](context-check/SKILL.md) | `/context-check` | OPTIONAL manual drift audit: load `.codearbiter/.provenance/` via `load_provenance_dir` + `compute_drift`, report stale docs, then per stale doc: re-scout / re-baseline (`rebaseline`) / defer. Not the daily loop — commit-gate auto-heal owns routine maintenance. |
-| [post-merge-cleanup](post-merge-cleanup/SKILL.md) | `$ca-cleanup` | The already-merged branch transition: fetch, prove `HEAD` is an ancestor of the fetched default, classify every dirty/untracked artifact as unique/redundant/superseded, resolve each under its own confirmation, `--ff-only` onto the default branch, then `branch -d` the merged local branch. Unique or unclassifiable artifacts are never discarded unbidden; the remote branch is never touched; never routes to `$ca-override`. |
+| [post-merge-cleanup](post-merge-cleanup/SKILL.md) | `$ca-cleanup` | The already-merged branch transition: fetch, prove `HEAD` is contained in the fetched default (SHA-ancestry, or a squash-merge proven by the merged PR record), classify every dirty/untracked artifact as unique/redundant/superseded, resolve each under its own confirmation, fast-forward the local default ref then `--ff-only` onto it, then `branch -d` the merged local branch. Unique or unclassifiable artifacts are never discarded unbidden; the remote branch is never touched; never routes to `$ca-override`. |
| [tribunal](tribunal/SKILL.md) | `$ca-tribunal` | The deep, rarely-convened whole-codebase audit lane: seven gated phases across eleven specialist lenses, each finding persisted to its own file (plus append-only triage/run logs) under `.codearbiter/reports//`, resumable from disk, filing approved findings as GitHub issues; never a required gate. |
diff --git a/plugins/ca-codex/routines/post-merge-cleanup/SKILL.md b/plugins/ca-codex/routines/post-merge-cleanup/SKILL.md
index 25a6d45e..82b4e128 100644
--- a/plugins/ca-codex/routines/post-merge-cleanup/SKILL.md
+++ b/plugins/ca-codex/routines/post-merge-cleanup/SKILL.md
@@ -1,6 +1,6 @@
---
name: post-merge-cleanup
-description: Finish an already-merged branch. Proves the branch is an ancestor of the fetched default, classifies leftover artifacts as unique / redundant / superseded, returns to a clean --ff-only default checkout, and deletes the merged local branch — every discard confirmed per item. Routed to by $ca-cleanup.
+description: Finish an already-merged branch. Proves the branch is contained in the fetched default, classifies leftover artifacts as unique / redundant / superseded, returns to a clean --ff-only default checkout, and deletes the merged local branch — every discard confirmed per item. Routed to by $ca-cleanup.
---
# post-merge-cleanup
@@ -31,23 +31,51 @@ dirty state read-only and exit; branch pruning across *other* branches belongs t
## Phase 1 — Prove the merge · gate: BLOCK
A branch that looks merged is not merged. Establish it against the network, not
-against a stale local ref:
+against a stale local ref. This repo squash-merges by default, so SHA-ancestry
+alone is the wrong instrument for most landings: it holds for a fast-forward or
+a merge-commit landing, but a squash merge writes a new commit with no SHA
+lineage back to the branch, and `--is-ancestor` will report non-zero for a
+branch that landed cleanly. The gate is **content-containment**, proven by
+whichever instrument fits the landing, always reported as a fact:
1. `git fetch` the remote holding the default branch. A fetch that fails STOPs —
an unfetched comparison proves nothing.
-2. Confirm the current branch is an **ancestor** of the fetched default
- (`git merge-base --is-ancestor HEAD origin/`). This is the test that
- matters, and it is deliberately not `: gone]` upstream state: a squash-merged
- branch whose remote still exists is fully contained in the default branch and
- is safe to delete, while a `: gone]` branch whose commits never landed is not.
-3. Report the proof as a fact — the default branch, the fetched SHA, and that
- HEAD is contained in it.
-
-If HEAD is **not** an ancestor, STOP. Name the un-landed commits and route to
-`$ca-pr`; nothing is deleted here.
-
-Gate: the remote is fetched and HEAD is proven an ancestor of the fetched default
-branch — or the skill has stopped.
+2. Prove containment with two primary instruments plus a fallback, tried in
+ order:
+ 1. **Ancestry** — `git merge-base --is-ancestor HEAD origin/`. Holds
+ for a fast-forward or merge-commit landing. If it holds, that is the
+ proof: report it and move on.
+ 2. **Squash-merge proof via the PR record** — if ancestry fails, run
+ `gh pr list --head "$(git branch --show-current)" --state merged --json number,state,headRefOid,mergeCommit`.
+ A `MERGED` PR whose `headRefOid` equals local `git rev-parse HEAD` proves
+ every commit on this branch rode that PR's squash into the default
+ branch. Report the PR number, that `headRefOid == HEAD`, the merge
+ commit (`mergeCommit.oid` in that JSON), **and** the ancestry check's
+ negative result — all as facts, not as a failure. Corroborate with
+ `git diff --quiet origin/ HEAD` when it happens to hold; a
+ **non-empty** diff alongside a valid PR proof is normal (the default
+ branch advanced since the merge landed) and is reported as a fact, never
+ treated as a failure. The PR-record identity — `MERGED` plus
+ `headRefOid == HEAD` — is the load-bearing proof; the diff is
+ corroboration only when it happens to be fresh, never a requirement.
+ 3. **Fallback when `gh` is unavailable or no PR record exists** —
+ `git diff --quiet origin/ HEAD` (the tree is byte-identical to
+ the fetched default) is an acceptable fallback proof. Report it as such.
+3. Report whichever instrument held as a fact — the default branch, the fetched
+ SHA, and which of the three proofs established containment.
+
+If none of the three hold, STOP exactly as today. Name the un-landed commits
+and route to `$ca-pr`; nothing is deleted here.
+
+> Deliberate deviation from issue #586's suggested contract: that suggestion
+> required the squash-merge proof's diff to be empty. That requirement
+> re-breaks this gate the moment any later PR merges to the default branch —
+> the common state, not an edge case. The PR-record identity is what proves
+> containment; the diff is corroboration, demoted from requirement to fact.
+
+Gate: the remote is fetched and HEAD is proven contained in the fetched default
+branch — by ancestry, by the PR-record squash proof, or by the byte-identical
+fallback — or the skill has stopped.
## Phase 2 — Classify the residue · gate: BLOCK
@@ -84,42 +112,94 @@ A stash is never dropped here. Stashes are reported with `git stash show` as the
suggested next step, the same report-and-route contract `$ca-standup` holds.
If anything the user chose to keep would block the checkout, STOP and say so
-rather than removing it anyway. A blocked checkout is a fine outcome; a silent
-discard is not.
+rather than removing it anyway — but distinguish the two causes before naming
+the artifact as the blocker:
+
+- **The kept artifact genuinely conflicts** — its content collides with what
+ checking out the default branch would need to change or remove. This is the
+ user's to resolve, as today.
+- **The local default ref lags the fetched one** — git refuses a checkout when
+ a locally-modified tracked file differs between HEAD and the target ref, and
+ a stale local `` makes that difference larger than reality: content
+ the kept artifact never actually touches can still collide with what a
+ *current* default branch would carry. That gap is not the user's problem and
+ not the kept artifact's fault. It is resolved by Phase 4's fast-forward-first
+ step, not by discarding anything here. Issue #586 recorded the observed
+ failure mode: a stale local `main` — 513 lines behind on
+ `.codearbiter/gate-events.log` alone — made this STOP name a correctly-kept
+ 38-line artifact as the blocker, which invites exactly the silent discard
+ this phase exists to prevent.
Gate: every item is resolved by an explicit per-item decision, and the working
tree is clean enough to check out the default branch — or the skill has stopped
-with the blocker named.
+with the blocker named, correctly attributed to a genuine conflict or a stale
+local ref.
## Phase 4 — Transition · gate: BLOCK
Only after Phase 3 leaves the tree safe:
-1. Check out the default branch, then **verify the checkout actually happened**
+1. **Fast-forward the local default ref BEFORE checking it out:**
+ `git fetch origin :`. Git refuses a non-fast-forward
+ update of a ref that is not currently checked out, so the `--ff-only`
+ guarantee is preserved by the instrument itself — this cannot silently
+ rewrite the local default ref, only advance it or refuse. This is what
+ keeps a stale local default from blocking (or worse, misattributing) the
+ checkout below. This step can itself be refused for the same reason a
+ checkout can be — `` checked out in another worktree — in which
+ case report that as the (correctly attributed) blocker, per Phase 3, and
+ stop; do not treat the refusal as license to skip ahead.
+2. Check out the default branch, then **verify the checkout actually happened**
(`git branch --show-current`). A checkout silently fails when another worktree
holds the branch, and every step after this one would otherwise run against
the wrong branch.
-2. Fast-forward with `--ff-only`. A divergence means the default branch moved in
- a way this skill will not reconcile: report it and stop. Never a merge commit,
- never a rebase, never a reset.
+3. Fast-forward with `--ff-only` (belt over the braces of step 1 — a no-op when
+ the pre-checkout fetch already brought the local ref current). A divergence
+ means the default branch moved in a way this skill will not reconcile:
+ report it and stop. Never a merge commit, never a rebase, never a reset.
-Gate: HEAD is confirmed on the default branch by re-read, and the fast-forward
-either succeeded or was reported as a refused divergence.
+Gate: the local default ref was fast-forwarded before the checkout — or the
+fast-forward's refusal was itself reported as a fact and the skill stopped —
+HEAD is confirmed on the default branch by re-read, and the belt-and-braces
+`--ff-only` pull either succeeded (typically a no-op) or was reported as a
+refused divergence.
## Phase 5 — Delete the merged local branch · gate: STOP
-Offer deletion of the now-merged local branch, with the Phase 1 ancestry proof
-restated. One confirmation, naming the branch.
-
-- `git branch -d` only. Never `-D`: the safety check is the point, and if `-d`
- refuses, the ancestry proof and the refusal disagree — report both and stop.
+Offer deletion of the now-merged local branch, with the Phase 1 containment
+proof restated. One confirmation, naming the branch.
+
+- `git branch -d` first, always. Never reach for `-D` on the assumption that a
+ refusal means `-d` can't handle a squash merge — it often can. `git branch
+ -d`'s safety check accepts a branch merged into its **upstream**, not only
+ into HEAD: with an upstream still configured and equal to the tip, `-d`
+ succeeds (with a warning) even for a squash merge that `--is-ancestor` alone
+ would call unmerged. Do not conclude a refusal here means `-D` is required —
+ check the reason first.
+- **The sanctioned `-D` path.** `-d` typically refuses once the remote branch
+ has been auto-deleted and pruned, because there is then no upstream left for
+ `-d` to test reachability against. When that happens, `git branch -D` is
+ permitted, but ONLY when both hold:
+ 1. Phase 1's proof, established this run, was the PR-record squash proof
+ (`MERGED` and `headRefOid == HEAD`) — not a bare ancestry pass, not a
+ stale or assumed proof.
+ 2. The confirmation restates that Phase-1 proof and explicitly names the
+ branch.
+ Frame this precisely: `-d`'s safety check tests SHA-reachability, and the
+ squash proof has already shown that instrument is the wrong one for this
+ repo's merge mode. The PR-record proof plus a named confirmation **replaces**
+ the check that `-d` can no longer run — it does not bypass it. Everywhere
+ else, `-D` stays forbidden exactly as before: if `-d` refuses and Phase 1's
+ proof was anything other than the PR-record squash proof, report both and
+ stop.
- The **remote** branch is never touched. If the user wants it gone, that is
theirs to do or the platform's auto-delete-on-merge to do.
Declining leaves the branch in place. That is a normal outcome, not a failure.
Gate: the branch is deleted only after an explicit confirmation naming it, via
-`-d`, with the remote untouched.
+`-d`, or via `-D` restricted to the sanctioned path above with the proof
+restated, with the remote untouched.
## Phase 6 — Receipt
@@ -129,18 +209,25 @@ correct outcome reported plainly, not an error.
## Hard rules
-- MUST fetch and prove `HEAD` is an ancestor of the fetched default branch before
- any deletion. MUST NOT infer merge state from a `: gone]` upstream alone.
+- MUST fetch and prove `HEAD` is **contained** in the fetched default branch
+ before any deletion — via ancestry, via the PR-record squash proof (`MERGED`
+ and `headRefOid == HEAD`), or, without `gh`, via a byte-identical tree diff.
+ MUST NOT infer merge state from a `: gone]` upstream alone.
- MUST classify every artifact, and MUST treat anything not provably redundant or
superseded as unique.
- MUST NOT discard a unique or unclassifiable artifact without an explicit
confirmation naming that item.
- MUST confirm every removal and the branch deletion individually — no batched or
implied yes.
-- MUST re-read the current branch after checkout before acting on it.
+- MUST fast-forward the local default ref (`git fetch origin :`)
+ before checking it out, or report the fast-forward's own refusal (e.g.
+ `` checked out in another worktree) as the blocker and stop. MUST
+ re-read the current branch after checkout before acting on it.
- MUST use `--ff-only`, and MUST NOT merge, rebase, or reset to reach the default
branch.
-- MUST use `git branch -d`, never `-D`; MUST NOT delete a remote branch,
- force-push, or write to the default branch.
+- MUST use `git branch -d`, and MAY use `-D` ONLY when the Phase-1 squash-merge
+ proof held this run and `-d` refused, with that proof restated and the branch
+ named in the confirmation. Everywhere else `-D` is forbidden. MUST NOT delete a
+ remote branch, force-push, or write to the default branch.
- MUST NOT drop a stash — report and route, as `$ca-standup` does.
- MUST NOT route to `$ca-override` when blocked. Name the gate instead.
diff --git a/plugins/ca-codex/skills/INDEX.md b/plugins/ca-codex/skills/INDEX.md
index c1151271..f410dc7a 100644
--- a/plugins/ca-codex/skills/INDEX.md
+++ b/plugins/ca-codex/skills/INDEX.md
@@ -14,7 +14,7 @@ skill is invoked — never bulk-read this directory.
| `$ca-btw` | Lightweight Q&A about the project — answer from context and return, no routing, no state change. |
| `$ca-checkpoint` | Periodic multi-reviewer sweep of the whole codebase — surfaces a triaged checkpoint report. |
| `$ca-chore` | Sanctioned lane for non-behavioral work — docs-only edits, dependency bumps, reverts. Type-scaled gates; no TDD demanded of prose. |
-| `$ca-cleanup` | Finish an already-merged branch — classify the leftover artifacts, return to a fast-forwarded default checkout, and delete the merged local branch. Every discard confirmed per item; ancestry proven, never assumed. |
+| `$ca-cleanup` | Finish an already-merged branch — classify the leftover artifacts, return to a fast-forwarded default checkout, and delete the merged local branch. Every discard confirmed per item; containment proven, never assumed. |
| `$ca-commands` | Show the codeArbiter command catalog — the public command list and what each routes to. |
| `$ca-commit` | Run the full commit gate — the only sanctioned path to a git commit. |
| `$ca-conflict` | Stop everything and surface a rule conflict — persona vs. docs vs. code. Present both sides and the conflict-hierarchy level; the user resolves. No silent reconciliation. |
diff --git a/plugins/ca-codex/skills/ca-cleanup/SKILL.md b/plugins/ca-codex/skills/ca-cleanup/SKILL.md
index 7c0071d7..35b627be 100644
--- a/plugins/ca-codex/skills/ca-cleanup/SKILL.md
+++ b/plugins/ca-codex/skills/ca-cleanup/SKILL.md
@@ -1,6 +1,6 @@
---
name: ca-cleanup
-description: Finish an already-merged branch — classify the leftover artifacts, return to a fast-forwarded default checkout, and delete the merged local branch. Every discard confirmed per item; ancestry proven, never assumed.
+description: Finish an already-merged branch — classify the leftover artifacts, return to a fast-forwarded default checkout, and delete the merged local branch. Every discard confirmed per item; containment proven, never assumed.
argument-hint: (none)
---
@@ -21,7 +21,7 @@ coverage. This is that coverage.
## Routes to
The `post-merge-cleanup` skill (`${CLAUDE_PLUGIN_ROOT}/routines/post-merge-cleanup/SKILL.md`),
-which owns the ancestry proof, the artifact classification, and the per-item
+which owns the containment proof, the artifact classification, and the per-item
confirmations.
## When NOT to use
@@ -36,9 +36,10 @@ confirmations.
## Hard gate
-- MUST prove the current branch is an ancestor of the **fetched** default branch
- before anything is deleted. An unproven or unfetched ancestry STOPs — a branch
- that only *looks* merged is not merged.
+- MUST prove the current branch is contained in the **fetched** default branch —
+ SHA-ancestry, or a squash-merge proven by the merged PR record (`headRefOid`
+ == HEAD) — before anything is deleted. An unproven or unfetched comparison
+ STOPs — a branch that only *looks* merged is not merged.
- MUST classify every dirty or untracked artifact as **unique**, **redundant**,
or **superseded**, and MUST NOT discard a unique or unclassifiable one without
explicit per-item confirmation naming it.
@@ -46,7 +47,9 @@ confirmations.
- MUST reach the default branch with a clean working tree, and MUST fast-forward
with `--ff-only` only — never a merge commit, never a rebase, never a reset
that discards work.
-- MUST NOT force-delete a branch, force-push, delete a remote branch, or write to
- the default branch.
+- MUST use `git branch -d`, and MAY use `-D` only when the squash-merge PR-record
+ proof held this run and `-d` refused, with that proof restated and the branch
+ named in the confirmation. MUST NOT otherwise force-delete a branch, force-push,
+ delete a remote branch, or write to the default branch.
- MUST NOT require `$ca-override`. This is ordinary lifecycle work; if it
cannot proceed, the reason is a stated gate, not a bypass.
diff --git a/plugins/ca-codex/skills/ca-standup/SKILL.md b/plugins/ca-codex/skills/ca-standup/SKILL.md
index 6624343f..35c03519 100644
--- a/plugins/ca-codex/skills/ca-standup/SKILL.md
+++ b/plugins/ca-codex/skills/ca-standup/SKILL.md
@@ -16,7 +16,10 @@ Arbiter gathers and proposes; you decide every mutation.
The orchestrator reads the current repo state (reusing the briefing's read-only
computation — branch, ahead/behind, dirty tree, stashes, prune-candidate branches,
stale worktrees) and presents it, then offers each applicable action in turn. Skip
-an action that has no candidates; never bundle confirmations.
+an action that has no candidates; never bundle confirmations across different
+actions or items — branch deletions always stay per-item, and the sole exception
+is step 3's explicitly enumerated worktree group, which is still one confirmation
+that names every member, never an implied yes.
1. **Fetch + fast-forward pull** — kick `git fetch`, then offer a **`--ff-only`**
pull of the current branch. Eligibility is the briefing summary's
@@ -29,9 +32,21 @@ an action that has no candidates; never bundle confirmations.
(the `: gone]` upstream set), excluding the current branch and the default
(`main`). Delete a listed branch only after an explicit per-branch confirmation;
declining leaves it in place.
+
+ A `: gone]` branch that was squash-merged will typically **refuse** plain
+ `git branch -d` — its upstream is already pruned, so `-d` has nothing to test
+ reachability against. Before reporting that as a stop, check for a merged PR
+ record: `gh pr list --head --state merged --json headRefOid`. When a
+ `MERGED` PR's `headRefOid` equals that branch's local tip, containment is
+ proven and `git branch -D` is permitted — with the proof stated and the
+ branch named in the confirmation — mirroring the `post-merge-cleanup` Phase 5
+ contract. Without that proof, a refusal stays a report-and-stop; never guess.
3. **Remove stale worktrees** — list stale/merged worktrees (branch gone-or-merged,
- or path missing on disk), never the main worktree. Remove one only after explicit
- per-item confirmation; declining leaves it intact.
+ or path missing on disk), never the main worktree. Present the full stale list
+ together with each item's evidence, then offer removal as an explicitly
+ **enumerated group** — one confirmation that names every member — as well as
+ per-item confirmation for anyone who wants to keep some. Declining the group
+ falls back to per-item confirmation, and declining any item leaves it intact.
4. **Surface stashes / dirty / un-pushed** — list stashes, uncommitted changes, and
un-pushed commits, each with a suggested next step (`$ca-commit`, `git push`,
`git stash show`). Report-and-route only: never discard a stash, reset, or push.
@@ -82,8 +97,11 @@ Present a one-line summary of what was done and what was declined.
commit, never on a dirty tree, never a rebase.
- MUST exclude the current branch and the default branch from branch pruning, and
the main worktree from worktree cleanup.
-- MUST confirm each destructive action (branch delete, worktree remove)
- individually before performing it — no batched or implied yes.
+- MUST confirm branch deletions individually — no batched or implied yes.
+ Worktree removals MAY be confirmed as one explicitly enumerated group (naming
+ every member) or individually; either way there is no implied yes, and
+ declining the group falls back to per-item confirmation rather than removing
+ anything.
- MUST treat stash / dirty / un-pushed state as report-and-route only — never
discard, reset, force, or push on the user's behalf.
- MUST NOT write to or force-push the default branch.
diff --git a/plugins/ca-pi/CHANGELOG.md b/plugins/ca-pi/CHANGELOG.md
index 1418f3a7..80dd792c 100644
--- a/plugins/ca-pi/CHANGELOG.md
+++ b/plugins/ca-pi/CHANGELOG.md
@@ -4,6 +4,13 @@ All notable changes to `ca-pi` are documented in this file.
## [Unreleased]
+## [0.2.2] - 2026-08-04
+
+### Fixed
+
+- `post-merge-cleanup` (issue #586): the Phase 1 gate no longer STOPs on every squash-merged branch. SHA-ancestry is now one of two accepted containment proofs — the second is the merged PR record (`headRefOid` == HEAD), reported alongside the ancestry check's negative result rather than treated as a failure. Phase 4 now fast-forwards the local default ref before checking it out, so a stale local default can no longer be misattributed to a correctly-kept Phase 3 artifact. Phase 5 documents why `-d` can accept a squash merge via its upstream, and sanctions `-D` only when this run's Phase 1 proof was the PR-record squash proof and `-d` refused.
+- `standup` (issue #596): stale worktrees can now be confirmed as one explicitly enumerated group naming every member, instead of one confirmation per worktree; declining the group falls back to per-item confirmation. Branch pruning documents the same squash-merge `-D` exception as `post-merge-cleanup`.
+
## [0.2.1] - 2026-08-04
### Fixed
diff --git a/plugins/ca-pi/SKILLS.md b/plugins/ca-pi/SKILLS.md
index 489cc6f8..3c788347 100644
--- a/plugins/ca-pi/SKILLS.md
+++ b/plugins/ca-pi/SKILLS.md
@@ -14,7 +14,7 @@ skill is invoked — never bulk-read this directory.
| `/ca-btw` | Lightweight Q&A about the project — answer from context and return, no routing, no state change. |
| `/ca-checkpoint` | Periodic multi-reviewer sweep of the whole codebase — surfaces a triaged checkpoint report. |
| `/ca-chore` | Sanctioned lane for non-behavioral work — docs-only edits, dependency bumps, reverts. Type-scaled gates; no TDD demanded of prose. |
-| `/ca-cleanup` | Finish an already-merged branch — classify the leftover artifacts, return to a fast-forwarded default checkout, and delete the merged local branch. Every discard confirmed per item; ancestry proven, never assumed. |
+| `/ca-cleanup` | Finish an already-merged branch — classify the leftover artifacts, return to a fast-forwarded default checkout, and delete the merged local branch. Every discard confirmed per item; containment proven, never assumed. |
| `/ca-commands` | Show the codeArbiter command catalog — the public command list and what each routes to. |
| `/ca-commit` | Run the full commit gate — the only sanctioned path to a git commit. |
| `/ca-conflict` | Stop everything and surface a rule conflict — persona vs. docs vs. code. Present both sides and the conflict-hierarchy level; the user resolves. No silent reconciliation. |
diff --git a/plugins/ca-pi/generated/command-catalog.json b/plugins/ca-pi/generated/command-catalog.json
index f8a40953..1e7444d8 100644
--- a/plugins/ca-pi/generated/command-catalog.json
+++ b/plugins/ca-pi/generated/command-catalog.json
@@ -41,7 +41,7 @@
},
{
"name": "cleanup",
- "description": "Finish an already-merged branch — classify the leftover artifacts, return to a fast-forwarded default checkout, and delete the merged local branch. Every discard confirmed per item; ancestry proven, never assumed.",
+ "description": "Finish an already-merged branch — classify the leftover artifacts, return to a fast-forwarded default checkout, and delete the merged local branch. Every discard confirmed per item; containment proven, never assumed.",
"skillPath": "skills/ca-cleanup/SKILL.md"
},
{
diff --git a/plugins/ca-pi/package.json b/plugins/ca-pi/package.json
index 4b0e188f..0c44338a 100644
--- a/plugins/ca-pi/package.json
+++ b/plugins/ca-pi/package.json
@@ -1,6 +1,6 @@
{
"name": "ca-pi",
- "version": "0.2.1",
+ "version": "0.2.2",
"private": true,
"license": "AGPL-3.0-only",
"type": "module",
diff --git a/plugins/ca-pi/routines/INDEX.md b/plugins/ca-pi/routines/INDEX.md
index 2070f0f4..562798c2 100644
--- a/plugins/ca-pi/routines/INDEX.md
+++ b/plugins/ca-pi/routines/INDEX.md
@@ -28,5 +28,5 @@ Skill bodies load on routing only. This index is the surface scan; never bulk-re
| [release](release/SKILL.md) | `/release` | Lean SemVer release, three phases: derive the bump from Conventional-Commits since the last tag + roll `CHANGELOG.md`; cut the annotated tag + report; publish (push tag + `gh release create`) on explicit authorization. No tag on a red suite; no direct-to-main/force-push; nothing published unbidden; a release commit routes through `commit-gate`. |
| [security-architecture](security-architecture/SKILL.md) | `/threat-model` (optional) | OPTIONAL lightweight STRIDE pass for sensitive features: attack surface → STRIDE → findings, governed by `security-controls.md`. MAY dispatch `security-reviewer` / `auth-crypto-reviewer`. Not a routine gate; hard-STOPs only on a critical unmitigated threat. |
| [context-check](context-check/SKILL.md) | `/context-check` | OPTIONAL manual drift audit: load `.codearbiter/.provenance/` via `load_provenance_dir` + `compute_drift`, report stale docs, then per stale doc: re-scout / re-baseline (`rebaseline`) / defer. Not the daily loop — commit-gate auto-heal owns routine maintenance. |
-| [post-merge-cleanup](post-merge-cleanup/SKILL.md) | `/ca-cleanup` | The already-merged branch transition: fetch, prove `HEAD` is an ancestor of the fetched default, classify every dirty/untracked artifact as unique/redundant/superseded, resolve each under its own confirmation, `--ff-only` onto the default branch, then `branch -d` the merged local branch. Unique or unclassifiable artifacts are never discarded unbidden; the remote branch is never touched; never routes to `/ca-override`. |
+| [post-merge-cleanup](post-merge-cleanup/SKILL.md) | `/ca-cleanup` | The already-merged branch transition: fetch, prove `HEAD` is contained in the fetched default (SHA-ancestry, or a squash-merge proven by the merged PR record), classify every dirty/untracked artifact as unique/redundant/superseded, resolve each under its own confirmation, fast-forward the local default ref then `--ff-only` onto it, then `branch -d` the merged local branch. Unique or unclassifiable artifacts are never discarded unbidden; the remote branch is never touched; never routes to `/ca-override`. |
| [tribunal](tribunal/SKILL.md) | `/ca-tribunal` | The deep, rarely-convened whole-codebase audit lane: seven gated phases across eleven specialist lenses, each finding persisted to its own file (plus append-only triage/run logs) under `.codearbiter/reports//`, resumable from disk, filing approved findings as GitHub issues; never a required gate. |
diff --git a/plugins/ca-pi/routines/post-merge-cleanup/SKILL.md b/plugins/ca-pi/routines/post-merge-cleanup/SKILL.md
index 411c8570..e4330300 100644
--- a/plugins/ca-pi/routines/post-merge-cleanup/SKILL.md
+++ b/plugins/ca-pi/routines/post-merge-cleanup/SKILL.md
@@ -1,6 +1,6 @@
---
name: post-merge-cleanup
-description: Finish an already-merged branch. Proves the branch is an ancestor of the fetched default, classifies leftover artifacts as unique / redundant / superseded, returns to a clean --ff-only default checkout, and deletes the merged local branch — every discard confirmed per item. Routed to by /ca-cleanup.
+description: Finish an already-merged branch. Proves the branch is contained in the fetched default, classifies leftover artifacts as unique / redundant / superseded, returns to a clean --ff-only default checkout, and deletes the merged local branch — every discard confirmed per item. Routed to by /ca-cleanup.
---
# post-merge-cleanup
@@ -31,23 +31,51 @@ dirty state read-only and exit; branch pruning across *other* branches belongs t
## Phase 1 — Prove the merge · gate: BLOCK
A branch that looks merged is not merged. Establish it against the network, not
-against a stale local ref:
+against a stale local ref. This repo squash-merges by default, so SHA-ancestry
+alone is the wrong instrument for most landings: it holds for a fast-forward or
+a merge-commit landing, but a squash merge writes a new commit with no SHA
+lineage back to the branch, and `--is-ancestor` will report non-zero for a
+branch that landed cleanly. The gate is **content-containment**, proven by
+whichever instrument fits the landing, always reported as a fact:
1. `git fetch` the remote holding the default branch. A fetch that fails STOPs —
an unfetched comparison proves nothing.
-2. Confirm the current branch is an **ancestor** of the fetched default
- (`git merge-base --is-ancestor HEAD origin/`). This is the test that
- matters, and it is deliberately not `: gone]` upstream state: a squash-merged
- branch whose remote still exists is fully contained in the default branch and
- is safe to delete, while a `: gone]` branch whose commits never landed is not.
-3. Report the proof as a fact — the default branch, the fetched SHA, and that
- HEAD is contained in it.
-
-If HEAD is **not** an ancestor, STOP. Name the un-landed commits and route to
-`/ca-pr`; nothing is deleted here.
-
-Gate: the remote is fetched and HEAD is proven an ancestor of the fetched default
-branch — or the skill has stopped.
+2. Prove containment with two primary instruments plus a fallback, tried in
+ order:
+ 1. **Ancestry** — `git merge-base --is-ancestor HEAD origin/`. Holds
+ for a fast-forward or merge-commit landing. If it holds, that is the
+ proof: report it and move on.
+ 2. **Squash-merge proof via the PR record** — if ancestry fails, run
+ `gh pr list --head "$(git branch --show-current)" --state merged --json number,state,headRefOid,mergeCommit`.
+ A `MERGED` PR whose `headRefOid` equals local `git rev-parse HEAD` proves
+ every commit on this branch rode that PR's squash into the default
+ branch. Report the PR number, that `headRefOid == HEAD`, the merge
+ commit (`mergeCommit.oid` in that JSON), **and** the ancestry check's
+ negative result — all as facts, not as a failure. Corroborate with
+ `git diff --quiet origin/ HEAD` when it happens to hold; a
+ **non-empty** diff alongside a valid PR proof is normal (the default
+ branch advanced since the merge landed) and is reported as a fact, never
+ treated as a failure. The PR-record identity — `MERGED` plus
+ `headRefOid == HEAD` — is the load-bearing proof; the diff is
+ corroboration only when it happens to be fresh, never a requirement.
+ 3. **Fallback when `gh` is unavailable or no PR record exists** —
+ `git diff --quiet origin/ HEAD` (the tree is byte-identical to
+ the fetched default) is an acceptable fallback proof. Report it as such.
+3. Report whichever instrument held as a fact — the default branch, the fetched
+ SHA, and which of the three proofs established containment.
+
+If none of the three hold, STOP exactly as today. Name the un-landed commits
+and route to `/ca-pr`; nothing is deleted here.
+
+> Deliberate deviation from issue #586's suggested contract: that suggestion
+> required the squash-merge proof's diff to be empty. That requirement
+> re-breaks this gate the moment any later PR merges to the default branch —
+> the common state, not an edge case. The PR-record identity is what proves
+> containment; the diff is corroboration, demoted from requirement to fact.
+
+Gate: the remote is fetched and HEAD is proven contained in the fetched default
+branch — by ancestry, by the PR-record squash proof, or by the byte-identical
+fallback — or the skill has stopped.
## Phase 2 — Classify the residue · gate: BLOCK
@@ -84,42 +112,94 @@ A stash is never dropped here. Stashes are reported with `git stash show` as the
suggested next step, the same report-and-route contract `/ca-standup` holds.
If anything the user chose to keep would block the checkout, STOP and say so
-rather than removing it anyway. A blocked checkout is a fine outcome; a silent
-discard is not.
+rather than removing it anyway — but distinguish the two causes before naming
+the artifact as the blocker:
+
+- **The kept artifact genuinely conflicts** — its content collides with what
+ checking out the default branch would need to change or remove. This is the
+ user's to resolve, as today.
+- **The local default ref lags the fetched one** — git refuses a checkout when
+ a locally-modified tracked file differs between HEAD and the target ref, and
+ a stale local `` makes that difference larger than reality: content
+ the kept artifact never actually touches can still collide with what a
+ *current* default branch would carry. That gap is not the user's problem and
+ not the kept artifact's fault. It is resolved by Phase 4's fast-forward-first
+ step, not by discarding anything here. Issue #586 recorded the observed
+ failure mode: a stale local `main` — 513 lines behind on
+ `.codearbiter/gate-events.log` alone — made this STOP name a correctly-kept
+ 38-line artifact as the blocker, which invites exactly the silent discard
+ this phase exists to prevent.
Gate: every item is resolved by an explicit per-item decision, and the working
tree is clean enough to check out the default branch — or the skill has stopped
-with the blocker named.
+with the blocker named, correctly attributed to a genuine conflict or a stale
+local ref.
## Phase 4 — Transition · gate: BLOCK
Only after Phase 3 leaves the tree safe:
-1. Check out the default branch, then **verify the checkout actually happened**
+1. **Fast-forward the local default ref BEFORE checking it out:**
+ `git fetch origin :`. Git refuses a non-fast-forward
+ update of a ref that is not currently checked out, so the `--ff-only`
+ guarantee is preserved by the instrument itself — this cannot silently
+ rewrite the local default ref, only advance it or refuse. This is what
+ keeps a stale local default from blocking (or worse, misattributing) the
+ checkout below. This step can itself be refused for the same reason a
+ checkout can be — `` checked out in another worktree — in which
+ case report that as the (correctly attributed) blocker, per Phase 3, and
+ stop; do not treat the refusal as license to skip ahead.
+2. Check out the default branch, then **verify the checkout actually happened**
(`git branch --show-current`). A checkout silently fails when another worktree
holds the branch, and every step after this one would otherwise run against
the wrong branch.
-2. Fast-forward with `--ff-only`. A divergence means the default branch moved in
- a way this skill will not reconcile: report it and stop. Never a merge commit,
- never a rebase, never a reset.
+3. Fast-forward with `--ff-only` (belt over the braces of step 1 — a no-op when
+ the pre-checkout fetch already brought the local ref current). A divergence
+ means the default branch moved in a way this skill will not reconcile:
+ report it and stop. Never a merge commit, never a rebase, never a reset.
-Gate: HEAD is confirmed on the default branch by re-read, and the fast-forward
-either succeeded or was reported as a refused divergence.
+Gate: the local default ref was fast-forwarded before the checkout — or the
+fast-forward's refusal was itself reported as a fact and the skill stopped —
+HEAD is confirmed on the default branch by re-read, and the belt-and-braces
+`--ff-only` pull either succeeded (typically a no-op) or was reported as a
+refused divergence.
## Phase 5 — Delete the merged local branch · gate: STOP
-Offer deletion of the now-merged local branch, with the Phase 1 ancestry proof
-restated. One confirmation, naming the branch.
-
-- `git branch -d` only. Never `-D`: the safety check is the point, and if `-d`
- refuses, the ancestry proof and the refusal disagree — report both and stop.
+Offer deletion of the now-merged local branch, with the Phase 1 containment
+proof restated. One confirmation, naming the branch.
+
+- `git branch -d` first, always. Never reach for `-D` on the assumption that a
+ refusal means `-d` can't handle a squash merge — it often can. `git branch
+ -d`'s safety check accepts a branch merged into its **upstream**, not only
+ into HEAD: with an upstream still configured and equal to the tip, `-d`
+ succeeds (with a warning) even for a squash merge that `--is-ancestor` alone
+ would call unmerged. Do not conclude a refusal here means `-D` is required —
+ check the reason first.
+- **The sanctioned `-D` path.** `-d` typically refuses once the remote branch
+ has been auto-deleted and pruned, because there is then no upstream left for
+ `-d` to test reachability against. When that happens, `git branch -D` is
+ permitted, but ONLY when both hold:
+ 1. Phase 1's proof, established this run, was the PR-record squash proof
+ (`MERGED` and `headRefOid == HEAD`) — not a bare ancestry pass, not a
+ stale or assumed proof.
+ 2. The confirmation restates that Phase-1 proof and explicitly names the
+ branch.
+ Frame this precisely: `-d`'s safety check tests SHA-reachability, and the
+ squash proof has already shown that instrument is the wrong one for this
+ repo's merge mode. The PR-record proof plus a named confirmation **replaces**
+ the check that `-d` can no longer run — it does not bypass it. Everywhere
+ else, `-D` stays forbidden exactly as before: if `-d` refuses and Phase 1's
+ proof was anything other than the PR-record squash proof, report both and
+ stop.
- The **remote** branch is never touched. If the user wants it gone, that is
theirs to do or the platform's auto-delete-on-merge to do.
Declining leaves the branch in place. That is a normal outcome, not a failure.
Gate: the branch is deleted only after an explicit confirmation naming it, via
-`-d`, with the remote untouched.
+`-d`, or via `-D` restricted to the sanctioned path above with the proof
+restated, with the remote untouched.
## Phase 6 — Receipt
@@ -129,18 +209,25 @@ correct outcome reported plainly, not an error.
## Hard rules
-- MUST fetch and prove `HEAD` is an ancestor of the fetched default branch before
- any deletion. MUST NOT infer merge state from a `: gone]` upstream alone.
+- MUST fetch and prove `HEAD` is **contained** in the fetched default branch
+ before any deletion — via ancestry, via the PR-record squash proof (`MERGED`
+ and `headRefOid == HEAD`), or, without `gh`, via a byte-identical tree diff.
+ MUST NOT infer merge state from a `: gone]` upstream alone.
- MUST classify every artifact, and MUST treat anything not provably redundant or
superseded as unique.
- MUST NOT discard a unique or unclassifiable artifact without an explicit
confirmation naming that item.
- MUST confirm every removal and the branch deletion individually — no batched or
implied yes.
-- MUST re-read the current branch after checkout before acting on it.
+- MUST fast-forward the local default ref (`git fetch origin :`)
+ before checking it out, or report the fast-forward's own refusal (e.g.
+ `` checked out in another worktree) as the blocker and stop. MUST
+ re-read the current branch after checkout before acting on it.
- MUST use `--ff-only`, and MUST NOT merge, rebase, or reset to reach the default
branch.
-- MUST use `git branch -d`, never `-D`; MUST NOT delete a remote branch,
- force-push, or write to the default branch.
+- MUST use `git branch -d`, and MAY use `-D` ONLY when the Phase-1 squash-merge
+ proof held this run and `-d` refused, with that proof restated and the branch
+ named in the confirmation. Everywhere else `-D` is forbidden. MUST NOT delete a
+ remote branch, force-push, or write to the default branch.
- MUST NOT drop a stash — report and route, as `/ca-standup` does.
- MUST NOT route to `/ca-override` when blocked. Name the gate instead.
diff --git a/plugins/ca-pi/skills/ca-cleanup/SKILL.md b/plugins/ca-pi/skills/ca-cleanup/SKILL.md
index 89acbb1c..055239ce 100644
--- a/plugins/ca-pi/skills/ca-cleanup/SKILL.md
+++ b/plugins/ca-pi/skills/ca-cleanup/SKILL.md
@@ -1,6 +1,6 @@
---
name: ca-cleanup
-description: Finish an already-merged branch — classify the leftover artifacts, return to a fast-forwarded default checkout, and delete the merged local branch. Every discard confirmed per item; ancestry proven, never assumed.
+description: Finish an already-merged branch — classify the leftover artifacts, return to a fast-forwarded default checkout, and delete the merged local branch. Every discard confirmed per item; containment proven, never assumed.
argument-hint: (none)
---
@@ -21,7 +21,7 @@ coverage. This is that coverage.
## Routes to
The `post-merge-cleanup` skill (`/routines/post-merge-cleanup/SKILL.md`),
-which owns the ancestry proof, the artifact classification, and the per-item
+which owns the containment proof, the artifact classification, and the per-item
confirmations.
## When NOT to use
@@ -36,9 +36,10 @@ confirmations.
## Hard gate
-- MUST prove the current branch is an ancestor of the **fetched** default branch
- before anything is deleted. An unproven or unfetched ancestry STOPs — a branch
- that only *looks* merged is not merged.
+- MUST prove the current branch is contained in the **fetched** default branch —
+ SHA-ancestry, or a squash-merge proven by the merged PR record (`headRefOid`
+ == HEAD) — before anything is deleted. An unproven or unfetched comparison
+ STOPs — a branch that only *looks* merged is not merged.
- MUST classify every dirty or untracked artifact as **unique**, **redundant**,
or **superseded**, and MUST NOT discard a unique or unclassifiable one without
explicit per-item confirmation naming it.
@@ -46,7 +47,9 @@ confirmations.
- MUST reach the default branch with a clean working tree, and MUST fast-forward
with `--ff-only` only — never a merge commit, never a rebase, never a reset
that discards work.
-- MUST NOT force-delete a branch, force-push, delete a remote branch, or write to
- the default branch.
+- MUST use `git branch -d`, and MAY use `-D` only when the squash-merge PR-record
+ proof held this run and `-d` refused, with that proof restated and the branch
+ named in the confirmation. MUST NOT otherwise force-delete a branch, force-push,
+ delete a remote branch, or write to the default branch.
- MUST NOT require `/ca-override`. This is ordinary lifecycle work; if it
cannot proceed, the reason is a stated gate, not a bypass.
diff --git a/plugins/ca-pi/skills/ca-standup/SKILL.md b/plugins/ca-pi/skills/ca-standup/SKILL.md
index d2b6c05b..6c618260 100644
--- a/plugins/ca-pi/skills/ca-standup/SKILL.md
+++ b/plugins/ca-pi/skills/ca-standup/SKILL.md
@@ -16,7 +16,10 @@ Arbiter gathers and proposes; you decide every mutation.
The orchestrator reads the current repo state (reusing the briefing's read-only
computation — branch, ahead/behind, dirty tree, stashes, prune-candidate branches,
stale worktrees) and presents it, then offers each applicable action in turn. Skip
-an action that has no candidates; never bundle confirmations.
+an action that has no candidates; never bundle confirmations across different
+actions or items — branch deletions always stay per-item, and the sole exception
+is step 3's explicitly enumerated worktree group, which is still one confirmation
+that names every member, never an implied yes.
1. **Fetch + fast-forward pull** — kick `git fetch`, then offer a **`--ff-only`**
pull of the current branch. Eligibility is the briefing summary's
@@ -29,9 +32,21 @@ an action that has no candidates; never bundle confirmations.
(the `: gone]` upstream set), excluding the current branch and the default
(`main`). Delete a listed branch only after an explicit per-branch confirmation;
declining leaves it in place.
+
+ A `: gone]` branch that was squash-merged will typically **refuse** plain
+ `git branch -d` — its upstream is already pruned, so `-d` has nothing to test
+ reachability against. Before reporting that as a stop, check for a merged PR
+ record: `gh pr list --head --state merged --json headRefOid`. When a
+ `MERGED` PR's `headRefOid` equals that branch's local tip, containment is
+ proven and `git branch -D` is permitted — with the proof stated and the
+ branch named in the confirmation — mirroring the `post-merge-cleanup` Phase 5
+ contract. Without that proof, a refusal stays a report-and-stop; never guess.
3. **Remove stale worktrees** — list stale/merged worktrees (branch gone-or-merged,
- or path missing on disk), never the main worktree. Remove one only after explicit
- per-item confirmation; declining leaves it intact.
+ or path missing on disk), never the main worktree. Present the full stale list
+ together with each item's evidence, then offer removal as an explicitly
+ **enumerated group** — one confirmation that names every member — as well as
+ per-item confirmation for anyone who wants to keep some. Declining the group
+ falls back to per-item confirmation, and declining any item leaves it intact.
4. **Surface stashes / dirty / un-pushed** — list stashes, uncommitted changes, and
un-pushed commits, each with a suggested next step (`/ca-commit`, `git push`,
`git stash show`). Report-and-route only: never discard a stash, reset, or push.
@@ -82,8 +97,11 @@ Present a one-line summary of what was done and what was declined.
commit, never on a dirty tree, never a rebase.
- MUST exclude the current branch and the default branch from branch pruning, and
the main worktree from worktree cleanup.
-- MUST confirm each destructive action (branch delete, worktree remove)
- individually before performing it — no batched or implied yes.
+- MUST confirm branch deletions individually — no batched or implied yes.
+ Worktree removals MAY be confirmed as one explicitly enumerated group (naming
+ every member) or individually; either way there is no implied yes, and
+ declining the group falls back to per-item confirmation rather than removing
+ anything.
- MUST treat stash / dirty / un-pushed state as report-and-route only — never
discard, reset, force, or push on the user's behalf.
- MUST NOT write to or force-push the default branch.
diff --git a/plugins/ca/.claude-plugin/plugin.json b/plugins/ca/.claude-plugin/plugin.json
index 231658c3..0fa08cf1 100644
--- a/plugins/ca/.claude-plugin/plugin.json
+++ b/plugins/ca/.claude-plugin/plugin.json
@@ -2,7 +2,7 @@
"name": "ca",
"displayName": "codeArbiter",
"description": "Orchestration layer for Claude Code. Routes every intent through gated skills and reviewer agents, drives spec-driven TDD, mechanically enforces the commit and audit-trail gates, decides via SMARTS, and keeps an append-only audit trail. Requires Python 3 on PATH. Dormant until you opt a repo in; run /ca:init to activate.",
- "version": "2.11.2",
+ "version": "2.11.3",
"author": { "name": "arbiterForge" },
"license": "AGPL-3.0-only",
"homepage": "https://github.com/arbiterForge/codeArbiter",
diff --git a/plugins/ca/commands/cleanup.md b/plugins/ca/commands/cleanup.md
index 58c3787f..9475c809 100644
--- a/plugins/ca/commands/cleanup.md
+++ b/plugins/ca/commands/cleanup.md
@@ -1,5 +1,5 @@
---
-description: Finish an already-merged branch — classify the leftover artifacts, return to a fast-forwarded default checkout, and delete the merged local branch. Every discard confirmed per item; ancestry proven, never assumed.
+description: Finish an already-merged branch — classify the leftover artifacts, return to a fast-forwarded default checkout, and delete the merged local branch. Every discard confirmed per item; containment proven, never assumed.
argument-hint: (none)
---
@@ -20,7 +20,7 @@ coverage. This is that coverage.
## Routes to
The `post-merge-cleanup` skill (`${CLAUDE_PLUGIN_ROOT}/skills/post-merge-cleanup/SKILL.md`),
-which owns the ancestry proof, the artifact classification, and the per-item
+which owns the containment proof, the artifact classification, and the per-item
confirmations.
## When NOT to use
@@ -35,9 +35,10 @@ confirmations.
## Hard gate
-- MUST prove the current branch is an ancestor of the **fetched** default branch
- before anything is deleted. An unproven or unfetched ancestry STOPs — a branch
- that only *looks* merged is not merged.
+- MUST prove the current branch is contained in the **fetched** default branch —
+ SHA-ancestry, or a squash-merge proven by the merged PR record (`headRefOid`
+ == HEAD) — before anything is deleted. An unproven or unfetched comparison
+ STOPs — a branch that only *looks* merged is not merged.
- MUST classify every dirty or untracked artifact as **unique**, **redundant**,
or **superseded**, and MUST NOT discard a unique or unclassifiable one without
explicit per-item confirmation naming it.
@@ -45,7 +46,9 @@ confirmations.
- MUST reach the default branch with a clean working tree, and MUST fast-forward
with `--ff-only` only — never a merge commit, never a rebase, never a reset
that discards work.
-- MUST NOT force-delete a branch, force-push, delete a remote branch, or write to
- the default branch.
+- MUST use `git branch -d`, and MAY use `-D` only when the squash-merge PR-record
+ proof held this run and `-d` refused, with that proof restated and the branch
+ named in the confirmation. MUST NOT otherwise force-delete a branch, force-push,
+ delete a remote branch, or write to the default branch.
- MUST NOT require `/ca:override`. This is ordinary lifecycle work; if it
cannot proceed, the reason is a stated gate, not a bypass.
diff --git a/plugins/ca/commands/standup.md b/plugins/ca/commands/standup.md
index b48a3405..a202399b 100644
--- a/plugins/ca/commands/standup.md
+++ b/plugins/ca/commands/standup.md
@@ -15,7 +15,10 @@ Arbiter gathers and proposes; you decide every mutation.
The orchestrator reads the current repo state (reusing the briefing's read-only
computation — branch, ahead/behind, dirty tree, stashes, prune-candidate branches,
stale worktrees) and presents it, then offers each applicable action in turn. Skip
-an action that has no candidates; never bundle confirmations.
+an action that has no candidates; never bundle confirmations across different
+actions or items — branch deletions always stay per-item, and the sole exception
+is step 3's explicitly enumerated worktree group, which is still one confirmation
+that names every member, never an implied yes.
1. **Fetch + fast-forward pull** — kick `git fetch`, then offer a **`--ff-only`**
pull of the current branch. Eligibility is the briefing summary's
@@ -28,9 +31,21 @@ an action that has no candidates; never bundle confirmations.
(the `: gone]` upstream set), excluding the current branch and the default
(`main`). Delete a listed branch only after an explicit per-branch confirmation;
declining leaves it in place.
+
+ A `: gone]` branch that was squash-merged will typically **refuse** plain
+ `git branch -d` — its upstream is already pruned, so `-d` has nothing to test
+ reachability against. Before reporting that as a stop, check for a merged PR
+ record: `gh pr list --head --state merged --json headRefOid`. When a
+ `MERGED` PR's `headRefOid` equals that branch's local tip, containment is
+ proven and `git branch -D` is permitted — with the proof stated and the
+ branch named in the confirmation — mirroring the `post-merge-cleanup` Phase 5
+ contract. Without that proof, a refusal stays a report-and-stop; never guess.
3. **Remove stale worktrees** — list stale/merged worktrees (branch gone-or-merged,
- or path missing on disk), never the main worktree. Remove one only after explicit
- per-item confirmation; declining leaves it intact.
+ or path missing on disk), never the main worktree. Present the full stale list
+ together with each item's evidence, then offer removal as an explicitly
+ **enumerated group** — one confirmation that names every member — as well as
+ per-item confirmation for anyone who wants to keep some. Declining the group
+ falls back to per-item confirmation, and declining any item leaves it intact.
4. **Surface stashes / dirty / un-pushed** — list stashes, uncommitted changes, and
un-pushed commits, each with a suggested next step (`/ca:commit`, `git push`,
`git stash show`). Report-and-route only: never discard a stash, reset, or push.
@@ -82,8 +97,11 @@ Present a one-line summary of what was done and what was declined.
commit, never on a dirty tree, never a rebase.
- MUST exclude the current branch and the default branch from branch pruning, and
the main worktree from worktree cleanup.
-- MUST confirm each destructive action (branch delete, worktree remove)
- individually before performing it — no batched or implied yes.
+- MUST confirm branch deletions individually — no batched or implied yes.
+ Worktree removals MAY be confirmed as one explicitly enumerated group (naming
+ every member) or individually; either way there is no implied yes, and
+ declining the group falls back to per-item confirmation rather than removing
+ anything.
- MUST treat stash / dirty / un-pushed state as report-and-route only — never
discard, reset, force, or push on the user's behalf.
- MUST NOT write to or force-push the default branch.
diff --git a/plugins/ca/skills/INDEX.md b/plugins/ca/skills/INDEX.md
index 9fd95843..ffce3bc3 100644
--- a/plugins/ca/skills/INDEX.md
+++ b/plugins/ca/skills/INDEX.md
@@ -28,5 +28,5 @@ Skill bodies load on routing only. This index is the surface scan; never bulk-re
| [release](release/SKILL.md) | `/release` | Lean SemVer release, three phases: derive the bump from Conventional-Commits since the last tag + roll `CHANGELOG.md`; cut the annotated tag + report; publish (push tag + `gh release create`) on explicit authorization. No tag on a red suite; no direct-to-main/force-push; nothing published unbidden; a release commit routes through `commit-gate`. |
| [security-architecture](security-architecture/SKILL.md) | `/threat-model` (optional) | OPTIONAL lightweight STRIDE pass for sensitive features: attack surface → STRIDE → findings, governed by `security-controls.md`. MAY dispatch `security-reviewer` / `auth-crypto-reviewer`. Not a routine gate; hard-STOPs only on a critical unmitigated threat. |
| [context-check](context-check/SKILL.md) | `/context-check` | OPTIONAL manual drift audit: load `.codearbiter/.provenance/` via `load_provenance_dir` + `compute_drift`, report stale docs, then per stale doc: re-scout / re-baseline (`rebaseline`) / defer. Not the daily loop — commit-gate auto-heal owns routine maintenance. |
-| [post-merge-cleanup](post-merge-cleanup/SKILL.md) | `/ca:cleanup` | The already-merged branch transition: fetch, prove `HEAD` is an ancestor of the fetched default, classify every dirty/untracked artifact as unique/redundant/superseded, resolve each under its own confirmation, `--ff-only` onto the default branch, then `branch -d` the merged local branch. Unique or unclassifiable artifacts are never discarded unbidden; the remote branch is never touched; never routes to `/ca:override`. |
+| [post-merge-cleanup](post-merge-cleanup/SKILL.md) | `/ca:cleanup` | The already-merged branch transition: fetch, prove `HEAD` is contained in the fetched default (SHA-ancestry, or a squash-merge proven by the merged PR record), classify every dirty/untracked artifact as unique/redundant/superseded, resolve each under its own confirmation, fast-forward the local default ref then `--ff-only` onto it, then `branch -d` the merged local branch. Unique or unclassifiable artifacts are never discarded unbidden; the remote branch is never touched; never routes to `/ca:override`. |
| [tribunal](tribunal/SKILL.md) | `/ca:tribunal` | The deep, rarely-convened whole-codebase audit lane: seven gated phases across eleven specialist lenses, each finding persisted to its own file (plus append-only triage/run logs) under `.codearbiter/reports//`, resumable from disk, filing approved findings as GitHub issues; never a required gate. |
diff --git a/plugins/ca/skills/post-merge-cleanup/SKILL.md b/plugins/ca/skills/post-merge-cleanup/SKILL.md
index 6ae9b79b..b40a5d4f 100644
--- a/plugins/ca/skills/post-merge-cleanup/SKILL.md
+++ b/plugins/ca/skills/post-merge-cleanup/SKILL.md
@@ -1,6 +1,6 @@
---
name: post-merge-cleanup
-description: Finish an already-merged branch. Proves the branch is an ancestor of the fetched default, classifies leftover artifacts as unique / redundant / superseded, returns to a clean --ff-only default checkout, and deletes the merged local branch — every discard confirmed per item. Routed to by /ca:cleanup.
+description: Finish an already-merged branch. Proves the branch is contained in the fetched default, classifies leftover artifacts as unique / redundant / superseded, returns to a clean --ff-only default checkout, and deletes the merged local branch — every discard confirmed per item. Routed to by /ca:cleanup.
---
# post-merge-cleanup
@@ -31,23 +31,51 @@ dirty state read-only and exit; branch pruning across *other* branches belongs t
## Phase 1 — Prove the merge · gate: BLOCK
A branch that looks merged is not merged. Establish it against the network, not
-against a stale local ref:
+against a stale local ref. This repo squash-merges by default, so SHA-ancestry
+alone is the wrong instrument for most landings: it holds for a fast-forward or
+a merge-commit landing, but a squash merge writes a new commit with no SHA
+lineage back to the branch, and `--is-ancestor` will report non-zero for a
+branch that landed cleanly. The gate is **content-containment**, proven by
+whichever instrument fits the landing, always reported as a fact:
1. `git fetch` the remote holding the default branch. A fetch that fails STOPs —
an unfetched comparison proves nothing.
-2. Confirm the current branch is an **ancestor** of the fetched default
- (`git merge-base --is-ancestor HEAD origin/`). This is the test that
- matters, and it is deliberately not `: gone]` upstream state: a squash-merged
- branch whose remote still exists is fully contained in the default branch and
- is safe to delete, while a `: gone]` branch whose commits never landed is not.
-3. Report the proof as a fact — the default branch, the fetched SHA, and that
- HEAD is contained in it.
-
-If HEAD is **not** an ancestor, STOP. Name the un-landed commits and route to
-`/ca:pr`; nothing is deleted here.
-
-Gate: the remote is fetched and HEAD is proven an ancestor of the fetched default
-branch — or the skill has stopped.
+2. Prove containment with two primary instruments plus a fallback, tried in
+ order:
+ 1. **Ancestry** — `git merge-base --is-ancestor HEAD origin/`. Holds
+ for a fast-forward or merge-commit landing. If it holds, that is the
+ proof: report it and move on.
+ 2. **Squash-merge proof via the PR record** — if ancestry fails, run
+ `gh pr list --head "$(git branch --show-current)" --state merged --json number,state,headRefOid,mergeCommit`.
+ A `MERGED` PR whose `headRefOid` equals local `git rev-parse HEAD` proves
+ every commit on this branch rode that PR's squash into the default
+ branch. Report the PR number, that `headRefOid == HEAD`, the merge
+ commit (`mergeCommit.oid` in that JSON), **and** the ancestry check's
+ negative result — all as facts, not as a failure. Corroborate with
+ `git diff --quiet origin/ HEAD` when it happens to hold; a
+ **non-empty** diff alongside a valid PR proof is normal (the default
+ branch advanced since the merge landed) and is reported as a fact, never
+ treated as a failure. The PR-record identity — `MERGED` plus
+ `headRefOid == HEAD` — is the load-bearing proof; the diff is
+ corroboration only when it happens to be fresh, never a requirement.
+ 3. **Fallback when `gh` is unavailable or no PR record exists** —
+ `git diff --quiet origin/ HEAD` (the tree is byte-identical to
+ the fetched default) is an acceptable fallback proof. Report it as such.
+3. Report whichever instrument held as a fact — the default branch, the fetched
+ SHA, and which of the three proofs established containment.
+
+If none of the three hold, STOP exactly as today. Name the un-landed commits
+and route to `/ca:pr`; nothing is deleted here.
+
+> Deliberate deviation from issue #586's suggested contract: that suggestion
+> required the squash-merge proof's diff to be empty. That requirement
+> re-breaks this gate the moment any later PR merges to the default branch —
+> the common state, not an edge case. The PR-record identity is what proves
+> containment; the diff is corroboration, demoted from requirement to fact.
+
+Gate: the remote is fetched and HEAD is proven contained in the fetched default
+branch — by ancestry, by the PR-record squash proof, or by the byte-identical
+fallback — or the skill has stopped.
## Phase 2 — Classify the residue · gate: BLOCK
@@ -84,42 +112,94 @@ A stash is never dropped here. Stashes are reported with `git stash show` as the
suggested next step, the same report-and-route contract `/ca:standup` holds.
If anything the user chose to keep would block the checkout, STOP and say so
-rather than removing it anyway. A blocked checkout is a fine outcome; a silent
-discard is not.
+rather than removing it anyway — but distinguish the two causes before naming
+the artifact as the blocker:
+
+- **The kept artifact genuinely conflicts** — its content collides with what
+ checking out the default branch would need to change or remove. This is the
+ user's to resolve, as today.
+- **The local default ref lags the fetched one** — git refuses a checkout when
+ a locally-modified tracked file differs between HEAD and the target ref, and
+ a stale local `` makes that difference larger than reality: content
+ the kept artifact never actually touches can still collide with what a
+ *current* default branch would carry. That gap is not the user's problem and
+ not the kept artifact's fault. It is resolved by Phase 4's fast-forward-first
+ step, not by discarding anything here. Issue #586 recorded the observed
+ failure mode: a stale local `main` — 513 lines behind on
+ `.codearbiter/gate-events.log` alone — made this STOP name a correctly-kept
+ 38-line artifact as the blocker, which invites exactly the silent discard
+ this phase exists to prevent.
Gate: every item is resolved by an explicit per-item decision, and the working
tree is clean enough to check out the default branch — or the skill has stopped
-with the blocker named.
+with the blocker named, correctly attributed to a genuine conflict or a stale
+local ref.
## Phase 4 — Transition · gate: BLOCK
Only after Phase 3 leaves the tree safe:
-1. Check out the default branch, then **verify the checkout actually happened**
+1. **Fast-forward the local default ref BEFORE checking it out:**
+ `git fetch origin :`. Git refuses a non-fast-forward
+ update of a ref that is not currently checked out, so the `--ff-only`
+ guarantee is preserved by the instrument itself — this cannot silently
+ rewrite the local default ref, only advance it or refuse. This is what
+ keeps a stale local default from blocking (or worse, misattributing) the
+ checkout below. This step can itself be refused for the same reason a
+ checkout can be — `` checked out in another worktree — in which
+ case report that as the (correctly attributed) blocker, per Phase 3, and
+ stop; do not treat the refusal as license to skip ahead.
+2. Check out the default branch, then **verify the checkout actually happened**
(`git branch --show-current`). A checkout silently fails when another worktree
holds the branch, and every step after this one would otherwise run against
the wrong branch.
-2. Fast-forward with `--ff-only`. A divergence means the default branch moved in
- a way this skill will not reconcile: report it and stop. Never a merge commit,
- never a rebase, never a reset.
+3. Fast-forward with `--ff-only` (belt over the braces of step 1 — a no-op when
+ the pre-checkout fetch already brought the local ref current). A divergence
+ means the default branch moved in a way this skill will not reconcile:
+ report it and stop. Never a merge commit, never a rebase, never a reset.
-Gate: HEAD is confirmed on the default branch by re-read, and the fast-forward
-either succeeded or was reported as a refused divergence.
+Gate: the local default ref was fast-forwarded before the checkout — or the
+fast-forward's refusal was itself reported as a fact and the skill stopped —
+HEAD is confirmed on the default branch by re-read, and the belt-and-braces
+`--ff-only` pull either succeeded (typically a no-op) or was reported as a
+refused divergence.
## Phase 5 — Delete the merged local branch · gate: STOP
-Offer deletion of the now-merged local branch, with the Phase 1 ancestry proof
-restated. One confirmation, naming the branch.
-
-- `git branch -d` only. Never `-D`: the safety check is the point, and if `-d`
- refuses, the ancestry proof and the refusal disagree — report both and stop.
+Offer deletion of the now-merged local branch, with the Phase 1 containment
+proof restated. One confirmation, naming the branch.
+
+- `git branch -d` first, always. Never reach for `-D` on the assumption that a
+ refusal means `-d` can't handle a squash merge — it often can. `git branch
+ -d`'s safety check accepts a branch merged into its **upstream**, not only
+ into HEAD: with an upstream still configured and equal to the tip, `-d`
+ succeeds (with a warning) even for a squash merge that `--is-ancestor` alone
+ would call unmerged. Do not conclude a refusal here means `-D` is required —
+ check the reason first.
+- **The sanctioned `-D` path.** `-d` typically refuses once the remote branch
+ has been auto-deleted and pruned, because there is then no upstream left for
+ `-d` to test reachability against. When that happens, `git branch -D` is
+ permitted, but ONLY when both hold:
+ 1. Phase 1's proof, established this run, was the PR-record squash proof
+ (`MERGED` and `headRefOid == HEAD`) — not a bare ancestry pass, not a
+ stale or assumed proof.
+ 2. The confirmation restates that Phase-1 proof and explicitly names the
+ branch.
+ Frame this precisely: `-d`'s safety check tests SHA-reachability, and the
+ squash proof has already shown that instrument is the wrong one for this
+ repo's merge mode. The PR-record proof plus a named confirmation **replaces**
+ the check that `-d` can no longer run — it does not bypass it. Everywhere
+ else, `-D` stays forbidden exactly as before: if `-d` refuses and Phase 1's
+ proof was anything other than the PR-record squash proof, report both and
+ stop.
- The **remote** branch is never touched. If the user wants it gone, that is
theirs to do or the platform's auto-delete-on-merge to do.
Declining leaves the branch in place. That is a normal outcome, not a failure.
Gate: the branch is deleted only after an explicit confirmation naming it, via
-`-d`, with the remote untouched.
+`-d`, or via `-D` restricted to the sanctioned path above with the proof
+restated, with the remote untouched.
## Phase 6 — Receipt
@@ -129,18 +209,25 @@ correct outcome reported plainly, not an error.
## Hard rules
-- MUST fetch and prove `HEAD` is an ancestor of the fetched default branch before
- any deletion. MUST NOT infer merge state from a `: gone]` upstream alone.
+- MUST fetch and prove `HEAD` is **contained** in the fetched default branch
+ before any deletion — via ancestry, via the PR-record squash proof (`MERGED`
+ and `headRefOid == HEAD`), or, without `gh`, via a byte-identical tree diff.
+ MUST NOT infer merge state from a `: gone]` upstream alone.
- MUST classify every artifact, and MUST treat anything not provably redundant or
superseded as unique.
- MUST NOT discard a unique or unclassifiable artifact without an explicit
confirmation naming that item.
- MUST confirm every removal and the branch deletion individually — no batched or
implied yes.
-- MUST re-read the current branch after checkout before acting on it.
+- MUST fast-forward the local default ref (`git fetch origin :`)
+ before checking it out, or report the fast-forward's own refusal (e.g.
+ `` checked out in another worktree) as the blocker and stop. MUST
+ re-read the current branch after checkout before acting on it.
- MUST use `--ff-only`, and MUST NOT merge, rebase, or reset to reach the default
branch.
-- MUST use `git branch -d`, never `-D`; MUST NOT delete a remote branch,
- force-push, or write to the default branch.
+- MUST use `git branch -d`, and MAY use `-D` ONLY when the Phase-1 squash-merge
+ proof held this run and `-d` refused, with that proof restated and the branch
+ named in the confirmation. Everywhere else `-D` is forbidden. MUST NOT delete a
+ remote branch, force-push, or write to the default branch.
- MUST NOT drop a stash — report and route, as `/ca:standup` does.
- MUST NOT route to `/ca:override` when blocked. Name the gate instead.
diff --git a/site/src/curated/commands/cleanup.md b/site/src/curated/commands/cleanup.md
index add443a6..a80673e6 100644
--- a/site/src/curated/commands/cleanup.md
+++ b/site/src/curated/commands/cleanup.md
@@ -2,9 +2,9 @@
entity: commands/cleanup
related: [commands/pr, commands/standup, commands/status, skills/post-merge-cleanup]
gates:
- - gate: fetched ancestry proof
+ - gate: fetched containment proof
when: before any local artifact or branch can be removed
- effect: fetches the default branch and stops unless the current HEAD is proven contained in that fetched ref
+ effect: fetches the default branch and stops unless the current HEAD is proven contained in that fetched ref — by SHA-ancestry, or, for a squash merge, by the merged PR record matching HEAD
- gate: residue classification
when: the working tree or stash list contains leftover material
effect: every item is classified with evidence; uncertainty is treated as unique rather than disposable
@@ -18,7 +18,9 @@ gates:
Use this after a pull request has merged but your local checkout is still on the topic branch.
`cleanup` proves that the branch's work reached the fetched default branch, inventories everything
left locally, and walks back to a clean default checkout without treating an upstream marked
-`gone` as proof.
+`gone` as proof. This repo squash-merges by default, so a plain SHA-ancestry check fails for most
+landings; when it does, `cleanup` falls back to the merged PR record (the PR's `headRefOid`
+matching local HEAD) as an equally valid containment proof, and reports whichever proof held.
## Usage
@@ -33,8 +35,11 @@ uses `/ca-cleanup`.
codeArbiter explains whether each leftover file is reproducible, replaced by landed work, or the
only remaining copy. You approve or decline each removal by name. A stash is reported but never
-dropped. After the tree is safe, the default branch is checked out and fast-forwarded, and the
-merged local branch is offered for deletion with `git branch -d`.
+dropped. Before the checkout, a stale local default ref is fast-forwarded from the fetched one so
+it can't be mistaken for a conflicting kept artifact; then the default branch is checked out and
+fast-forwarded, and the merged local branch is offered for deletion with `git branch -d`. If `-d`
+refuses because the remote branch is already gone, `-D` is offered instead, but only when the
+squash-merge PR-record proof held — restated in the confirmation alongside the branch name.
## Successful exit
diff --git a/site/src/curated/commands/standup.md b/site/src/curated/commands/standup.md
index ba8cc2ff..9352a75d 100644
--- a/site/src/curated/commands/standup.md
+++ b/site/src/curated/commands/standup.md
@@ -4,7 +4,7 @@ related: [status, task]
gates:
- gate: per-action confirmation
when: pruning a merged branch or removing a stale worktree
- effect: each candidate is confirmed individually before it's deleted — declining leaves it in place, and nothing is batched into one implied yes
+ effect: branch deletions are confirmed one at a time; stale worktrees may be confirmed as one explicitly enumerated group naming every member, or individually if you'd rather keep some — either way declining leaves items in place, and nothing is ever an implied yes
- gate: fast-forward-only pull
when: the working tree is clean and the branch is behind upstream
effect: a --ff-only pull is offered; a dirty tree withholds the pull and reports the dirty state instead, and a diverged branch is refused rather than merged
@@ -16,12 +16,23 @@ gates:
## What it does
The daily hygiene checklist, made routine and gated: fetch and offer a fast-forward pull, list
-locally-merged branches and stale worktrees for individual confirm-and-delete, surface stashes,
-uncommitted changes, and un-pushed commits with a suggested next step, run a read-only advisory
-board-drift sweep comparing recent merge history against `open-tasks.md`, and offer to archive
-long-done tasks one at a time. Nothing destructive happens without an explicit per-item yes, and
-the task board is never auto-flipped — a drifted task is resolved only through
-`/ca:task done `.
+locally-merged branches for individual confirm-and-delete, list stale worktrees for either
+confirm-and-delete as one named group or individually, surface stashes, uncommitted changes, and
+un-pushed commits with a suggested next step, run a read-only advisory board-drift sweep comparing
+recent merge history against `open-tasks.md`, and offer to archive long-done tasks one at a time.
+Nothing destructive happens without an explicit yes, and the task board is never auto-flipped — a
+drifted task is resolved only through `/ca:task done `.
+
+A locally-merged branch that was squash-merged (its upstream already pruned to `: gone]`) will
+typically refuse a plain `git branch -d`. When that happens, `standup` checks the merged PR record
+for that branch; if it proves containment (the PR's `headRefOid` matching the branch's local tip),
+`-D` is offered instead, with the proof and the branch name both stated in the confirmation.
+Without that proof, a refusal is reported and left alone.
+
+Stale worktrees are presented together with the evidence for each, then offered as one explicitly
+enumerated group — naming every member — so a multi-lane day doesn't turn into four or five
+identical yes prompts. Declining the group falls back to confirming each worktree on its own;
+either way, the main worktree is never touched and nothing is removed without a yes.
The archival sweep exists because long-done tasks accumulate until the in-flight count stops
meaning anything. Each aged item is proposed separately and moved by its own helper call: the