From d524bf9f1978e1b2e2370348a05a5649d95423b6 Mon Sep 17 00:00:00 2001 From: SUaDtL Date: Tue, 4 Aug 2026 07:37:01 -0400 Subject: [PATCH 1/4] fix(cleanup): prove squash merges by PR record, ff the local default first, clear worktrees in groups CHANGELOG: post-merge cleanup now proves squash merges via the PR record instead of STOPping on them, fast-forwards a stale local default before checkout, and standup can clear stale worktrees as one named group Claude-Session: https://claude.ai/code/session_01QjJeSbcwPHwMmd6CEZeagB --- README.md | 4 +- core/surface/commands/cleanup.md | 17 +- core/surface/commands/standup.md | 28 +++- core/surface/skills/INDEX.md | 2 +- .../skills/post-merge-cleanup/SKILL.md | 157 ++++++++++++++---- package.json | 2 +- plugins/ca-codex/.codex-plugin/plugin.json | 2 +- plugins/ca-codex/routines/INDEX.md | 2 +- .../routines/post-merge-cleanup/SKILL.md | 157 ++++++++++++++---- plugins/ca-codex/skills/INDEX.md | 2 +- plugins/ca-codex/skills/ca-cleanup/SKILL.md | 17 +- plugins/ca-codex/skills/ca-standup/SKILL.md | 28 +++- plugins/ca-pi/SKILLS.md | 2 +- plugins/ca-pi/generated/command-catalog.json | 2 +- plugins/ca-pi/package.json | 2 +- plugins/ca-pi/routines/INDEX.md | 2 +- .../routines/post-merge-cleanup/SKILL.md | 157 ++++++++++++++---- plugins/ca-pi/skills/ca-cleanup/SKILL.md | 17 +- plugins/ca-pi/skills/ca-standup/SKILL.md | 28 +++- plugins/ca/.claude-plugin/plugin.json | 2 +- plugins/ca/commands/cleanup.md | 17 +- plugins/ca/commands/standup.md | 28 +++- plugins/ca/skills/INDEX.md | 2 +- plugins/ca/skills/post-merge-cleanup/SKILL.md | 157 ++++++++++++++---- site/src/curated/commands/cleanup.md | 15 +- site/src/curated/commands/standup.md | 25 ++- 26 files changed, 661 insertions(+), 213 deletions(-) diff --git a/README.md b/README.md index 8ea04715..3a921ca8 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ project context. You decide. codeArbiter enforces. Claude Code plugin Codex plugin Pi Feature Forge preview -version 2.11.1 +version 2.11.3 commands skills agents @@ -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.0`; +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 6a46a727..a9c2e9db 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "ca-pi", - "version": "0.2.0", + "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 d7bef188..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.0", + "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/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 35bee47c..0c44338a 100644 --- a/plugins/ca-pi/package.json +++ b/plugins/ca-pi/package.json @@ -1,6 +1,6 @@ { "name": "ca-pi", - "version": "0.2.0", + "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 f963449c..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.1", + "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 From 569c00b6ca40047c1740c0c84f4cb73df3b48ee0 Mon Sep 17 00:00:00 2001 From: SUaDtL Date: Tue, 4 Aug 2026 07:38:05 -0400 Subject: [PATCH 2/4] chore(ca-pi): add missing CHANGELOG entry for 0.2.2 The ca-pi release guard requires the version bump to ride a new changelog heading; the prior commit bumped the version without one. Claude-Session: https://claude.ai/code/session_01QjJeSbcwPHwMmd6CEZeagB --- plugins/ca-pi/CHANGELOG.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/plugins/ca-pi/CHANGELOG.md b/plugins/ca-pi/CHANGELOG.md index 3b25e536..97d9061f 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.0] - 2026-08-01 ### Added From ff26b4d03ab8a68f84c4b1ff177f4af9b6885956 Mon Sep 17 00:00:00 2001 From: SUaDtL Date: Tue, 4 Aug 2026 07:39:36 -0400 Subject: [PATCH 3/4] chore(ca): add missing CHANGELOG entry for 2.11.3 The release-trace guard requires ca's manifest version to be reconciled with CHANGELOG.md's top section; add the missing heading for this PR's version bump. Claude-Session: https://claude.ai/code/session_01QjJeSbcwPHwMmd6CEZeagB --- CHANGELOG.md | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7933577b..bbe545be 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.1] — 2026-08-03 ### Fixed From 9175f95fbd6a4820dc96abd56379ad5f66859907 Mon Sep 17 00:00:00 2001 From: SUaDtL Date: Tue, 4 Aug 2026 08:19:18 -0400 Subject: [PATCH 4/4] test(cleanup): repoint the #308 acceptance-gate pins at the #586 containment contract test_the_skill_carries_the_issue_308_acceptance_gates pinned the OLD Phase-1 ancestry-only wording and the OLD unconditional "-d, never -D" wording, both of which this branch deliberately replaced (#586). Repoint at the new contract instead of deleting: containment is now proven by ancestry OR the PR-record squash proof (MERGED + headRefOid == HEAD), and `-D` is forbidden everywhere except the scoped Phase-5 exception. Also fixes an assertion that was already wrap-fragile ("MUST NOT delete a remote branch" spans a line break in the rewritten Hard rules block) by matching on \s+ instead of a literal space, following the pattern the file already used elsewhere. Verified the repointed assertions actually pin the new contract: swapped in the pre-#586 SKILL.md from origin/main for one vendored copy and confirmed the test fails at the first new assertion (`contained in the fetched default`); separately reverted just the Phase-1 gate line and the Phase-5 -d/-D hard rule and confirmed the `-D` scoping assertion also fails on its own. Restored the fix before committing. CHANGELOG: repoints the #308 acceptance-gate test at the #586 content-containment contract instead of the retired ancestry-only wording, so the suite pins the current gate rather than a regression to it Claude-Session: https://claude.ai/code/session_01QjJeSbcwPHwMmd6CEZeagB --- .../test_routing_and_cleanup_surface.py | 25 +++++++++++++++---- 1 file changed, 20 insertions(+), 5 deletions(-) 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")