Files
gstack/ship/sections/pr-body.md
T
Garry Tanandcapy-ai[bot] 914e7b6fb3 Write PR titles, commit messages and branch names in plain English (GST-1825) (#4146)
* Add a check that keeps tracker codes and internal jargon out of PR titles and branch names

gstack-plain-title checks the readable front of a title, branch name, PR opening or changelog entry against the repository's .gstack/plain-titles.json; one trailing issue code stays allowed. A Plain title workflow runs it on every new pull request, and /ship's push refuses a never-pushed branch whose name fails it.

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

* Move the PR body's machine-read lines into a closing Agent notes section

The ship receipt, its runs block and the Plan line now live in one collapsed section at the end of the body, and every reader looks there first while still accepting the old receipt-at-top layout. gstack-post refuses to post a title the plain-title check refuses, and land uses the checked PR title as main's commit title.

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

* Teach /ship to write plain titles, PR openings, commit messages and changelog entries

The README now lists what gstack is for, so a PR opening can name the goal it serves.

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

* Keep tests, size budgets and generated files in step with plain titles and Agent notes

Copy lists that install result-code modules now include the plain-title codes, the ship goldens and size ledger are refreshed, the new workflow is classified, and the pinned prose tests read the new title and body layout.

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

* Record the test-profile list change as non-behavioral for the shared test harness

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

* Keep text people add below the PR notes, and fix three smaller review findings

The notes section now ends at its own closing tag, so a collapsible added after it survives the next post. The opening check splits the body the same way the notes reader does. Wave PR titles no longer name the area, which the plain-title check could refuse. The Capy line in the PR-body steps now puts Approvals in the notes.

Review-Finding: p1/specialist-batched#1 reader=testing
Review-Finding: p1/red-team#1 reader=red-team
Review-Finding: p1/red-team#2 reader=red-team
Review-Finding: p1/red-team#3 reader=red-team
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* Mask credentials in refused-title lines and keep older PRs' plan lines readable

A refused title's printed match is masked by the secret scanner first. The Plan line reader falls back to the whole body when the Agent notes section has none, so a body written before this change keeps its plan when its first receipt lands. The notes section also ends at its own closing tag when its opener is split or marked open, or when a collapsible inside it is left unclosed.

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

* Print a fixed placeholder when a credential in a refused title cannot be masked

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

* Keep a plain-title policy edit from scheduling every paid eval

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

* Record the paid-eval list change as non-behavioral for the shared test harness

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

* Recheck the PR title on every edit so a base change cannot turn a refused title green

Review-Finding: s11/targeted#1 reader=targeted
Review-Finding: s11/adversarial-native#1 reader=adversarial-native
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-08 00:59:38 +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; 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:`, `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.