This skill should be used when preparing a feature for production deployment. Enforces the lifecycle checklist: commit artifacts, update docs, capture learnings, create PR. Version bumping happens in CI.
Scanned 5/27/2026
Install via CLI
openskills install jikig-ai/soleur---
name: ship
description: "This skill should be used when preparing a feature for production deployment. Enforces the lifecycle checklist: commit artifacts, update docs, capture learnings, create PR. Version bumping happens in CI."
---
# ship Skill
**Purpose:** Enforce the full feature lifecycle before creating a PR, preventing missed steps like forgotten /compound runs and uncommitted artifacts. Version bumping is handled by CI at merge time via semver labels.
**CRITICAL: No command substitution.** Never use `$()` in Bash commands. When a step says "get value X, then use it in command Y", run them as **two separate Bash tool calls** -- first get the value, then use it literally in the next call. This avoids Claude Code's security prompt for command substitution.
## Headless Mode Detection
If `$ARGUMENTS` contains `--headless`, set `HEADLESS_MODE=true`. Strip `--headless` from `$ARGUMENTS` before processing remaining args.
When `HEADLESS_MODE=true`:
- Phase 2: auto-invoke `skill: soleur:compound --headless` (forward flag, no user prompt)
- Phase 4: if test files are missing, continue without writing (CI gate catches this)
- Phase 6: auto-accept generated PR title/body without user confirmation
- Phase 7: if CI is flaky or unrelated check fails, abort pipeline (do not ask whether to proceed)
- All failure conditions: abort with clear error message, do not prompt
## Phase 0: Context Detection
Detect the current environment:
```bash
# Determine current branch and worktree
git rev-parse --abbrev-ref HEAD
git worktree list
pwd
```
**Branch safety check (defense-in-depth):** If the branch from the command above is `main` or `master`, abort immediately with: "Error: ship cannot run on main/master. Checkout a feature branch first." This is defense-in-depth alongside PreToolUse hooks -- it fires even if hooks are unavailable (e.g., in CI).
**Trailer-parse verification gate (defense-in-depth for [hr-always-read-a-file-before-editing-it]).** For every commit on this branch since `origin/main`, parse any `Key: value`-shaped lines in the body and confirm `git interpret-trailers` recognises each as a trailer. The modal failure is a blank line between an `Allowlist-Widened-By:`/`Reviewed-by:`/`Signed-off-by:` line and `Co-Authored-By:`, which silently demotes the upstream trailer into body prose and breaks downstream consumers parsing via `git log --format='%(trailers:key=NAME,valueonly)'`:
```bash
RC=0
for sha in $(git rev-list origin/main..HEAD); do
BODY=$(git log -1 --format=%B "$sha")
declare -a CANDIDATES=()
while IFS= read -r line; do
[[ "$line" =~ ^([A-Z][A-Za-z-]+):[[:space:]] ]] && CANDIDATES+=("${BASH_REMATCH[1]}")
done <<< "$BODY"
for key in "${CANDIDATES[@]}"; do
val=$(git log -1 --format="%(trailers:key=${key},valueonly)" "$sha")
if [[ -z "$val" ]]; then
echo "[FAIL] ${sha:0:8}: '${key}:' is in the body but does not parse as a trailer." >&2
echo " Fix: git rebase -i, reword the commit to make the final paragraph a contiguous Key: value block." >&2
RC=1
fi
done
done
exit $RC
```
If the gate fails, do NOT proceed — reword the offending commit(s) via `git rebase -i` (or `git commit --amend` if the failing commit is HEAD AND has not been pushed) so the final paragraph is a pure contiguous `Key: value` block. See `knowledge-base/project/learnings/2026-05-16-git-trailer-parser-requires-contiguous-key-value-block.md` and PR #4106.
Load project conventions:
```bash
if [[ -f "CLAUDE.md" ]]; then
cat CLAUDE.md
fi
```
Identify the base branch (main/master) for comparison:
```bash
git remote show origin | grep 'HEAD branch'
```
## Phase 1: Validate Artifact Trail
Check that feature artifacts exist and are committed. Look for files related to the current feature branch name:
Get the current branch name:
```bash
git rev-parse --abbrev-ref HEAD
```
Extract the feature name from the result by stripping the `feat-`, `feature/`, `fix-`, or `fix/` prefix. Then search for related artifacts using the Glob and Bash tools:
- Brainstorms: glob `knowledge-base/project/brainstorms/*FEATURE*`
- Specs: check `knowledge-base/project/specs/feat-FEATURE/spec.md`
- Plans: glob `knowledge-base/project/plans/*FEATURE*`
- Uncommitted files: `git status --porcelain knowledge-base/`
**If artifacts exist but are not committed:** Stage and commit them.
**If no artifacts exist:** Note this in the checklist but do not block. Not all features go through the full brainstorm/plan cycle.
## Phase 1.5: Review Evidence Gate
Check for evidence that `/review` ran on the current branch. This is defense-in-depth --
`/one-shot` already enforces review ordering, but direct `/ship` invocations bypass it.
**Step 1: Check for review artifacts (legacy).**
Search for todo files tagged as code-review findings:
```bash
grep -rl "code-review" todos/ 2>/dev/null | head -1 || true
```
**Step 2: Check commit history for review evidence.**
If Step 1 found nothing, check for review commit patterns (both legacy and new fix-inline convention from `rf-review-finding-default-fix-inline`):
```bash
git log origin/main..HEAD --oneline | grep -E "(refactor: add code review findings|^[a-f0-9]+ review: )" || true
```
The `^[a-f0-9]+ review:` alternative matches the new convention — `review: <summary> (P<N>)` commits produced when findings are fixed inline per `rf-review-finding-default-fix-inline`.
**Step 3: Check for GitHub issues with `code-review` label (current).**
If Steps 1 and 2 found nothing, check for review issues linked to this branch's PR. This requires two separate Bash calls (no command substitution):
Step 3a — get the current branch name:
```bash
git branch --show-current
```
Step 3b — get the PR number for that branch (use the branch name from Step 3a literally):
```bash
gh pr list --head <branch-name> --state open --json number --jq '.[0].number // empty'
```
Step 3c — if Step 3b returned a PR number, search for code-review issues referencing it:
```bash
gh issue list --label code-review --search "PR #<number>" --limit 1 --json number --jq '.[0].number // empty'
```
If `gh` fails or is unavailable, treat as no output (fail open on Signal 3).
**Note:** Three signals are checked, any one suffices:
- Signal 1 (`todos/` grep): coupled to legacy review workflow (pre-#1329)
- Signal 2 (commit message grep): matches legacy `refactor: add code review findings` OR new `review: <summary>` fix-inline commits (post-#2374)
- Signal 3 (`gh issue list`): coupled to `review-todo-structure.md` issue body template (`**Source:** PR #<number>`). Expected to be empty under the new fix-inline default unless findings were scoped out — Signal 2 is the primary signal post-#2374.
**If any step produced output:** Review evidence found. Continue to Phase 2.
**If no step produced output:**
**Headless mode:** Abort with: "Error: no review evidence found on this branch. Run `/review` before `/ship`, or use `/one-shot` for the full pipeline."
**Interactive mode:** Present options via AskUserQuestion:
"No evidence that `/review` ran on this branch. How would you like to proceed?"
- **Run /review now** -> invoke `skill: soleur:review`, then continue to Phase 2
- **Skip review** -> continue to Phase 2 (user accepts the risk; this also covers zero-finding reviews where review ran cleanly)
- **Abort** -> stop shipping
**Why:** Identified during #1129/#1131/#1134 implementation session when the `/one-shot` pipeline ran correctly but the gap was noted as a systemic risk for direct `/ship` invocations. See #1170.
## Phase 2: Capture Learnings
Check if /compound was run for this feature. Use the feature name extracted in Phase 1:
```bash
git log --oneline --since="1 week ago" -- knowledge-base/project/learnings/
```
Also use the Glob tool to search `knowledge-base/project/learnings/**/*FEATURE*` (replacing FEATURE with the actual name).
**If no recent learning exists:** Check for unarchived KB artifacts before offering a choice.
Search for unarchived artifacts matching the feature name (excluding `archive/` paths) using the Glob tool:
- Brainstorms: `knowledge-base/project/brainstorms/*FEATURE*`
- Plans: `knowledge-base/project/plans/*FEATURE*`
- Spec directory: `knowledge-base/project/specs/feat-FEATURE/`
**If unarchived artifacts exist:** Do NOT offer Skip. List the found artifacts and explain that compound must run to consolidate and archive them before shipping. Then use `skill: soleur:compound` (or `skill: soleur:compound --headless` if `HEADLESS_MODE=true`). The compound flow will automatically consolidate and archive the artifacts on `feat-*` branches.
**If no unarchived artifacts exist:**
**Headless mode:** Auto-invoke `skill: soleur:compound --headless` without prompting.
**Interactive mode:** Offer the standard choice:
"No learnings documented for this feature. Run /compound to capture what you learned?"
- **Yes** -> Use `skill: soleur:compound`
- **Skip** -> Continue without documenting
After compound completes (or is skipped), continue to Phase 3 immediately. Do NOT stop or wait for user input — the ship pipeline is not complete until Phase 7 finishes.
## Phase 3: Verify Documentation
Check if new commands, skills, or agents were added in this branch.
**Step 1** (separate Bash call): Get the merge base hash.
```bash
git merge-base HEAD origin/main
```
**Step 2** (separate Bash call): Use the hash from Step 1 literally in this command.
```bash
git diff --name-status HASH..HEAD -- plugins/soleur/commands/ plugins/soleur/skills/ plugins/soleur/agents/
```
Replace `HASH` with the actual commit hash from Step 1. Do NOT use `$()` to combine these.
**If new components were added:**
1. Run `bash scripts/sync-readme-counts.sh` to auto-update counts in both `README.md` and `plugins/soleur/README.md`
2. Verify new entries appear in the correct tables in `plugins/soleur/README.md`
3. If `knowledge-base/marketing/brand-guide.md` exists, check for stale agent/skill counts and update them
**If no new components:** Run `bash scripts/sync-readme-counts.sh --check` to verify counts are still in sync. Fix if drifted.
## Phase 4: Run Tests
First, verify that new source files have corresponding test files:
Find new source files added in this branch. First, get the merge base hash (reuse from Phase 3 if already obtained):
```bash
git merge-base HEAD origin/main
```
Then, in a separate Bash call, use the hash literally:
```bash
git diff --name-only --diff-filter=A HASH..HEAD
```
Replace `HASH` with the actual commit hash. Filter results for `.ts`, `.js`, `.rb`, `.py` files (excluding test/spec/config files).
For each new source file, check if a corresponding test file exists (e.g., `foo.ts` -> `foo.test.ts` or `foo.spec.ts`). Report any source files missing test coverage.
**If test files are missing:**
**Headless mode:** Continue without writing tests (CI gate catches missing coverage).
**Interactive mode:** Ask the user whether to write tests now or continue without them. Do not silently proceed.
Then run the project's full test suite (matches CI):
```bash
bash scripts/test-all.sh
```
**If tests fail:**
1. **Check if failures are pre-existing:** Run the same test command on an unmodified checkout (or compare failure count/names with main). If the exact same tests fail on main, the failures are pre-existing.
2. **If failures are caused by this branch:** Stop and fix before proceeding.
3. **If failures are pre-existing:** Create a GitHub issue to track them (`gh issue create --title "fix: N pre-existing test failures in <app>" --milestone "Post-MVP / Later" --label bug`), then continue. Do not silently bypass pre-existing failures — a red test suite normalizes breakage and masks future regressions. **Why:** In #1411, 71 pre-existing web-platform test failures were silently bypassed during ship. The tracking issue (#1413) was only created after the founder noticed post-session.
## Phase 5: Final Checklist
Create a TodoWrite checklist summarizing the state:
```text
Ship Checklist for [branch name]:
- [x/skip] Artifacts committed (brainstorm/spec/plan)
- [x/skip] Learnings captured (/compound)
- [x/skip] README counts synced (`bash scripts/sync-readme-counts.sh`)
- [x/skip] Tests pass
- [ ] Preflight passed (Phase 5.4 gate)
- [ ] Code review completed (Phase 5.5 gate)
- [ ] Undeferred operator-step gate passed (Phase 5.5 gate)
- [ ] Push to remote
- [ ] Create PR with semver label
- [ ] PR is mergeable (no conflicts)
- [ ] CI checks pass
```
## Phase 5.4: Pre-Flight Validation
Run technical readiness checks before creating the PR. This catches unapplied migrations, missing security headers, and bare-repo execution context.
Invoke the preflight skill via the **Skill tool**:
- If `HEADLESS_MODE=true`: `skill: soleur:preflight`, args: `--headless`
- Otherwise: `skill: soleur:preflight`
**If preflight reports any FAIL:** Abort the ship pipeline. Display the preflight results table and stop. Do not proceed to Phase 5.5 or Phase 6.
**If preflight reports all PASS or SKIP:** Continue to Phase 5.5 immediately. Do NOT stop or wait for user input after preflight passes — the ship pipeline is not complete until Phase 7 finishes. Nested skill invocations (preflight, compound) return control here; losing track of the pipeline state after a nested skill is a known failure mode.
## Phase 5.5: Pre-Ship Review Gates
Emit rule-application telemetry (records that the conditional-domain-gates phase was entered — see AGENTS.md `hr-before-shipping-ship-phase-5-5-runs`):
```bash
source "$(git rev-parse --show-toplevel)/.claude/hooks/lib/incidents.sh" && \
emit_incident hr-before-shipping-ship-phase-5-5-runs applied \
'Before shipping, `/ship` Phase 5.5 runs conditional'
```
### Code Review Completion Gate (mandatory)
Defense-in-depth check that review ran before shipping. Phase 1.5 catches this earlier, but if context compaction erased Phase 1.5's check or the skill was invoked mid-flow, this gate is the second net.
**Detection:** Check for review evidence using the same three signals described in Phase 1.5 (Signal 1: `todos/` grep, Signal 2: commit message grep, Signal 3: GitHub issues with `code-review` label). Run the same commands in the same order. See Phase 1.5 for full details and coupling notes.
**If review evidence is found:** Pass silently.
**If no review evidence is found:**
**Headless mode:** Abort with: "Error: no review evidence found on this branch. Run `/review` before `/ship`, or use `/one-shot` for the full pipeline."
**Interactive mode:** Display warning: "No code review was run before ship." Then invoke `skill: soleur:review`. After review completes, if findings include critical or high severity issues, resolve them before continuing to Phase 6.
### Review-Findings Exit Gate (mandatory)
Blocks merge when review findings from Phase 1.5 / Phase 5.5 Completion Gate
remain unresolved — neither fixed inline nor formally scoped out with a
`deferred-scope-out` label.
**Trigger:** Always runs after the Code Review Completion Gate passes.
Emit rule-application telemetry (records that the fix-inline-default gate ran — see AGENTS.md `rf-review-finding-default-fix-inline`):
```bash
source "$(git rev-parse --show-toplevel)/.claude/hooks/lib/incidents.sh" && \
emit_incident rf-review-finding-default-fix-inline applied \
"Review findings default to fix-inline on the PR bra"
```
**Detection:** Resolve the current PR number, then query for open, unresolved
review-origin issues that cross-reference this PR via body regex
`(Ref|Closes|Fixes) #<N>\b` — NOT `gh search`'s loose substring matcher
(which would match any body containing "<N>" as a substring, including
unrelated SHAs, timestamps, and inline numbers).
```bash
PR_NUMBER=$(gh pr view --json number --jq .number)
[[ "$PR_NUMBER" =~ ^[0-9]+$ ]] || { echo "Error: PR_NUMBER is not a positive integer: $PR_NUMBER"; exit 1; }
UNRESOLVED=$(gh issue list \
--state open \
--search "-label:deferred-scope-out -label:synthetic-test" \
--json number,title,body \
--jq '[.[]
| select(.title | test("^(review:|Code review #|Refactor:|arch:|compound:|follow-through:)"; "i"))
| select((.body // "") | test("(^|\\s)(Ref|Closes|Fixes) #'"$PR_NUMBER"'(\\s|$|[^0-9])"))
| {number, title}]')
COUNT=$(echo "$UNRESOLVED" | jq 'length')
```
Notes:
- `PR_NUMBER` is validated as digits-only before use (`[[ =~ ^[0-9]+$ ]]`).
This is the canonical defense against regex-metachar widening and shell/jq
injection — `gh issue list --jq` does not forward `--arg` to jq, so the
digits-only pre-check is the sole (and sufficient) safeguard. If this gate
is ever ported to two-stage piping (`gh ... --json ... | jq --arg pr ...`),
swap in `--arg` then.
- The regex anchors on keyword `Ref|Closes|Fixes` followed by `#<N>` followed
by a non-digit or end-of-string — prevents `#23750` matching when
`PR_NUMBER=2375`.
- `synthetic-test` label excluded so Phase 3 validation test issues
self-exclude.
- Body-keyword detection only: issues linked via GitHub's sidebar "Development
→ Link an issue" UI (without `Ref|Closes|Fixes #<N>` in the body) are NOT
detected. This is an accepted limitation — `Ref #N` is the canonical
cross-reference convention across this repo and the gate optimizes for
false-negative safety (missed detection) over false-positive merge-blocks.
- Perf contract: under 5s on a repo with <1000 open issues. If the GitHub
API returns 5xx, retry once with 2s backoff; on second failure, abort the
gate with the API error surfaced — do NOT silent-pass.
**If COUNT == 0:** Pass silently.
**If COUNT > 0:** Abort with a structured error listing each unresolved issue
number + title. Same abort path in both headless and interactive modes (no
`--force` flag, no interactive remediation menu). Message:
```text
Error: N unresolved review-origin issues reference this PR.
Resolve each by:
(a) Fixing inline on the branch and closing the issue, OR
(b) Adding a ## Scope-Out Justification section to the issue body AND
applying the deferred-scope-out label.
Issues:
- #A: <title>
- #B: <title>
```
**Why:** In #2374, 53 review-origin issues accumulated in 3 days because
findings were filed but never resolved before ship. This gate enforces the
fix-inline default at the merge boundary. See rule
`rf-review-finding-default-fix-inline`.
### Net-Issue-Flow Surfacing (advisory)
Before queueing auto-merge, compute and display the per-PR net-issue-flow:
how many issues this PR **closes** vs. how many `deferred-scope-out` issues
it **files**. The display is advisory — it does NOT block merge — but it
makes the backlog math visible at the last moment when the operator can
still pivot a filed issue back to inline.
**Why this surface exists.** PR #4452 introduced the cost-of-filing
auto-flip and concrete-trigger rules; this metric is the observability
layer that catches regressions in those rules. PRs #4418 and #4440 were on
track to file 6 deferred-scope-out issues combined; the operator manually
walked them back to 1 — but only because the math was visible during
synthesis. Once outside review synthesis, the per-PR delta becomes
invisible and the backlog accretes silently.
**Detection:**
```bash
PR_NUMBER=$(gh pr view --json number --jq .number)
[[ "$PR_NUMBER" =~ ^[0-9]+$ ]] || { echo "Error: PR_NUMBER is not a positive integer: $PR_NUMBER"; exit 1; }
# N: issues this PR closes via `Closes #X` / `Fixes #X` / `Resolves #X`.
# Body-keyword detection matches /ship Phase 6 issue detection conventions.
PR_BODY=$(gh pr view "$PR_NUMBER" --json body --jq .body)
CLOSING=$(printf '%s\n' "$PR_BODY" \
| grep -oiE '(close[sd]?|fix(e[sd])?|resolve[sd]?) #[0-9]+' \
| grep -oE '#[0-9]+' \
| sort -u \
| wc -l)
# M: deferred-scope-out issues opened AFTER the PR was created that cross-reference
# this PR via `Ref|Closes|Fixes #<N>` in the body. The `created:>=<PR-opened-date>`
# filter scopes the count to issues filed during THIS PR cycle (not pre-existing
# scope-outs that merely cross-reference the PR for context). Matches Phase 5.5
# detection regex.
PR_CREATED_AT=$(gh pr view "$PR_NUMBER" --json createdAt --jq .createdAt | cut -c1-10)
FILED=$(gh issue list \
--label deferred-scope-out \
--state open \
--search "created:>=${PR_CREATED_AT}" \
--json number,body \
--jq '[.[] | select((.body // "") | test("(^|\\s)(Ref|Closes|Fixes) #'"$PR_NUMBER"'(\\s|$|[^0-9])"))] | length')
NET=$(( FILED - CLOSING ))
```
**Display (always emit, never block):**
```text
PR net-issue-flow:
Closing: <CLOSING> (extracted from `Closes #X` keywords in body)
Filing: <FILED> (count of deferred-scope-out issues created during this PR cycle that Ref #<PR>)
Net: <signed NET> (positive = backlog growth)
```
**If `NET > 0`** (net-positive backlog growth), print a warning ABOVE the
PR body in the ship checklist:
```text
⚠ Net-positive backlog flow: this PR adds +<NET> issues to the deferred-scope-out queue.
Before merging, consider whether any of the <FILED> filed issues could be done inline instead.
Cost-of-filing gate: ≤30 lines AND ≤2 files → auto-flip to fix-inline (see
plugins/soleur/skills/review/SKILL.md "Mechanical pre-CONCUR auto-flip").
```
The warning is advisory — it does NOT block auto-merge. The pipeline
continues into Phase 6 immediately after emitting the warning. The point
is to surface the math at the moment when the operator can still pivot,
not to add a new merge-blocker (the Review-Findings Exit Gate above
already covers the "unjustified filing" case).
**Why advisory (not blocking).** Advisory only — legitimate architectural-pivot deferrals can be net-positive and correct.
### Pre-Ship Domain Review (conditional)
Domain leaders are consulted at brainstorm time but not at ship time. The actual deliverables may have implications the brainstorm couldn't predict. This phase runs three conditional gates in parallel.
### CMO Content-Opportunity Gate
**Trigger:** PR matches ANY of: (a) touches files in `knowledge-base/product/research/`, `knowledge-base/marketing/`, or adds new workflow patterns (new AGENTS.md rules, new skill phases); (b) has a `semver:minor` or `semver:major` label; (c) title matches `^feat(\(.*\))?:` pattern.
**Detection:** Run `git diff --name-only origin/main...HEAD` and check file paths against trigger (a). Run `gh pr view --json labels,title` and check against triggers (b) and (c). If any trigger matches, proceed to "If triggered."
**If triggered:**
1. Spawn the CMO agent with a pre-ship content assessment prompt: "Assess content and distribution opportunities from this PR. What was produced, what data points are content-worthy, which channels should be used, and what's the recommended timing (ship with PR or schedule for later)?"
2. Present the CMO's recommendations to the user.
3. **Interactive mode:** Ask "Create content now, schedule for later, or skip?" Options: Create now (invoke content-writer/social-distribute), Schedule (create a GitHub issue with content brief), Skip.
4. **Headless mode:** Auto-create a GitHub issue with the CMO's content brief for later action. Do not block the ship.
5. **Update content strategy (mandatory if content is scheduled or created).** When a content piece is identified (option 1 or 2 above), update `knowledge-base/marketing/content-strategy.md`: add the piece to the content pipeline table under the appropriate pillar AND insert it into the rolling quarterly calendar at the correct week. A GitHub issue without a content strategy entry is an orphan — it will be forgotten. **Why:** In #1173, a methodology blog post was created as issue #1176 but never added to the content strategy calendar, requiring a manual fix.
**Why:** In #1173, a research sprint produced a novel methodology with compelling data, but no content was planned because the CMO was only consulted when the scope was "should we explore this?" — not when the actual content existed.
### CMO Website Framing Review Gate
**Trigger:** PR modifies `knowledge-base/marketing/brand-guide.md` — specifically the Value Proposition Framings, Positioning, Tagline, or Voice sections. Also triggers if the PR modifies value prop findings or competitive positioning documents that inform website copy.
**Detection:** Run `git diff --name-only origin/main...HEAD` and check for `brand-guide.md`. If present, check `git diff origin/main...HEAD -- knowledge-base/marketing/brand-guide.md` for changes to positioning-related sections.
**If triggered:**
1. Spawn the CMO agent (or conversion-optimizer for landing page specifics) with a website framing audit prompt. **Read the site source templates directly from the repo** (e.g., `apps/web-platform/`, `docs/`, or the Eleventy source directory) — do NOT use Playwright to fetch the rendered site when the source files are local. Prompt: "The brand guide's value proposition framings have been updated. Audit the website source templates for alignment: does the hero headline, subheadline, feature descriptions, and pricing page messaging match the updated framing recommendations? Identify specific copy that needs updating and propose replacements with file paths and line numbers."
2. Present the audit findings to the user.
3. **Interactive mode:** Ask "Apply website copy updates now, create issue for later, or skip?" Options: Apply now (edit site templates), Schedule (create GitHub issue with copy changes), Skip.
4. **Headless mode:** Auto-create a GitHub issue with the copy audit findings for later action.
**Why:** In #1173, the brand guide was updated with a new primary framing ("Stop hiring, start delegating"), a memory-first A/B variant, and trust scaffolding recommendations — but the website still used the old framing. Brand guide changes that don't cascade to the website create a disconnect between strategy and execution.
### COO Expense-Tracking Gate
**Trigger:** The PR or session involved signing up for new services, provisioning new tools, subscribing to APIs, or using paid external resources during implementation. Also triggers if the diff adds new entries to infrastructure configs, Terraform files, or references new SaaS tools not already in `knowledge-base/operations/expenses.md`.
**Detection:** Scan the session for: account creation actions (Playwright flows, CLI signups), new API key generation, new tool installations, new Terraform resources, or references to services not already tracked in the expense ledger. Also check `git diff origin/main...HEAD` for new domain names, new provider references in `.tf` files, or new environment variables suggesting new service integrations.
**If triggered:**
1. Spawn the COO agent with an expense-tracking prompt: "Review this PR for new tools, services, or subscriptions introduced during implementation. Check each against `knowledge-base/operations/expenses.md`. For any not already tracked, provide the service name, estimated cost, billing cycle, and category for the expense ledger."
2. Apply the COO's recommended updates to `expenses.md`.
3. **Interactive mode:** Present additions for confirmation before editing.
4. **Headless mode:** Auto-apply and commit.
**If not triggered:** Skip silently.
**Why:** New tools and subscriptions adopted during implementation often go unrecorded in the expense ledger because they feel incidental to the engineering work. The COO gate ensures every new cost is tracked at ship time, not discovered months later during a financial review.
### gdpr-gate `compliance/critical` Auto-Label Gate
**Trigger:** PR diff matches `^plugins/soleur/skills/gdpr-gate/` OR the referenced plan/spec file declares `brand_survival_threshold: single-user incident`.
**Detection:**
```bash
gdpr_gate_touch=$(git diff main...HEAD --name-only | grep -E '^plugins/soleur/skills/gdpr-gate/' | head -n 1)
sui_plan=$(gh pr view --json body --jq .body \
| grep -oE 'knowledge-base/project/(plans|specs)/[^[:space:])]+' | head -n 1 || true)
sui_threshold=""
if [[ -n "$sui_plan" && -f "$sui_plan" ]]; then
sui_threshold=$(grep -E '^brand_survival_threshold:\s*single-user incident' "$sui_plan" || true)
fi
```
**If triggered AND PR is not already labeled `compliance/critical`:**
1. Apply the label: `gh pr edit <N> --add-label compliance/critical` (idempotent — `gh` silently no-ops if already applied).
2. Announce: "Auto-applied `compliance/critical` to PR #<N> (gdpr-gate diff match) — `user-impact-reviewer` will be invoked at PR-review time per `review/SKILL.md` conditional-agent block."
**Why:** AC10 of any `single-user incident` plan requires PR co-label. Operator-attested labels are a workflow-gap class (see #3521 review user-impact #7) — auto-application closes the gap. Idempotent + reversible (operator can remove if false-positive).
### gdpr-gate Critical-Finding Acknowledgment Gate
**Trigger:** PR diff matches the `hr-gdpr-gate-on-regulated-data-surfaces` canonical regex (mirrored in `plugins/soleur/skills/gdpr-gate/SKILL.md` §"Path globs (canonical)" and `plugins/soleur/skills/gdpr-gate/scripts/gdpr-gate.sh`) AND the PR body references an open issue with label `compliance/critical` via `Closes #N` or `Ref #N`.
**Detection:**
```bash
CANONICAL_REGEX='^(apps/web-platform/supabase/migrations/|apps/web-platform/lib/auth/|apps/web-platform/server/.*auth.*\.(ts|tsx|js)|apps/web-platform/app/api/.*\.(ts|tsx)$|.*\.sql$)'
diff_match=$(git diff main...HEAD --name-only | grep -E "$CANONICAL_REGEX" | head -n 1)
crit_refs=$(gh pr view --json body --jq .body | grep -oE '(Closes|Ref) #[0-9]+' | head -n 5)
```
For each `crit_ref`, check `gh issue view <N> --json labels --jq '.labels[].name'` for `compliance/critical`.
**If triggered:**
1. Verify each `compliance/critical` issue referenced has a corresponding row in `knowledge-base/legal/compliance-posture.md` Active Items.
2. **Interactive mode:** Ask "Critical finding #N has no Active Items row. File the row now via `/soleur:compound`, or proceed with operator acknowledgment recorded inline?" Options: (a) File row, (b) Acknowledge inline, (c) Halt.
3. **Headless mode:** Halt — operator must run `/soleur:ship` interactively when a `compliance/critical` issue is referenced. Auto-merging without an Active Items row is a workflow violation.
**If not triggered:** Skip silently.
**Why:** Critical findings are the load-bearing artifact for `single-user incident` brand-survival; auto-merge without an Active Items row produces silent compliance drift. Defense-in-depth alongside `/soleur:gdpr-gate`'s plan-time and work-time gates.
### Deploy Pipeline Fix Drift Gate
**Trigger:** PR touches any of the 6 `terraform_data.deploy_pipeline_fix` trigger files:
- `apps/web-platform/infra/ci-deploy.sh`
- `apps/web-platform/infra/ci-deploy-wrapper.sh`
- `apps/web-platform/infra/webhook.service`
- `apps/web-platform/infra/cat-deploy-state.sh`
- `apps/web-platform/infra/canary-bundle-claim-check.sh`
- `apps/web-platform/infra/hooks.json.tmpl`
**Detection:**
The five trigger files are enumerated as a single bash array. The regex below MUST be derived from this array — keep the gate's reject criteria, documentation block, and test fixtures in sync (per `cq-when-a-plan-prescribes-a-validator-guard-or` — guard-surface coupling). If `apps/web-platform/infra/server.tf`'s `triggers_replace` `sha256(join(",",...))` block is changed (file added, removed, renamed), update the array, the regex, and `plugins/soleur/test/ship-deploy-pipeline-fix-gate.test.ts` in the same PR.
```bash
DEPLOY_PIPELINE_FIX_TRIGGERS=(
"apps/web-platform/infra/ci-deploy.sh"
"apps/web-platform/infra/ci-deploy-wrapper.sh"
"apps/web-platform/infra/webhook.service"
"apps/web-platform/infra/cat-deploy-state.sh"
"apps/web-platform/infra/canary-bundle-claim-check.sh"
"apps/web-platform/infra/hooks.json.tmpl"
"apps/web-platform/infra/deploy-inngest-bootstrap.sudoers"
"apps/web-platform/infra/infra-config-apply.sh"
"apps/web-platform/infra/push-infra-config.sh"
)
DPF_REGEX='^apps/web-platform/infra/(ci-deploy\.sh|ci-deploy-wrapper\.sh|webhook\.service|cat-deploy-state\.sh|canary-bundle-claim-check\.sh|hooks\.json\.tmpl|deploy-inngest-bootstrap\.sudoers|infra-config-apply\.sh|push-infra-config\.sh)$'
git diff --name-only origin/main...HEAD | grep -E "$DPF_REGEX"
```
If the grep matches at least one path, the gate fires. Trigger condition is "≥1 match" — the gate fires once for the PR, not once per matched file.
**If triggered:**
The PR's diff will produce drift on `terraform_data.deploy_pipeline_fix` — by design, because `hcloud_server.web` has `lifecycle.ignore_changes = [user_data]` (per `#967`) so cloud-init can't re-apply.
**Auto-apply on merge.** The [`apply-deploy-pipeline-fix.yml`](../../../../.github/workflows/apply-deploy-pipeline-fix.yml) workflow auto-fires on push to `main` when any of the 6 trigger files change. It runs the targeted `terraform apply` from Doppler `prd_terraform`, verifies server-side hashes match the merged tree, and auto-closes any open `infra: drift detected in web-platform` issue. **Zero operator action required** post-merge — the PR review is the human authorization. Kill switch: include `[skip-deploy-fix-apply]` in any commit message on the PR to suppress the apply for that merge.
This gate's role is now purely informational: surface that the PR will trigger the auto-apply, and confirm the operator has not used the kill-switch unintentionally. Issue #3618 tracks the deeper refactor that eliminates the `terraform_data.deploy_pipeline_fix` pattern entirely (containerized deploy-orchestrator).
The local-terminal flow below is preserved as a documented fallback for the rare case where the auto-apply fails (transient network, Hetzner outage, terraform state lock). Display this block to the operator only when the auto-apply has actually failed:
```text
This PR edits `terraform_data.deploy_pipeline_fix` trigger files. Drift will be
detected on the next 12h cron tick. To prevent the drift-issue cycle, run the
apply as part of the merge ritual:
cd apps/web-platform/infra
doppler run -p soleur -c prd_terraform -- \
terraform apply -target=terraform_data.deploy_pipeline_fix -input=true
You will be prompted for "yes" by Terraform — that prompt is the load-bearing
authorization per `hr-menu-option-ack-not-prod-write-auth`. Do NOT pass
`-auto-approve`.
After the apply completes, verify (server IP comes from Terraform output —
the output name is `server_ip`, not `server_ipv4`):
SERVER_IP=$(cd apps/web-platform/infra && terraform output -raw server_ip)
LOCAL_HASHES=$(sha256sum \
apps/web-platform/infra/ci-deploy.sh \
apps/web-platform/infra/webhook.service \
apps/web-platform/infra/cat-deploy-state.sh \
apps/web-platform/infra/canary-bundle-claim-check.sh)
echo "$LOCAL_HASHES"
ssh -o ConnectTimeout=5 root@"$SERVER_IP" \
"sha256sum /usr/local/bin/ci-deploy.sh \
/etc/systemd/system/webhook.service \
/usr/local/bin/cat-deploy-state.sh \
/usr/local/bin/canary-bundle-claim-check.sh && \
systemctl is-active webhook"
Each server-side hash must match the corresponding local hash AND
`systemctl is-active webhook` must return `active`. (`hooks.json` is
generated server-side from `local.hooks_json` so its hash will not match
the `.tmpl` source — verify it via `stat /etc/webhook/hooks.json`; the
mtime should be within seconds of the apply.)
Do NOT use the HTTP probe at `https://deploy.soleur.ai/hooks/*` for
post-apply verification — it returns 403 from CF Access for anonymous
probes (proxy-layer signal that decayed silently). See #3034 and
plugins/soleur/skills/postmerge/references/deploy-status-debugging.md
"When NOT to use this probe."
```
**Interactive mode:**
Inform the operator: "PR touches `terraform_data.deploy_pipeline_fix` trigger file(s). The `apply-deploy-pipeline-fix.yml` workflow will auto-apply on merge — no action required. Kill switch: add `[skip-deploy-fix-apply]` to a commit message if you want to defer the apply." Proceed to Phase 6 without blocking on user input.
**Headless mode:**
Same as interactive — surface a tracking comment on the PR noting the auto-apply will fire on merge, then proceed. The comment also names the kill-switch and the fallback terminal command for the rare auto-apply failure case.
```bash
TRACKING_MSG=$'[deploy_pipeline_fix-drift-gate] This PR touches a trigger file. `apply-deploy-pipeline-fix.yml` will auto-apply on merge — no action required. To skip the auto-apply, add `[skip-deploy-fix-apply]` to a commit message. If the auto-apply fails (transient outage), run the workflow manually from the Actions tab, or as a last resort: `doppler run -p soleur -c prd_terraform -- terraform apply -target=terraform_data.deploy_pipeline_fix -input=true`.'
if ! gh pr comment "$PR_NUMBER" --body "$TRACKING_MSG" 2>/dev/null; then
echo "$TRACKING_MSG" >&2
if [[ -n "${GITHUB_STEP_SUMMARY:-}" ]]; then
printf '### deploy_pipeline_fix drift gate\n\n%s\n' "$TRACKING_MSG" >> "$GITHUB_STEP_SUMMARY"
fi
fi
```
**If not triggered:** Skip silently.
**Why:** The drift pattern is structural — 9 cycles in ~6 weeks before this gate landed (see [`2026-04-24-recurring-deploy-pipeline-fix-drift-as-feature.md`](../../../../knowledge-base/project/learnings/bug-fixes/2026-04-24-recurring-deploy-pipeline-fix-drift-as-feature.md)). The gate moves discovery from "next 12h cron tick" to "PR-creation time," shrinking the window where prod runs stale `ci-deploy.sh` against fresh container images. The post-apply verification contract (server-side `sha256sum` + `systemctl is-active`) is the file+systemd-layer signal that replaces the decayed HTTP probe (see [`2026-04-29-deploy-pipeline-fix-postapply-verification-cf-access.md`](../../../../knowledge-base/project/learnings/bug-fixes/2026-04-29-deploy-pipeline-fix-postapply-verification-cf-access.md)). Closes the structural-prevention threshold defined in #2881; canonicalizes the verification contract from #3034.
**Defense in depth.** This gate covers the `/ship` code path only. PRs created without `/ship` (direct `gh pr create`, GitHub UI) bypass it. The 12h `scheduled-terraform-drift.yml` cron remains the terminal safety net for those paths and for "operator deferred / forgot to apply" scenarios.
### Retroactive Gate Application (conditional)
**Trigger:** The PR fixes a gate's detection logic (trigger conditions, assessment questions, or routing rules) AND the fix was motivated by a specific case that the gate missed.
**Detection:** Check if the PR modifies any of: Phase 5.5 gate trigger/detection sections in this file, assessment questions in `brainstorm-domain-config.md`, or domain routing rules in AGENTS.md. If yes, check the linked issue or brainstorm document for the original missed case (e.g., a PR number, feature name, or issue that exposed the gap).
**If triggered:**
Emit rule-application telemetry (records that the retroactive-gate-application branch ran — see AGENTS.md `wg-when-fixing-a-workflow-gates-detection`):
```bash
source "$(git rev-parse --show-toplevel)/.claude/hooks/lib/incidents.sh" && \
emit_incident wg-when-fixing-a-workflow-gates-detection applied \
"When fixing a workflow gate's detection logic, retr"
```
1. Identify the original missed case from the issue/brainstorm (e.g., "PR #1256 PWA was not assessed for content").
2. Run the fixed gate retroactively against the missed case: spawn the relevant domain leader with the original PR/feature context and the same assessment prompt the gate would have used.
3. Produce the artifacts that would have been created if the gate had worked (content briefs, expense entries, website audits, etc.).
4. Commit the artifacts before proceeding to Phase 6.
**If not triggered:** Skip silently.
**Why:** In #1265, the CMO content gate was fixed to catch product features but the PWA feature itself was never assessed — the fix shipped without remediating the original gap. "Gate fixed" is not done — "gate fixed AND missed case remediated" is done.
### Undeferred Operator-Step Gate (mandatory)
Enforces hard rule `hr-never-label-any-step-as-manual-without` at the `gh pr ready` boundary. Blocks PR-ready when the PR body contains "operator runs"-class steps without a `Tracks #NNNN` / `Refs #NNNN` companion linking to an OPEN `type/chore` (or `type/feature`) issue that carries the `deferred-automation` / `automation gap` sentinel.
Emit rule-application telemetry (records the gate fired):
```bash
source "$(git rev-parse --show-toplevel)/.claude/hooks/lib/incidents.sh" && \
emit_incident wg-block-pr-ready-on-undeferred-operator-steps applied \
'`/ship` Phase 5.5 blocks PR-ready when the PR body has operator-action'
```
**Detection.** Capture the PR body once, **strip fenced code blocks** (the gate body and `AC-PM` example snippets in PRs that edit this skill would otherwise self-trip), then run a multi-pattern grep with LIST-ANCHORED patterns. Bash ERE has no `(?i)` modifier — use `grep -iE`.
```bash
PR_BODY_FILE=$(mktemp)
trap 'rm -f "$PR_BODY_FILE"' EXIT INT TERM
PR_BODY=$(gh pr view --json body --jq .body)
# Strip fenced code blocks (```...```) — the gate body and the AC section
# in the PR will quote the regex and the AC-PM tokens; those quotations
# inside ``` fences MUST NOT count as undeferred declarations.
# FAIL-CLOSED on unbalanced fence: an unclosed ``` would otherwise drop
# every subsequent line and silently bypass the gate (PR-H failure class).
printf '%s' "$PR_BODY" | awk '
/^```/ { in_fence = !in_fence; next }
!in_fence { print }
END { if (in_fence) exit 2 }
' > "$PR_BODY_FILE"
if [ "$?" -eq 2 ]; then
echo "[gate] WARN: unbalanced ``` fence in PR body — re-scanning unfiltered body (fail-closed)" >&2
printf '%s' "$PR_BODY" > "$PR_BODY_FILE"
fi
# LIST-ANCHORED regex: a match requires the LINE to start with a bullet
# (`-`/`*`) or numbered-list marker (`1.`), optionally a checklist box
# (`[ ]`/`[x]`), optionally bold (`**`), then a keyword. Excludes
# prose-style mid-paragraph mentions of "operator" or "AC-PM".
DETECT_RE='^[[:space:]]*([-*]|[0-9]+\.)[[:space:]]+(\[[[:space:]xX]\][[:space:]]+)?(\*\*)?(AC-PM[0-9]+|operator[[:space:]]+(run|create|provision|configure|paste|cop(y|ies))s?|manual[[:space:]]+gate|post-merge[[:space:]]+operator)'
MATCHES=$(grep -niE "$DETECT_RE" "$PR_BODY_FILE" || true)
```
**Why list-anchored.** PR bodies routinely discuss operator behavior in prose ("the operator's choice", "the operator runs the script ONCE post-merge per the prior convention"). Only DECLARATIVE list-shape entries (`- Operator runs ...` or `- [ ] **AC-PM3** Operator creates ...`) are operator-step accretion vectors. Prose mentions are review-noise.
**Rule.** For each match, the previous line, the same line, OR the following line MUST contain `(Tracks|Refs) #NNNN` (header-above + same-line-trailing + next-line continuation all qualify). Extract every referenced `#NNNN` from those companions, then for each: verify the linked issue is OPEN, labeled `type/chore` or `type/feature`, AND its body contains the sentinel `deferred-automation` or `automation gap` (case-insensitive).
```bash
UNDEFERRED=()
for line_no in $(printf '%s\n' "$MATCHES" | awk -F: '$1 ~ /^[0-9]+$/ {print $1}'); do
prev=$((line_no > 1 ? line_no - 1 : 1))
ctx=$(sed -n "${prev}p;${line_no}p;$((line_no+1))p" "$PR_BODY_FILE")
refs=$(printf '%s' "$ctx" | grep -oE '(Tracks|Refs)[[:space:]]+#[0-9]+' || true)
if [ -z "$refs" ]; then
UNDEFERRED+=("$line_no"); continue
fi
ok=0
for n in $(printf '%s' "$refs" | grep -oE '[0-9]+'); do
state=$(gh issue view "$n" --json state --jq .state 2>/dev/null || echo "")
[ "$state" = "OPEN" ] || continue
labels=$(gh issue view "$n" --json labels --jq '[.labels[].name] | join(",")' 2>/dev/null || echo "")
[[ "$labels" =~ (^|,)type/(chore|feature)(,|$) ]] || continue
body=$(gh issue view "$n" --json body --jq .body 2>/dev/null || echo "")
if printf '%s' "$body" | grep -qiE 'deferred-automation|automation gap'; then
ok=1; break
fi
done
[ "$ok" = 1 ] || UNDEFERRED+=("$line_no")
done
```
**If not triggered (`${#UNDEFERRED[@]}` is 0):** Skip silently.
**If triggered (`${#UNDEFERRED[@]}` > 0):** Halt and present the structured prompt (3-option choice). The operator chooses one:
1. **File deferred-automation issues now.** For each undeferred match, the skill prompts for an issue title + 1-paragraph re-evaluation criterion, then `gh issue create --label type/chore --title <...> --body "<...>\n\nThis is a deferred-automation backlog item per wg-block-pr-ready-on-undeferred-operator-steps. Re-evaluate when: <...>"`. Update the PR body with `Tracks #NNNN` companions. Re-run detection.
2. **Cite an existing OPEN issue.** Operator pastes `#NNNN` per undeferred match. Skill verifies state/labels/sentinel and updates the PR body with `Tracks #NNNN`.
3. **Override with operator-attestation.** Operator pastes a 1-paragraph justification (rare; e.g., first non-Soleur tenant onboarding triggers a one-off K-bis upload). Skill appends a `<!-- gate-override: wg-block-pr-ready-on-undeferred-operator-steps -->` HTML comment followed by the attestation text to the PR body, then proceeds.
**Headless mode.** Abort with the same structured error. No auto-file / auto-override in headless — operator must run interactively to make the choice.
**Why:** PR-H #4066 violated `hr-never-label-any-step-as-manual-without` (3 unfiled deferred-automation steps; #4114 + #4115 filed too late). This gate moves enforcement from honor-system to mechanical.
## Phase 6.4: Unpushed-Commits Gate
[skill-enforced: ship Phase 6.4 + hook ship-unpushed-commits-gate.sh]
Before queueing `gh pr merge --squash --auto` (Phase 6 below), verify every local commit is on `origin/<branch>`. GitHub's auto-merge consumes the PR head ref on origin — local-only commits are silently dropped from the squash. This was the failure mode in PR #3624 → #3627 → #3630: the orchestrator went `preflight → gh pr edit → gh pr ready → gh pr merge --squash --auto` without re-pushing, and 2 of 5 commits (the actual fix + the review fix) never landed on `main`.
The PreToolUse hook [`.claude/hooks/ship-unpushed-commits-gate.sh`](../../../../.claude/hooks/ship-unpushed-commits-gate.sh) enforces this gate mechanically — it intercepts every `gh pr merge` (including chained forms like `gh pr ready && gh pr merge`) and denies the tool call when `git rev-list origin/<branch>..HEAD --count` returns > 0. The deny message lists the unpushed SHAs so the operator can `git push` and re-issue.
For headless or non-hooked contexts (CI workflows, direct shell invocations), run the equivalent check before `gh pr merge`:
```bash
BRANCH=$(git rev-parse --abbrev-ref HEAD)
git fetch origin "$BRANCH" 2>/dev/null || true
UNPUSHED=$(git rev-list "origin/${BRANCH}..HEAD" --count 2>/dev/null || echo 0)
if [[ "$UNPUSHED" -gt 0 ]]; then
echo "FAIL: ${UNPUSHED} unpushed commit(s) on origin/${BRANCH}. Run 'git push'." >&2
git log "origin/${BRANCH}..HEAD" --oneline >&2
exit 1
fi
```
**Fail-open conditions** (the hook exits silently): branch is `main`/`master`, detached HEAD, no upstream tracking ref, bare-repo context, branch name fails refname validation. **Fail-closed on fetch failure** — a stale tracking ref re-introduces the silent-miss class this gate exists to prevent, so the hook denies and prompts the operator to fetch manually. See rule `wg-ship-push-before-merge` in `AGENTS.core.md` for the canonical contract.
**Hook ordering** matters: the gate is wired AFTER [`pre-merge-rebase.sh`](../../../../.claude/hooks/pre-merge-rebase.sh) in [`.claude/settings.json`](../../../../.claude/settings.json) so any auto-sync push performed by the rebase hook has updated the upstream tracking ref before this gate counts unpushed commits. `T11` in [`ship-unpushed-commits-gate.test.sh`](../../../../.claude/hooks/ship-unpushed-commits-gate.test.sh) enforces the ordering invariant — keep it green if either hook moves.
## Phase 6: Push and Create PR
### Detect Associated Issue
Before creating or editing the PR, detect if the work resolves a GitHub issue. Check these sources (in order, stop at first match):
1. **Branch name:** Extract issue number from patterns like `fix/123-description`, `feat/issue-123`, `fix-123`, or any segment matching `\b(\d+)\b` after a `fix` or `issue` prefix.
2. **Commit messages:** Search recent branch commits for `#N` references:
```bash
git log origin/main..HEAD --oneline
```
Extract any `#N` references from the output.
3. **User context:** If the user mentioned an issue number earlier in the conversation, use it.
If an issue number is found, store it as `ISSUE_NUMBER` for use in the PR body below. If multiple are found, use all of them. If none are found, omit the `Closes` line from the PR body.
**Important:** Use `Closes #N` syntax (not `Ref #N`, not `(#N)` in the title). GitHub only auto-closes issues when the PR body contains a keyword (`Closes`, `Fixes`, or `Resolves`) followed by the issue reference.
### Auto-Close Keyword Pre-Creation Scan (#3407)
Before invoking `gh pr edit` or `gh pr create` below, scan the proposed PR title and body for unintentional auto-close-keyword + #N references. GitHub's parser is markdown-blind: matches inside checkboxes, code blocks, blockquotes, and prose all auto-close (`#3185` was closed twice in three days by this trap — first via PR title `(Closes #N after fire)` in #3200, then via body checkbox `- [ ] Post-merge: close #N` in #3402).
Write the proposed `PR_TITLE` and `PR_BODY` to temp files, then run the shared scanner:
```bash
TMP_TITLE=$(mktemp); TMP_BODY=$(mktemp)
printf '%s\n' "$PR_TITLE" > "$TMP_TITLE"
printf '%s\n' "$PR_BODY" > "$TMP_BODY"
T_MATCHES=$(bash plugins/soleur/skills/ship/scripts/auto-close-scan.sh "$TMP_TITLE")
B_MATCHES=$(bash plugins/soleur/skills/ship/scripts/auto-close-scan.sh "$TMP_BODY")
```
If `T_MATCHES` OR `B_MATCHES` is non-empty:
1. Display every match with line context: `printf 'In title:\n%s\nIn body:\n%s\n' "${T_MATCHES:-(none)}" "${B_MATCHES:-(none)}"`.
2. Compare each match against the intended `ISSUE_NUMBER` set from the detection step above. Any match where the issue number is in `ISSUE_NUMBER` AND the match line is the canonical `Closes #N` body line (one keyword, one number, on its own line, no surrounding prose) is intentional — keep it. Any other match is a candidate trap.
3. **Headless mode:** If unintentional matches remain after the comparison, abort with an error listing every match — do NOT silently create the PR. The operator must either edit the body to remove the trap OR (when the match IS intentional, e.g., `Closes #N` was filtered out by step 2's heuristic incorrectly) add a `<!-- auto-close-scanner: confirm -->` marker to the body and re-run.
4. **Interactive mode:** Use AskUserQuestion to surface every unintentional match and offer (a) edit the body to remove the trap, (b) add the `<!-- auto-close-scanner: confirm -->` marker (intentional), or (c) abort and let the operator edit manually.
The CI workflow [`.github/workflows/pr-auto-close-scanner.yml`](../../../../.github/workflows/pr-auto-close-scanner.yml) is the observational post-creation surface for PRs created outside this skill (manual `gh pr create`, GitHub UI, third-party plugins). This pre-creation scan is the only blocking surface; both share [`./scripts/auto-close-scan.sh`](./scripts/auto-close-scan.sh) so the regex stays canonical.
The PR body of THIS Soleur PR will typically contain `Closes #N` lines that ARE intentional — those are not traps and should be kept. The trap pattern is auto-close keyword + #N where the issue is NOT in the intentional `ISSUE_NUMBER` set, OR where the form is a checkbox / prose / code-fence rather than the canonical body line.
Push the branch to remote. Get the branch name first:
```bash
git rev-parse --abbrev-ref HEAD
```
Then push in a separate Bash call, using the branch name literally:
```bash
git push -u origin BRANCH_NAME
```
Replace `BRANCH_NAME` with the actual branch name from the previous call.
**Check for existing PR on this branch:**
Check for an existing open PR using the branch name from above:
```bash
gh pr list --head BRANCH_NAME --state open --json number,isDraft --jq '.[0]'
```
Replace `BRANCH_NAME` with the actual branch name.
**If an open PR exists:**
1. The PR was likely created as a draft earlier in the workflow.
2. **Headless mode:** Auto-accept the generated PR title/body from diff analysis. **Interactive mode:** Confirm the PR title and body with the user before editing.
3. Update the PR. Pass the body as a multi-line string (no `$()` needed):
```bash
gh pr edit PR_NUMBER --title "the pr title" --body "## Summary
- bullet points
Closes #ISSUE_NUMBER
## Changelog
- changelog entries describing what changed
## Test plan
- checklist
Generated with [Claude Code](https://claude.com/claude-code)"
```
If `ISSUE_NUMBER` was detected, include the `Closes #N` line. If multiple issues, list each (`Closes #N, Closes #M`). If no issue was detected, omit the `Closes` line entirely.
Do not quote flag names -- write `--title` not `"--title"`.
4. If the PR is a draft, mark it ready:
```bash
gh pr ready PR_NUMBER
```
5. Present the PR URL to the user.
**If no open PR exists:**
Fall through to creating a new PR. This handles cases where the user entered the pipeline through `/plan` or `/work` directly (skipping brainstorm/one-shot).
```bash
gh pr create --title "the pr title" --body "## Summary
- bullet points
Closes #ISSUE_NUMBER
## Changelog
- changelog entries describing what changed
## Test plan
- checklist
Generated with [Claude Code](https://claude.com/claude-code)"
```
If `ISSUE_NUMBER` was detected, include the `Closes #N` line. If no issue was detected, omit it.
Do not quote flag names -- write `--title` not `"--title"`.
Present the PR URL to the user.
### Semver Label and Changelog
After the PR is created or updated, determine the appropriate semver label and apply it.
**Step 1:** Analyze the diff to determine bump type. Get the merge base hash (reuse from Phase 3 if already obtained):
```bash
git merge-base HEAD origin/main
```
Then, in a separate Bash call, check for new components:
```bash
git diff --name-status HASH..HEAD -- plugins/soleur/commands/ plugins/soleur/skills/ plugins/soleur/agents/
```
Replace `HASH` with the actual commit hash.
**Step 1b:** Check for app changes (in a separate Bash call):
```bash
git diff --name-only HASH..HEAD -- apps/web-platform/ | head -1
```
If `apps/web-platform/` has changes, apply `app:web-platform` label:
```bash
gh pr edit PR_NUMBER --add-label app:web-platform
```
Only apply the label if the path has changes.
**Step 2:** Determine the bump type:
- **MAJOR**: Breaking changes (removed commands, renamed agents, restructured plugin interface)
- **MINOR**: New agents, skills, or commands added (any `A` status files in the diff above), OR new files added under `apps/*/`
- **PATCH**: Everything else (bug fixes, doc updates, improvements to existing components)
When ONLY app files changed (no plugin files), still apply `semver:*` based on app change significance — new files added means `semver:minor`, changes only means `semver:patch`.
**Step 3:** Apply the semver label to the PR:
```bash
gh pr edit PR_NUMBER --add-label semver:patch
```
Replace `semver:patch` with `semver:minor` or `semver:major` as appropriate. Replace `PR_NUMBER` with the actual PR number.
**Step 4:** Generate a `## Changelog` section from the changes and update the PR body to include it. The changelog should describe what changed in user-facing terms (not file paths). If the PR body already has a `## Changelog` section, update it. Include app changes alongside plugin changes — group by component if multiple components changed (e.g., "### Plugin", "### Web Platform").
**Step 5:** Validate consistency -- if new agents, skills, or commands were detected in Step 1 but the label is `semver:patch`, warn the user that the label may be incorrect. New components typically warrant `semver:minor`.
## Phase 6.5: Verify PR Mergeability
After pushing (or after any subsequent push), verify the PR has no merge conflicts with the base branch:
```bash
git fetch origin main
gh pr view --json mergeable,mergeStateStatus | jq '{mergeable, mergeStateStatus}'
```
**If `mergeable` is `MERGEABLE`:** Continue to Phase 7.
**If `mergeable` is `CONFLICTING`:**
1. Merge the base branch locally to surface conflicts:
```bash
git merge origin/main --no-commit --no-ff
```
2. Identify conflicted files:
```bash
git diff --name-only --diff-filter=U
```
3. Read each conflicted file and resolve. Common conflict patterns:
- **Component counts**: Use the feature branch count (it includes the new additions)
- **Code conflicts**: Resolve based on intent of both changes
4. Stage resolved files and commit the merge:
```bash
git add <resolved files>
git commit -m "Merge origin/main -- resolve conflicts"
```
5. Push and re-verify:
```bash
git push
gh pr view --json mergeable | jq '.mergeable'
```
6. If still `CONFLICTING` after resolution: stop and ask the user for help.
**If `mergeable` is `UNKNOWN`:** Wait 5 seconds and re-check (GitHub may still be computing). After 3 retries, warn and continue.
### CI Status Check
After confirming mergeability, queue auto-merge and let GitHub handle waiting for CI. Wrap the call in the merge-main lock so parallel sessions don't queue auto-merges in the same window:
```bash
bash .claude/hooks/lib/session-state.sh with_lock merge-main 600 -- \
gh pr merge <number> --squash --auto
rc=$?
if [[ "$rc" -eq 99 ]]; then
echo "merge-main lock contended >600s — another session is queueing auto-merge. Retry: re-run /ship after that session completes."
exit 1
fi
```
The `with_lock <name> <timeout_s> -- <cmd> [args...]` wrapper acquires the lock, runs the command inline (so the lock fd stays open for the duration), and releases on exit. **The `--` separator is required** — it terminates `with_lock`'s positional arguments. Returns 99 on `>timeout_s` contention; check `$?` and surface to the operator rather than silently failing the merge.
Do NOT use `gh pr checks --watch` -- it exits immediately with "no checks reported" when CI hasn't registered yet, causing premature merge attempts.
**If auto-merge fails to queue:** Check `gh api repos/{owner}/{repo} --jq '.allow_auto_merge'` -- it must be `true`.
## Phase 7: Poll for Merge and Cleanup
After auto-merge is queued, poll until the PR is merged. Do NOT ask "merge now or later?" -- auto-merge handles it. Do NOT use foreground `sleep` — Claude Code blocks `sleep` >= 2s in foreground Bash calls.
**HARD GATE: Use the Monitor tool, NEVER Bash `run_in_background`.** The Monitor tool streams each stdout line as a real-time notification (state-change visibility). Bash `run_in_background` is opaque until completion — the agent and operator see nothing until the entire loop finishes or fails, which defeats the purpose of heartbeat polling. If you catch yourself reaching for `Bash` with `run_in_background: true` for a polling loop, stop — that is the failure mode this gate exists to block. **Why:** PR #4512 — the agent used `Bash run_in_background` for release monitoring; the background task failed silently with exit code 1, producing zero visibility into the release state. The Monitor tool would have surfaced every state transition as it happened.
Use the **Monitor tool** with this shell loop (state-change + heartbeat, max 15 iterations = 15 minutes). The loop covers three structurally-unmergeable states in addition to the terminal MERGED/CLOSED exits: **required-check failure** (exit at first failing required check, name it in stderr), **BEHIND** (auto-sync main into the branch up to 6 attempts, then emit a structured "main moving faster than CI" warning at the inflection point), and **DIRTY** (server-side merge conflict — exit and surface). See "Auto-sync on BEHIND" and "Required-check failure exit" below:
```bash
# <!-- phase-7-poll-block:start --> (do NOT edit without updating the
# mirror in plugins/soleur/skills/merge-pr/SKILL.md §5.2 and the fixture
# at plugins/soleur/test/ship-phase-7-poll-fixtures.sh; the fixture's awk
# extractor anchors on this fence + the variable-set fingerprint below.)
prev=""; i=0; behind_syncs=0; MAX_BEHIND_SYNCS=6; behind_warned=0
BRANCH=$(git rev-parse --abbrev-ref HEAD)
# Worktree precondition: the BEHIND auto-sync calls `git merge origin/main`
# + `git push` which require a checked-out work tree. Bare-repo invocation
# would silently corrupt state or fail mid-sync. /soleur:ship always runs
# from a worktree (Step 0b creates one); the guard is defense-in-depth.
if ! git rev-parse --is-inside-work-tree >/dev/null 2>&1; then
echo "[ship.phase7.precondition] not inside a worktree — BEHIND auto-sync disabled" >&2
fi
# Required-check name set — fetched ONCE at loop entry (branch-protection
# rules change only via operator action; per-tick fetches cost rate-limit
# headroom for no value). Fail-open by design: if the API call fails (no
# auth, no ruleset, archived repo, 5xx), REQUIRED_CHECKS is empty and the
# per-tick failure scan becomes a no-op. The existing CLOSED-on-CI-failure
# fallback below still catches the terminal case. Do NOT "harden" to
# fail-closed — that breaks the loop for repos without branch protection.
# Read into an array so check names with whitespace (e.g. "skill-security-scan
# PR gate") survive iteration intact — a `for r in $REQUIRED_CHECKS` would
# word-split on spaces and silently miss multi-word required checks.
mapfile -t REQUIRED_CHECKS < <(gh api 'repos/{owner}/{repo}/rules/branches/main' \
--jq '[.[] | select(.type == "required_status_checks") | .parameters.required_status_checks[].context] | .[]' \
2>/dev/null || true)
while true; do
i=$((i+1))
s=$(gh pr view <number> --json state,mergeStateStatus \
--jq '"\(.state) \(.mergeStateStatus)"' 2>&1) \
|| s="fetch-error: $s"
if [[ "$s" != "$prev" ]] || (( i % 3 == 1 )); then
echo "$(date +%H:%M:%S) [${i}/15] PR <number> ${s}"
prev="$s"
fi
echo "$s" | grep -qE "^(MERGED|CLOSED|fetch-error)" && break
# Required-check failure scan: if a required check has transitioned to
# bucket == "fail", exit immediately with the failing check name instead
# of heartbeating to the 15-minute cap. Tolerates absent checks (CI not
# yet registered) — only bucket="fail" produces an intersection match.
# Same fail-open rationale as the once-fetch above: transient `gh pr
# checks` 5xx → empty failure set → no-op for this tick.
if (( ${#REQUIRED_CHECKS[@]} > 0 )); then
mapfile -t failed_names < <(gh pr checks <number> --json name,bucket \
--jq '.[] | select(.bucket == "fail") | .name' 2>/dev/null || true)
if (( ${#failed_names[@]} > 0 )); then
required_failed=""
for n in "${failed_names[@]}"; do
for r in "${REQUIRED_CHECKS[@]}"; do
[[ "$n" == "$r" ]] && { required_failed="$n"; break 2; }
done
done
if [[ -n "$required_failed" ]]; then
echo "$(date +%H:%M:%S) [${i}/15] [ship.phase7.required_failed] check='${required_failed}' — exiting poll" >&2
echo "Inspect: gh pr checks <number> ; gh run view --log-failed (pick the failing workflow run)" >&2
break
fi
fi
fi
# DIRTY exit (server-side merge conflict): GitHub computed a conflict that
# may or may not be local to this worktree. Exit and surface — looping
# produces no progress; the operator must resolve. Glob `*DIRTY*` (not
# `*" DIRTY"`) tolerates leading-whitespace and trailing-whitespace variants
# that future `--jq` template tweaks could introduce.
if [[ "$s" == *DIRTY* ]]; then
echo "$(date +%H:%M:%S) [${i}/15] [ship.phase7.dirty] PR is DIRTY (merge conflict) — exiting poll" >&2
echo "Conflicted paths (local view; may be empty for server-side conflicts):" >&2
git diff --name-only --diff-filter=U >&2 || true
echo "Server-side conflicts may not appear locally. Run: git fetch origin && git merge origin/main" >&2
break
fi
# Auto-sync on BEHIND: GitHub auto-merge will not fire while the head
# ref is behind base. Merge origin/main into the branch and push so the
# queued auto-merge can re-evaluate. Capped at MAX_BEHIND_SYNCS so a
# pathological merge-loop (every sync produces a fresh BEHIND) does not
# consume the whole poll budget — fall through to a structured warning.
if [[ "$s" == "OPEN BEHIND" && "$behind_syncs" -lt "$MAX_BEHIND_SYNCS" ]]; then
behind_syncs=$((behind_syncs+1))
echo "$(date +%H:%M:%S) [${i}/15] BEHIND detected — auto-sync attempt ${behind_syncs}/${MAX_BEHIND_SYNCS}"
if ! git fetch origin main 2>&1 | tail -2; then
echo "fetch origin main failed — skipping this sync attempt"
elif ! git merge origin/main --no-edit 2>&1 | tail -5; then
echo "git merge origin/main produced conflicts — aborting sync, reporting:"
git diff --name-only --diff-filter=U
git merge --abort 2>/dev/null
echo "Manual conflict resolution required on $BRANCH. Stopping the poll."
break
elif ! git push 2>&1 | tail -2; then
echo "git push failed after merge — auto-sync incomplete. Stopping the poll."
break
else
echo "$(date +%H:%M:%S) [${i}/15] auto-sync ${behind_syncs} pushed — auto-merge will re-evaluate"
# Re-fetch state immediately after a successful sync. GitHub may
# have already cleared OPEN BEHIND → OPEN CLEAN → MERGED in the time
# the sync took (~5-30s); without this re-fetch, we'd burn a 60s
# `sleep` waiting on state we already know has progressed.
s=$(gh pr view <number> --json state,mergeStateStatus \
--jq '"\(.state) \(.mergeStateStatus)"' 2>&1) \
|| s="fetch-error: $s"
echo "$s" | grep -qE "^(MERGED|CLOSED|fetch-error)" && break
fi
elif [[ "$s" == "OPEN BEHIND" && "$behind_syncs" -ge "$MAX_BEHIND_SYNCS" && "$behind_warned" -eq 0 ]]; then
# BEHIND cap exhausted: emit structured operator signal exactly once at
# the inflection point (sync #${MAX_BEHIND_SYNCS}+1), then fall through
# to heartbeat. PR may still merge if main calms down — but the
# operator now has the diagnosis without log-archaeology.
elapsed=$((i * 60))
echo "$(date +%H:%M:%S) [${i}/15] [ship.phase7.behind_exhausted] BEHIND budget exhausted after ${MAX_BEHIND_SYNCS} auto-syncs in ${elapsed}s. origin/main is moving faster than this PR's CI cycle. Recommendation: stop ship pipeline; merge during a quieter window." >&2
behind_warned=1
fi
if [ "$i" -ge 15 ]; then
echo "Merge poll timed out after 15 minutes. Last state: $s"
break
fi
sleep 60
done
# <!-- phase-7-poll-block:end -->
```
Each meaningful event (first iteration, every state change, heartbeat every 3rd poll ~3 min) arrives as a Monitor notification — quiet while nothing changes, loud when it matters. React to the final state (the last non-heartbeat event). `fetch-error:` appears if `gh` hits a transient API failure; chronic errors break the loop so the caller can surface the outage instead of polling silently. If the loop exits via timeout, report the timeout and investigate why the PR has not merged.
**Auto-sync on BEHIND.** When the polling loop observes `OPEN BEHIND`, origin/main has moved ahead of the branch's head since the queued auto-merge started waiting on CI. GitHub's auto-merge does not fire while the branch is behind base — it observes the BEHIND state and silently waits forever. The poll loop closes this by:
1. Fetching origin/main once.
2. Merging origin/main into the branch with `--no-edit`. If conflicts arise (`--diff-filter=U` returns paths), the loop aborts the merge and stops polling — manual resolution is required and continuing would mask the conflict.
3. Pushing the merge commit. This bumps the PR head ref, GitHub re-evaluates the queued auto-merge, and (assuming CI passes) the merge fires.
The sync is capped at `MAX_BEHIND_SYNCS=6` per poll invocation. A pathological case — every sync triggers a new commit on main (parallel-active-repo class) — would otherwise consume the full 15-minute budget on BEHIND→BEHIND→BEHIND with no progress. After 6 syncs, the loop emits a structured `BEHIND budget exhausted` warning naming the elapsed time and recommendation to merge during a quieter window, then falls through to heartbeat — the PR may still merge if main calms down, but the operator now has the diagnosis at the inflection point instead of at the 15-minute timeout.
**Required-check failure exit.** Each tick, the loop intersects `gh pr checks --json name,bucket` failures (`bucket == "fail"`) with the repo's required-check name set (fetched once at loop entry via `gh api 'repos/{owner}/{repo}/rules/branches/main'`). On the first intersection, the loop exits and prints the failing check name + a pointer to `gh pr checks <number>` / `gh run view --log-failed`. This replaces the silent 15-minute heartbeat that occurs when a required check fails mid-poll but auto-merge sits queued waiting for a state transition that will never come. If the required-check fetch fails (no auth, no ruleset, archived repo), the scan is a no-op and the existing CLOSED-on-CI-failure fallback below still catches the terminal case — fail-open is deliberate, do NOT "harden" to fail-closed.
**DIRTY exit (server-side merge conflict).** When `mergeStateStatus == DIRTY`, GitHub has computed a merge conflict that may or may not be visible locally (operator may not have fetched the conflicting push). The loop exits, runs `git diff --name-only --diff-filter=U` for the local conflict view (often empty for server-side conflicts), and prints a `git fetch origin && git merge origin/main` recovery pointer. The operator must resolve before re-queueing auto-merge.
Two failure paths exit early instead of looping:
- **Merge conflicts** — `git diff --name-only --diff-filter=U` lists conflicted paths and the operator must resolve. Looping with `--abort` only buys time on a fundamentally non-automatic resolution.
- **Push failure** — usually means a concurrent push raced this one (force-push, branch protection, or a sibling session). Stop and surface; the operator decides whether to fetch + retry.
This complements the PreToolUse hook [`.claude/hooks/pre-merge-rebase.sh`](../../../../.claude/hooks/pre-merge-rebase.sh) which fires on certain Bash invocations during the ship flow (commit, push, merge). The hook handles the pre-merge case (branch is behind when auto-merge is FIRST queued); this loop handles the post-merge-queue case (branch becomes behind WHILE the queued auto-merge is waiting on CI). The two surfaces don't overlap — the hook fires on operator-triggered git/gh commands; the poll loop fires on a fixed 60-second cadence regardless of operator activity.
**If the poll loop exits due to a required-check failure (PR still OPEN) or CLOSED state:**
First, check the PR state. If CLOSED (merge queue rejection or manual close), skip directly to escalation — auto-fix cannot proceed on a closed PR. The autonomous fix path below applies only when the PR is still OPEN (the primary required-check-failure case).
The agent maintains a `fix_attempt_count` counter (agent-level state, not a bash variable — each Monitor invocation is a fresh shell).
1. Read the failure details:
```bash
gh pr checks <number> --json name,state,description,detailsUrl \
| jq '.[] | select(.state != "SUCCESS")'
```
2. Identify the failing workflow run and read its logs:
```bash
gh run list --branch <branch> --limit 5 --json databaseId,status,conclusion,workflowName \
| jq '.[] | select(.conclusion == "failure")'
gh run view <failing-run-id> --log-failed 2>&1 | tail -80
```
3. **If `fix_attempt_count >= 1`:** Escalate to the operator. **Headless mode:** abort with structured error naming the failing check. **Interactive mode:** present failure details and ask whether to investigate manually or abort.
4. **If `fix_attempt_count == 0`:** Increment `fix_attempt_count`. Attempt autonomous fix:
a. If the failure is in tests or lint: invoke `skill: soleur:test-fix-loop` to diagnose, fix, and commit. After test-fix-loop completes, push and re-queue auto-merge:
```bash
git push
gh pr merge <number> --squash --auto
```
Note: `gh pr reopen` is NOT needed — when auto-merge is cancelled due to CI failure, the PR remains OPEN. Re-queuing auto-merge is sufficient.
b. If the failure is in a flaky or unrelated check (not reproducible locally): **Headless mode:** abort. **Interactive mode:** ask whether to wait for a re-run or abort.
5. After re-queuing auto-merge, re-invoke the Phase 7 Monitor poll loop from the beginning. The agent carries `fix_attempt_count` across poll invocations.
Note: The DIRTY (merge conflict) exit is already handled inside the poll block — do not duplicate merge conflict resolution here. The CI auto-fix logic is OUTSIDE the Phase 7 poll block (`<!-- phase-7-poll-block:start/end -->` markers) and does NOT affect the mirror in `merge-pr/SKILL.md §5.2`.
**CRITICAL: Do NOT use `--delete-branch` on merge.** The guardrails hook blocks `--delete-branch` whenever ANY worktree exists in the repo -- not just the one for the branch being merged -- so the restriction applies unconditionally during parallel development. Merge with `--squash` only, then `cleanup-merged` handles branch deletion after removing the worktree.
**If merged (either now or user says "merge PR" later in the session):**
1. **Version bump and release are automatic.** The `version-bump-and-release.yml` GitHub Actions workflow reads the PR's `semver:*` label, computes the next version from the latest release tag, creates a GitHub Release with a `vX.Y.Z` tag, and posts to Discord. No committed files are modified — version is derived from git tags.
If the workflow did not fire (e.g., no semver label was set), run `/release-announce` manually as a fallback.
2. **Verify all release/deploy workflows triggered by the merge.** The push to main triggers release workflows based on path filters (e.g., `web-platform-release.yml` when `apps/web-platform/**` changed). These can fail for reasons unrelated to PR CI (Docker build failures, lockfile drift, deploy health mismatches). A failing release workflow means the old version keeps running in production — this is a silent outage.
Emit rule-application telemetry (records that the post-merge release/deploy verification ran — see AGENTS.md `wg-after-a-pr-merges-to-main-verify-all`):
```bash
source "$(git rev-parse --show-toplevel)/.claude/hooks/lib/incidents.sh" && \
emit_incident wg-after-a-pr-merges-to-main-verify-all applied \
"After a PR merges to main, verify all release/depl"
```
**Step 1:** Get the merge commit SHA. Use the PR number from Phase 6:
```bash
gh pr view <number> --json mergeCommit --jq .mergeCommit.oid
```
**Step 2:** Wait 15 seconds for workflows to trigger, then count pending runs on the merge commit:
```bash
gh run list --branch main --commit <merge-sha> --json databaseId,workflowName,status,conclusion --jq '[.[] | select(.status != "completed")] | length'
```
This outputs a single integer (the count of non-completed runs). If the output is empty or non-numeric, re-run the command once. If still invalid, report an error and abort.
**Empty-result fallback:** If the pending count is `0`, verify that runs actually exist:
```bash
gh run list --branch main --commit <merge-sha> --json databaseId --jq 'length'
```
- If total runs > 0 and pending = 0: all runs completed. Proceed to Step 4.
- If total runs = 0: workflows have not registered yet. Wait 15 seconds and re-query. Retry up to 3 times (45 seconds total). If still 0 after 3 retries, treat as "no workflows triggered" and skip verification (the PR only touched files outside all path filters).
**Step 3:** For each run that is not yet `completed`, use the **Monitor tool** (NEVER Bash `run_in_background` — see Phase 7 HARD GATE) with a state-change + heartbeat loop:
```bash
prev=""; i=0
while true; do
i=$((i+1))
r=$(gh run view <id> --json status,conclusion --jq '"\(.status) \(.conclusion // "-")"' 2>&1) \
|| r="fetch-error: $r"
if [[ "$r" != "$prev" ]] || (( i % 6 == 1 )); then
echo "$(date +%H:%M:%S) [${i}/40] run=<id> ${r}"
prev="$r"
fi
echo "$r" | grep -qE "^(completed|fetch-error)" && break
[ "$i" -ge 40 ] && { echo "TIMEOUT after 40 iterations"; break; }
sleep 30
done
```
**Why this pattern:** emit on every state change (so `queued → in_progress → completed` produces three events) plus a heartbeat every 6th poll (~3 min) so the monitor never looks stuck. `.conclusion // "-"` guarantees a non-empty second token even while the run is in progress (null conclusion would otherwise render as a trailing space that looks like silence). `2>&1` on the `gh` call turns transient API errors into visible `fetch-error:` events instead of empty lines. The `grep -qE "^(completed|fetch-error)"` exit clause still breaks on terminal success; chronic `fetch-error` breaks so the caller can surface the API failure rather than polling forever against an outage.
Poll until all runs report `completed`. Maximum 40 iterations (20 minutes). If the maximum is reached, report: "Release verification timed out after 20 minutes. N runs still pending: [list workflow names and IDs]." Do NOT silently continue -- investigate the stalled workflows.
**Step 4:** Check conclusions:
- All `success`: Report "Release verification: N/N workflows passed" and continue.
- Any `failure`: Report which workflow failed, fetch logs with `gh run view <id> --log-failed | tail -n 50`, and investigate. Do NOT silently proceed. If the failure is in the release/deploy pipeline, it must be fixed before ending the session — production is running stale code.
**If no workflows were triggered** (the PR only touched files outside all path filters): Skip this step.
**Why this matters:** In #1293, PR #1275 added `@playwright/test` to `package.json` but didn't update `package-lock.json`. PR CI passed (it uses `bun test`, not `npm ci`), but the Docker build uses `npm ci` which requires lockfile sync. Five consecutive release runs failed silently — production stayed on v0.8.6 for hours while new PRs kept merging.
3. **Post-merge validation of new or modified workflows.** If the PR added or modified GitHub Actions workflow files (`.github/workflows/*.yml`), validate them by triggering each affected workflow via `workflow_dispatch` and polling for completion. This is mandatory — never leave validation as a manual step for the user.
**Step 1:** Detect new or modified workflow files in this PR. Use the merge base hash from Phase 3:
```bash
git diff --name-only --diff-filter=AM HASH..HEAD -- .github/workflows/
```
Note: `--diff-filter=AM` catches both **A**dded and **M**odified files. A modified workflow is just as likely to break as a new one — both must be validated.
**Step 2:** For each affected workflow file, trigger it:
```bash
gh workflow run <workflow-filename>
```
If a workflow has a long expected runtime (>10 minutes), note this to the user and continue polling. Do not skip validation because the workflow is slow.
**Step 3:** Poll each triggered run until completion using the **Monitor tool** (state-change + heartbeat pattern — see Step 3 above for rationale):
```bash
prev=""; i=0
while true; do
i=$((i+1))
r=$(gh run list --workflow <workflow-filename> --limit 1 \
--json databaseId,status,conclusion \
--jq '.[0] | "\(.status) \(.conclusion // "-")"' 2>&1) \
|| r="fetch-error: $r"
if [[ "$r" != "$prev" ]] || (( i % 6 == 1 )); then
echo "$(date +%H:%M:%S) [${i}/40] <workflow-filename> ${r}"
prev="$r"
fi
echo "$r" | grep -qE "^(completed|fetch-error)" && break
[ "$i" -ge 40 ] && { echo "TIMEOUT after 40 iterations"; break; }
sleep 30
done
```
Poll until output starts with `completed`. Maximum 40 iterations (20 minutes). If the maximum is reached, report: "Post-merge validation timed out after 20 minutes for workflow [name]." Do NOT silently continue. Then check `conclusion`:
- **success**: Report pass and continue
- **failure**: Report failure, fetch logs with `gh run view <id> --log | tail -50`, and present the error to the user. Do NOT silently proceed.
**Step 4:** Report summary: "Post-merge validation: N/N workflows passed" or "Post-merge validation: X/N workflows failed — [details]"
**If no new or modified workflow files were detected:** Skip this step.
**Why this matters:** The founder is a solo operator. Every "please run this manually" is a context switch. `gh workflow run` exists — use it. Modified workflows are equally risky — a prompt change, a new step, or a timeout bump can all cause failures that are invisible without a live run. **Why `AM` not just `A`:** In #1126, a modified workflow (new Steps 5.5/5.6 in growth audit) was merged without validation because the ship skill only checked for new files.
3.5. **Follow-Through: detect unchecked external dependencies and create tracking issues.** Scan the merged PR body for unchecked test plan items marked with the ⏳ emoji. For each detected item, create a GitHub issue so external dependencies are tracked and monitored after the session ends.
**Step 1:** Read the PR body. Use the PR number from Phase 6:
```bash
gh pr view <PR_NUMBER> --json body --jq .body
```
**Step 2:** Scan the body for lines matching unchecked items with the ⏳ marker. Match this pattern (both lowercase `x` and uppercase `X` mean checked — skip those):
- Unchecked with marker: `- [ ] ⏳ <description>` → **create issue**
- Checked (lowercase): `- [x] ⏳ <description>` → skip
- Checked (uppercase): `- [X] ⏳ <description>` → skip
- No marker: `- [ ] <description>` → skip
If zero unchecked ⏳ items are found, skip to Step 4 (cleanup) silently.
**Step 3:** For each detected item, create a tracking issue.
First, ensure labels exist:
```bash
gh label create "follow-through" --description "External dependency awaiting verification" --color "C5DEF5" 2>/dev/null || true
gh label create "needs-attention" --description "SLA exceeded, requires human action" --color "D93F0B" 2>/dev/null || true
```
Then read `knowledge-base/product/roadmap.md` to determine the appropriate milestone. Default to "Post-MVP / Later" if unclear.
When follow-through items reference `terraform apply -replace`, enumerate ALL affected resources by scanning the full PR diff for `terraform_data` and `null_resource` connection block changes -- not just the resource named in the PR title or description. Use `git diff MERGE_BASE..HEAD -- '*.tf' | grep -E '(terraform_data|null_resource)' | grep -E '(connection|provisioner)'` to detect all changed provisioner blocks.
Before creating each follow-through issue, check for duplicates:
```bash
gh issue list --label follow-through --state open --search "Source PR: #<PR_NUMBER>" --json title --jq '.[].title'
```
If an issue with a matching title prefix already exists, skip creation and note "Dedup: skipped [title] -- existing issue found."
**CI-verified migration skip.** Before creating the issue, grep the item description for a migration filename (`NNN_*.sql`). If one is matched AND a sibling verify file exists at `apps/web-platform/supabase/verify/<filename>`, skip creating the follow-through — the `verify-migrations` job in `web-platform-release.yml` will run the sentinels and auto-close any existing issue referencing that filename. Log: "Skip: [item] — CI verify covers <filename>". This prevents the #2826/#2827 pattern (one apply issue + one sentinel issue per data-backfill migration) from regenerating on future PRs.
**Migration filename anchor.** If the item description mentions any migration filename OR a bare migration number (e.g. "migration 031") AND no sibling verify file exists yet (so we're still creating the issue), prepend a `**Migration file:** \`NNN_full_stem.sql\`` line to the body below the `<ITEM_DESCRIPTION>` paragraph. The `verify-migrations` auto-close job matches on both the full filename AND the stem (`NNN_full_stem`) — having either in the body ensures auto-close works once a verify file is later added. Bare `NNN` alone is not enough to match.
**Callback URL audit anchor.** If the item description matches BOTH a callback/redirect signal `/(callback URL|redirect_uri)/i` AND a GitHub-OAuth signal `/(GitHub App|OAuth App|Iv23|client_id)/i` (case-insensitive), this is a callback-URL-class follow-through. The two-signal AND prevents false-positives on unrelated docs/copy issues that happen to mention "GitHub App" once in passing. Closure requires more than a "looks fixed in dashboard" comment — issue #1784 was closed without a verified second remediation, and the same symptom recurred in #3183. Append the **Callback URL closure gate** block (below) to the issue body, and instruct any closer that the closing comment MUST contain ALL THREE of:
1. The verbatim `redirect_uri` value(s) verified — paste each registered callback URL byte-for-byte.
2. A workflow run ID showing `scheduled-oauth-probe.yml` ran green AFTER the dashboard change (link via `https://github.com/<repo>/actions/runs/<id>`).
3. The byte count of the GitHub App's Callback URL textarea contents (e.g., `wc -c <<<"$contents"`) — forensic anchor for future drift comparisons.
A close attempt without all three fields is workflow non-compliance per `wg-when-fixing-a-workflow-gates-detection` (the gap that allowed #1784 to recur). When closing the issue manually, verify the closing comment contains:
- Each registered callback URL listed verbatim (substring grep against the comment body).
- A run-URL of the form `actions/runs/[0-9]+` whose conclusion is `success` (verify via `gh run view <id> --json conclusion --jq .conclusion`).
- A byte-count line matching `bytes:\s*[0-9]+`.
If any field is missing, comment on the issue requesting it and leave the issue open. Do NOT close.
**Callback URL closure gate template** (append to issue body when the audit anchor matches):
```text
## Callback URL closure gate
This is a callback-URL-class follow-through. To prevent recurrence of #1784/#3183
(closed without verified second remediation), this issue is **NOT closeable** until
a comment is posted containing ALL THREE fields:
- [ ] Verbatim redirect_uri values verified (paste each callback URL byte-for-byte)
- [ ] Workflow run ID showing `scheduled-oauth-probe.yml` ran green AFTER the dashboard change
- [ ] Byte count of the GitHub App Callback URL textarea (`wc -c` output)
Auditor checklist (operator):
1. Open the GitHub App settings page (e.g., `https://github.com/organizations/<org>/settings/apps/<app>`)
2. Capture the textarea contents verbatim into the issue
3. Run `gh workflow run scheduled-oauth-probe.yml` and wait for it to be green
4. Paste run-URL + byte count + verbatim URLs into the closing comment
```
For each item, write the issue body to a temp file (do NOT use heredocs in this step — write with `{ echo "..."; } > /tmp/follow-through-body.md`), then create the issue:
```bash
gh issue create --title "follow-through: <ITEM_DESCRIPTION>" --label "follow-through" --milestone "<MILESTONE>" --body-file /tmp/follow-through-body.md
```
Replace `<ITEM_DESCRIPTION>` with the text after the ⏳ emoji (trimmed). Replace `<MILESTONE>` with the value from the roadmap or "Post-MVP / Later".
**Issue body template** (write to the temp file):
````text
## Follow-Through Item
<ITEM_DESCRIPTION>
<!-- When this item is about a migration, include either of:
**Migration file:** `NNN_full_stem.sql`
See "Migration filename anchor" rule above. -->
**Source PR:** #<PR_NUMBER>
**Created by:** /ship Phase 7 Step 3.5
**Created:** <YYYY-MM-DD>
## Verification
```html
<!-- soleur:followthrough
script=scripts/followthroughs/<feature-name>-<ISSUE_NUM>.sh
earliest=<ISO-8601-UTC>
secrets=<comma-separated-secret-names-or-omit>
-->
```
Canonical convention: `knowledge-base/engineering/ops/runbooks/followthrough-convention.md`.
The directive is parsed daily by `.github/workflows/scheduled-followthrough-sweeper.yml`
via [scripts/sweep-followthroughs.sh](../../../../scripts/sweep-followthroughs.sh) — exit 0 PASS / exit 1 FAIL / other TRANSIENT.
## Status
Awaiting verification. The follow-through sweeper will check this issue once `earliest` is reached.
````
**Step 3.5.A — Generate the stub script.** For each item, scaffold a stub under
[scripts/followthroughs/](../../../../scripts/followthroughs/) named
`<feature-name>-<ISSUE_NUM>.sh` by copying
[./references/followthrough-stub-template.sh](./references/followthrough-stub-template.sh)
and customizing the TODO block. Make the script executable (`chmod +x`). Mirror the structure of
[scripts/followthroughs/sentry-checkins-3859.sh](../../../../scripts/followthroughs/sentry-checkins-3859.sh) (the canonical reference).
**Step 3.5.B — Choose a verification pattern.** Default to automated per
`hr-no-dashboard-eyeball-pull-data-yourself`:
- **HTTP probe** (canary, status page): `curl -sS -o /dev/null -w '%{http_code}' "$URL" | grep -q '^200$' && exit 0 || exit 1`
- **DNS probe**: `dig +short +time=5 +tries=2 TXT example.com | grep -qF "$EXPECTED" && exit 0 || exit 1`
- **SQL probe** (Supabase prd): scaffold via `/soleur:schedule --once` so the workflow brings its own Doppler env; the follow-through script then queries the workflow run status via `gh run list --workflow <name>.yml --status success`.
- **GitHub Actions probe**: `gh run list --workflow <wf>.yml --status success --created '>=<earliest>' --json conclusion | jq -e 'length > 0'`
- **Operator-confirmed** (CAPTCHA, OAuth consent, subjective design call): the script runs `gh issue view <N> --comments --json comments | jq -re '.comments[].body' | grep -qE '^RESULT: PASS$'` — the operator types `RESULT: PASS` in an issue comment when verification is done. This is the legitimate use of operator-confirmed exit-0: the script reads the human verdict, not the human reads a dashboard.
Bare "operator manually checks" with NO scripted gate is non-compliant with
`hr-no-dashboard-eyeball-pull-data-yourself` AND `wg-pm-class-followthrough-for-operator-dogfood`
(#4188). If the operator-confirmed pattern is unsuitable, the verification is not
follow-through-shaped — file a regular GitHub issue without the `follow-through` label.
**Step 3.5.C — Declare needed secrets.** If the script reads any `$X` value beyond
`GH_TOKEN` / `GH_REPO` / `HOME` / `PATH`, declare them as a comma-separated list in
the directive's `secrets=` clause AND add each secret to
`.github/workflows/scheduled-followthrough-sweeper.yml` `env:` block (the sweeper
passes ONLY allowlisted vars into the script's environment per the directive's
`secrets=` clause). Omit the `secrets=` line entirely if no secrets are needed.
**Step 3.5.D — Choose `earliest`.** ISO-8601 UTC, formatted `YYYY-MM-DDTHH:MM:SSZ`.
Default `now + 24h` for HTTP/DNS probes; `now + 48h` for cron-triggered probes
(allows ≥2 cron windows to fire); `now + 5 business days` for operator-confirmed
patterns. The sweeper skips the issue until `now >= earliest`.
**Step 3.5.E — Precondition gate.** Before `gh issue create`, the agent MUST self-test
the body it composed by piping the proposed body through the same awk parser the
sweeper uses (extracted verbatim from [scripts/sweep-followthroughs.sh](../../../../scripts/sweep-followthroughs.sh) lines 36-48):
```bash
awk '
/^<!-- *soleur:followthrough/, /-->/ {
gsub(/^<!-- *soleur:followthrough/, "")
gsub(/-->/, "")
for (i = 1; i <= NF; i++) {
if ($i ~ /^script=/) { sub(/^script=/, "", $i); print "script " $i }
if ($i ~ /^earliest=/) { sub(/^earliest=/, "", $i); print "earliest " $i }
if ($i ~ /^secrets=/) { sub(/^secrets=/, "", $i); print "secrets " $i }
}
}
' /tmp/follow-through-body.md
```
Assert that:
1. `script` extracted is non-empty AND, after `realpath -m --relative-to=$REPO_ROOT`
canonicalization, points under the [scripts/followthroughs/](../../../../scripts/followthroughs/)
root. Use realpath rather than a bare prefix-match — a path that uses `..` traversal
under the followthroughs root (e.g. one pointing at `../../bin/sh` via the
followthroughs directory) satisfies a naïve `case` prefix match but is rejected
after canonicalization. Concrete check:
```bash
canon=$(realpath -m --relative-to="$REPO_ROOT" "$script_path" 2>/dev/null)
case "$canon" in
scripts/followthroughs/*) : ;;
*) fail "script '$script_path' escapes scripts/followthroughs/ root" ;;
esac
```
2. `earliest` extracted parses cleanly via `date -u -d "$earliest" +%s`,
3. The referenced script path exists on disk and is executable.
If any assertion fails, warn the operator, do NOT create the issue, and offer to
scaffold the missing pieces. **Why:** PR #4178 was filed with the OLD-convention
YAML and rotted open for ~24h until #4186 retrofitted it. The precondition gate
is the cheapest forward defense; the contract is asserted at PR time by
`plugins/soleur/test/ship-followthrough-directive.test.sh`.
**Mechanical backstop** (defense-in-depth on top of this honor-system gate):
the PreToolUse hook [`.claude/hooks/follow-through-directive-gate.sh`](../../../../.claude/hooks/follow-through-directive-gate.sh)
intercepts every `gh issue create --label follow-through` call at the Bash-tool
boundary and re-runs the same awk parser against the resolved `--body-file` or
inline `--body`. The agent step above MUST still run — the hook is the second
net, not the first. The hook denies the tool call with a structured error if the
directive is absent, malformed, or references a missing/non-executable script.
See `.claude/hooks/follow-through-directive-gate.test.sh` for the cases the hook
enforces.
**Step 3.5.E.2 — Post-create re-validation.** After `gh issue create` succeeds,
re-fetch the just-created issue body via `gh issue view <N> --json body --jq .body`
and re-run the same awk parser against it. The on-create body MUST extract the
same `script`/`earliest` tokens as the proposed body. This catches the rare class
where GitHub's API silently truncates or mangles a body whose markdown collides
with one of GitHub's own template processors (the `<!-- ... -->` shape is
markdown-suppressed but mishandled by some legacy edit-path-validators). If the
post-create parse diverges from the proposed parse, fail the step: the issue
exists on GitHub but is sweeper-invisible — close it with a comment naming the
divergence and retry the create.
**Step 3.5.F — Operator-only ack.** When the chosen pattern is operator-confirmed
(Step 3.5.B), append a `## Operator instructions` block to the issue body explaining
the `RESULT: PASS` / `RESULT: FAIL` comment sentinel the script greps for.
**Legal-attestation follow-throughs** (replaces former `clo_routable: true` field):
for legal-source verification (AUP, Privacy Policy, GDPR Policy, DPD, Article 30
register, T&C against EUR-Lex / leginfo.legislature.ca.gov / congress.gov /
federalregister.gov / legislation.gov.uk / laws-lois.justice.gc.ca, or any cited
`Art.\s*\d+` / `§\s*\d+` regulation/code section), use the operator-confirmed pattern
(Step 3.5.B) with body instruction `Run /soleur:go #<this issue> to invoke the CLO
agent for verification`. The script reads the operator's `RESULT: PASS` comment after
CLO completes. See `knowledge-base/project/learnings/workflow-patterns/2026-05-18-clo-attestation-auto-route-instead-of-human-task.md`.
**Step 4:** Report: "Created N follow-through issue(s): #X, #Y, #Z"
**Why this matters:** PR #1398 (Google OAuth brand verification) had no tracking mechanism after the session ended. External dependencies that outlive a session — DNS propagation, app store reviews, certificate issuance, brand verification — get forgotten without automated tracking. See [#1433](https://github.com/jikig-ai/soleur/issues/1433). PR #4178 was filed via the OLD-convention YAML emitter and rotted open for ~24h until PR #4186 retrofitted it; this directive shape (PR for #4190) prevents the regression class. See `knowledge-base/project/learnings/2026-05-20-test-stubs-env-and-csp-gates-miss-runtime-bugs.md`.
3.6. **Post-merge Supabase migration verification.** If the PR includes database migration files (`supabase/migrations/`), verify each migration was applied to production before proceeding to cleanup.
**Step 1:** Detect migration files in the PR diff. Use the merge base hash from Phase 3:
```bash
git diff --name-only --diff-filter=A HASH..HEAD -- '*/supabase/migrations/*.sql'
```
If no migration files found, skip to Step 4.
**Step 2:** Classify each new migration.
- **Schema-addition** (`ADD COLUMN`, `CREATE TABLE`, `CREATE INDEX`): verify via the REST probe in Step 3 below.
- **Data migration** (backfill, value normalization, constraint-preparation rewrite): require a sibling verify file at `apps/web-platform/supabase/verify/<same-filename>.sql`. If absent, block the session and prompt the author to add one — see `knowledge-base/engineering/ops/runbooks/supabase-migrations.md` §3 ("Data backfill verification"). Once present, CI's `verify-migrations` job runs the sentinels on every deploy and auto-closes any matching `follow-through` issues; no additional manual step here.
**Step 3:** Verify each schema-addition migration is live in production by querying the Supabase REST API:
```bash
SUPABASE_URL=$(doppler secrets get NEXT_PUBLIC_SUPABASE_URL -p soleur -c prd --plain)
SUPABASE_KEY=$(doppler secrets get SUPABASE_SERVICE_ROLE_KEY -p soleur -c prd --plain)
curl -s "$SUPABASE_URL/rest/v1/<table>?select=<new_column>&limit=1" \
-H "apikey: $SUPABASE_KEY" -H "Authorization: Bearer $SUPABASE_KEY"
```
- If the query returns data (even `null` values): the column exists. Report "Migration verified: `<column>` exists in `<table>`."
- If the query returns a 400/404 or `column not found` error: the migration was NOT applied. Report the failure and attempt to apply it using the Supabase CLI or Management API. Do NOT silently proceed — the deployed code expects the new schema.
**Step 4:** Report: "Migration verification: N/N columns confirmed in production" or "Migration verification: FAILED — <details>."
**Why this matters:** In the 2026-03-28 session, migration `010_tag_and_route.sql` was committed and deployed but never applied, causing `NOT NULL` constraint failures on every Command Center session start. In the 2026-04-03 session (#1375), migration verification was left as a manual "post-merge todo" instead of being executed — violating the "execute, don't list" rule. This step ensures migrations are verified automatically.
3.7. **Terraform provisioner gate.** If the PR modified `.tf` files, grep for `remote-exec` provisioner blocks. If found, warn: "This PR contains remote-exec provisioners that cannot run in CI. Run `terraform apply` now to prevent drift." Block the session from ending until apply is confirmed or explicitly deferred.
**Step 1:** Detect `.tf` files in the PR diff:
```bash
git diff --name-only --diff-filter=AM HASH..HEAD -- '*.tf'
```
If no `.tf` files found, skip to Step 4.
**Step 2:** Grep for `remote-exec` provisioners in changed files:
```bash
grep -l 'remote-exec' <changed .tf files>
```
If no matches, skip to Step 4.
**Step 3:** Display the warning and ask: "Run `terraform apply` now, or defer with justification?" If deferred, record the justification in the PR body.
3.8. **Chain to postmerge verification (CONTINUATION GATE — MUST complete before Step 4).** After release workflows pass and migration verification completes, invoke `/soleur:postmerge` to verify production health, Sentry cron monitors, and file freshness:
```
skill: soleur:postmerge <PR-number>
```
If postmerge reports any FAILED phase (production health, Sentry warning, browser regression), display the failures prominently but do NOT block cleanup — the deploy has already happened; the signal is for immediate operator attention, not rollback.
**Do NOT skip this step.** Do NOT proceed to Step 4 (cleanup) without invoking postmerge. The rationalization "the PR merged, we're done" is exactly the failure mode this gate prevents — a merged PR is not a deployed PR, and a deployed PR is not a healthy deployment. **Why:** PR #4512 — the agent jumped from merge confirmation directly to cleanup, skipping release workflow monitoring (Step 2) and postmerge verification (Step 3.8). The release was still in progress and could have failed silently.
4. Clean up worktree and local branch:
Navigate to the repository root directory, then run `bash ./plugins/soleur/skills/git-worktree/scripts/worktree-manager.sh cleanup-merged`.
This detects `[gone]` branches (where the remote was deleted after merge), removes their worktrees, archives spec directories, deletes local branches, and pulls latest main so the next worktree branches from the current state.
**If working from a worktree:** Navigate to the main repo root first, then run cleanup.
**If the session ends before cleanup runs:** The next session will handle it automatically via the Session-Start Hygiene check in AGENTS.md. The `cleanup-merged` script is idempotent and safe to run at any time.
## Important Rules
- **Always set a semver label.** Every PR that touches `plugins/soleur/` must have a `semver:patch`, `semver:minor`, or `semver:major` label. CI uses this label to bump the version at merge time.
- **Never edit version fields.** `plugin.json` and `marketplace.json` versions are frozen sentinels (`0.0.0-dev`). Version is derived from git tags via GitHub Releases at build time.
- **Ask before running /compound.** The user may have already documented learnings.
- **Do not block on missing artifacts.** Not every change needs a brainstorm or plan.
- **Confirm the PR title and body** with the user before creating it (skip in headless mode).
- **CI workflow edits:** When the PR touches `.github/workflows/*.yml` or `.github/actions/**`, load [ci-workflow-authoring.md](./references/ci-workflow-authoring.md) for known-buggy idioms, heredoc/YAML indentation traps, Doppler service-token naming, `claude-code-action` pin freshness, and `jq -e` guards for JSON polling. These were migrated out of AGENTS.md — review them before pushing CI changes.
- **Register / policy update PRs:** When the PR diff is bounded to `knowledge-base/legal/**` or `docs/legal/**` and documents controls introduced by an upstream PR (typical for follow-through register updates per Phase 7 Step 3.5), load [register-update-pr-pattern.md](./references/register-update-pr-pattern.md) before authoring the PR body. The pattern: cite by semantic identifier (function / RPC / migration anchor), not by plain-prose file path, to avoid the `Block PR body citing files not in diff` (#2905) gate firing on legitimate cross-references. Inline-backtick file references are exempt as of PR #3882's follow-up.
No comments yet. Be the first to comment!