Files
gstack/ship/sections/pr-body.md
T
Garry Tanandcapy-ai[bot] b1f78301db The lander must accept each owner-approved gate change before a PR merges (GST-2491) (#4304)
* Let an owner recorded approval close a gate finding in unattended ship (GST-2491)

An unattended /ship logs every gate finding open and nothing could close it, so a PR with an owner-approved gate change could never finish. gstack-approvals gate records the owner disposition outside the run; it closes only that fingerprint on a review row whose range holds the head, and finish, the receipt, the PR body and READY all carry it.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* Keep the pinned review brief unchanged; document the resume steps for a dispositioned gate finding (GST-2491)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* Bind a gate disposition to its gate change and show it as kept to the rest of /ship (GST-2491)

A disposition now names the finding's gate_id as well as its fingerprint, so a later loosening of the same line stays open. gstack-review-read shows a closed gate finding as kept with the disposition's approval id, so the existing --blocking-open, --gate-open, draft and Gate edits rules carry it without prose changes, and an unreadable approval record applies no disposition instead of failing the reader.

Review-Finding: p1/red-team#1 reader=red-team
Review-Finding: s11/outside-voice#1 reader=outside-voice
Review-Finding: s11/outside-voice#2 reader=outside-voice
Review-Finding: s11/adversarial-native#1 reader=adversarial-native
Review-Finding: s11/adversarial-native#2 reader=adversarial-native
Review-Finding: s11/adversarial-native#4 reader=adversarial-native
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* List the Gate disposition line in the receipt and prose troubleshooting entries (GST-2491)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* Require the lander to accept each owner gate disposition before merging (GST-2491)

Land and a new read-only verify --pin re-derive the dispositions the approval store applies at the pin and merge only with --accept-gate-disposition for each one. The gate writer refuses while any worktree holds an open /ship run on the branch, under a lock; a row counts only in the writer's full shape with its digest; receipt write needs --body when a disposition applies; ready and land cross-check the receipt and body against the store. Supersedes #4299.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* Regenerate the result-code anchors for the gate-disposition refusal (GST-2491)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* Always record the receipt's gate dispositions and refuse a store that disagrees at merge (GST-2491)

The receipt always carries gate_dispositions (an empty list when none apply) and write refuses on an unreadable store or a git error. Land, verify and ready refuse a receipt id the merge-time store lacks, naming the likely wrong state root or mirror, and a field-less receipt beside an applied row. A git ancestry error while deriving dispositions refuses instead of reading as not applied.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* Count a disposition whose head a rewrite removed as not applied, and expect the receipt to end with gate_dispositions (GST-2491)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* Trim and check the gate disposition recorder name, and let a parked run's ended lease stop blocking it (GST-2491)

Review-Finding: s11/outside-voice#3 reader=outside-voice
Review-Finding: s11/outside-voice#2 reader=outside-voice
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

---------

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
2026-11-12 01:49:08 +00:00

23 KiB

Prepare the title (Step 18)

Prepare the title in a private file; Step 19 posts that file, never a shell string:

_GT="$(git rev-parse --show-toplevel 2>/dev/null || pwd)/.gstack/tmp"
mkdir -p "$_GT" && chmod 700 "$_GT" || { echo "Not sent: cannot create $_GT for the text file." >&2; exit 1; }
_EX=$(git rev-parse --git-path info/exclude 2>/dev/null) && mkdir -p "$(dirname "$_EX")" && { grep -qxF '/.gstack/tmp/' "$_EX" 2>/dev/null || echo '/.gstack/tmp/' >> "$_EX"; }
TITLE_FILE=$(mktemp "${_GT:?}/pr-title.XXXXXX") || { echo "Not sent: mktemp failed in $_GT." >&2; exit 1; }; echo "TITLE_FILE: $TITLE_FILE (name: ${TITLE_FILE##*/})"

Write the text into each printed file with your file-write tool (Claude Code's Write tool needs a Read of the empty file first), exactly as it should appear. The text never goes into a shell command, heredoc or quoted argument. If a write fails or is refused, do not send: print the cause, the file path and the command below for sending by hand.

  1. For an existing open PR/MR (not Step 6.5's early PR, which takes item 2), fill the file from the platform, substituting the printed name, the matched number and NEW_VERSION; no title text passes through you:
    TITLE_FILE="$(git rev-parse --show-toplevel 2>/dev/null || pwd)/.gstack/tmp/<title-file-name>"
    gh pr view <pr-number> --json title -q .title | ~/.claude/skills/gstack/bin/gstack-pr-title-rewrite.sh <new-version> --stdin > "$TITLE_FILE" || exit 1
    
    GitLab reads glab mr view <pr-number> --output json | jq -r .title into the same pipe.
  2. For a new PR/MR, write v<NEW_VERSION> <plain change> into the file with your file-write tool, adding (<issue code>) at the end when the work has one. The front says in everyday words what changed for someone using the project ("Stop gstack from hitting GitHub's rate limit while it waits on PRs (GST-1713)") and names what the user gets, not the mechanism; no <type>(<scope>): prefix, no tracker code, lane or Captain name, and none of the repository's coined terms before that trailing group (docs/ship-policy.md#plain-titles).
  3. Every created or updated title MUST start with v$NEW_VERSION ; check the file with grep -Eq '^v<new-version>( |$)' "$TITLE_FILE" and never publish an unprefixed title.
  4. NO_VERSION: replaces items 1-3: keep an existing title (the platform title piped straight into the file), or write <plain change>[ (<issue code>)]; no version prefix.
  5. Plain title: run ~/.claude/skills/gstack/bin/gstack-plain-title check --title-file "$TITLE_FILE". On refused=<n>, rewrite the file's front in everyday words (each PLAIN_TITLE: line names the word) and rerun until ok, exempt or none; an existing PR's title is rewritten the same way. Then gstack-plain-title check --branch "$(git branch --show-current)" --mode warn and print its last line; a pushed branch is never renamed.

Save the title file name for Step 19.


Step 19: Create PR/MR

Recheck Step 18's PR/MR lookup and record it. Errors or ambiguous matches STOP publication. If the open PR/MR or title changed, repeat Step 18's identity/title preparation, then return here for a new lookup and a fresh body before publishing.

Resolve Linked Spec before composing the body

  1. Resolve the archive directory and branch:
    GSTACK_STATE_ROOT=$(~/.claude/skills/gstack/bin/gstack-paths --get GSTACK_STATE_ROOT); : "${GSTACK_STATE_ROOT:?gstack-paths failed; reinstall with ./setup or /gstack-upgrade}"
    SLUG=$(~/.claude/skills/gstack/bin/gstack-slug --get SLUG)
    CURRENT_BRANCH=$(git branch --show-current)
    SPEC_ARCHIVES="$GSTACK_STATE_ROOT/projects/$SLUG/specs"
    
  2. Read archive frontmatter as data, never shell source. Select an exact spec_branch match to CURRENT_BRANCH; among matches use the newest spec_filed_at. Never infer an issue number from a branch name. If no readable match or positive integer spec_issue_number, omit only ## Linked Spec and continue composing the PR. Resolve ambiguous matches before linking an issue.
  3. Compare that spec's acceptance criteria with Step 8's results. Only fully completed Step 8 plan scope permits Closes #N, with every spec criterion verified. Partial, deferred, failed, dropped or unverified scope uses Linked to #N and names the remaining work; never auto-close it. Include the archive filename and spec_filed_at, not a private absolute path; gstack-post scans them with the body.

Run ~/.claude/skills/gstack/bin/gstack-issue-links --base <base> --out .gstack/tmp/issues.json, adding --issue <n> per issue the user named as fixed and --ci-run <id> per failing CI run. Interactive: ask once about its (promote: fix #N) references (each fix #<n> reply adds --issue <n>), and add --supersedes <n>/--credit <n> for a Related: PR only when the owner confirms; rerun after any change. The completion message lists unpromoted candidates, Related: lines and any ISSUE_LOOKUP_FAILED.

Capy

Run [ "${GSTACK_CAPY:-}" = 1 ] && echo 'CAPY_PR: on' || echo 'CAPY_PR: off'. On CAPY_PR: on, read ~/.claude/skills/gstack/ship/sections/capy-pr.md now: ## What changed, ## Why and ## Blast radius lead; ## Approvals goes into the Agent notes. On CAPY_PR: off, skip it.

The PR/MR body should contain these sections (never reuse a prior run's body). The front is for people, the end is for machines: the body opens with one or two plain sentences (what changed for someone using the project, and which goal from the project's README it serves; with no stated goals, the user-visible benefit), with no heading above them. Tracker codes, receipts, approval and Plan lines, review-loop and gate lines and every other machine-read line go only in the closing Agent notes section, never above it.

<One or two plain sentences: what changed for a user, and the README goal it serves.>

## Summary
<Open with the problem in the user's words (what they hit, saw or asked), then the fix
and what they get now; themes from `git log origin/<base>..HEAD --oneline` after that,
minus VERSION/CHANGELOG bookkeeping. Never an inventory or a commit list. BAD: "Adds
gstack-prose-lint, wires Steps 13 and 19, refreshes the goldens." GOOD: "Nobody reads a
CHANGELOG entry twice, so autopilot words shipped every release. /ship now lints the entry
before the commit and names each line to rewrite.">

## Blast radius
<One or two sentences: what else the change touches (callers, consumers, generated
files, hosts) and the one fact that makes that safe, with the rung of the evidence
ladder the fact reached (file:line read, walked failure path, script ran, repro).>

## Test Coverage
<coverage diagram from Step 7, or "All new code paths have test coverage.">
<If Step 7 ran: "Tests: {before} → {after} (+{delta} new)">
<If Step 7 ran: "Coverage: {X}% value-weighted ({Y}% including {W} weakly covered paths)">
<If Step 7 ran: "Test value: {K} tests written, {R} rejected by the authoring gate, {E} existing tests extended, {W} paths weakly covered (weak = ★, gate-failing or unrated)." Use the singular noun for a count of 1 ("1 test written", "1 existing test extended", "1 path weakly covered").>
<Weak paths and leftover gaps as proposed tests with value cards; each regression test's
"Regression proof — fails at HEAD · passes at base · passes after fix" line; Test value
details for cards whose file type has no known comment syntax.>
<One line per Step 5 lane: "Tests ran: <lane> N" from its receipt's `tests_ran`, or
"Tests ran: <lane> count unavailable" when the runner summary was not recognised. A
ZERO-RUN lane never reaches this body unresolved.>

## Pre-Landing Review
<findings from Step 9 code review, or "No issues found."; then Step 9.4's `REVIEW_LOOP:` line.>
<Each `Ruled note: …` line `gstack-approvals rulings` prints, verbatim: a finding the owner ruled a disclosed note,
with its ruler and reason; each `Gate disposition: …` line `gstack-approvals dispositions` prints, verbatim; finish's `MECHANICAL: x` as `Mechanical retake: x` and its `UID_SIDECAR: x (beside y)` as `Godot UID sidecar: x (beside y)`. `gstack-ship-receipt write --body` refuses a body missing one.>
<Gate edits: Step 9's latest `Gate edits:` line verbatim — `none detected (...)`,
`N listed, M read, K findings — J kept, R restored, O open`, `partial (...)` or
`UNAVAILABLE — ...`; never `none detected` for partial or UNAVAILABLE. Under it, each
kept finding's `gate`, path and `reason`, and each open finding's `gate` and path.
Identity and reason only; no hunk text.>
<Outside review: its verdict; `unverified` or `unavailable` is listed as missing coverage with its reason, never as passed.>

<Step 9.4's saved `## Deferred findings` and `## Review notes` sections, verbatim, as their own
sections here; omit each when empty. References only: never a closing keyword.>

## Exploratory QA
<Step 9's current surfaces/charters, reproducers, approved regressions and red/green
proof, fixes and blocked/inconclusive/not-run coverage. Never present stale or
unavailable results as passing.>

## Design Review
<If design review ran: "Design Review: static (no preview declared). Design-lite: N findings — M auto-fixed, K skipped. AI Slop: clean/N issues." The receipt's `design` field says the same; no rendered /design-review runs inside /ship yet.>
<Detector: "clean" | "N findings (rule-id, rule-id)" | "not installed" | "not cached" | "off" — the state the probe printed; rule ids and counts only, finding text and snippets never reach the PR body.>
<If no frontend files changed: "No frontend files changed — design review skipped.">

## Eval Results
<If evals ran: suite names, pass/fail counts, cost dashboard summary. If skipped: "No prompt-related files changed — evals skipped.">

## Review bot comments
<Step 10 complete: list comments with their bot and [FIXED] / [FALSE POSITIVE] / [ALREADY FIXED], or "No review bot comments." for a successful empty fetch.>
<Step 10 unavailable: include `Review bot triage: UNAVAILABLE (dispatch failed)` and the actual reason.>
<Step 10 no_pr: omit this section.>

## Scope Drift
<If scope drift ran: "Scope Check: CLEAN" or list of drift/creep findings>
<If no scope drift: omit this section>

## Plan Completion
<If plan file found: completion checklist summary from Step 8>
<If Step 8 printed the not-run line: that line verbatim.>
<If plan items deferred: list deferred items>

## Linked Spec
<Closes #N only when the Linked Spec check above permits it; otherwise
"Linked to #N (partial delivery — not auto-closing)" with remaining work and
"Close #N manually after follow-up lands." Include archive filename and filed date.
Without a valid match, omit this entire section.>

## Issues
<Issue links stdout, verbatim; omit on ISSUE_LINKS: none.>

## Verification Results
<Step 8.1 obligations executed at Step 9: N PASS, M FAIL, K BLOCKED, J NOT RUN,
not-applicable reasons, unresolved obligations and accepted deferrals.
Unavailable/inconclusive is never PASS.>

## TODOS
<If items marked complete: bullet list of completed items with version>
<If no items completed: "No TODO items completed in this PR.">
<If TODOS.md created or reorganized: note that>
<If TODOS.md doesn't exist and user skipped: omit this section>

## Documentation
<Embed Step 14.5's vetted nonempty `documentation_section` for this invocation:
its saved section file, inserted unchanged by the compose block's `DOCS_SECTION_FILE` lines.
A blocked audit shipped under a user exception has no section file: state its
blocked status, scope and exception here and drop the block's `DOCS_SECTION_FILE` guard
and its `cat -- "$DOCS_SECTION_FILE" && echo &&` step. The exception is one line,
word for word as Step 14.5 saved it in `.gstack/tmp/docs-audit.json`:
`Documentation exception: accepted by <accepted_by> <date> via <via>: <reason>`;
the receipt write below refuses (`DOCS_AUDIT_BLOCKED`) when the body lacks it.>
<Always include the status and reviewed scope: updated, current, or blocked with the actual user's named risk exception. Never omit this section or reuse another invocation's audit.>

## Test plan
- [x] <Each executed test lane's command>: <observed passing summary>

🤖 Generated with [Claude Code](https://claude.com/claude-code)

<!-- gstack:agent-notes -->
<details><summary>Agent notes</summary>

<Machine-read lines, last: `Refs <issue codes>`, the `## Approvals` output (Capy) with its Plan line,
and the Pre-Landing Review section's `REVIEW_LOOP:`, `Gate edits:`, `Ruled note:`, `Gate disposition:`, `Review-loop exception:`
and exception lines. `gstack-ship-receipt write` adds the receipt here; never type it.>

</details>

Compose the body, then publish through gstack-post

The PR body is world-readable on a public repo. gstack-post scans the exact title and body bytes it sends and passes them to gh/glab as arguments, so there is no separate scan here. Wrap any Codex / review bot / eval output sections in tool-attributed fences (```codex-review / ```review-bot) so the scanner WARN-degrades the example credentials those tools quote instead of blocking the PR (a live-format credential inside the fence still blocks).

Write the body from above into two private files: the first through the ## Documentation heading line, the second from ## Test plan on, ending with the Agent notes section.

_GT="$(git rev-parse --show-toplevel 2>/dev/null || pwd)/.gstack/tmp"
mkdir -p "$_GT" && chmod 700 "$_GT" || { echo "Not sent: cannot create $_GT for the text file." >&2; exit 1; }
_EX=$(git rev-parse --git-path info/exclude 2>/dev/null) && mkdir -p "$(dirname "$_EX")" && { grep -qxF '/.gstack/tmp/' "$_EX" 2>/dev/null || echo '/.gstack/tmp/' >> "$_EX"; }
BODY_TOP_FILE=$(mktemp "${_GT:?}/pr-body-top.XXXXXX") || { echo "Not sent: mktemp failed in $_GT." >&2; exit 1; }; echo "BODY_TOP_FILE: $BODY_TOP_FILE (name: ${BODY_TOP_FILE##*/})"
BODY_REST_FILE=$(mktemp "${_GT:?}/pr-body-rest.XXXXXX") || { echo "Not sent: mktemp failed in $_GT." >&2; exit 1; }; echo "BODY_REST_FILE: $BODY_REST_FILE (name: ${BODY_REST_FILE##*/})"

Write the text into each printed file with your file-write tool (Claude Code's Write tool needs a Read of the empty file first), exactly as it should appear. The text never goes into a shell command, heredoc or quoted argument. If a write fails or is refused, do not send: print the cause, the file path and the command below for sending by hand.

In a new shell, restore Step 14.5's saved section file path as DOCS_SECTION_FILE and substitute the printed names. The block deletes both drafts; to change the body, write fresh ones.

: "${DOCS_SECTION_FILE:?Restore the saved Step 14.5 section file path before composing}"
PR_BODY_FILE=$(mktemp "${TMPDIR:-/tmp}/gstack-pr-body.XXXXXX") || { echo "ERROR: mktemp failed — cannot compose the PR body; refusing to publish." >&2; exit 1; }
BODY_TOP_FILE="$(git rev-parse --show-toplevel 2>/dev/null || pwd)/.gstack/tmp/<body-top-file-name>"
BODY_REST_FILE="$(git rev-parse --show-toplevel 2>/dev/null || pwd)/.gstack/tmp/<body-rest-file-name>"
[ -s "$BODY_TOP_FILE" ] && [ -s "$BODY_REST_FILE" ] || { echo "Not composed: $BODY_TOP_FILE or $BODY_REST_FILE is empty, so the body was never written. Write both, then rerun." >&2; exit 1; }
~/.claude/skills/gstack/bin/gstack-prose-lint "$BODY_TOP_FILE" --sections 'Why,Summary,Blast radius' --gate >&2 || { echo "Not composed: fix what gstack-prose-lint printed above (a PROSE: line is a sentence to rewrite in the draft; a usage line means the draft lost a heading), then rerun." >&2; exit 1; }
~/.claude/skills/gstack/bin/gstack-plain-title check --body-file "$BODY_TOP_FILE" >&2 || { echo "Not composed: rewrite the opening the PLAIN_TITLE: lines above name (one or two plain sentences, no heading or code above them), then rerun." >&2; exit 1; }
{ awk 1 "$BODY_TOP_FILE" && cat -- "$DOCS_SECTION_FILE" && echo && awk 1 "$BODY_REST_FILE"; } > "$PR_BODY_FILE" || exit 1
rm -f "$BODY_TOP_FILE" "$BODY_REST_FILE"
echo "PR_BODY_FILE: $PR_BODY_FILE"

Handoff record first (identifiers survive truncation). Write Step 16 stage 4's per-lane counts ({"<lane>":{"pass":N,"fail":N,"skip":N}}) to .gstack/tmp/ship-gate.json with your file-write tool, then:

rm -f .gstack/tmp/review-loop.json
NS=$(~/.claude/skills/gstack/bin/gstack-no-stop list | sed -n 's/^NO_STOP_FILE: //p'); [ -s "$NS" ] || NS=
~/.claude/skills/gstack/bin/gstack-review-scope summary --since "$REVIEW_SINCE" --no-ledger --out .gstack/tmp/review-loop.json &&
~/.claude/skills/gstack/bin/gstack-ship-receipt write --pr <pr-number> --base <base> --gate @.gstack/tmp/ship-gate.json --pregate .gstack/tmp/pregate.json --issues @.gstack/tmp/issues.json --review-loop @.gstack/tmp/review-loop.json --docs @.gstack/tmp/docs-audit.json $([ -s .gstack/tmp/targeted.json ] && echo --review-targeted @.gstack/tmp/targeted.json) ${NS:+--no-stop "@$NS"} --body "$PR_BODY_FILE"

If write refuses review_targeted for a docs claim added after the read, or after a granted fix round (add its --grant), rerun Step 9's plan with --range-base <its head> --targeted-ran <its targeted_patch_id>; on NEXT: targeted, run that one reader (pass, its voices stage, verify --targeted-out .gstack/tmp/targeted.json) and write again. A Step 5 red-base pause adds --base-red <pause file> (a conditional receipt the queue refuses, QUEUE_RECEIPT_CONDITIONAL). --docs reads the record Step 14.5 saved ({status, scope[, exception]}): the receipt carries it as docs, and a blocked status without the owner's exception, or a body missing the exception line, is DOCS_AUDIT_BLOCKED with nothing written. When the owner accepted a stop after a fix-check edit, add --review-loop-exception @<file> ({accepted_by, date, via, reason, fix_check: {head, findings}}) and put its printed Review-loop exception: line in the body; never a prose line under REVIEW_LOOP:. A finding the owner rules a disclosed note instead of a fix is closed only by the owner's gstack-approvals rule, recorded outside this run, never by this run or a review-log field (docs/ship-policy.md#ruled-notes); the receipt lists each one as ruled_notes. When gstack-review-dispatch verify printed REVIEW_EXCLUDE_RECORD: digest=<d>, add --review-exclude <d> and the last Step 14.5 audit's --docs-post <audit-id>-post.json and --docs-pre <its Prepare snapshot>; a digest that no longer matches HEAD is RECEIPT_INVALID field=review_exclude: rerun the review, not the write, unless only the docs audit's files under review_exclude_paths changed it, which --docs-post binds (REVIEW_EXCLUDE_DOCS_AUDIT:).

The summary reads this branch's review log for the invocation's passes (its REVIEW_LOOP: line is the one the Pre-Landing Review section carries). Here every finding reaches a row through Step 9.4; a run whose readers were collected by gstack-review-dispatch (ship/DRIVER.md) passes --run <run dir> instead. The && matters: a summary that refuses (no pass matched REVIEW_SINCE) must not let write record a stale or empty loop file.

Add --receipt .gstack/tmp/tree-receipt.json only when Step 16 wrote it (gate-ahead). New PR/MR: omit --pr, then rerun with the number after creation and repost the body. Its SHIP_RECEIPT: line opens the completion message. The posted receipt is what gstack-ship-queue ready, train build and verify --train require (QUEUE_RECEIPT_MISSING, QUEUE_RECEIPT_STALE, no flag around them), so /ship runs before every READY and after any push. RECEIPT_INVALID names the bad field: fix that input file and rerun; nothing was written. ISSUE_LINKS_MISSING: add the named line under ## Issues and rerun. PREGATE_STALE means rerun gstack-pregate on the stamped tree; PREGATE_REMOTE_UNCLEARED needs gstack-pregate clear with the run URL. Both stop publication.

In each block below, restore the literal PR_BODY_FILE path and substitute Step 18's title file name; gstack-post detects GitHub or GitLab from the remote and the repo's visibility (an unavailable lookup, including on GitLab, uses the scanner's public-strict policy). Never re-render the body: every retry sends the same files.

Existing open PR/MR (<pr-number> from the recheck):

TITLE_FILE="$(git rev-parse --show-toplevel 2>/dev/null || pwd)/.gstack/tmp/<title-file-name>"
~/.claude/skills/gstack/bin/gstack-post pr-body <pr-number> --body-file "${PR_BODY_FILE:?restore the composed body path}" && \
~/.claude/skills/gstack/bin/gstack-post pr-title <pr-number> --title-file "$TITLE_FILE"

It updates the body, then the title, retries a retired-GraphQL edit through REST and reads the title back. Then resolve the draft state from Step 9's gate findings:

  1. Step 6.5 opened this PR as an early draft, the user did not ask for a draft, and no gate finding is open: mark it ready now with gh pr ready <pr-number>.
  2. Step 6.5 opened it as an early draft and any gate finding is open: leave it a draft; print Draft kept: Gate edits: N open and the open findings' gate and path.
  3. The PR was already ready (not a draft) and any gate finding is open: publish only under Step 17's recorded human authorisation (option B there); the body already carries the Gate edits: N open banner. Without that record, STOP here and report.

Print the existing URL and continue to Step 20; skip the create command below.

No open PR/MR: add --draft when any gate finding is open (a later agent never infers approval from the PR existing; the human marks it ready after dispositioning the open items). Otherwise create it ready:

TITLE_FILE="$(git rev-parse --show-toplevel 2>/dev/null || pwd)/.gstack/tmp/<title-file-name>"
~/.claude/skills/gstack/bin/gstack-post pr-create --base <base> --title-file "$TITLE_FILE" --body-file "${PR_BODY_FILE:?restore the composed body path}"

With open gate findings the same command carries --draft before --title-file.

Branch on the exit code of either block:

  • 0 posted. Remove $PR_BODY_FILE and the title file.
  • 4 PLAIN_TITLE_REFUSED: nothing was posted, body included. Rewrite the title file's front the way each PLAIN_TITLE: line says (Step 18 item 5) and rerun the same block.
  • 1 HIGH finding: BLOCKED — do not publish. Rotate and redact at the source, then compose fresh files.
  • 2 MEDIUM findings, printed as RULE: lines with a TOKEN:. AskUserQuestion per finding (sterner on public repos, no batch acknowledge). PII offers auto-redact: write ~/.claude/skills/gstack/bin/gstack-redact --from-file "$PR_BODY_FILE" --auto-redact <ids> output back into the body file and rerun the block, which scans again. Only when the user accepts the findings unchanged, rerun the same block with --confirm <confirm-token> added to the command that printed it; any edit needs a new token.
  • 64 with stderr saying the current body holds a title more than once: nothing was posted. STOP and report the duplicated titles; never rerun with --replace yourself, since a human picks which copy stays (docs/troubleshooting.md#gstack-post-pr-body-merge).
  • 3 gh/glab failed, or any other 64 (no usable remote or CLI): print the error, branch, remote URL and the block to post by hand. Do not stop — the code is pushed — but never report a PR that was not created; Step 22 ends status=refused. With open gate findings, also say the gate edits were not disclosed and leave the Step 16 open-items report in the invocation record; never report the disclosure as done.
  • Any other exit errors: it blocks publication until its cause is fixed.

Output the PR/MR URL — then proceed to Step 20.