diff --git a/.github/workflows/plain-title.yml b/.github/workflows/plain-title.yml new file mode 100644 index 000000000..0807fb9f3 --- /dev/null +++ b/.github/workflows/plain-title.yml @@ -0,0 +1,89 @@ +name: Plain title + +# Checks that a PR's title reads in everyday words (R76, docs/ship-policy.md#plain-titles). +# The title is refused in the front: tracker codes, project-only words and a +# type(scope): prefix. One trailing group of references like (ABC-123) or (#4122) +# is fine. The branch name, the PR opening and the commit subjects only warn. +# PRs created before the policy's since date are exempt. +# +# WHY pull_request_target: it runs the base repository's copy of this workflow, +# of bin/gstack-plain-title and of .gstack/plain-titles.json, so a PR cannot +# change the rule it is checked against, and a title edit reruns the check. +# +# WHY this is SAFE: +# - The job checks out the BASE repo (no ref:) and never runs PR code. +# - The token is read-only (contents: read, pull-requests: read). +# - The title, branch, body and created_at arrive only through env: and are +# written to files with printf '%s'. No PR field is inlined into run:. +# - Only the checker's match lines reach the log; PR text is never echoed. +# test/plain-title-workflow-safety.test.ts is the static tripwire for all of it. + +on: + pull_request_target: + types: [opened, edited, synchronize, reopened, ready_for_review] + +permissions: + contents: read + pull-requests: read + +jobs: + plain-title: + name: Plain title + # No job-level if: every event, a base change included, reruns the title + # check, because a skipped run would count as passing over a refused title. + runs-on: ubuntu-24.04 + timeout-minutes: 5 + # A newer event for the same PR cancels the older run. + concurrency: + group: plain-title-${{ github.event.pull_request.number }} + cancel-in-progress: true + steps: + # Base repo only: trusted code. No PR-head checkout. + - name: Checkout base repo (trusted) + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 1 + persist-credentials: false + + - name: Setup Bun + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 + with: + bun-version: 1.4.2 + + - name: Write the PR fields to files + env: + # Attacker-controlled on fork PRs: env only, never inlined into run:. + PR_TITLE: ${{ github.event.pull_request.title }} + PR_BODY: ${{ github.event.pull_request.body }} + GH_TOKEN: ${{ github.token }} + PR_NUM: ${{ github.event.pull_request.number }} + REPO: ${{ github.repository }} + run: | + set -euo pipefail + printf '%s' "$PR_TITLE" > "$RUNNER_TEMP/pr-title.txt" + printf '%s' "$PR_BODY" > "$RUNNER_TEMP/pr-body.txt" + # Commit subjects are a warning only: skip quietly when the API read fails. + if ! gh api --paginate "repos/$REPO/pulls/$PR_NUM/commits" \ + --jq '.[] | select((.parents | length) < 2) | .commit.message | split("\n")[0]' \ + > "$RUNNER_TEMP/pr-commits.txt" 2>/dev/null; then + : > "$RUNNER_TEMP/pr-commits.txt" + fi + + - name: Check the title + env: + PR_CREATED_AT: ${{ github.event.pull_request.created_at }} + run: | + set -euo pipefail + bun --no-env-file --config=/dev/null bin/gstack-plain-title check \ + --title-file "$RUNNER_TEMP/pr-title.txt" --created-at "$PR_CREATED_AT" --mode refuse + + - name: Check the branch name, opening and commit subjects (warnings only) + if: ${{ !cancelled() }} + env: + PR_BRANCH: ${{ github.event.pull_request.head.ref }} + PR_CREATED_AT: ${{ github.event.pull_request.created_at }} + run: | + set -euo pipefail + bun --no-env-file --config=/dev/null bin/gstack-plain-title check \ + --branch "$PR_BRANCH" --body-file "$RUNNER_TEMP/pr-body.txt" \ + --commits-file "$RUNNER_TEMP/pr-commits.txt" --created-at "$PR_CREATED_AT" --mode warn diff --git a/.gstack/plain-titles.json b/.gstack/plain-titles.json new file mode 100644 index 000000000..05a4746b1 --- /dev/null +++ b/.gstack/plain-titles.json @@ -0,0 +1,40 @@ +{ + "$schema": "https://github.com/garrytan/gstack/blob/main/docs/ship-policy.md#plain-titles", + "codes": [ + "\\b(?:GST|GSTA|GBRA|GEVA|FOUN|HARN|ALPH)-\\d+\\b", + "\\bR\\d{1,3}\\b" + ], + "terms": [ + "voices", + "outside voice", + "pregate", + "fix-check", + "ledger", + "ledgers", + "receipt", + "receipts", + "train", + "trains", + "wave", + "waves", + "lane", + "lanes", + "restamp", + "no-stop", + "GStack Minder", + "Capy Workflow Improver", + "Captain" + ], + "allow": [ + { + "text": "R2", + "reason": "Cloudflare R2, the storage service, not a rules number" + } + ], + "since": "2026-11-08", + "exempt_branch_prefixes": [ + "release/", + "wave/", + "gstack-land-repair/" + ] +} diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a6d8f0866..119b2ea26 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1035,6 +1035,12 @@ executable and parse without syntax errors. ## Shipping your changes +Write the pull request title as what changed for someone using gstack, in everyday words, for example +"Stop gstack from hitting GitHub's rate limit while it waits on PRs". Skip `type(scope):` prefixes, and if +you reference an issue, put it at the end in parentheses. The "Plain title" check on every pull request +names any word it refuses; editing the title reruns it. The full rule is in +[docs/ship-policy.md](docs/ship-policy.md#plain-titles). + When you're happy with your skill edits: ```bash diff --git a/README.md b/README.md index 775523902..05a392778 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,13 @@ Fork it. Improve it. Make it yours. And if you want to hate on free open source - **First-time Claude Code users** — structured roles instead of a blank prompt - **Tech leads and staff engineers** — rigorous review, QA, and release automation on every PR +## What gstack is for + +- **A whole team's judgment for one builder.** Product, design, engineering, QA, security and release reviews, each from a specialist role. +- **Catch what CI misses.** Find the bugs that pass tests but break in production, before they ship. +- **AI-written changes you can trust.** Every change carries its tests, its review and the evidence for both, so you can merge without rereading every line. +- **Free, open and portable.** MIT-licensed, and it runs on Claude Code and the other major coding agents. + ## Quick start 1. Install gstack (two pastes, about 30 seconds of setup; see below). diff --git a/autoplan/DRIVER.md b/autoplan/DRIVER.md index 70b6ff619..f12ea0bca 100644 --- a/autoplan/DRIVER.md +++ b/autoplan/DRIVER.md @@ -148,7 +148,7 @@ is the completion check. Copy the whole directory, never single files. ## Coordinator contract (inherited by every lane) -Merge conditions. A lane's PR merges only when: the required gate lanes are green on the stamped tree or the tree receipt says `gate-reuse: eligible`; `gstack-lane-check` reports `clear` for its planned files against every other open lane; the PR body opens with the `gstack-ship-receipt` block; and the coordinator, not the lane, says merge. A lane never merges, rebases another lane, or force-pushes. +Merge conditions. A lane's PR merges only when: the required gate lanes are green on the stamped tree or the tree receipt says `gate-reuse: eligible`; `gstack-lane-check` reports `clear` for its planned files against every other open lane; the PR body carries the `gstack-ship-receipt` block in its Agent notes section; and the coordinator, not the lane, says merge. A lane never merges, rebases another lane, or force-pushes. Behavior-change notices. Every user-visible behavior change outside the plan's own items is listed in the PR body under `## Behavior changes` with the file, the old behavior and the new one, before the review starts. An omitted change found in review is a CRITICAL finding, whatever its size. diff --git a/bin/gstack-doctor-components.sh b/bin/gstack-doctor-components.sh index 9214221b0..9c6db1111 100644 --- a/bin/gstack-doctor-components.sh +++ b/bin/gstack-doctor-components.sh @@ -423,6 +423,12 @@ gstack_result_anchor() { GSTACK_CLEAN_TARGET_REFUSED) printf '%s' 'gstack-clean-target-refused' ;; GSTACK_CLEAN_PARTIAL) printf '%s' 'gstack-clean-partial' ;; GSTACK_CLEAN_INTERNAL) printf '%s' 'gstack-clean-internal' ;; + PLAIN_TITLE_REFUSED) printf '%s' 'plain-title-refused' ;; + PLAIN_BRANCH_REFUSED) printf '%s' 'plain-branch-refused' ;; + PLAIN_OPENING_REFUSED) printf '%s' 'plain-opening-refused' ;; + PLAIN_CHANGELOG_REFUSED) printf '%s' 'plain-changelog-refused' ;; + PLAIN_COMMIT_REFUSED) printf '%s' 'plain-commit-refused' ;; + PLAIN_POLICY_INVALID) printf '%s' 'plain-policy-invalid' ;; *) return 1 ;; esac } diff --git a/bin/gstack-plain-title b/bin/gstack-plain-title new file mode 100755 index 000000000..bdbe0dbfe --- /dev/null +++ b/bin/gstack-plain-title @@ -0,0 +1,133 @@ +#!/usr/bin/env -S bun --no-env-file --config=/dev/null +/** + * gstack-plain-title — check that a PR title, branch name, PR opening, + * changelog entry and commit subjects read in everyday words (R76, + * docs/ship-policy.md#plain-titles). Rules: lib/plain-title.ts; policy: + * .gstack/plain-titles.json; codes PLAIN_* in docs/troubleshooting.md. + * Prints one `PLAIN_TITLE: "" — ` line per finding + * (never the whole title or body) and a last summary line. + */ +import { spawnSync } from 'node:child_process'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { PLAIN_TITLE_POLICY_PATH, checkBranch, checkChangelogEntry, checkCommitSubject, checkOpening, checkTitle, loadPlainTitlePolicy, type PlainFinding, type PlainTitlePolicy } from '../lib/plain-title'; +import { resultLine } from '../lib/result-codes'; + +const HELP = `gstack-plain-title — check that titles, branch names, PR openings and changelog entries read in plain words (docs/ship-policy.md#plain-titles) + + check [--title | --title-file ] [--branch ] [--body-file ] [--changelog ] + [--commits-file ] [--created-at ] [--mode refuse|warn] [--json] + check each input against ${PLAIN_TITLE_POLICY_PATH}; --commits-file holds one commit + subject per line and only ever warns; a PR created before the policy's since date is exempt + validate check ${PLAIN_TITLE_POLICY_PATH} against its schema + init [--force] write an example ${PLAIN_TITLE_POLICY_PATH} + +Last line: PLAIN_TITLE: ok | refused= | warned= | exempt (created before ) | none (no policy) +Exit codes: 0 ok, none, exempt or warnings only · 1 refused · 2 usage · 3 invalid policy`; + +const EXAMPLE = { + $schema: 'https://github.com/garrytan/gstack/blob/main/docs/ship-policy.md#plain-titles', + codes: ['\\bPROJ-\\d+\\b'], + terms: ['an-internal-word'], + allow: [{ text: 'PROJ-1 SDK', reason: 'a public product name that contains a code' }], + since: new Date().toISOString().slice(0, 10), + exempt_branch_prefixes: ['release/'], +}; + +const VALUE_FLAGS = ['--title', '--title-file', '--branch', '--body-file', '--changelog', '--commits-file', '--created-at', '--mode']; + +function usage(message: string): number { + process.stderr.write(`gstack-plain-title: ${message}\n${HELP}\n`); + return 2; +} + +function repoRoot(): string { + const r = spawnSync('git', ['rev-parse', '--show-toplevel'], { encoding: 'utf8', timeout: 15_000 }); + return r.status === 0 && r.stdout.trim() ? r.stdout.trim() : process.cwd(); +} + +function loadPolicy(root: string, context: string): PlainTitlePolicy | null | 'invalid' { + try { return loadPlainTitlePolicy(root); } catch (e: any) { + process.stderr.write(resultLine(context, 'PLAIN_POLICY_INVALID', e.message) + '\n'); + return 'invalid'; + } +} + +const clean = (s: string) => s.replace(/[\u0000-\u001f\u007f"]/g, ' ').slice(0, 80); + +function check(args: string[]): number { + const opts: Record = {}; + let json = false; + for (let i = 0; i < args.length; i++) { + const a = args[i]!; + if (a === '--json') { json = true; continue; } + if (!VALUE_FLAGS.includes(a)) return usage(`unknown flag ${a}`); + const v = args[++i]; + if (v === undefined) return usage(`${a} needs a value`); + opts[a] = v; + } + if (opts['--title'] !== undefined && opts['--title-file'] !== undefined) return usage('pass --title or --title-file, not both'); + if (!['--title', '--title-file', '--branch', '--body-file', '--changelog', '--commits-file'].some(f => opts[f] !== undefined)) return usage('nothing to check; pass a title, branch, body, changelog or commits file'); + const mode = opts['--mode'] ?? 'refuse'; + if (mode !== 'refuse' && mode !== 'warn') return usage(`--mode must be refuse or warn, not ${clean(mode)}`); + const createdAt = opts['--created-at']; + if (createdAt !== undefined && Number.isNaN(Date.parse(createdAt))) return usage(`--created-at is not a date: ${clean(createdAt)}`); + const read: Record = {}; + for (const f of ['--title-file', '--body-file', '--changelog', '--commits-file']) { + if (opts[f] === undefined) continue; + try { read[f] = fs.readFileSync(path.resolve(opts[f]!), 'utf8'); } catch (e: any) { return usage(`cannot read ${f} ${opts[f]}: ${e.code ?? e.message}`); } + } + + const policy = loadPolicy(repoRoot(), 'gstack-plain-title check'); + if (policy === 'invalid') return 3; + const done = (status: string, findings: Array = []) => { + const refused = findings.filter(f => f.severity === 'refused').length; + process.stdout.write(json ? JSON.stringify({ status, refused, warned: findings.length - refused, findings }) + '\n' : `PLAIN_TITLE: ${status}\n`); + return refused ? 1 : 0; + }; + if (!policy) return done('none (no policy)'); + if (createdAt !== undefined && Date.parse(createdAt) < Date.parse(policy.since)) return done(`exempt (created before ${policy.since})`); + + const title = opts['--title'] ?? read['--title-file']?.replace(/\r?\n+$/, ''); + const severity = mode === 'warn' ? 'warned' : 'refused'; + const findings = [ + ...(title !== undefined ? checkTitle(title, policy) : []), + ...(opts['--branch'] !== undefined ? checkBranch(opts['--branch'], policy) : []), + ...(read['--body-file'] !== undefined ? checkOpening(read['--body-file'], policy) : []), + ...(read['--changelog'] !== undefined ? checkChangelogEntry(read['--changelog'], policy) : []), + ].map(f => ({ ...f, severity })); + for (const line of (read['--commits-file'] ?? '').split(/\r?\n/)) { + if (line.trim()) findings.push(...checkCommitSubject(line, policy).map(f => ({ ...f, severity: 'warned' }))); + } + if (!json) for (const f of findings) process.stdout.write(`PLAIN_TITLE: ${f.code} ${f.kind} "${clean(f.match)}" — ${f.hint}${f.severity === 'warned' ? ' (warning only)' : ''}\n`); + const refused = findings.filter(f => f.severity === 'refused').length; + const warned = findings.length - refused; + return done(refused ? `refused=${refused}${warned ? ` warned=${warned}` : ''}` : warned ? `warned=${warned}` : 'ok', findings); +} + +function main(argv: string[]): number { + if (argv.includes('--help') || argv.includes('-h')) { process.stdout.write(HELP + '\n'); return 0; } + const [cmd, ...rest] = argv; + if (cmd === 'check') return check(rest); + if (cmd === 'validate') { + if (rest.length) return usage(`validate takes no arguments`); + const policy = loadPolicy(repoRoot(), 'gstack-plain-title validate'); + if (policy === 'invalid') return 3; + process.stdout.write(policy + ? `PLAIN_TITLE: valid ${PLAIN_TITLE_POLICY_PATH} (codes=${policy.codes.length} terms=${policy.terms.length} allow=${policy.allow.length} since=${policy.since})\n` + : 'PLAIN_TITLE: none (no policy)\n'); + return 0; + } + if (cmd === 'init') { + if (rest.some(a => a !== '--force')) return usage('init takes only --force'); + const file = path.join(repoRoot(), PLAIN_TITLE_POLICY_PATH); + if (fs.existsSync(file) && !rest.includes('--force')) { process.stderr.write(`gstack-plain-title: ${PLAIN_TITLE_POLICY_PATH} exists (pass --force to overwrite)\n`); return 1; } + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, JSON.stringify(EXAMPLE, null, 2) + '\n'); + process.stdout.write(`PLAIN_TITLE: wrote ${PLAIN_TITLE_POLICY_PATH}; list your tracker codes and project-only words, then run gstack-plain-title validate\n`); + return 0; + } + return usage(cmd ? `unknown command ${clean(cmd)}` : 'no command'); +} + +process.exitCode = main(process.argv.slice(2)); diff --git a/bin/gstack-post b/bin/gstack-post index affe3cbb1..d8cf6e7f7 100755 --- a/bin/gstack-post +++ b/bin/gstack-post @@ -9,7 +9,8 @@ * Scans the exact bytes it sends (lib/redact-engine.ts) and passes every value * to gh/glab as an argv element or on stdin, never through a shell string. * Exit: 0 posted · 1 HIGH refused · 2 MEDIUM needs --confirm · - * 3 gh/glab failed · 64 usage. Logic and full usage: lib/gstack-post.ts. + * 3 gh/glab failed · 4 title not plain words (PLAIN_TITLE: lines) · 64 usage. + * Logic and full usage: lib/gstack-post.ts. */ import * as fs from "fs"; import * as path from "path"; diff --git a/bin/gstack-ship-receipt b/bin/gstack-ship-receipt index 3c190a097..897684d58 100755 --- a/bin/gstack-ship-receipt +++ b/bin/gstack-ship-receipt @@ -18,7 +18,7 @@ * * `write` validates against `gstack-artifact schema ship-receipt`, prints the * fenced block (and SHIP_RECEIPT: on stderr), writes it to --out, - * and with --body makes it the first section of that PR body file. + * and with --body puts it in that PR body file's Agent notes section (lib/agent-notes.ts). * `result` is /ship's terminal step: it binds the receipt to HEAD, writes the * run record under /ship/runs/ (ship-run.json: a /ship record, not a * `run` v1 manifest, so `gstack-artifact validate` does not apply to it) and @@ -46,7 +46,7 @@ import { installedGstackVersion, isUnavailable, loadPolicyFromBase, matchesAny, import { approvalForReceipt } from '../lib/approval-citations'; import { CARRY_METADATA_GLOBS, CARRY_NAMED_PATHS, CARRY_TEST_GLOBS, - bindReceipt, carryReceipt, classifyCarryPath, computeCensus, extractReceipt, landedStackParent, missingIssueLines, orderReceipt, parseReviewLoop, placeReceiptFirst, planCarry, planTreeCarry, + bindReceipt, carryReceipt, classifyCarryPath, computeCensus, extractReceipt, landedStackParent, missingIssueLines, orderReceipt, parseReviewLoop, placeReceipt, planCarry, planTreeCarry, receiptIssuesFrom, receiptSummaryLine, renderCensus, renderReceiptBlock, validateReceipt, type CensusPr, } from '../lib/ship-receipt'; import { checkDocsCaps, docsCapLine, docsExceptionLine, parseReceiptDocs } from '../lib/ship-receipt-docs'; @@ -64,7 +64,7 @@ import { isRegistryUnavailable, loadRegistryFromBase } from '../lib/regen'; const HELP = `gstack-ship-receipt — the \`\`\`gstack-ship-receipt\`\`\` block (docs/ship-policy.md#ship-receipt) - write [flags] compose, validate and print the block; --body puts it first in that PR body; + write [flags] compose, validate and print the block; --body puts it in that PR body's Agent notes; --base-red makes it conditional (gstack-base-red classify --pause) read --pr [--repo ] parse the block from the PR body through gh (RECEIPT_MISSING / RECEIPT_INVALID) read --body parse the block from a file @@ -361,7 +361,7 @@ function cmdWrite(rest: string[], json: boolean): number { const block = renderReceiptBlock(receipt); const out = flag(rest, '--out'); if (out) fs.writeFileSync(out, block); - if (body) fs.writeFileSync(body, placeReceiptFirst(fs.existsSync(body) ? fs.readFileSync(body, 'utf8') : '', receipt)); + if (body) fs.writeFileSync(body, placeReceipt(fs.existsSync(body) ? fs.readFileSync(body, 'utf8') : '', receipt)); process.stderr.write(receiptSummaryLine(receipt) + '\n'); process.stdout.write(json ? JSON.stringify(receipt) + '\n' : block); return EXIT.ok; @@ -916,7 +916,7 @@ function cmdCarry(rest: string[], json: boolean): number { if (errors.length) return refused('RECEIPT_CARRY_REFUSED', `the carried receipt does not validate: ${errors[0]!.message} at ${errors[0]!.path}`); const targetedVerdict = receiptTargetedAtHead(repoRoot, baseRef, carried, head); if (!targetedVerdict.ok) return refused('RECEIPT_CARRY_REFUSED', `${targetedVerdict.line}: ${targetedVerdict.message}, so the queue refuses it too (#3859); run /ship on ${head.slice(0, 12)}`); - const newBody = placeReceiptFirst(body, carried); + const newBody = placeReceipt(body, carried); const line = receiptSummaryLine(carried); if (scopedDryRun) return refused('RECEIPT_CARRY_SCOPED_REVIEW', scopedDryRun); if (rest.includes('--dry-run')) { diff --git a/bin/gstack-skill-start b/bin/gstack-skill-start index 825a5d0cb..83049c678 100755 --- a/bin/gstack-skill-start +++ b/bin/gstack-skill-start @@ -374,7 +374,7 @@ echo "GSTACK_PLAN_MODE: $GSTACK_PLAN_MODE" # GSTACK_SESSION_KIND=spawned override must light this up too. [ "$_SESSION_KIND" = "spawned" ] && echo "SPAWNED_SESSION: true" || true [ "$_SESSION_KIND" = "unattended" ] && echo "UNATTENDED_SESSION: true" || true -[ "$_SESSION_KIND" = "unattended" ] && [ "$SKILL_NAME" = "ship" ] && echo "UNATTENDED_SHIP: no push, no new PR; an open PR at HEAD gets its body updated (pr-body section, receipt first), else Step 18 takes the handoff section; receipt at .gstack/tmp/ship-receipt.md; end with gstack-ship-receipt result" || true +[ "$_SESSION_KIND" = "unattended" ] && [ "$SKILL_NAME" = "ship" ] && echo "UNATTENDED_SHIP: no push, no new PR; an open PR at HEAD gets its body updated (pr-body section, receipt in Agent notes), else Step 18 takes the handoff section; receipt at .gstack/tmp/ship-receipt.md; end with gstack-ship-receipt result" || true # --------------------------------------------------------------------------- # Artifacts sync (the former "Artifacts Sync (skill start)" fence, verbatim diff --git a/captain/templates/CREW.md b/captain/templates/CREW.md index fa06c29bd..a4e20842c 100644 --- a/captain/templates/CREW.md +++ b/captain/templates/CREW.md @@ -97,8 +97,9 @@ Homes are paths in the gstack checkout the lane checked at session start. `docs/ship-policy.md` (Run a merge train, Carrying a receipt). 15. **CHANGELOG entry under `## [Unreleased]` before READY;** version files stay at - `BASE`'s version; the stamp touches only `STAMP_FILES`. Home: `docs/ship-policy.md` - (stamp-at-merge). + `BASE`'s version; the stamp touches only `STAMP_FILES`. Titles, commit subjects, branch + names and entries are plain words, any issue code last; internal notation goes in the PR + body's Agent notes. Home: `docs/ship-policy.md` (stamp-at-merge, Plain titles). 16. **Flaky reruns.** Once, same tree, logged in `handoffs/flaky-ledger.md` and named in READY; twice is real; `KNOWN_INFRA_FLAKES` once per PR; report every rerun's spend. @@ -141,6 +142,7 @@ Homes are paths in the gstack checkout the lane checked at session start. current main or a failing test written first in the PR; a feature gets the lean run (`--kind feature`). Home: `docs/captain.md` (Plan before build), `captain/templates/brief.md`. + ## Crew-specific rules Add rules here, numbered from 25, each with the date, the owner's words where there are diff --git a/capy/skill/gstack/SKILL.md b/capy/skill/gstack/SKILL.md index 03e7fae1e..00294e91a 100644 --- a/capy/skill/gstack/SKILL.md +++ b/capy/skill/gstack/SKILL.md @@ -69,7 +69,7 @@ When you work for a Capy Captain (a crew thread) or straight for a person, gstac - **These always need a fresh ask:** a product or behaviour decision, spend over a stated cap, anything published outside the repository, a change to a threshold, budget or trial count, closing someone's contribution without landing it, and anything destructive. - **Record every answer.** Use `gstack-approvals add --ask … --words "" --by … --scope …`, or after an autoplan answer, `gstack-approvals import --autoplan --by `. `gstack-approvals render` prints the `## Approvals` table for your report and the PR body. Rows are secret-scanned, and on Capy they are mirrored to the private Drive folder. - **Plan approvals** use `gstack-owner-brief render --captain --out `. It prints before → after, why it matters, what caused this work, the big decisions (options, the pick, why, what would flip it) and what we need, as lettered ask lines closed by "do it". Relay it whole. Gate codes appear only on its last line, which is for you. If it exits `CAPTAIN_BRIEF_INCOMPLETE`, add the missing plan sections first. -- **Merge asks.** /ship's PR body opens with `## What changed` (captioned before/after images from `gstack-pr-shots` or a UI screenshot, uploaded with Capy's PR asset tool), `## Why`, `## Blast radius` and `## Approvals`. Your READY report attaches the key image. +- **Merge asks.** /ship's PR body opens with plain sentences, then `## What changed` (captioned before/after images from `gstack-pr-shots` or a UI screenshot, uploaded with Capy's PR asset tool), `## Why` and `## Blast radius`, Agent notes last (`## Approvals`). Your READY report attaches the key image. ## Before any push or pull request diff --git a/changelog.d/capy-plain-english-pr-titles-gst-1825.md b/changelog.d/capy-plain-english-pr-titles-gst-1825.md new file mode 100644 index 000000000..8e1f77944 --- /dev/null +++ b/changelog.d/capy-plain-english-pr-titles-gst-1825.md @@ -0,0 +1,6 @@ +--- +bump: minor +--- +- **/ship now writes pull request titles, commit messages, branch names and changelog entries that say in everyday words what changed for someone using the project.** An issue code may follow at the end, as in "Stop gstack from hitting GitHub's rate limit while it waits on PRs (GST-1713)", but the front of a title stays plain: no `type(scope):` prefix, no tracker codes and none of the repository's own coined terms. The new `gstack-plain-title` command checks titles, branch names, PR openings and changelog entries against a word list a repository keeps in `.gstack/plain-titles.json`; a repository without that file gets warnings, never a refusal. /ship and `gstack-post` refuse to post a title that fails, and a "Plain title" check on every new pull request does the same for PRs opened any other way. Pull requests opened before the rule started are exempt. +- **PR bodies now open with one or two plain sentences and end with one collapsed "Agent notes" section.** The ship receipt, approval and plan lines, issue codes and review bookkeeping that gstack's merge tools read now live in that section instead of a JSON block at the top of the body. The merge tools read it there and still accept the older layout, so open pull requests keep working. When gstack merges a pull request, main's commit title is the pull request's checked title. +- **The README now lists what gstack is for**, and /ship's PR opening names the goal a change serves. diff --git a/docs/captain.md b/docs/captain.md index ea1baa6d9..aa5cabc2b 100644 --- a/docs/captain.md +++ b/docs/captain.md @@ -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 diff --git a/docs/ship-policy.md b/docs/ship-policy.md index 8b9b50fdf..af8a29f67 100644 --- a/docs/ship-policy.md +++ b/docs/ship-policy.md @@ -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 : …` | Step 12 writes no version; Step 13 writes the entry as `/.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 --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. ## 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 -> — fix: gstack-restamp --after -again (RESTAMP_PREDECESSOR_MOVED)` and refuses `eligible`. \ No newline at end of file +again (RESTAMP_PREDECESSOR_MOVED)` and refuses `eligible`. + +## 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 +`` 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= | warned= | exempt (created before ) | 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. diff --git a/docs/skills.md b/docs/skills.md index 31aba91e6..3d337ac9b 100644 --- a/docs/skills.md +++ b/docs/skills.md @@ -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 (`` then `
Agent notes`) 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 )`, 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 )`, 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/ --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/ --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 diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 91ed434dc..435f6ea84 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -7377,3 +7377,37 @@ These codes come from `bin/gstack-clean`, which reclaims old codex scratch in `< ### `RUNAWAY_INPUT: file= …` (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: "" — `, naming only the matched words, never the whole title or body. + + +### `PLAIN_TITLE: PLAIN_TITLE_REFUSED "" — …` + +**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. + + +### `PLAIN_TITLE: PLAIN_BRANCH_REFUSED "" — …` + +**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. + + +### `PLAIN_TITLE: PLAIN_OPENING_REFUSED "" — …` + +**Meaning.** The readable part of the PR body (everything above the `` 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. + + +### `PLAIN_TITLE: PLAIN_CHANGELOG_REFUSED "" — …` + +**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. + + +### `PLAIN_TITLE: PLAIN_COMMIT_REFUSED "" — … (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. + + +### `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`. diff --git a/docs/unattended.md b/docs/unattended.md index 82f408227..13a684ff3 100644 --- a/docs/unattended.md +++ b/docs/unattended.md @@ -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, diff --git a/lib/agent-notes.ts b/lib/agent-notes.ts new file mode 100644 index 000000000..2be7bf26e --- /dev/null +++ b/lib/agent-notes.ts @@ -0,0 +1,108 @@ +/** + * agent-notes — the collapsed section at the end of a PR body that holds every line gstack's tools read + * (R76: the front of a body is for people, the end is for machines). The section is the marker line, then + * `
Agent notes`, a blank line, the content, a blank line and `
`; there is one, + * at the very end of the body (a Capy badge a bot appends may follow it). It carries the ship receipt and its runs + * block, the Approvals table and Plan line, issue and Refs lines, ruled notes and the exception lines /ship writes, so + * the readable part above it opens with plain sentences. + * + * The marker counts only on a line of its own outside a code fence, so a body that quotes it in an example never + * opens a section; the last such line wins. `
` closes the section only on its own line outside a fence, so a + * receipt block inside the notes cannot end it early. A body with no marker has no section (`notes: null`): readers + * then read the whole body, which keeps PRs posted before this section existed working. + */ +export const AGENT_NOTES_MARKER = ''; +export const AGENT_NOTES_OPEN = '
Agent notes'; +const CLOSE = '
'; +const FENCE_OPEN = /^ {0,3}(`{3,}|~{3,})/; +const fenceCloses = (fence: string, line: string) => new RegExp(`^ {0,3}${fence[0] === '`' ? '`' : '~'}{${fence.length},}\\s*$`).test(line); + +interface Parsed { readable: string; notes: string | null; after: string } + +/** Line indexes of `wanted` lines outside code fences, from `from` on. */ +function linesOutsideFences(lines: string[], from: number, wanted: string): number[] { + const hits: number[] = []; + let fence: string | null = null; + for (let i = from; i < lines.length; i++) { + const line = lines[i]!.replace(/\r?\n$/, ''); + if (fence !== null) { if (fenceCloses(fence, line)) fence = null; continue; } + const open = FENCE_OPEN.exec(line); + if (open) { fence = open[1]!; continue; } + if (line.trim() === wanted) hits.push(i); + } + return hits; +} + +/** + * The section's lines in a body split after each newline: the marker line, the line after the wrapper's opener, and + * the closing `` line (`lines.length` when it never closes); null when the body has no section. + */ +export function agentNotesLines(lines: string[]): { marker: number; start: number; close: number } | null { + const marker = linesOutsideFences(lines, 0, AGENT_NOTES_MARKER).at(-1); + if (marker === undefined) return null; + let start = marker + 1; + while (start < lines.length && lines[start]!.trim() === '') start++; + if (start < lines.length && /^]*>\s*(?:[^<]*<\/summary>)?$/i.test(lines[start]!.trim())) { + start++; + if (start < lines.length && /^[^<]*<\/summary>$/i.test(lines[start]!.trim())) start++; + } + return { marker, start, close: wrapperClose(lines, start) }; +} + +/** + * The `` line that closes the wrapper: the first one outside fences with every `
` opened inside the + * notes closed, or, when a `
` in the notes never closes, the first `
` line outside fences. + */ +function wrapperClose(lines: string[], from: number): number { + let depth = 0; + let first = -1; + let fence: string | null = null; + for (let i = from; i < lines.length; i++) { + const line = lines[i]!.replace(/\r?\n$/, ''); + if (fence !== null) { if (fenceCloses(fence, line)) fence = null; continue; } + const open = FENCE_OPEN.exec(line); + if (open) { fence = open[1]!; continue; } + if (line.trim() === CLOSE && depth === 0) return i; + if (line.trim() === CLOSE && first < 0) first = i; + const prose = line.replace(/`[^`]*`/g, ''); + depth = Math.max(0, depth + (prose.match(//gi)?.length ?? 0)); + } + return first >= 0 ? first : lines.length; +} + +/** The body around its section: the text above the marker, the content inside the wrapper, and any text after `
`. */ +function parse(body: string): Parsed { + const lines = body.split(/(?<=\n)/); + const at = agentNotesLines(lines); + if (!at) return { readable: body, notes: null, after: '' }; + const { marker, start, close: end } = at; + return { + readable: lines.slice(0, marker).join(''), + notes: lines.slice(start, end).join('').replace(/^(?:[ \t]*\r?\n)+/, '').replace(/\s+$/, ''), + after: lines.slice(end + 1).join(''), + }; +} + +/** The text above the Agent notes marker, and the section's content (`null` when the body has no section). */ +export function splitAgentNotes(body: string): { readable: string; notes: string | null } { + const { readable, notes } = parse(body); + return { readable, notes }; +} + +/** What machine readers parse: the section's content when the body has one, else the whole body (a legacy body). */ +export function agentNotesOrBody(body: string): string { + return parse(body).notes ?? body; +} + +/** The section for `content`: the marker, the collapsed wrapper, and the content between blank lines. */ +export function renderAgentNotes(content: string): string { + return `${AGENT_NOTES_MARKER}\n${AGENT_NOTES_OPEN}\n\n${content.trim()}\n\n${CLOSE}\n`; +} + +/** `body` with its section's content replaced by `content` (the section appended when the body has none); text after `` stays after it. */ +export function withAgentNotes(body: string, content: string): string { + const { readable, after } = parse(body); + const lead = readable.replace(/\s+$/, ''); + const tail = after.replace(/^\s+/, ''); + return (lead ? `${lead}\n\n` : '') + renderAgentNotes(content) + (tail ? `\n${tail}` : ''); +} diff --git a/lib/coordinator-contract.ts b/lib/coordinator-contract.ts index 8d221eec6..ca93e3c7e 100644 --- a/lib/coordinator-contract.ts +++ b/lib/coordinator-contract.ts @@ -13,7 +13,7 @@ export const COORDINATOR_CONTRACT_HEADING = '## Coordinator contract (inherited export const COORDINATOR_CONTRACT = `${COORDINATOR_CONTRACT_HEADING} -Merge conditions. A lane's PR merges only when: the required gate lanes are green on the stamped tree or the tree receipt says \`gate-reuse: eligible\`; \`gstack-lane-check\` reports \`clear\` for its planned files against every other open lane; the PR body opens with the \`gstack-ship-receipt\` block; and the coordinator, not the lane, says merge. A lane never merges, rebases another lane, or force-pushes. +Merge conditions. A lane's PR merges only when: the required gate lanes are green on the stamped tree or the tree receipt says \`gate-reuse: eligible\`; \`gstack-lane-check\` reports \`clear\` for its planned files against every other open lane; the PR body carries the \`gstack-ship-receipt\` block in its Agent notes section; and the coordinator, not the lane, says merge. A lane never merges, rebases another lane, or force-pushes. Behavior-change notices. Every user-visible behavior change outside the plan's own items is listed in the PR body under \`## Behavior changes\` with the file, the old behavior and the new one, before the review starts. An omitted change found in review is a CRITICAL finding, whatever its size. diff --git a/lib/gstack-post.ts b/lib/gstack-post.ts index 25e0b9d2f..02ed12502 100644 --- a/lib/gstack-post.ts +++ b/lib/gstack-post.ts @@ -12,20 +12,26 @@ * rewrite are kept, new ones are appended, and a file with a receipt above * its first `## ` section replaces the text between the receipt and the * first heading while the opening text above the receipt is kept; - * `--replace` overwrites instead. + * the Agent notes section (lib/agent-notes.ts) is one section that always + * stays last; `--replace` overwrites instead. + * `pr-title` and `pr-create` first check the title against the repository's + * plain-title policy (lib/plain-title.ts); a refused title posts nothing. * The scan runs on the merged bytes, which are the bytes sent. * * Exit codes: 0 posted; 1 HIGH finding (or oversize input) refused; * 2 MEDIUM findings need confirmation, printed as `RULE:` lines plus a * `TOKEN:` that binds the bytes, destination, operation and findings; - * 3 `gh`/`glab` failed; 64 usage or invalid input. Nothing is posted on any + * 3 `gh`/`glab` failed; 4 the title breaks the plain-title policy, printed as + * `PLAIN_TITLE:` lines; 64 usage or invalid input. Nothing is posted on any * non-zero exit. bin/gstack-post is the CLI; tests drive `runPost` with a fake * runner so the logic is covered on every platform. */ import { createHash } from "crypto"; import { scan, type Finding, type RepoVisibility } from "./redact-engine"; import { CI_RUN_WORD_BEFORE } from "./redact-patterns"; -import { RECEIPT_FENCE } from "./ship-receipt"; +import { agentNotesLines } from "./agent-notes"; +import { checkTitle, loadPlainTitlePolicy, PLAIN_TITLE_POLICY_PATH } from "./plain-title"; +import { RECEIPT_FENCE, extractReceipt } from "./ship-receipt"; import { canonicalRemote } from "./remote-identity"; import { parseChangelog } from "./changelog-check"; import { versionWidth } from "./version-source"; @@ -43,7 +49,7 @@ export interface PostEnv { err(text: string): void; } -export const EXIT = { posted: 0, refused: 1, confirm: 2, cliFailed: 3, usage: 64 } as const; +export const EXIT = { posted: 0, refused: 1, confirm: 2, cliFailed: 3, plainTitle: 4, usage: 64 } as const; export const USAGE = `usage: gstack-post [] [options] @@ -67,9 +73,13 @@ export const USAGE = `usage: gstack-post [] [options] Write the text files with your file-write tool; never put the text in a shell command. The helper scans the exact bytes it sends. + pr-title and pr-create check the title against ${PLAIN_TITLE_POLICY_PATH} + (gstack-plain-title) first; a repository without that file is not checked. Exit: 0 posted · 1 HIGH finding refused · 2 MEDIUM findings need - confirmation (RULE:/TOKEN: lines on stdout) · 3 gh/glab failed · 64 usage + confirmation (RULE:/TOKEN: lines on stdout) · 3 gh/glab failed · + 4 the title is not plain words (PLAIN_TITLE: lines on stdout; rewrite the + title file and rerun) or the plain-title policy is invalid; nothing posted · 64 usage `; interface Request { @@ -160,8 +170,9 @@ function parseArgs(argv: string[]): Request { return req; } -/** One piece of a PR body: the leading text, a `## ` section, the ship-receipt block, text after a receipt block, or a Capy badge that ends the body. */ +/** One piece of a PR body: the leading text, a `## ` section, the ship-receipt block, text after a receipt block, the Agent notes section, or a Capy badge that ends the body. */ interface Section { key: string; label: string; text: string } +const NOTES_KEY = "agent-notes"; /** * Split a body into its sections, in order. A `## ` line outside a code @@ -180,10 +191,15 @@ interface Section { key: string; label: string; text: string } * heading, with only blank lines after it, is a piece of its own * (`badge:capy-badge`), so it never leaves with the section above it * (GST-1227). A badge anywhere else, an unclosed badge marker and every other - * marker block stay inside the section they sit in. + * marker block stay inside the section they sit in. The Agent notes section + * (its marker line through its closing ``, lib/agent-notes.ts + * agentNotesLines) is one piece (`agent-notes`) whatever it holds, the + * receipt block included; text after it is trailing text. */ function splitSections(body: string): Section[] { const out: Section[] = []; + const lines = body.split(/(?<=\n)/); + const notes = agentNotesLines(lines); let fence: string | null = null; let badgeAt = -1; let inBadge = false; @@ -192,8 +208,14 @@ function splitSections(body: string): Section[] { const openText = () => out.length === 0 ? open("text:lead", "leading text") : open(`text:${plain++}`, "trailing text"); const closes = (run: string) => new RegExp(`^ {0,3}${run[0] === "`" ? "`" : "~"}{${run.length},}\\s*$`).test(bare); let bare = ""; - for (const line of body.split(/(?<=\n)/)) { + for (const [i, line] of lines.entries()) { bare = line.replace(/\r?\n$/, ""); + if (notes && i >= notes.marker && i <= notes.close) { + if (i === notes.marker) { fence = null; open(NOTES_KEY, "Agent notes"); } + out.at(-1)!.text += line; + if (i === notes.close) openText(); + continue; + } const fenceOpen: RegExpExecArray | null = fence === null ? /^ {0,3}(`{3,}|~{3,})/.exec(bare) : null; const outside = fence === null && !fenceOpen; if (fenceOpen && bare.trim() === "```" + RECEIPT_FENCE) { open("receipt", "ship receipt"); fence = "receipt"; } @@ -242,9 +264,14 @@ function splitSections(body: string): Section[] { * flags it, since the merged bytes no longer hold it). The current leading text * (the opening paragraph and a bot's badge above the first receipt or heading) * is kept byte for byte unless the file brings leading text of its own, which - * replaces it as in any merge (GST-1227). Any other file (`## ` sections with - * no receipt above them, or no `## ` section) merges as described above, so - * the text under the receipt is kept too. + * replaces it as in any merge (GST-1227). A file whose last piece is an Agent + * notes section (as /ship composes it since GST-1825) is a full post too: it + * drops the text under a legacy top receipt the same way, and when its notes + * carry a receipt the legacy receipt block itself goes (reported as replaced). + * The Agent notes section, the current one or the file's, always ends the + * merged body, above a Capy badge that ends it. Any other file (`## ` + * sections with no receipt above them, or no `## ` section) merges as + * described above, so the text under the receipt is kept too. */ export function mergeBodySections(current: string, incoming: string): { body: string; replaced: string[]; kept: string[]; added: string[]; dropped: string[] } { const now = splitSections(current); @@ -252,18 +279,24 @@ export function mergeBodySections(current: string, incoming: string): { body: st const ambiguous = next.filter(n => now.filter(s => s.key === n.key).length > 1).map(n => `"${n.label}"`); if (ambiguous.length) throw new UsageError(`the PR's current body holds ${[...new Set(ambiguous)].join(", ")} more than once, so the file's copy matches no single section; nothing was posted. Rerun with --replace and the whole body`); const preamble = (list: Section[]) => { - const end = list.findIndex(s => s.key.startsWith("h2:")); + const end = list.findIndex(s => s.key.startsWith("h2:") || s.key === NOTES_KEY); return list.slice(0, end < 0 ? list.length : end).filter(s => s.key.startsWith("text:") && s.key !== "text:lead" && s.text.trim() !== ""); }; const firstHeading = next.findIndex(n => n.key.startsWith("h2:")); - const managed = firstHeading > 0 && next.slice(0, firstHeading).some(n => n.key === "receipt"); + const isNotes = (s: Section | undefined) => s?.key === NOTES_KEY; + const fileNotes = next.filter(n => n.key !== "badge:capy-badge" && n.text.trim() !== "").at(-1); + const notesPost = isNotes(fileNotes); + const managed = notesPost || (firstHeading > 0 && next.slice(0, firstHeading).some(n => n.key === "receipt")); + const movedReceipt = notesPost && extractReceipt(fileNotes!.text).found; const stale = managed ? preamble(now) : []; const ours = managed ? preamble(next) : []; const paired = new Set
([...stale.slice(0, ours.length), ...ours.slice(0, stale.length)]); const used = new Set
(ours); const replaced: string[] = [], kept: string[] = [], added: string[] = [], dropped: string[] = []; const merged: Section[] = []; - for (const s of now) { + for (const [i, s] of now.entries()) { + if (movedReceipt && s.key === "receipt") { replaced.push(s.label); continue; } + if (movedReceipt && now[i - 1]?.key === "receipt" && s.key.startsWith("text:") && !s.text.trim()) continue; if (stale.includes(s)) { const line = s.text.trim().split(/\r?\n/)[0]!; if (paired.has(s)) replaced.push(s.label); @@ -278,12 +311,16 @@ export function mergeBodySections(current: string, incoming: string): { body: st const fresh = next.filter(n => !used.has(n)); for (const n of next) if (fresh.includes(n) || (ours.includes(n) && !paired.has(n))) added.push(n.label); const badge = (s: Section) => s.key === "badge:capy-badge"; - const first = fresh.filter(n => !n.key.startsWith("h2:") && !badge(n)); + const first = fresh.filter(n => !n.key.startsWith("h2:") && !badge(n) && !isNotes(n)); if (now.length === 0) return { body: incoming, replaced, kept, added, dropped }; const lead = merged[0]?.key === "text:lead" && merged[0].text.trim() ? 1 : 0; const tail = merged.length && badge(merged.at(-1)!) ? merged.length - 1 : merged.length; const all = [...merged.slice(0, lead), ...first, ...merged.slice(lead, tail), ...fresh.filter(n => n.key.startsWith("h2:")), ...merged.slice(tail), ...fresh.filter(badge)]; for (const t of [...ours].reverse()) all.splice(all.indexOf(next[next.indexOf(t) - 1]!) + 1, 0, t); + const sectionNotes = [...all, ...fresh.filter(isNotes)].filter((s, i, list) => isNotes(s) || (s.key.startsWith("text:") && !s.text.trim() && isNotes(list[i - 1]))); + const rest = all.filter(s => !sectionNotes.includes(s)); + const badges = rest.length && badge(rest.at(-1)!) ? rest.splice(rest.findLastIndex(s => !badge(s)) + 1) : []; + all.splice(0, all.length, ...rest, ...sectionNotes, ...badges); const fromFile = (s: Section) => next.includes(s); const body = all.map((s, i) => { const after = all[i + 1]; @@ -542,6 +579,28 @@ function send(env: PostEnv, kind: HostKind, req: Request, n: string | undefined, return EXIT.posted; } +/** + * The plain-title check (R76, lib/plain-title.ts) against the checkout's `.gstack/plain-titles.json`: true when the + * title passes or the repository has no policy. A finding prints one `PLAIN_TITLE:` line each, in gstack-plain-title's + * format, then `PLAIN_TITLE: refused=`; an invalid policy refuses too, so nothing is posted until it is fixed. + */ +function plainTitle(env: PostEnv, title: string): boolean { + const top = env.run("git", ["rev-parse", "--show-toplevel"]); + const root = top.status === 0 ? top.stdout.trim() : ""; + if (!root) return true; + let findings; + try { findings = checkTitle(title, loadPlainTitlePolicy(root)); } catch (e) { + env.err(`gstack-post: ${e instanceof Error ? e.message : String(e)} (PLAIN_POLICY_INVALID); nothing was posted. Fix the policy (gstack-plain-title validate), then rerun.\n`); + return false; + } + if (!findings.length) return true; + const clean = (s: string) => s.replace(/[\u0000-\u001f\u007f"]/g, " ").slice(0, 80); + for (const f of findings) env.out(`PLAIN_TITLE: ${f.code} ${f.kind} "${clean(f.match)}" — ${f.hint}\n`); + env.out(`PLAIN_TITLE: refused=${findings.length}\n`); + env.err("gstack-post: the title is not in plain words; nothing was posted. Rewrite the title file so its front says what changed for a gstack user (one trailing issue code like (GST-1713) is fine), then rerun.\n"); + return false; +} + export function runPost(argv: string[], env: PostEnv, configBin = "gstack-config"): number { if (argv.length === 0 || ["help", "--help", "-h"].includes(argv[0]!)) { (argv.length === 0 ? env.err : env.out)(USAGE); @@ -553,6 +612,7 @@ export function runPost(argv: string[], env: PostEnv, configBin = "gstack-config if (req.op === "reply" && remote.kind !== "github") throw new UsageError("reply is supported on GitHub only"); const n = req.target === undefined ? undefined : targetNumber(req.target, req.op, remote); const title = req.titleFile ? readText(env, req.titleFile, "title") : undefined; + if (title !== undefined && (req.op === "pr-title" || req.op === "pr-create") && !plainTitle(env, title)) return EXIT.plainTitle; let body = req.bodyFile ? readText(env, req.bodyFile, "body") : undefined; if (req.op === "pr-body" && !req.replace) { const current = currentBody(env, remote.kind, n!); diff --git a/lib/plain-title.ts b/lib/plain-title.ts new file mode 100644 index 000000000..f659d7f16 --- /dev/null +++ b/lib/plain-title.ts @@ -0,0 +1,193 @@ +/** + * Plain-English public record (R76): PR titles, branch names, PR openings, + * changelog entries and commit subjects say in everyday words what changed for + * someone using the project. Each repository opts in with + * `.gstack/plain-titles.json` (docs/ship-policy.md#plain-titles): `codes` are + * tracker-code regexes, `terms` are the project's own coined words, `allow` + * entries (each with a reason) suppress a match they contain, `since` exempts + * PRs created before that date, and `exempt_branch_prefixes` names generated + * branches. Front for humans, end for machines: one trailing group of + * references such as `(GST-1713)` or `(#4122)` is never checked. A repository + * without the file gets no findings. Callers: bin/gstack-plain-title, + * gstack-post and the "Plain title" CI check. + */ +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { splitAgentNotes } from './agent-notes'; +import { maskProse } from './prose-lint'; +import { redactFindingSpans } from './redact-engine'; + +export interface PlainTitlePolicy { codes: string[]; terms: string[]; allow: { text: string; reason: string }[]; since: string; exempt_branch_prefixes: string[] } +export interface PlainFinding { code: 'PLAIN_TITLE_REFUSED' | 'PLAIN_BRANCH_REFUSED' | 'PLAIN_OPENING_REFUSED' | 'PLAIN_CHANGELOG_REFUSED' | 'PLAIN_COMMIT_REFUSED'; kind: 'code' | 'term' | 'scope-prefix' | 'opening-shape'; match: string; hint: string } +export const PLAIN_TITLE_POLICY_PATH = '.gstack/plain-titles.json'; + +const KEYS = ['$schema', 'codes', 'terms', 'allow', 'since', 'exempt_branch_prefixes']; +const HINTS = { + code: 'move the tracker code to one trailing group like (ABC-123)', + PLAIN_BRANCH_REFUSED: 'keep at most one tracker code, at the very end of the branch name', + PLAIN_OPENING_REFUSED: 'move tracker codes to the end of the PR body', + PLAIN_CHANGELOG_REFUSED: 'leave tracker codes out of changelog entries', + term: 'say what it does for someone using the project, in everyday words', + 'scope-prefix': 'drop the type(scope): prefix and start with what changed', + 'opening-shape': 'open with one or two plain sentences: what changed and which goal of the project it serves', +} as const; + +type Code = PlainFinding['code']; +type Hit = { kind: 'code' | 'term'; match: string }; + +const isStringList = (v: unknown): v is string[] => Array.isArray(v) && v.every(s => typeof s === 'string' && s.trim().length > 0); + +/** Reads and validates the policy; null when the repository has no file. Throws an Error naming the bad key. */ +export function loadPlainTitlePolicy(repoRoot: string): PlainTitlePolicy | null { + const file = path.join(repoRoot, PLAIN_TITLE_POLICY_PATH); + if (!fs.existsSync(file)) return null; + let raw: unknown; + try { raw = JSON.parse(fs.readFileSync(file, 'utf8')); } catch (e: any) { throw new Error(`${PLAIN_TITLE_POLICY_PATH} is not valid JSON: ${e.message}`); } + if (!raw || typeof raw !== 'object' || Array.isArray(raw)) throw new Error(`${PLAIN_TITLE_POLICY_PATH} must be a JSON object`); + const obj = raw as Record; + const bad = (key: string, why: string) => new Error(`${PLAIN_TITLE_POLICY_PATH}: ${key} ${why}`); + for (const key of Object.keys(obj)) if (!KEYS.includes(key)) throw bad(key, `is not a known key (valid keys: ${KEYS.slice(1).join(', ')})`); + if (!isStringList(obj.codes)) throw bad('codes', 'must be a list of non-empty regex strings'); + obj.codes.forEach((src, i) => { + let re: RegExp; + try { re = new RegExp(src, 'i'); } catch (e: any) { throw bad(`codes[${i}]`, `is not a valid regex: ${e.message}`); } + if (re.test('')) throw bad(`codes[${i}]`, 'matches empty text'); + }); + if (!isStringList(obj.terms)) throw bad('terms', 'must be a list of non-empty strings'); + const allow = obj.allow ?? []; + if (!Array.isArray(allow)) throw bad('allow', 'must be a list of { text, reason }'); + allow.forEach((a, i) => { + if (!a || typeof a !== 'object' || Array.isArray(a)) throw bad(`allow[${i}]`, 'must be { text, reason }'); + const entry = a as Record; + for (const key of Object.keys(entry)) if (key !== 'text' && key !== 'reason') throw bad(`allow[${i}].${key}`, 'is not a known key (valid keys: text, reason)'); + if (typeof entry.text !== 'string' || !entry.text.trim()) throw bad(`allow[${i}].text`, 'must be a non-empty string'); + if (typeof entry.reason !== 'string' || !entry.reason.trim()) throw bad(`allow[${i}].reason`, 'must say why the entry is allowed'); + }); + if (typeof obj.since !== 'string' || !/^\d{4}-\d{2}-\d{2}/.test(obj.since) || Number.isNaN(Date.parse(obj.since))) throw bad('since', 'must be a date like 2026-11-08'); + const prefixes = obj.exempt_branch_prefixes ?? []; + if (!Array.isArray(prefixes) || !prefixes.every(p => typeof p === 'string' && p.length > 0)) throw bad('exempt_branch_prefixes', 'must be a list of non-empty strings'); + return { codes: obj.codes, terms: obj.terms, allow: allow as PlainTitlePolicy['allow'], since: obj.since, exempt_branch_prefixes: prefixes as string[] }; +} + +/** A whole-word, case-insensitive regex for a term; a space in the term matches any run of spaces. */ +function wordRegExp(term: string): RegExp { + const t = term.trim(); + const escaped = t.replace(/[.*+?^${}()|[\]\\]/g, '\\$&').replace(/\s+/g, '\\s+'); + const edge = (c: string) => (/[A-Za-z0-9_]/.test(c) ? '\\b' : ''); + return new RegExp(`${edge(t[0]!)}${escaped}${edge(t.slice(-1))}`, 'gi'); +} + +/** Every refused code and term in the text, minus matches an allow entry contains; one hit per distinct match. */ +function scan(text: string, policy: PlainTitlePolicy, normalize: (s: string) => string = s => s): Hit[] { + const allowed = policy.allow.flatMap(a => [...text.matchAll(wordRegExp(normalize(a.text)))].map(m => [m.index!, m.index! + m[0].length] as const)); + const hits: Hit[] = []; + const seen = new Set(); + const push = (kind: Hit['kind'], m: RegExpMatchArray) => { + const start = m.index!, end = start + m[0].length; + if (!m[0] || allowed.some(([s, e]) => s <= start && end <= e)) return; + const key = `${kind}:${m[0].toLowerCase()}`; + if (seen.has(key)) return; + seen.add(key); + hits.push({ kind, match: m[0] }); + }; + for (const src of policy.codes) for (const m of text.matchAll(new RegExp(src, 'gi'))) push('code', m); + for (const term of policy.terms) for (const m of text.matchAll(wordRegExp(normalize(term)))) push('term', m); + return hits; +} + +/** A match as it may be printed: a credential the title or branch carries is masked before any line shows it, and a match the redactor cannot mask in place prints as a fixed placeholder. */ +const shown = (match: string): string => redactFindingSpans(match) ?? ''; + +const toFindings = (code: Code, hits: Hit[]): PlainFinding[] => hits.map(h => ({ + code, kind: h.kind, match: shown(h.match), + hint: h.kind === 'code' && (code === 'PLAIN_BRANCH_REFUSED' || code === 'PLAIN_OPENING_REFUSED' || code === 'PLAIN_CHANGELOG_REFUSED') ? HINTS[code] : HINTS[h.kind], +})); + +const REF = /^(?:#\d+|[A-Z][A-Z0-9]{1,9}-\d+|R\d{1,4})$/i; + +/** Splits off one trailing "(...)" group made only of references (#123, ABC-123, R76), comma or space separated. */ +export function splitTitle(title: string): { front: string; refs: string | null } { + const m = /^(.*?)\s*\(([^()]*)\)\s*$/s.exec(title); + const refs = m ? m[2]!.split(/[\s,]+/).filter(Boolean) : []; + if (!m || !refs.length || !refs.every(r => REF.test(r))) return { front: title.trim(), refs: null }; + return { front: m[1]!.trim(), refs: m[2]!.trim() }; +} + +function titleFindings(title: string, policy: PlainTitlePolicy, code: Code): PlainFinding[] { + const front = splitTitle(title).front.replace(/^v\d+(?:\.\d+)+\s*/i, ''); + const scope = /^[A-Za-z][\w.-]*\([^()]{0,60}\)!?:/.exec(front); + return [ + ...(scope ? [{ code, kind: 'scope-prefix' as const, match: shown(scope[0]), hint: HINTS['scope-prefix'] }] : []), + ...toFindings(code, scan(front, policy)), + ]; +} + +/** Refused codes, terms and a leading `word(scope):` prefix in the title's front; the trailing reference group is never checked. */ +export function checkTitle(title: string, policy: PlainTitlePolicy | null): PlainFinding[] { + return policy ? titleFindings(title, policy, 'PLAIN_TITLE_REFUSED') : []; +} + +/** Commit subjects follow the title rule under their own code, because CI only warns on them. */ +export function checkCommitSubject(subject: string, policy: PlainTitlePolicy | null): PlainFinding[] { + return policy ? titleFindings(subject, policy, 'PLAIN_COMMIT_REFUSED') : []; +} + +/** Generated branches (exempt prefixes) pass; one trailing code segment (`-gst-1713`) is allowed; `-` and `_` separate words. */ +export function checkBranch(branch: string, policy: PlainTitlePolicy | null): PlainFinding[] { + if (!policy || policy.exempt_branch_prefixes.some(p => branch.startsWith(p))) return []; + const segments = branch.replace(/_/g, '-').split('/').filter(Boolean); + let last = segments.pop() ?? ''; + for (const src of policy.codes) { + const m = new RegExp(`(?:^|-)(?:${src})$`, 'i').exec(last); + if (m) { last = last.slice(0, m.index); break; } + } + const rest = [...segments, last].join('/'); + const words = (s: string) => s.replace(/[-_/]+/g, ' '); + return toFindings('PLAIN_BRANCH_REFUSED', [ + ...scan(rest, { ...policy, terms: [] }), + ...scan(words(rest), { ...policy, codes: [] }, words), + ]); +} + +function openingShape(line: string): string | null { + if (/^\s{0,3}#{1,6}(\s|$)/.test(line)) return 'heading'; + if (/^\s{0,3}(`{3,}|~{3,})/.test(line)) return 'fenced block'; + if (/^\s{0,3}([-*+]|\d+[.)])\s/.test(line)) return 'list'; + if (/^\s*\|/.test(line)) return 'table'; + if (/^\s*!\[/.test(line)) return 'image'; + if (/^\s* ({ code, kind: 'opening-shape', match, hint: HINTS['opening-shape'] }); + const lines = splitAgentNotes(body).readable.replace(/\r\n?/g, '\n').split('\n'); + const start = lines.findIndex(l => l.trim() !== ''); + if (start < 0) return [shapeFinding('no opening paragraph')]; + const shape = openingShape(lines[start]!); + if (shape) return [shapeFinding(`${shape} first`)]; + const end = lines.findIndex((l, i) => i > start && l.trim() === ''); + const paragraph = lines.slice(start, end < 0 ? undefined : end).join(' ') + .replace(/\]\([^)\s]*\)/g, m => ']' + ' '.repeat(m.length - 1)) + .replace(/https?:\/\/\S+/g, 'URL'); + const sentences = paragraph.replace(/`[^`]*`/g, 'x').replace(/\b(?:e\.g|i\.e|etc|vs)\./gi, 'x') + .split(/[.!?]+["')\]]*(?:\s+|$)/).filter(s => s.trim()).length; + return [ + ...(sentences > 2 ? [shapeFinding(`${sentences} sentences`)] : []), + ...toFindings(code, scan(paragraph.replace(/`/g, ' '), policy)), + ]; +} + +/** No tracker code anywhere in the entry; refused terms only in its bold lead. Fenced and inline code, URLs and quoted words are skipped. */ +export function checkChangelogEntry(text: string, policy: PlainTitlePolicy | null): PlainFinding[] { + if (!policy) return []; + const masked = maskProse(text).masked; + const lead = /\*\*([^*]+)\*\*/.exec(masked); + return toFindings('PLAIN_CHANGELOG_REFUSED', [ + ...scan(masked, { ...policy, terms: [] }), + ...(lead ? scan(lead[1]!, { ...policy, codes: [] }) : []), + ]); +} diff --git a/lib/plan-record.ts b/lib/plan-record.ts index 169c593c6..3932dff44 100644 --- a/lib/plan-record.ts +++ b/lib/plan-record.ts @@ -23,6 +23,7 @@ import { spawnSync } from 'node:child_process'; import { createHash } from 'node:crypto'; import * as fs from 'node:fs'; import * as path from 'node:path'; +import { agentNotesOrBody } from './agent-notes'; import { locate, type ApprovalRow, type Env } from './approval-record'; import type { ResultCodeName } from './result-codes'; import { isUnavailable, loadPolicyFromBase, type ShipPolicy } from './ship-policy'; @@ -77,10 +78,19 @@ export function planMode(policyValue: PlanMode | undefined, env: Env): { mode: P /** Under off, the one PLAN: line ready, land and finish still print when the machine's key decided the mode or was ignored. */ const offNote = (mode: { mode: PlanMode; source: string }) => (mode.source.includes(LOCAL_SOURCE) ? `PLAN: off [mode off from ${mode.source}]` : undefined); -/** The first `Plan:` line at the start of a line, outside fenced blocks; null when the body has none. */ +/** + * The first `Plan:` line at the start of a line, outside fenced blocks; null when the body has none. A body with an + * Agent notes section is read there only (lib/agent-notes.ts); a body without one is read whole. + */ export function parsePlanLine(body: string): PlanDecl | null { + const notes = agentNotesOrBody(body); + return planLineIn(notes) ?? (notes === body ? null : planLineIn(body)); +} + +/** The first Plan line in the text, outside code fences. */ +function planLineIn(text: string): PlanDecl | null { let fenced = false; - for (const raw of body.split(/\r?\n/)) { + for (const raw of text.split(/\r?\n/)) { if (/^\s*(```|~~~)/.test(raw)) { fenced = !fenced; continue; } if (fenced) continue; const m = /^(?:\*\*)?Plan:(?:\*\*)?\s*(.*)$/.exec(raw.trim()); diff --git a/lib/release-stamp.ts b/lib/release-stamp.ts index 4e23ec32c..6e7b71cc3 100644 --- a/lib/release-stamp.ts +++ b/lib/release-stamp.ts @@ -127,7 +127,7 @@ export function openRelease(o: OpenOptions): OpenResult { lines.push(`RELEASE: ${stamp.oldVersion} -> ${version} at origin/${o.base} ${baseSha.slice(0, 12)}`); if (o.dryRun) return { ok: true, version, branch, lines: [...lines, stamp.diff ?? ''] }; const folded = /folded (.+?)(;|$)/.exec(stamp.stages.find(s => s.name === 'changelog')?.detail ?? '')?.[1] ?? 'see the CHANGELOG diff'; - for (const step of [['add', '-A', '--', ...(stamp.files ?? [])], ['commit', '-q', '-m', `v${version} release: fold the changelog fragments`]]) { + for (const step of [['add', '-A', '--', ...(stamp.files ?? [])], ['commit', '-q', '-m', `Release v${version}`]]) { const r = git(dir, step); if (!r.ok) return fail('RELEASE_STAMP_INVALID', `git ${step[0]} failed in the release worktree: ${r.err.split('\n')[0]}`); } diff --git a/lib/result-codes-plain.ts b/lib/result-codes-plain.ts new file mode 100644 index 000000000..e2c4497c2 --- /dev/null +++ b/lib/result-codes-plain.ts @@ -0,0 +1,41 @@ +/** + * Result codes for the plain-English title check (lib/plain-title.ts, + * bin/gstack-plain-title, the "Plain title" CI check; R76). Kept beside + * lib/result-codes.ts (which spreads this table into RESULT_CODES) so the owner + * module stays within the module-size ratchet; the same rules apply: one stable + * docs/troubleshooting.md anchor, a summary and a fix clause each. + */ +import type { ResultCode } from './result-codes'; + +export const PLAIN_RESULT_CODES = { + PLAIN_TITLE_REFUSED: { + anchor: 'plain-title-refused', + summary: 'the PR title has a tracker code, a project-only word or a type(scope): prefix before its trailing reference group', + fix: 'rewrite the title as a plain sentence about what changed for someone using the project; keep at most one trailing group like (ABC-123)', + }, + PLAIN_BRANCH_REFUSED: { + anchor: 'plain-branch-refused', + summary: 'the branch name has a tracker code or a project-only word before its last segment', + fix: 'name the branch in plain words, with at most one tracker code at the very end (owner/what-changed-abc-123)', + }, + PLAIN_OPENING_REFUSED: { + anchor: 'plain-opening-refused', + summary: 'the PR body does not open with one or two plain sentences', + fix: 'start the body with one or two sentences saying what changed and which goal of the project it serves; move codes and records to the Agent notes section at the end', + }, + PLAIN_CHANGELOG_REFUSED: { + anchor: 'plain-changelog-refused', + summary: 'the changelog entry has a tracker code, or a project-only word in its bold lead', + fix: 'drop tracker codes from the entry and rewrite the bold lead in everyday words', + }, + PLAIN_COMMIT_REFUSED: { + anchor: 'plain-commit-refused', + summary: 'a commit subject has a tracker code, a project-only word or a type(scope): prefix before its trailing reference group', + fix: 'write later commit subjects as plain sentences; the squash commit takes the PR title, so this is a warning only', + }, + PLAIN_POLICY_INVALID: { + anchor: 'plain-policy-invalid', + summary: '.gstack/plain-titles.json is not valid JSON or breaks the schema; the line names the key', + fix: 'fix the named key (codes, terms, allow, since, exempt_branch_prefixes), then run gstack-plain-title validate', + }, +} as const satisfies Record; diff --git a/lib/result-codes.ts b/lib/result-codes.ts index f7e0e701b..6046a8239 100644 --- a/lib/result-codes.ts +++ b/lib/result-codes.ts @@ -18,6 +18,7 @@ import { PLAN_RESULT_CODES } from './result-codes-plan'; import { AUTOPLAN_RESULT_CODES } from './result-codes-autoplan'; import { BROWSE_RESULT_CODES } from './result-codes-browse'; import { CLEAN_RESULT_CODES } from './result-codes-clean'; +import { PLAIN_RESULT_CODES } from './result-codes-plain'; export interface ResultCode { /** Stable `` in docs/troubleshooting.md. */ @@ -770,6 +771,7 @@ export const RESULT_CODES = { ...AUTOPLAN_RESULT_CODES, ...BROWSE_RESULT_CODES, ...CLEAN_RESULT_CODES, + ...PLAIN_RESULT_CODES, } as const satisfies Record; export type ResultCodeName = keyof typeof RESULT_CODES; diff --git a/lib/ship-queue-land-direct.ts b/lib/ship-queue-land-direct.ts index b6a614b48..09013ad73 100644 --- a/lib/ship-queue-land-direct.ts +++ b/lib/ship-queue-land-direct.ts @@ -8,7 +8,8 @@ * moved past the pin's merge base, the checks at the pin never saw the tree that would land (GST-1316), so land builds * the pin merged onto the base tip and runs the base policy's land.merged_checks there (lib/land-merged-checks.ts; * repo commands, so only under --allow-repo-commands and never under --dry-run), refusing on a conflict or a failure; - * with no list it prints a STALE_BASE: warn line and lands. Then gh pr merge --squash --match-head-commit , a check + * with no list it prints a STALE_BASE: warn line and lands. Then gh pr merge --squash --match-head-commit with the + * PR title as the commit subject (`--subject " (#<n>)"`, so the checked title is main's history title), a check * that the merge commit's tree is the pin merged onto that commit's parent (its merged checks rerun when that parent is * neither the base land checked nor in the pin), a land note on refs/notes/gstack-trains, and the lock's release. A * rerun after GitHub merged goes straight to the tree check. @@ -26,13 +27,13 @@ import * as os from 'node:os'; import { activeFreeze, loadReady, persist, rebuildFromNotes, repoIdentity, type FreezeRecord } from './ship-queue-train-record'; import { bindShippedReceipt } from './ship-receipt-depth'; -interface DirectPr { state: string; baseRefName?: string; headRefName: string; headRefOid: string; isCrossRepository: boolean; body: string; mergeable?: string; mergeStateStatus?: string; mergeCommit?: { oid: string } | null } +interface DirectPr { state: string; baseRefName?: string; headRefName: string; headRefOid: string; isCrossRepository: boolean; body: string; title?: string; mergeable?: string; mergeStateStatus?: string; mergeCommit?: { oid: string } | null } export interface DirectOptions { pr: string; pin: string; dryRun?: boolean; allowRepoCommands?: boolean; breakLock?: boolean; repair?: boolean; now?: () => Date } const isSha = (s: string | undefined) => /^[0-9a-f]{40,64}$/.test(s ?? ''); function view(ctx: QueueContext, pr: string): DirectPr | QueueFailure { - const r = ctx.gh(['pr', 'view', pr, '--json', 'state,baseRefName,headRefName,headRefOid,isCrossRepository,body,mergeable,mergeStateStatus,mergeCommit']); + const r = ctx.gh(['pr', 'view', pr, '--json', 'state,baseRefName,headRefName,headRefOid,isCrossRepository,body,title,mergeable,mergeStateStatus,mergeCommit']); try { return JSON.parse(r.out) as DirectPr; } catch { return { code: 'QUEUE_NOT_READY', message: `gh pr view ${pr} failed: ${(r.err ?? r.out).trim().slice(0, 160)}` }; } } @@ -191,7 +192,7 @@ export function landDirect(ctx: QueueContext, o: DirectOptions): StepOutput { if (!ci.green) return fail(lines, { code: 'QUEUE_LAND_CHECKS', message: `checks are ${ci.line}`, fix: `wait for pending checks (gstack-pr-wait ${o.pr} --until green), or fix the red one at a new head; then rerun land` }); const passed = new Set([...ci.checks.filter(c => c.conclusion === 'success').map(c => c.name), ...ci.contexts.filter(c => c.state === 'success').map(c => c.name)]); const missing = (policy.changelog_fragments?.required_checks ?? []).filter(n => !passed.has(n)); - if (missing.length) return fail(lines, { code: 'QUEUE_LAND_CHECKS', message: `required check(s) ${missing.join(', ')} did not pass at the pin ${o.pin.slice(0, 12)}`, fix: `dispatch them on the branch (gh workflow run <workflow> --ref ${info.headRefName}) and rerun land once they pass` }); + if (missing.length) return fail(lines, { code: 'QUEUE_LAND_CHECKS', message: `required check(s) ${missing.join(', ')} did not pass at the pin ${o.pin.slice(0, 12)}`, fix: `dispatch them on the branch (gh workflow run <workflow> --ref ${info.headRefName}) and rerun land once they pass; a check that runs on pull_request_target (such as Plain title) cannot be dispatched, so edit the PR title or body to rerun it` }); if (info.mergeable !== 'MERGEABLE' || info.mergeStateStatus !== 'CLEAN') return fail(lines, { code: 'QUEUE_LAND_NOT_CLEAN', message: `GitHub reports #${o.pr} mergeable=${info.mergeable ?? 'unknown'} mergeStateStatus=${info.mergeStateStatus ?? 'unknown'}`, fix: info.mergeable === 'CONFLICTING' ? `the lane merges origin/${ctx.base} into the branch, resolves, runs /ship and ready at the new head` : 'UNKNOWN means GitHub is still computing: rerun land in a minute; BLOCKED or BEHIND names the branch rule to meet first' }); let held: HeldLandLock | undefined; if (!o.dryRun) { @@ -200,6 +201,7 @@ export function landDirect(ctx: QueueContext, o: DirectOptions): StepOutput { if ('failure' in got) return fail(lines, got.failure); held = got.held; } + const subject = info.title?.trim() ? ['--subject', `${info.title.trim()} (#${o.pr})`] : []; const locked = (): StepOutput => { if (held && !git(root, ['fetch', '-q', 'origin', ctx.base]).ok) return fail(lines, { code: 'QUEUE_NOT_READY', message: `cannot re-fetch origin/${ctx.base} under the land lock`, fix: 'fix the fetch, then rerun land' }); const tip = git(root, ['rev-parse', `origin/${ctx.base}`]).out; @@ -221,7 +223,7 @@ export function landDirect(ctx: QueueContext, o: DirectOptions): StepOutput { lines.push(`MERGED_CHECKS: pass tree=${mc.tree} base=${tip} pin=${o.pin.slice(0, 12)} checks: ${checks.join('; ')}`); } } - if (o.dryRun) return { ok: true, run, lines: [...lines, `DRY-RUN: nothing merged (would run gh pr merge ${o.pr} --squash --match-head-commit ${o.pin})`] }; + if (o.dryRun) return { ok: true, run, lines: [...lines, `DRY-RUN: nothing merged (would run gh pr merge ${o.pr} ${policy.history === 'merge-only' ? '--merge' : '--squash'} --match-head-commit ${o.pin}${subject.length ? ` --subject ${JSON.stringify(subject[1])}` : ''})`] }; if (o.repair) { if (!rebuildFromNotes(root, remote).fetched) return fail(lines, { code: 'QUEUE_NOT_READY', message: `cannot fetch refs/notes/gstack-trains under the land lock, so the freeze's repair slot cannot be read; nothing merged`, fix: 'fix the fetch, then rerun land' }); const frozen = activeFreeze(keys); @@ -239,7 +241,7 @@ export function landDirect(ctx: QueueContext, o: DirectOptions): StepOutput { const method = policy.history === 'merge-only' ? '--merge' : '--squash'; const lost = held!.set('merging'); if (lost) return fail(lines, lost); - const m = ctx.gh(['pr', 'merge', o.pr, method, '--match-head-commit', o.pin]); + const m = ctx.gh(['pr', 'merge', o.pr, method, '--match-head-commit', o.pin, ...subject]); let merge: string | undefined; for (let i = 0; i < (m.ok ? 3 : 1) && !isSha(merge); i++) { if (i) Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, pollDelayMs(MIN_POLL_MS)); diff --git a/lib/ship-queue-land.ts b/lib/ship-queue-land.ts index 5b5c312ee..b596327e9 100644 --- a/lib/ship-queue-land.ts +++ b/lib/ship-queue-land.ts @@ -303,7 +303,7 @@ function repair(ctx: QueueContext, policy: ShipPolicy, j: LandJournal | null, fi pushed = remote[0]!; lines.push(`branch: ${branch} already on origin at ${pushed.slice(0, 12)} (an earlier --repair pushed it); opening or finding its PR`); } - const pr = ctx.gh(['pr', 'create', '--base', ctx.base, '--head', branch, '--title', `Release entry for the merge outside land beside #${j.pr}`, '--body', `QUEUE_LAND_DRIFT repair (docs/troubleshooting.md#queue-land-drift). Outside commits: ${outside.join(', ') || 'unknown'}.`]); + const pr = ctx.gh(['pr', 'create', '--base', ctx.base, '--head', branch, '--title', `Add a changelog entry for changes merged by hand next to #${j.pr}`, '--body', `QUEUE_LAND_DRIFT repair (docs/troubleshooting.md#queue-land-drift). Outside commits: ${outside.join(', ') || 'unknown'}.`]); const n = /\/pull\/(\d+)/.exec(`${pr.out}\n${pr.err ?? ''}`)?.[1]; if (!n) return fail(lines, { code: 'QUEUE_NOT_READY', message: `gh pr create failed: ${(pr.err ?? pr.out).trim().slice(0, 160)}` }, run); j.repair_pr = n; writeJsonAtomic(file, j); diff --git a/lib/ship-queue-train-gate.ts b/lib/ship-queue-train-gate.ts index a7867cb1d..9be43dac1 100644 --- a/lib/ship-queue-train-gate.ts +++ b/lib/ship-queue-train-gate.ts @@ -26,7 +26,7 @@ import { fileURLToPath } from 'node:url'; import { awaitNonceRun, listDispatchRuns, makeNonce, runForNonce } from '../scripts/eval-bg'; import { scriptInvocation } from './script-invocation'; import type { ShipReceipt } from './headless-artifacts'; -import { extractReceipt, placeReceiptFirst } from './ship-receipt'; +import { extractReceipt, placeReceipt } from './ship-receipt'; import { bindShippedReceipt } from './ship-receipt-depth'; import type { CommandGate, TrainGate, WorkflowsGate } from './ship-policy'; import { git, type QueueFailure } from './ship-queue-gate'; @@ -66,7 +66,7 @@ export function bindTrainReceipt(root: string, baseRef: string, body: string, pr const b = bindShippedReceipt(root, baseRef, body, pr, accepted); if (b.ok || b.code !== 'QUEUE_RECEIPT_CONDITIONAL' || !conditional) return b; const { base_red: _, ...rest } = extractReceipt(body).receipt!; - return bindShippedReceipt(root, baseRef, placeReceiptFirst(body, rest as ShipReceipt), pr, accepted); + return bindShippedReceipt(root, baseRef, placeReceipt(body, rest as ShipReceipt), pr, accepted); } /** Per-run price reservation of a command gate: its timeout plus overhead, at the list price. */ diff --git a/lib/ship-queue-train-land.ts b/lib/ship-queue-train-land.ts index 8cc2bf516..2bd7d0ab8 100644 --- a/lib/ship-queue-train-land.ts +++ b/lib/ship-queue-train-land.ts @@ -133,7 +133,7 @@ function chooseMode(ctx: QueueContext, s: Slot, mainSha: string, conditional: bo return { mode: 'update', why: `the pin's own checks are not green (${ci.line}${missing.length ? `; missing ${missing.join(', ')}` : ''}); they ran against an older base` }; } -/** Merge with the policy's method at an exact head, then confirm main's tree is the slot's prefix. */ +/** Merge with the policy's method at an exact head, the PR title as the commit subject (a rebase has none), then confirm main's tree is the slot's prefix. */ async function mergeAt(ctx: QueueContext, s: Slot, head: string, base: string): Promise<TrainOut> { const pr = s.t.slots[s.i]!.pr; const method = s.env.train.land?.merge_method ?? 'squash'; @@ -146,7 +146,10 @@ async function mergeAt(ctx: QueueContext, s: Slot, head: string, base: string): const moved = lockPhase(s, 'merging'); if (moved) return outFailed(moved, s.lines, s.rerun, s.run); s.lock.effect = true; - const m = ctx.gh(['pr', 'merge', pr, `--${method}`, '--match-head-commit', head]); + const viewed = method === 'rebase' ? null : ctx.gh(['pr', 'view', pr, '--json', 'title']); + let title = ''; + try { title = viewed?.ok ? String(JSON.parse(viewed.out).title ?? '').trim() : ''; } catch {} + const m = ctx.gh(['pr', 'merge', pr, `--${method}`, '--match-head-commit', head, ...(title ? ['--subject', `${title} (#${pr})`] : [])]); if (!m.ok) return outFailed({ code: 'QUEUE_NOT_READY', message: `gh pr merge refused: ${(m.err ?? m.out).trim().slice(0, 200)}` }, s.lines, s.rerun, s.run); main = mainNow(ctx, s.env); if ('error' in main) return outPending({ code: 'QUEUE_PROOF_MISSING', message: `merged; ${main.error}` }, s.lines, s.rerun, s.run); diff --git a/lib/ship-queue-wave.ts b/lib/ship-queue-wave.ts index 7e0f0767e..c3fbc6136 100644 --- a/lib/ship-queue-wave.ts +++ b/lib/ship-queue-wave.ts @@ -430,7 +430,7 @@ function publish(env: TrainEnv, w: WaveRecord, lines: string[], dryRun: boolean) fs.writeFileSync(body, [`## Wave ${w.nonce}`, '', `One slot and one version for ${w.constituents.length} PRs in area \`${w.area}\`, each merged unchanged with a merge commit:`, '', ...w.constituents.map(c => `- #${c.pr} at ${c.head}`), '', 'Each constituent is closed with a link to this PR after it lands (`gstack-ship-queue wave settle`).', ''].join('\n')); return outOk([...lines, `PUSHED: ${w.branch} at ${w.head} (${saved.detail})`, `FROZEN_NOTICE: ${notices.join(' ')}`, `PR_BODY: ${body}`], - `open the wave PR (gh pr create --base ${env.ctx.base} --head ${w.branch} --title "wave(${w.area}): ${w.constituents.map(c => '#' + c.pr).join(' ')}" --body-file ${body}, or the host's PR tool), run /ship on it, then ${Q} ready --pr <wave pr> --screen|--no-screen`, wavePath(w.nonce)); + `open the wave PR (gh pr create --base ${env.ctx.base} --head ${w.branch} --title "Merge ${w.constituents.length} related changes together (${w.constituents.map(c => '#' + c.pr).join(', ')})" --body-file ${body}, or the host's PR tool), run /ship on it, then ${Q} ready --pr <wave pr> --screen|--no-screen`, wavePath(w.nonce)); } function continueWave(env: TrainEnv, w: WaveRecord, dryRun: boolean): TrainOut { diff --git a/lib/ship-receipt.ts b/lib/ship-receipt.ts index 0bfb37440..aae90c13e 100644 --- a/lib/ship-receipt.ts +++ b/lib/ship-receipt.ts @@ -1,13 +1,14 @@ /** * ship-receipt — the handoff record, identifiers first (plan C5). Renders the * ```gstack-ship-receipt``` block from the `ship-receipt` schema in - * lib/headless-artifacts.ts, puts it first in a PR body so truncation loses - * prose rather than identifiers, reads it back from a PR through `gh`, and + * lib/headless-artifacts.ts, puts it in the PR body's Agent notes section (lib/agent-notes.ts) so + * the body opens with plain sentences, reads it back from a PR through `gh`, and * computes the P7 adoption census over merged PRs (deduplicated by run and * thread id against an owner-maintained thread cohort). */ import { spawnSync } from 'node:child_process'; import { ARTIFACT_SCHEMAS, checkSchema, type ShipReceipt, type ValidationError } from './headless-artifacts'; +import { agentNotesOrBody, splitAgentNotes, withAgentNotes } from './agent-notes'; import { approvalSummary } from './approval-citations'; import { FRAGMENT_README } from './changelog-fragments'; import { renderIssueLines } from './issue-links'; @@ -45,9 +46,13 @@ export function renderReceiptBlock(receipt: ShipReceipt): string { const BLOCK_RE = /```gstack-ship-receipt[ \t]*\r?\n([\s\S]*?)\r?\n```/; -/** The first receipt block in a PR body, parsed; `found: false` when absent. */ +/** + * The receipt block a PR body carries, parsed; `found: false` when absent. A body with an Agent notes section is read + * there only, so a receipt fence quoted in the readable part never binds; a body without one (posted before the + * section existed) gives its first block anywhere. + */ export function extractReceipt(body: string): { found: boolean; receipt?: ShipReceipt; raw?: string; errors: ValidationError[] } { - const m = BLOCK_RE.exec(body); + const m = BLOCK_RE.exec(agentNotesOrBody(body)); if (!m) return { found: false, errors: [] }; try { const parsed = JSON.parse(m[1]!); @@ -105,7 +110,7 @@ export const RECEIPT_RUNS_KEPT = 30; const RUNS_RE = /```gstack-receipt-runs[ \t]*\r?\n([\s\S]*?)\r?\n```[ \t]*(?:\r?\n|$)/; export type ReceiptRun = Pick<ShipReceipt, 'head' | 'version' | 'gstack_version' | 'run' | 'review_loop' | 'review_loop_exception'> & { depth?: string; carried_from?: string }; export function receiptRuns(body: string): ReceiptRun[] { - const m = RUNS_RE.exec(body); + const m = RUNS_RE.exec(agentNotesOrBody(body)); return m ? m[1]!.split('\n').flatMap(l => { try { const o = JSON.parse(l); return o && typeof o.head === 'string' ? [o as ReceiptRun] : []; } catch { return []; } }) : []; } export function receiptRun(r: ShipReceipt): ReceiptRun { @@ -113,15 +118,31 @@ export function receiptRun(r: ShipReceipt): ReceiptRun { return JSON.parse(JSON.stringify({ head, version, gstack_version, run, review_loop, review_loop_exception, depth: r.review_depth?.depth, carried_from: r.carry?.from })); } -/** The block becomes the first section of the body: an existing block is replaced in place at the top, prose follows; a replaced receipt joins the runs block. */ -export function placeReceiptFirst(body: string, receipt: ShipReceipt): string { - const prior = extractReceipt(body).receipt; +/** Every receipt and runs block with the blank lines after it, wherever it sits. */ +const STRIP_RE = /```gstack-(?:ship-receipt|receipt-runs)[ \t]*\r?\n[\s\S]*?\r?\n```[ \t]*(?:\r?\n|$)(?:[ \t]*\r?\n)*/g; + +/** + * The receipt and its runs block go at the end of the body's Agent notes section, which is created when the body has + * none; the section's other content stays above them. Every receipt and runs block elsewhere in the body (a quoted + * example, a legacy block) is removed, so one receipt remains. A replaced receipt joins the runs block. + * A legacy body (a receipt and no section, as /ship posted before the section existed) keeps its layout: the block is + * replaced in place at the top, because its Plan and issue lines sit in the readable part and a new section holding + * only the receipt would hide them from the readers that prefer the section (a carry on an open PR, GST-1825). + */ +export function placeReceipt(body: string, receipt: ShipReceipt): string { + const ex = extractReceipt(body); + const prior = ex.receipt; const runs = receiptRuns(body); if (prior && typeof prior.head === 'string' && JSON.stringify(prior) !== JSON.stringify(receipt)) runs.push(receiptRun(prior)); const kept = runs.slice(-RECEIPT_RUNS_KEPT); const block = renderReceiptBlock(receipt) + (kept.length ? '\n```' + RUNS_FENCE + '\n' + kept.map(r => JSON.stringify(r)).join('\n') + '\n```\n' : ''); - const stripped = body.replace(BLOCK_RE, '').replace(RUNS_RE, '').replace(/^\s+/, ''); - return block + (stripped ? '\n' + stripped : ''); + if (ex.found && splitAgentNotes(body).notes === null) { + const rest = body.replace(BLOCK_RE, '').replace(RUNS_RE, '').replace(/^\s+/, ''); + return block + (rest ? '\n' + rest : ''); + } + const stripped = body.replace(STRIP_RE, ''); + const notes = (splitAgentNotes(stripped).notes ?? '').trim(); + return withAgentNotes(stripped.replace(/^\s+/, ''), (notes ? notes + '\n\n' : '') + block); } /** The receipt's `issues` object from a gstack-issue-links --out or --json file (its other keys are preview-only). */ diff --git a/lib/ship-release.ts b/lib/ship-release.ts index b022007e4..cb0c982ee 100644 --- a/lib/ship-release.ts +++ b/lib/ship-release.ts @@ -33,6 +33,7 @@ import { MIN_POLL_MS } from './gh-poll'; import { lanesOnlyHeadPolicy } from './pregate/head-policy'; import { finishPlan } from './plan-record'; import { PREPUSH_CHECK_IDS } from './pregate/index'; +import { checkBranch, loadPlainTitlePolicy, type PlainFinding } from './plain-title'; import { buildRunCheck } from './pregate/run-check'; import { bin, carryLane, carryNoneLines, evidenceExempt, firstLine, flagReader, fragmentPath, gh, git, hostedOrigin, laneLine, lanes, mappedLanes, recordCiTimeout, releasePaths, Report, runLanes, sh, shipPolicy, shipRunMarker, short, stampedFiles } from './ship-bin-common'; @@ -737,6 +738,10 @@ export function push(argv: readonly string[], cwd: string): Report { const remote = git(cwd, ['ls-remote', '--heads', 'origin', `refs/heads/${branch}`], 120_000); if (remote.status !== 0) return r.stop(`cannot verify the remote branch (${firstLine(remote.stderr)}): restore access before pushing`); if (remote.stdout.split(/\s/)[0] === local) return r.add(`PUSH: ALREADY_PUSHED ${local.slice(0, 12)}`).next('Step 18'); + let plain: PlainFinding[] = []; + try { if (!remote.stdout.trim()) plain = checkBranch(branch, loadPlainTitlePolicy(git(cwd, ['rev-parse', '--show-toplevel']).stdout.trim())); } + catch (e: any) { return r.stop(`.gstack/plain-titles.json is invalid (${e.message}): fix it, then rerun push`); } + if (plain.length) return r.add(`BRANCH: refused ${plain.map(f => `"${f.match}"`).join(', ')}`).stop(`the branch name ${branch} is not plain words (docs/ship-policy.md#plain-titles): rename it with git branch -m <plain words>[-<issue code>], then rerun push --branch <new name>`); const p = git(cwd, ['push', '-u', 'origin', branch], 600_000); if (p.status !== 0) { const nff = /non-fast-forward|fetch first|rejected/.test(p.stderr); diff --git a/scripts/harness-pins/scripts/test-pr-profile.ts.json b/scripts/harness-pins/scripts/test-pr-profile.ts.json index 682302f06..93f120ba5 100644 --- a/scripts/harness-pins/scripts/test-pr-profile.ts.json +++ b/scripts/harness-pins/scripts/test-pr-profile.ts.json @@ -1 +1 @@ -{"blob":"507e219536d4cbd4e28b0d76a5f9ee471e0a2176","since":6,"nonBehavioral":"GST-1717 scoped fix: a changed prompt file counts as deleted only when it is absent from disk, so an untracked prompt on disk still needs full validation (stricter selection); no case's prompt, runner or grading changes, so no verdict can change"} +{"blob":"36c45c32b6fed41e80255187d1e07e52fd7c32a4","since":6,"nonBehavioral":"test-pr-profile lists the plain-title workflow and gstack's own plain-title policy as files no PR-gate case reads; no case's verdict can change"} diff --git a/scripts/pr-dependencies.ts b/scripts/pr-dependencies.ts index d498f1c40..4959fe0a2 100644 --- a/scripts/pr-dependencies.ts +++ b/scripts/pr-dependencies.ts @@ -89,7 +89,7 @@ export const SELECTION_DATA_MODULES: readonly string[] = [ // The build entry list: every compiled binary, which skills reach through their `--outfile` path instead. 'scripts/build.sh', // The reason-code table: its `fix:` prose names the command a person reruns, not a file the module consumes. - 'lib/result-codes.ts', 'lib/result-codes-release.ts', 'lib/result-codes-capy.ts', 'lib/result-codes-budget.ts', 'lib/result-codes-eval.ts', 'lib/result-codes-plan.ts', 'lib/result-codes-autoplan.ts', 'lib/result-codes-browse.ts', 'lib/result-codes-clean.ts', + 'lib/result-codes.ts', 'lib/result-codes-release.ts', 'lib/result-codes-capy.ts', 'lib/result-codes-budget.ts', 'lib/result-codes-eval.ts', 'lib/result-codes-plan.ts', 'lib/result-codes-autoplan.ts', 'lib/result-codes-browse.ts', 'lib/result-codes-clean.ts', 'lib/result-codes-plain.ts', // The exercised-path map: observed commands per case, read only by exercisedConsumers() (shadow data; a change selects no paid case). 'test/helpers/exercised-commands.json', ]; diff --git a/scripts/resolvers/utility.ts b/scripts/resolvers/utility.ts index 2942bd451..96035aba5 100644 --- a/scripts/resolvers/utility.ts +++ b/scripts/resolvers/utility.ts @@ -391,7 +391,7 @@ export function generateChangelogWorkflow(ctx: TemplateContext): string { - Write concise, descriptive bullet points - ${ctx.skillName === 'ship' ? 'Insert after the observed file header, before the first release entry, dated today' : 'Insert after the file header (line 5), dated today'} - Format: \`## [X.Y.Z.W] - YYYY-MM-DD\` - - **Voice:** Lead with what the user can now **do** that they couldn't before. Use plain language, not implementation details. Never mention TODOS.md, internal tracking, or contributor-facing details. + - **Voice:** Lead with what the user can now **do** that they couldn't before. Use plain language, not implementation details. Never mention TODOS.md, internal tracking, or contributor-facing details: no tracker codes (GST-123, R-numbers) anywhere in the entry, and a bold lead a user of the project understands without the repository's coined terms (docs/ship-policy.md#plain-titles). 6. **Cross-check:** Compare your CHANGELOG entry against the commit list from step 2. Every user-facing change in that list must be represented; add any that is missing. diff --git a/scripts/test-pr-profile.ts b/scripts/test-pr-profile.ts index 507e21953..36c45c32b 100644 --- a/scripts/test-pr-profile.ts +++ b/scripts/test-pr-profile.ts @@ -252,6 +252,7 @@ export const FREE_ONLY_PR_FILES = [ '.gstack/pregate.json', // gstack's own pre-gate policy: same trust boundary, same reason. '.gstack/ship-policy.json', // gstack's own ship policy: gstack-ship-policy/gstack-restamp read it from origin/<base> of the repo they run in; same reason. '.gstack/ship-policy.example.json', // The example beside it: only .gitignore and docs/ship-policy.md name it. + '.gstack/plain-titles.json', // gstack's own plain-title policy: gstack-plain-title reads it from the repo it runs in and the Plain title check from the base branch; paid cases run in fixture repos. '.github/dependabot.yml', // Dependabot's own config: read by GitHub, and by free test/compare-pr-version.test.ts; dependabot runs skip the paid lane. '.osv-scanner.toml', // Read by .github/workflows/osv-scanner.yml and the free OSV tests (test/osv-*.test.ts). 'setup.ps1', // The Windows installer: run only by .github/workflows/windows-setup-e2e.yml; the paid lane is Linux. @@ -280,6 +281,7 @@ export const FREE_ONLY_PR_FILES = [ '.github/workflows/native-qualification.yml', '.github/workflows/osv-scanner.yml', '.github/workflows/platform-qualification.yml', + '.github/workflows/plain-title.yml', '.github/workflows/pr-title-sync.yml', '.github/workflows/quality-gate.yml', '.github/workflows/scorecard.yml', diff --git a/ship/DRIVER.md b/ship/DRIVER.md index ffecddc48..effbebd92 100644 --- a/ship/DRIVER.md +++ b/ship/DRIVER.md @@ -115,8 +115,8 @@ repo gates run beside CI. Unattended, push defers the pre-push offer; else its S ## 8. Publish: Steps 18, 19, 19-C, 20, 21, 22 -- Step 18: the open-PR lookup; a `v<NEW_VERSION> ` title file. `UNATTENDED_SHIP:` with no open PR at HEAD takes Step 18-H. -- Step 19 body: the receipt block, then sections `sections/pr-body.md` lists (Pre-Landing Review: `REVIEW_LOOP:`, `Gate edits:`, deferred, notes), led under `CAPY_PR: on` (Step 19-C) by What changed, Why, Blast radius and Approvals (`gstack-approvals render --plan`). `gstack-prose-lint <top> --sections 'Why,Summary,Blast radius' --gate`; `gstack-issue-links`; `gstack-review-scope summary --since "$REVIEW_SINCE" --run <dir> && gstack-ship-receipt write --pr <n> --base <base> --gate @<f> --pregate <f> --issues @<f> --review-loop @<f> --docs @<f> [--review-exclude <d>] [--review-targeted @<f>] --body <body>`; `gstack-post pr-body` and `pr-title` (new PR: host PR tool; draft while a gate finding is `open`). `SHIP_RECEIPT:` opens your report. +- Step 18: the open-PR lookup; a plain `v<NEW_VERSION> ` title (`gstack-plain-title check`). `UNATTENDED_SHIP:` with no open PR at HEAD takes Step 18-H. +- Step 19 body: plain opening sentences, then sections `sections/pr-body.md` lists, led under `CAPY_PR: on` (Step 19-C) by What changed, Why and Blast radius; Agent notes last (receipt, `gstack-approvals render --plan`, `REVIEW_LOOP:`). `gstack-prose-lint <top> --sections 'Why,Summary,Blast radius' --gate`; `gstack-issue-links`; `gstack-review-scope summary --since "$REVIEW_SINCE" --run <dir> && gstack-ship-receipt write --pr <n> --base <base> --gate @<f> --pregate <f> --issues @<f> --review-loop @<f> --docs @<f> [--review-exclude <d>] [--review-targeted @<f>] --body <body>`; `gstack-post pr-body` and `pr-title` (new PR: host PR tool; draft while a gate finding is `open`). `SHIP_RECEIPT:` opens your report. - Step 20: a metrics row via `gstack-review-log`. - Step 22: `gstack-ship-budget close --reading R --credits C` (report `BUDGET_FINAL:`, `BUDGET_TIMELINE:` (tokens, credits), any `METER:` line), then `gstack-ship-receipt result --pr <n>`: `GSTACK_RESULT: skill=ship status=complete head=<sha> run=<dir>`. Under a merge queue, `gstack-ship-queue ready --pr <n> [--screen|--no-screen]` at the pushed head; paste its output. diff --git a/ship/DRIVER.md.tmpl b/ship/DRIVER.md.tmpl index 716274c3e..f14197dd3 100644 --- a/ship/DRIVER.md.tmpl +++ b/ship/DRIVER.md.tmpl @@ -113,8 +113,8 @@ repo gates run beside CI. Unattended, push defers the pre-push offer; else its S ## 8. Publish: Steps 18, 19, 19-C, 20, 21, 22 -- Step 18: the open-PR lookup; a `v<NEW_VERSION> ` title file. `UNATTENDED_SHIP:` with no open PR at HEAD takes Step 18-H. -- Step 19 body: the receipt block, then sections `sections/pr-body.md` lists (Pre-Landing Review: `REVIEW_LOOP:`, `Gate edits:`, deferred, notes), led under `CAPY_PR: on` (Step 19-C) by What changed, Why, Blast radius and Approvals (`gstack-approvals render --plan`). `gstack-prose-lint <top> --sections 'Why,Summary,Blast radius' --gate`; `gstack-issue-links`; `gstack-review-scope summary --since "$REVIEW_SINCE" --run <dir> && gstack-ship-receipt write --pr <n> --base <base> --gate @<f> --pregate <f> --issues @<f> --review-loop @<f> --docs @<f> [--review-exclude <d>] [--review-targeted @<f>] --body <body>`; `gstack-post pr-body` and `pr-title` (new PR: host PR tool; draft while a gate finding is `open`). `SHIP_RECEIPT:` opens your report. +- Step 18: the open-PR lookup; a plain `v<NEW_VERSION> ` title (`gstack-plain-title check`). `UNATTENDED_SHIP:` with no open PR at HEAD takes Step 18-H. +- Step 19 body: plain opening sentences, then sections `sections/pr-body.md` lists, led under `CAPY_PR: on` (Step 19-C) by What changed, Why and Blast radius; Agent notes last (receipt, `gstack-approvals render --plan`, `REVIEW_LOOP:`). `gstack-prose-lint <top> --sections 'Why,Summary,Blast radius' --gate`; `gstack-issue-links`; `gstack-review-scope summary --since "$REVIEW_SINCE" --run <dir> && gstack-ship-receipt write --pr <n> --base <base> --gate @<f> --pregate <f> --issues @<f> --review-loop @<f> --docs @<f> [--review-exclude <d>] [--review-targeted @<f>] --body <body>`; `gstack-post pr-body` and `pr-title` (new PR: host PR tool; draft while a gate finding is `open`). `SHIP_RECEIPT:` opens your report. - Step 20: a metrics row via `gstack-review-log`. - Step 22: `gstack-ship-budget close --reading R --credits C` (report `BUDGET_FINAL:`, `BUDGET_TIMELINE:` (tokens, credits), any `METER:` line), then `gstack-ship-receipt result --pr <n>`: `GSTACK_RESULT: skill=ship status=complete head=<sha> run=<dir>`. Under a merge queue, `gstack-ship-queue ready --pr <n> [--screen|--no-screen]` at the pushed head; paste its output. diff --git a/ship/SKILL.md b/ship/SKILL.md index 7f523cdf8..c389dd072 100644 --- a/ship/SKILL.md +++ b/ship/SKILL.md @@ -644,8 +644,8 @@ Make bisectable commits; if already committed, continue to Step 16. Never create migrations may stand alone. Under 50 lines across fewer than 4 files may be one commit. 2. Order dependencies first: infrastructure → models/services → controllers/views. Each commit works alone (no broken imports or missing code). -3. Use `<type>: <summary>` (feat/fix/chore/refactor/docs) and a brief body; fixing a - user-named issue adds a `Fixes: #<n>` trailer. Leave VERSION, CHANGELOG/fragment, TODOS.md +3. Subject: the change for a user in plain words, issue code last; brief body; + trailers last (a user-named issue: `Fixes: #<n>`). Leave VERSION, CHANGELOG/fragment, TODOS.md and the digest uncommitted: Step 16's `finish` commits them last with `--trailer 'Co-Authored-By: Claude <noreply@anthropic.com>'`. Do not add a Git tag. diff --git a/ship/SKILL.md.tmpl b/ship/SKILL.md.tmpl index 106beab8a..4d604f7ae 100644 --- a/ship/SKILL.md.tmpl +++ b/ship/SKILL.md.tmpl @@ -365,8 +365,8 @@ Make bisectable commits; if already committed, continue to Step 16. Never create migrations may stand alone. Under 50 lines across fewer than 4 files may be one commit. 2. Order dependencies first: infrastructure → models/services → controllers/views. Each commit works alone (no broken imports or missing code). -3. Use `<type>: <summary>` (feat/fix/chore/refactor/docs) and a brief body; fixing a - user-named issue adds a `Fixes: #<n>` trailer. Leave VERSION, CHANGELOG/fragment, TODOS.md +3. Subject: the change for a user in plain words, issue code last; brief body; + trailers last (a user-named issue: `Fixes: #<n>`). Leave VERSION, CHANGELOG/fragment, TODOS.md and the digest uncommitted: Step 16's `finish` commits them last with `--trailer '{{CO_AUTHOR_TRAILER}}'`. Do not add a Git tag. diff --git a/ship/sections/capy-pr.md b/ship/sections/capy-pr.md index 89bf352cc..e9cd5581a 100644 --- a/ship/sections/capy-pr.md +++ b/ship/sections/capy-pr.md @@ -6,9 +6,9 @@ Read only when Step 19 printed `CAPY_PR: on`. Under Capy a person decides the me the READY report, through a Captain, so the body shows what changed in pictures, why, and who approved what. Guide: `captain.md` beside the Capy `gstack` skill's SKILL.md. -**Order.** The `gstack-ship-receipt` block stays first when Step 19 writes one. Then `## What changed`, -`## Why`, `## Blast radius` and `## Approvals`, then the standard sections below. Keep `## Summary` to -commit themes; do not repeat the captions. +**Order.** The plain opening sentences come first, then `## What changed`, `## Why` and `## Blast radius`, +then the standard sections below. `## Approvals` goes inside the closing Agent notes section, where +`gstack-ship-receipt write` also puts the receipt. Keep `## Summary` to commit themes; do not repeat the captions. 1. **Pick the evidence:** one to three before/after pairs, highest-signal first. The first is the key image. @@ -38,7 +38,7 @@ commit themes; do not repeat the captions. a lint and a CI step." GOOD "'The review loop is too overzealous.' Pass 2 now reads only pass 1's range." 6. **`## Blast radius`:** the body template's item, written once, here: what else the change touches and the one fact that makes that safe, with the evidence-ladder rung it reached (file:line read, walked failure path, script ran, repro). -7. **`## Approvals`:** the output of `~/.claude/skills/gstack/bin/gstack-approvals render --plan "<the brief's Plan line>"`, verbatim. It +7. **`## Approvals`** (inside Agent notes): the output of `~/.claude/skills/gstack/bin/gstack-approvals render --plan "<the brief's Plan line>"`, verbatim. It escapes its cells, and the receipt never reads it as an approval citation. A public repo (or unknown visibility) gets only `Approved: yes|no (record <id>)` and `Plan: approved (record r-<id>)`; never pass `--full` for a PR body. If render refuses with a `PLAN_*` code, it printed nothing: run the fix it names and render again. On diff --git a/ship/sections/capy-pr.md.tmpl b/ship/sections/capy-pr.md.tmpl index 502eb0291..41a36d84f 100644 --- a/ship/sections/capy-pr.md.tmpl +++ b/ship/sections/capy-pr.md.tmpl @@ -4,9 +4,9 @@ Read only when Step 19 printed `CAPY_PR: on`. Under Capy a person decides the me the READY report, through a Captain, so the body shows what changed in pictures, why, and who approved what. Guide: `captain.md` beside the Capy `gstack` skill's SKILL.md. -**Order.** The `gstack-ship-receipt` block stays first when Step 19 writes one. Then `## What changed`, -`## Why`, `## Blast radius` and `## Approvals`, then the standard sections below. Keep `## Summary` to -commit themes; do not repeat the captions. +**Order.** The plain opening sentences come first, then `## What changed`, `## Why` and `## Blast radius`, +then the standard sections below. `## Approvals` goes inside the closing Agent notes section, where +`gstack-ship-receipt write` also puts the receipt. Keep `## Summary` to commit themes; do not repeat the captions. 1. **Pick the evidence:** one to three before/after pairs, highest-signal first. The first is the key image. @@ -36,7 +36,7 @@ commit themes; do not repeat the captions. a lint and a CI step." GOOD "'The review loop is too overzealous.' Pass 2 now reads only pass 1's range." 6. **`## Blast radius`:** the body template's item, written once, here: what else the change touches and the one fact that makes that safe, with the evidence-ladder rung it reached (file:line read, walked failure path, script ran, repro). -7. **`## Approvals`:** the output of `~/.claude/skills/gstack/bin/gstack-approvals render --plan "<the brief's Plan line>"`, verbatim. It +7. **`## Approvals`** (inside Agent notes): the output of `~/.claude/skills/gstack/bin/gstack-approvals render --plan "<the brief's Plan line>"`, verbatim. It escapes its cells, and the receipt never reads it as an approval citation. A public repo (or unknown visibility) gets only `Approved: yes|no (record <id>)` and `Plan: approved (record r-<id>)`; never pass `--full` for a PR body. If render refuses with a `PLAN_*` code, it printed nothing: run the fix it names and render again. On diff --git a/ship/sections/changelog.md b/ship/sections/changelog.md index 9aa01741d..99e596515 100644 --- a/ship/sections/changelog.md +++ b/ship/sections/changelog.md @@ -33,7 +33,7 @@ - Write concise, descriptive bullet points - Insert after the observed file header, before the first release entry, dated today - Format: `## [X.Y.Z.W] - YYYY-MM-DD` - - **Voice:** Lead with what the user can now **do** that they couldn't before. Use plain language, not implementation details. Never mention TODOS.md, internal tracking, or contributor-facing details. + - **Voice:** Lead with what the user can now **do** that they couldn't before. Use plain language, not implementation details. Never mention TODOS.md, internal tracking, or contributor-facing details: no tracker codes (GST-123, R-numbers) anywhere in the entry, and a bold lead a user of the project understands without the repository's coined terms (docs/ship-policy.md#plain-titles). 6. **Cross-check:** Compare your CHANGELOG entry against the commit list from step 2. Every user-facing change in that list must be represented; add any that is missing. @@ -43,6 +43,8 @@ **Do NOT ask the user to describe changes.** Infer from the diff and commit history. 7. **Lint the entry:** `~/.claude/skills/gstack/bin/gstack-prose-lint CHANGELOG.md --base origin/<base> --gate` reads only the lines this branch added; rewrite each `PROSE:` line it names and rerun until the trailer is `PROSE_LINT: ok`. + A fragment file (policy `changelog_fragments`) also runs `~/.claude/skills/gstack/bin/gstack-plain-title check --changelog <fragment>` + until its last line is `ok`, `exempt` or `none`: no tracker code in the entry and a plain bold lead. **NO_VERSION (Step 12):** skip this step; write no CHANGELOG entry or version header. diff --git a/ship/sections/changelog.md.tmpl b/ship/sections/changelog.md.tmpl index edb55dae5..dd1e39763 100644 --- a/ship/sections/changelog.md.tmpl +++ b/ship/sections/changelog.md.tmpl @@ -1,6 +1,8 @@ {{CHANGELOG_WORKFLOW}} 7. **Lint the entry:** `~/.claude/skills/gstack/bin/gstack-prose-lint CHANGELOG.md --base origin/<base> --gate` reads only the lines this branch added; rewrite each `PROSE:` line it names and rerun until the trailer is `PROSE_LINT: ok`. + A fragment file (policy `changelog_fragments`) also runs `~/.claude/skills/gstack/bin/gstack-plain-title check --changelog <fragment>` + until its last line is `ok`, `exempt` or `none`: no tracker code in the entry and a plain bold lead. **NO_VERSION (Step 12):** skip this step; write no CHANGELOG entry or version header. diff --git a/ship/sections/pr-body.md b/ship/sections/pr-body.md index a84c8930f..a745c52b6 100644 --- a/ship/sections/pr-body.md +++ b/ship/sections/pr-body.md @@ -20,12 +20,20 @@ Write the text into each printed file with your file-write tool (Claude Code's W 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. +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 `<type>: <summary>`; no version prefix. + 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. @@ -68,12 +76,18 @@ The completion message lists unpromoted candidates, `Related:` lines and any `IS ### 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. +`~/.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 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, @@ -175,6 +189,15 @@ the receipt write below refuses (`DOCS_AUDIT_BLOCKED`) when the body lacks it.> - [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 @@ -187,7 +210,7 @@ WARN-degrades the example credentials those tools quote instead of blocking the (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. +`## Documentation` heading line, the second from `## Test plan` on, ending with the Agent notes section. ```bash _GT="$(git rev-parse --show-toplevel 2>/dev/null || pwd)/.gstack/tmp" @@ -210,6 +233,7 @@ 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/<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" @@ -301,6 +325,8 @@ 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 diff --git a/ship/sections/pr-body.md.tmpl b/ship/sections/pr-body.md.tmpl index 2bf22808e..6ea65e084 100644 --- a/ship/sections/pr-body.md.tmpl +++ b/ship/sections/pr-body.md.tmpl @@ -11,12 +11,20 @@ Prepare the title in a private file; Step 19 posts that file, never a shell stri 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. +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 `<type>: <summary>`; no version prefix. + 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. @@ -59,12 +67,18 @@ The completion message lists unpromoted candidates, `Related:` lines and any `IS ### 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. +`~/.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 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, @@ -166,6 +180,15 @@ the receipt write below refuses (`DOCS_AUDIT_BLOCKED`) when the body lacks it.> - [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 @@ -178,7 +201,7 @@ WARN-degrades the example credentials those tools quote instead of blocking the (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. +`## Documentation` heading line, the second from `## Test plan` on, ending with the Agent notes section. {{FREE_TEXT_FILE:BODY_TOP_FILE=pr-body-top:BODY_REST_FILE=pr-body-rest}} @@ -193,6 +216,7 @@ 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/<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" @@ -284,6 +308,8 @@ 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 diff --git a/spec/sections/gate-and-file.md b/spec/sections/gate-and-file.md index 6cf2ceec3..ca2bdd97b 100644 --- a/spec/sections/gate-and-file.md +++ b/spec/sections/gate-and-file.md @@ -438,7 +438,7 @@ followed by the coordinator contract every lane inherits word for word ## Coordinator contract (inherited by every lane) -Merge conditions. A lane's PR merges only when: the required gate lanes are green on the stamped tree or the tree receipt says `gate-reuse: eligible`; `gstack-lane-check` reports `clear` for its planned files against every other open lane; the PR body opens with the `gstack-ship-receipt` block; and the coordinator, not the lane, says merge. A lane never merges, rebases another lane, or force-pushes. +Merge conditions. A lane's PR merges only when: the required gate lanes are green on the stamped tree or the tree receipt says `gate-reuse: eligible`; `gstack-lane-check` reports `clear` for its planned files against every other open lane; the PR body carries the `gstack-ship-receipt` block in its Agent notes section; and the coordinator, not the lane, says merge. A lane never merges, rebases another lane, or force-pushes. Behavior-change notices. Every user-visible behavior change outside the plan's own items is listed in the PR body under `## Behavior changes` with the file, the old behavior and the new one, before the review starts. An omitted change found in review is a CRITICAL finding, whatever its size. diff --git a/test/agent-notes.test.ts b/test/agent-notes.test.ts new file mode 100644 index 000000000..2d093c2e3 --- /dev/null +++ b/test/agent-notes.test.ts @@ -0,0 +1,147 @@ +/** + * The Agent notes section (lib/agent-notes.ts, GST-1825): the collapsed block at the end of a PR body that holds the + * lines gstack's tools read. Covers where the receipt goes, which receipt binds (legacy top, inside the notes, a quoted + * example in the readable part, none), and that the Plan line and issue lines are found inside the notes. + */ +import { describe, expect, test } from 'bun:test'; +import { AGENT_NOTES_MARKER, agentNotesOrBody, renderAgentNotes, splitAgentNotes, withAgentNotes } from '../lib/agent-notes'; +import { parsePlanLine } from '../lib/plan-record'; +import { bindReceipt, computeCensus, extractReceipt, missingIssueLines, orderReceipt, placeReceipt, receiptRuns, renderReceiptBlock } from '../lib/ship-receipt'; +import type { ShipReceipt } from '../lib/headless-artifacts'; + +const HEAD = 'a'.repeat(40); +const OTHER = 'b'.repeat(40); +const receipt = (head: string, pr = 7): ShipReceipt => orderReceipt({ schema_version: 1, pr, head, base: 'main' }); +const notes = (content: string) => renderAgentNotes(content); + +describe('splitAgentNotes and withAgentNotes', () => { + test('a body with a section splits into the readable part and the content; one without has no notes', () => { + const body = 'Opening sentence.\n\n## Summary\n\nx\n\n' + notes('Plan: exempt: docs-only\nFixes #14'); + expect(splitAgentNotes(body)).toEqual({ readable: 'Opening sentence.\n\n## Summary\n\nx\n\n', notes: 'Plan: exempt: docs-only\nFixes #14' }); + expect(splitAgentNotes('Opening.\n')).toEqual({ readable: 'Opening.\n', notes: null }); + expect(agentNotesOrBody('Opening.\n')).toBe('Opening.\n'); + }); + + test('the marker inside a code fence opens no section, and </details> inside a fence in the notes does not close it', () => { + const quoted = 'Example:\n\n```md\n' + AGENT_NOTES_MARKER + '\n```\n'; + expect(splitAgentNotes(quoted).notes).toBeNull(); + const inner = 'Plan: exempt: x\n\n```text\n</details>\n```'; + expect(splitAgentNotes('Body.\n\n' + notes(inner)).notes).toBe(inner); + }); + + test('withAgentNotes appends a section, replaces an existing one in place, keeps a trailing badge after it, and is stable', () => { + const once = withAgentNotes('Opening.\n\n## Summary\n\nx\n', 'Fixes #1'); + expect(once).toBe('Opening.\n\n## Summary\n\nx\n\n' + notes('Fixes #1')); + const badge = '<!-- capy-badge:start -->\nB\n<!-- capy-badge:end -->\n'; + const twice = withAgentNotes(once + '\n' + badge, 'Fixes #2'); + expect(twice).toBe('Opening.\n\n## Summary\n\nx\n\n' + notes('Fixes #2') + '\n' + badge); + expect(withAgentNotes(twice, 'Fixes #2')).toBe(twice); + }); + + test('a collapsible a person adds after the section stays after it, and one nested inside the notes stays inside', () => { + const shots = '<details><summary>Screenshots</summary>\n\n![a](a.png)\n\n</details>\n'; + const body = 'Opening.\n\n' + notes('Fixes #1') + '\n' + shots; + expect(splitAgentNotes(body).notes).toBe('Fixes #1'); + expect(withAgentNotes(body, 'Fixes #2')).toBe('Opening.\n\n' + notes('Fixes #2') + '\n' + shots); + expect(placeReceipt(body, receipt(HEAD)).endsWith('</details>\n\n' + shots)).toBe(true); + const nested = 'Fixes #1\n\n<details><summary>Runs</summary>\n\nr\n\n</details>'; + expect(splitAgentNotes('Opening.\n\n' + notes(nested) + '\n' + shots).notes).toBe(nested); + }); + + test('an open or two-line opener and an unclosed collapsible inside the notes keep text added after the section outside it', () => { + const after = '\nA note a person added.\n'; + for (const opener of ['<details open><summary>Agent notes</summary>', '<details>\n<summary>Agent notes</summary>']) { + const body = `Opening.\n\n${AGENT_NOTES_MARKER}\n${opener}\n\nFixes #1\n\n</details>\n${after}`; + expect(splitAgentNotes(body).notes).toBe('Fixes #1'); + expect(withAgentNotes(body, 'Fixes #2').endsWith('</details>\n' + after)).toBe(true); + } + const unclosed = 'Opening.\n\n' + notes('Fixes #1\n\n<details><summary>Runs</summary>\n\nr') + '\n' + after; + expect(splitAgentNotes(unclosed).notes).toBe('Fixes #1\n\n<details><summary>Runs</summary>\n\nr'); + expect(withAgentNotes(unclosed, 'Fixes #2').endsWith('</details>\n' + after)).toBe(true); + }); +}); + +describe('placeReceipt: the receipt lives in the Agent notes section', () => { + test('a body with no receipt gets a section holding the receipt; the other notes content stays above it', () => { + const fresh = placeReceipt('Opening.\n\n## Summary\n\nx\n', receipt(HEAD)); + expect(fresh).toBe('Opening.\n\n## Summary\n\nx\n\n' + notes(renderReceiptBlock(receipt(HEAD)))); + const withLines = placeReceipt('Opening.\n\n' + notes('Plan: exempt: docs-only\nFixes #14'), receipt(HEAD)); + expect(splitAgentNotes(withLines).notes).toBe('Plan: exempt: docs-only\nFixes #14\n\n' + renderReceiptBlock(receipt(HEAD)).trim()); + expect(placeReceipt(withLines, receipt(HEAD))).toBe(withLines); + }); + + test('a new receipt replaces the one in the notes, the old one joins the runs block there, and a quoted example fence in the readable part is removed', () => { + const example = 'Here is what the record looks like:\n\n' + renderReceiptBlock(receipt(OTHER, 1)) + '\nMore prose.\n'; + const first = placeReceipt(example + '\n' + notes('Fixes #14'), receipt(HEAD)); + const second = placeReceipt(first, receipt(OTHER)); + expect(second.match(/```gstack-ship-receipt/g)).toHaveLength(1); + expect(splitAgentNotes(second).readable).toBe('Here is what the record looks like:\n\nMore prose.\n\n'); + expect(extractReceipt(second).receipt!.head).toBe(OTHER); + expect(receiptRuns(second).map(r => r.head)).toEqual([HEAD]); + expect(splitAgentNotes(second).notes).toStartWith('Fixes #14\n\n```gstack-ship-receipt'); + }); + + test('a legacy body (receipt at the top, no section) keeps its layout, so its Plan and issue lines stay readable', () => { + const legacy = renderReceiptBlock(receipt(HEAD)) + '\nOpening.\n\n## Approvals\n\nPlan: exempt: docs-only\n'; + const placed = placeReceipt(legacy, receipt(OTHER)); + expect(placed).toStartWith('```gstack-ship-receipt\n'); + expect(placed).not.toContain(AGENT_NOTES_MARKER); + expect(extractReceipt(placed).receipt!.head).toBe(OTHER); + expect(receiptRuns(placed).map(r => r.head)).toEqual([HEAD]); + expect(parsePlanLine(placed)?.line).toBe('Plan: exempt: docs-only'); + }); +}); + +describe('which receipt binds', () => { + test('(a) a legacy receipt at the top of a body with no section binds', () => { + const body = renderReceiptBlock(receipt(HEAD)) + '\nOpening.\n\n## Summary\n\nx\n'; + expect(bindReceipt(body, '7', [HEAD])).toMatchObject({ ok: true, head: HEAD }); + }); + + test('(b) a receipt inside the Agent notes section binds', () => { + const body = 'Opening.\n\n## Summary\n\nx\n\n' + notes('Fixes #14\n\n' + renderReceiptBlock(receipt(HEAD))); + expect(bindReceipt(body, '7', [HEAD])).toMatchObject({ ok: true, head: HEAD }); + }); + + test('(c) an example fence in the readable part never binds when the body has a section; the real one in the notes does', () => { + const body = 'Opening.\n\n' + renderReceiptBlock(receipt(OTHER)) + '\n## Summary\n\nx\n\n' + notes(renderReceiptBlock(receipt(HEAD))); + expect(extractReceipt(body).receipt!.head).toBe(HEAD); + expect(bindReceipt(body, '7', [HEAD])).toMatchObject({ ok: true, head: HEAD }); + expect(bindReceipt(body, '7', [OTHER])).toMatchObject({ ok: false, code: 'QUEUE_RECEIPT_STALE' }); + const quotedOnly = 'Opening.\n\n' + renderReceiptBlock(receipt(OTHER)) + '\n' + notes('Fixes #14'); + expect(bindReceipt(quotedOnly, '7', [OTHER])).toMatchObject({ ok: false, code: 'QUEUE_RECEIPT_MISSING' }); + }); + + test('(d) a body with no receipt binds nothing, with or without a section', () => { + expect(bindReceipt('Opening.\n', '7', [HEAD])).toMatchObject({ ok: false, code: 'QUEUE_RECEIPT_MISSING' }); + expect(bindReceipt('Opening.\n\n' + notes('Fixes #14'), '7', [HEAD])).toMatchObject({ ok: false, code: 'QUEUE_RECEIPT_MISSING' }); + }); + + test('the census counts the receipt in the notes and ignores a quoted one in the readable part', () => { + const body = 'Opening.\n\n' + renderReceiptBlock(orderReceipt({ schema_version: 1, head: OTHER, base: 'main', run: 'quoted' })) + '\n' + + notes(renderReceiptBlock(orderReceipt({ schema_version: 1, head: HEAD, base: 'main', run: 'real', session_kind: 'unattended' }))); + const census = computeCensus('acme/widget', '2026-11-01', [{ number: 1, mergedAt: '2026-11-02T00:00:00Z', body }], null); + expect(census.runs).toEqual(['real']); + expect(census.unattended_prs).toBe(1); + }); +}); + +describe('readers find their lines inside the Agent notes section', () => { + test('the Plan line is read from the notes first; with none there, the readable part still counts', () => { + const body = 'Opening.\n\n## Summary\n\nx\n\n' + notes('Plan: exempt: docs-only\n\n' + renderReceiptBlock(receipt(HEAD))); + expect(parsePlanLine(body)).toEqual({ kind: 'exempt', line: 'Plan: exempt: docs-only', reason: 'docs-only' }); + expect(parsePlanLine('Plan: approved: r-1\n\n' + notes('Plan: exempt: docs-only'))?.kind).toBe('exempt'); + const readableOnly = 'Plan: approved: r-1\n\n' + notes('Fixes #14'); + expect(parsePlanLine(readableOnly)).toEqual({ kind: 'approved', line: 'Plan: approved: r-1', token: 'r-1' }); + const older = 'Opening.\n\n## Approvals\n\nPlan: exempt: docs-only\n'; + expect(parsePlanLine(placeReceipt(older, receipt(HEAD)))).toEqual(parsePlanLine(older)); + expect(parsePlanLine('## Approvals\n\nPlan: exempt: docs-only\n')?.kind).toBe('exempt'); + }); + + test('issue lines count inside the notes, in the readable part, and in a legacy body', () => { + const issues = { fixes: [14], refs: [30], flakes: [] }; + expect(missingIssueLines('Opening.\n\n' + notes('Fixes #14\nRefs #30'), issues)).toEqual([]); + expect(missingIssueLines('## Issues\nFixes #14\n\n' + notes('Refs #30'), issues)).toEqual([]); + expect(missingIssueLines('Opening.\n\n' + notes('Fixes #14'), issues)).toEqual(['Refs #30']); + }); +}); diff --git a/test/base-red.test.ts b/test/base-red.test.ts index 221957d7c..7405bc98c 100644 --- a/test/base-red.test.ts +++ b/test/base-red.test.ts @@ -14,7 +14,7 @@ import * as os from 'node:os'; import * as path from 'node:path'; import { classifyBaseRed, parseEvidence, pausePath, readPause, receiptBaseRed, shellRunner, waitBaseGreen, writePause, type FailureEvidence, type Runner } from '../lib/base-red'; import { EXIT } from '../lib/headless-artifacts'; -import { bindReceipt, extractReceipt, placeReceiptFirst, receiptSummaryLine } from '../lib/ship-receipt'; +import { bindReceipt, extractReceipt, placeReceipt, receiptSummaryLine } from '../lib/ship-receipt'; import { BASE_RED_KEY_VERSION, validatePolicy, type BaseRedPolicy } from '../lib/ship-policy'; const ROOT = path.resolve(import.meta.dir, '..'); @@ -121,7 +121,7 @@ describe('pause, receipt and wait', () => { if (typeof pause === 'string') throw new Error(pause); expect(pause.expires_at).toBe('2026-10-30T00:00:00.000Z'); const head = git('rev-parse', 'HEAD'); - const body = placeReceiptFirst('## Summary\n', { schema_version: 1, pr: 7, head, base: 'main', base_red: receiptBaseRed(pause) } as any); + const body = placeReceipt('## Summary\n', { schema_version: 1, pr: 7, head, base: 'main', base_red: receiptBaseRed(pause) } as any); expect(extractReceipt(body).errors).toEqual([]); const refused = bindReceipt(body, '7', [head]); expect(refused.ok ? 'bound' : refused.code).toBe('QUEUE_RECEIPT_CONDITIONAL'); @@ -141,7 +141,7 @@ describe('pause, receipt and wait', () => { if (typeof pause === 'string') throw new Error(pause); const head = git('rev-parse', 'HEAD'); const bodyFile = path.join(root, 'body.md'); - fs.writeFileSync(bodyFile, placeReceiptFirst('## Summary\n', { schema_version: 1, pr: 7, head, base: 'main', base_red: receiptBaseRed(pause) } as any)); + fs.writeFileSync(bodyFile, placeReceipt('## Summary\n', { schema_version: 1, pr: 7, head, base: 'main', base_red: receiptBaseRed(pause) } as any)); const res = bin('gstack-ship-receipt', ['result', '--body', bodyFile, '--head', head, '--out', path.join(root, 'run')]); expect(res.status).toBe(EXIT.fail); expect(res.stdout.trim()).toBe(`GSTACK_RESULT: skill=ship status=incomplete head=${head} run=${path.join(root, 'run')}`); diff --git a/test/fixtures/golden/agy-ship-SKILL.md b/test/fixtures/golden/agy-ship-SKILL.md index a567c9257..a9671d209 100644 --- a/test/fixtures/golden/agy-ship-SKILL.md +++ b/test/fixtures/golden/agy-ship-SKILL.md @@ -645,8 +645,8 @@ Make bisectable commits; if already committed, continue to Step 16. Never create migrations may stand alone. Under 50 lines across fewer than 4 files may be one commit. 2. Order dependencies first: infrastructure → models/services → controllers/views. Each commit works alone (no broken imports or missing code). -3. Use `<type>: <summary>` (feat/fix/chore/refactor/docs) and a brief body; fixing a - user-named issue adds a `Fixes: #<n>` trailer. Leave VERSION, CHANGELOG/fragment, TODOS.md +3. Subject: the change for a user in plain words, issue code last; brief body; + trailers last (a user-named issue: `Fixes: #<n>`). Leave VERSION, CHANGELOG/fragment, TODOS.md and the digest uncommitted: Step 16's `finish` commits them last with `--trailer 'Co-Authored-By: Claude <noreply@anthropic.com>'`. Do not add a Git tag. diff --git a/test/fixtures/golden/claude-ship-SKILL.md b/test/fixtures/golden/claude-ship-SKILL.md index 7f523cdf8..c389dd072 100644 --- a/test/fixtures/golden/claude-ship-SKILL.md +++ b/test/fixtures/golden/claude-ship-SKILL.md @@ -644,8 +644,8 @@ Make bisectable commits; if already committed, continue to Step 16. Never create migrations may stand alone. Under 50 lines across fewer than 4 files may be one commit. 2. Order dependencies first: infrastructure → models/services → controllers/views. Each commit works alone (no broken imports or missing code). -3. Use `<type>: <summary>` (feat/fix/chore/refactor/docs) and a brief body; fixing a - user-named issue adds a `Fixes: #<n>` trailer. Leave VERSION, CHANGELOG/fragment, TODOS.md +3. Subject: the change for a user in plain words, issue code last; brief body; + trailers last (a user-named issue: `Fixes: #<n>`). Leave VERSION, CHANGELOG/fragment, TODOS.md and the digest uncommitted: Step 16's `finish` commits them last with `--trailer 'Co-Authored-By: Claude <noreply@anthropic.com>'`. Do not add a Git tag. diff --git a/test/fixtures/golden/codex-ship-SKILL.md b/test/fixtures/golden/codex-ship-SKILL.md index 6e9c8dfa1..3f8c25589 100644 --- a/test/fixtures/golden/codex-ship-SKILL.md +++ b/test/fixtures/golden/codex-ship-SKILL.md @@ -647,8 +647,8 @@ Make bisectable commits; if already committed, continue to Step 16. Never create migrations may stand alone. Under 50 lines across fewer than 4 files may be one commit. 2. Order dependencies first: infrastructure → models/services → controllers/views. Each commit works alone (no broken imports or missing code). -3. Use `<type>: <summary>` (feat/fix/chore/refactor/docs) and a brief body; fixing a - user-named issue adds a `Fixes: #<n>` trailer. Leave VERSION, CHANGELOG/fragment, TODOS.md +3. Subject: the change for a user in plain words, issue code last; brief body; + trailers last (a user-named issue: `Fixes: #<n>`). Leave VERSION, CHANGELOG/fragment, TODOS.md and the digest uncommitted: Step 16's `finish` commits them last with `--trailer 'Co-Authored-By: OpenAI Codex <noreply@openai.com>'`. Do not add a Git tag. diff --git a/test/fixtures/golden/factory-ship-SKILL.md b/test/fixtures/golden/factory-ship-SKILL.md index 3ae429ebf..2854fa993 100644 --- a/test/fixtures/golden/factory-ship-SKILL.md +++ b/test/fixtures/golden/factory-ship-SKILL.md @@ -649,8 +649,8 @@ Make bisectable commits; if already committed, continue to Step 16. Never create migrations may stand alone. Under 50 lines across fewer than 4 files may be one commit. 2. Order dependencies first: infrastructure → models/services → controllers/views. Each commit works alone (no broken imports or missing code). -3. Use `<type>: <summary>` (feat/fix/chore/refactor/docs) and a brief body; fixing a - user-named issue adds a `Fixes: #<n>` trailer. Leave VERSION, CHANGELOG/fragment, TODOS.md +3. Subject: the change for a user in plain words, issue code last; brief body; + trailers last (a user-named issue: `Fixes: #<n>`). Leave VERSION, CHANGELOG/fragment, TODOS.md and the digest uncommitted: Step 16's `finish` commits them last with `--trailer 'Co-Authored-By: Factory Droid <droid@users.noreply.github.com>'`. Do not add a Git tag. diff --git a/test/fixtures/module-size-ratchet.json b/test/fixtures/module-size-ratchet.json index ccb015672..97e68297a 100644 --- a/test/fixtures/module-size-ratchet.json +++ b/test/fixtures/module-size-ratchet.json @@ -202,7 +202,10 @@ { "file": "lib/pr-wait.ts", "movedFrom": "(new: gstack-pr-wait event logic, GSTA-115 item 1; design ported from tstack pr-wait)" }, { "file": "lib/pr-wait-github.ts", "movedFrom": "(new: gstack-pr-wait gh reads, GSTA-115 item 1; design ported from tstack pr-wait)" }, { "file": "lib/repo-state-clean.ts", "movedFrom": "(new: per-repo .gstack cleanup behind bin/gstack-clean, #2627)" }, - { "file": "lib/result-codes-clean.ts", "movedFrom": "lib/result-codes.ts (the gstack-clean rows, #2627)" } + { "file": "lib/result-codes-clean.ts", "movedFrom": "lib/result-codes.ts (the gstack-clean rows, #2627)" }, + { "file": "lib/plain-title.ts", "movedFrom": "(new: the plain-English title, branch, opening and changelog check behind bin/gstack-plain-title, R76)" }, + { "file": "lib/result-codes-plain.ts", "movedFrom": "lib/result-codes.ts (the plain-title rows, R76)" }, + { "file": "lib/agent-notes.ts", "movedFrom": "(new: the PR body's closing Agent notes section, read by the receipt and Plan-line readers)" } ], "residualFiles": [ { "file": "scripts/test-free-shards.ts", "maxLines": 2361, "reason": "free lane policy after the shard engine took spawn, sandbox, logs, seeds and flags" }, diff --git a/test/gstack-post.test.ts b/test/gstack-post.test.ts index c9f6a5948..1878b470b 100644 --- a/test/gstack-post.test.ts +++ b/test/gstack-post.test.ts @@ -7,6 +7,9 @@ * the real executable against stub gh/glab on POSIX. */ import { describe, expect, test } from "bun:test"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; import { EXIT, attributionCallout, mergeBodySections, runPost, type PostEnv } from "../lib/gstack-post"; interface Call { cmd: string; args: string[]; input?: string } @@ -738,7 +741,7 @@ describe("gstack-post pr-body merges the PR body by section", () => { expect(() => mergeBodySections(current, RECEIPT("cccc") + "\n## A\n\nnew\n")).toThrow('holds "ship receipt" more than once'); }); - // Value: protects=the body /ship composes (lib/ship-receipt.ts placeReceiptFirst: the receipt block, one blank line, then the sections) reposts + // Value: protects=the body /ship composes (lib/ship-receipt.ts placeReceipt: the receipt block, one blank line, then the sections) reposts // idempotently: merged into a PR body of the same shape it yields the file's bytes, the receipt stays byte-first, and the stderr line reports no // addition; fails_when=the blank line after the receipt is read as new leading text and prepended before the receipt (one more leading newline per // repost, "added leading text" on every /ship re-READY), or a kept blank line is counted as added; why_new=the merge tests above post a receipt-only @@ -774,3 +777,98 @@ describe("gstack-post pr-body merges the PR body by section", () => { expect(afterReceipt.dropped).toEqual([]); }); }); + +describe("gstack-post keeps the Agent notes section last", () => { + const RECEIPT = (head: string) => "```gstack-ship-receipt\n{\n \"head\": \"" + head + "\"\n}\n```\n"; + const NOTES = (content: string) => "<!-- gstack:agent-notes -->\n<details><summary>Agent notes</summary>\n\n" + content + "\n\n</details>\n"; + const composed = (head: string, summary: string) => "Opening sentence.\n\n## Summary\n\n" + summary + "\n\n## Test plan\n\n- [x] bun test\n\n" + NOTES("Plan: exempt: docs-only\n\n" + RECEIPT(head).trim()); + + // Value: protects=the body /ship composes since GST-1825 (opening, sections, Agent notes last) reposts byte for byte and replaces the notes as one + // section; fails_when=the receipt inside the notes is split out as its own section, or the notes stop being last; why_new=the notes section is new; + // seam=none (mergeBodySections); no_claim=the gh round trip + test("a full post whose notes section is last reposts byte for byte and replaces the notes, receipt included, as one section", () => { + const first = mergeBodySections(composed("aaaa", "old"), composed("bbbb", "new")); + expect(first.body).toBe(composed("bbbb", "new")); + expect([first.replaced, first.kept, first.added, first.dropped]).toEqual([["leading text", "Summary", "Test plan", "Agent notes"], [], [], []]); + expect(mergeBodySections(first.body, composed("bbbb", "new")).body).toBe(composed("bbbb", "new")); + }); + + // Value: protects=a partial post keeps the current notes and they stay last, above a Capy badge that ends the body; fails_when=a new section is + // appended after the notes or the badge moves above them; why_new=the notes section is new; seam=none; no_claim=the gh round trip + test("a partial post appends new sections above the kept notes, and a trailing Capy badge stays after them", () => { + const badge = "<!-- capy-badge:start -->\nB\n<!-- capy-badge:end -->\n"; + const current = composed("aaaa", "old") + "\n" + badge; + const m = mergeBodySections(current, "## Why\n\nBecause.\n"); + expect(m.body).toBe("Opening sentence.\n\n## Summary\n\nold\n\n## Test plan\n\n- [x] bun test\n\n## Why\n\nBecause.\n\n" + NOTES("Plan: exempt: docs-only\n\n" + RECEIPT("aaaa").trim()) + "\n" + badge); + expect(m.added).toEqual(["Why"]); + expect(m.kept).toContain("Agent notes"); + }); + + // Value: protects=a PR posted before the notes section (receipt at the top) still merges: a partial post keeps the legacy receipt where it is, and a + // full post with notes last replaces it, so one receipt remains and it is the notes' one; fails_when=the legacy receipt survives a full post (two + // receipts, the stale one first) or a partial post drops it; why_new=the legacy layout meets the new one; seam=none; no_claim=the gh round trip + test("a legacy receipt-at-top body still merges: a partial post keeps it, a full post with notes last replaces it", () => { + const legacy = RECEIPT("aaaa") + "\n## Summary\n\nold\n\n## Approvals\n\nPlan: exempt: docs-only\n"; + const partial = mergeBodySections(legacy, "## Summary\n\nnew\n"); + expect(partial.body).toBe(RECEIPT("aaaa") + "\n## Summary\n\nnew\n\n## Approvals\n\nPlan: exempt: docs-only\n"); + const full = mergeBodySections(legacy, composed("bbbb", "new")); + expect(full.body).toBe("Opening sentence.\n\n## Summary\n\nnew\n\n## Approvals\n\nPlan: exempt: docs-only\n\n## Test plan\n\n- [x] bun test\n\n" + NOTES("Plan: exempt: docs-only\n\n" + RECEIPT("bbbb").trim())); + expect(full.body.match(/```gstack-ship-receipt/g)).toHaveLength(1); + expect(full.replaced).toEqual(["ship receipt", "Summary"]); + }); +}); + +describe("gstack-post refuses a title that is not in plain words (exit 4)", () => { + const repoWith = (policy: string | null) => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "gstack-post-plain-")); + if (policy !== null) { fs.mkdirSync(path.join(root, ".gstack")); fs.writeFileSync(path.join(root, ".gstack", "plain-titles.json"), policy); } + return root; + }; + const POLICY = JSON.stringify({ codes: ["\\bGST-\\d+\\b"], terms: ["pregate"], allow: [], since: "2026-11-08", exempt_branch_prefixes: [] }); + + // Value: protects=pr-title and pr-create post nothing (no title edit, no PR, no body) when the repository's plain-title policy refuses the title, + // print the PLAIN_TITLE lines and exit 4; a plain title with one trailing code posts; fails_when=the check runs after a post or not at all; + // why_new=R76 adds the check; seam=a real policy file under the fake root; no_claim=real gh + test("a refused title posts nothing, prints PLAIN_TITLE lines and exits 4; a plain title posts", () => { + const root = repoWith(POLICY); + try { + const bad = fakeEnv({ root, files: { "/t": "Make the pregate quieter\n", "/b": "Body.\n" } }); + expect(run(["pr-title", "7", "--title-file", "/t"], bad)).toBe(EXIT.plainTitle); + expect(EXIT.plainTitle).toBe(4); + expect(bad.posts()).toEqual([]); + expect(bad.stdout()).toContain('PLAIN_TITLE: PLAIN_TITLE_REFUSED term "pregate"'); + expect(bad.stdout()).toContain("PLAIN_TITLE: refused=1\n"); + expect(bad.stderr()).toContain("nothing was posted"); + const create = fakeEnv({ root, files: { "/t": "Fix GST-1713 retries", "/b": "Body.\n" } }); + expect(run(["pr-create", "--base", "main", "--title-file", "/t", "--body-file", "/b"], create)).toBe(EXIT.plainTitle); + expect(create.calls.filter(c => c.cmd === "gh" || c.cmd === "glab")).toEqual([]); + const good = fakeEnv({ root, files: { "/t": "Stop gstack from hitting GitHub's rate limit (GST-1713)", "/b": "Body.\n" } }); + expect(run(["pr-create", "--base", "main", "--title-file", "/t", "--body-file", "/b"], good)).toBe(EXIT.posted); + expect(good.posts()).toHaveLength(1); + const comment = fakeEnv({ root, files: { "/b": "The pregate passed.\n" } }); + expect(run(["pr-comment", "7", "--body-file", "/b"], comment)).toBe(EXIT.posted); + } finally { fs.rmSync(root, { recursive: true, force: true }); } + }); + + // Value: protects=a repository without the policy file is never checked, and an invalid policy refuses rather than posting unchecked; + // fails_when=the missing file refuses every title or a broken file is ignored; why_new=R76 adds the check; seam=a real file; no_claim=real gh + test("no policy file posts unchecked; an invalid policy file refuses with exit 4 and posts nothing", () => { + const none = repoWith(null); + const broken = repoWith("{ not json"); + try { + const f = fakeEnv({ root: none, files: { "/t": "Make the pregate quieter" } }); + expect(run(["pr-title", "7", "--title-file", "/t"], f)).toBe(EXIT.posted); + const g = fakeEnv({ root: broken, files: { "/t": "Make the pregate quieter" } }); + expect(run(["pr-title", "7", "--title-file", "/t"], g)).toBe(EXIT.plainTitle); + expect(g.posts()).toEqual([]); + expect(g.stderr()).toContain("(PLAIN_POLICY_INVALID); nothing was posted"); + } finally { fs.rmSync(none, { recursive: true, force: true }); fs.rmSync(broken, { recursive: true, force: true }); } + }); + + test("the help names exit 4", () => { + const f = fakeEnv(); + expect(run(["--help"], f)).toBe(EXIT.posted); + expect(f.stdout()).toContain("4 the title is not plain words"); + }); +}); + diff --git a/test/gstack-ship-receipt.test.ts b/test/gstack-ship-receipt.test.ts index eff3d9175..3f0fe8479 100644 --- a/test/gstack-ship-receipt.test.ts +++ b/test/gstack-ship-receipt.test.ts @@ -9,7 +9,7 @@ import * as fs from 'node:fs'; import * as os from 'node:os'; import * as path from 'node:path'; import { EXIT } from '../lib/headless-artifacts'; -import { bindReceipt, classifyCarryPath, computeCensus, extractReceipt, parseReviewLoop, placeReceiptFirst, planCarry, receiptSummaryLine, renderReceiptBlock, stampOnlyText } from '../lib/ship-receipt'; +import { bindReceipt, classifyCarryPath, computeCensus, extractReceipt, parseReviewLoop, placeReceipt, planCarry, receiptSummaryLine, renderReceiptBlock, stampOnlyText } from '../lib/ship-receipt'; import { cleanup, ghShim, makeFixtureRepo, makePr, recordAndBundle, restamp, runBin, type FixtureRepo } from './helpers/restamp-fixture'; const repos: FixtureRepo[] = []; @@ -20,7 +20,7 @@ const HEAD = 'a'.repeat(40); const block = (receipt: Record<string, unknown>) => renderReceiptBlock(receipt as any); describe('gstack-ship-receipt write', () => { - test('writes the block identifiers-first, validates it against the ship-receipt schema, and puts it at the top of the PR body', () => { + test('writes the block identifiers-first, validates it against the ship-receipt schema, and puts it in the Agent notes section at the end of the PR body', () => { const repo = fixture(); makePr(repo, 'pr-a', { expects: '1.2.4.0' }); expect(restamp(repo, ['--version', '1.2.4.0']).status).toBe(EXIT.ok); @@ -54,8 +54,8 @@ describe('gstack-ship-receipt write', () => { expect(parsed.receipt!.product_patch_id).toMatch(/^([0-9a-f]{40}|none)$/); expect(parsed.receipt!.policy).toMatch(/^origin\/main@[0-9a-f]{12}:\.gstack\/ship-policy\.json$/); const written = fs.readFileSync(body, 'utf8'); - expect(written.startsWith('```gstack-ship-receipt\n{\n "schema_version": 1,\n "pr": 42,\n "head": "' + head + '"')).toBe(true); - expect(written).toContain('\n```\n\n## Summary\n\nProse that a truncated message may lose.\n'); + expect(written.startsWith('## Summary\n\nProse that a truncated message may lose.\n\n<!-- gstack:agent-notes -->\n<details><summary>Agent notes</summary>\n\n```gstack-ship-receipt\n{\n "schema_version": 1,\n "pr": 42,\n "head": "' + head + '"')).toBe(true); + expect(written.endsWith('\n```\n\n</details>\n')).toBe(true); expect(fs.readFileSync(path.join(repo.root, 'receipt.md'), 'utf8')).toBe(ok.out); const art = runBin(repo, 'gstack-artifact', ['validate', path.join(repo.root, 'receipt.json'), '--as', 'ship-receipt']); expect(art.status).not.toBe(EXIT.ok); @@ -94,7 +94,7 @@ describe('gstack-ship-receipt read', () => { const repo = fixture(); const receipt = { schema_version: 1, pr: 5, head: HEAD, version: '1.2.4.0', base: 'main', session_kind: 'unattended', artifacts_consumed: '1/1', run: 'run-5', thread: 'jam_5' }; const body = path.join(repo.root, 'body.md'); - fs.writeFileSync(body, placeReceiptFirst('Prose.\n', receipt as any)); + fs.writeFileSync(body, placeReceipt('Prose.\n', receipt as any)); const b = runBin(repo, 'gstack-ship-receipt', ['read', '--body', body, '--json']); expect(b.status).toBe(EXIT.ok); expect(JSON.parse(b.out)).toEqual(receipt); @@ -156,11 +156,11 @@ describe('gstack-ship-receipt result (/ship terminal line, GBRA-108)', () => { const head = repo.git('rev-parse', 'HEAD'); const state = path.join(repo.root, 'state'); const env = { ...ghShim(repo, { - 5: { headRefOid: head, body: placeReceiptFirst('Prose.\n', { schema_version: 1, pr: 5, head, base: 'main', queue_mode: 'stamp-at-merge' } as any) }, - 6: { headRefOid: head, body: placeReceiptFirst('Prose.\n', { schema_version: 1, pr: 6, head: 'b'.repeat(40), base: 'main' } as any) }, + 5: { headRefOid: head, body: placeReceipt('Prose.\n', { schema_version: 1, pr: 5, head, base: 'main', queue_mode: 'stamp-at-merge' } as any) }, + 6: { headRefOid: head, body: placeReceipt('Prose.\n', { schema_version: 1, pr: 6, head: 'b'.repeat(40), base: 'main' } as any) }, 7: { headRefOid: head, body: 'No receipt here.\n' }, - 9: { headRefOid: 'd'.repeat(40), body: placeReceiptFirst('Prose.\n', { schema_version: 1, pr: 9, head, base: 'main' } as any) }, - 10: { body: placeReceiptFirst('Prose.\n', { schema_version: 1, pr: 10, head, base: 'main' } as any) }, + 9: { headRefOid: 'd'.repeat(40), body: placeReceipt('Prose.\n', { schema_version: 1, pr: 9, head, base: 'main' } as any) }, + 10: { body: placeReceipt('Prose.\n', { schema_version: 1, pr: 10, head, base: 'main' } as any) }, }), GSTACK_STATE_ROOT: state, GSTACK_SESSION_KIND: 'unattended' }; const ok = runBin(repo, 'gstack-ship-receipt', ['result', '--pr', '5'], env); expect(ok.status).toBe(EXIT.ok); @@ -245,7 +245,7 @@ describe('gstack-ship-receipt result (/ship terminal line, GBRA-108)', () => { const invalid = runBin(repo, 'gstack-ship-receipt', ['result', '--body', invalidFile], env); expect([invalid.status, invalid.out.trim()]).toEqual([EXIT.refused, 'GSTACK_RESULT: skill=ship status=refused run=-']); expect(invalid.err).toMatch(/the receipt's ship receipt is invalid \(.*\).*\(QUEUE_RECEIPT_MISSING/); - const otherPrEnv = { ...env, ...ghShim(repo, { 8: { headRefOid: head, body: placeReceiptFirst('Prose.\n', { schema_version: 1, pr: 99, head, base: 'main' } as any) } }) }; + const otherPrEnv = { ...env, ...ghShim(repo, { 8: { headRefOid: head, body: placeReceipt('Prose.\n', { schema_version: 1, pr: 99, head, base: 'main' } as any) } }) }; const otherPr = runBin(repo, 'gstack-ship-receipt', ['result', '--pr', '8'], otherPrEnv); expect([otherPr.status, otherPr.out.trim()]).toEqual([EXIT.refused, 'GSTACK_RESULT: skill=ship status=refused run=-']); expect(otherPr.err).toContain("PR #8's ship receipt names pr=99, not this PR"); @@ -303,7 +303,7 @@ describe('gstack-ship-receipt carry (harness lane E row 3: receipt follows a hea const head = repo.git('rev-parse', 'HEAD'); const state = path.join(repo.root, 'state'); const body = path.join(repo.root, 'body.md'); - fs.writeFileSync(body, placeReceiptFirst('## Summary\n\nProse.\n', { schema_version: 1, pr: 5, head: reviewed, base: 'main', queue_mode: 'stamp-at-merge', review_loop: { passes: 2, blocking_found: 1, deferred: 1 } } as any)); + fs.writeFileSync(body, placeReceipt('## Summary\n\nProse.\n', { schema_version: 1, pr: 5, head: reviewed, base: 'main', queue_mode: 'stamp-at-merge', review_loop: { passes: 2, blocking_found: 1, deferred: 1 } } as any)); const env = { GSTACK_STATE_ROOT: state, GSTACK_SESSION_KIND: 'unattended' }; const dry = runBin(repo, 'gstack-ship-receipt', ['carry', '--body', body, '--dry-run'], env); expect(dry.status).toBe(EXIT.ok); @@ -323,7 +323,7 @@ describe('gstack-ship-receipt carry (harness lane E row 3: receipt follows a hea expect(after.receipt!.carry!.product_patch_id).toMatch(/^[0-9a-f]{40}$/); expect(after.receipt!.carry!.changed_paths.sort()).toEqual(['product:src/other.ts', 'test:test/app.test.ts']); expect(after.receipt!.carry!.range_diff).not.toMatch(/^unavailable/); - expect(fs.readFileSync(body, 'utf8')).toContain('\n## Summary\n\nProse.\n'); + expect(fs.readFileSync(body, 'utf8')).toStartWith('## Summary\n\nProse.\n\n<!-- gstack:agent-notes -->\n'); const run = JSON.parse(fs.readFileSync(path.join(repo.root, 'carry-run', 'ship-run.json'), 'utf8')); expect(run).toMatchObject({ skill: 'ship', status: 'complete', head, pr: 5, carried_from: reviewed, carry_reason: 'base merge + test-only change' }); expect(bindReceipt(fs.readFileSync(body, 'utf8'), '5', [head]).ok).toBe(true); @@ -351,7 +351,7 @@ describe('gstack-ship-receipt carry (harness lane E row 3: receipt follows a hea const reviewed = repo.git('rev-parse', 'HEAD'); const env = { GSTACK_STATE_ROOT: path.join(repo.root, 'state') }; const foreign = path.join(repo.root, 'foreign.md'); - fs.writeFileSync(foreign, placeReceiptFirst('x\n', { schema_version: 1, pr: 5, head: 'c'.repeat(40), base: 'main' } as any)); + fs.writeFileSync(foreign, placeReceipt('x\n', { schema_version: 1, pr: 5, head: 'c'.repeat(40), base: 'main' } as any)); repo.git('commit', '-q', '--allow-empty', '-m', 'empty follow-up'); const notAncestor = runBin(repo, 'gstack-ship-receipt', ['carry', '--body', foreign], env); expect(notAncestor.status).toBe(EXIT.refused); @@ -367,7 +367,7 @@ describe('gstack-ship-receipt carry (harness lane E row 3: receipt follows a hea const shimDir = path.join(repo.root, 'gh2'); fs.mkdirSync(shimDir); const posted = path.join(shimDir, 'posted.md'); - const prBody = placeReceiptFirst('## Summary\n\nProse.\n', { schema_version: 1, pr: 8, head: reviewed, base: 'main' } as any); + const prBody = placeReceipt('## Summary\n\nProse.\n', { schema_version: 1, pr: 8, head: reviewed, base: 'main' } as any); fs.writeFileSync(path.join(shimDir, 'pr.json'), JSON.stringify({ body: prBody, headRefOid: head })); fs.writeFileSync(path.join(shimDir, 'gh'), [ '#!/bin/sh', @@ -462,7 +462,7 @@ describe('gstack-ship-receipt carry (harness lane E row 3: receipt follows a hea repo.git('commit', '-qam', 'stamp 1.2.4.0'); const head = repo.git('rev-parse', 'HEAD'); const body = path.join(repo.root, 'body.md'); - fs.writeFileSync(body, placeReceiptFirst('## Summary\n\nProse.\n', { schema_version: 1, pr: 6, head: reviewed, base: 'main', version: '1.2.3.0' } as any)); + fs.writeFileSync(body, placeReceipt('## Summary\n\nProse.\n', { schema_version: 1, pr: 6, head: reviewed, base: 'main', version: '1.2.3.0' } as any)); const env = { GSTACK_STATE_ROOT: path.join(repo.root, 'state'), GSTACK_SESSION_KIND: 'unattended' }; const carried = runBin(repo, 'gstack-ship-receipt', ['carry', '--body', body, '--out', path.join(repo.root, 'carry-run'), '--json'], env); expect(carried.status).toBe(EXIT.ok); @@ -544,7 +544,7 @@ describe('gstack-ship-receipt carry (harness lane E row 3: receipt follows a hea for (const [rel, text] of Object.entries(extra)) put(rel, text); repo.git('add', '-A'); repo.git('commit', '-qm', 'merge main; entry into a fragment'); const body = path.join(repo.root, 'body.md'); - fs.writeFileSync(body, placeReceiptFirst('## Summary\n\nProse.\n', { schema_version: 1, pr: 6, head: reviewed, base: 'main', version: '1.2.3.0' } as any)); + fs.writeFileSync(body, placeReceipt('## Summary\n\nProse.\n', { schema_version: 1, pr: 6, head: reviewed, base: 'main', version: '1.2.3.0' } as any)); const env = { GSTACK_STATE_ROOT: path.join(repo.root, 'state'), GSTACK_SESSION_KIND: 'unattended' }; return { reviewed, head: repo.git('rev-parse', 'HEAD'), out: runBin(repo, 'gstack-ship-receipt', ['carry', '--body', body, '--dry-run'], env) }; }; @@ -575,7 +575,7 @@ describe('gstack-ship-receipt carry (harness lane E row 3: receipt follows a hea repo.git('commit', '-qam', 'reviewed'); const reviewed = repo.git('rev-parse', 'HEAD'); const body = path.join(repo.root, 'body.md'); - fs.writeFileSync(body, placeReceiptFirst('## Summary\n\nProse.\n', { schema_version: 1, pr: 6, head: reviewed, base: 'main' } as any)); + fs.writeFileSync(body, placeReceipt('## Summary\n\nProse.\n', { schema_version: 1, pr: 6, head: reviewed, base: 'main' } as any)); const env = { GSTACK_STATE_ROOT: path.join(repo.root, 'state'), GSTACK_SESSION_KIND: 'unattended' }; // a carry-able commit (a test) with a pending product edit beside it: the pending edit is not in what the carry compares fs.mkdirSync(path.join(repo.work, 'test'), { recursive: true }); @@ -615,7 +615,7 @@ describe('gstack-ship-receipt carry (harness lane E row 3: receipt follows a hea repo.git('checkout', '-q', 'feat'); repo.git('merge', '-q', '--no-edit', 'main'); const b = repo.git('rev-parse', 'HEAD'); const body = path.join(repo.root, 'body.md'); - fs.writeFileSync(body, placeReceiptFirst('## Summary\n\nProse.\n', { schema_version: 1, pr: 6, head: a, base: 'main' } as any)); + fs.writeFileSync(body, placeReceipt('## Summary\n\nProse.\n', { schema_version: 1, pr: 6, head: a, base: 'main' } as any)); const env = { GSTACK_STATE_ROOT: path.join(repo.root, 'state'), GSTACK_SESSION_KIND: 'unattended' }; expect(runBin(repo, 'gstack-ship-receipt', ['carry', '--body', body], env).status).toBe(EXIT.ok); fs.mkdirSync(path.join(repo.work, 'test'), { recursive: true }); @@ -655,7 +655,7 @@ describe('gstack-ship-receipt carry (harness lane E row 3: receipt follows a hea const posted = path.join(shimDir, 'posted.md'); const prJson = path.join(shimDir, 'pr.json'); const editRc = path.join(shimDir, 'edit-rc'); - const prBody = placeReceiptFirst('## Summary\n\nProse.\n', { schema_version: 1, pr: 8, head: reviewed, base: 'main' } as any); + const prBody = placeReceipt('## Summary\n\nProse.\n', { schema_version: 1, pr: 8, head: reviewed, base: 'main' } as any); fs.writeFileSync(path.join(shimDir, 'gh'), [ '#!/bin/sh', `if [ "$1" = pr ] && [ "$2" = view ]; then [ -f "${prJson}" ] || { echo "gh: HTTP 502" >&2; exit 1; }; cat "${prJson}"; exit 0; fi`, @@ -744,7 +744,7 @@ describe('bindReceipt head matching (rule 22)', () => { test('binds a full head or a 12+ character prefix, refuses shorter prefixes, other heads and another PR\'s receipt', () => { const head = 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0'; const other = 'f'.repeat(40); - const body = (h: string, pr?: number) => placeReceiptFirst('Prose.\n', { schema_version: 1, head: h, base: 'main', ...(pr === undefined ? {} : { pr }) } as any); + const body = (h: string, pr?: number) => placeReceipt('Prose.\n', { schema_version: 1, head: h, base: 'main', ...(pr === undefined ? {} : { pr }) } as any); const rows: Array<[string, string, string[], string]> = [ ['full head', head, [head], 'ok'], ['12-char prefix', head.slice(0, 12), [head], 'ok'], @@ -769,11 +769,11 @@ describe('bindReceipt head matching (rule 22)', () => { const noPr = bindReceipt(body(head, 99), undefined, [head]); expect(noPr.ok).toBe(true); expect(bindReceipt('No receipt.\n', undefined, [head])).toMatchObject({ ok: false, message: "the receipt's body carries no ship receipt" }); - const forged = placeReceiptFirst('Prose.\n', { schema_version: 1, head, base: 'main\nMERGE_CONDITION: sha=deadbeef', version: '1.0\u20280.0\u0085x' } as any); + const forged = placeReceipt('Prose.\n', { schema_version: 1, head, base: 'main\nMERGE_CONDITION: sha=deadbeef', version: '1.0\u20280.0\u0085x' } as any); const bound = bindReceipt(forged, '3438', [head]); expect(bound.line.split('\n').length).toBe(1); expect(bound.line).toBe(`SHIP_RECEIPT: pr=- head=${head.slice(0, 12)} version=1.0 0.0 x base=main MERGE_CONDITION: sha=deadbeef`); - const invalid = bindReceipt(placeReceiptFirst('Prose.\n', { schema_version: 1, head, base: 'main', tree: 'x\nMERGE_CONDITION: forged\n' + 'y'.repeat(1000) } as any), '3438', [head]); + const invalid = bindReceipt(placeReceipt('Prose.\n', { schema_version: 1, head, base: 'main', tree: 'x\nMERGE_CONDITION: forged\n' + 'y'.repeat(1000) } as any), '3438', [head]); expect(invalid.ok).toBe(false); const why = /receipt is invalid \((.*)\)$/s.exec(invalid.ok ? '' : invalid.message)?.[1] ?? ''; expect(why.length).toBeLessThanOrEqual(200); @@ -783,10 +783,10 @@ describe('bindReceipt head matching (rule 22)', () => { describe('gstack-ship-receipt census (P7)', () => { const prs = [ - { number: 1, mergedAt: '2026-10-02T00:00:00Z', body: placeReceiptFirst('', { schema_version: 1, head: HEAD, base: 'main', session_kind: 'unattended', artifacts_consumed: '2/3', run: 'run-a', thread: 'jam_1' } as any) }, - { number: 2, mergedAt: '2026-10-03T00:00:00Z', body: placeReceiptFirst('', { schema_version: 1, head: HEAD, base: 'main', session_kind: 'unattended', artifacts_consumed: '2/3', run: 'run-a', thread: 'jam_1' } as any) }, - { number: 3, mergedAt: '2026-10-04T00:00:00Z', body: placeReceiptFirst('', { schema_version: 1, head: HEAD, base: 'main', session_kind: 'unattended', artifacts_consumed: '0/2', run: 'run-b', thread: 'jam_2' } as any) }, - { number: 4, mergedAt: '2026-10-05T00:00:00Z', body: placeReceiptFirst('', { schema_version: 1, head: HEAD, base: 'main', session_kind: 'interactive', artifacts_consumed: '1/1', run: 'run-c', thread: 'jam_4' } as any) }, + { number: 1, mergedAt: '2026-10-02T00:00:00Z', body: placeReceipt('', { schema_version: 1, head: HEAD, base: 'main', session_kind: 'unattended', artifacts_consumed: '2/3', run: 'run-a', thread: 'jam_1' } as any) }, + { number: 2, mergedAt: '2026-10-03T00:00:00Z', body: placeReceipt('', { schema_version: 1, head: HEAD, base: 'main', session_kind: 'unattended', artifacts_consumed: '2/3', run: 'run-a', thread: 'jam_1' } as any) }, + { number: 3, mergedAt: '2026-10-04T00:00:00Z', body: placeReceipt('', { schema_version: 1, head: HEAD, base: 'main', session_kind: 'unattended', artifacts_consumed: '0/2', run: 'run-b', thread: 'jam_2' } as any) }, + { number: 4, mergedAt: '2026-10-05T00:00:00Z', body: placeReceipt('', { schema_version: 1, head: HEAD, base: 'main', session_kind: 'interactive', artifacts_consumed: '1/1', run: 'run-c', thread: 'jam_4' } as any) }, { number: 5, mergedAt: '2026-10-06T00:00:00Z', body: 'A PR shipped without gstack.\n' }, { number: 6, mergedAt: '2026-10-07T00:00:00Z', body: block({ schema_version: 1, base: 'main' }) }, ]; diff --git a/test/gstack-skill-start.test.ts b/test/gstack-skill-start.test.ts index 2cc14c5a6..79e072629 100644 --- a/test/gstack-skill-start.test.ts +++ b/test/gstack-skill-start.test.ts @@ -433,7 +433,7 @@ describe('gstack-skill-start behavior', () => { }, 60_000); test('unattended /ship alone prints the UNATTENDED_SHIP status line naming the receipt file, the existing-PR body update and the result line (GSTA-88, rule 22)', () => { - const line = /^UNATTENDED_SHIP: no push, no new PR; an open PR at HEAD gets its body updated \(pr-body section, receipt first\), else Step 18 takes the handoff section; receipt at \.gstack\/tmp\/ship-receipt\.md; end with gstack-ship-receipt result$/gm; + const line = /^UNATTENDED_SHIP: no push, no new PR; an open PR at HEAD gets its body updated \(pr-body section, receipt in Agent notes\), else Step 18 takes the handoff section; receipt at \.gstack\/tmp\/ship-receipt\.md; end with gstack-ship-receipt result$/gm; const ship = runStart(['--skill', 'ship'], { GSTACK_SESSION_KIND: 'unattended' }); expect(ship.match(line)).toHaveLength(1); expect((ship.match(/^GSTACK_INSTRUCTION_BEGIN: (\S+)/gm) ?? []).map((h) => h.replace(/^GSTACK_INSTRUCTION_BEGIN: /, ''))).toEqual(['unattended-session']); diff --git a/test/helpers/doctor-fixture.ts b/test/helpers/doctor-fixture.ts index 70ed825ef..f8f49c1a2 100644 --- a/test/helpers/doctor-fixture.ts +++ b/test/helpers/doctor-fixture.ts @@ -23,7 +23,7 @@ const COPIED = [ 'autoplan/SKILL.md', // its frontmatter registers the autoplan hook the hooks-row test breaks 'bin/gstack-launch-probe.sh', 'bin/gstack-doctor-check.sh', 'bin/gstack-doctor-components.sh', 'bin/gstack-runtime-pins.ts', 'lib/runtime-pins.ts', - 'bin/gstack-windows-bash.ts', 'lib/script-invocation.ts', 'lib/bun-isolation.ts', 'lib/result-codes.ts', 'lib/result-codes-release.ts', 'lib/result-codes-capy.ts', 'lib/result-codes-budget.ts', 'lib/result-codes-eval.ts', 'lib/result-codes-plan.ts', 'lib/result-codes-autoplan.ts', 'lib/result-codes-browse.ts', 'lib/result-codes-clean.ts', // the windows bash row + 'bin/gstack-windows-bash.ts', 'lib/script-invocation.ts', 'lib/bun-isolation.ts', 'lib/result-codes.ts', 'lib/result-codes-release.ts', 'lib/result-codes-capy.ts', 'lib/result-codes-budget.ts', 'lib/result-codes-eval.ts', 'lib/result-codes-plan.ts', 'lib/result-codes-autoplan.ts', 'lib/result-codes-browse.ts', 'lib/result-codes-clean.ts', 'lib/result-codes-plain.ts', // the windows bash row ]; const bases: string[] = []; /** Registers the per-test fixture cleanup; each test file calls it at top level (a hook in this module would bind to the first importing file only). */ diff --git a/test/helpers/ship-queue-train-fixture.ts b/test/helpers/ship-queue-train-fixture.ts index 1ec394990..880c7e167 100644 --- a/test/helpers/ship-queue-train-fixture.ts +++ b/test/helpers/ship-queue-train-fixture.ts @@ -106,7 +106,7 @@ export function openPr(fx: TrainFixture, pr: string, opts: { file?: string; text repo.git('commit', '-q', '-m', `feat #${pr}`); const head = repo.git('rev-parse', 'HEAD'); repo.git('push', '-q', '-f', 'origin', `HEAD:refs/pull/${pr}/head`, `${mergeRefCommit(fx, head)}:refs/pull/${pr}/merge`); - fx.write(d => { d.prs[pr] = { headRefOid: head, isCrossRepository: !!opts.cross, state: 'OPEN', headRefName: `pr-${pr}`, url: `https://github.com/fixture/repo/pull/${pr}`, body: opts.shipped === false ? '' : shippedBody(pr, head) }; }); + fx.write(d => { d.prs[pr] = { headRefOid: head, isCrossRepository: !!opts.cross, state: 'OPEN', headRefName: `pr-${pr}`, title: `Change from #${pr}`, url: `https://github.com/fixture/repo/pull/${pr}`, body: opts.shipped === false ? '' : shippedBody(pr, head) }; }); return head; } @@ -160,7 +160,7 @@ export function wrapLand(fx: TrainFixture): void { "const rev=r=>{try{return g('rev-parse','--verify','--quiet',r)}catch{return ''}};", "if(a[0]==='pr'&&a[1]==='view'&&d.prs[a[2]]){const p=d.prs[a[2]];const h=rev('refs/heads/'+p.headRefName);if(h)p.headRefOid=h;save();console.log(JSON.stringify(p));process.exit(0)}", "if(a[0]==='pr'&&a[1]==='edit'){(d.labels=d.labels||{})[a[2]]=a[a.indexOf('--add-label')+1];save();process.exit(0)}", - "if(a[0]==='pr'&&a[1]==='merge'){const p=d.prs[a[2]];const want=a[a.indexOf('--match-head-commit')+1];if(rev('refs/heads/'+p.headRefName)!==want){console.error('Head branch was modified');process.exit(1)}", + "if(a[0]==='pr'&&a[1]==='merge'){const p=d.prs[a[2]];const want=a[a.indexOf('--match-head-commit')+1];if(rev('refs/heads/'+p.headRefName)!==want){console.error('Head branch was modified');process.exit(1)}(d.mergeArgs=d.mergeArgs||{})[a[2]]=a.slice(3);", " const before=rev('main');let tree=rev(want+'^{tree}');", " if(d.outsideOnMerge){const blob=cp.execFileSync('git',['--git-dir',d.origin,'hash-object','-w','--stdin'],{input:'outside\\n',encoding:'utf8',timeout:30000}).trim();", " const ls=g('ls-tree',before+'^{tree}')+'\\n100644 blob '+blob+'\\toutside.txt';const ot=cp.execFileSync('git',['--git-dir',d.origin,'mktree'],{input:ls+'\\n',encoding:'utf8',timeout:30000}).trim();", diff --git a/test/plain-title-workflow-safety.test.ts b/test/plain-title-workflow-safety.test.ts new file mode 100644 index 000000000..94cc0aa37 --- /dev/null +++ b/test/plain-title-workflow-safety.test.ts @@ -0,0 +1,96 @@ +/** + * plain-title.yml runs on pull_request_target, so it is held to the same static + * tripwire as pr-title-sync.yml (test/pr-title-sync-workflow-safety.test.ts): + * the job checks out the base repo only (no ref:), every PR field reaches the + * shell through env:, never as an inlined ${{ github.event.pull_request.* }} + * inside run:, the token is read-only, and every action is pinned to a commit. + * The job name "Plain title" is what ship-policy's required_checks will name. + */ +import { describe, expect, test } from 'bun:test'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; + +const WORKFLOW = path.resolve(import.meta.dir, '..', '.github/workflows/plain-title.yml'); +const content = fs.readFileSync(WORKFLOW, 'utf8'); + +const indent = (line: string) => line.match(/^( *)/)![1]!.length; + +/** The lines inside every run: block (inline or block scalar), with 1-based line numbers. */ +function runBlockLines(text: string): Array<{ n: number; text: string }> { + const out: Array<{ n: number; text: string }> = []; + let runIndent = -1; + text.split('\n').forEach((line, i) => { + const inline = line.match(/^\s*(?:-\s*)?run:\s*(\S.*)$/); + if (runIndent >= 0 && line.trim() !== '' && indent(line) <= runIndent) runIndent = -1; + if (runIndent >= 0) { out.push({ n: i + 1, text: line }); return; } + if (inline && !/^[|>]/.test(inline[1]!)) { out.push({ n: i + 1, text: inline[1]! }); return; } + if (/^\s*(?:-\s*)?run:\s*[|>][+-]?\s*$/.test(line)) runIndent = indent(line); + }); + return out; +} + +describe('plain-title.yml pull_request_target safety', () => { + test('runs on pull_request_target for the PR events that change a title, body or head', () => { + expect(content).toMatch(/^on:\s*\n\s+pull_request_target:/m); + expect(content).toMatch(/types:\s*\[opened, edited, synchronize, reopened, ready_for_review\]/); + }); + + test('the job is named "Plain title" and cancels older runs for the same PR', () => { + expect(content).toMatch(/^\s+name: Plain title\s*$/m); + expect(content).toMatch(/group: plain-title-\$\{\{ github\.event\.pull_request\.number \}\}/); + expect(content).toMatch(/cancel-in-progress: true/); + }); + + test('checks out the base repo only: no ref: anywhere', () => { + expect(content.split('\n').filter(l => /^\s*ref:/.test(l))).toEqual([]); + expect(content).toMatch(/uses: actions\/checkout@/); + }); + + test('no ${{ github.event.pull_request.* }} inside any run: block', () => { + const lines = runBlockLines(content); + expect(lines.length).toBeGreaterThan(3); + expect(lines.filter(l => /\$\{\{\s*github\.event\.pull_request/.test(l.text)).map(l => `L${l.n}: ${l.text.trim()}`)).toEqual([]); + }); + + test('the tripwire itself catches an inlined PR field', () => { + const planted = 'steps:\n - name: x\n run: |\n echo "${{ github.event.pull_request.title }}"\n - run: echo ${{ github.event.pull_request.body }}\n'; + expect(runBlockLines(planted).filter(l => /\$\{\{\s*github\.event\.pull_request/.test(l.text)).length).toBe(2); + }); + + test('PR text reaches files through env: and printf, and is never echoed', () => { + expect(content).toMatch(/PR_TITLE: \$\{\{ github\.event\.pull_request\.title \}\}/); + expect(content).toMatch(/PR_BODY: \$\{\{ github\.event\.pull_request\.body \}\}/); + expect(content).toMatch(/printf '%s' "\$PR_TITLE" >/); + expect(content).toMatch(/printf '%s' "\$PR_BODY" >/); + const run = runBlockLines(content).map(l => l.text).join('\n'); + expect(run).not.toMatch(/\becho\b[^\n]*\$\{?PR_/); + expect(run).not.toMatch(/\bcat\b/); + }); + + test('permissions are read-only', () => { + const perms = content.match(/^permissions:\n((?:\s+.+\n)+)/m)?.[1] ?? ''; + expect(perms).toMatch(/contents: read/); + expect(perms).toMatch(/pull-requests: read/); + expect(content).not.toMatch(/:\s*write\b/); + }); + + test('the title is refused against created_at; branch, opening and commits only warn', () => { + expect(content).toMatch(/--title-file "\$RUNNER_TEMP\/pr-title\.txt" --created-at "\$PR_CREATED_AT" --mode refuse/); + expect(content).toMatch(/--commits-file "\$RUNNER_TEMP\/pr-commits\.txt" --created-at "\$PR_CREATED_AT" --mode warn/); + }); + + test('every action runs at a reviewed commit SHA, not a movable tag', () => { + const uses = [...content.matchAll(/^\s*(?:-\s*)?uses:\s*(\S+)/gm)].map(m => m[1]!); + expect(uses.length).toBeGreaterThan(0); + for (const ref of uses) expect(ref).toMatch(/^[\w.-]+\/[\w.-]+@[0-9a-f]{40}$/); + }); + + // Value: protects=a base-only edit reruns the title check and never posts a skipped "Plain title" run that ciAtHead and + // branch protection count as passing over an earlier refusal; fails_when=a job-level if: can skip the whole job; + // why_new=review finding plain-title.yml:34 (fail-open); seam=the workflow text; no_claim=GitHub's check ranking + test('the job has no job-level if:, so a skipped run never replaces a refused title', () => { + const job = content.split(/^ plain-title:\s*$/m)[1]!.split(/^ steps:\s*$/m)[0]!; + expect(job).toMatch(/name: Plain title/); + expect(job).not.toMatch(/^ if:/m); + }); +}); diff --git a/test/plain-title.test.ts b/test/plain-title.test.ts new file mode 100644 index 000000000..0b8bdd381 --- /dev/null +++ b/test/plain-title.test.ts @@ -0,0 +1,292 @@ +/** + * lib/plain-title.ts and bin/gstack-plain-title (R76): titles, branch names, PR + * openings, changelog entries and commit subjects read in everyday words. The + * fixtures are real squash subjects from main for 2026-10-31..2026-11-07, frozen + * here without GitHub's " (#n)" squash suffix, since a PR title never carries it. + */ +import { describe, expect, test } from 'bun:test'; +import { spawnSync } from 'node:child_process'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { bunIsolationArgs } from '../lib/bun-isolation'; +import { PLAIN_TITLE_POLICY_PATH, checkBranch, checkChangelogEntry, checkCommitSubject, checkOpening, checkTitle, loadPlainTitlePolicy, splitTitle, type PlainFinding } from '../lib/plain-title'; + +const ROOT = path.resolve(import.meta.dir, '..'); +const BIN = path.join(ROOT, 'bin/gstack-plain-title'); +const policy = loadPlainTitlePolicy(ROOT)!; +const brief = (f: PlainFinding[]) => f.map(x => `${x.kind}:${x.match}`); + +const MAIN_SUBJECTS: Array<[string, string[]]> = [ + ['Let the design lint check one section of a running app for crowded rows, cramped help text, loud notes and small tap targets', []], + ['Outside reviews that mention a problem no longer pass as clean', []], + ['fix(autoplan): bind rec_hash to its run and plan; keep legacy gates answerable (GST-1819)', ['scope-prefix:fix(autoplan):']], + ['fix: /autoplan gate makes each phase publish in order after its report (GST-1814)', []], + ['fix(outside-review): a check-results sign-off keeps the no-findings conclusion (#3469)', ['scope-prefix:fix(outside-review):']], + ['fix(ship): record-ci reuses its own CI row at the same tree, run and selection (GST-1795)', ['scope-prefix:fix(ship):']], + ['v1.98.0.0 release: fold the changelog fragments', []], + ['ship tooling: post-review voices dispatch, same-tree pregate clears, fix-check p<N> ledgers (GST-1775)', ['term:voices', 'term:pregate', 'term:fix-check', 'term:ledgers']], + ["feat: crew PRs carry their brief's Plan line; ship-queue ready and land check it (GST-1751)", []], + ['Require the daemon secret and loopback Host/Origin on the design and iOS QA daemons', []], + ["browse: keep tunnel-paired agents on web URLs and away from the daemon's disk", []], + ['feat(diff-scope): template UI in .ts/.js counts as frontend; receipt records the static design pass (GST-1718)', ['scope-prefix:feat(diff-scope):', 'term:receipt']], + ["fix: gstack's GitHub waits poll REST at most once a minute and wait out rate limits (GST-1713)", []], + ['fix(ship-receipt): name the non-ancestor targeted-read commit in RECEIPT_INVALID (GST-1700)', ['scope-prefix:fix(ship-receipt):', 'term:receipt']], + ['fix: release stamps and a docs-audit edit after the last fix-check are no gate-class edit (GST-1702)', ['term:fix-check']], + ["fix(ship): a cleared remote gate for HEAD's exact tree satisfies a lane whose inventory refuses that tree (GST-1688)", ['scope-prefix:fix(ship):', 'term:lane']], + ['fix: the receipt and the review plan agree on stamp and docs carry for review_targeted (GST-1689)', ['term:receipt']], + ['Capy session: defer the upgrade while a /ship or review run is open; upgrade-path fixes (GST-1677, GST-1658)', []], + ['Playwright 1.65.0: bundled Chromium 158 drops the distrusted eMudhra root (#3338)', []], + ['changelog.d: a second GST-1631 fragment names what the audit redaction does not cover yet (GST-1635)', ['code:GST-1631']], + ['ship finish chains granted fix-check rows; a pregate rerun at the same tree keeps its clears (GST-1627)', ['term:pregate', 'term:fix-check']], + ['release: daily stable check: important gated changes reach stable within a day, forced cut at 7 days (GST-1646, R74)', []], +]; + +describe('checkTitle', () => { + test.each(MAIN_SUBJECTS)('main subject: %s', (subject, expected) => { + expect(brief(checkTitle(subject, policy))).toEqual(expected); + }); + + test("R76's example passes with and without one trailing issue code", () => { + const good = "Stop gstack from hitting GitHub's rate limit while it waits on PRs"; + expect(checkTitle(good, policy)).toEqual([]); + expect(checkTitle(`${good} (GST-1713)`, policy)).toEqual([]); + expect(checkTitle(`${good} (GST-1808, GST-1809)`, policy)).toEqual([]); + expect(checkTitle(`${good} (#4122)`, policy)).toEqual([]); + }); + + test('a code in the front is refused, wherever it sits', () => { + const f = checkTitle("Stop GST-1713 from hitting GitHub's rate limit", policy); + expect(f).toEqual([{ code: 'PLAIN_TITLE_REFUSED', kind: 'code', match: 'GST-1713', hint: expect.any(String) }]); + expect(brief(checkTitle('Apply R76 to titles (GST-1825)', policy))).toEqual(['code:R76']); + expect(brief(checkTitle('GSTA-36 lands PRs alone', policy))).toEqual(['code:GSTA-36']); + }); + + test("GitHub's squash suffix is a second group, so a code before it stays in the front", () => { + expect(brief(checkTitle('Fix the wait loop (GST-1713) (#4109)', policy))).toEqual(['code:GST-1713']); + }); + + test('UTF-8 and Cloudflare R2 are not read as codes', () => { + expect(checkTitle('Store UTF-8 uploads in Cloudflare R2', policy)).toEqual([]); + }); + + test('allow entries suppress only the match they contain', () => { + const p = { ...policy, allow: [...policy.allow, { text: 'merge train', reason: 'test' }] }; + expect(checkTitle('Speed up the merge train', p)).toEqual([]); + expect(brief(checkTitle('Speed up the train', p))).toEqual(['term:train']); + }); + + test('terms match whole words, case-insensitive, multi-word terms included', () => { + expect(brief(checkTitle('Ask the Outside Voice once', policy))).toEqual(['term:Outside Voice']); + expect(checkTitle('Training runs no longer time out', policy)).toEqual([]); + expect(brief(checkTitle('Report to the CAPTAIN', policy))).toEqual(['term:CAPTAIN']); + }); + + test('a scope prefix is refused; a bare type prefix and a version prefix are allowed', () => { + expect(brief(checkTitle('docs(readme): explain setup', policy))).toEqual(['scope-prefix:docs(readme):']); + expect(checkTitle('fix: explain setup', policy)).toEqual([]); + expect(checkTitle('docs: explain setup', policy)).toEqual([]); + expect(checkTitle('v1.98.0.0 release', policy)).toEqual([]); + }); + + test('a credential in a refused title is masked in the finding, so no printed line carries it', () => { + const key = 'ghp_' + 'A1b2C3d4E5f6G7h8I9j0K1l2M3n4O5p6Q7r8'; + const [f] = checkTitle(`fix(${key}): explain setup`, policy); + expect(f?.kind).toBe('scope-prefix'); + expect(f?.match).not.toContain(key); + }); + + test('a credential the redactor cannot mask in place prints as a fixed placeholder, never the raw match', () => { + const header = '-----BEGIN ' + 'PRIVATE KEY-----'; + const [f] = checkTitle(`fix(${header}): explain setup`, policy); + expect(f?.kind).toBe('scope-prefix'); + expect(f?.match).toBe('<REDACTED>'); + }); + + test('the titles gstack generates pass', () => { + expect(checkTitle('v1.98.0.0 release', policy)).toEqual([]); + expect(checkTitle('Add a changelog entry for changes merged by hand next to #4122', policy)).toEqual([]); + expect(checkTitle('Merge 2 related changes together (#4120, #4121)', policy)).toEqual([]); + }); + + test('splitTitle keeps only a trailing group made of references', () => { + expect(splitTitle('Fix waits (GST-1713)')).toEqual({ front: 'Fix waits', refs: 'GST-1713' }); + expect(splitTitle('Fix waits (#4122, R76)')).toEqual({ front: 'Fix waits', refs: '#4122, R76' }); + expect(splitTitle('Fix waits (on macOS)')).toEqual({ front: 'Fix waits (on macOS)', refs: null }); + expect(splitTitle('Fix waits')).toEqual({ front: 'Fix waits', refs: null }); + }); + + test('commit subjects use the title rule under their own code', () => { + expect(checkCommitSubject('fix(ship): pregate', policy).map(f => f.code)).toEqual(['PLAIN_COMMIT_REFUSED', 'PLAIN_COMMIT_REFUSED']); + }); +}); + +describe('checkBranch', () => { + test('one trailing code is fine; a code in front is refused; generated branches are exempt', () => { + expect(checkBranch('capy/github-rate-limit-gst-1713', policy)).toEqual([]); + expect(checkBranch('capy/plain-english-pr-titles-gst-1825', policy)).toEqual([]); + expect(brief(checkBranch('capy/gst-1814-autoplan-gate-order', policy))).toEqual(['code:gst-1814']); + expect(checkBranch('release/1.98.0.0', policy)).toEqual([]); + expect(checkBranch('wave/ab12-r12', policy)).toEqual([]); + expect(checkBranch('gstack-land-repair/4122', policy)).toEqual([]); + }); + + test('hyphens and underscores separate words', () => { + expect(brief(checkBranch('capy/fix-check-loop', policy))).toEqual(['term:fix check']); + expect(brief(checkBranch('capy/faster_pregate', policy))).toEqual(['term:pregate']); + expect(checkBranch('capy/r2-uploads_r76', policy)).toEqual([]); + expect(checkBranch('capy/trainings-page', policy)).toEqual([]); + }); + + test("the exempt prefixes are the ones gstack's own tools generate", () => { + const src = (f: string) => fs.readFileSync(path.join(ROOT, f), 'utf8'); + expect(src('lib/release-stamp.ts')).toContain('`release/${version}`'); + expect(src('lib/ship-queue-wave.ts')).toContain("startsWith('wave/')"); + expect(src('lib/ship-queue-land.ts')).toContain('`gstack-land-repair/${j.pr}`'); + expect(policy.exempt_branch_prefixes).toEqual(['release/', 'wave/', 'gstack-land-repair/']); + }); +}); + +describe('checkOpening', () => { + const NOTES = '<!-- gstack:agent-notes -->\n<details><summary>Agent notes</summary>\n\n```gstack-ship-receipt\n{"pr": 1}\n```\nRefs: GST-1713, pregate receipts\n\n</details>\n'; + + test('two plain sentences pass, and the Agent notes section is never read', () => { + const body = '\n\nWaits on GitHub now poll at most once a minute. This keeps gstack from hitting rate limits.\n\n## What changed\n\nGST-1713 pregate\n\n'; + expect(checkOpening(body, policy)).toEqual([]); + expect(checkOpening(`${body}${NOTES}`, policy)).toEqual([]); + }); + + test('a receipt fence, heading, list, table, HTML or image first is refused', () => { + for (const [first, shape] of [['```gstack-ship-receipt\n{}\n```', 'fenced block'], ['## Summary', 'heading'], ['- one', 'list'], ['| a | b |', 'table'], ['<details>', 'HTML'], ['![shot](x.png)', 'image']]) { + expect(brief(checkOpening(`${first}\n\nPlain words.\n`, policy))).toEqual([`opening-shape:${shape} first`]); + } + expect(brief(checkOpening(NOTES, policy))).toEqual(['opening-shape:no opening paragraph']); + }); + + test('more than two sentences, or a code or term in the opening, is refused', () => { + expect(brief(checkOpening('One thing. Two things. Three things.', policy))).toEqual(['opening-shape:3 sentences']); + expect(checkOpening('This changes the setup, e.g. the default. It is plain.', policy)).toEqual([]); + expect(brief(checkOpening('The pregate clears faster (GST-1713).', policy))).toEqual(['code:GST-1713', 'term:pregate']); + }); + + test('a marker quoted inline in the opening does not end it; only the section marker on its own line does', () => { + const opening = 'Readers find the `<!-- gstack:agent-notes -->` section at the end. It holds the checks. It stays collapsed.\n\n'; + expect(brief(checkOpening(opening + NOTES, policy))).toEqual(['opening-shape:3 sentences']); + }); +}); + +describe('checkChangelogEntry', () => { + test('no code anywhere; terms only in the bold lead; code spans, URLs and quotes skipped', () => { + expect(brief(checkChangelogEntry('**The plan check works on public repos (GST-1774).** It reads the record.', policy))).toEqual(['code:GST-1774']); + expect(checkChangelogEntry('**The plan check works on public repos.** The receipt and the train now agree.', policy)).toEqual([]); + expect(brief(checkChangelogEntry('**A faster pregate.** Plain words.', policy))).toEqual(['term:pregate']); + expect(brief(checkChangelogEntry('**Plain lead.** Fixed in GST-12.', policy))).toEqual(['code:GST-12']); + expect(checkChangelogEntry('**Plain lead "pregate".** See `GST-12` and https://example.com/GST-12.\n\n```\nGST-13\n```\n', policy)).toEqual([]); + }); +}); + +describe('policy', () => { + const tmp = () => fs.mkdtempSync(path.join(os.tmpdir(), 'plain-title-')); + const withPolicy = (text: string) => { const dir = tmp(); fs.mkdirSync(path.join(dir, '.gstack')); fs.writeFileSync(path.join(dir, PLAIN_TITLE_POLICY_PATH), text); return dir; }; + + test('no policy file means no findings anywhere', () => { + expect(loadPlainTitlePolicy(tmp())).toBeNull(); + expect(checkTitle('fix(ship): pregate GST-1', null)).toEqual([]); + expect(checkBranch('capy/gst-1-pregate', null)).toEqual([]); + expect(checkOpening('## heading', null)).toEqual([]); + expect(checkChangelogEntry('**GST-1 pregate**', null)).toEqual([]); + expect(checkCommitSubject('GST-1', null)).toEqual([]); + }); + + test('an invalid policy throws with the bad key named', () => { + const base = { codes: ['\\bX-\\d+\\b'], terms: ['a'], allow: [], since: '2026-11-08', exempt_branch_prefixes: [] }; + expect(() => loadPlainTitlePolicy(withPolicy('{'))).toThrow(/not valid JSON/); + expect(() => loadPlainTitlePolicy(withPolicy(JSON.stringify({ ...base, codes: ['('] })))).toThrow(/codes\[0\] is not a valid regex/); + expect(() => loadPlainTitlePolicy(withPolicy(JSON.stringify({ ...base, codes: ['x?'] })))).toThrow(/matches empty text/); + expect(() => loadPlainTitlePolicy(withPolicy(JSON.stringify({ ...base, allow: [{ text: 'R2' }] })))).toThrow(/allow\[0\]\.reason/); + expect(() => loadPlainTitlePolicy(withPolicy(JSON.stringify({ ...base, since: 'soon' })))).toThrow(/since/); + expect(() => loadPlainTitlePolicy(withPolicy(JSON.stringify({ ...base, extra: 1 })))).toThrow(/extra is not a known key/); + expect(loadPlainTitlePolicy(withPolicy(JSON.stringify(base)))).toEqual(base); + }); + + test("gstack's policy loads and every allow entry says why", () => { + expect(PLAIN_TITLE_POLICY_PATH).toBe('.gstack/plain-titles.json'); + expect(policy.since).toBe('2026-11-08'); + expect(policy.allow.length).toBeGreaterThan(0); + for (const a of policy.allow) expect(a.reason.length).toBeGreaterThan(10); + }); +}); + +describe('bin/gstack-plain-title', () => { + const dirs = { policy: fs.mkdtempSync(path.join(os.tmpdir(), 'plain-title-bin-')), none: fs.mkdtempSync(path.join(os.tmpdir(), 'plain-title-bin-')), invalid: fs.mkdtempSync(path.join(os.tmpdir(), 'plain-title-bin-')) }; + fs.mkdirSync(path.join(dirs.policy, '.gstack')); + fs.copyFileSync(path.join(ROOT, PLAIN_TITLE_POLICY_PATH), path.join(dirs.policy, PLAIN_TITLE_POLICY_PATH)); + fs.mkdirSync(path.join(dirs.invalid, '.gstack')); + fs.writeFileSync(path.join(dirs.invalid, PLAIN_TITLE_POLICY_PATH), '{"codes": [}'); + const run = (dir: string, args: string[]) => { + const r = spawnSync(process.execPath, [...bunIsolationArgs(), BIN, ...args], { cwd: dir, encoding: 'utf8', timeout: 30_000, env: { ...process.env, GIT_CEILING_DIRECTORIES: path.dirname(dir) } }); + const lines = r.stdout.trim().split('\n'); + return { status: r.status, stdout: r.stdout, stderr: r.stderr, last: lines[lines.length - 1] }; + }; + const file = (name: string, text: string) => { const f = path.join(dirs.policy, name); fs.writeFileSync(f, text); return f; }; + + test('a refused title exits 1 with one line per finding and never echoes the title', () => { + const r = run(dirs.policy, ['check', '--title', 'fix(ship): the pregate secret-words-here']); + expect(r.status).toBe(1); + expect(r.stdout).toContain('PLAIN_TITLE: PLAIN_TITLE_REFUSED scope-prefix "fix(ship):" — '); + expect(r.stdout).toContain('PLAIN_TITLE: PLAIN_TITLE_REFUSED term "pregate" — '); + expect(r.stdout).not.toContain('secret-words-here'); + expect(r.last).toBe('PLAIN_TITLE: refused=2'); + }); + + test('a plain title and branch exit 0 with ok', () => { + const r = run(dirs.policy, ['check', '--title-file', file('t.txt', "Stop gstack from hitting GitHub's rate limit (GST-1713)\n"), '--branch', 'capy/github-rate-limit-gst-1713']); + expect([r.status, r.last]).toEqual([0, 'PLAIN_TITLE: ok']); + }); + + test('warn mode and commit subjects only warn', () => { + const warn = run(dirs.policy, ['check', '--branch', 'capy/gst-1814-autoplan-gate-order', '--body-file', file('b.md', '## Heading\n'), '--mode', 'warn']); + expect([warn.status, warn.last]).toEqual([0, 'PLAIN_TITLE: warned=2']); + const commits = run(dirs.policy, ['check', '--title', 'Plain title', '--commits-file', file('c.txt', 'fix(ship): pregate\nPlain subject\n\n')]); + expect([commits.status, commits.last]).toEqual([0, 'PLAIN_TITLE: warned=2']); + expect(commits.stdout).toContain('(warning only)'); + const both = run(dirs.policy, ['check', '--title', 'pregate', '--commits-file', file('c2.txt', 'GST-1 done\n')]); + expect([both.status, both.last]).toEqual([1, 'PLAIN_TITLE: refused=1 warned=1']); + }); + + test('a changelog entry is checked', () => { + const r = run(dirs.policy, ['check', '--changelog', file('frag.md', '**Plain lead (GST-1774).**\n')]); + expect([r.status, r.last]).toEqual([1, 'PLAIN_TITLE: refused=1']); + }); + + test('a PR created before since is exempt; no policy is none', () => { + expect(run(dirs.policy, ['check', '--title', 'pregate', '--created-at', '2026-11-01T10:00:00Z'])).toMatchObject({ status: 0, last: 'PLAIN_TITLE: exempt (created before 2026-11-08)' }); + expect(run(dirs.policy, ['check', '--title', 'pregate', '--created-at', '2026-11-08T10:00:00Z']).status).toBe(1); + expect(run(dirs.none, ['check', '--title', 'pregate GST-1'])).toMatchObject({ status: 0, last: 'PLAIN_TITLE: none (no policy)' }); + }); + + test('an invalid policy exits 3; usage errors exit 2', () => { + const bad = run(dirs.invalid, ['check', '--title', 'x']); + expect(bad.status).toBe(3); + expect(bad.stderr).toContain('(PLAIN_POLICY_INVALID)'); + expect(run(dirs.invalid, ['validate']).status).toBe(3); + expect(run(dirs.policy, ['check']).status).toBe(2); + expect(run(dirs.policy, ['check', '--nope']).status).toBe(2); + expect(run(dirs.policy, ['check', '--title', 'x', '--mode', 'loud']).status).toBe(2); + expect(run(dirs.policy, ['check', '--title', 'x', '--title-file', 'y']).status).toBe(2); + expect(run(dirs.policy, ['frobnicate']).status).toBe(2); + }); + + test('--json, validate, init and --help', () => { + const j = run(dirs.policy, ['check', '--title', 'pregate', '--json']); + expect(j.status).toBe(1); + expect(JSON.parse(j.stdout)).toMatchObject({ status: 'refused=1', refused: 1, warned: 0, findings: [{ code: 'PLAIN_TITLE_REFUSED', kind: 'term', match: 'pregate', severity: 'refused' }] }); + expect(run(dirs.policy, ['validate'])).toMatchObject({ status: 0, last: expect.stringMatching(/^PLAIN_TITLE: valid \.gstack\/plain-titles\.json/) }); + expect(run(dirs.none, ['validate'])).toMatchObject({ status: 0, last: 'PLAIN_TITLE: none (no policy)' }); + expect(run(dirs.policy, ['init']).status).toBe(1); + const fresh = fs.mkdtempSync(path.join(os.tmpdir(), 'plain-title-bin-')); + expect(run(fresh, ['init']).status).toBe(0); + expect(loadPlainTitlePolicy(fresh)).not.toBeNull(); + expect(run(dirs.policy, ['--help']).stdout).toContain('Exit codes: 0 ok'); + }); +}); diff --git a/test/plan-tune-cathedral.test.ts b/test/plan-tune-cathedral.test.ts index c8a01b96f..cbe751a23 100644 --- a/test/plan-tune-cathedral.test.ts +++ b/test/plan-tune-cathedral.test.ts @@ -71,7 +71,7 @@ function scaffoldFixture(workDir: string): { workDir: string; stateRoot: string; // These imports are resolved relative to the copied install, not the source checkout. const libDir = path.join(workDir, 'lib'); fs.mkdirSync(libDir, { recursive: true }); - for (const file of ['jsonl-store.ts', 'is-conductor.ts', 'state-root.ts', 'bin-context.ts', 'remote-identity.ts', 'script-invocation.ts', 'bun-isolation.ts', 'result-codes.ts', 'result-codes-release.ts', 'result-codes-capy.ts', 'result-codes-budget.ts', 'result-codes-eval.ts', 'result-codes-plan.ts', 'result-codes-autoplan.ts', 'result-codes-browse.ts', 'result-codes-clean.ts']) { + for (const file of ['jsonl-store.ts', 'is-conductor.ts', 'state-root.ts', 'bin-context.ts', 'remote-identity.ts', 'script-invocation.ts', 'bun-isolation.ts', 'result-codes.ts', 'result-codes-release.ts', 'result-codes-capy.ts', 'result-codes-budget.ts', 'result-codes-eval.ts', 'result-codes-plan.ts', 'result-codes-autoplan.ts', 'result-codes-browse.ts', 'result-codes-clean.ts', 'result-codes-plain.ts']) { fs.copyFileSync(path.join(ROOT, 'lib', file), path.join(libDir, file)); } diff --git a/test/pr-dependencies.test.ts b/test/pr-dependencies.test.ts index b61b2859d..84d979c59 100644 --- a/test/pr-dependencies.test.ts +++ b/test/pr-dependencies.test.ts @@ -162,7 +162,7 @@ describe('safe classification (A1-2)', () => { }); test('repository config read only by free lanes or GitHub stays in the PR profile', () => { - const files = ['.github/dependabot.yml', '.osv-scanner.toml', 'setup.ps1', '.gitlab-ci.yml', '.github/ISSUE_TEMPLATE/bug_report.md', '.gstack/ship-policy.example.json']; + const files = ['.github/dependabot.yml', '.osv-scanner.toml', 'setup.ps1', '.gitlab-ci.yml', '.github/ISSUE_TEMPLATE/bug_report.md', '.gstack/ship-policy.example.json', '.gstack/plain-titles.json']; for (const file of files) expect(real.tracked.has(file), file).toBe(true); const result = computePaidCaseSelection({ profile: 'pr', env: {}, changedFiles: files, packageVersionOnly: true }); expect(result.coverage?.mode).toBe('pr'); diff --git a/test/prose-lint.test.ts b/test/prose-lint.test.ts index be3eb1961..04a8f19a7 100644 --- a/test/prose-lint.test.ts +++ b/test/prose-lint.test.ts @@ -327,7 +327,7 @@ describe('wiring', () => { expect(summary).not.toContain('/investigate'); expect(prBody).toContain('names what the user gets, not the mechanism'); const capy = read('ship/sections/capy-pr.md.tmpl'); - expect(capy).toContain('`## Why`, `## Blast radius` and `## Approvals`'); + expect(capy).toContain('`## What changed`, `## Why` and `## Blast radius`'); expect(capy).toMatch(/\*\*`## Why`:\*\* two or three sentences\. The first states the problem in the person's words/); expect(capy).toMatch(/BAD "[\s\S]+" GOOD "/); expect(capy).toContain("**`## Blast radius`:** the body template's item, written once"); diff --git a/test/review-targeted-record.test.ts b/test/review-targeted-record.test.ts index 33a0de0ef..5239f78bb 100644 --- a/test/review-targeted-record.test.ts +++ b/test/review-targeted-record.test.ts @@ -11,7 +11,7 @@ import * as path from 'node:path'; import { EXIT } from '../lib/headless-artifacts'; import { checkoutTree } from '../lib/review-brief'; import { TARGETED_RULES, targetedBriefSha } from '../lib/review-targeted'; -import { extractReceipt, placeReceiptFirst } from '../lib/ship-receipt'; +import { extractReceipt, placeReceipt } from '../lib/ship-receipt'; import { receiptDepthAtHead, targetedClassesSince } from '../lib/ship-receipt-depth'; import { cleanup, makeFixtureRepo, runBin, type FixtureRepo } from './helpers/restamp-fixture'; @@ -255,7 +255,7 @@ describe('write and carry refuse a receipt the queue\'s targeted verdict refuses let n = 0; const carry = (receipt: object) => { const body = path.join(repo.root, `body-${++n}.md`); - fs.writeFileSync(body, placeReceiptFirst('## Summary\n\nProse.\n', receipt as any)); + fs.writeFileSync(body, placeReceipt('## Summary\n\nProse.\n', receipt as any)); return { ...runBin(repo, 'gstack-ship-receipt', ['carry', '--body', body], { GSTACK_STATE_ROOT: path.join(repo.root, 'state') }), body }; }; const uncovered = carry(versioned); diff --git a/test/ship-issue-intent.test.ts b/test/ship-issue-intent.test.ts index 85755b2de..4f800f390 100644 --- a/test/ship-issue-intent.test.ts +++ b/test/ship-issue-intent.test.ts @@ -7,6 +7,7 @@ import { afterEach, describe, expect, test } from 'bun:test'; import * as fs from 'node:fs'; import * as path from 'node:path'; +import { splitAgentNotes } from '../lib/agent-notes'; import { EXIT } from '../lib/headless-artifacts'; import { lineSources, mentionsIn, promotionCandidates, relatedPrs, renderIssueLines, resolveLinks, selectFlakes, type ClosingPr, type IssueRow } from '../lib/issue-links'; import { cleanup, commit, ghShim, makeFixtureRepo, runBin, type FixtureRepo } from './helpers/pregate-fixture'; @@ -240,7 +241,8 @@ describe('gstack-issue-links (bin): related PRs, flake validation, lookup failur const ok = runBin(repo, 'gstack-ship-receipt', args, env); expect(ok.status).toBe(EXIT.ok); expect(JSON.parse(ok.out).issues).toEqual({ fixes: [14], refs: [30], flakes: [], related: [{ pr: 7, author: 'alice', closes: [14] }] }); - expect(fs.readFileSync(body, 'utf8').startsWith('```gstack-ship-receipt')).toBe(true); + expect(fs.readFileSync(body, 'utf8').startsWith('## Summary')).toBe(true); + expect(splitAgentNotes(fs.readFileSync(body, 'utf8')).notes).toStartWith('```gstack-ship-receipt'); }); }); diff --git a/test/ship-publication-gates.test.ts b/test/ship-publication-gates.test.ts index b5bc8862b..08db7e73f 100644 --- a/test/ship-publication-gates.test.ts +++ b/test/ship-publication-gates.test.ts @@ -16,12 +16,13 @@ function writeDrafts(root: string, top = '## Summary\n\n## Documentation', rest } const POST = resolve(import.meta.dir, '../bin/gstack-post'); const PROSE_LINT = resolve(import.meta.dir, '../bin/gstack-prose-lint'); +const PLAIN_TITLE = resolve(import.meta.dir, '../bin/gstack-plain-title'); /** The two real bins the blocks call, at the installed path under a HOME of `root`. */ function installBins(root: string): void { const bin = join(root, '.claude/skills/gstack/bin'); mkdirSync(bin, { recursive: true }); - for (const [name, target] of [['gstack-post', POST], ['gstack-prose-lint', PROSE_LINT]] as const) { + for (const [name, target] of [['gstack-post', POST], ['gstack-prose-lint', PROSE_LINT], ['gstack-plain-title', PLAIN_TITLE]] as const) { writeFileSync(join(bin, name), `#!/bin/sh\nexec ${JSON.stringify(process.execPath)} ${JSON.stringify(target)} "$@"\n`, { mode: 0o755 }); } } diff --git a/test/ship-queue-land-direct.test.ts b/test/ship-queue-land-direct.test.ts index 9aa14f927..8d828f478 100644 --- a/test/ship-queue-land-direct.test.ts +++ b/test/ship-queue-land-direct.test.ts @@ -141,7 +141,7 @@ describe('land --pin: direct merge at the PR\'s own green head', () => { const r = land(fx, '7', pin); expect(r.err).toBe(''); expect(r.status).toBe(EXIT.ok); - expect(fx.read().mergeArgs).toEqual(['7', '--squash', '--match-head-commit', pin]); + expect(fx.read().mergeArgs).toEqual(['7', '--squash', '--match-head-commit', pin, '--subject', 'PR 7 (#7)']); const main = remote(fx, 'main'); expect(fx.repo.git('rev-parse', main + '^1')).toBe(base); expect(r.out).toContain('MERGED: #7 as ' + main); @@ -580,6 +580,7 @@ describe('land --pin: refusals (nothing merged)', () => { const missing = land(fx, '17', pin); refused(fx, missing, 'QUEUE_LAND_CHECKS'); expect(missing.err).toContain('Check VERSION is not stale vs queue'); + expect(missing.out + missing.err).toContain('cannot be dispatched, so edit the PR title or body to rerun it'); }, 90_000); test('QUEUE_LAND_NOT_CLEAN: GitHub does not report the PR mergeable and clean', () => { @@ -658,7 +659,7 @@ describe('land --pin: fail closed and merge methods', () => { fx.write(d => { d.prs['25'].baseRefName = 'main'; }); const r = land(fx, '25', pin); expect(r.err).toBe(''); - expect(fx.read().mergeArgs).toEqual(['25', '--merge', '--match-head-commit', pin]); + expect(fx.read().mergeArgs).toEqual(['25', '--merge', '--match-head-commit', pin, '--subject', 'PR 25 (#25)']); }, 90_000); test('land reads the policy after fetching the base: a required check main added since the last fetch is enforced', () => { @@ -961,7 +962,7 @@ describe('land --pin --repair: the one way through a backstop freeze under merge expect(r.err).toBe(''); expect(r.status).toBe(EXIT.ok); expect(r.out).toMatch(/^FREEZE: warn only: train backstop froze main \(backstop red on main [0-9a-f]{12}: .*\); the base policy declares release_gate/m); - expect(fx.read().mergeArgs).toEqual(['7', '--squash', '--match-head-commit', pin]); + expect(fx.read().mergeArgs).toEqual(['7', '--squash', '--match-head-commit', pin, '--subject', 'PR 7 (#7)']); expect(freezeOf(fx)).toMatchObject({ train: 'backstop', repair: null, cleared_at: null }); }, 90_000); @@ -996,7 +997,7 @@ describe('land --pin --repair: the one way through a backstop freeze under merge expect(r.err).toBe(''); expect(r.status).toBe(EXIT.ok); expect(r.out).toContain(`REPAIR: #7@${pin7.slice(0, 12)} admitted under the land lock`); - expect(fx.read().mergeArgs).toEqual(['7', '--squash', '--match-head-commit', pin7]); + expect(fx.read().mergeArgs).toEqual(['7', '--squash', '--match-head-commit', pin7, '--subject', 'PR 7 (#7)']); expect(fx.read().lockAtMerge).toMatchObject({ pr: '7', journal: 'land-pin:' + pin7 }); expect(freezeOf(fx)).toMatchObject({ train: 'backstop', repair: { pr: '7', head: pin7, train: null }, cleared_at: null, cleared_by: null }); expect(landNotes(fx)).toEqual([expect.objectContaining({ pr: '7', pin: pin7, merge_sha: remote(fx, 'main') })]); @@ -1004,7 +1005,7 @@ describe('land --pin --repair: the one way through a backstop freeze under merge expect(second.err).toContain('(QUEUE_TRAIN_FROZEN)'); expect(second.err).toContain(`repair #7@${pin7.slice(0, 12)}`); expect(land(fx, '8', pin8).err).toContain('(QUEUE_TRAIN_FROZEN)'); - expect(fx.read().mergeArgs).toEqual(['7', '--squash', '--match-head-commit', pin7]); + expect(fx.read().mergeArgs).toEqual(['7', '--squash', '--match-head-commit', pin7, '--subject', 'PR 7 (#7)']); }, 120_000); test('a recorded repair that never merged at its pin gives way to the next --repair; a repair train holding the freeze does not', () => { diff --git a/test/ship-queue-train-generic.test.ts b/test/ship-queue-train-generic.test.ts index 3596917be..c990d6cd1 100644 --- a/test/ship-queue-train-generic.test.ts +++ b/test/ship-queue-train-generic.test.ts @@ -247,7 +247,7 @@ describe('generic trains end to end (fixture repos)', () => { expect(origin('rev-parse', 'main^{tree}')).toBe(t.gated[2].tree); expect(trainJson(fx, nonce)).toMatchObject({ state: 'merged', landing: { 1: { mode: 'direct', update: null }, 2: { mode: 'direct' } } }); const merges = fx.read().log.filter(l => l.cmd === 'pr merge').map(l => (l.args as string[]).join(' ')); - expect(merges[0]).toMatch(/^pr merge 1 --squash --match-head-commit [0-9a-f]{40}$/); + expect(merges[0]).toMatch(/^pr merge 1 --squash --match-head-commit [0-9a-f]{40} --subject Change from #1 \(#1\)$/); }, 300_000); test('strict protection forces update; a rebase method refuses it; a fork head is refused', () => { @@ -653,6 +653,7 @@ describe('a pin whose own checks are red (round-2 High 2)', () => { expect(merged.status, merged.err + merged.out).toBe(EXIT.ok); const t = trainJson(fx, nonce); expect(t.landing['1']).toMatchObject({ mode: 'update' }); - expect((fx.read().log.find(l => l.cmd === 'pr merge')!.args as string[]).at(-1)).toBe(t.landing['1'].update); + const mergeArgs = fx.read().log.find(l => l.cmd === 'pr merge')!.args as string[]; + expect(mergeArgs[mergeArgs.indexOf('--match-head-commit') + 1]).toBe(t.landing['1'].update); }, 300_000); }); diff --git a/test/ship-queue-train-land.test.ts b/test/ship-queue-train-land.test.ts index 4d3b2a279..027a54fa8 100644 --- a/test/ship-queue-train-land.test.ts +++ b/test/ship-queue-train-land.test.ts @@ -62,6 +62,7 @@ describe('stamped trains: build and land', () => { expect(trainJson(fx, nonce)).toMatchObject({ state: 'merged' }); expect(fx.read().prs['2']).toMatchObject({ state: 'MERGED' }); expect((fx.read() as any).labels).toEqual({ 1: 'evals-no-debounce', 2: 'evals-no-debounce' }); + expect((fx.read() as any).mergeArgs['1']).toEqual(['--squash', '--match-head-commit', stamp, '--subject', 'Change from #1 (#1)']); void h2; }, 300_000); diff --git a/test/ship-receipt-runs.test.ts b/test/ship-receipt-runs.test.ts index 9752b23c8..c40494158 100644 --- a/test/ship-receipt-runs.test.ts +++ b/test/ship-receipt-runs.test.ts @@ -3,37 +3,40 @@ * schema carries K1's no_stop records for L5. */ import { describe, expect, test } from 'bun:test'; -import { extractReceipt, orderReceipt, placeReceiptFirst, readyTailLines, receiptRuns, RECEIPT_RUNS_KEPT, validateReceipt } from '../lib/ship-receipt'; +import { extractReceipt, orderReceipt, placeReceipt, readyTailLines, receiptRuns, RECEIPT_RUNS_KEPT, validateReceipt } from '../lib/ship-receipt'; import type { ShipReceipt } from '../lib/headless-artifacts'; const receipt = (head: string, loop: Partial<NonNullable<ShipReceipt['review_loop']>> = {}): ShipReceipt => orderReceipt({ schema_version: 1, head, base: 'main', version: '1.94.1.0', review_loop: { passes: 1, blocking_found: 0, deferred: 0, ...loop } }); describe('receipt runs block', () => { - test('a write over a different receipt keeps the old run, oldest first, after the current block', () => { - let body = placeReceiptFirst('## Summary\nprose\n', receipt('a'.repeat(40), { passes: 2, blocking_found: 3, fixed: 3 })); - body = placeReceiptFirst(body, receipt('b'.repeat(40))); - body = placeReceiptFirst(body, receipt('c'.repeat(40), { passes: 1 })); + test('a write over a different receipt keeps the old run, oldest first, after the current block in the Agent notes section', () => { + let body = placeReceipt('## Summary\nprose\n', receipt('a'.repeat(40), { passes: 2, blocking_found: 3, fixed: 3 })); + body = placeReceipt(body, receipt('b'.repeat(40))); + body = placeReceipt(body, receipt('c'.repeat(40), { passes: 1 })); expect(extractReceipt(body).receipt!.head).toBe('c'.repeat(40)); expect(receiptRuns(body).map(r => [r.head[0], r.review_loop?.blocking_found])).toEqual([['a', 3], ['b', 0]]); - expect(body.indexOf('gstack-receipt-runs')).toBeLessThan(body.indexOf('## Summary')); + expect(body.indexOf('gstack-receipt-runs')).toBeGreaterThan(body.indexOf('<!-- gstack:agent-notes -->')); + expect(body.indexOf('gstack-receipt-runs')).toBeGreaterThan(body.indexOf('```gstack-ship-receipt')); + expect(body.indexOf('<!-- gstack:agent-notes -->')).toBeGreaterThan(body.indexOf('## Summary')); expect(body.match(/## Summary/g)!.length).toBe(1); }); test('a fence inside a recorded reason never closes the runs block (adversarial p-voices #1)', () => { const withReason = (head: string) => orderReceipt({ ...receipt(head, { fix_checks: 1 }), review_loop_exception: { accepted_by: 'Garry', date: '2026-11-03', via: 'x', reason: 'the \x60\x60\x60retry\x60\x60\x60 block is fine', fix_check: { head, findings: 0 } } }); - let body = placeReceiptFirst('## Summary\nprose\n', withReason('b'.repeat(40))); - body = placeReceiptFirst(body, withReason('c'.repeat(40))); - body = placeReceiptFirst(body, withReason('d'.repeat(40))); + let body = placeReceipt('## Summary\nprose\n', withReason('b'.repeat(40))); + body = placeReceipt(body, withReason('c'.repeat(40))); + body = placeReceipt(body, withReason('d'.repeat(40))); expect(receiptRuns(body).map(r => r.head[0])).toEqual(['b', 'c']); - expect(body.endsWith('## Summary\nprose\n')).toBe(true); + expect(body.startsWith('## Summary\nprose\n\n<!-- gstack:agent-notes -->\n')).toBe(true); + expect(body.endsWith('```\n\n</details>\n')).toBe(true); }); test('rewriting the same receipt adds no run, and the block keeps the newest RECEIPT_RUNS_KEPT', () => { const same = receipt('d'.repeat(40)); - expect(receiptRuns(placeReceiptFirst(placeReceiptFirst('', same), same))).toEqual([]); + expect(receiptRuns(placeReceipt(placeReceipt('', same), same))).toEqual([]); let body = ''; - for (let i = 0; i < RECEIPT_RUNS_KEPT + 5; i++) body = placeReceiptFirst(body, receipt(i.toString(16).padStart(40, '0'))); + for (let i = 0; i < RECEIPT_RUNS_KEPT + 5; i++) body = placeReceipt(body, receipt(i.toString(16).padStart(40, '0'))); const runs = receiptRuns(body); expect(runs.length).toBe(RECEIPT_RUNS_KEPT); expect(runs.at(-1)!.head).toBe((RECEIPT_RUNS_KEPT + 3).toString(16).padStart(40, '0')); diff --git a/test/ship-recheck.test.ts b/test/ship-recheck.test.ts index d5cc8a33b..a3077f24c 100644 --- a/test/ship-recheck.test.ts +++ b/test/ship-recheck.test.ts @@ -10,7 +10,7 @@ import * as fs from 'node:fs'; import * as os from 'node:os'; import * as path from 'node:path'; import { EXIT } from '../lib/headless-artifacts'; -import { extractReceipt, placeReceiptFirst } from '../lib/ship-receipt'; +import { extractReceipt, placeReceipt } from '../lib/ship-receipt'; import { planRecheck, readRecheck, RECHECK_PACKET_BYTES, RECHECK_RANGE_DIFF_CAP } from '../lib/ship-recheck'; import { cleanup, makeFixtureRepo, runBin, type FixtureRepo } from './helpers/restamp-fixture'; @@ -125,7 +125,7 @@ describe('carry with a scoped re-check (CLI)', () => { test('a legacy receipt, more changed paths than the walk names, and moved base context print RECHECK: not eligible', () => { const legacy = reviewed(); - fs.writeFileSync(legacy.body, placeReceiptFirst('## Summary\n', { schema_version: 1, pr: 5, head: legacy.from, base: 'main' } as any)); + fs.writeFileSync(legacy.body, placeReceipt('## Summary\n', { schema_version: 1, pr: 5, head: legacy.from, base: 'main' } as any)); legacy.commit({ 'src/app.ts': 'export const a = 3;\n' }, 'fix'); expect(runBin(legacy.repo, 'gstack-ship-receipt', ['carry', '--body', legacy.body], legacy.env).err).toContain('RECHECK: not eligible (the receipt records no review_depth'); diff --git a/test/ship-release.test.ts b/test/ship-release.test.ts index 8eb513583..1dd8b2bc1 100644 --- a/test/ship-release.test.ts +++ b/test/ship-release.test.ts @@ -311,6 +311,23 @@ describe('push (Step 17)', () => { expect(r.stdout).toContain('STOP: non-fast-forward'); expect(r.stdout).toContain('never force-push'); }); + + // Value: protects=a never-pushed branch whose name breaks the repo's plain-words policy stops before the push, and a plain name pushes; fails_when=a coded or jargon branch name reaches origin, or a plain one is refused; + // why_new=plain public record (docs/ship-policy.md#plain-titles); seam=.gstack/plain-titles.json, git ls-remote; no_claim=branches already on origin + test('a never-pushed branch name with a tracker code or coined term in its front stops; a plain name pushes', () => { + const f = fx(); + f.write({ '.gstack/plain-titles.json': JSON.stringify({ codes: ['\\bGST-\\d+\\b'], terms: ['pregate'], allow: [], since: '2026-11-08', exempt_branch_prefixes: ['release/'] }) }); + f.commit('policy'); + f.git('branch', '-m', 'gst-1825-pregate-fix'); + const r = f.run(RELEASE, ['push', '--branch', 'gst-1825-pregate-fix'], { GSTACK_SESSION_KIND: 'unattended' }); + expect(r.status, r.stdout + r.stderr).toBe(3); + expect(lineOf(r.stdout, 'BRANCH')).toContain('BRANCH: refused'); + expect(r.stdout).toContain('is not plain words'); + f.git('branch', '-m', 'faster-checks-gst-1825'); + const ok = f.run(RELEASE, ['push', '--branch', 'faster-checks-gst-1825'], { GSTACK_SESSION_KIND: 'unattended' }); + expect(ok.status, ok.stdout + ok.stderr).toBe(0); + expect(lineOf(ok.stdout, 'PUSH')).toMatch(/^PUSH: pushed [0-9a-f]{12} to origin\/faster-checks-gst-1825$/); + }); }); describe('gstack-ship-release push: branch names', () => { diff --git a/test/ship-rereview-triggers.test.ts b/test/ship-rereview-triggers.test.ts index 99f9967b2..5d9805c51 100644 --- a/test/ship-rereview-triggers.test.ts +++ b/test/ship-rereview-triggers.test.ts @@ -10,7 +10,7 @@ import * as path from 'node:path'; import { EXIT } from '../lib/headless-artifacts'; import { comparePath } from '../lib/ship-patch'; import { STAMP_PLACEHOLDERS_KEY_VERSION } from '../lib/ship-policy'; -import { extractReceipt, placeReceiptFirst } from '../lib/ship-receipt'; +import { extractReceipt, placeReceipt } from '../lib/ship-receipt'; import { cleanup, makeFixtureRepo, runBin, write, type FixtureRepo } from './helpers/pregate-fixture'; import { lineOf, PREFLIGHT, RELEASE, shipFixture, type ShipFx } from './helpers/ship-bin-fixture'; @@ -354,7 +354,7 @@ describe('adjacent base edits (GST-384, #3631, founderstar#441)', () => { write(repo.work, 'lib/rows.js', src({ 6: 'exports.v6 = 600;' })); repo.git('commit', '-qam', 'feat: v6'); const reviewed = repo.git('rev-parse', 'HEAD'); const body = path.join(repo.root, 'body.md'); - fs.writeFileSync(body, placeReceiptFirst('## Summary\n\nProse.\n', { schema_version: 1, pr: 9, head: reviewed, base: 'main', queue_mode: 'stamp-at-merge' } as any)); + fs.writeFileSync(body, placeReceipt('## Summary\n\nProse.\n', { schema_version: 1, pr: 9, head: reviewed, base: 'main', queue_mode: 'stamp-at-merge' } as any)); repo.git('checkout', '-q', 'main'); write(repo.work, 'lib/rows.js', src({ 5: 'exports.v5 = "five";' })); repo.git('commit', '-qam', 'main: v5'); repo.git('push', '-q', 'origin', 'main'); repo.git('checkout', '-q', 'feature'); @@ -382,7 +382,7 @@ describe('adjacent base edits (GST-384, #3631, founderstar#441)', () => { const guard = (text: string, after: string) => text.replace('authorize();\n', '').replace(after + '\n', after + '\nauthorize();\n'); const rows = () => fs.readFileSync(path.join(repo.work, 'lib/rows.js'), 'utf8'); write(repo.work, 'lib/rows.js', guard(rows(), 'exports.v2 = 2;')); repo.git('commit', '-qam', 'feat: guard v2'); - fs.writeFileSync(body, placeReceiptFirst('## Summary\n\nProse.\n', { schema_version: 1, pr: 9, head: repo.git('rev-parse', 'HEAD'), base: 'main', queue_mode: 'stamp-at-merge' } as any)); + fs.writeFileSync(body, placeReceipt('## Summary\n\nProse.\n', { schema_version: 1, pr: 9, head: repo.git('rev-parse', 'HEAD'), base: 'main', queue_mode: 'stamp-at-merge' } as any)); write(repo.work, 'lib/rows.js', guard(rows(), 'exports.v4 = 4;')); repo.git('commit', '-qam', 'feat: move the guard'); const moved = carry(); expect(moved.status).toBe(EXIT.refused); @@ -408,7 +408,7 @@ describe('carry with a context path and a gap elsewhere (GST-407 cycle 5)', () = write(repo.work, 'lib/rows.js', src({ 6: 'exports.v6 = 600;' })); write(repo.work, 'plugin/b.json', json('b2')); repo.git('commit', '-qam', 'feat: v6, b2'); const reviewed = repo.git('rev-parse', 'HEAD'); const body = path.join(repo.root, 'body.md'); - fs.writeFileSync(body, placeReceiptFirst('## Summary\n\nProse.\n', { schema_version: 1, pr: 9, head: reviewed, base: 'main', queue_mode: 'stamp-at-merge' } as any)); + fs.writeFileSync(body, placeReceipt('## Summary\n\nProse.\n', { schema_version: 1, pr: 9, head: reviewed, base: 'main', queue_mode: 'stamp-at-merge' } as any)); repo.git('checkout', '-q', 'main'); write(repo.work, 'lib/rows.js', src({ 5: 'exports.v5 = "five";' })); write(repo.work, 'plugin/b.json', json('b2')); write(repo.work, 'plugin/a.json', json('a2')); repo.git('commit', '-qam', 'main: v5, b2 landed, a2'); repo.git('push', '-q', 'origin', 'main'); @@ -418,7 +418,7 @@ describe('carry with a context path and a gap elsewhere (GST-407 cycle 5)', () = expect([gap.status, gap.out.trim().split('\n')[0]]).toEqual([EXIT.refused, 'CARRY: refused (RECEIPT_CARRY_PRODUCT_CHANGED)']); write(repo.work, 'plugin/b.json', json('b')); write(repo.work, 'lib/rows.js', src({ 5: 'exports.v5 = "five";', 6: 'exports.v6 = 600;' }) + Array.from({ length: 220 }, (_, i) => `exports.w${i} = ${i};\n`).join('')); repo.git('commit', '-qam', 'feat: many rows'); - fs.writeFileSync(body, placeReceiptFirst('## Summary\n\nProse.\n', { schema_version: 1, pr: 9, head: repo.git('rev-parse', 'HEAD'), base: 'main', queue_mode: 'stamp-at-merge' } as any)); + fs.writeFileSync(body, placeReceipt('## Summary\n\nProse.\n', { schema_version: 1, pr: 9, head: repo.git('rev-parse', 'HEAD'), base: 'main', queue_mode: 'stamp-at-merge' } as any)); repo.git('checkout', '-q', 'main'); write(repo.work, 'lib/rows.js', src({ 5: 'exports.v5 = "5";' })); repo.git('commit', '-qam', 'main: v5 again'); repo.git('push', '-q', 'origin', 'main'); repo.git('checkout', '-q', 'feature'); @@ -595,7 +595,7 @@ describe('preflight carry route for an adjacent base edit (GST-442)', () => { mergeMain(f, { 'lib/rows.js': src({ 5: 'exports.v5 = "five";', 6: 'exports.v6 = 600;' }) }); const head = f.git('rev-parse', 'HEAD').stdout.trim(); const view = path.join(f.root, 'view.json'); - fs.writeFileSync(view, JSON.stringify({ body: placeReceiptFirst('## Summary\n\nProse.\n', { schema_version: 1, pr: 9, head: reviewed, base: 'main', queue_mode: 'stamp-at-merge' } as any), headRefOid: head })); + fs.writeFileSync(view, JSON.stringify({ body: placeReceipt('## Summary\n\nProse.\n', { schema_version: 1, pr: 9, head: reviewed, base: 'main', queue_mode: 'stamp-at-merge' } as any), headRefOid: head })); f.gh(`case "$1 $2" in "pr list") echo 9 ;; "pr view") cat '${view}' ;; *) exit 1 ;; esac`); const r = f.run(PREFLIGHT, ['--base', 'main']); expect(lineOf(r.stdout, 'CARRY')).toBe('CARRY: refused (RECEIPT_CARRY_SCOPED_REVIEW)'); @@ -616,7 +616,7 @@ describe('preflight carry route for an adjacent base edit (GST-442)', () => { mergeMain(f); const head = f.git('rev-parse', 'HEAD').stdout.trim(); const view = path.join(f.root, 'view.json'); - fs.writeFileSync(view, JSON.stringify({ body: placeReceiptFirst('## Summary\n\nProse.\n', { schema_version: 1, pr: 9, head: reviewed, base: 'main', queue_mode: 'stamp-at-merge' } as any), headRefOid: head })); + fs.writeFileSync(view, JSON.stringify({ body: placeReceipt('## Summary\n\nProse.\n', { schema_version: 1, pr: 9, head: reviewed, base: 'main', queue_mode: 'stamp-at-merge' } as any), headRefOid: head })); f.gh(`case "$1 $2" in "pr list") echo 9 ;; "pr view") cat '${view}' ;; *) exit 1 ;; esac`); const r = f.run(PREFLIGHT, ['--base', 'main']); expect(lineOf(r.stdout, 'CARRY')).toMatch(/^CARRY: would carry/); @@ -634,7 +634,7 @@ describe('preflight carry route for an adjacent base edit (GST-442)', () => { mergeMain(f, { 'lib/rows.js': src({ 5: 'exports.v5 = "five";', 6: 'exports.v6 = 600;' }) }); const head = f.git('rev-parse', 'HEAD').stdout.trim(); const view = path.join(f.root, 'view.json'); - fs.writeFileSync(view, JSON.stringify({ body: placeReceiptFirst('## Summary\n\nProse.\n', { schema_version: 1, pr: 9, head: reviewed, base: 'main', queue_mode: 'stamp-at-merge', gstack_version: '1.0.0' } as any), headRefOid: head })); + fs.writeFileSync(view, JSON.stringify({ body: placeReceipt('## Summary\n\nProse.\n', { schema_version: 1, pr: 9, head: reviewed, base: 'main', queue_mode: 'stamp-at-merge', gstack_version: '1.0.0' } as any), headRefOid: head })); f.gh(`case "$1 $2" in "pr list") echo 9 ;; "pr view") cat '${view}' ;; *) exit 1 ;; esac`); const r = f.run(path.join(BIN, 'gstack-ship-receipt'), ['carry', '--pr', '9', '--base', 'main', '--dry-run']); expect([r.status, lineOf(r.stdout, 'CARRY')]).toEqual([EXIT.refused, 'CARRY: refused (RECEIPT_CARRY_REFUSED)']); diff --git a/test/ship-workflow-clarity.test.ts b/test/ship-workflow-clarity.test.ts index 7d443885c..b8847b6e6 100644 --- a/test/ship-workflow-clarity.test.ts +++ b/test/ship-workflow-clarity.test.ts @@ -175,7 +175,7 @@ test('Step 18 resolves open PR state before preparing the title file Step 19 pos expectTokens(lookup, ['glab mr list --source-branch <branch-name> --output json'], 'ship Step 18'); const title = compact(between(prTemplate, '### Prepare the title (Step 18)', '## Step 19:')); expectOrdered(title, ['{{FREE_TEXT_FILE:TITLE_FILE=pr-title}}', 'gstack-pr-title-rewrite.sh <new-version> --stdin > "$TITLE_FILE"'], 'ship Step 18 title'); - expectTokens(title, ['`v<NEW_VERSION> <type>: <summary>`', '`v$NEW_VERSION `', "grep -Eq '^v<new-version>( |$)' \"$TITLE_FILE\""], 'ship Step 18 title'); + expectTokens(title, ['`v<NEW_VERSION> <plain change>`', '`v$NEW_VERSION `', "grep -Eq '^v<new-version>( |$)' \"$TITLE_FILE\""], 'ship Step 18 title'); expectAbsent(compact(entryTemplate + prTemplate), ['"<current title>"', 'NEW_TITLE'], 'ship Steps 18-19'); expectTokens(prTemplate, ['--title-file "$TITLE_FILE"', 'exit 1', '**3**'], 'ship pr-body'); const generated = readTemplate('ship/sections/pr-body.md'); diff --git a/test/size-ledger/ship/capy-plain-english-pr-titles-gst-1825.json b/test/size-ledger/ship/capy-plain-english-pr-titles-gst-1825.json new file mode 100644 index 000000000..0f06eedeb --- /dev/null +++ b/test/size-ledger/ship/capy-plain-english-pr-titles-gst-1825.json @@ -0,0 +1,4 @@ +{ + "bytes": 2758, + "why": "/ship writes plain titles, a plain PR opening and a closing Agent notes section, and checks titles before posting" +} diff --git a/test/timeline-stop-hook-slug.test.ts b/test/timeline-stop-hook-slug.test.ts index dd861ec8f..f29681471 100644 --- a/test/timeline-stop-hook-slug.test.ts +++ b/test/timeline-stop-hook-slug.test.ts @@ -38,6 +38,7 @@ const HOOK_FILES = [ 'lib/result-codes-autoplan.ts', 'lib/result-codes-browse.ts', 'lib/result-codes-clean.ts', + 'lib/result-codes-plain.ts', ]; const SLUG = 'stop-hook-slug-test'; const BUDGET_MS = 2000;