### Prepare the title (Step 18) Prepare the title in a private file; Step 19 posts that file, never a shell string: ```bash _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: ```bash TITLE_FILE="$(git rev-parse --show-toplevel 2>/dev/null || pwd)/.gstack/tmp/" gh pr view --json title -q .title | ~/.claude/skills/gstack/bin/gstack-pr-title-rewrite.sh --stdin > "$TITLE_FILE" || exit 1 ``` GitLab reads `glab mr view --output json | jq -r .title` into the same pipe. 2. For a new PR/MR, write `v : ` into the file with your file-write tool; `` names what the user gets, not the mechanism. 3. Every created or updated title MUST start with `v$NEW_VERSION `; check the file with `grep -Eq '^v( |$)' "$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 `: `; no version prefix. 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: ```bash 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. ### Issue links Run `~/.claude/skills/gstack/bin/gstack-issue-links --base --out .gstack/tmp/issues.json`, adding `--issue ` per issue the user named as fixed and `--ci-run ` per failing CI run. Interactive: ask once about its `(promote: fix #N)` references (each `fix #` reply adds `--issue `), and add `--supersedes `/`--credit ` 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: the body opens with its `## What changed`, `## Why` and `## Approvals` before the sections below. On `CAPY_PR: off`, skip it. The PR/MR body should contain these sections (never reuse a prior run's body): ``` ## Summary ..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 ## Test Coverage N" from its receipt's `tests_ran`, or "Tests ran: count unavailable" when the runner summary was not recognised. A ZERO-RUN lane never reaches this body unresolved.> ## Pre-Landing Review ## Exploratory QA ## Design Review ## Eval Results ## Greptile Review ## Scope Drift ## Plan Completion ## Linked Spec ## Issues ## Verification Results ## TODOS ## Documentation via : `; the receipt write below refuses (`DOCS_AUDIT_BLOCKED`) when the body lacks it.> ## Test plan - [x] : 🤖 Generated with [Claude Code](https://claude.com/claude-code) ``` #### 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 / Greptile / eval output sections in tool-attributed fences (` ```codex-review ` / ` ```greptile `) 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. ```bash _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. ```bash : "${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_REST_FILE="$(git rev-parse --show-toplevel 2>/dev/null || pwd)/.gstack/tmp/" [ -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; } { 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 (`{"":{"pass":N,"fail":N,"skip":N}}`) to `.gstack/tmp/ship-gate.json` with your file-write tool, then: ```bash rm -f .gstack/tmp/review-loop.json ~/.claude/skills/gstack/bin/gstack-review-scope summary --since "$REVIEW_SINCE" --out .gstack/tmp/review-loop.json && ~/.claude/skills/gstack/bin/gstack-ship-receipt write --pr --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 --body "$PR_BODY_FILE" ``` `--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 @` (`{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:`. 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). 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** (`` from the recheck): ```bash TITLE_FILE="$(git rev-parse --show-toplevel 2>/dev/null || pwd)/.gstack/tmp/" ~/.claude/skills/gstack/bin/gstack-post pr-body --body-file "${PR_BODY_FILE:?restore the composed body path}" && \ ~/.claude/skills/gstack/bin/gstack-post pr-title --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 `. 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; do not run 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: ```bash TITLE_FILE="$(git rev-parse --show-toplevel 2>/dev/null || pwd)/.gstack/tmp/" ~/.claude/skills/gstack/bin/gstack-post pr-create --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. - **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 ` output back into the body file and rerun the block, which scans again. Only when the user accepts the findings as they are, rerun the same block with `--confirm ` 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, the branch name, the remote URL and the block for posting by hand. Do not stop — the code is pushed — but never report a PR that was not created. 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 is an error that blocks publication until its cause is fixed. **Output the PR/MR URL** — then proceed to Step 20. ---