| 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:
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)':
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:
if [[ -f "CLAUDE.md" ]]; then
cat CLAUDE.md
fi
Identify the base branch (main/master) for comparison:
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:
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:
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):
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:
git branch --show-current
Step 3b — get the PR number for that branch (use the branch name from Step 3a literally):
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:
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:
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.
git merge-base HEAD origin/main
Step 2 (separate Bash call): Use the hash from Step 1 literally in this command.
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:
- Run
bash scripts/sync-readme-counts.sh to auto-update counts in both README.md and plugins/soleur/README.md
- Verify new entries appear in the correct tables in
plugins/soleur/README.md
- 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):
git merge-base HEAD origin/main
Then, in a separate Bash call, use the hash literally:
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 scripts/test-all.sh
If tests fail:
- 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.
- If failures are caused by this branch: Stop and fix before proceeding.
- 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:
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):
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):
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 "" as a substring, including
unrelated SHAs, timestamps, and inline numbers).
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:
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:
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; }
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)
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):
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:
⚠ 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:
- 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)?"
- Present the CMO's recommendations to the user.
- 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.
- Headless mode: Auto-create a GitHub issue with the CMO's content brief for later action. Do not block the ship.
- 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:
- 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."
- Present the audit findings to the user.
- 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.
- 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:
- 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."
- Apply the COO's recommended updates to
expenses.md.
- Interactive mode: Present additions for confirmation before editing.
- 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:
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:
- Apply the label:
gh pr edit <N> --add-label compliance/critical (idempotent — gh silently no-ops if already applied).
- Announce: "Auto-applied
compliance/critical to PR # (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:
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:
- Verify each
compliance/critical issue referenced has a corresponding row in knowledge-base/legal/compliance-posture.md Active Items.
- 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.
- 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.
Counsel-Review CLO-Attestation Gate
The reviewing authority for legal-doc attestation is the clo agent, NOT the human operator. The Soleur user is a non-lawyer founder; deferring legal sign-off to them bottlenecks indefinitely and mis-allocates expertise (the clo agent orchestrates legal-compliance-auditor + legal-document-generator and can cross-check prose against statute and against the implementing migration in one cycle). This is symmetric to how /soleur:plan routes CPO sign-off to the CPO agent. See knowledge-base/project/learnings/workflow-patterns/2026-05-18-clo-attestation-auto-route-instead-of-human-task.md (the operator has corrected human-routed legal sign-off ≥3×).
Trigger: the PR diff touches a legal-doc directory AND the change is legal-attestation-bearing:
legal_touch=$(git diff main...HEAD --name-only \
| grep -E '^(docs/legal/|plugins/soleur/docs/pages/legal/|knowledge-base/legal/)' | head -n 1)
draft_marker=$(git diff main...HEAD -- docs/legal/ plugins/soleur/docs/pages/legal/ knowledge-base/legal/ \
| grep -E '^\+.*\[DRAFT — pending CLO/counsel review' | head -n 1 || true)
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 (legal_touch non-empty AND (sui_threshold OR draft_marker non-empty)):
- Invoke the
clo agent via Task with: the diff, every changed legal artifact, and the implementing files it must cross-check against (migrations, RPC bodies, the consuming TS). Instruct it to produce/attest the counsel-review audit at knowledge-base/legal/audits/<YYYY-MM>-counsel-review-<issue>.md (house style: 2026-05-counsel-review-4353.md), resolving lawful-basis, consent, retention, and Art. 6(1)(f) LIA questions, and to return a per-artifact verdict + an overall disposition (DISCHARGED or BLOCKED).
- On DISCHARGED — the CLO agent is the authority, so proceed without a human sign-off:
- Apply any in-PR conditions the CLO agent names (prose corrections, LIA-test updates).
- Remove the
[DRAFT — pending CLO/counsel review per #<issue>] markers across docs/legal/ plugins/soleur/docs/pages/legal/ knowledge-base/legal/ (derive the file list via grep -rl; do NOT strip the literal from spec/tasks.md descriptive references). Keep each canonical doc and its Eleventy mirror in lockstep, then regenerate apps/web-platform/lib/legal/legal-doc-shas.ts for each changed canonical doc. Non-T&C edits → no TC_VERSION bump. Re-run legal-doc-shas-guard.test.ts + legal-doc-consistency.test.ts AFTER this marker-clearing mutation and confirm green — Phase 4 ran the suite BEFORE this gate, so these post-mutation edits are otherwise unverified within the pipeline (a stale SHA or broken mirror lockstep would slip to CI otherwise).
- Set the audit frontmatter
status: SIGNED-OFF (CLO-agent-attested, Soleur-as-tenant-zero v1).
- Optional human veto (not a block). Emit exactly one line:
COUNSEL-REVIEW: clo agent DISCHARGED #<issue> (audit: <path>). Reply "veto" to hold for external counsel; otherwise ship proceeds. Then continue the pipeline. Do NOT wait for an ack — the veto is an interrupt the operator may raise, not a gate that blocks on their input (matches the operator's chosen v1 model). If the operator vetoes, halt and route the named concern back to the clo agent. (Headless mode: there is no veto channel — emit the line and proceed.)
- On BLOCKED — the CLO agent found prose that misstates the implementation, a weak/absent lawful basis, or a missing disclosure. Halt the ship pipeline and surface the agent's named blocker + recommended fix. This is the ONLY block path, and it is an agent verdict — never "waiting on the human to do legal review."
If not triggered: Skip silently.
Why: PR #4559 (#4558, ADR-044) shipped legal amendments under a single-user incident threshold with [DRAFT — pending CLO/counsel review] markers and an issue (#4564) framed as "a genuine human CLO/CPO sign-off." That framing is the recurring bug the 2026-05-18 learning already named — legal review is a CLO-agent function. This gate closes it at ship time: the clo agent attests and the DRAFT markers clear automatically, with the operator retaining an optional veto rather than being the bottleneck. External counsel re-review is reserved for the audit's frontmatter re-evaluation triggers (first arms-length user, EEA-out, regulated industry), not routine review.
Deploy Pipeline Fix Drift Gate
Trigger: PR touches any of the 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
apps/web-platform/infra/deploy-inngest-bootstrap.sudoers
apps/web-platform/infra/infra-config-apply.sh
apps/web-platform/infra/infra-config-install.sh (#4829 — delivered by the SSH bridge, kept in the hash for drift-guard sync)
apps/web-platform/infra/push-infra-config.sh
apps/web-platform/infra/cat-infra-config-state.sh
Detection:
The 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.
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/infra-config-install.sh"
"apps/web-platform/infra/push-infra-config.sh"
"apps/web-platform/infra/cat-infra-config-state.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|infra-config-install\.sh|push-infra-config\.sh|cat-infra-config-state\.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 workflow auto-fires on push to main when any trigger file changes. It runs the targeted terraform apply from Doppler prd_terraform, verifies the post-apply files_written == files_total invariant, 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.
Both resources auto-apply (#4829). The workflow's -target= set now lists BOTH terraform_data.deploy_pipeline_fix (HTTPS webhook push) AND terraform_data.infra_config_handler_bootstrap (the root-SSH bridge that delivers the handler + the infra-config-install escalation helper + the sudoers grant). The runner reaches the SSH bridge over the existing Cloudflare Tunnel SSH route — it installs cloudflared, opens a cloudflared access tcp localhost forward authenticated by the CF Access ci_ssh service token, and adds an iptables -t nat OUTPUT REDIRECT rule so terraform's Go SSH client transparently reaches sshd. The firewall admin_ips allowlist is unchanged (the tunnel is the access path, not an IP grant). A handler/helper/sudoers change therefore lands on prod with zero operator terraform apply — eliminating the manual step that left #4827 dormant. One-time precondition: the live host must already trust the current CI key (terraform_data.root_authorized_keys, applied on the operator's most recent full terraform apply); a first-apply Permission denied (publickey) means the key is not on-host, not a bridge defect (the CI path cannot self-apply root_authorized_keys — same firewall reason).
In-session apply (operator-machine fallback, #4829). When /ship runs on the operator's own machine rather than CI — detect via [[ -z "${CI:-}" && -z "${GITHUB_ACTIONS:-}" ]] AND ssh-add -l listing a key — the agent CAN apply the bridge in-session over the operator's direct SSH (their IP is in admin_ips, their ssh-agent key is in root's authorized_keys) instead of deferring to the CI auto-apply. This is the rare fallback (transient CI failure, or shipping a handler change you want live immediately); the CI auto-apply above is the default. Run:
if [[ -z "${CI:-}" && -z "${GITHUB_ACTIONS:-}" ]] && ssh-add -l >/dev/null 2>&1; then
cd apps/web-platform/infra
doppler run -p soleur -c prd_terraform -- \
terraform apply -target=terraform_data.infra_config_handler_bootstrap -input=true
fi
Verify with the no-host-login status hook (per hr-no-ssh-fallback-in-runbooks — files_written == files_total via /hooks/infra-config-status, NOT an SSH hash compare):
WEBHOOK_SECRET=$(doppler secrets get WEBHOOK_DEPLOY_SECRET -p soleur -c prd_terraform --plain)
CF_ACCESS_ID=$(doppler secrets get CF_ACCESS_CLIENT_ID -p soleur -c prd_terraform --plain)
CF_ACCESS_SECRET=$(doppler secrets get CF_ACCESS_CLIENT_SECRET -p soleur -c prd_terraform --plain)
HMAC=$(printf '' | openssl dgst -sha256 -hmac "$WEBHOOK_SECRET" | sed 's/.*= //')
curl -fsS -H "X-Signature-256: sha256=${HMAC}" \
-H "CF-Access-Client-Id: ${CF_ACCESS_ID}" \
-H "CF-Access-Client-Secret: ${CF_ACCESS_SECRET}" \
"https://deploy.$(doppler secrets get APP_DOMAIN_BASE -p soleur -c prd_terraform --plain)/hooks/infra-config-status" \
| jq -e '.exit_code == 0 and .files_failed == 0 and .files_written == .files_total'
If ssh-add -l lists no key (no agent), do NOT attempt the in-session apply — let the CI auto-apply on merge handle it (no operator-only step is introduced; the CI path is the default).
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:
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.
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). 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). 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):
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"
- Identify the original missed case from the issue/brainstorm (e.g., "PR #1256 PWA was not assessed for content").
- 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.
- Produce the artifacts that would have been created if the gate had worked (content briefs, expense entries, website audits, etc.).
- 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.
Incident-PIR Gate (mandatory when triggered)
Enforces the operator's standing rule — every detected incident gets a post-incident report — at the merge boundary: when a PR fixes a production incident/outage — including an incident discovered incidentally while doing other work (after-the-fact) — a post-incident report (PIR) MUST be produced before merge. (Constitution: "Incident detected → PIR always.") A fix that silently closes an outage without a PIR loses the learning that prevents recurrence (this gate exists because the 2026-06-02 chat-RLS outage went undetected for ~3 weeks and was nearly shipped-and-forgotten without a post-mortem).
Trigger — fires if ANY of:
-
The session invoked /soleur:incident (a PIR was scaffolded) — then this gate just verifies it landed on the branch.
-
The referenced plan/spec OR the PR body declares brand_survival_threshold: single-user incident or aggregate pattern AND the change is a production-incident fix (not a greenfield feature). Distinguish via the incident-signal scan below.
-
Incident-signal scan. The PR title/body or linked plan matches (case-insensitive) an outage signal AND a production signal:
PR_TEXT=$(gh pr view --json title,body --jq '.title + "\n" + .body' 2>/dev/null || true)
PLAN_PATH=$(printf '%s' "$PR_TEXT" | grep -oE 'knowledge-base/project/(plans|specs)/[^[:space:])"`]+' | head -n1 || true)
PLAN_TEXT=""; [[ -n "$PLAN_PATH" && -f "$PLAN_PATH" ]] && PLAN_TEXT=$(cat "$PLAN_PATH")
HAYSTACK=$(printf '%s\n%s' "$PR_TEXT" "$PLAN_TEXT")
OUTAGE_RE='(outage|incident|broke[n]?|down for|silently (broken|failing)|regression in prod|users? (cannot|could not|can.?t)|unable to (send|use|log ?in)|production .*(broken|down|failing)|Sentry .*(error|alert).*(prod|production|user))'
PROD_RE='(prod|production|deployed|live|app\.soleur\.ai|tenant-zero|customer)'
echo "$HAYSTACK" | grep -qiE "$OUTAGE_RE" && echo "$HAYSTACK" | grep -qiE "$PROD_RE" && echo "INCIDENT-SIGNAL: yes"
A greenfield-feature PR (no production-failure framing) does NOT trigger — the signals require BOTH an outage verb AND a production context. When uncertain, the gate fires (fail-toward-PIR for ambiguous prod-fix PRs); over-producing a short PIR is cheaper than losing an incident's learning.
If triggered — require a PIR on the branch:
git diff --name-only origin/main...HEAD | grep -E '^knowledge-base/engineering/operations/post-mortems/.+-postmortem\.md$'
- Match (a PIR was added/modified on this branch): Pass. Confirm its frontmatter carries
brand_survival_threshold and the Art. 33/34 fields (availability outages set both false with an n/a rationale; data-exposure incidents must evaluate the GDPR gate per /soleur:incident Phase 2).
- No match: the incident has no PIR. Headless mode: invoke
/soleur:incident (or, if unavailable in the loaded plugin snapshot, author the PIR directly using plugins/soleur/skills/incident/templates/pir.md → knowledge-base/engineering/operations/post-mortems/<slug>-postmortem.md), commit it, then re-run the gate. Interactive mode: prompt — (a) run /soleur:incident now, (b) author the PIR inline, or (c) defer with a tracked type/chore issue carrying a Re-eval by: criterion AND the deferred-automation sentinel (only when the PIR genuinely needs data not yet available). Default-deny on "we'll write it later" with no tracked issue.
Also file the systemic follow-ups the PIR names (alerting gaps, write-site sweeps) as their own issues — a PIR with ## Follow-ups that never become issues is shelf-ware.
If not triggered: Skip silently (greenfield features, docs, refactors with no production-incident framing).
Why: The 2026-06-02 chat-message-saving outage (migration 059 made messages.workspace_id RLS-required but the INSERT sites were never swept) ran for ~3 weeks, was first MISdiagnosed, and was nearly shipped-and-forgotten with no post-mortem. The operator's standing instruction is that any detected incident — even one found incidentally while fixing something else — always gets a post-mortem. This gate makes that mechanical at the merge boundary. PIR: knowledge-base/engineering/operations/post-mortems/chat-rls-workspace-id-outage-postmortem.md.
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):
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.
PR_BODY_FILE=$(mktemp)
trap 'rm -f "$PR_BODY_FILE"' EXIT INT TERM
PR_BODY=$(gh pr view --json body --jq .body)
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
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).
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:
- 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.
- Cite an existing OPEN issue. Operator pastes
#NNNN per undeferred match. Skill verifies state/labels/sentinel and updates the PR body with Tracks #NNNN.
- 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 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:
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 in .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 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):
-
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.
-
Commit messages: Search recent branch commits for #N references:
git log origin/main..HEAD --oneline
Extract any #N references from the output.
-
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:
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:
- Display every match with line context:
printf 'In title:\n%s\nIn body:\n%s\n' "${T_MATCHES:-(none)}" "${B_MATCHES:-(none)}".
- 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.
- 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.
- 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 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 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:
git rev-parse --abbrev-ref HEAD
Then push in a separate Bash call, using the branch name literally:
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:
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:
-
The PR was likely created as a draft earlier in the workflow.
-
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.
-
Update the PR. Pass the body as a multi-line string (no $() needed):
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".
-
If the PR is a draft, mark it ready:
gh pr ready PR_NUMBER
-
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).
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):
git merge-base HEAD origin/main
Then, in a separate Bash call, check for new components:
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):
git diff --name-only HASH..HEAD -- apps/web-platform/ | head -1
If apps/web-platform/ has changes, apply app:web-platform label:
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:
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:
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:
-
Merge the base branch locally to surface conflicts:
git merge origin/main --no-commit --no-ff
-
Identify conflicted files:
git diff --name-only --diff-filter=U
-
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
- Many files conflict with whole-function (not line-level) competing implementations: a sibling PR may have shipped your feature mid-pipeline (the one-shot collision gate only probes at START and misses a sibling that implements the same feature under a different issue). Do NOT reflexively resolve to "mine."
git merge --abort, read origin/main's ACTUAL implementation (git show origin/main:<file>), and decide "is my PR still needed?" If main supersedes it, trace main end-to-end against the original bug for any residual gap, surface the collision + gap to the operator for a design call, then git reset --hard origin/main (salvage plan/spec to /tmp first — they live only on the branch) and rebuild ONLY the residual delta. Why: PR #4641 — #4638 shipped the same invite-redirect feature mid-one-shot; reset-and-rebuild turned a 6-file competing rewrite into a 2-file delta. See knowledge-base/project/learnings/workflow-patterns/2026-05-29-dirty-conflict-during-ship-may-mean-sibling-shipped-your-feature.md.
-
Stage resolved files and commit the merge:
git add <resolved files>
git commit -m "Merge origin/main -- resolve conflicts"
-
Push and re-verify:
git push
gh pr view --json mergeable | jq '.mergeable'
-
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 .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:
prev=""; i=0; behind_syncs=0; MAX_BEHIND_SYNCS=6; behind_warned=0
BRANCH=$(git rev-parse --abbrev-ref HEAD)
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
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
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
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
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"
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
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: for a zero-conflict-surface change, use the settle-then-admin-merge escape hatch (gh pr merge --squash --admin after confirming required checks are green on the current SHA — see \"Auto-sync on BEHIND\" below for the full procedure); else 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
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:
- Fetching origin/main once.
- 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.
- 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, 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.
Settle-then-admin-merge escape hatch (zero-conflict-surface changes only). When main is merging PRs faster than this PR's ~8-minute CI cycle, the auto-sync loop livelocks: every git merge origin/main push bumps the head ref, re-triggers the full required-check set, and main moves again before the checks settle — so the branch is never CLEAN-at-current-main and GitHub's queued auto-merge never fires (learning 2026-06-02-auto-merge-livelock-fast-moving-main.md, surfaced on PR #4774). At the 6-sync cap, if this change has zero conflict surface (a docs/skill edit, an additive file, anything that cannot semantically conflict with what's landing on main), the up-to-date requirement is purely procedural and can be bypassed deterministically:
- Stop auto-syncing. The loop has already capped itself; do not hand-roll more
git merge origin/main pushes (that is the livelock).
- Confirm required checks are green on the CURRENT SHA —
gh pr checks <N> must show no required check in a pending or fail bucket (the canonical poll loop reads this via gh pr checks --json name,bucket). --admin bypasses ONLY the up-to-date gate, NOT the checks; merging with a red or pending required check ships unverified code.
- Sync local → origin so the local ref is fast-forward with the pushed head:
git fetch origin && git reset --hard origin/<branch> (this discards any uncommitted or un-pushed local work on the branch — confirm git status is clean first).
- Admin-merge:
gh pr merge <N> --squash --admin. This bypasses only the "branch must be up to date with base" rule.
- Retry the transient race. A busy
main returns Base branch was modified. Review and try the merge again. between the check read and the merge call; loop with a short backoff until it lands: for i in $(seq 1 20); do gh pr merge <N> --squash --admin && break; sleep 18; done.
Do not use this hatch for a change with real conflict surface — there, the up-to-date requirement is load-bearing and the correct move is to merge during a quieter window (or resolve the conflict and let CI re-verify).
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 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).
-
Read the failure details:
gh pr checks <number> --json name,state,description,detailsUrl \
| jq '.[] | select(.state != "SUCCESS")'
-
Identify the failing workflow run and read its logs:
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
-
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.
-
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:
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.
-
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):
-
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.
-
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):
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:
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:
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:
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:
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.
-
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:
git diff --name-only --diff-filter=AM HASH..HEAD -- .github/workflows/
Note: --diff-filter=AM catches both Added and Modified 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:
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):
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:
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:
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:
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 ". 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. Theverify-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:
- The verbatim
redirect_uri value(s) verified — paste each registered callback URL byte-for-byte.
- A workflow run ID showing
scheduled-oauth-probe.yml ran green AFTER the dashboard change (link via https://github.com/<repo>/actions/runs/<id>).
- 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):
## 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:
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):
## 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/operations/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/ named
<feature-name>-<ISSUE_NUM>.sh by copying
./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 (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.
- Self-armed Inngest oneshot (autonomous — no operator, no GH-Actions): when the verification needs fire-time prd secrets / an installation-token repo write and has bespoke logic, ship a reviewed
oneshot-*.ts + a server/index.ts boot-arm (ADR-046). It fires server-side at a future ts and reports to an issue / Sentry on its own. Precedent oneshot-heartbeat-recovery-verify.ts; see inngest-oneshot-and-reminder-patterns.md.
- Generic reminder primitive (autonomous — no deploy): for a one-off issue comment or a registered check, arm it via
POST /api/internal/schedule-reminder (Bearer INNGEST_MANUAL_TRIGGER_SECRET, allowlisted action) — no new function, no deploy. Same runbook.
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 lines 36-48):
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:
-
script extracted is non-empty AND, after realpath -m --relative-to=$REPO_ROOT
canonicalization, points under the 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:
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
-
earliest extracted parses cleanly via date -u -d "$earliest" +%s,
-
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
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. 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:
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/operations/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:
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 —
."
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:
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:
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.
-
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 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 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.