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>
This commit is contained in:
Garry Tanandcapy-ai[bot] authored and GitHub committed 2026-11-08 00:59:38 +00:00
1 parent 892c3b13cd
commit 914e7b6fb3
78 files changed
+1770 -174

No files matched your search

+6
View File
@@ -127,6 +127,12 @@ cheap guards (generated-content freshness, size and prompt budgets, credential s
crew names them in `GUARD_CHECKS`), because a paid gate that turns red on a free check
is spend wasted.
A lane writes the public record in plain words: its branch name, PR title, commit subjects
and changelog fragment say what changed for someone using the project, with an issue code
only at the end (`capy/github-rate-limit-gst-1713`, "Stop gstack from hitting GitHub's rate
limit while it waits on PRs (GST-1713)"), and its PR body keeps codes, receipts and lane
state in the closing Agent notes section ([ship-policy.md](ship-policy.md#plain-titles)).
A lane edits only the files it was given. The controller keeps the ownership list in the
digest's **In progress**; a lane that needs a file another live lane owns tells the
controller, who routes it, and never edits it itself. Everything crosses the crew through
+99 -3
View File
@@ -1256,8 +1256,9 @@ which carry the workflow consequences the skill prose would otherwise spell out:
| `ship: review_scope …` | Step 9.4 item 0 runs `gstack-review-scope classify` on each pass's findings; see [review_scope](#review-scope) |
| `ship: changelog_fragments <dir>: …` | Step 12 writes no version; Step 13 writes the entry as `<dir>/<branch-slug>.md` (entry text only, `bump:` front matter only when the change warrants more than the policy bump), never an `[Unreleased]` section, linted with `gstack-prose-lint <file> --gate`; `gstack-ship-queue land` assigns the version; see [changelog fragments](#changelog-fragments) |
The PR body's first section is the ship receipt (`gstack-ship-receipt write … --body`),
written by the pr-body section of `/ship` after the body is composed.
The ship receipt (`gstack-ship-receipt write … --body`) goes in the PR body's closing Agent notes
section, written by the pr-body section of `/ship` after the body is composed; a body from before that
layout keeps its receipt at the top and is still read.
<a id="ship-receipt"></a>
## The ship receipt
@@ -2677,4 +2678,99 @@ a bundle the identity is unknown and the receipt is `not-eligible`. When the
recorded predecessor head is no longer the predecessor PR's head (checked
through `--predecessor-ref` or `gh pr view --predecessor-pr`) the receipt
prints `PREDECESSOR MOVED <old> -> <new> — fix: gstack-restamp --after <pr>
again (RESTAMP_PREDECESSOR_MOVED)` and refuses `eligible`.
again (RESTAMP_PREDECESSOR_MOVED)` and refuses `eligible`.
<a id="plain-titles"></a>
## Plain titles: `.gstack/plain-titles.json`
People outside a project read its pull requests and commit log to decide
whether it is worth using. So PR titles (and the squash commit titles they
become), branch names, the opening of each PR body, changelog entries and commit
subjects say in everyday words what changed for someone using the project, for
example "Stop gstack from hitting GitHub's rate limit while it waits on PRs".
**Front for humans, end for machines.** The rule applies to the readable front.
A title may end with one group of references, such as `(GST-1713)`,
`(GST-1808, GST-1809)` or `(#4122)`; that group is never checked. A branch
may end with one tracker code (`capy/github-rate-limit-gst-1713`). A PR body
opens with one or two plain sentences (what changed and which goal of the
project it serves) and keeps tracker codes, the ship record and other
machine-read lines in the Agent notes section at the end, below the
`<!-- gstack:agent-notes -->` marker. The checker skips that section.
What the checker refuses in the front:
- a tracker code matching one of the policy's `codes` patterns;
- a word or phrase from `terms` (whole words, any case; in branch names a
hyphen or underscore counts as a space);
- in titles and commit subjects, a `word(scope):` prefix, since the scope is
usually an internal module name. A bare `fix:` or `docs:` is allowed, and so
is a leading version such as `v1.98.0.0 release`;
- in the PR opening, a first block that is a heading, code block, list, table,
HTML or image, or a paragraph longer than two sentences;
- in a changelog entry, a tracker code anywhere, and a listed word in the bold
lead. Code spans, code blocks, links and quoted words are skipped.
### The policy file
Each repository opts in by committing `.gstack/plain-titles.json`
(`gstack-plain-title init` writes an example; `gstack-plain-title validate`
checks it). Without the file every check passes with
`PLAIN_TITLE: none (no policy)`, so gstack's own word list never blocks
another project. The keys:
| Key | Meaning |
|-----|---------|
| `codes` | Regex patterns for tracker codes, matched in any case. Keep them narrow: `\\b(?:GST\|GSTA)-\\d+\\b`, not every `ABC-123`, so `UTF-8` stays readable. |
| `terms` | The project's own words that mean nothing to an outside reader. |
| `allow` | `{ "text", "reason" }` entries. A refused match inside an allowed phrase passes. Every entry says why. |
| `since` | A date (`YYYY-MM-DD`, midnight UTC). PRs created before it are exempt, so nothing already open breaks. |
| `exempt_branch_prefixes` | Branch prefixes that gstack's own tools generate; those branches are not checked. |
gstack's own file lists its tracker prefixes (GST, GSTA, GBRA, GEVA, FOUN, HARN,
ALPH) and R-numbers (`R76`), the crew and Captain names, and the words last
week's titles used most: voices, outside voice, pregate, fix-check, ledger,
receipt, train, wave, lane, restamp and no-stop, with their plurals where
they were used. It exempts `release/` (the release PR), `wave/` (wave PRs) and
`gstack-land-repair/` (land's repair PR). The allow list is one entry, kept short
on purpose:
| Allowed | Why |
|---------|-----|
| `R2` | Cloudflare R2, the storage service, not a rules number. |
"merge train" is not allowed: to a reader outside the crew it still names
gstack's own merge process, so titles say "merge several PRs together" instead.
### Where it runs
1. **/ship, before posting.** /ship writes a plain title and runs
`gstack-plain-title check` on the title, the branch (before its first
push), the PR opening and the changelog fragment, and rewrites anything it
refuses.
2. **gstack-post, before posting.** `gstack-post pr-title` and `pr-create`
run the title check first and post nothing when it refuses.
3. **The "Plain title" CI check** (`.github/workflows/plain-title.yml`). It
runs on every new PR, on each push, and when the title or body is edited.
It fails on a refused title and only warns about the branch name, the
opening and commit subjects, because those cannot always be fixed in place.
It runs on `pull_request_target` with a read-only token and the base
repository's checkout, so a PR is checked against the policy and checker
already on main and cannot change either; the PR text reaches the checker
only as files. The PR that first adds the workflow does not run it for the
same reason.
`gstack-plain-title check` exits 0 when everything passes, the policy is
missing, the PR is exempt or there are only warnings; 1 when something is
refused; 2 on a usage error; 3 when the policy is invalid. Each finding is
one `PLAIN_TITLE:` line naming only the matched words, and the last line is
`PLAIN_TITLE: ok | refused=<n> | warned=<n> | exempt (created before <since>) | none (no policy)`.
Codes and fixes: [troubleshooting.md](troubleshooting.md#plain-title-refused).
### Making it a required check
The rollout takes two merges. The first ships the checker, the writers and the
workflow. Once every open PR has a "Plain title" run (an edit to its title or
body starts one), a one-line follow-up adds `"Plain title"` to
`changelog_fragments.required_checks`, and `gstack-ship-queue land` then
refuses a PR whose check is red or missing. Adding it in the first merge would
hold every open PR that has never had a run.
+3 -3
View File
@@ -698,14 +698,14 @@ A lot of branches die when the interesting work is done and only the boring rele
### The PR body
`/ship` writes the PR body from the diff, the receipt and the review, and a reader who was not in the session decides the merge from it. On Capy (`CAPY_PR: on`) the body opens with four sections, in this order, before the standard Summary, Test plan and Documentation sections:
`/ship` writes the PR body from the diff, the receipt and the review, and a reader who was not in the session decides the merge from it. The front is for people and the end is for machines: the body opens with one or two plain sentences saying what changed for someone using the project and which README goal it serves, and it ends with one collapsed **Agent notes** section (`<!-- gstack:agent-notes -->` then `<details><summary>Agent notes</summary>`) that holds the ship receipt, the approval and Plan lines, issue codes and the review-loop lines gstack's tools read. The title follows the same shape: plain words first, an issue code only at the end (`Stop gstack from hitting GitHub's rate limit while it waits on PRs (GST-1713)`), checked by `gstack-plain-title` (see [Plain titles](ship-policy.md#plain-titles)). On Capy (`CAPY_PR: on`) the opening is followed by three sections, in this order, before the standard Summary, Test plan and Documentation sections:
1. **`## What changed`**: one to three captioned before/after images from `gstack-pr-shots` or a UI screenshot, highest-signal first, with a collapsed text version under each.
2. **`## Why`**: two or three sentences. The first states the problem in the person's words (the issue, the message, the failing run), then the cause and the user-visible effect.
3. **`## Blast radius`**: what else the change touches and the one fact that makes that safe, with the evidence-ladder rung that fact reached (a test that exercises the shared path, a grep over every caller, a measured run). A change to a shared helper, a generated file, a selection list or a policy default always has one; a leaf fix says so in one line.
4. **`## Approvals`**: the output of `gstack-approvals render`, verbatim. On a private repository that is the table; on a public repository, or one whose visibility gh cannot read, it is only `Approved: yes|no (record <id>)`, because GitHub keeps every earlier version of a PR description.
`## Approvals`, the output of `gstack-approvals render` verbatim, goes inside Agent notes. On a private repository that is the table; on a public repository, or one whose visibility gh cannot read, it is only `Approved: yes|no (record <id>)`, because GitHub keeps every earlier version of a PR description.
Before the body is composed, `gstack-prose-lint` reads the Why, Summary and Blast radius drafts (`--sections 'Why,Summary,Blast radius' --gate`) and the CHANGELOG entry (`--base origin/<base> --gate`), and a `PROSE_LINT_FOUND` line names the sentence to rewrite; the rest of the body is advisory. The Captain's merge-ask template (`captain/templates/merge-ask.md`) carries the same four sections, so a READY report and its PR say the same thing.
Before the body is composed, `gstack-prose-lint` reads the Why, Summary and Blast radius drafts (`--sections 'Why,Summary,Blast radius' --gate`) and the CHANGELOG entry (`--base origin/<base> --gate`), and a `PROSE_LINT_FOUND` line names the sentence to rewrite; the rest of the body is advisory. The Captain's merge-ask template (`captain/templates/merge-ask.md`) carries the same sections, so a READY report and its PR say the same thing.
### Versions: where `/ship` looks, and when it ships without one
+34
View File
@@ -7377,3 +7377,37 @@ These codes come from `bin/gstack-clean`, which reclaims old codex scratch in `<
### `RUNAWAY_INPUT: file=<path> …` (gstack-runaway)
**Meaning.** That thread's file is not valid JSON, or lacks `cost.tokens`, a parseable `created_at` or numeric `tasks[].cost.tokens`. The thread is skipped (never counted as OK), the rest of the sweep still runs, `SWEEP:` counts it as `unreadable`, and the exit is 1. **Fix.** Fetch that thread again with `tools.thread.get` (the whole object, not a summary) and rerun the scan.
## Plain titles (gstack-plain-title)
These codes come from `bin/gstack-plain-title`, which /ship, gstack-post and the "Plain title" CI check run so that PR titles, branch names, PR openings, changelog entries and commit subjects say in everyday words what changed. The rules are in [ship-policy.md](ship-policy.md#plain-titles); the word list is the repository's `.gstack/plain-titles.json`. Each finding prints as `PLAIN_TITLE: <CODE> <kind> "<match>" — <hint>`, naming only the matched words, never the whole title or body.
<a id="plain-title-refused"></a>
### `PLAIN_TITLE: PLAIN_TITLE_REFUSED <kind> "<match>" — …`
**Meaning.** The front of the PR title (everything before one optional trailing group of references such as `(GST-1713)` or `(#4122)`) holds a tracker code, a word from the policy's `terms` list, or a `word(scope):` prefix. Exit 1; gstack-post posts nothing, and the CI check fails. **Fix.** Rewrite the title as a plain sentence about what changed for someone using the project, for example "Stop gstack from hitting GitHub's rate limit while it waits on PRs (GST-1713)". Editing the title reruns the CI check.
<a id="plain-branch-refused"></a>
### `PLAIN_TITLE: PLAIN_BRANCH_REFUSED <kind> "<match>" — …`
**Meaning.** The branch name holds a tracker code or a listed word before its last segment. Hyphens and underscores count as spaces, one tracker code at the very end is fine (`capy/github-rate-limit-gst-1713`), and branches gstack generates (`release/`, `wave/`, `gstack-land-repair/`) are exempt. /ship refuses it before the first push; CI only warns, since a pushed branch cannot be renamed in place. **Fix.** Name the next branch in plain words; for this one, open a new branch only if the PR has not been pushed yet.
<a id="plain-opening-refused"></a>
### `PLAIN_TITLE: PLAIN_OPENING_REFUSED <kind> "<match>" — …`
**Meaning.** The readable part of the PR body (everything above the `<!-- gstack:agent-notes -->` marker) does not start with a plain paragraph: it starts with a heading, code block, list, table, HTML or image, runs past two sentences, or names a tracker code or listed word. /ship refuses it; CI only warns. **Fix.** Start the body with one or two sentences saying what changed and which goal of the project it serves. Move codes and records into the Agent notes section at the end.
<a id="plain-changelog-refused"></a>
### `PLAIN_TITLE: PLAIN_CHANGELOG_REFUSED <kind> "<match>" — …`
**Meaning.** The changelog entry holds a tracker code, or its bold lead holds a listed word. Code spans, code blocks, links and quoted words are skipped. **Fix.** Drop tracker codes from the entry and rewrite the bold lead in everyday words.
<a id="plain-commit-refused"></a>
### `PLAIN_TITLE: PLAIN_COMMIT_REFUSED <kind> "<match>" — … (warning only)`
**Meaning.** A commit subject on the PR breaks the title rule. This is always a warning: the squash commit on main takes the PR title, which the title check already guards. **Fix.** Write later commit subjects as plain sentences; nothing needs rewriting now.
<a id="plain-policy-invalid"></a>
### `gstack-plain-title: .gstack/plain-titles.json is not valid JSON or breaks the schema … (PLAIN_POLICY_INVALID)`
**Meaning.** The policy file does not parse, has an unknown key, a `codes` entry that is not a valid regex or matches empty text, an `allow` entry without a reason, or a `since` that is not a date. Exit 3; nothing was checked. **Fix.** Fix the key the line names, then run `gstack-plain-title validate`.
+1 -1
View File
@@ -159,7 +159,7 @@ waiting on the base, not failed ([base_red](ship-policy.md#base-red)).
The one publication the unattended rules allow: when an open PR for the branch
exists and HEAD is its pushed head, Step 18 takes the pr-body section's
existing-PR path and posts the body (receipt first) and title through
existing-PR path and posts the body (receipt in its Agent notes section) and title through
`gstack-post`. It never runs `pr-create`, never pushes a commit and never runs
`gh pr ready`; a branch with no open PR, or whose PR head is not HEAD, takes
the handoff section. When the run committed anything (a CHANGELOG entry,