Files
gstack/ship/sections/pr-body.md
T
Garry Tanandcapy-ai[bot] 319ef500ca v1.91.320.0 fix(pregate,gstack-post): PREGATE_NO_LANE names the base policy when a branch adds its own lane; pr-body refuses a body holding a title twice (#3579)
* fix(pregate,gstack-post): PREGATE_NO_LANE names the base policy when the branch adds its own lane; pr-body refuses a body holding a title twice

Fixes #3574
Fixes #3575

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

* fix(ship): pr-body stops and reports a duplicate-title refusal instead of the by-hand path

gstack-post pr-body now exits 64 when the PR's current body holds a title
twice. Step 19 read every 64 as 'no usable remote or CLI'; it now stops and
names the duplicated titles, and never reposts with --replace itself, since a
person picks which copy stays. Size ledger records the +281 bytes.

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

* test: pin the own-policy fix line's guards, its user-policy source and receipt duplicates

A head-policy run and a branch deleting pregate.json get the plain fix; under
a user-owned policy the line names the user file; a body with two receipt
blocks refuses like a doubled title.

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

* docs(changelog): Unreleased entry for the PREGATE_NO_LANE own-policy line and the pr-body duplicate refusal

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-10-29 16:22:12 +00:00

19 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> <type>: <summary> into the file with your file-write tool; <summary> 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<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 <type>: <summary>; 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:
    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: 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
<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). The
ladder is the `/investigate` skill's Blast radius section, when present.>

## 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.>
<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 (lite): N findings — M auto-fixed, K skipped. AI Slop: clean/N issues.">
<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.">

## Greptile Review
<Step 10 complete: list comments with [FIXED] / [FALSE POSITIVE] / [ALREADY FIXED], or "No Greptile comments." for a successful empty fetch.>
<Step 10 unavailable: include `Greptile 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)

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.

_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; }
{ 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
~/.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 <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 --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 @<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:.

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 (<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; 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:

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.
  • 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 as they are, 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, 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.