v1.91.273.0 feat(ship): PR bodies open with the problem and a Blast radius line, and gstack-prose-lint catches autopilot phrasing before /ship publishes a CHANGELOG entry or PR body (#3501)

* feat(ship): PR bodies lead with the problem and carry a Blast radius line, and gstack-prose-lint checks the CHANGELOG entry and PR body drafts

Absorb lane B (Garry, 2026-10-26: "Do it, send" on plans/absorb-tstack.md item 4 and plans/absorb-pstack.md items 2(a) and 11).

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

* test: register the prose-lint fixtures and NOTICE.md as free-only PR inputs

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

* test: pin prose-lint gate lines and usage exits, use a gstack-native dropped-article example, keep NOTICE.md unmapped

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

* fix: pre-landing review fixes

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

* fix: pre-landing review fixes

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

* fix: pre-landing review fixes

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

* docs: name the CHANGELOG prose-lint gate and the Blast radius body section in the contributor docs

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

* fix(ship): name the evidence ladder by skill instead of by a runtime-root path that does not exist yet

test/runtime-root-assets.test.ts checks every $GSTACK_ROOT/<path> literal in a host render against the staged root; investigate/sections/blast-radius.md lands with its own lane, so the Blast radius item and the Capy body step now say "the /investigate skill's Blast radius section, when present".

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

* docs: name the Blast radius section in the two Capy body-order lists

The docs re-audit found capy/skill/gstack/SKILL.md (Merge asks) and docs/capy-install.md still listing the Capy PR body as What changed, Why, Approvals; both now match capy-pr.md.tmpl.

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

* fix(ship): the compose block's prose-lint trailer goes to stderr, and the publication-gates fixture stages gstack-prose-lint beside gstack-post

test/ship-publication-gates.test.ts runs the real compose block under a HOME whose installed bin dir held only gstack-post, so the block's new gstack-prose-lint call failed with "No such file" on every CI free lane; the fixture now stages both bins the blocks call, and the lint's ok line goes to stderr so the composed body is the only stdout.

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

---------

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
This commit is contained in:
Garry Tanandcapy-ai[bot] authored and GitHub committed 2026-10-27 10:16:08 +00:00
1 parent c1900088d5
commit a1934a1498
29 files changed
+890 -30

No files matched your search

+6
View File
@@ -57,6 +57,12 @@ jobs:
- name: Slop scan (changed files, advisory) - name: Slop scan (changed files, advisory)
run: bun run slop:diff || true run: bun run slop:diff || true
# The CHANGELOG's [Unreleased] block is the entry this PR adds (the train
# stamps it at merge). The lint gates only that block: an older entry, a
# template or a doc gets a report from the same bin and never a red check.
- name: Prose lint (CHANGELOG [Unreleased] block, gating)
run: bun --no-env-file --config=/dev/null bin/gstack-prose-lint CHANGELOG.md --unreleased --gate
- name: Scan changed text for credentials (added lines, own redact engine) - name: Scan changed text for credentials (added lines, own redact engine)
env: env:
BASE_SHA: ${{ github.event.pull_request.base.sha || github.event.before }} BASE_SHA: ${{ github.event.pull_request.base.sha || github.event.before }}
+20
View File
@@ -1,5 +1,25 @@
# Changelog # Changelog
## [1.91.273.0] - 2026-10-27
**A PR body opens with the problem it solves, says what else it touches, and the prose /ship publishes is checked for autopilot tells before it goes out.** A /ship PR body began with a list of what changed, so a reader had to work out the problem from the inventory; nothing in the body said what else the change could break or how sure the author was that it would not; and a CHANGELOG entry or PR body could carry the words a model writes when it is not thinking (`a vibrant tapestry of enhancements`, `not just X, but Y`, an arrow where a sentence belongs) because nothing checked prose mechanically. Garry approved the tstack and pstack absorb plans on 2026-10-26 ("Do it, send"); this release carries tstack item 4 and pstack items 2(a) and 11.
### Added
- **`bin/gstack-prose-lint`, `lib/prose-lint.ts`**: the AI tells a regular expression can decide, as a free check over the files it is given. Eight rules: `vocabulary` (the autopilot word list), `not-just-but`, `metaphor-noun` (each with the plain word to use), `mid-sentence-colon` (a colon doing a conjunction's job; a label, a list introducer or a time is not one), `inline-header-list` (a bold label that restates its line), `arrow` (a measurement such as `1.6740 → 1.7010` and the `before → after` heading pass), `dropped-article` (a bare noun opening a sentence, with compound nouns such as test runs and merge commits left alone) and `unclosed-block` (a fence or HTML comment left open, since nothing after it was read). Fenced code, inline code, URLs, HTML comments and quoted words are never read, so the problem a `## Why` quotes stays in the person's words. `--unreleased` reads only a CHANGELOG's `## [Unreleased]` block, `--base <ref>` only the lines the working tree added since the merge base, and `--sections <a,b>` only the text under those `## ` headings, so an older entry or a verbatim tool section is never judged; `--gate` turns a finding into exit 1 with `PROSE_LINT_FOUND` ([docs/troubleshooting.md#prose-lint-found](docs/troubleshooting.md#prose-lint-found)); without it the run is a report and exits 0. gstack's own templates and docs keep their em dashes, because the lint gates only the CHANGELOG entry and the PR body sections the author writes. The word lists come from pstack's unslop skill (Lauren Tan, MIT; NOTICE.md), the rules are written in gstack's words.
- **`## Blast radius` in the /ship PR body** (`ship/sections/pr-body.md`, and `capy-pr.md` after `## Why` on the Capy path): one or two sentences on what else the change touches and the one fact that makes that safe, with the rung of the evidence ladder the fact reached (a file:line read, a walked failure path, a script that ran, a repro). The ladder itself is the `/investigate` skill's Blast radius section once its own lane lands; until then the item's inline rung list is the ladder.
- **Quality gate workflow step**: `gstack-prose-lint CHANGELOG.md --unreleased --gate` runs on every PR over the `[Unreleased]` block, which is the entry the PR adds until the train stamps it; a stamped or absent block is `none` and passes.
### Changed
- **`ship/sections/pr-body.md` Step 19 `## Summary`**: the first paragraph states the problem in the user's words, then the fix and what they get now; commit themes follow, and the body is never an inventory or a commit list. The instruction carries one BAD/GOOD pair. Step 18's new-PR title keeps its `v<version>` prefix and the text after it names what the user gets, not the mechanism. The compose block runs `gstack-prose-lint --sections 'Why,Summary,Blast radius' --gate` over the first body draft before the drafts are joined, so a review line such as `Problem → what you did`, an approvals row or an image's text is never read, a finding in a section the author wrote stops composition with the lines to rewrite, and a draft that lost one of those headings is a usage error rather than a pass.
- **`ship/sections/capy-pr.md` `## Why`**: the first sentence quotes the person's problem, the rest say what changes for them, with its own BAD/GOOD pair; the section order gains `## Blast radius`.
- **`ship/sections/changelog.md` Step 13**: item 7 lints the lines this branch added to `CHANGELOG.md` (`--base origin/<base> --gate`) and the entry is rewritten until the trailer is `PROSE_LINT: ok`.
### Tests
- `test/prose-lint.test.ts`: one positive and one negative case per rule, masking of code, URLs and comments with line numbers held, the `--unreleased` and `--base` selections against a fixture CHANGELOG and a temporary repository, the exit codes and the `PROSE_LINT_FOUND` line, the `--sections` selection, a multi-line HTML comment masked through its close, the template and workflow wiring, and the templates themselves getting a report rather than a gate. Fixtures: `test/fixtures/prose-lint/{slopped,clean}-changelog.md`.
### Gate integrity
- No threshold, timeout, trial count or case kind changed. The ship parity `maxSizeRatio` in `test/helpers/carve-guards.ts` moves 1.7400 → 1.7500 (measured 328,319 / 187,706 = 1.7491 on the tree with v1.91.270.0 merged) for the pr-body, capy-pr and changelog section text above; this lane leaves the ship skeleton untouched, so no host pin in `test/fixtures/skill-md-size-allowlist.json` moves. The raise is this lane's own call, made under Garry's 2026-10-26 "Do it, send" on the two absorb plans (which budget these ship sections; the quote approves the plans, not the number); the skeleton is unchanged, so no host pin moves.
## [1.91.272.0] - 2026-10-27 ## [1.91.272.0] - 2026-10-27
### Added ### Added
+1 -1
View File
@@ -621,7 +621,7 @@ A GitHub Action (`.github/workflows/skill-docs.yml`) generates all hosts on push
Supply-chain gates run alongside it: Supply-chain gates run alongside it:
- **Quality gate** (`.github/workflows/quality-gate.yml`, every PR and push) — scans the diff's added lines for credentials using gstack's own redact engine (`.github/scripts/gate-secret-scan.mjs`). HIGH findings fail the job; MEDIUM findings surface as an advisory count. Fails closed if the scan can't produce a report. Also runs ShellCheck on the setup/build boundaries. - **Quality gate** (`.github/workflows/quality-gate.yml`, every PR and push) — scans the diff's added lines for credentials using gstack's own redact engine (`.github/scripts/gate-secret-scan.mjs`). HIGH findings fail the job; MEDIUM findings surface as an advisory count. Fails closed if the scan can't produce a report. Also runs ShellCheck on the setup/build boundaries, and gates the `## [Unreleased]` block of `CHANGELOG.md` with `bin/gstack-prose-lint CHANGELOG.md --unreleased --gate` (autopilot vocabulary, "not just X, but Y" and the other tells it names; a stamped or absent block passes; fixes in [docs/troubleshooting.md](docs/troubleshooting.md#prose-lint-found)).
- **Dependency review** (`.github/workflows/dependency-review.yml`) — reviews dependency changes on PRs that touch `package.json` or `bun.lock` files and fails on high or critical advisories. It and the weekly OSV scan are the dependency gates. - **Dependency review** (`.github/workflows/dependency-review.yml`) — reviews dependency changes on PRs that touch `package.json` or `bun.lock` files and fails on high or critical advisories. It and the weekly OSV scan are the dependency gates.
- **OSV scanner** (`.github/workflows/osv-scanner.yml`) — weekly vulnerability scan against the OSV database. Config lives in `.osv-scanner.toml` and is loaded via an explicit `--config` flag (OSV does not auto-discover that filename); every ignore entry needs a reason and an `ignoreUntil` expiry, enforced by `test/osv-config-wiring.test.ts`. A failed main scan upserts the tracking issue "OSV scanner: vulnerable dependency needs triage" and a clean scan closes it; `test/osv-ignore-expiry.test.ts` fails 14 days before any `ignoreUntil`, so an expiring suppression surfaces in a PR. - **OSV scanner** (`.github/workflows/osv-scanner.yml`) — weekly vulnerability scan against the OSV database. Config lives in `.osv-scanner.toml` and is loaded via an explicit `--config` flag (OSV does not auto-discover that filename); every ignore entry needs a reason and an `ignoreUntil` expiry, enforced by `test/osv-config-wiring.test.ts`. A failed main scan upserts the tracking issue "OSV scanner: vulnerable dependency needs triage" and a clean scan closes it; `test/osv-ignore-expiry.test.ts` fails 14 days before any `ignoreUntil`, so an expiring suppression surfaces in a PR.
- **Weekly test health** (`.github/workflows/test-health.yml`, Mondays and on dispatch) — runs `bun run test:health --since-days 7 --enforce`. It fails when a free test file flakes in more than 5% of at least 20 main runs or more than 5 free files are missing from the duration seed, upserts one tracking issue on failure and closes it only when every previously failing check has evidence and passes. Metrics it cannot read print "unavailable: <reason>; next: <step>" and never fail the run. - **Weekly test health** (`.github/workflows/test-health.yml`, Mondays and on dispatch) — runs `bun run test:health --since-days 7 --enforce`. It fails when a free test file flakes in more than 5% of at least 20 main runs or more than 5 free files are missing from the duration seed, upserts one tracking issue on failure and closes it only when every previously failing check has evidence and passes. Metrics it cannot read print "unavailable: <reason>; next: <step>" and never fail the run.
+10
View File
@@ -152,3 +152,13 @@ Derived, modified:
- `bin/gstack-worktree-audit`: `pstack/skills/poteto-mode/scripts/worktree-audit.sh`, - `bin/gstack-worktree-audit`: `pstack/skills/poteto-mode/scripts/worktree-audit.sh`,
with the PR column read through `gh --jq` alone, the chat column read from with the PR column read through `gh --jq` alone, the chat column read from
Claude Code's per-worktree transcript directory, and a `--no-fetch` flag. Claude Code's per-worktree transcript directory, and a `--no-fetch` flag.
## pstack (unslop) — Copyright Lauren Tan — MIT License
https://github.com/cursor/plugins (`pstack/skills/unslop/SKILL.md`, at 99b609a7). The
word lists in `lib/prose-lint.ts` (the autopilot vocabulary and the metaphor nouns with
their plain replacements) are facts taken from unslop's rules 7 and 26; the rules a
regular expression decides (the "not just X, but Y" shape, the mid-sentence colon, the
bold label that restates its line, arrows and dropped articles) are written in gstack's
words, scoped to the CHANGELOG entry and PR body /ship writes, and report-only elsewhere.
No sentence is copied, so no license text is carried; this entry is a courtesy.
+1 -1
View File
@@ -1 +1 @@
1.91.272.0 1.91.273.0
+1 -1
View File
@@ -1,4 +1,4 @@
# gstack digest v1.91.272.0 — regenerate/re-copy after upgrading gstack # gstack digest v1.91.273.0 — regenerate/re-copy after upgrading gstack
Behavioral rules from gstack (https://github.com/garrytan/gstack), compressed Behavioral rules from gstack (https://github.com/garrytan/gstack), compressed
for agent hosts without a full skill install. The full skills add workflows, for agent hosts without a full skill install. The full skills add workflows,
+1
View File
@@ -304,6 +304,7 @@ gstack_result_anchor() {
REVIEW_SCOPE_INPUT) printf '%s' 'review-scope-input' ;; REVIEW_SCOPE_INPUT) printf '%s' 'review-scope-input' ;;
REVIEW_SCOPE_RANGE) printf '%s' 'review-scope-range' ;; REVIEW_SCOPE_RANGE) printf '%s' 'review-scope-range' ;;
RECEIPT_CARRY_REFUSED) printf '%s' 'receipt-carry-refused' ;; RECEIPT_CARRY_REFUSED) printf '%s' 'receipt-carry-refused' ;;
PROSE_LINT_FOUND) printf '%s' 'prose-lint-found' ;;
RECEIPT_CARRY_PRODUCT_CHANGED) printf '%s' 'receipt-carry-product-changed' ;; RECEIPT_CARRY_PRODUCT_CHANGED) printf '%s' 'receipt-carry-product-changed' ;;
DOCS_AUDIT_BLOCKED) printf '%s' 'docs-audit-blocked' ;; DOCS_AUDIT_BLOCKED) printf '%s' 'docs-audit-blocked' ;;
RECEIPT_PROSE_MISMATCH) printf '%s' 'receipt-prose-mismatch' ;; RECEIPT_PROSE_MISMATCH) printf '%s' 'receipt-prose-mismatch' ;;
+72
View File
@@ -0,0 +1,72 @@
#!/usr/bin/env -S bun --no-env-file --config=/dev/null
/**
* gstack-prose-lint — the AI tells a regular expression can decide, over the
* prose /ship writes (docs/troubleshooting.md#prose-lint-found).
*
* gstack-prose-lint <file>... [--unreleased | --base <ref> | --sections <a,b>] [--gate] [--json]
*
* Scans each file (fenced code, inline code, URLs, comments and quoted words excluded) and
* prints one `PROSE:` line per finding, then `PROSE_LINT: ok | found=<n> | none`.
* --unreleased reads only each file's `## [Unreleased]` CHANGELOG block,
* --base <ref> only the lines the working tree adds since the merge base with
* <ref>, and --sections <a,b> only the text under those `## ` headings (nothing
* to scan is `none`). Exit 0 unless --gate is set and something was found
* (exit 1); unreadable input is exit 2. Report-only everywhere else: a
* template, a doc or a README gets the report and keeps its em dashes.
* Logic: lib/prose-lint.ts. Called from /ship Steps 13 and 19 and the
* quality-gate workflow.
*/
import * as fs from 'node:fs';
import { EXIT, exitTableHelp } from '../lib/headless-artifacts';
import { lintFiles, renderProseLint } from '../lib/prose-lint';
import { resultLine } from '../lib/result-codes';
const HELP = `gstack-prose-lint — AI tells a regex can decide, over CHANGELOG entries and PR bodies (docs/troubleshooting.md#prose-lint-found)
<file>... files to scan; fenced code, inline code, URLs and HTML comments are skipped
--unreleased scan only each file's ## [Unreleased] CHANGELOG block (none present: PROSE_LINT: none)
--base <ref> scan only the lines the working tree adds to each file since the merge base with <ref>
--sections <a,b> scan only the text under these ## headings (the sections an author writes in a PR body)
--gate exit 1 when anything was found (default: report and exit 0)
--json findings as JSON instead of PROSE: lines
Rules: vocabulary, not-just-but, metaphor-noun, mid-sentence-colon, inline-header-list, arrow, dropped-article,
and unclosed-block (a fence or HTML comment left open hid the text after it). Quoted words are never read.
--sections with --gate exits 2 when a file has none of the headings, so a renamed heading cannot pass unread.
${exitTableHelp()}`;
function main(argv: string[]): number {
if (argv.length === 0 || argv.includes('--help') || argv.includes('-h')) { process.stdout.write(HELP + '\n'); return argv.length ? EXIT.ok : EXIT.usage; }
const flags = new Set<string>();
const files: string[] = [];
let base: string | undefined;
let sections: string[] | undefined;
for (let i = 0; i < argv.length; i++) {
const a = argv[i]!;
if (a === '--base') { base = argv[++i]; if (!base || base.startsWith('--')) { process.stderr.write('gstack-prose-lint: --base needs a ref\n'); return EXIT.usage; } }
else if (a === '--sections') { const v = argv[++i]; if (!v || v.startsWith('--')) { process.stderr.write('gstack-prose-lint: --sections needs a comma-separated list of headings\n'); return EXIT.usage; } sections = v.split(','); }
else if (['--unreleased', '--gate', '--json'].includes(a)) flags.add(a);
else if (a.startsWith('--')) { process.stderr.write(`gstack-prose-lint: unknown flag ${a}\n${HELP}\n`); return EXIT.usage; }
else files.push(a);
}
if (files.length === 0) { process.stderr.write('gstack-prose-lint: name at least one file\n'); return EXIT.usage; }
if ([base, sections, flags.has('--unreleased') || undefined].filter(Boolean).length > 1) { process.stderr.write('gstack-prose-lint: --unreleased, --base and --sections are different selections; pass one\n'); return EXIT.usage; }
for (const f of files) if (!fs.existsSync(f) || fs.statSync(f).isDirectory()) { process.stderr.write(`gstack-prose-lint: cannot read ${f}\n`); return EXIT.usage; }
let result;
try { result = lintFiles(files, { unreleased: flags.has('--unreleased'), base, sections }); }
catch (e: any) { process.stderr.write(`gstack-prose-lint: ${e.message}\n`); return EXIT.usage; }
if (sections && flags.has('--gate') && result.empty.length) {
process.stderr.write(`gstack-prose-lint: no "## <section>" heading from --sections in ${result.empty.join(', ')}; nothing would be gated\n`);
return EXIT.usage;
}
if (flags.has('--json')) process.stdout.write(JSON.stringify({ findings: result.findings, scanned: result.scanned, empty: result.empty }, null, 2) + '\n');
else process.stdout.write(renderProseLint(result));
if (result.findings.length && flags.has('--gate')) {
process.stderr.write(resultLine('gstack-prose-lint', 'PROSE_LINT_FOUND', `${result.findings.length} finding${result.findings.length === 1 ? '' : 's'}`) + '\n');
return EXIT.fail;
}
return EXIT.ok;
}
process.exit(main(process.argv.slice(2)));
+1 -1
View File
@@ -51,7 +51,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. - **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 "<their exact words>" --by … --scope …`, or after an autoplan answer, `gstack-approvals import --autoplan <run dir> --by <name>`. `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. - **Record every answer.** Use `gstack-approvals add --ask … --words "<their exact words>" --by … --scope …`, or after an autoplan answer, `gstack-approvals import --autoplan <run dir> --by <name>`. `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 <run dir>`. 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, with `rec` as the recommended one-word reply. 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. - **Plan approvals** use `gstack-owner-brief render --captain --out <run dir>`. 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, with `rec` as the recommended one-word reply. 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` and `## Approvals`. Your READY report attaches the key image. - **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.
## Before any push or pull request ## Before any push or pull request
+3
View File
@@ -62,6 +62,9 @@ the user gets; the path to that system is invisible to them.
**When to write the CHANGELOG entry:** **When to write the CHANGELOG entry:**
- At `/ship` time (Step 13), not during development or mid-branch. - At `/ship` time (Step 13), not during development or mid-branch.
- The entry covers ALL commits on this branch vs the base branch. - The entry covers ALL commits on this branch vs the base branch.
- `/ship` lints the lines the branch added with `bin/gstack-prose-lint CHANGELOG.md --base origin/<base> --gate`,
and the Quality gate workflow gates the `## [Unreleased]` block the same way. Rewrite each `PROSE:` line
it names ([docs/troubleshooting.md](troubleshooting.md#prose-lint-found)).
- Never fold new work into an existing CHANGELOG entry from a prior version that - Never fold new work into an existing CHANGELOG entry from a prior version that
already landed on main. If main has v0.10.0.0 and your branch adds features, already landed on main. If main has v0.10.0.0 and your branch adds features,
bump to v0.10.1.0 with a new entry — don't edit the v0.10.0.0 entry. bump to v0.10.1.0 with a new entry — don't edit the v0.10.0.0 entry.
+2 -2
View File
@@ -97,7 +97,7 @@ gstack/
├── docs/designs/ # Design documents (incl. IMPECCABLE_INTEROP.md = the design detector / catalog / open DESIGN.md record, and fork-port-residual-2026-09/ evaluation evidence) ├── docs/designs/ # Design documents (incl. IMPECCABLE_INTEROP.md = the design detector / catalog / open DESIGN.md record, and fork-port-residual-2026-09/ evaluation evidence)
├── setup-deploy/ # /setup-deploy skill (one-time deploy config) ├── setup-deploy/ # /setup-deploy skill (one-time deploy config)
├── .github/ # CI workflows + shared composite actions (.github/actions/) + Docker image (claude CLI pinned) ├── .github/ # CI workflows + shared composite actions (.github/actions/) + Docker image (claude CLI pinned)
│ ├── workflows/ # evals.yml (E2E on Ubicloud), quality-gate.yml (secret scan), dependency-review.yml, osv-scanner.yml, skill-docs.yml, actionlint.yml, plus free-tests, Windows, periodic and marathon evals, CSO release, platform qualification and smaller gates │ ├── workflows/ # evals.yml (E2E on Ubicloud), quality-gate.yml (secret scan, CHANGELOG prose lint), dependency-review.yml, osv-scanner.yml, skill-docs.yml, actionlint.yml, plus free-tests, Windows, periodic and marathon evals, CSO release, platform qualification and smaller gates
│ └── docker/ # Dockerfile.ci (pre-baked toolchain + Playwright/Chromium) │ └── docker/ # Dockerfile.ci (pre-baked toolchain + Playwright/Chromium)
├── contrib/ # Contributor-only tools (never installed for users) ├── contrib/ # Contributor-only tools (never installed for users)
│ └── add-host/ # /gstack-contrib-add-host skill │ └── add-host/ # /gstack-contrib-add-host skill
@@ -105,7 +105,7 @@ gstack/
├── SKILL.md # Generated from SKILL.md.tmpl (don't edit directly) ├── SKILL.md # Generated from SKILL.md.tmpl (don't edit directly)
├── SKILL.md.tmpl # Template: edit this, run gen:skill-docs ├── SKILL.md.tmpl # Template: edit this, run gen:skill-docs
├── ETHOS.md # Builder philosophy (Boil the Ocean, Search Before Building) ├── ETHOS.md # Builder philosophy (Boil the Ocean, Search Before Building)
├── NOTICE.md # Third-party notices: material derived from impeccable and the DESIGN.md spec (Apache-2.0), Elaya's design skills and hallmark's sloplint (MIT) ├── NOTICE.md # Third-party notices: material derived from impeccable and the DESIGN.md spec (Apache-2.0), Elaya's design skills and hallmark's sloplint (MIT), plus courtesy entries for tstack and pstack ideas and word lists (MIT; no license text carried)
├── licenses/ # Verbatim license texts for the notices above (Apache-2.0.txt, MIT-elaya.txt, MIT-hallmark.txt) ├── licenses/ # Verbatim license texts for the notices above (Apache-2.0.txt, MIT-elaya.txt, MIT-hallmark.txt)
└── package.json # Build scripts for browse └── package.json # Build scripts for browse
``` ```
+1 -1
View File
@@ -165,7 +165,7 @@ a push included. The sequence that works unattended on Capy:
to the current tree. to the current tree.
6. Docs audit: snapshot the documentation candidates read-only and record the vetted 6. Docs audit: snapshot the documentation candidates read-only and record the vetted
section. section.
7. Body and receipt: compose the PR body (What changed, Why, Approvals first on Capy), 7. Body and receipt: compose the PR body (What changed, Why, Blast radius, Approvals first on Capy),
write the ship receipt into it, post body and title. write the ship receipt into it, post body and title.
8. Terminal line: `gstack-ship-receipt result --pr <n>` prints 8. Terminal line: `gstack-ship-receipt result --pr <n>` prints
`GSTACK_RESULT: skill=ship status=complete`. `GSTACK_RESULT: skill=ship status=complete`.
+1 -1
View File
@@ -490,7 +490,7 @@ thread asks its Captain, otherwise the person), plan approvals are rendered
with `gstack-owner-brief render --captain`, answers are recorded with with `gstack-owner-brief render --captain`, answers are recorded with
`gstack-approvals` (mirrored to the Captain's drive `approvals/` directory when the `gstack-approvals` (mirrored to the Captain's drive `approvals/` directory when the
Captain created one, so the crew can read every lane's record), and /ship's PR body opens Captain created one, so the crew can read every lane's record), and /ship's PR body opens
with before/after images (`gstack-pr-shots`), Why and Approvals. Every autoplan gate with before/after images (`gstack-pr-shots`), Why, Blast radius and Approvals. Every autoplan gate
prints its cost (`GATE_SPEND:`, `GATE_REC_COST:`; `autoplan/DRIVER.md` says how to relay prints its cost (`GATE_SPEND:`, `GATE_REC_COST:`; `autoplan/DRIVER.md` says how to relay
them). The guide is them). The guide is
[`capy/skill/gstack/captain.md`](../capy/skill/gstack/captain.md); [`capy/skill/gstack/captain.md`](../capy/skill/gstack/captain.md);
+34
View File
@@ -4852,6 +4852,40 @@ every flag and code.
**Fix.** Rerun once; if it repeats, report the printed frame to the gstack maintainers. **Fix.** Rerun once; if it repeats, report the printed frame to the gstack maintainers.
## Prose lint (gstack-prose-lint)
`bin/gstack-prose-lint` reads the prose /ship is about to publish, a CHANGELOG
entry (Step 13) and the sections the author writes in the PR body (Step 19:
`## Why`, `## Summary` and `## Blast radius`, through `--sections`; the
`## What changed` captions and image text stay a report), and the quality-gate
workflow runs it over `CHANGELOG.md`'s `[Unreleased]` block. It decides only
what a regular expression can: autopilot vocabulary, "not just X, but Y",
metaphor nouns, a colon used as a conjunction, a bold label that restates its
line, an arrow or a dropped article standing in for a sentence, and a fence or
HTML comment left open (`unclosed-block`), because nothing after it was read.
Fenced code, inline code, URLs, HTML comments and quoted words are never read,
so the problem quoted in `## Why` is the person's to phrase. Everywhere else (a
template, a doc, this file) it is a report: run it, read it, keep your em dashes.
<a id="prose-lint-found"></a>
### `PROSE_LINT_FOUND`
**Meaning.** A gated run (`--gate`) found at least one tell in the text it was
given. Each `PROSE: <file>:<line> <rule> "<match>" (<hint>)` line above the
trailer names the rule and what to write instead; `PROSE_LINT: found=<n>` counts
them. With `--unreleased` only the `## [Unreleased]` block was read; with
`--base <ref>` only the lines the working tree added since the merge base; with
`--sections <a,b>` only the text under those `## ` headings (under `--gate`, a
file with none of them is exit 2, not a pass: the draft lost a heading).
**Fix.** Rewrite each named line in the draft the way its hint says (a measurement
such as `1.6740 → 1.7010` and the `before → after` heading are not findings), then
rerun the same command. The PR body's verbatim tool sections (the approvals
table, issue links, review findings, the coverage diagram) are outside
`--sections` and are never read, so a finding there means the compose block was
edited: restore its `--sections` list. A rule that keeps flagging correct prose
is a bug in `lib/prose-lint.ts`: file it with the sentence.
## Quick answers ## Quick answers
The short troubleshooting entries that used to live in the README. Each names the symptom and the fix; the sections above cover every gate message in depth. The short troubleshooting entries that used to live in the README. Each names the symptom and the fix; the sections above cover every gate message in depth.
+275
View File
@@ -0,0 +1,275 @@
/**
* prose-lint — the AI tells a regular expression can decide, as a free check
* over the prose /ship writes: a CHANGELOG entry and a PR body. Ideas from
* pstack's unslop skill (Lauren Tan, MIT; NOTICE.md); the rule text is
* gstack's and the word lists are facts. Everything else about voice stays
* with the reader: this file never judges tone, length or metaphor it cannot
* match. Fenced code, inline code, URLs, HTML comments and quoted words (someone
* else's) are never scanned; a fence or comment left open is itself a finding,
* because the text after it went unread.
* `bin/gstack-prose-lint` prints one line per finding and a
* `PROSE_LINT: ok | found=<n> | none` trailer; it gates only when asked
* (`--gate`), so templates and docs get a report and nothing more.
*/
import * as fs from 'node:fs';
import { spawnSync } from 'node:child_process';
import { addedLines } from './banned-terms';
import { parseChangelog } from './changelog-check';
export type ProseRuleId =
| 'vocabulary'
| 'not-just-but'
| 'metaphor-noun'
| 'mid-sentence-colon'
| 'inline-header-list'
| 'arrow'
| 'dropped-article'
| 'unclosed-block';
export interface ProseFinding {
rule: ProseRuleId;
file: string;
line: number;
/** The matched text, trimmed to 80 characters. */
match: string;
/** What to do instead, in one clause. */
hint: string;
}
export interface ProseLintResult {
findings: ProseFinding[];
/** Files that were read. */
scanned: string[];
/** Files that had no text to scan (no `[Unreleased]` block, or no added lines). */
empty: string[];
}
/** Words that mark text as written on autopilot. Replace each with the plain word. */
export const VOCABULARY = [
'additionally', 'crucial', 'delve', 'delves', 'delving', 'enduring', 'enhance', 'enhances', 'enhanced', 'enhancing',
'fostering', 'fosters', 'garner', 'garners', 'garnered', 'interplay', 'intricate', 'pivotal', 'showcase', 'showcases',
'showcasing', 'testament', 'underscores', 'underscored', 'underscoring', 'vibrant',
] as const;
/** Nouns borrowed from engineering or geography to make an ordinary thing sound structural. */
export const METAPHOR_NOUNS: ReadonlyArray<{ word: string; plain: string }> = [
{ word: 'substrate', plain: 'base' },
{ word: 'wedge', plain: 'the thing itself, or "add"' },
{ word: 'locus', plain: 'place' },
{ word: 'vantage', plain: 'view' },
{ word: 'nexus', plain: 'link or center' },
{ word: 'bedrock', plain: 'base' },
{ word: 'modality', plain: 'mode or kind' },
{ word: 'paradigm', plain: 'model or approach' },
{ word: 'gold-plating', plain: 'more than the job needs' },
{ word: 'north star', plain: 'goal' },
{ word: 'flywheel', plain: 'the loop you mean' },
{ word: 'endgame', plain: 'last phase' },
{ word: 'API surface', plain: 'the API, or the exported names' },
{ word: 'evacuate', plain: 'move out' },
];
const VOCABULARY_RE = new RegExp(String.raw`\b(?:${VOCABULARY.join('|')})\b`, 'gi');
const LANDSCAPE_RE = /\b(?:landscape|tapestry)\b(?!\s+(?:mode|orientation|image|photo|print))/gi;
const METAPHOR_RE = new RegExp(String.raw`\b(?:${METAPHOR_NOUNS.map(m => m.word.replace('-', '[- ]')).join('|')})s?\b`, 'gi');
const NOT_JUST_BUT_RE = /(?:\bnot|n['’]t)\s+(?:just|only|merely|simply)\b[^.!?\n]{0,120}?\bbut\b/gi;
/** A sentence of four or more words, a colon, then a lowercase clause: the colon is doing a conjunction's job. */
const MID_COLON_RE = /(?:^[ \t]*(?:(?:[-*+]|\d+\.)[ \t]+)?|[.!?][ \t]+)((?:[A-Za-z][\w'’-]*[ \t,]+){3,}[A-Za-z][\w'’-]*): ([a-z][\w'’-]*(?:[ \t]+[\w'’-]+){2,})/gm;
const INLINE_HEADER_RE = /^\s*(?:[-*+]|\d+\.)\s+\*\*([^*\n]{1,60}?):?\*\*:?\s+(\S+)/;
const ARROW_RE = /(\S{1,80})[ \t]*(→|->|=>|⇒)[ \t]*(\S{1,80})/g;
const SENTENCE_START_RE = /(?:^|[.!?]\s+|^\s*(?:[-*+]|\d+\.)\s+)([A-Z][a-z]+)\s+([a-z]+s)\b(?=\s+[a-z])/gm;
const BARE_NOUNS = new Set([
'parser', 'user', 'agent', 'reviewer', 'runner', 'command', 'script', 'test', 'file', 'branch', 'build', 'gate',
'receipt', 'train', 'lane', 'thread', 'model', 'caller', 'server', 'client', 'daemon', 'process', 'worker',
'function', 'helper', 'hook', 'skill', 'section', 'template', 'body', 'title', 'entry', 'step', 'loop', 'check',
'linter', 'workflow', 'pipeline', 'job', 'tool', 'flag', 'option', 'config', 'policy', 'rule', 'finding', 'issue',
'request', 'response', 'result', 'output', 'error', 'warning', 'page', 'button', 'form', 'field', 'input', 'token',
'key', 'value', 'record', 'row', 'table', 'column', 'query', 'index', 'cache', 'queue', 'message', 'event',
'handler', 'listener', 'plugin', 'driver', 'module', 'package', 'library', 'dependency', 'version', 'release',
'commit', 'merge', 'rebase', 'diff', 'patch', 'compiler', 'loader', 'scheduler', 'watcher', 'migration', 'schema',
'endpoint', 'route', 'controller', 'service', 'container', 'image', 'cluster', 'node', 'deploy', 'install',
'installer', 'upgrade', 'session', 'browser', 'tab', 'window', 'dialog', 'prompt', 'reply', 'answer', 'question',
]);
/** Verbs that are rarely plural nouns: "Test runs", "Error logs" and "Merge commits" are compound nouns, so runs, logs, calls, checks, passes, builds, counts, records, reports and their kind are left out. */
const VERBS = new Set([
'rejects', 'refuses', 'accepts', 'emits', 'throws', 'renders', 'resolves', 'retries', 'restarts', 'tells',
'validates', 'verifies', 'scans', 'lints', 'compiles', 'installs', 'upgrades', 'fetches', 'stamps', 'reheads',
'owns', 'carries', 'loses', 'parses', 'creates', 'deletes', 'updates', 'handles', 'hides', 'keeps', 'skips',
'adds', 'removes', 'writes', 'reads', 'prints', 'sends', 'exits', 'fails', 'needs', 'takes', 'uses',
]);
const NUMERIC_SIDE = /^[$£€]?[+-]?\d|^v\d|^CODE$|^URL$|^(?:before|after)$/i;
const LIST_INTRODUCER = /\b(?:examples?|following|these|this|below|measurements?|two|three|four|five|six)$/i;
const trimSide = (s: string) => s.replace(/^[(\[`"'*_]+|[)\]`"'*_,.;:]+$/g, '');
const FENCE_OPEN_RE = /^\s*(`{3,}|~{3,})/;
const FENCE_CLOSE_RE = /^\s*(`{3,}|~{3,})\s*$/;
const pad = (m: string) => ' '.repeat(m.length);
/**
* Walk the lines once, tracking the fenced-block and HTML-comment state a Markdown
* renderer would. `inside` is true for a line that is wholly inside a fence or a
* comment; a line that opens or closes one is reported with the text outside it.
* A fence closes only on a bare fence line of the same kind and at least its length.
*/
function walkBlocks(text: string, visit: (line: string, inside: boolean, index: number) => void): { kind: 'fence' | 'comment'; line: number } | null {
let fence: string | null = null;
let fenceLine = 0, commentLine = 0;
let comment = false;
text.split('\n').forEach((raw, i) => {
if (fence) {
const close = FENCE_CLOSE_RE.exec(raw);
if (close && close[1]![0] === fence[0] && close[1]!.length >= fence.length) fence = null;
return visit(raw, true, i);
}
let line = raw;
if (comment) {
const end = line.indexOf('-->');
if (end < 0) return visit(raw, true, i);
comment = false;
line = ' '.repeat(end + 3) + line.slice(end + 3);
}
const open = FENCE_OPEN_RE.exec(line);
if (open) { fence = open[1]!; fenceLine = i + 1; return visit(raw, true, i); }
line = line
.replace(/(`+)([^`\n](?:[^\n]*?[^`\n])?)\1(?!`)/g, (m) => 'CODE' + ' '.repeat(Math.max(0, m.length - 4)))
.replace(/\]\([^)\s]+\)/g, (m) => ']' + ' '.repeat(m.length - 1))
.replace(/https?:\/\/(?:(?!-->)\S)+/g, (m) => 'URL' + ' '.repeat(Math.max(0, m.length - 3)));
const tail = line.lastIndexOf('<!--');
if (tail >= 0 && line.indexOf('-->', tail) < 0) { comment = true; commentLine = i + 1; line = line.slice(0, tail); }
visit(line.replace(/<!--[\s\S]*?-->/g, pad), false, i);
});
return fence ? { kind: 'fence', line: fenceLine } : comment ? { kind: 'comment', line: commentLine } : null;
}
/** Replace fenced blocks, inline code, links' targets, URLs, HTML comments and quoted words with same-length placeholders so line numbers hold. A fence or comment left open at the end is reported, since everything after it went unread. */
export function maskProse(text: string): { masked: string; unclosed: { kind: 'fence' | 'comment'; line: number } | null } {
const out: string[] = [];
const unclosed = walkBlocks(text, (line, inside) => {
out.push(inside ? '' : line.replace(/(["“])[^"“”\n]{1,400}(["”])/g, pad));
});
return { masked: out.join('\n'), unclosed };
}
function excerpt(s: string): string {
const t = s.replace(/\s+/g, ' ').trim();
return t.length > 80 ? t.slice(0, 77) + '...' : t;
}
/** Lint one text; `file` is only carried into the findings. Lines are 1-based from the text's own start plus `offset`. */
export function lintProse(text: string, file: string, offset = 0): ProseFinding[] {
const { masked, unclosed } = maskProse(text);
const lines = masked.split('\n');
const findings: ProseFinding[] = [];
const push = (rule: ProseRuleId, line: number, match: string, hint: string) => findings.push({ rule, file, line: line + offset, match: excerpt(match), hint });
const lineAt = (index: number) => masked.slice(0, index).split('\n').length;
if (unclosed) push('unclosed-block', unclosed.line, unclosed.kind === 'fence' ? 'fence never closed' : 'HTML comment never closed', 'close it; nothing after it was read');
lines.forEach((line, i) => {
const n = i + 1;
for (const m of line.matchAll(VOCABULARY_RE)) push('vocabulary', n, m[0], 'use the plain word');
for (const m of line.matchAll(LANDSCAPE_RE)) push('vocabulary', n, m[0], 'name the actual set of things');
for (const m of line.matchAll(METAPHOR_RE)) {
const row = METAPHOR_NOUNS.find(r => new RegExp(String.raw`^${r.word.replace('-', '[- ]')}s?$`, 'i').test(m[0]));
push('metaphor-noun', n, m[0], `say "${row?.plain ?? 'the concrete thing'}"`);
}
for (const m of line.matchAll(NOT_JUST_BUT_RE)) push('not-just-but', n, m[0], 'state the point once, without the contrast');
const header = INLINE_HEADER_RE.exec(line);
if (header) {
const label = header[1]!.replace(/[*`]/g, '').trim().split(/\s+/)[0]!.toLowerCase().replace(/[^a-z0-9]/g, '');
const first = header[2]!.toLowerCase().replace(/[^a-z0-9]/g, '');
if (label && label.length > 2 && label !== 'code' && first === label) push('inline-header-list', n, header[0], 'drop the label or make the line say something the label does not');
}
for (const m of line.matchAll(ARROW_RE)) {
const left = trimSide(m[1]!), right = trimSide(m[3]!);
if (!left || !right || (NUMERIC_SIDE.test(left) && NUMERIC_SIDE.test(right))) continue;
push('arrow', n, m[0], 'write the sentence the arrow stands for');
}
});
for (const m of masked.matchAll(MID_COLON_RE)) if (!LIST_INTRODUCER.test(m[1]!)) push('mid-sentence-colon', lineAt(m.index! + m[0].indexOf(':')), `${m[1]!.split(/\s+/).slice(-3).join(' ')}: ${m[2]!.split(/\s+/).slice(0, 3).join(' ')}`, 'end the sentence there, or join the halves with a plain conjunction');
for (const m of masked.matchAll(SENTENCE_START_RE)) {
if (BARE_NOUNS.has(m[1]!.toLowerCase()) && VERBS.has(m[2]!)) push('dropped-article', lineAt(m.index! + m[0].indexOf(m[1]!)), `${m[1]} ${m[2]}`, 'start with "the" or "a"');
}
return findings.sort((a, b) => a.line - b.line || a.rule.localeCompare(b.rule));
}
/** The `## [Unreleased]` block of a CHANGELOG, with the 1-based line its heading sits on; null when there is none. */
export function unreleasedBlock(text: string): { text: string; line: number } | null {
const section = parseChangelog(text).sections.find(s => s.unreleased);
if (!section) return null;
return { text: section.lines.join('\n'), line: section.start + 1 };
}
/** The text with every line outside the named `## ` sections blanked, so line numbers hold; null when none of them is present. */
export function sectionsOnly(text: string, names: string[]): string | null {
const wanted = new Set(names.map(n => n.trim().toLowerCase()).filter(Boolean));
const raw = text.split('\n');
let keep = false, found = false;
const out: string[] = [];
walkBlocks(text, (line, inside, i) => {
const h = inside ? null : /^##\s+(.+?)\s*$/.exec(line);
if (h) { keep = wanted.has(h[1]!.toLowerCase()); found ||= keep; out.push(''); return; }
out.push(keep ? raw[i]! : '');
});
return found ? out.join('\n') : null;
}
export interface LintFilesOptions {
/** Scan only each file's `[Unreleased]` CHANGELOG block. */
unreleased?: boolean;
/** Scan only the text under these `## ` headings (the sections an author writes in a PR body). */
sections?: string[];
/** Scan only the lines the working tree adds to each file since the merge base with this ref (the ref itself when no merge base resolves). */
base?: string;
}
/** The file's whole text plus the 1-based lines the branch added, so fences and comments around an added line still mask it; `added` is null for an untracked file (read whole) and the result is null when nothing was added. */
export function addedOnly(file: string, base: string): { text: string; added: Set<number> | null } | null {
const run = (args: string[]) => spawnSync('git', args, { encoding: 'utf8', timeout: 30_000 });
const text = fs.readFileSync(file, 'utf8');
if (run(['ls-files', '--error-unmatch', '--', file]).status !== 0) return { text, added: null };
const mb = run(['merge-base', base, 'HEAD']);
const since = mb.status === 0 ? mb.stdout.trim() : base;
const diff = run(['diff', '--unified=0', '--no-color', '--no-ext-diff', since, '--', file]);
if (diff.status !== 0) throw new Error(`git diff ${since} -- ${file} failed: ${diff.stderr.trim()}`);
const added = [...addedLines(diff.stdout).values()].flat();
if (added.length === 0) return null;
return { text, added: new Set(added.map(l => l.line)) };
}
export function lintFiles(files: string[], opts: LintFilesOptions = {}): ProseLintResult {
const result: ProseLintResult = { findings: [], scanned: [], empty: [] };
for (const file of files) {
const text = fs.readFileSync(file, 'utf8');
if (opts.base) {
const added = addedOnly(file, opts.base);
if (added === null) { result.empty.push(file); continue; }
result.scanned.push(file);
result.findings.push(...lintProse(added.text, file).filter(f => !added.added || added.added.has(f.line)));
continue;
}
if (opts.sections) {
const picked = sectionsOnly(text, opts.sections);
if (picked === null) { result.empty.push(file); continue; }
result.scanned.push(file);
result.findings.push(...lintProse(picked, file));
continue;
}
if (opts.unreleased) {
const block = unreleasedBlock(text);
if (!block) { result.empty.push(file); continue; }
result.scanned.push(file);
result.findings.push(...lintProse(block.text, file, block.line - 1));
continue;
}
result.scanned.push(file);
result.findings.push(...lintProse(text, file));
}
return result;
}
export function renderProseLint(r: ProseLintResult): string {
const lines = r.findings.map(f => `PROSE: ${f.file}:${f.line} ${f.rule} "${f.match}" (${f.hint})`);
const state = r.findings.length ? `found=${r.findings.length}` : r.scanned.length ? 'ok' : 'none';
lines.push(`PROSE_LINT: ${state} files=${r.scanned.length}${r.empty.length ? ` nothing-to-scan=${r.empty.length}` : ''}`);
return lines.join('\n') + '\n';
}
+5
View File
@@ -244,6 +244,11 @@ export const RELEASE_RESULT_CODES = {
summary: 'gstack-ship-receipt carry could not carry the /ship receipt to HEAD: the PR body has no receipt, the receipt head is not an ancestor of HEAD (rebase or force-push), or the product diff could not be compared', summary: 'gstack-ship-receipt carry could not carry the /ship receipt to HEAD: the PR body has no receipt, the receipt head is not an ancestor of HEAD (rebase or force-push), or the product diff could not be compared',
fix: 'merge the base in instead of rebasing, keep the reviewed head in HEAD\'s history, then rerun carry; otherwise run /ship on the new head', fix: 'merge the base in instead of rebasing, keep the reviewed head in HEAD\'s history, then rerun carry; otherwise run /ship on the new head',
}, },
PROSE_LINT_FOUND: {
anchor: 'prose-lint-found',
summary: 'the prose /ship is about to publish (a CHANGELOG entry or a PR body) carries an AI tell a regular expression can decide',
fix: 'rewrite each PROSE: line in the draft the way its hint says, then rerun gstack-prose-lint on the same file',
},
RECEIPT_CARRY_PRODUCT_CHANGED: { RECEIPT_CARRY_PRODUCT_CHANGED: {
anchor: 'receipt-carry-product-changed', anchor: 'receipt-carry-product-changed',
summary: 'the product diff against the base changed between the receipt\'s head and HEAD (a product file differs beyond a base merge, a test-only fix or release metadata), so the reviewed verdict does not cover HEAD', summary: 'the product diff against the base changed between the receipt\'s head and HEAD (a product file differs beyond a base merge, a test-only fix or release metadata), so the reviewed verdict does not cover HEAD',
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "gstack", "name": "gstack",
"version": "1.91.272", "version": "1.91.273",
"description": "Garry's Stack — Claude Code skills + fast headless browser. One repo, one install, entire AI engineering workflow.", "description": "Garry's Stack — Claude Code skills + fast headless browser. One repo, one install, entire AI engineering workflow.",
"license": "MIT", "license": "MIT",
"type": "module", "type": "module",
+9 -6
View File
@@ -7,8 +7,8 @@ the READY report, through a Captain, so the body shows what changed in pictures,
approved what. Guide: `captain.md` beside the Capy `gstack` skill's SKILL.md. 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`, **Order.** The `gstack-ship-receipt` block stays first when Step 19 writes one. Then `## What changed`,
`## Why` and `## Approvals`, then the standard sections below. Keep `## Summary` to commit themes; `## Why`, `## Blast radius` and `## Approvals`, then the standard sections below. Keep `## Summary` to
do not repeat the captions. commit themes; do not repeat the captions.
1. **Pick the evidence:** one to three before/after pairs, highest-signal first. The first is the 1. **Pick the evidence:** one to three before/after pairs, highest-signal first. The first is the
key image. key image.
@@ -33,10 +33,13 @@ do not repeat the captions.
`image upload failed: <reason>` under the caption. Never block publication on an image. `image upload failed: <reason>` under the caption. Never block publication on an image.
4. **Under every image:** a one-line caption saying what changed and why, then the contents of the 4. **Under every image:** a one-line caption saying what changed and why, then the contents of the
printed `text.md` (a collapsed text before/after, for readers who cannot see the image and for search). printed `text.md` (a collapsed text before/after, for readers who cannot see the image and for search).
5. **`## Why`:** two or three sentences covering the cause (issue, failed run, or the person's request, 5. **`## Why`:** two or three sentences. The first states the problem in the person's words (the issue,
quoted) and the effect the user will see. the failed run, or their request, quoted); the rest say what changes for them. No inventory: BAD "Adds
6. **`## Approvals`:** the output of `~/.claude/skills/gstack/bin/gstack-approvals render`, verbatim. It 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 (the `/investigate` skill's Blast radius section, when present).
7. **`## Approvals`:** the output of `~/.claude/skills/gstack/bin/gstack-approvals render`, verbatim. It
escapes its cells, and the receipt never reads it as an approval citation. On escapes its cells, and the receipt never reads it as an approval citation. On
`APPROVAL_RECORD_UNAVAILABLE` keep its text and say so in the READY report. Never type rows by hand. `APPROVAL_RECORD_UNAVAILABLE` keep its text and say so in the READY report. Never type rows by hand.
7. **READY report:** attach the key image's PNG to the report you send (Capy's message 8. **READY report:** attach the key image's PNG to the report you send (Capy's message
attachments) and give its uploaded URL, so the Captain can show it inline. attachments) and give its uploaded URL, so the Captain can show it inline.
+9 -6
View File
@@ -5,8 +5,8 @@ the READY report, through a Captain, so the body shows what changed in pictures,
approved what. Guide: `captain.md` beside the Capy `gstack` skill's SKILL.md. 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`, **Order.** The `gstack-ship-receipt` block stays first when Step 19 writes one. Then `## What changed`,
`## Why` and `## Approvals`, then the standard sections below. Keep `## Summary` to commit themes; `## Why`, `## Blast radius` and `## Approvals`, then the standard sections below. Keep `## Summary` to
do not repeat the captions. commit themes; do not repeat the captions.
1. **Pick the evidence:** one to three before/after pairs, highest-signal first. The first is the 1. **Pick the evidence:** one to three before/after pairs, highest-signal first. The first is the
key image. key image.
@@ -31,10 +31,13 @@ do not repeat the captions.
`image upload failed: <reason>` under the caption. Never block publication on an image. `image upload failed: <reason>` under the caption. Never block publication on an image.
4. **Under every image:** a one-line caption saying what changed and why, then the contents of the 4. **Under every image:** a one-line caption saying what changed and why, then the contents of the
printed `text.md` (a collapsed text before/after, for readers who cannot see the image and for search). printed `text.md` (a collapsed text before/after, for readers who cannot see the image and for search).
5. **`## Why`:** two or three sentences covering the cause (issue, failed run, or the person's request, 5. **`## Why`:** two or three sentences. The first states the problem in the person's words (the issue,
quoted) and the effect the user will see. the failed run, or their request, quoted); the rest say what changes for them. No inventory: BAD "Adds
6. **`## Approvals`:** the output of `~/.claude/skills/gstack/bin/gstack-approvals render`, verbatim. It 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 (the `/investigate` skill's Blast radius section, when present).
7. **`## Approvals`:** the output of `~/.claude/skills/gstack/bin/gstack-approvals render`, verbatim. It
escapes its cells, and the receipt never reads it as an approval citation. On escapes its cells, and the receipt never reads it as an approval citation. On
`APPROVAL_RECORD_UNAVAILABLE` keep its text and say so in the READY report. Never type rows by hand. `APPROVAL_RECORD_UNAVAILABLE` keep its text and say so in the READY report. Never type rows by hand.
7. **READY report:** attach the key image's PNG to the report you send (Capy's message 8. **READY report:** attach the key image's PNG to the report you send (Capy's message
attachments) and give its uploaded URL, so the Captain can show it inline. attachments) and give its uploaded URL, so the Captain can show it inline.
+2
View File
@@ -42,6 +42,8 @@
**Do NOT ask the user to describe changes.** Infer from the diff and commit history. **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`.
**NO_VERSION (Step 12):** skip this step; write no CHANGELOG entry or version header. **NO_VERSION (Step 12):** skip this step; write no CHANGELOG entry or version header.
**No CHANGELOG:** when the policy's `changelog` is `null`, or the file it names **No CHANGELOG:** when the policy's `changelog` is `null`, or the file it names
+2
View File
@@ -1,5 +1,7 @@
{{CHANGELOG_WORKFLOW}} {{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`.
**NO_VERSION (Step 12):** skip this step; write no CHANGELOG entry or version header. **NO_VERSION (Step 12):** skip this step; write no CHANGELOG entry or version header.
**No CHANGELOG:** when the policy's `changelog` is `null`, or the file it names **No CHANGELOG:** when the policy's `changelog` is `null`, or the file it names
+15 -3
View File
@@ -20,7 +20,8 @@ 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 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. 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. 2. For a new PR/MR, write `v<NEW_VERSION> <type>: <summary>` into the file with your file-write tool;
`<summary>` names what the user gets, not the mechanism.
3. Every created or updated title MUST start with `v$NEW_VERSION `; check the file with 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. `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 4. **NO_VERSION:** replaces items 1-3: keep an existing title (the platform title
@@ -74,8 +75,18 @@ The PR/MR body should contain these sections (never reuse a prior run's body):
``` ```
## Summary ## Summary
<Read `git log origin/<base>..HEAD --oneline`. Group every substantive commit by <Open with the problem in the user's words (what they hit, saw or asked), then the fix
theme, excluding VERSION/CHANGELOG bookkeeping. Do not paste the commit list.> and what they get now; themes from `git log origin/<base>..HEAD --oneline` after that,
minus VERSION/CHANGELOG bookkeeping. Never an inventory or a commit list. BAD: "Adds
gstack-prose-lint, wires Steps 13 and 19, refreshes the goldens." GOOD: "Nobody reads a
CHANGELOG entry twice, so autopilot words shipped every release. /ship now lints the entry
before the commit and names each line to rewrite.">
## Blast radius
<One or two sentences: what else the change touches (callers, consumers, generated
files, hosts) and the one fact that makes that safe, with the rung of the evidence
ladder the fact reached (file:line read, walked failure path, script ran, repro). The
ladder is the `/investigate` skill's Blast radius section, when present.>
## Test Coverage ## Test Coverage
<coverage diagram from Step 7, or "All new code paths have test coverage."> <coverage diagram from Step 7, or "All new code paths have test coverage.">
@@ -197,6 +208,7 @@ PR_BODY_FILE=$(mktemp "${TMPDIR:-/tmp}/gstack-pr-body.XXXXXX") || { echo "ERROR:
BODY_TOP_FILE="$(git rev-parse --show-toplevel 2>/dev/null || pwd)/.gstack/tmp/<body-top-file-name>" BODY_TOP_FILE="$(git rev-parse --show-toplevel 2>/dev/null || pwd)/.gstack/tmp/<body-top-file-name>"
BODY_REST_FILE="$(git rev-parse --show-toplevel 2>/dev/null || pwd)/.gstack/tmp/<body-rest-file-name>" 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; } [ -s "$BODY_TOP_FILE" ] && [ -s "$BODY_REST_FILE" ] || { echo "Not composed: $BODY_TOP_FILE or $BODY_REST_FILE is empty, so the body was never written. Write both, then rerun." >&2; exit 1; }
~/.claude/skills/gstack/bin/gstack-prose-lint "$BODY_TOP_FILE" --sections 'Why,Summary,Blast radius' --gate >&2 || { echo "Not composed: fix what gstack-prose-lint printed above (a PROSE: line is a sentence to rewrite in the draft; a usage line means the draft lost a heading), then rerun." >&2; exit 1; }
{ awk 1 "$BODY_TOP_FILE" && cat -- "$DOCS_SECTION_FILE" && echo && awk 1 "$BODY_REST_FILE"; } > "$PR_BODY_FILE" || exit 1 { 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" rm -f "$BODY_TOP_FILE" "$BODY_REST_FILE"
echo "PR_BODY_FILE: $PR_BODY_FILE" echo "PR_BODY_FILE: $PR_BODY_FILE"
+15 -3
View File
@@ -11,7 +11,8 @@ 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 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. 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. 2. For a new PR/MR, write `v<NEW_VERSION> <type>: <summary>` into the file with your file-write tool;
`<summary>` names what the user gets, not the mechanism.
3. Every created or updated title MUST start with `v$NEW_VERSION `; check the file with 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. `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 4. **NO_VERSION:** replaces items 1-3: keep an existing title (the platform title
@@ -65,8 +66,18 @@ The PR/MR body should contain these sections (never reuse a prior run's body):
``` ```
## Summary ## Summary
<Read `git log origin/<base>..HEAD --oneline`. Group every substantive commit by <Open with the problem in the user's words (what they hit, saw or asked), then the fix
theme, excluding VERSION/CHANGELOG bookkeeping. Do not paste the commit list.> and what they get now; themes from `git log origin/<base>..HEAD --oneline` after that,
minus VERSION/CHANGELOG bookkeeping. Never an inventory or a commit list. BAD: "Adds
gstack-prose-lint, wires Steps 13 and 19, refreshes the goldens." GOOD: "Nobody reads a
CHANGELOG entry twice, so autopilot words shipped every release. /ship now lints the entry
before the commit and names each line to rewrite.">
## Blast radius
<One or two sentences: what else the change touches (callers, consumers, generated
files, hosts) and the one fact that makes that safe, with the rung of the evidence
ladder the fact reached (file:line read, walked failure path, script ran, repro). The
ladder is the `/investigate` skill's Blast radius section, when present.>
## Test Coverage ## Test Coverage
<coverage diagram from Step 7, or "All new code paths have test coverage."> <coverage diagram from Step 7, or "All new code paths have test coverage.">
@@ -180,6 +191,7 @@ PR_BODY_FILE=$(mktemp "${TMPDIR:-/tmp}/gstack-pr-body.XXXXXX") || { echo "ERROR:
BODY_TOP_FILE="$(git rev-parse --show-toplevel 2>/dev/null || pwd)/.gstack/tmp/<body-top-file-name>" BODY_TOP_FILE="$(git rev-parse --show-toplevel 2>/dev/null || pwd)/.gstack/tmp/<body-top-file-name>"
BODY_REST_FILE="$(git rev-parse --show-toplevel 2>/dev/null || pwd)/.gstack/tmp/<body-rest-file-name>" 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; } [ -s "$BODY_TOP_FILE" ] && [ -s "$BODY_REST_FILE" ] || { echo "Not composed: $BODY_TOP_FILE or $BODY_REST_FILE is empty, so the body was never written. Write both, then rerun." >&2; exit 1; }
~/.claude/skills/gstack/bin/gstack-prose-lint "$BODY_TOP_FILE" --sections 'Why,Summary,Blast radius' --gate >&2 || { echo "Not composed: fix what gstack-prose-lint printed above (a PROSE: line is a sentence to rewrite in the draft; a usage line means the draft lost a heading), then rerun." >&2; exit 1; }
{ awk 1 "$BODY_TOP_FILE" && cat -- "$DOCS_SECTION_FILE" && echo && awk 1 "$BODY_REST_FILE"; } > "$PR_BODY_FILE" || exit 1 { 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" rm -f "$BODY_TOP_FILE" "$BODY_REST_FILE"
echo "PR_BODY_FILE: $PR_BODY_FILE" echo "PR_BODY_FILE: $PR_BODY_FILE"
+15
View File
@@ -0,0 +1,15 @@
# Release notes
## [Unreleased]
**The CHANGELOG lint lands.** A /ship run wrote entries nobody read twice, so the autopilot words slipped through. The lint reads the entry before the commit and names each line to rewrite.
### Added
- **`bin/gstack-prose-lint`**: scans the files it is given and prints one line per finding. The gate refuses a stale receipt, exits with code 3, and stamps nothing.
- A measurement line keeps its arrow. The ratio moved 1.6740 → 1.7010 (measured 321,630 / 187,706), and the headline reads before → after.
- Fix: run `gstack-prose-lint CHANGELOG.md --unreleased` and read the trailer.
- The reviewer reads the diff. A caller that passes no file gets the usage text.
## [1.0.0] - 2026-10-01
Older text is never scanned under --unreleased, even when it showcases a vibrant tapestry.
+16
View File
@@ -0,0 +1,16 @@
# Release notes
## [Unreleased]
**Prose lint lands.** This release doesn't just add a linter, but a whole new paradigm for CHANGELOG hygiene, showcasing a vibrant tapestry of checks.
### Added
- **Performance:** Performance improved across the substrate because the parser now short-circuits.
- Gate refuses stale receipt → exit 3, no stamp.
- The lint is pivotal for teams coming from other tools: instead of reading every entry by hand, they run one command and additionally get a report.
- Measured 1.6740 → 1.7010 (this arrow is a measurement and stays).
- `gstack-prose-lint`: a code span that says `not just X, but Y` is never scanned.
## [1.0.0] - 2026-10-01
An older entry that showcases the vibrant tapestry of the past and is never scanned under --unreleased.
+1 -1
View File
@@ -168,7 +168,7 @@ export const CARVE_GUARDS: Record<string, CarveGuard> = {
// wave's headline capability) grows the union to 1.195x. Deliberate: // wave's headline capability) grows the union to 1.195x. Deliberate:
// the section is on-demand (loads only for Apple store targets), so // the section is on-demand (loads only for Apple store targets), so
// per-invocation cost for non-iOS ships is one manifest line. // per-invocation cost for non-iOS ships is one manifest line.
maxSizeRatio: 1.7400, // Shared advisory identity/dedup + critical-severity validation: 248,065 union bytes / 187,706 baseline = 1.3216 (2026-09-17). + test value bar in the lazy Step 7 section (value cards, weak paths, gate table, base control, machine checks; ~13.6KB): measured 1.396 (2026-09-29). + W1 guarded state-root resolution (`eval gstack-paths; : "${GSTACK_STATE_ROOT:?…}"`) in the Context Recovery preamble, the eureka log and each state-writing bash block; measured 1.401 (2026-09-30). + the shared QA review step's plan-check timing rule (plan checks and their revalidation run on --timeout-ms after smoke expiry); measured 1.4022 (2026-09-30). + the shared TEST_BOOTSTRAP owned-change undo rules (s03 H1-H3, M1) and the value-bar coverage wording (s03 M2/M3); measured 1.4044 (2026-10-02). + severe fix wave ship-review lane: G1 NO_VERSION dispatch, title and CHANGELOG arms (versionless repos ship without inventing a version), F1 parent-side plan binding before the audit dispatch (binding, docs/designs candidates, exact not-run line) and D3's data-not-instructions line; +2,126 union bytes, measured 1.4135 (2026-10-03). + INV-1 consumer lines (outside review `unverified`/`unavailable` is missing coverage in the readiness note and PR body); measured 1.4148 (2026-10-03). + v1.91.19.0 severe fix wave: verdict-form outside-review callers with sandbox preflight and first-use notice (INV-1/B1/Q2), P0/P1 gate and unverified wording, bound-plan discovery (F1), visible LEARNINGS failures (B5), carved external pointers; measured 267,752 / 187,706 = 1.4264 (2026-10-03). + v1.91.18.0 (#3023) merged into the v1.91.19.0 wave; measured 267,874 / 187,706 = 1.4271 (2026-10-04). + Oct 6 fix wave: quota_exhausted preflight branch (#3051) and quoted-heredoc issue template (#3046); measured 268,199 / 187,706 = 1.4288 (2026-10-06). + Step 10's inline no-PR check before the Greptile dispatch (#3020); measured 268,655 / 187,706 = 1.4313 (2026-10-06). + Oct 6 fix wave A3/CEO-12: free text reaches commands only through agent-written mktemp files (mktemp block, write rule, path rebuild and refusal at each posting, prompt and brief site); measured 275,422 / 187,706 = 1.4673 (2026-10-06). Combined Oct 6 wave head (B free-text files + D measure loop + A rework): measured 280,363 / 187,706 = 1.4936 (2026-10-06). + gate-once push rule (no second paid gate after a CI push); measured 280,573 / 187,706 = 1.4947 (2026-10-06). + the measurement bar's decision step (exit-code branches for MEETS, MEETS-qualified, needs-classify with the qualifying and fix-round classes, EXTEND once; the measure prose was tightened to offset most of it); measured 281,058 / 187,706 = 1.4973 (2026-10-06). + Oct 7 wave B3: the Codex probe runs as a command (select-model capture lines, check-sandbox/probe-model/run-with-timeout subcommands) instead of being sourced; measured 281,438 / 187,706 = 1.4994 (2026-10-07). + Oct 7 wave CEO-28/DX-11: review prompts ask for a severity per finding and design proposals use the proposal gate; measured 281,556 / 187,706 = 1.5000 (2026-10-07). + Oct 7 wave B4: /ship publishes through gstack-post (title prepared in a file in the pr-body section, compose block plus gstack-post exit-code branches replacing the two scans); measured 281,894 / 187,706 = 1.5018 (2026-10-07). + B2 early Greptile PR (lazy Step 6.5 section: detect, public-repo consent, push, provisional title file, open; Step 10's bounded wait; the stop-leaves-the-draft rule); measured 286,626 / 187,706 = 1.5270 (2026-10-07). B3, the classifier, B4 and B2 merged: measured 287,128 / 187,706 = 1.5297 (2026-10-07). + Oct 7 wave guard (CEO-20): conditional `run_in_background` request plus the background-run recovery at each foreground dispatch site; measured 281,370 / 187,706 = 1.4990 (2026-10-07). Merged with the guard area: measured 287,440 / 187,706 = 1.5313 (2026-10-07). + Oct 8 wave: the defensive-security framing and fixture summary mode reach the security specialist and the Red Team (stated once in the dispatch, referenced by the Red Team); measured 288,783 / 187,706 = 1.5385 (2026-10-08). + gate integrity (HONEST_WORK_GATE_INTEGRITY W3): the shared GATE_SCAN_BLOCK in the core checklist (2.5) and after Step 7's child, the four-option gate question and record fields in 9.4, tests_ran states in Step 5, Gate edits and per-lane counts in the PR body with draft-while-open; measured 300,143 / 187,706 = 1.5990 (2026-10-10). + Honest Work wave merged (W2/W3 gate integrity in review/ship, W4 kernel + DONE line in every tier-2+ skill, W5/W7 in spec/ship): re-measured on the integrated tree (2026-10-10). + Oct 10 multi-agent wave PR E2 (ship/sections/handoff.md: the no-PR automation handoff, with its Section-index row and STOP pointer in the skeleton), offset by tightening Steps 1–3, 12, 14–17 and 20–21 prose so the skeleton stays under 81,000 bytes (80,997); C-pregate landed 1,123 union bytes in tests.md in between; measured 308,619 / 187,706 = 1.6442 on the integra Line truncated maxSizeRatio: 1.7500, // Shared advisory identity/dedup + critical-severity validation: 248,065 union bytes / 187,706 baseline = 1.3216 (2026-09-17). + test value bar in the lazy Step 7 section (value cards, weak paths, gate table, base control, machine checks; ~13.6KB): measured 1.396 (2026-09-29). + W1 guarded state-root resolution (`eval gstack-paths; : "${GSTACK_STATE_ROOT:?…}"`) in the Context Recovery preamble, the eureka log and each state-writing bash block; measured 1.401 (2026-09-30). + the shared QA review step's plan-check timing rule (plan checks and their revalidation run on --timeout-ms after smoke expiry); measured 1.4022 (2026-09-30). + the shared TEST_BOOTSTRAP owned-change undo rules (s03 H1-H3, M1) and the value-bar coverage wording (s03 M2/M3); measured 1.4044 (2026-10-02). + severe fix wave ship-review lane: G1 NO_VERSION dispatch, title and CHANGELOG arms (versionless repos ship without inventing a version), F1 parent-side plan binding before the audit dispatch (binding, docs/designs candidates, exact not-run line) and D3's data-not-instructions line; +2,126 union bytes, measured 1.4135 (2026-10-03). + INV-1 consumer lines (outside review `unverified`/`unavailable` is missing coverage in the readiness note and PR body); measured 1.4148 (2026-10-03). + v1.91.19.0 severe fix wave: verdict-form outside-review callers with sandbox preflight and first-use notice (INV-1/B1/Q2), P0/P1 gate and unverified wording, bound-plan discovery (F1), visible LEARNINGS failures (B5), carved external pointers; measured 267,752 / 187,706 = 1.4264 (2026-10-03). + v1.91.18.0 (#3023) merged into the v1.91.19.0 wave; measured 267,874 / 187,706 = 1.4271 (2026-10-04). + Oct 6 fix wave: quota_exhausted preflight branch (#3051) and quoted-heredoc issue template (#3046); measured 268,199 / 187,706 = 1.4288 (2026-10-06). + Step 10's inline no-PR check before the Greptile dispatch (#3020); measured 268,655 / 187,706 = 1.4313 (2026-10-06). + Oct 6 fix wave A3/CEO-12: free text reaches commands only through agent-written mktemp files (mktemp block, write rule, path rebuild and refusal at each posting, prompt and brief site); measured 275,422 / 187,706 = 1.4673 (2026-10-06). Combined Oct 6 wave head (B free-text files + D measure loop + A rework): measured 280,363 / 187,706 = 1.4936 (2026-10-06). + gate-once push rule (no second paid gate after a CI push); measured 280,573 / 187,706 = 1.4947 (2026-10-06). + the measurement bar's decision step (exit-code branches for MEETS, MEETS-qualified, needs-classify with the qualifying and fix-round classes, EXTEND once; the measure prose was tightened to offset most of it); measured 281,058 / 187,706 = 1.4973 (2026-10-06). + Oct 7 wave B3: the Codex probe runs as a command (select-model capture lines, check-sandbox/probe-model/run-with-timeout subcommands) instead of being sourced; measured 281,438 / 187,706 = 1.4994 (2026-10-07). + Oct 7 wave CEO-28/DX-11: review prompts ask for a severity per finding and design proposals use the proposal gate; measured 281,556 / 187,706 = 1.5000 (2026-10-07). + Oct 7 wave B4: /ship publishes through gstack-post (title prepared in a file in the pr-body section, compose block plus gstack-post exit-code branches replacing the two scans); measured 281,894 / 187,706 = 1.5018 (2026-10-07). + B2 early Greptile PR (lazy Step 6.5 section: detect, public-repo consent, push, provisional title file, open; Step 10's bounded wait; the stop-leaves-the-draft rule); measured 286,626 / 187,706 = 1.5270 (2026-10-07). B3, the classifier, B4 and B2 merged: measured 287,128 / 187,706 = 1.5297 (2026-10-07). + Oct 7 wave guard (CEO-20): conditional `run_in_background` request plus the background-run recovery at each foreground dispatch site; measured 281,370 / 187,706 = 1.4990 (2026-10-07). Merged with the guard area: measured 287,440 / 187,706 = 1.5313 (2026-10-07). + Oct 8 wave: the defensive-security framing and fixture summary mode reach the security specialist and the Red Team (stated once in the dispatch, referenced by the Red Team); measured 288,783 / 187,706 = 1.5385 (2026-10-08). + gate integrity (HONEST_WORK_GATE_INTEGRITY W3): the shared GATE_SCAN_BLOCK in the core checklist (2.5) and after Step 7's child, the four-option gate question and record fields in 9.4, tests_ran states in Step 5, Gate edits and per-lane counts in the PR body with draft-while-open; measured 300,143 / 187,706 = 1.5990 (2026-10-10). + Honest Work wave merged (W2/W3 gate integrity in review/ship, W4 kernel + DONE line in every tier-2+ skill, W5/W7 in spec/ship): re-measured on the integrated tree (2026-10-10). + Oct 10 multi-agent wave PR E2 (ship/sections/handoff.md: the no-PR automation handoff, with its Section-index row and STOP pointer in the skeleton), offset by tightening Steps 1–3, 12, 14–17 and 20–21 prose so the skeleton stays under 81,000 bytes (80,997); C-pregate landed 1,123 union bytes in tests.md in between; measured 308,619 / 187,706 = 1.6442 on the integra Line truncated
}, },
'plan-ceo-review': { 'plan-ceo-review': {
skill: 'plan-ceo-review', skill: 'plan-ceo-review',
+1
View File
@@ -181,6 +181,7 @@ export const FREE_FIXTURES: Readonly<Record<string, readonly string[]>> = {
'test/fixtures/plans/autoplan-focus-appearance.md': [], 'test/fixtures/plans/autoplan-focus-appearance.md': [],
'test/fixtures/plans/ui-heavy-feature-design.md': [], 'test/fixtures/plans/ui-heavy-feature-design.md': [],
'test/fixtures/prosons-neutral-neg-captures.json': ['test/prosons-neutral-posture.test.ts'], 'test/fixtures/prosons-neutral-neg-captures.json': ['test/prosons-neutral-posture.test.ts'],
'test/fixtures/prose-lint/**': ['test/prose-lint.test.ts'],
'test/fixtures/pty-idle-turn-end.json': ['test/helpers/claude-pty-runner.runners.unit.test.ts', 'test/helpers/claude-pty-runner.screen.unit.test.ts'], 'test/fixtures/pty-idle-turn-end.json': ['test/helpers/claude-pty-runner.runners.unit.test.ts', 'test/helpers/claude-pty-runner.screen.unit.test.ts'],
'test/fixtures/redact-diagram/**': ['test/redact-context-exemptions.test.ts'], 'test/fixtures/redact-diagram/**': ['test/redact-context-exemptions.test.ts'],
'test/fixtures/remote-identity-vectors.json': ['test/bin-context-windows-slug.test.ts', 'test/gstack-slug-parity.test.ts', 'test/remote-identity-vectors.test.ts'], 'test/fixtures/remote-identity-vectors.json': ['test/bin-context-windows-slug.test.ts', 'test/gstack-slug-parity.test.ts', 'test/remote-identity-vectors.test.ts'],
+357
View File
@@ -0,0 +1,357 @@
/**
* gstack-prose-lint: the AI tells a regular expression can decide, over the
* CHANGELOG entry and PR body /ship writes (absorbed from pstack's unslop,
* rewritten; NOTICE.md). Positive and negative fixtures per rule, the two
* selections (--unreleased, --base), the exit codes, and the template and
* workflow wiring that calls 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 { addedOnly, lintProse, maskProse, renderProseLint, sectionsOnly, unreleasedBlock } from '../lib/prose-lint';
const ROOT = path.resolve(import.meta.dir, '..');
const FIXTURES = path.join(ROOT, 'test', 'fixtures', 'prose-lint');
const BIN = path.join(ROOT, 'bin', 'gstack-prose-lint');
const run = (args: string[], cwd = ROOT) => {
const r = spawnSync('bun', [BIN, ...args], { cwd, encoding: 'utf8', timeout: 60_000 });
return { code: r.status ?? -1, out: r.stdout ?? '', err: r.stderr ?? '' };
};
const rules = (text: string) => lintProse(text, 'x.md').map(f => f.rule);
describe('lintProse: each rule, positive and negative', () => {
test('vocabulary flags the autopilot words and leaves plain prose alone', () => {
expect(rules('This release showcases a vibrant, pivotal change that underscores our intricate work.')).toEqual(['vocabulary', 'vocabulary', 'vocabulary', 'vocabulary', 'vocabulary']);
expect(rules('The lint reads the entry and names each line to rewrite.')).toEqual([]);
expect(rules('Rotate the photo to landscape orientation; a leading underscore marks a private name.')).toEqual([]);
expect(rules('They mapped the vendor landscape before choosing.')).toEqual(['vocabulary']);
});
test('not-just-but catches the contrast in both spellings', () => {
expect(rules("It isn't just a linter, but a whole new way of writing.")).toEqual(['not-just-but']);
expect(rules('This is not only faster but also smaller.')).toEqual(['not-just-but']);
expect(rules('The lint is fast. It is also small.')).toEqual([]);
});
test('metaphor-noun names the plain word', () => {
const f = lintProse('The queue is the substrate every lane builds on, and its north star is a green train.', 'x.md');
expect(f.map(x => [x.rule, x.match, x.hint])).toEqual([
['metaphor-noun', 'substrate', 'say "base"'],
['metaphor-noun', 'north star', 'say "goal"'],
]);
expect(rules('A smaller API with two exported names is easier to keep stable.')).toEqual([]);
expect(rules('Shrink the API surface before the release.')).toEqual(['metaphor-noun']);
});
test("mid-sentence-colon flags a colon doing a conjunction's job and spares labels, lists and times", () => {
expect(rules('If you come from other tools: instead of reading every entry by hand, run one command.')).toEqual(['mid-sentence-colon']);
expect(rules('- The classifier is conservative: a local lane that cannot prove its base is refused.')).toEqual(['mid-sentence-colon']);
expect(rules('Fix: run the command again.')).toEqual([]);
expect(rules('The gate reads three inputs: the head, the base and the receipt.')).toEqual([]);
expect(rules('The run started at 12:30 and the following rules apply: no retries, no merges.')).toEqual([]);
expect(rules('- **`gstack-post`**: the receipt is written first.')).toEqual([]);
expect(lintProse(['The parser stops at the end of a line here', 'see note: something else happens now'].join('\n'), 'x.md')).toEqual([]);
});
test('inline-header-list flags a bold label that restates its line and spares a label that adds detail', () => {
expect(rules('- **Performance:** Performance improved by 40% on the hot path.')).toEqual(['inline-header-list']);
expect(rules('- **Performance.** The hot path is 40% faster because the parser short-circuits.')).toEqual([]);
expect(rules('- **`bin/gstack-prose-lint`**: `--gate` exits 1 on a finding.')).toEqual([]);
});
test('arrow flags symbol-speak and keeps measurements and the before → after heading', () => {
expect(rules('Bad input → exit 2, no write.')).toEqual(['arrow']);
expect(rules('policy -> warn')).toEqual(['arrow']);
expect(rules('The ratio moved 1.6740 → 1.7010 (measured 323,295 / 187,706) and the pins 250 -> 500.')).toEqual([]);
expect(rules('### Before → after')).toEqual([]);
expect(rules('Rename `old` → `new` in one pass.')).toEqual([]);
});
test('dropped-article flags a bare noun opening a sentence and spares one with its article', () => {
expect(rules('Gate refuses stale receipt and exits.')).toEqual(['dropped-article']);
expect(rules('- Gate refuses the head when the receipt is stale.')).toEqual(['dropped-article']);
expect(rules('The gate refuses a stale receipt and exits.')).toEqual([]);
expect(rules('Garry approves the plan; Bun runs the tests.')).toEqual([]);
expect(rules('Parser errors are rare.')).toEqual([]);
for (const s of ['Test runs now finish faster.', 'Error logs are rotated daily.', 'Tool calls take a while.', 'Merge commits stay.']) expect(rules(s), s).toEqual([]);
});
test('fenced code, inline code, URLs and HTML comments are never scanned, and line numbers hold', () => {
const text = [
'# Title',
'',
'```bash',
'echo "not just X, but Y" → substrate',
'```',
'See https://example.com/vibrant-tapestry and `a pivotal → change`. <!-- showcases -->',
'Gate refuses stale receipt.',
].join('\n');
const f = lintProse(text, 'x.md');
expect(f.map(x => [x.rule, x.line])).toEqual([['dropped-article', 7]]);
const { masked, unclosed } = maskProse(text);
expect(unclosed).toBeNull();
expect(masked.split('\n').length).toBe(text.split('\n').length);
expect(masked).not.toContain('substrate');
expect(masked).not.toContain('vibrant');
});
// Value: protects=someone else's words are never the author's finding (the Capy `## Why` quotes the person's problem verbatim), and a double-backtick code span holding a single backtick is one code span; fails_when=a quoted tell gates the body, or the inner backtick splits the span and leaks its text; why_new=new masking; seam=none; no_claim=a quote spanning lines is read
test('quoted words and multi-backtick code spans are masked', () => {
expect(rules('"Please enhance the landscape view." The fix names the view.')).toEqual([]);
expect(rules('“Please enhance the landscape view.” The fix names the view.')).toEqual([]);
expect(rules('He asked to enhance the view.')).toEqual(['vocabulary']);
expect(rules('Use `` `name` showcases the result `` as the span.')).toEqual([]);
expect(rules('Rename --foo -> --bar in the flags.')).toEqual(['arrow']);
});
// Value: protects=a fence or comment left open is a finding instead of a silent mask of everything after it, and a fence closes only on a bare fence line; fails_when=an unclosed block reads as clean, or a fence line with an info string closes the block early; why_new=the fence test above pinned the fail-open behaviour; seam=none; no_claim=an indented code block is not a fence
test('an unclosed fence or comment is a finding at its opening line, and an info string never closes a fence', () => {
expect(lintProse(['Plain.', '```', 'showcasing a vibrant release'].join('\n'), 'x.md').map(f => [f.rule, f.line])).toEqual([['unclosed-block', 2]]);
expect(lintProse(['Plain. <!-- oops', 'It is crucial and pivotal.'].join('\n'), 'x.md').map(f => [f.rule, f.line])).toEqual([['unclosed-block', 1]]);
expect(lintProse(['```', 'foo', '```js', 'crucial', '```', 'done, and vibrant.'].join('\n'), 'x.md').map(f => [f.rule, f.line])).toEqual([['vocabulary', 6]]);
});
// Value: protects=a longer fence is closed only by a fence at least as long, so a quoted template in a PR body draft is never linted; fails_when=a shorter inner fence or a tilde fence closes the block early, or an unclosed fence stops masking; why_new=the case above uses one three-backtick fence; seam=none; no_claim=other fence syntaxes (indented code blocks) are not masked
test('a longer fence holds a shorter inner fence, a tilde fence masks too, and an unclosed fence masks to the end', () => {
const text = [
'````md',
'```',
'Showcasing a vibrant release.',
'````',
'Gate refuses stale receipt.',
'~~~',
'Showcasing a vibrant release.',
'~~~',
'```',
'Showcasing a vibrant release.',
].join('\n');
expect(lintProse(text, 'x.md').map(f => [f.rule, f.line])).toEqual([['dropped-article', 5], ['unclosed-block', 9]]);
});
test('findings are sorted by line and the render ends in the trailer', () => {
const text = renderProseLint({ findings: lintProse('Showcasing a vibrant release.\n\nParser rejects it.', 'f.md'), scanned: ['f.md'], empty: [] });
expect(text.split('\n').filter(Boolean)).toEqual([
'PROSE: f.md:1 vocabulary "Showcasing" (use the plain word)',
'PROSE: f.md:1 vocabulary "vibrant" (use the plain word)',
'PROSE: f.md:3 dropped-article "Parser rejects" (start with "the" or "a")',
'PROSE_LINT: found=3 files=1',
]);
expect(renderProseLint({ findings: [], scanned: ['a'], empty: [] })).toBe('PROSE_LINT: ok files=1\n');
expect(renderProseLint({ findings: [], scanned: [], empty: ['a'] })).toBe('PROSE_LINT: none files=0 nothing-to-scan=1\n');
});
});
describe('selections', () => {
// Value: protects=the Step 19 compose gate reads only the sections an author writes, so a verbatim tool section (a review line such as "Problem → what you did") can never dead-end /ship; fails_when=a tool section's text is linted, an author section is skipped, heading case is significant, or a body with none of the headings reads as scanned; why_new=new selection; seam=none; no_claim=the compose block's heading list itself is pinned by the wiring test
test('sectionsOnly keeps only the named ## sections, line numbers intact, and is null when none is present', () => {
const body = [
'## Summary', 'The parser showcases a vibrant result.', '',
'## Pre-Landing Review', '[AUTO-FIXED] [x.ts:1] Problem → what you did', '',
'## blast radius', 'Touches the compose block only.', 'Caller -> callee is the only arrow here.',
'## Test plan', '- [x] bun test → 20 pass',
].join('\n');
const picked = sectionsOnly(body, ['Summary', 'Blast radius'])!;
expect(picked.split('\n')).toHaveLength(body.split('\n').length);
expect(lintProse(picked, 'b.md').map(f => [f.rule, f.line])).toEqual([['vocabulary', 2], ['vocabulary', 2], ['arrow', 9]]);
expect(sectionsOnly(body, ['Why'])).toBeNull();
const fenced = ['## Summary', 'Fine.', '', '## Pre-Landing Review', '```codex-review', '## Summary', 'The parser showcases a vibrant result.', '```', '', '## Blast radius', '```', '## Why', 'a fenced heading showcases nothing', '```', 'but this vibrant line is read.'].join('\n');
expect(lintProse(sectionsOnly(fenced, ['Summary', 'Blast radius'])!, 'b.md').map(f => [f.rule, f.line])).toEqual([['vocabulary', 15]]);
const gated = run([path.join(FIXTURES, 'slopped-changelog.md'), '--sections', '[1.0.0] - 2026-10-01', '--gate']);
expect(gated.code).toBe(1);
expect(gated.out).toMatch(/^PROSE: \S+slopped-changelog\.md:16 /m);
expect(gated.out).not.toMatch(/^PROSE: \S+slopped-changelog\.md:(5|8|9|10) /m);
expect(run([path.join(FIXTURES, 'clean-changelog.md'), '--sections', 'Nope']).out).toContain('PROSE_LINT: none');
const lost = run([path.join(FIXTURES, 'clean-changelog.md'), '--sections', 'Nope', '--gate']);
expect(lost.code).toBe(2);
expect(lost.err).toContain('nothing would be gated');
const commented = ['## Summary', 'Fine.', '<!--', '## Test plan', '-->', 'Still the summary, showcasing a vibrant tell.'].join('\n');
expect(lintProse(sectionsOnly(commented, ['Summary'])!, 'b.md').map(f => [f.rule, f.line])).toEqual([['vocabulary', 6], ['vocabulary', 6]]);
});
// Value: protects=an HTML comment that spans lines is never read, as the header promises; fails_when=the second line of a comment, or the text before its closing marker, produces a finding, or prose after the close is skipped; why_new=the mask test above covers one-line comments only; seam=none; no_claim=none beyond protects
test('a multi-line HTML comment is masked through its closing marker and prose after it is read', () => {
const text = ['Plain start. <!-- a vibrant', 'multi-line crucial comment', 'still pivotal --> The parser showcases it.', 'Showcasing a vibrant end.'].join('\n');
expect(lintProse(text, 'x.md').map(f => [f.rule, f.line])).toEqual([['vocabulary', 3], ['vocabulary', 4], ['vocabulary', 4]]);
const quoted = ['Use `<!--` to open a comment, or see https://example.com/<!--', 'The parser showcases a vibrant result.'].join('\n');
expect(lintProse(quoted, 'x.md').map(f => [f.rule, f.line])).toEqual([['vocabulary', 2], ['vocabulary', 2]]);
});
test('unreleasedBlock returns the block with its heading line, or null', () => {
const text = fs.readFileSync(path.join(FIXTURES, 'slopped-changelog.md'), 'utf8');
const block = unreleasedBlock(text)!;
expect(block.line).toBe(3);
expect(block.text).toStartWith('## [Unreleased]');
expect(block.text).not.toContain('[1.0.0]');
expect(unreleasedBlock('# Changelog\n\n## [1.0.0] - 2026-01-01\n- x\n')).toBeNull();
});
// Value: protects=the Step 13 call (--base <ref> --gate): exit 1 on a tell the branch added, none for an older one, exit 2 and no trailer on an unresolvable ref; fails_when=a bad ref or failed git diff reads as ok, --gate stops gating, or a ref with no merge base is not diffed directly; why_new=this test asserted only the none case and addedOnly's return values; seam=none; no_claim=origin/<base> resolution in a real clone (a temp repo with a local main stands in)
test('addedOnly keeps only the lines the working tree added, with their line numbers, and reads an untracked file whole', () => {
const repo = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-prose-lint-'));
const git = (...a: string[]) => {
const r = spawnSync('git', a, { cwd: repo, encoding: 'utf8', timeout: 30_000, env: { ...process.env, GIT_CONFIG_GLOBAL: path.join(repo, '.gitconfig'), GIT_CONFIG_NOSYSTEM: '1', GIT_AUTHOR_NAME: 't', GIT_AUTHOR_EMAIL: 't@x', GIT_COMMITTER_NAME: 't', GIT_COMMITTER_EMAIL: 't@x' } });
if (r.status !== 0) throw new Error(r.stderr);
return r.stdout.trim();
};
try {
git('init', '-q', '-b', 'main');
fs.writeFileSync(path.join(repo, 'CHANGELOG.md'), '# Changelog\n\n## [1.0.0] - 2026-01-01\n\nOld entry showcases a vibrant tapestry.\n');
git('add', '.'); git('commit', '-q', '-m', 'base');
git('checkout', '-q', '-b', 'feature');
fs.writeFileSync(path.join(repo, 'CHANGELOG.md'), '# Changelog\n\n## [1.0.1] - 2026-01-02\n\nGate refuses stale receipt → exit 3.\n\n## [1.0.0] - 2026-01-01\n\nOld entry showcases a vibrant tapestry.\n');
const cwd = process.cwd();
process.chdir(repo);
try {
const added = addedOnly('CHANGELOG.md', 'main')!;
expect([...added.added!]).toEqual([3, 4, 5, 6]);
expect(added.text.split('\n')[8]).toBe('Old entry showcases a vibrant tapestry.');
const gated = run(['CHANGELOG.md', '--base', 'main', '--gate'], repo);
expect(gated.code).toBe(1);
expect(gated.out).toMatch(/^PROSE: CHANGELOG\.md:5 arrow /m);
expect(gated.out).toMatch(/^PROSE: CHANGELOG\.md:5 dropped-article /m);
expect(gated.out).not.toMatch(/^PROSE: CHANGELOG\.md:9 /m);
expect(gated.err).toContain('PROSE_LINT_FOUND');
fs.writeFileSync(path.join(repo, 'CHANGELOG.md'), '# Changelog\n\n## [1.0.0] - 2026-01-01\n\n```\nold line\nit delves into a vibrant example\n```\nOld entry showcases a vibrant tapestry.\n');
git('add', '.'); git('commit', '-q', '-m', 'base with a fence');
git('checkout', '-q', 'main'); git('merge', '-q', '--ff-only', 'feature'); git('checkout', '-q', 'feature');
fs.writeFileSync(path.join(repo, 'CHANGELOG.md'), '# Changelog\n\n## [1.0.0] - 2026-01-01\n\n```\nold line\nit delves into a vibrant example\nadded line that showcases code\n```\nOld entry showcases a vibrant tapestry.\n');
const fenced = run(['CHANGELOG.md', '--base', 'main', '--gate'], repo);
expect(fenced.code).toBe(0);
expect(fenced.out).toBe('PROSE_LINT: ok files=1\n');
git('checkout', '-q', '--', 'CHANGELOG.md');
fs.writeFileSync(path.join(repo, 'CHANGELOG.md'), '# Changelog\n\n## [1.0.1] - 2026-01-02\n\nGate refuses stale receipt → exit 3.\n\n## [1.0.0] - 2026-01-01\n\n```\nold line\nit delves into a vibrant example\n```\nOld entry showcases a vibrant tapestry.\n');
const badRef = run(['CHANGELOG.md', '--base', 'no-such-ref', '--gate'], repo);
expect(badRef.code).toBe(2);
expect(badRef.out).not.toContain('PROSE_LINT:');
git('branch', 'orphan', git('commit-tree', git('write-tree'), '-m', 'unrelated history'));
expect(() => git('merge-base', 'orphan', 'HEAD')).toThrow();
expect([...addedOnly('CHANGELOG.md', 'orphan')!.added!]).toEqual([3, 4, 5, 6]);
fs.writeFileSync(path.join(repo, 'NEW.md'), 'A vibrant note.\n');
expect(addedOnly('NEW.md', 'main')).toEqual({ text: 'A vibrant note.\n', added: null });
git('checkout', '-q', '--', 'CHANGELOG.md');
expect(addedOnly('CHANGELOG.md', 'main')).toBeNull();
} finally { process.chdir(cwd); }
const r = run(['CHANGELOG.md', '--base', 'main'], repo);
expect(r.out.trim()).toBe('PROSE_LINT: none files=0 nothing-to-scan=1');
} finally { fs.rmSync(repo, { recursive: true, force: true }); }
});
});
describe('bin/gstack-prose-lint', () => {
// Value: protects=printed line numbers locate the finding in the file, and one gated call covers several files (the Step 19 body-draft shape); fails_when=the block offset is off by one, or a later file's findings are dropped or misattributed; why_new=the line check here was a 3..12 range and the multi-file call ran only as a report; seam=none; no_claim=none beyond protects
test('a slopped [Unreleased] block gates with exit 1, every rule named, and the result code on stderr', () => {
const r = run([path.join(FIXTURES, 'slopped-changelog.md'), '--unreleased', '--gate']);
expect(r.code).toBe(1);
const found = [...r.out.matchAll(/^PROSE: \S+:(\d+) (\S+) /gm)].map(m => [Number(m[1]), m[2]] as const);
for (const rule of ['vocabulary', 'not-just-but', 'metaphor-noun', 'mid-sentence-colon', 'inline-header-list', 'arrow', 'dropped-article']) expect(found.map(f => f[1])).toContain(rule);
expect(found.every(f => f[0] >= 3 && f[0] <= 12)).toBe(true);
for (const pair of [[5, 'not-just-but'], [8, 'inline-header-list'], [9, 'arrow'], [10, 'mid-sentence-colon']] as const) expect(found).toContainEqual(pair);
const both = run([path.join(FIXTURES, 'clean-changelog.md'), path.join(FIXTURES, 'slopped-changelog.md'), '--unreleased', '--gate']);
expect(both.code).toBe(1);
expect(both.out).toMatch(/^PROSE: \S+slopped-changelog\.md:5 /m);
expect(both.out).not.toMatch(/^PROSE: \S+clean-changelog\.md/m);
expect(both.out).toMatch(/\nPROSE_LINT: found=\d+ files=2\n$/);
expect(r.out).toMatch(/\nPROSE_LINT: found=\d+ files=1\n$/);
expect(r.err).toMatch(/^gstack-prose-lint: .+; fix: .+ \(PROSE_LINT_FOUND\)$/m);
});
test('the clean block is ok, and the same file read whole shows the older entry the selection skipped', () => {
const clean = path.join(FIXTURES, 'clean-changelog.md');
const gated = run([clean, '--unreleased', '--gate']);
expect(gated.code).toBe(0);
expect(gated.out.trim()).toBe('PROSE_LINT: ok files=1');
const whole = run([clean]);
expect(whole.code).toBe(0);
expect(whole.out).toMatch(/^PROSE: \S+clean-changelog\.md:15 vocabulary/m);
expect(whole.out).toMatch(/PROSE_LINT: found=\d+ files=1\n$/);
});
test('no [Unreleased] block is none, exit 0, even with --gate', () => {
const r = run([path.join(ROOT, 'NOTICE.md'), '--unreleased', '--gate']);
expect(r.code).toBe(0);
expect(r.out.trim()).toBe('PROSE_LINT: none files=0 nothing-to-scan=1');
});
// Value: protects=a --base with no ref, a directory and a call with no file exit 2 with their own message, never a clean run; fails_when=a missing ref falls through to a whole-file lint (exit 0) or no file reads as none; why_new=the rows above cover unknown flags, a missing file and --base with --unreleased only; seam=none; no_claim=none beyond protects
test('--json carries the findings; usage errors exit 2', () => {
const j = run([path.join(FIXTURES, 'slopped-changelog.md'), '--unreleased', '--json']);
expect(j.code).toBe(0);
const parsed = JSON.parse(j.out);
expect(parsed.findings.length).toBeGreaterThan(5);
expect(parsed.findings[0]).toMatchObject({ rule: expect.any(String), line: expect.any(Number), match: expect.any(String), hint: expect.any(String) });
expect(run([]).code).toBe(2);
expect(run(['--help']).code).toBe(0);
expect(run(['--help']).out).toContain('--unreleased');
expect(run([path.join(FIXTURES, 'missing.md')]).code).toBe(2);
expect(run([path.join(FIXTURES, 'clean-changelog.md'), '--nope']).code).toBe(2);
expect(run([path.join(FIXTURES, 'clean-changelog.md'), '--unreleased', '--base', 'main']).code).toBe(2);
expect(run([path.join(FIXTURES, 'clean-changelog.md'), '--sections', 'Added', '--base', 'main']).code).toBe(2);
expect(run([path.join(FIXTURES, 'clean-changelog.md'), '--sections', '--gate']).code).toBe(2);
const dir = run([FIXTURES]);
expect(dir.code).toBe(2);
expect(dir.err).toContain('cannot read');
expect(run(['--gate']).code).toBe(2);
const noRef = run([path.join(FIXTURES, 'slopped-changelog.md'), '--base']);
expect(noRef.code).toBe(2);
expect(noRef.err).toContain('--base needs a ref');
});
});
describe('wiring', () => {
const read = (p: string) => fs.readFileSync(path.join(ROOT, p), 'utf8');
test('/ship Step 13 lints the added CHANGELOG lines and Step 19 gates the body drafts before composing', () => {
const changelog = read('ship/sections/changelog.md.tmpl');
expect(changelog).toContain('gstack-prose-lint CHANGELOG.md --base origin/<base> --gate');
const prBody = read('ship/sections/pr-body.md.tmpl');
const compose = prBody.slice(prBody.indexOf(': "${DOCS_SECTION_FILE:?'), prBody.indexOf('echo "PR_BODY_FILE: $PR_BODY_FILE"'));
expect(compose).toContain(`gstack-prose-lint "$BODY_TOP_FILE" --sections 'Why,Summary,Blast radius' --gate`);
expect(compose).not.toContain('What changed,');
expect(compose).not.toContain('"$BODY_REST_FILE" --gate');
expect(compose.indexOf('gstack-prose-lint')).toBeGreaterThan(compose.indexOf('is empty, so the body was never written'));
expect(compose.indexOf('gstack-prose-lint')).toBeLessThan(compose.indexOf('{ awk 1 "$BODY_TOP_FILE"'));
for (const host of ['claude', 'codex', 'factory', 'agy']) expect(fs.existsSync(path.join(ROOT, 'test', 'fixtures', 'golden', host + '-ship-SKILL.md'))).toBe(true);
});
test('the PR body leads with the problem and carries a Blast radius item, on both the standard and the Capy path', () => {
const prBody = read('ship/sections/pr-body.md.tmpl');
const summary = prBody.slice(prBody.indexOf('## Summary'), prBody.indexOf('## Test Coverage'));
expect(summary).toContain("Open with the problem in the user's words");
expect(summary).toContain('Never an inventory or a commit list.');
expect(summary).toMatch(/BAD: ".+" GOOD: "/s);
expect(summary).toContain('## Blast radius');
expect(summary).toContain('the one fact that makes that safe');
expect(summary).toContain('file:line read, walked failure path, script ran, repro');
expect(summary).toContain("the `/investigate` skill's Blast radius section, when present");
expect(summary).not.toMatch(/skills\/gstack\/investigate\/sections\/blast-radius\.md/);
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).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");
expect(capy).toContain("the `/investigate` skill's Blast radius section, when present");
expect(capy).not.toMatch(/skills\/gstack\/investigate\/sections\/blast-radius\.md/);
});
test('the quality gate runs the lint over the [Unreleased] block, and NOTICE.md credits unslop', () => {
const wf = read('.github/workflows/quality-gate.yml');
expect(wf).toContain('bun --no-env-file --config=/dev/null bin/gstack-prose-lint CHANGELOG.md --unreleased --gate');
expect(read('NOTICE.md')).toContain('## pstack (unslop) — Copyright Lauren Tan — MIT License');
expect(read('docs/troubleshooting.md')).toContain('<a id="prose-lint-found"></a>');
});
// Value: protects=report mode (no --gate) exits 0 with a trailer even over a file full of findings, which is what lets the templates and docs keep their em dashes; fails_when=a finding exits nonzero without --gate, or a template run prints no trailer or writes to stderr; why_new=the earlier row accepted ok or found over the templates alone and so proved nothing about findings; seam=none; no_claim=the templates' own findings count is not pinned (it moves with every prose edit, and ok is as valid as found)
test('report mode never gates: the slopped fixture exits 0 without --gate, and the shipped templates get a trailer and no stderr', () => {
const slop = run([path.join(FIXTURES, 'slopped-changelog.md')]);
expect(slop.code).toBe(0);
expect(slop.out).toMatch(/PROSE_LINT: found=\d+ files=1\n$/);
expect(slop.err).toBe('');
const r = run(['ship/sections/changelog.md.tmpl', 'ship/sections/capy-pr.md.tmpl', 'ship/sections/pr-body.md.tmpl']);
expect(r.code).toBe(0);
expect(r.out).toMatch(/PROSE_LINT: (ok|found=\d+) files=3\n$/);
expect(r.err).toBe('');
});
});
+13 -2
View File
@@ -14,6 +14,16 @@ function writeDrafts(root: string, top = '## Summary\n\n## Documentation', rest
return block => block.replace('<body-top-file-name>', 'pr-body-top.abc123').replace('<body-rest-file-name>', 'pr-body-rest.abc123'); return block => block.replace('<body-top-file-name>', 'pr-body-top.abc123').replace('<body-rest-file-name>', 'pr-body-rest.abc123');
} }
const POST = resolve(import.meta.dir, '../bin/gstack-post'); const POST = resolve(import.meta.dir, '../bin/gstack-post');
const PROSE_LINT = resolve(import.meta.dir, '../bin/gstack-prose-lint');
/** 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) {
writeFileSync(join(bin, name), `#!/bin/sh\nexec ${JSON.stringify(process.execPath)} ${JSON.stringify(target)} "$@"\n`, { mode: 0o755 });
}
}
const fenceAfter = (marker: string) => { const fenceAfter = (marker: string) => {
const at = template.indexOf(marker); const at = template.indexOf(marker);
expect(at).toBeGreaterThan(-1); expect(at).toBeGreaterThan(-1);
@@ -26,7 +36,7 @@ const createBlock = fenceAfter('**No open PR/MR:**');
/** /**
* A repo whose origin is GitHub, a stub gh that records argv and stdin, and * A repo whose origin is GitHub, a stub gh that records argv and stdin, and
* the real gstack-post at the installed path the blocks call. * the real gstack-post and gstack-prose-lint at the installed path the blocks call.
*/ */
function publicationRepo(root: string) { function publicationRepo(root: string) {
const log = join(root, 'log'); const log = join(root, 'log');
@@ -38,7 +48,7 @@ function publicationRepo(root: string) {
for (const args of [['init', '-q'], ['remote', 'add', 'origin', 'https://github.com/o/r.git']]) { for (const args of [['init', '-q'], ['remote', 'add', 'origin', 'https://github.com/o/r.git']]) {
Bun.spawnSync(['git', ...args], { cwd: root, env: { ...process.env, ...gitEnv }, timeout: 10_000 }); Bun.spawnSync(['git', ...args], { cwd: root, env: { ...process.env, ...gitEnv }, timeout: 10_000 });
} }
writeFileSync(join(bin, 'gstack-post'), `#!/bin/sh\nexec ${JSON.stringify(process.execPath)} ${JSON.stringify(POST)} "$@"\n`, { mode: 0o755 }); installBins(root);
writeFileSync(join(stubs, 'gh'), `#!/usr/bin/env bash writeFileSync(join(stubs, 'gh'), `#!/usr/bin/env bash
case "$1 $2" in case "$1 $2" in
"repo view") echo PRIVATE; exit 0 ;; "repo view") echo PRIVATE; exit 0 ;;
@@ -133,6 +143,7 @@ test('publication composes the saved documentation section unchanged and refuses
const compose = block.slice(block.indexOf('PR_BODY_FILE=$(mktemp'), block.indexOf('echo "PR_BODY_FILE:')); const compose = block.slice(block.indexOf('PR_BODY_FILE=$(mktemp'), block.indexOf('echo "PR_BODY_FILE:'));
const root = mkdtempSync(join(tmpdir(), 'ship-compose-')); const root = mkdtempSync(join(tmpdir(), 'ship-compose-'));
try { try {
installBins(root);
const section = '**Status:** current — no edits.\n\n- Diagram drift: none (no diagrams in any doc).'; const section = '**Status:** current — no edits.\n\n- Diagram drift: none (no diagrams in any doc).';
writeFileSync(join(root, 'section.md'), section); writeFileSync(join(root, 'section.md'), section);
const run = (file: string) => Bun.spawnSync(['bash', '-c', `${writeDrafts(root)(compose)}cat -- "$PR_BODY_FILE"`], { const run = (file: string) => Bun.spawnSync(['bash', '-c', `${writeDrafts(root)(compose)}cat -- "$PR_BODY_FILE"`], {